diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs index a253b97..d3b7671 100644 --- a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -6,6 +6,7 @@ using TeleWave.Application.Metadata; using TeleWave.Application.Metadata.ApplyShowMetadata; using TeleWave.Application.Metadata.ClearShowMetadata; using TeleWave.Application.Metadata.GetProviders; +using TeleWave.Application.Metadata.RefreshEpisodes; using TeleWave.Application.Metadata.SearchShows; using TeleWave.Application.Metadata.SetShowPoster; using TeleWave.Application.Metadata.UpdateShowMetadata; @@ -39,11 +40,15 @@ public static class MetadataEndpoints admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent); admin.MapPut("/shows/{showId:guid}/poster", UploadPoster).Produces(StatusCodes.Status204NoContent); + admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces(); - // Постеры отдаём публично (просто картинки, id не угадать) — чтобы работал . + // Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал . app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster) .WithTags("Metadata") .Produces(StatusCodes.Status200OK); + app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill) + .WithTags("Metadata") + .Produces(StatusCodes.Status200OK); return app; } @@ -126,6 +131,19 @@ public static class MetadataEndpoints return result.ToHttpResult(); } + private static async Task RefreshEpisodes( + Guid showId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RefreshShowEpisodesMetadataCommand(showId), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task ServePoster( Guid showId, IAppDbContext dbContext, @@ -137,14 +155,30 @@ public static class MetadataEndpoints .Where(s => s.Id == showId) .Select(s => s.PosterPath) .FirstOrDefaultAsync(cancellationToken); - if (string.IsNullOrEmpty(path)) - return Results.NotFound(); + return ServeImage(path, imageStore); + } - var abs = imageStore.ResolveAbsolutePath(path); - if (abs is null) - return Results.NotFound(); + private static async Task ServeStill( + Guid episodeId, + IAppDbContext dbContext, + IMetadataImageStore imageStore, + CancellationToken cancellationToken + ) + { + var path = await dbContext.Shows.AsNoTracking() + .SelectMany(s => s.Episodes) + .Where(e => e.Id == episodeId) + .Select(e => e.StillPath) + .FirstOrDefaultAsync(cancellationToken); + return ServeImage(path, imageStore); + } - return Results.File(abs, ContentTypeFor(Path.GetExtension(abs))); + private static IResult ServeImage(string? relativePath, IMetadataImageStore imageStore) + { + if (string.IsNullOrEmpty(relativePath)) + return Results.NotFound(); + var abs = imageStore.ResolveAbsolutePath(relativePath); + return abs is null ? Results.NotFound() : Results.File(abs, ContentTypeFor(Path.GetExtension(abs))); } private static string ContentTypeFor(string extension) => diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 8c690eb..0526d4f 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -24,6 +24,7 @@ public sealed class ScheduleGenerator( IRandomSource random, IBumperRenderer bumperRenderer, IBumperTemplateStorage bumperStorage, + IMetadataImageStore metadataImages, IOptions options, IOptions bumperOptions, IOptions streamingOptions, @@ -192,6 +193,13 @@ public sealed class ScheduleGenerator( var fromIds = pairs.Select(p => p.From).Distinct().ToList(); var toIds = pairs.Select(p => p.To).Distinct().ToList(); + // Постеры шоу-получателей — как фон заставки (если у канала нет своего фона). + var showIds = fromIds.Concat(toIds).Distinct().ToList(); + var posterByShow = await dbContext.Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id) && s.PosterPath != null) + .Select(s => new { s.Id, s.PosterPath }) + .ToDictionaryAsync(s => s.Id, s => s.PosterPath!, cancellationToken); + // Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить). var cached = await dbContext.BumperAssets.AsNoTracking() .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId)) @@ -215,7 +223,8 @@ public sealed class ScheduleGenerator( { var fromName = showNames.GetValueOrDefault(pair.From, "…"); var toName = showNames.GetValueOrDefault(pair.To, "…"); - var signature = ComputeSignature(fromName, toName, styleSignature); + var toPosterRel = posterByShow.GetValueOrDefault(pair.To); + var signature = ComputeSignature(fromName, toName, styleSignature, toPosterRel ?? "-"); var hit = cached.FirstOrDefault(c => c.FromShowId == pair.From @@ -238,6 +247,7 @@ public sealed class ScheduleGenerator( fromName, toName, signature, + toPosterRel, cancellationToken ); result[pair] = assetId; @@ -263,13 +273,17 @@ public sealed class ScheduleGenerator( string fromName, string toName, string signature, + string? toPosterRelative, CancellationToken cancellationToken ) { var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}"); + var posterAbs = toPosterRelative is null + ? null + : metadataImages.ResolveAbsolutePath(toPosterRelative); var render = await bumperRenderer.RenderAsync( asset.Id, - BuildSpec(channel, fromName, toName), + BuildSpec(channel, fromName, toName, posterAbs), cancellationToken ); @@ -291,7 +305,12 @@ public sealed class ScheduleGenerator( return asset.Id; } - private BumperRenderSpec BuildSpec(Channel channel, string fromName, string toName) => + private BumperRenderSpec BuildSpec( + Channel channel, + string fromName, + string toName, + string? posterAbsolutePath + ) => new( AlignedBumperDuration(channel), _bumper.Width, @@ -306,7 +325,8 @@ public sealed class ScheduleGenerator( channel.BumperNextLabel, toName, bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension), - bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension) + bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension), + posterAbsolutePath ); private string FontPath(BumperFont font) => @@ -340,9 +360,14 @@ public sealed class ScheduleGenerator( channel.BumperMusicExtension ?? "-" ); - private static string ComputeSignature(string fromName, string toName, string styleSignature) + private static string ComputeSignature( + string fromName, + string toName, + string styleSignature, + string poster + ) { - var raw = string.Join('', fromName, toName, styleSignature); + var raw = string.Join('', fromName, toName, styleSignature, poster); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); return Convert.ToHexString(hash); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs index ba40808..324d358 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs @@ -19,7 +19,9 @@ public sealed record BumperRenderSpec( string NextLabel, string NextTitle, string? BackgroundFile = null, - string? MusicFile = null + string? MusicFile = null, + /// Постер шоу как фон (используется, если нет загруженного фона канала; затемняется). + string? PosterFile = null ); /// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки. diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs index b596e2f..b817cda 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs @@ -23,6 +23,15 @@ public interface IMetadataImageStore void DeleteShowImages(Guid showId); + /// Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке. + Task DownloadEpisodeStillAsync( + Guid episodeId, + string url, + CancellationToken cancellationToken + ); + + void DeleteEpisodeImages(Guid episodeId); + /// Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня. string? ResolveAbsolutePath(string relativePath); } diff --git a/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs b/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs index 44f5c22..82ca820 100644 --- a/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs @@ -22,14 +22,17 @@ public sealed class AddEpisodeCommandHandler(IAppDbContext dbContext) if (!show.CanAddEpisode) return Result.Failure(ShowErrors.SingleAlreadyHasEpisode); - var assetExists = await dbContext.MediaAssets.AnyAsync( - a => a.Id == command.MediaAssetId, - cancellationToken - ); - if (!assetExists) + var fileName = await dbContext.MediaAssets + .Where(a => a.Id == command.MediaAssetId) + .Select(a => a.OriginalFileName) + .FirstOrDefaultAsync(cancellationToken); + if (fileName is null) return Result.Failure(ShowErrors.AssetNotFound); var episode = show.AddEpisode(command.MediaAssetId); + if (EpisodeName.Parse(fileName) is { } parsed) + episode.SetNumbers(parsed.Season, parsed.Episode); + return Result.Success(episode.Id); } } diff --git a/backend/src/TeleWave.Application/Library/EpisodeName.cs b/backend/src/TeleWave.Application/Library/EpisodeName.cs index 62efabd..0129e75 100644 --- a/backend/src/TeleWave.Application/Library/EpisodeName.cs +++ b/backend/src/TeleWave.Application/Library/EpisodeName.cs @@ -17,21 +17,35 @@ public static class EpisodeName RegexOptions.Compiled | RegexOptions.IgnoreCase ); + // Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым. + private static readonly Regex LeadingNumber = new( + @"^\s*(\d{1,3})[\s._)\]-]", + RegexOptions.Compiled + ); + 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); + if (match.Success) + return (Int(match, 1), Int(match, 2)); - return match.Success - ? (int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)) - : null; + match = NxNN.Match(name); + if (match.Success) + return (Int(match, 1), Int(match, 2)); + + match = LeadingNumber.Match(name); + if (match.Success) + return (1, Int(match, 1)); + + return null; } + private static int Int(Match match, int group) => + int.Parse(match.Groups[group].Value, CultureInfo.InvariantCulture); + public static int? ParseSeason(string? name) => Parse(name)?.Season; /// Метка вида «S16E03», либо null если распознать не удалось. diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index 663dcfc..aa87285 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -39,7 +39,13 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) e.Position, asset?.OriginalFileName, asset?.Status, - asset?.Duration?.TotalSeconds + asset?.Duration?.TotalSeconds, + e.Season, + e.Episode, + e.Title, + e.Overview, + e.StillPath is not null, + e.AirDate ); }) .ToList(); diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 71b8ba3..489ac55 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -20,7 +20,13 @@ public sealed record EpisodeDto( int Position, string? AssetName, MediaAssetStatus? AssetStatus, - double? DurationSeconds + double? DurationSeconds, + int? Season, + int? Episode, + string? Title, + string? Overview, + bool HasStill, + DateOnly? AirDate ); public sealed record ShowDto( diff --git a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs index 204a243..31d9bcb 100644 --- a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs +++ b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs @@ -18,4 +18,9 @@ public static class MetadataErrors "Metadata.InvalidPoster", "Недопустимый файл постера (формат или размер)." ); + + public static readonly Error NoLinkedSource = Error.Validation( + "Metadata.NoLinkedSource", + "У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb." + ); } diff --git a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommand.cs b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommand.cs new file mode 100644 index 0000000..0527668 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.RefreshEpisodes; + +/// Довыгрузить метаданные всех серий шоу из привязанного источника. Возвращает число обновлённых. +public sealed record RefreshShowEpisodesMetadataCommand(Guid ShowId) : ICommand>; diff --git a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs new file mode 100644 index 0000000..a8f2e97 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs @@ -0,0 +1,83 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; + +namespace TeleWave.Application.Metadata.RefreshEpisodes; + +public sealed class RefreshShowEpisodesMetadataCommandHandler( + IAppDbContext dbContext, + IMetadataProviderResolver resolver, + IMetadataImageStore imageStore +) : ICommandHandler> +{ + public async Task> Handle( + RefreshShowEpisodesMetadataCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows + .Include(s => s.Episodes) + .FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + if (string.IsNullOrEmpty(show.MetadataExternalId)) + return Result.Failure(MetadataErrors.NoLinkedSource); + + var provider = show.MetadataProvider is { } key ? resolver.Resolve(key) : null; + if (provider is null) + return Result.Failure(MetadataErrors.ProviderNotAvailable); + + // Имена файлов — чтобы распознать номера у серий, где они ещё не проставлены. + var assetIds = show.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); + + var updated = 0; + foreach (var episode in show.Episodes) + { + var season = episode.Season; + var number = episode.Episode; + if (season is null || number is null) + { + if ( + names.TryGetValue(episode.MediaAssetId, out var name) + && EpisodeName.Parse(name) is { } parsed + ) + { + season = parsed.Season; + number = parsed.Episode; + episode.SetNumbers(season, number); + } + } + if (season is null || number is null) + continue; + + var meta = await provider.GetEpisodeAsync( + show.MetadataExternalId, + season.Value, + number.Value, + cancellationToken + ); + if (meta is null) + continue; + + string? stillPath = null; + if (!string.IsNullOrEmpty(meta.StillUrl)) + stillPath = await imageStore.DownloadEpisodeStillAsync( + episode.Id, + meta.StillUrl, + cancellationToken + ); + + episode.ApplyMetadata(meta.Title, meta.Overview, stillPath, meta.AirDate); + updated++; + } + + return Result.Success(updated); + } +} diff --git a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQuery.cs b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQuery.cs index 895a1f8..6703730 100644 --- a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQuery.cs +++ b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQuery.cs @@ -1,8 +1,7 @@ using LiteCqrs; -using TeleWave.Application.Broadcast; using TeleWave.Application.Common.Models; namespace TeleWave.Application.Streaming.GetPublicEpg; public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc) - : IQuery>>; + : IQuery>>; diff --git a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs index 9838d52..ed3c7f7 100644 --- a/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/GetPublicEpg/GetPublicEpgQueryHandler.cs @@ -7,9 +7,9 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Streaming.GetPublicEpg; public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext) - : IQueryHandler>> + : IQueryHandler>> { - public async Task>> Handle( + public async Task>> Handle( GetPublicEpgQuery query, CancellationToken cancellationToken ) @@ -19,7 +19,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext) .Select(c => (Guid?)c.Id) .FirstOrDefaultAsync(cancellationToken); if (channelId is null) - return Result.Failure>(ChannelErrors.NotFound); + return Result.Failure>(ChannelErrors.NotFound); var entries = await dbContext.ScheduleEntries.AsNoTracking() .Where(e => @@ -28,28 +28,65 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext) && e.EndsAtUtc > query.FromUtc ) .OrderBy(e => e.StartsAtUtc) - .ToListAsync(cancellationToken); - - var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList(); - var showNames = await dbContext.Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id)) - .Select(s => new { s.Id, s.Name }) - .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); - - var dtos = entries - .Select(e => new ScheduleEntryDto( - e.Id, + .Select(e => new + { e.Kind, - e.MediaAssetId, e.StartsAtUtc, e.EndsAtUtc, e.ShowId, - e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, - e.EpisodeIndex, - null // сезон/серию зрителю не показываем (и не светим имена файлов) - )) + e.MediaAssetId, + }) + .ToListAsync(cancellationToken); + + var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList(); + var shows = await dbContext.Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Name, s.PosterPath }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + // Метаданные серий: ключ — (шоу, ассет). + var assetIds = entries.Select(e => e.MediaAssetId).Distinct().ToList(); + var episodes = await dbContext.Shows.AsNoTracking() + .SelectMany(s => s.Episodes) + .Where(e => showIds.Contains(e.ShowId) && assetIds.Contains(e.MediaAssetId)) + .Select(e => new + { + e.ShowId, + e.MediaAssetId, + e.Id, + e.Title, + e.Overview, + e.StillPath, + }) + .ToListAsync(cancellationToken); + var episodeByKey = episodes + .GroupBy(e => (e.ShowId, e.MediaAssetId)) + .ToDictionary(g => g.Key, g => g.First()); + + var dtos = entries + .Select(e => + { + var show = e.ShowId is { } sid ? shows.GetValueOrDefault(sid) : null; + var episode = + e.ShowId is { } showId + && episodeByKey.TryGetValue((showId, e.MediaAssetId), out var ep) + ? ep + : null; + return new PublicEpgEntryDto( + e.Kind, + e.StartsAtUtc, + e.EndsAtUtc, + e.ShowId, + show?.Name, + show?.PosterPath is not null, + episode?.Id, + episode?.Title, + episode?.Overview, + episode?.StillPath is not null + ); + }) .ToList(); - return Result.Success>(dtos); + return Result.Success>(dtos); } } diff --git a/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs index a62614d..21a2a12 100644 --- a/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs @@ -1,6 +1,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Streaming.ListPublicChannels; @@ -12,10 +13,52 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { - return await dbContext.Channels.AsNoTracking() + var channels = await dbContext.Channels.AsNoTracking() .Where(c => c.IsEnabled) .OrderBy(c => c.Name) - .Select(c => new PublicChannelDto(c.Id, c.Slug, c.Name)) + .Select(c => new { c.Id, c.Slug, c.Name }) .ToListAsync(cancellationToken); + if (channels.Count == 0) + return []; + + var now = DateTimeOffset.UtcNow; + var channelIds = channels.Select(c => c.Id).ToList(); + + // Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки. + var currentByChannel = await dbContext.ScheduleEntries.AsNoTracking() + .Where(e => + channelIds.Contains(e.ChannelId) + && e.Kind == ScheduleEntryKind.Program + && e.StartsAtUtc <= now + && e.EndsAtUtc > now + && e.ShowId != null + ) + .Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value }) + .ToListAsync(cancellationToken); + var currentShowByChannel = currentByChannel + .GroupBy(x => x.ChannelId) + .ToDictionary(g => g.Key, g => g.First().ShowId); + + var showIds = currentShowByChannel.Values.Distinct().ToList(); + var shows = await dbContext.Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Name, s.PosterPath }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + return channels + .Select(c => + { + var showId = currentShowByChannel.GetValueOrDefault(c.Id); + var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null; + return new PublicChannelDto( + c.Id, + c.Slug, + c.Name, + show is null ? null : showId, + show?.Name, + show?.PosterPath is not null + ); + }) + .ToList(); } } diff --git a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs index f637e20..9e2a08e 100644 --- a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs +++ b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs @@ -1,6 +1,29 @@ +using TeleWave.Domain.Broadcast; + namespace TeleWave.Application.Streaming; -public sealed record PublicChannelDto(Guid Id, string Slug, string Name); +public sealed record PublicChannelDto( + Guid Id, + string Slug, + string Name, + Guid? CurrentShowId, + string? CurrentShowName, + bool CurrentShowHasPoster +); + +/// Запись публичного телегида с метаданными (без имён файлов и номеров серий). +public sealed record PublicEpgEntryDto( + ScheduleEntryKind Kind, + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + Guid? ShowId, + string? ShowName, + bool ShowHasPoster, + Guid? EpisodeId, + string? EpisodeTitle, + string? EpisodeOverview, + bool EpisodeHasStill +); public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity); diff --git a/backend/src/TeleWave.Domain/Library/ShowEpisode.cs b/backend/src/TeleWave.Domain/Library/ShowEpisode.cs index c262980..27a233e 100644 --- a/backend/src/TeleWave.Domain/Library/ShowEpisode.cs +++ b/backend/src/TeleWave.Domain/Library/ShowEpisode.cs @@ -1,6 +1,6 @@ namespace TeleWave.Domain.Library; -/// Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа. +/// Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа + метаданные. public class ShowEpisode { public Guid Id { get; private set; } @@ -10,6 +10,18 @@ public class ShowEpisode /// Порядковый номер внутри шоу (может иметь разрывы после удалений). public int Position { get; private set; } + /// Распознанные сезон/серия (из имени файла) — база для довыгрузки метаданных. + public int? Season { get; private set; } + public int? Episode { get; private set; } + + // ── Метаданные серии ── + public string? Title { get; private set; } + public string? Overview { get; private set; } + + /// Относительный путь локального кадра или null. + public string? StillPath { get; private set; } + public DateOnly? AirDate { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } private ShowEpisode() { } @@ -23,4 +35,29 @@ public class ShowEpisode Position = position, CreatedAt = DateTimeOffset.UtcNow, }; + + /// Проставить распознанные номера сезона/серии. + public void SetNumbers(int? season, int? episode) + { + Season = season; + Episode = episode; + } + + /// Применить метаданные серии (кадр — уже скачанный локально — может быть null). + public void ApplyMetadata(string? title, string? overview, string? stillPath, DateOnly? airDate) + { + Title = title; + Overview = overview; + if (stillPath is not null) + StillPath = stillPath; + AirDate = airDate; + } + + public void ClearMetadata() + { + Title = null; + Overview = null; + StillPath = null; + AirDate = null; + } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index 133fbc9..66fd196 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -104,7 +104,8 @@ public sealed class FfmpegBumperRenderer( var font = EscapePath(spec.FontFile); var outStart = Math.Max(0, target - 1); - // Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп) либо анимированный градиент. + // Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп), постер шоу (затемнённый) либо + // анимированный градиент. var inputs = new List(); string videoPrefix; if (!string.IsNullOrEmpty(spec.BackgroundFile)) @@ -117,6 +118,15 @@ public sealed class FfmpegBumperRenderer( videoPrefix = $"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p"; } + else if (!string.IsNullOrEmpty(spec.PosterFile)) + { + // Постер (портрет) растягиваем на кадр, размываем и затемняем, чтобы текст читался. + inputs.AddRange(["-loop", "1", "-i", spec.PosterFile]); + videoPrefix = + $"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}" + + ",boxblur=6:1,eq=brightness=-0.28:saturation=0.9" + + ",fps=30,format=yuv420p"; + } else { var gradient = diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index 51d8428..23d3c10 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -55,6 +55,17 @@ public sealed class MediaPathResolver public string MetadataShowPosterRelative(Guid showId, string extension) => $"metadata/shows/{showId:N}/poster{extension}"; + public string MetadataEpisodeDir(Guid episodeId) => + EnsureWithinRoot(Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"))); + + public string MetadataEpisodeStillPath(Guid episodeId, string extension) => + EnsureWithinRoot( + Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"), "still" + extension) + ); + + public string MetadataEpisodeStillRelative(Guid episodeId, string extension) => + $"metadata/episodes/{episodeId:N}/still{extension}"; + /// Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня. public string? ResolveRelative(string relativePath) { diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs index 62afae2..e846c08 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs @@ -44,6 +44,43 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto Directory.Delete(dir, recursive: true); } + public async Task DownloadEpisodeStillAsync( + Guid episodeId, + string url, + CancellationToken cancellationToken + ) + { + try + { + var client = httpFactory.CreateClient("metadata"); + using var response = await client.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + + var ext = ExtensionFor(url, response.Content.Headers.ContentType?.MediaType); + var dir = paths.MetadataEpisodeDir(episodeId); + Directory.CreateDirectory(dir); + RemoveExisting(dir, "still"); + + var abs = paths.MetadataEpisodeStillPath(episodeId, ext); + await using var content = await response.Content.ReadAsStreamAsync(cancellationToken); + await using (var fs = File.Create(abs)) + await content.CopyToAsync(fs, cancellationToken); + return paths.MetadataEpisodeStillRelative(episodeId, ext); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) + { + return null; + } + } + + public void DeleteEpisodeImages(Guid episodeId) + { + var dir = paths.MetadataEpisodeDir(episodeId); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + public string? ResolveAbsolutePath(string relativePath) { var abs = paths.ResolveRelative(relativePath); diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725061302_EpisodeMetadata.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725061302_EpisodeMetadata.Designer.cs new file mode 100644 index 0000000..b3b268a --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725061302_EpisodeMetadata.Designer.cs @@ -0,0 +1,848 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725061302_EpisodeMetadata")] + partial class EpisodeMetadata + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperAccentColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundColor2") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundExtension") + .HasColumnType("text"); + + b.Property("BumperDurationSeconds") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperMode") + .HasColumnType("integer"); + + b.Property("BumperMusicExtension") + .HasColumnType("text"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperRevision") + .HasColumnType("integer"); + + b.Property("BumperTextColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextJingleIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelJingle"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Jingles") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("Jingles"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725061302_EpisodeMetadata.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725061302_EpisodeMetadata.cs new file mode 100644 index 0000000..b53a7b2 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725061302_EpisodeMetadata.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class EpisodeMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AirDate", + table: "ShowEpisode", + type: "date", + nullable: true); + + migrationBuilder.AddColumn( + name: "Episode", + table: "ShowEpisode", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Overview", + table: "ShowEpisode", + type: "character varying(4096)", + maxLength: 4096, + nullable: true); + + migrationBuilder.AddColumn( + name: "Season", + table: "ShowEpisode", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "StillPath", + table: "ShowEpisode", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "ShowEpisode", + type: "character varying(512)", + maxLength: 512, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AirDate", + table: "ShowEpisode"); + + migrationBuilder.DropColumn( + name: "Episode", + table: "ShowEpisode"); + + migrationBuilder.DropColumn( + name: "Overview", + table: "ShowEpisode"); + + migrationBuilder.DropColumn( + name: "Season", + table: "ShowEpisode"); + + migrationBuilder.DropColumn( + name: "StillPath", + table: "ShowEpisode"); + + migrationBuilder.DropColumn( + name: "Title", + table: "ShowEpisode"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 35334e8..3422d93 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -488,18 +488,39 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("AirDate") + .HasColumnType("date"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Episode") + .HasColumnType("integer"); + b.Property("MediaAssetId") .HasColumnType("uuid"); + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + b.Property("Position") .HasColumnType("integer"); + b.Property("Season") + .HasColumnType("integer"); + b.Property("ShowId") .HasColumnType("uuid"); + b.Property("StillPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + b.HasKey("Id"); b.HasIndex("MediaAssetId"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs index 551da4b..3ba148b 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -30,5 +30,8 @@ public class ShowEpisodeConfiguration : IEntityTypeConfiguration { builder.HasIndex(x => new { x.ShowId, x.Position }); builder.HasIndex(x => x.MediaAssetId); + builder.Property(x => x.Title).HasMaxLength(512); + builder.Property(x => x.Overview).HasMaxLength(4096); + builder.Property(x => x.StillPath).HasMaxLength(256); } } diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index f1b8380..f7e89a1 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -18,7 +18,7 @@ import { } from '@/features/admin/media/episode-parse' import { formatDuration } from '@/features/admin/media/MediaPanel' import { ShowMetadataCard } from './ShowMetadataCard' -import { addEpisode, getShow, removeEpisode } from './api' +import { addEpisode, episodeStillUrl, getShow, removeEpisode } from './api' type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode } @@ -209,14 +209,32 @@ export function ShowDetail({ showId }: { showId: string }) { {show.episodes.map((episode, index) => { - const label = formatSeasonEpisode(parseEpisodeName(episode.assetName ?? '')) + const parsed = + episode.season != null && episode.episode != null + ? { season: episode.season, episode: episode.episode } + : parseEpisodeName(episode.assetName ?? '') + const label = formatSeasonEpisode(parsed) return ( {index + 1}
+ {episode.hasStill && ( + + )} {label && {label}} - {episode.assetName ?? '—'} +
+
{episode.title ?? episode.assetName ?? '—'}
+ {episode.title && ( +
+ {episode.assetName} +
+ )} +
diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index ff34074..a9a8de5 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -13,6 +13,7 @@ import { applyMetadata, clearMetadata, getMetadataProviders, + refreshEpisodesMetadata, searchMetadata, showPosterUrl, updateMetadata, @@ -83,6 +84,17 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged onSuccess: changed, onError, }) + const refreshEpisodes = useMutation({ + mutationFn: () => refreshEpisodesMetadata(show.id), + onSuccess: (count) => { + toast.success(t('admin.metadata.refreshedCount', { count })) + onChanged() + }, + onError, + }) + + const linked = + !!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual' return ( @@ -209,10 +221,22 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged /> -
+
+ {linked && ( + + )} {(show.metadataProvider || show.hasPoster) && ( ))} @@ -122,14 +137,37 @@ export function AirPage() {
{current && ( -
-
- {t('air.now')} - {current.showName} +
+ {currentEntry?.episodeHasStill && currentEntry.episodeId ? ( + + ) : currentEntry?.showHasPoster && current.showId ? ( + + ) : null} +
+
+ {t('air.now')} + {current.showName} +
+ {currentEntry?.episodeTitle && ( + {currentEntry.episodeTitle} + )} + + {formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)} + + {currentEntry?.episodeOverview && ( +

+ {currentEntry.episodeOverview} +

+ )}
- - {formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)} -
)} @@ -166,10 +204,14 @@ type GuideBlock = { } /** - * Строит телегид: рекламу не показываем, а подряд идущие серии одного шоу склеиваем в один блок - * с диапазоном «с – по». Реклама между сериями одного шоу поглощается блоком (как в обычном EPG). + * Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один + * блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»). */ -function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcoming: GuideBlock[] } { +function buildGuide(entries: PublicEpgEntryDto[]): { + current?: GuideBlock + upcoming: GuideBlock[] + currentEntry?: PublicEpgEntryDto +} { const blocks: GuideBlock[] = [] for (const entry of entries) { if (entry.kind !== 'Program') continue @@ -178,7 +220,7 @@ function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcomi last.endsAtUtc = entry.endsAtUtc } else { blocks.push({ - key: entry.id, + key: entry.startsAtUtc, showId: entry.showId, showName: entry.showName ?? '—', startsAtUtc: entry.startsAtUtc, @@ -188,9 +230,12 @@ function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcomi } const now = Date.now() - const current = blocks.find( - (b) => new Date(b.startsAtUtc).getTime() <= now && new Date(b.endsAtUtc).getTime() > now, - ) + const active = (start: string, end: string) => + new Date(start).getTime() <= now && new Date(end).getTime() > now + const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc)) const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now) - return { current, upcoming } + const currentEntry = entries.find( + (e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc), + ) + return { current, upcoming, currentEntry } } diff --git a/frontend/src/features/streaming/api.ts b/frontend/src/features/streaming/api.ts index 55293d5..37976c5 100644 --- a/frontend/src/features/streaming/api.ts +++ b/frontend/src/features/streaming/api.ts @@ -1,5 +1,13 @@ import { apiRequest } from '@/shared/api/client' -import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types' +import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types' + +/** Ссылки на локальные постер шоу / кадр серии (публичные эндпоинты метаданных). */ +export function showPosterUrl(showId: string) { + return `/api/metadata/shows/${showId}/poster` +} +export function episodeStillUrl(episodeId: string) { + return `/api/metadata/episodes/${episodeId}/still` +} export function listChannels() { return apiRequest('/channels') @@ -15,5 +23,5 @@ export function getEpg(slug: string, from?: Date, to?: Date) { if (from) query.set('from', from.toISOString()) if (to) query.set('to', to.toISOString()) const qs = query.toString() - return apiRequest(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`) + return apiRequest(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index ac9e8dd..8be7557 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -91,6 +91,12 @@ export type EpisodeDto = { assetName: string | null assetStatus: MediaAssetStatus | null durationSeconds: number | null + season: number | null + episode: number | null + title: string | null + overview: string | null + hasStill: boolean + airDate: string | null } export type ShowDto = { @@ -200,4 +206,24 @@ export type ScheduleEntryDto = { } // ── Публичный эфир ───────────────────────────────────────────────────────── -export type PublicChannelDto = { id: string; slug: string; name: string } +export type PublicChannelDto = { + id: string + slug: string + name: string + currentShowId: string | null + currentShowName: string | null + currentShowHasPoster: boolean +} + +export type PublicEpgEntryDto = { + kind: ScheduleEntryKind + startsAtUtc: string + endsAtUtc: string + showId: string | null + showName: string | null + showHasPoster: boolean + episodeId: string | null + episodeTitle: string | null + episodeOverview: string | null + episodeHasStill: boolean +} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 1f88fc3..4ba7ac1 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -273,6 +273,9 @@ const resources = { clear: 'Очистить', uploadPoster: 'Загрузить постер', noPoster: 'Нет постера', + refreshEpisodes: 'Обновить серии', + refreshing: 'Обновляем…', + refreshedCount: 'Обновлено серий: {{count}}', }, }, }, @@ -548,6 +551,9 @@ const resources = { clear: 'Clear', uploadPoster: 'Upload poster', noPoster: 'No poster', + refreshEpisodes: 'Refresh episodes', + refreshing: 'Refreshing…', + refreshedCount: 'Episodes updated: {{count}}', }, }, },