From 8119305c3679a53058b2e6bf1c876dc4d71142ba Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 30 Jul 2026 01:01:24 +0300 Subject: [PATCH] Enhance ScheduleEntryDto and related components to support franchise collection details 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. --- .../GetChannelScheduleQueryHandler.cs | 52 ++++++++++++++++++- .../Broadcast/ScheduleEntryDto.cs | 9 +++- .../channels/components/SchedulePreview.tsx | 16 +++++- .../src/features/admin/media/episode-parse.ts | 8 +++ .../src/features/admin/media/episode-regex.ts | 2 + frontend/src/shared/api/types.ts | 4 ++ frontend/src/shared/lib/locales/en.ts | 2 + frontend/src/shared/lib/locales/ru.ts | 2 + 8 files changed, 92 insertions(+), 3 deletions(-) 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: 'первое число', },