diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs index edf2747..b6a3cf9 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; namespace TeleWave.Application.Broadcast.GetSchedule; @@ -36,6 +37,17 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) .Select(s => new { s.Id, s.Name }) .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + // Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки. + var assetIds = entries + .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Program) + .Select(e => e.MediaAssetId) + .Distinct() + .ToList(); + var assetNames = await dbContext.MediaAssets.AsNoTracking() + .Where(a => assetIds.Contains(a.Id)) + .Select(a => new { a.Id, a.OriginalFileName }) + .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); + var dtos = entries .Select(e => new ScheduleEntryDto( e.Id, @@ -45,7 +57,8 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) e.EndsAtUtc, e.ShowId, e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, - e.EpisodeIndex + e.EpisodeIndex, + assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null )) .ToList(); diff --git a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs index 62c1eb5..b6f58ad 100644 --- a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs +++ b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs @@ -10,5 +10,6 @@ public sealed record ScheduleEntryDto( DateTimeOffset EndsAtUtc, Guid? ShowId, string? ShowName, - int? EpisodeIndex + int? EpisodeIndex, + string? SeasonEpisode ); diff --git a/backend/src/TeleWave.Application/Library/EpisodeName.cs b/backend/src/TeleWave.Application/Library/EpisodeName.cs new file mode 100644 index 0000000..62efabd --- /dev/null +++ b/backend/src/TeleWave.Application/Library/EpisodeName.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace TeleWave.Application.Library; + +/// Разбор сезона/серии из имени файла (SxxEyy либо NxNN). Номера в модели не хранятся — +/// вытаскиваются из имён по мере надобности (сводка в списке шоу, метка в расписании админки). +public static class EpisodeName +{ + private static readonly Regex SxxEyy = new( + @"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})", + RegexOptions.Compiled + ); + + private static readonly Regex NxNN = new( + @"(?:^|[^\d])(\d{1,2})x(\d{1,3})", + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + + public static (int Season, int Episode)? Parse(string? name) + { + if (string.IsNullOrEmpty(name)) + return null; + + var match = SxxEyy.Match(name); + if (!match.Success) + match = NxNN.Match(name); + + return match.Success + ? (int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)) + : null; + } + + public static int? ParseSeason(string? name) => Parse(name)?.Season; + + /// Метка вида «S16E03», либо null если распознать не удалось. + public static string? ParseLabel(string? name) => + Parse(name) is { } p ? $"S{p.Season:D2}E{p.Episode:D2}" : null; +} diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index 4c933f7..4c4811d 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -12,9 +12,28 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { - return await dbContext.Shows.AsNoTracking() + var shows = await dbContext.Shows.AsNoTracking() + .Include(s => s.Episodes) .OrderBy(s => s.Name) - .Select(s => new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, s.CreatedAt)) .ToListAsync(cancellationToken); + + // Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся). + var assetIds = shows.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList(); + var names = await dbContext.MediaAssets.AsNoTracking() + .Where(a => assetIds.Contains(a.Id)) + .Select(a => new { a.Id, a.OriginalFileName }) + .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); + + return shows + .Select(s => + { + var seasons = s.Episodes + .Select(e => names.TryGetValue(e.MediaAssetId, out var n) ? EpisodeName.ParseSeason(n) : null) + .Where(season => season is not null) + .Distinct() + .Count(); + return new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, seasons, s.CreatedAt); + }) + .ToList(); } } diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 38faf2d..16b8fce 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -8,6 +8,7 @@ public sealed record ShowSummaryDto( string Name, ShowKind Kind, int EpisodeCount, + int SeasonCount, DateTimeOffset CreatedAt ); diff --git a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs index 66bc4f0..9838d52 100644 --- a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs @@ -45,7 +45,8 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext) e.EndsAtUtc, e.ShowId, e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, - e.EpisodeIndex + e.EpisodeIndex, + null // сезон/серию зрителю не показываем (и не светим имена файлов) )) .ToList(); diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index aed7ace..d0d5f06 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -614,8 +614,12 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) { ) : ( {e.showName ?? '—'} - {e.episodeIndex != null && ( - · {t('air.episode')} {e.episodeIndex + 1} + {e.seasonEpisode ? ( + · {e.seasonEpisode} + ) : ( + e.episodeIndex != null && ( + · {t('air.episode')} {e.episodeIndex + 1} + ) )} )} diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 1bffd9c..f0ac573 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -67,6 +67,7 @@ export function ShowsPanel() { {t('admin.shows.name')} {t('admin.shows.kind')} + {t('admin.shows.seasons')} {t('admin.shows.episodes')} {t('common.actions')} @@ -74,7 +75,7 @@ export function ShowsPanel() { {isLoading && ( - + {t('common.loading')} @@ -93,6 +94,7 @@ export function ShowsPanel() { {t(`admin.shows.kinds.${show.kind}`)} + {show.seasonCount} {show.episodeCount}