Enhance ScheduleEntryDto and related components to support franchise collection details
ci / build-backend (push) Successful in 3m19s
ci / build-frontend (push) Successful in 1m6s
ci / tests (push) Successful in 1m46s
ci / sonar (push) Successful in 4m50s

Updated ScheduleEntryDto to include properties for collection part, total parts, and collection name, allowing for better representation of franchise information. Modified GetChannelScheduleQueryHandler to populate these new fields based on collection data. Adjusted SchedulePreview component to prioritize collection part display over episode information. Enhanced localization strings to support new collection-related terms in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-30 01:01:24 +03:00
parent 6be373476c
commit 8119305c36
8 changed files with 92 additions and 3 deletions
@@ -82,6 +82,33 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(a => new { a.Id, a.OriginalFileName }) .Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
// Франшизы записей: у фильма серия всегда одна, и «Серия 1» в расписании — пустая метка.
// Осмысленно другое — какой частью коллекции он шёл.
var collectionIds = entries
.Where(e => e.CollectionId != null)
.Select(e => e.CollectionId!.Value)
.Distinct()
.ToList();
var collectionParts =
collectionIds.Count == 0
? []
: await (
from item in dbContext.CollectionItems.AsNoTracking()
join collection in dbContext.Collections.AsNoTracking()
on item.CollectionId equals collection.Id
where collectionIds.Contains(item.CollectionId)
select new
{
item.CollectionId,
item.ShowId,
item.Position,
collection.Name,
}
).ToListAsync(cancellationToken);
var partsByCollection = collectionParts
.GroupBy(p => p.CollectionId)
.ToDictionary(g => g.Key, g => g.OrderBy(p => p.Position).ToList());
var dtos = new List<ScheduleEntryDto>(entries.Count); var dtos = new List<ScheduleEntryDto>(entries.Count);
foreach (var e in entries) foreach (var e in entries)
{ {
@@ -93,6 +120,26 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
bumperText = BumperText(bumper.RenderedLinesJson); bumperText = BumperText(bumper.RenderedLinesJson);
} }
// Номер части ищем по позиции шоу в коллекции, а не по индексу записи: франшизу могли
// переупорядочить, и «часть 2» обязана означать вторую в текущем составе.
int? part = null;
int? parts = null;
string? collectionName = null;
if (
e.CollectionId is { } collectionId
&& e.ShowId is { } showId
&& partsByCollection.TryGetValue(collectionId, out var ordered)
)
{
var at = ordered.FindIndex(p => p.ShowId == showId);
if (at >= 0)
{
part = at + 1;
parts = ordered.Count;
collectionName = ordered[0].Name;
}
}
dtos.Add( dtos.Add(
new ScheduleEntryDto( new ScheduleEntryDto(
e.Id, e.Id,
@@ -107,7 +154,10 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
? EpisodeName.ParseLabel(name) ? EpisodeName.ParseLabel(name)
: null, : null,
bumperName, bumperName,
bumperText bumperText,
part,
parts,
collectionName
) )
); );
} }
@@ -14,5 +14,12 @@ public sealed record ScheduleEntryDto(
string? SeasonEpisode, string? SeasonEpisode,
// Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании. // Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании.
string? BumperName = null, string? BumperName = null,
string? BumperText = null string? BumperText = null,
/// <summary>
/// Какой частью франшизы шла запись и сколько их всего. У фильма серия всегда одна, и «Серия 1»
/// в расписании ничего не значит; «часть 3 из 3» — значит.
/// </summary>
int? CollectionPart = null,
int? CollectionParts = null,
string? CollectionName = null
); );
@@ -34,13 +34,27 @@ function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
) )
} }
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */ /** «· S02E05», «· часть 3 из 3» либо «· серия N» — что удалось распознать; иначе ничего. */
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) { function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation() const { t } = useTranslation()
if (entry.seasonEpisode) if (entry.seasonEpisode)
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span> return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
// Часть франшизы важнее номера серии и идёт раньше него: у фильма серия всегда одна, и «Серия 1»
// не говорит ничего, а «часть 3 из 3» сразу объясняет, где мы в трилогии.
if (entry.collectionPart != null && entry.collectionParts != null)
return (
<span className="text-muted-foreground" title={entry.collectionName ?? undefined}>
{' '}
·{' '}
{t('air.collectionPart', {
part: entry.collectionPart,
total: entry.collectionParts,
})}
</span>
)
if (entry.episodeIndex == null) return null if (entry.episodeIndex == null) return null
return ( return (
@@ -20,6 +20,14 @@ function parseBuiltin(name: string): ParsedEpisode {
const nx = /(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i.exec(name) const nx = /(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i.exec(name)
if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) } if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) }
// Производственный код: «2ACV01» (Футурама), «1ACX05» (Гриффины), «1ACG02» (Разочарование).
// Сезон слева, номер серии справа, между ними буквенный код студии.
//
// Регистр здесь принципиален и флага `i` нет: код всегда прописной, а со строчными буквами
// под шаблон попало бы «Part1of10» — «1» сезоном, «10» серией.
const production = /(?:^|[^\dA-Za-z])(\d{1,2})[A-Z]{2,4}(\d{2,3})(?:[^\d]|$)/.exec(name)
if (production) return { season: Number(production[1]), episode: Number(production[2]) }
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название». // Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название».
const lead = /^\s*(\d{1,3})[\s._)\]-]/.exec(name) const lead = /^\s*(\d{1,3})[\s._)\]-]/.exec(name)
return { season: null, episode: lead ? Number(lead[1]) : null } return { season: null, episode: lead ? Number(lead[1]) : null }
@@ -3,6 +3,8 @@ export const REGEX_PRESETS: { key: string; pattern: string }[] = [
{ key: 'seriesWord', pattern: String.raw`[Сс]ерия\s*(\d{1,3})` }, { key: 'seriesWord', pattern: String.raw`[Сс]ерия\s*(\d{1,3})` },
{ key: 'episodeWord', pattern: String.raw`[Ээ]пизод\s*(\d{1,3})` }, { key: 'episodeWord', pattern: String.raw`[Ээ]пизод\s*(\d{1,3})` },
{ key: 'seasonEpisode', pattern: String.raw`[Ss](\d{1,2})[Ee](\d{1,3})` }, { key: 'seasonEpisode', pattern: String.raw`[Ss](\d{1,2})[Ee](\d{1,3})` },
// Производственный код: «2ACV01». Две группы — из него читаются и сезон, и серия.
{ key: 'productionCode', pattern: String.raw`(\d{1,2})[A-Z]{1,4}(\d{2,3})` },
{ key: 'afterDash', pattern: String.raw`[-–—]\s*(\d{1,3})` }, { key: 'afterDash', pattern: String.raw`[-–—]\s*(\d{1,3})` },
{ key: 'firstNumber', pattern: String.raw`(?:^|\D)(\d{1,3})(?:\D|$)` }, { key: 'firstNumber', pattern: String.raw`(?:^|\D)(\d{1,3})(?:\D|$)` },
] ]
+4
View File
@@ -975,6 +975,10 @@ export type ScheduleEntryDto = {
/** Для заставок: имя подблока и его текст — для метки в расписании. */ /** Для заставок: имя подблока и его текст — для метки в расписании. */
bumperName: string | null bumperName: string | null
bumperText: string | null bumperText: string | null
/** Какой частью франшизы шла запись и сколько их всего; null — шло само по себе. */
collectionPart: number | null
collectionParts: number | null
collectionName: string | null
} }
// ── Публичный эфир ───────────────────────────────────────────────────────── // ── Публичный эфир ─────────────────────────────────────────────────────────
+2
View File
@@ -59,6 +59,7 @@ export const en = {
ad: 'Ad', ad: 'Ad',
bumper: 'Bumper', bumper: 'Bumper',
episode: 'Episode', episode: 'Episode',
collectionPart: 'part {{part}} of {{total}}',
volume: 'Volume', volume: 'Volume',
live: 'Live', live: 'Live',
noChannels: 'No channels available yet. Check back later.', noChannels: 'No channels available yet. Check back later.',
@@ -318,6 +319,7 @@ export const en = {
seriesWord: 'Серия N', seriesWord: 'Серия N',
episodeWord: 'Эпизод N', episodeWord: 'Эпизод N',
seasonEpisode: 'SxxEyy', seasonEpisode: 'SxxEyy',
productionCode: '2ACV01',
afterDash: 'after a dash', afterDash: 'after a dash',
firstNumber: 'first number', firstNumber: 'first number',
}, },
+2
View File
@@ -59,6 +59,7 @@ export const ru = {
ad: 'Реклама', ad: 'Реклама',
bumper: 'Заставка', bumper: 'Заставка',
episode: 'Серия', episode: 'Серия',
collectionPart: 'часть {{part}} из {{total}}',
live: 'В эфире', live: 'В эфире',
volume: 'Громкость', volume: 'Громкость',
noChannels: 'Пока нет доступных каналов. Загляните позже.', noChannels: 'Пока нет доступных каналов. Загляните позже.',
@@ -315,6 +316,7 @@ export const ru = {
seriesWord: 'Серия N', seriesWord: 'Серия N',
episodeWord: 'Эпизод N', episodeWord: 'Эпизод N',
seasonEpisode: 'SxxEyy', seasonEpisode: 'SxxEyy',
productionCode: '2ACV01',
afterDash: 'после тире', afterDash: 'после тире',
firstNumber: 'первое число', firstNumber: 'первое число',
}, },