diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs index f11fde2..ad83521 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs @@ -82,6 +82,33 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) .Select(a => new { a.Id, a.OriginalFileName }) .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(entries.Count); foreach (var e in entries) { @@ -93,6 +120,26 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) 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( new ScheduleEntryDto( e.Id, @@ -107,7 +154,10 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) ? EpisodeName.ParseLabel(name) : null, bumperName, - bumperText + bumperText, + part, + parts, + collectionName ) ); } diff --git a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs index 86a6218..0b5b062 100644 --- a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs +++ b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs @@ -14,5 +14,12 @@ public sealed record ScheduleEntryDto( string? SeasonEpisode, // Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании. string? BumperName = null, - string? BumperText = null + string? BumperText = null, + /// + /// Какой частью франшизы шла запись и сколько их всего. У фильма серия всегда одна, и «Серия 1» + /// в расписании ничего не значит; «часть 3 из 3» — значит. + /// + int? CollectionPart = null, + int? CollectionParts = null, + string? CollectionName = null ); diff --git a/frontend/src/features/admin/channels/components/SchedulePreview.tsx b/frontend/src/features/admin/channels/components/SchedulePreview.tsx index 70b324c..579b355 100644 --- a/frontend/src/features/admin/channels/components/SchedulePreview.tsx +++ b/frontend/src/features/admin/channels/components/SchedulePreview.tsx @@ -34,13 +34,27 @@ function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) { ) } -/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */ +/** «· S02E05», «· часть 3 из 3» либо «· серия N» — что удалось распознать; иначе ничего. */ function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) { const { t } = useTranslation() if (entry.seasonEpisode) return · {entry.seasonEpisode} + // Часть франшизы важнее номера серии и идёт раньше него: у фильма серия всегда одна, и «Серия 1» + // не говорит ничего, а «часть 3 из 3» сразу объясняет, где мы в трилогии. + if (entry.collectionPart != null && entry.collectionParts != null) + return ( + + {' '} + ·{' '} + {t('air.collectionPart', { + part: entry.collectionPart, + total: entry.collectionParts, + })} + + ) + if (entry.episodeIndex == null) return null return ( diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts index a5a7c51..c0c0a1e 100644 --- a/frontend/src/features/admin/media/episode-parse.ts +++ b/frontend/src/features/admin/media/episode-parse.ts @@ -20,6 +20,14 @@ function parseBuiltin(name: string): ParsedEpisode { 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]) } + // Производственный код: «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) Название». const lead = /^\s*(\d{1,3})[\s._)\]-]/.exec(name) return { season: null, episode: lead ? Number(lead[1]) : null } diff --git a/frontend/src/features/admin/media/episode-regex.ts b/frontend/src/features/admin/media/episode-regex.ts index a807e13..1dbe007 100644 --- a/frontend/src/features/admin/media/episode-regex.ts +++ b/frontend/src/features/admin/media/episode-regex.ts @@ -3,6 +3,8 @@ export const REGEX_PRESETS: { key: string; pattern: string }[] = [ { key: 'seriesWord', 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})` }, + // Производственный код: «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: 'firstNumber', pattern: String.raw`(?:^|\D)(\d{1,3})(?:\D|$)` }, ] diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 4adc0f4..a8cc30b 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -975,6 +975,10 @@ export type ScheduleEntryDto = { /** Для заставок: имя подблока и его текст — для метки в расписании. */ bumperName: string | null bumperText: string | null + /** Какой частью франшизы шла запись и сколько их всего; null — шло само по себе. */ + collectionPart: number | null + collectionParts: number | null + collectionName: string | null } // ── Публичный эфир ───────────────────────────────────────────────────────── diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 87df633..22078d8 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -59,6 +59,7 @@ export const en = { ad: 'Ad', bumper: 'Bumper', episode: 'Episode', + collectionPart: 'part {{part}} of {{total}}', volume: 'Volume', live: 'Live', noChannels: 'No channels available yet. Check back later.', @@ -318,6 +319,7 @@ export const en = { seriesWord: 'Серия N', episodeWord: 'Эпизод N', seasonEpisode: 'SxxEyy', + productionCode: '2ACV01', afterDash: 'after a dash', firstNumber: 'first number', }, diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 580a33f..7b042da 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -59,6 +59,7 @@ export const ru = { ad: 'Реклама', bumper: 'Заставка', episode: 'Серия', + collectionPart: 'часть {{part}} из {{total}}', live: 'В эфире', volume: 'Громкость', noChannels: 'Пока нет доступных каналов. Загляните позже.', @@ -315,6 +316,7 @@ export const ru = { seriesWord: 'Серия N', episodeWord: 'Эпизод N', seasonEpisode: 'SxxEyy', + productionCode: '2ACV01', afterDash: 'после тире', firstNumber: 'первое число', },