diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs index 9517c98..1d1d18c 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs @@ -36,7 +36,9 @@ public sealed record BumperVariantInput( BumperTrigger Trigger, BumperBackground Background, int Weight, - IReadOnlyList Lines + IReadOnlyList Lines, + /// Ролик-подложка для фона . + Guid? BackgroundClipShowId = null ); public sealed record UpdateBumperVariantCommand( @@ -92,5 +94,11 @@ public sealed class UpdateBumperVariantCommandValidator RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines); RuleForEach(x => x.Input.Lines) .ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120)); + + // Фон из ролика без самого ролика — это не «пока не выбрал», а заставка, которая молча + // отрендерится градиентом. Пусть выбор будет обязательным сразу. + RuleFor(x => x.Input.BackgroundClipShowId) + .NotNull() + .When(x => x.Input.Background == BumperBackground.Clip); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs index 094cb5b..124cdbc 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs @@ -13,7 +13,9 @@ public sealed record BumperTextVariantDto( BumperTrigger Trigger, BumperBackground Background, int Weight, - IReadOnlyList Lines + IReadOnlyList Lines, + /// Ролик-подложка для фона . + Guid? BackgroundClipShowId ); /// Блок заставки: оформление + звук + подблоки. — длина звука (сек). diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs index 6d69ce6..348a9ad 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs @@ -29,7 +29,8 @@ public static class BumperMapper v.Weight, v.Lines.OrderBy(l => l.Position) .Select(l => new BumperLineDto(l.Style, l.Color, l.Text)) - .ToList() + .ToList(), + v.BackgroundClipShowId )) .ToList() ); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs index c6a3324..2747fd3 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs @@ -27,6 +27,10 @@ public static class BumperSpecFactory inputs.Lines, inputs.BackgroundAbsolutePath, inputs.AudioPath, - inputs.PosterAbsolutePath + inputs.PosterAbsolutePath, + inputs.ClipAbsolutePath, + // Джингл назначен — он и звучит, подрезанный под ролик; не назначен — играет + // оригинальная дорожка ролика. + UseClipAudio: inputs.ClipAbsolutePath is not null && inputs.AudioPath is null ); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs index 5786a4a..e0479f6 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs @@ -12,5 +12,7 @@ public sealed record BumperSpecInputs( string? AudioPath = null, /// Постер шоу как фон; подставляется, только если подблок его запросил. string? PosterAbsolutePath = null, - string? BackgroundAbsolutePath = null + string? BackgroundAbsolutePath = null, + /// Плейлист ролика-подложки, если текст пишется поверх готового видео. + string? ClipAbsolutePath = null ); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs index 793cacc..b463e89 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs @@ -4,6 +4,7 @@ using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Media; namespace TeleWave.Application.Broadcast.Bumpers; @@ -16,6 +17,7 @@ public sealed class BumperSpecLoader( IAppDbContext dbContext, IBumperTemplateStorage bumperStorage, IImageStore imageStore, + IMediaStorage mediaStorage, IOptions bumperOptions, IOptions streamingOptions ) @@ -47,8 +49,15 @@ public sealed class BumperSpecLoader( ? await ResolveShowPosterAsync(showId, cancellationToken) : null; var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken); + var (clipPath, clipSeconds) = await ResolveClipAsync( + cache.BackgroundAssetId, + cancellationToken + ); + + // Длину задаёт ролик, если он есть: подрезать чужую заставку под джингл значило бы обрывать + // её на полуслове. Выравнивание по сегменту остаётся — лента этого требует от любой записи. var aligned = BumperDuration.Aligned( - BumperDuration.TemplateSeconds(template), + clipSeconds ?? BumperDuration.TemplateSeconds(template), _segmentSeconds ); @@ -60,11 +69,33 @@ public sealed class BumperSpecLoader( BumperRenderedText.FromJson(cache.RenderedLinesJson), bumperStorage.AudioPath(template.Id, template.AudioExtension), posterPath, - bgPath + bgPath, + clipPath ) ); } + /// Плейлист ролика-подложки и его длительность; null — ролика уже нет или он не готов. + private async Task<(string? Path, int? Seconds)> ResolveClipAsync( + Guid? assetId, + CancellationToken cancellationToken + ) + { + if (assetId is not { } id) + return (null, null); + + var duration = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => a.Id == id && a.Status == MediaAssetStatus.Ready) + .Select(a => a.Duration) + .FirstOrDefaultAsync(cancellationToken); + if (duration is not { } value || value <= TimeSpan.Zero) + return (null, null); + + var path = mediaStorage.ResolvePlaylistPath(id); + return path is null ? (null, null) : (path, (int)Math.Ceiling(value.TotalSeconds)); + } + private async Task ResolveShowPosterAsync( Guid showId, CancellationToken cancellationToken diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index 605a307..5e0da84 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -6,6 +6,7 @@ using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Streaming; using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Media; using TeleWave.Domain.Programming; namespace TeleWave.Application.Broadcast.Bumpers; @@ -20,6 +21,7 @@ public sealed class RenderBumperPreviewCommandHandler( IBumperRenderer renderer, IBumperTemplateStorage storage, IImageStore imageStore, + IMediaStorage mediaStorage, IOptions bumperOptions, IOptions streamingOptions ) : ICommandHandler @@ -58,6 +60,8 @@ public sealed class RenderBumperPreviewCommandHandler( ); var audioPath = storage.AudioPath(template.Id, template.AudioExtension); + var clips = await ResolveClipsAsync(template, cancellationToken); + foreach (var variant in template.Variants.OrderBy(v => v.Position)) { var lines = variant @@ -70,15 +74,22 @@ public sealed class RenderBumperPreviewCommandHandler( .Where(l => !string.IsNullOrWhiteSpace(l.Text)) .ToList(); + // У ролика своя длина — в превью она тоже должна быть настоящей, иначе админ увидит + // не ту заставку, которая пойдёт в эфир. + var clip = variant.BackgroundClipShowId is { } clipShowId + ? clips.GetValueOrDefault(clipShowId) + : null; + var spec = BumperSpecFactory.Build( _bumper, template, - aligned, + clip is null ? aligned : BumperDuration.Aligned(clip.Seconds, _segmentSeconds), new BumperSpecInputs( lines, audioPath, variant.Background == BumperBackground.Template ? null : posterPath, - backgroundPath + backgroundPath, + clip?.Path ) ); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); @@ -87,6 +98,58 @@ public sealed class RenderBumperPreviewCommandHandler( return Result.Success(); } + /// Ролик-подложка: плейлист и длительность. + private sealed record ClipSource(string Path, int Seconds); + + /// Ролики-подложки подблоков блока, по шоу. Неготовые молча выпадают — превью не эфир. + private async Task> ResolveClipsAsync( + BumperTemplate template, + CancellationToken cancellationToken + ) + { + var showIds = template + .Variants.Where(v => v.Background == BumperBackground.Clip) + .Select(v => v.BackgroundClipShowId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + if (showIds.Count == 0) + return []; + + var episodes = await dbContext + .Shows.AsNoTracking() + .SelectMany(s => s.Episodes) + .Where(e => showIds.Contains(e.ShowId)) + .OrderBy(e => e.Position) + .Select(e => new { e.ShowId, e.MediaAssetId }) + .ToListAsync(cancellationToken); + + var assetIds = episodes.Select(e => e.MediaAssetId).ToList(); + var ready = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => + assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null + ) + .Select(a => new { a.Id, a.Duration }) + .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken); + + var result = new Dictionary(); + foreach (var episode in episodes) + { + if (result.ContainsKey(episode.ShowId)) + continue; + if (!ready.TryGetValue(episode.MediaAssetId, out var duration)) + continue; + if (mediaStorage.ResolvePlaylistPath(episode.MediaAssetId) is not { } path) + continue; + + result[episode.ShowId] = new ClipSource(path, (int)Math.Ceiling(duration.TotalSeconds)); + } + + return result; + } + private static BumperContext BuildContext(Channel? channel, SampleShows samples) { var offset = TimeSpan.FromMinutes( diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs index 04beb59..56ae11d 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs @@ -38,7 +38,8 @@ public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext) command.Input.Name, command.Input.Trigger, command.Input.Background, - command.Input.Weight + command.Input.Weight, + command.Input.BackgroundClipShowId ); variant.SetLines( command.Input.Lines.Select( diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs index 45df867..887ab6c 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs @@ -24,7 +24,17 @@ public sealed record BumperRenderSpec( string? BackgroundFile = null, string? MusicFile = null, /// Постер шоу как фон (используется, если подблок его запросил; затемняется). - string? PosterFile = null + string? PosterFile = null, + /// + /// Готовый ролик как подложка: плейлист ассета из библиотеки. Побеждает остальные источники — + /// это самый явный выбор из всех, его не подставляют молча. + /// + string? ClipFile = null, + /// + /// Брать звук из самого ролика. Ставится, когда у блока нет джингла: подкладывать под чужую + /// заставку синтезированный писк хуже, чем оставить её собственную дорожку. + /// + bool UseClipAudio = false ); /// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки. diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index 55f8db8..080c5e1 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -11,6 +11,12 @@ public interface IMediaStorage /// Свободное место на томе хранилища, байт. long GetAvailableFreeSpaceBytes(); + /// + /// Абсолютный путь к HLS-плейлисту готового ассета (assets/{id}/index.m3u8) — им ffmpeg + /// читает уже нарезанное медиа как обычный вход. Null, если каталога ассета нет на диске. + /// + string? ResolvePlaylistPath(Guid assetId); + /// /// Стримит загружаемый контент во временный файл в uploads/ без буферизации в память. /// Возвращает непрозрачный токен (имя временного файла) для последующего . diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs index abc176e..253bf4f 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs @@ -57,6 +57,7 @@ public sealed class BumperResolver( tokens, cancellationToken ); + var clipAssets = await LoadClipAssetsAsync(templates.Values, cancellationToken); var requests = new List<(int Index, BumperRequest Request)>(); foreach (var (item, index) in reserved) @@ -91,6 +92,15 @@ public sealed class BumperResolver( _ => (Guid?)null, }; + // Ролик-подложка: нет готового ассета — подблок работает как обычно, по фону блока. + // Ронять заставку из-за необработанного ролика нельзя, она уже стоит в ленте. + var clipAssetId = + variant.Background == BumperBackground.Clip + && variant.BackgroundClipShowId is { } clipShowId + && clipAssets.TryGetValue(clipShowId, out var found) + ? found + : (Guid?)null; + var linesJson = BumperRenderedText.ToJson(lines); requests.Add( ( @@ -100,7 +110,8 @@ public sealed class BumperResolver( variant.Id, linesJson, posterShowId, - ComputeSignature(template, variant.Id, linesJson, posterShowId) + clipAssetId, + ComputeSignature(template, variant.Id, linesJson, posterShowId, clipAssetId) ) ) ); @@ -138,7 +149,8 @@ public sealed class BumperResolver( request.Signature, request.Lines, request.PosterShowId, - asset.Id + asset.Id, + request.BackgroundAssetId ) ); @@ -214,22 +226,69 @@ public sealed class BumperResolver( BumperTemplate template, Guid variantId, string linesJson, - Guid? posterShowId + Guid? posterShowId, + Guid? backgroundAssetId ) { var raw = string.Create( CultureInfo.InvariantCulture, - $"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}" + $"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{backgroundAssetId}|{linesJson}" ); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32]; } + /// + /// Готовые ассеты роликов-подложек по шоу. Берётся первая готовая серия: у ролика она всегда + /// одна, а необработанному в эфире взяться неоткуда. + /// + private async Task> LoadClipAssetsAsync( + IEnumerable templates, + CancellationToken cancellationToken + ) + { + var showIds = templates + .SelectMany(t => t.Variants) + .Where(v => v.Background == BumperBackground.Clip) + .Select(v => v.BackgroundClipShowId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + if (showIds.Count == 0) + return []; + + var episodes = await dbContext + .Shows.AsNoTracking() + .SelectMany(s => s.Episodes) + .Where(e => showIds.Contains(e.ShowId)) + .OrderBy(e => e.Position) + .Select(e => new { e.ShowId, e.MediaAssetId }) + .ToListAsync(cancellationToken); + + var ready = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => + a.Status == MediaAssetStatus.Ready + && a.Duration != null + && episodes.Select(e => e.MediaAssetId).Contains(a.Id) + ) + .Select(a => a.Id) + .ToListAsync(cancellationToken); + + var readySet = ready.ToHashSet(); + return episodes + .Where(e => readySet.Contains(e.MediaAssetId)) + .GroupBy(e => e.ShowId) + .ToDictionary(g => g.Key, g => g.First().MediaAssetId); + } + /// Что нужно отрендерить для одной записи ленты. private sealed record BumperRequest( BumperTemplate Template, Guid VariantId, string Lines, Guid? PosterShowId, + Guid? BackgroundAssetId, string Signature ) { diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs index d04952f..daf00f3 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs @@ -27,6 +27,12 @@ public class BumperAsset /// Шоу, чей постер идёт фоном, или null (фон блока/градиент). public Guid? PosterShowId { get; private set; } + /// + /// Ассет ролика-подложки, если текст пишется поверх готового видео. Хранится именно ассет, + /// а не шоу: рендеру нужен конкретный файл, а перезалив ролика обязан дать новую сигнатуру. + /// + public Guid? BackgroundAssetId { get; private set; } + /// Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером). public Guid MediaAssetId { get; private set; } @@ -40,7 +46,8 @@ public class BumperAsset string signature, string renderedLinesJson, Guid? posterShowId, - Guid mediaAssetId + Guid mediaAssetId, + Guid? backgroundAssetId = null ) => new() { @@ -50,6 +57,7 @@ public class BumperAsset Signature = signature, RenderedLinesJson = renderedLinesJson, PosterShowId = posterShowId, + BackgroundAssetId = backgroundAssetId, MediaAssetId = mediaAssetId, CreatedAt = DateTimeOffset.UtcNow, }; diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs index 9cf6cb7..5fee6bb 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs @@ -14,4 +14,10 @@ public enum BumperBackground /// Постер предыдущего шоу. NowPoster = 2, + + /// + /// Готовый ролик из библиотеки: текст пишется поверх видео. Длину задаёт сам ролик, а не + /// джингл блока — подрезать чужую заставку под музыку значило бы обрывать её на полуслове. + /// + Clip = 3, } diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs index 2fec998..3885d13 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs @@ -19,6 +19,12 @@ public class BumperTextVariant /// Источник картинки под текстом. public BumperBackground Background { get; private set; } + /// + /// Ролик-подложка для : шоу-Interstitial из библиотеки. + /// Ссылка на шоу, а не на ассет: перезалив файла не должен рвать настройку подблока. + /// + public Guid? BackgroundClipShowId { get; private set; } + /// Вес при выборе подблока на переходе (0 — не выбирается никогда). public int Weight { get; private set; } = DefaultWeight; @@ -49,12 +55,21 @@ public class BumperTextVariant CreatedAt = DateTimeOffset.UtcNow, }; - public void Update(string name, BumperTrigger trigger, BumperBackground background, int weight) + public void Update( + string name, + BumperTrigger trigger, + BumperBackground background, + int weight, + Guid? backgroundClipShowId = null + ) { Name = name; Trigger = trigger; Background = background; Weight = Math.Max(0, weight); + // Ссылка на ролик живёт только при своём источнике фона: иначе она осталась бы висеть + // после переключения на постер и всплыла бы при возврате уже неожиданно. + BackgroundClipShowId = background == BumperBackground.Clip ? backgroundClipShowId : null; } /// diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index 37b7250..5e417ff 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -183,7 +183,16 @@ public sealed class FfmpegBumperRenderer( // анимированный градиент. var inputs = new List(); string videoPrefix; - if (!string.IsNullOrEmpty(spec.BackgroundFile)) + if (!string.IsNullOrEmpty(spec.ClipFile)) + { + // Готовый ролик как подложка. Не зацикливаем: длину задаёт он сам, а выравнивание по + // сегменту добираем стоп-кадром — повтор первых секунд в конце заметнее любой заморозки. + inputs.AddRange(["-i", spec.ClipFile]); + videoPrefix = + $"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}" + + $",tpad=stop_mode=clone:stop_duration={Fmt(target)},fps=30,format=yuv420p"; + } + else if (!string.IsNullOrEmpty(spec.BackgroundFile)) { if (IsImage(spec.BackgroundFile)) inputs.AddRange(["-loop", "1"]); @@ -212,8 +221,17 @@ public sealed class FfmpegBumperRenderer( } // Вход 1 — звук: загруженная музыка (петля + loudnorm) либо синтезированный джингл. + // У ролика-подложки без джингла звучит его собственная дорожка — второго входа не нужно. string audioChain; - if (!string.IsNullOrEmpty(spec.MusicFile)) + if (spec.UseClipAudio) + { + audioChain = + $"[0:a]loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11" + // apad добивает тишиной хвост, дорисованный стоп-кадром: без него поток звука + // кончается раньше видео и ffmpeg обрезает кадр по нему. + + $",apad,afade=t=out:st={Fmt(outStart)}:d=1.0[a]"; + } + else if (!string.IsNullOrEmpty(spec.MusicFile)) { inputs.AddRange(["-stream_loop", "-1", "-i", spec.MusicFile]); audioChain = diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index 908acd4..57c1592 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -23,6 +23,12 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor } } + public string? ResolvePlaylistPath(Guid assetId) + { + var path = Path.Combine(paths.AssetDir(assetId), "index.m3u8"); + return File.Exists(path) ? path : null; + } + public async Task SaveUploadAsync( Stream content, string extension, diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs new file mode 100644 index 0000000..969914a --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs @@ -0,0 +1,1428 @@ +// +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("20260729204622_BumperClipBackground")] + partial class BumperClipBackground + { + /// + 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("BackgroundAssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("PosterShowId") + .HasColumnType("uuid"); + + b.Property("RenderedLinesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("Signature") + .IsUnique(); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Font") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Background") + .HasColumnType("integer"); + + b.Property("BackgroundClipShowId") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .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("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", 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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + 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("StillImageId") + .HasColumnType("uuid"); + + 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.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + 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("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + 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.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", 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("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + 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.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .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.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs new file mode 100644 index 0000000..ffba5a9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperClipBackground : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BackgroundClipShowId", + table: "BumperTextVariants", + type: "uuid", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "BackgroundAssetId", + table: "BumperAssets", + type: "uuid", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "BackgroundClipShowId", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "BackgroundAssetId", table: "BumperAssets"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 51fb479..0dff446 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -164,6 +164,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("BackgroundAssetId") + .HasColumnType("uuid"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -262,6 +265,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Background") .HasColumnType("integer"); + b.Property("BackgroundClipShowId") + .HasColumnType("uuid"); + b.Property("BumperTemplateId") .HasColumnType("uuid"); diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs index 12a44b5..ba261f2 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs @@ -26,13 +26,15 @@ public class BumperPreviewTests AppDbContext db, IBumperRenderer? renderer = null, IBumperTemplateStorage? storage = null, - IImageStore? imageStore = null + IImageStore? imageStore = null, + IMediaStorage? mediaStorage = null ) => new( db, renderer ?? Substitute.For(), storage ?? Substitute.For(), imageStore ?? Substitute.For(), + mediaStorage ?? Substitute.For(), Options.Create(new BumperOptions()), Options.Create(new StreamingOptions { SegmentSeconds = 5 }) ); diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs index f88fba1..f20f923 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs @@ -27,12 +27,14 @@ public class BumperSpecLoaderTests private static BumperSpecLoader NewLoader( Infrastructure.Persistence.AppDbContext db, IBumperTemplateStorage? storage = null, - IImageStore? images = null + IImageStore? images = null, + IMediaStorage? media = null ) => new( db, storage ?? Substitute.For(), images ?? Substitute.For(), + media ?? Substitute.For(), Options.Create(new BumperOptions()), Options.Create(new StreamingOptions { SegmentSeconds = 5 }) ); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 632bd88..63e502c 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -601,6 +601,19 @@ BumperLine Источник фона становится явным полем подблока. Раньше постер шоу подставлялся молча и только в режиме `NowNext`; при свободных строках такой связи взяться неоткуда. +**Фон из готового ролика** (`background = clip`, `backgroundClipShowId`) — текст пишется поверх +видео из библиотеки, а не поверх картинки. Отличается от прочих источников тем, что задаёт время: + +- **длину заставки диктует ролик**, а не джингл блока. Подрезать чужую заставку под музыку значило бы + обрывать её на полуслове. Выравнивание по сегменту остаётся, хвост добирается стоп-кадром — + повтор первых секунд в конце заметнее любой заморозки; +- **звук**: назначен джингл — звучит он, подрезанный под ролик; не назначен — играет оригинальная + дорожка ролика. Синтезированный писк под чужую заставку не подкладывается; +- в сигнатуру кэша входит **ассет** ролика, а не шоу: перезалив файла обязан дать новый рендер. + В подблоке при этом хранится ссылка на шоу — перезалив не должен рвать настройку; +- ролик не готов или удалён — подблок молча отрабатывает по фону блока: ронять заставку, которая + уже стоит в ленте, нельзя. + **Плейсхолдеры** подставляются в момент планирования — там уже известны канал, пара соседей и точное время врезки: diff --git a/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx index c26f28b..1e74991 100644 --- a/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx +++ b/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { @@ -13,13 +13,15 @@ import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' +import { listInterstitials } from '@/features/admin/interstitials/api' +import { qk } from '@/shared/api/query-keys' import { removeBumperVariant, updateBumperVariant } from '../api' import { unknownTokens } from '../placeholders' import { BumperFramePreview } from './BumperFramePreview' import { BumperLinesEditor } from './BumperLinesEditor' const TRIGGERS: BumperTrigger[] = ['OnShowChange', 'BetweenEpisodes', 'Both'] -const BACKGROUNDS: BumperBackground[] = ['Template', 'NextPoster', 'NowPoster'] +const BACKGROUNDS: BumperBackground[] = ['Template', 'NextPoster', 'NowPoster', 'Clip'] /** * Подблок: слева строки, справа постоянный предпросмотр кадра. Предпросмотр не опциональный — @@ -39,10 +41,16 @@ export function BumperVariantEditor({ onError: (e: unknown) => void }>) { const { t } = useTranslation() + // Ролики грузим списком: их немного, а выбирать подложку удобнее из готового перечня. + const { data: clips } = useQuery({ + queryKey: qk.interstitials.all, + queryFn: listInterstitials, + }) const [form, setForm] = useState({ name: variant.name, trigger: variant.trigger, background: variant.background, + backgroundClipShowId: variant.backgroundClipShowId, weight: variant.weight, lines: variant.lines, }) @@ -52,6 +60,7 @@ export function BumperVariantEditor({ name: variant.name, trigger: variant.trigger, background: variant.background, + backgroundClipShowId: variant.backgroundClipShowId, weight: variant.weight, lines: variant.lines, }) @@ -128,6 +137,31 @@ export function BumperVariantEditor({ + {/* Ролик-подложка — только при своём источнике фона: в остальных случаях поле было бы + настройкой, которая ни на что не влияет. */} + {form.background === 'Clip' && ( +
+ + + + {t('admin.bumpers.backgroundClipHint')} + +
+ )}