From 7b12a06d1bef9f2fae8f8eff9753fc1789ba584f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 14:03:53 +0300 Subject: [PATCH] Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration. --- .../Endpoints/InterstitialEndpoints.cs | 64 + .../TeleWave.Api/Endpoints/MediaEndpoints.cs | 59 + .../Endpoints/TemplateEndpoints.cs | 19 + backend/src/TeleWave.Api/Program.cs | 1 + .../Broadcast/ChannelDtos.cs | 8 +- .../GetChannel/GetChannelQueryHandler.cs | 154 +- .../UpdateChannelSettingsCommand.cs | 11 +- .../UpdateChannelSettingsCommandHandler.cs | 8 +- .../UpdateChannelSettingsCommandValidator.cs | 4 - .../ImportInterstitialsCommand.cs | 12 + .../ImportInterstitialsCommandHandler.cs | 52 + .../ImportInterstitialsCommandValidator.cs | 10 + .../Library/Interstitials/InterstitialDtos.cs | 28 + .../ListInterstitialBlocksQuery.cs | 6 + .../ListInterstitialBlocksQueryHandler.cs | 55 + .../ListInterstitialsQuery.cs | 5 + .../ListInterstitialsQueryHandler.cs | 60 + .../Library/ShowErrors.cs | 2 +- .../Planning/GridScheduleGenerator.cs | 33 + .../Planning/Preview/PreviewScheduleQuery.cs | 33 + .../Preview/PreviewScheduleQueryHandler.cs | 112 ++ .../Broadcast/BumperSelection.cs | 20 +- .../src/TeleWave.Domain/Broadcast/Channel.cs | 321 ++-- backend/src/TeleWave.Domain/Library/Show.cs | 6 +- ...6105315_DropDeadBumperSettings.Designer.cs | 1368 +++++++++++++++++ .../20260726105315_DropDeadBumperSettings.cs | 68 + .../Migrations/AppDbContextModelSnapshot.cs | 12 - .../Broadcast/ChannelHandlersTests.cs | 11 +- .../Validators/ValidatorTests.cs | 11 +- .../Broadcast/ChannelTests.cs | 29 +- docs/tv-scheduler-tasks.md | 13 +- .../features/admin/channels/ChannelDetail.tsx | 17 +- frontend/src/features/admin/channels/api.ts | 79 +- .../admin/channels/components/BumperCard.tsx | 46 +- .../components/BumperPreviewPlayer.tsx | 40 +- .../components/JunctionElementDialog.tsx | 222 +++ .../channels/components/JunctionsCard.tsx | 320 ++++ .../channels/components/SlotInspector.tsx | 44 +- .../channels/components/TemplatePreview.tsx | 205 +++ .../src/features/admin/channels/lib/format.ts | 21 +- .../admin/interstitials/BlockBuilder.tsx | 145 ++ .../admin/interstitials/ClipGroupPanel.tsx | 132 ++ .../interstitials/InterstitialsPanel.tsx | 282 ++++ .../src/features/admin/interstitials/api.ts | 23 + .../src/features/admin/interstitials/dnd.ts | 27 + .../features/admin/interstitials/format.ts | 13 + .../src/features/admin/media/upload-store.ts | 15 +- frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/admin.tsx | 7 + frontend/src/routes/admin/interstitials.tsx | 4 + frontend/src/shared/api/types.ts | 87 +- frontend/src/shared/lib/i18n.ts | 158 +- frontend/src/shared/ui/hls-video.tsx | 42 + 53 files changed, 4076 insertions(+), 469 deletions(-) create mode 100644 backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.cs create mode 100644 frontend/src/features/admin/channels/components/JunctionElementDialog.tsx create mode 100644 frontend/src/features/admin/channels/components/JunctionsCard.tsx create mode 100644 frontend/src/features/admin/channels/components/TemplatePreview.tsx create mode 100644 frontend/src/features/admin/interstitials/BlockBuilder.tsx create mode 100644 frontend/src/features/admin/interstitials/ClipGroupPanel.tsx create mode 100644 frontend/src/features/admin/interstitials/InterstitialsPanel.tsx create mode 100644 frontend/src/features/admin/interstitials/api.ts create mode 100644 frontend/src/features/admin/interstitials/dnd.ts create mode 100644 frontend/src/features/admin/interstitials/format.ts create mode 100644 frontend/src/routes/admin/interstitials.tsx create mode 100644 frontend/src/shared/ui/hls-video.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs new file mode 100644 index 0000000..638a7a4 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs @@ -0,0 +1,64 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Library.Interstitials; +using TeleWave.Application.Library.Interstitials.ImportInterstitials; +using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks; +using TeleWave.Application.Library.Interstitials.ListInterstitials; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// +/// Ролики-врезки: тот же Show(Kind = Interstitial), но со своим экраном (см. 6.7). Правка +/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет: +/// список с длительностями, блоки и импорт загруженных файлов. +/// +public static class InterstitialEndpoints +{ + public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/interstitials") + .WithTags("Admin.Interstitials") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", List).Produces>(); + admin.MapGet("/blocks", ListBlocks).Produces>(); + admin.MapPost("/import", Import).Produces(); + + return app; + } + + private static async Task List(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task ListBlocks( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task Import( + ImportInterstitialsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ImportInterstitialsCommand(body.MediaAssetIds), + cancellationToken + ); + return result.IsSuccess + ? Results.Ok(new ImportInterstitialsResponse(result.Value)) + : result.ToHttpResult(); + } +} + +public sealed record ImportInterstitialsBody(IReadOnlyList MediaAssetIds); + +public sealed record ImportInterstitialsResponse(int Imported); diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 2277204..f65363f 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.RegularExpressions; using LiteCqrs; using Microsoft.Extensions.Options; using TeleWave.Api.Common; @@ -16,6 +18,8 @@ namespace TeleWave.Api.Endpoints; public static class MediaEndpoints { + private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled); + public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) { var admin = app.MapGroup("/api/admin/media") @@ -27,6 +31,11 @@ public static class MediaEndpoints admin.MapGet("/stats", Stats).Produces(); admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); + // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт + // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. + admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); + admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); + return app; } @@ -135,6 +144,56 @@ public static class MediaEndpoints var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); return result.ToHttpResult(); } + + /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. + private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) + { + string indexPath; + try + { + indexPath = paths.SegmentPath(id, "index.m3u8"); + } + catch (UnauthorizedAccessException) + { + return Results.NotFound(); + } + if (!File.Exists(indexPath)) + return Results.NotFound(); + + var baseUrl = $"/api/admin/media/{id}/preview/"; + var sb = new StringBuilder(); + foreach (var line in File.ReadLines(indexPath)) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + continue; + // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. + sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed)) + .Append('\n'); + } + + return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); + } + + private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) + { + if (!SegmentFileName.IsMatch(file)) + return Results.NotFound(); + + string path; + try + { + path = paths.SegmentPath(id, file); + } + catch (UnauthorizedAccessException) + { + return Results.NotFound(); + } + if (!File.Exists(path)) + return Results.NotFound(); + + return Results.File(path, "video/mp2t", enableRangeProcessing: true); + } } public sealed record UploadMediaResponse(Guid Id); diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index 8acc6cc..524f324 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -1,6 +1,7 @@ using LiteCqrs; using TeleWave.Api.Common; using TeleWave.Application.Programming.Planning.ApplyTemplate; +using TeleWave.Application.Programming.Planning.Preview; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.CreateSlot; using TeleWave.Application.Programming.Templates.DeleteSlot; @@ -34,6 +35,10 @@ public static class TemplateEndpoints admin .MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate) .Produces(); + // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров. + admin + .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate) + .Produces(); admin .MapPost("/templates/{templateId:guid}/layers", CreateLayer) @@ -75,6 +80,20 @@ public static class TemplateEndpoints return result.ToHttpResult(); } + private static async Task PreviewTemplate( + Guid channelId, + ISender sender, + CancellationToken cancellationToken, + int days = 1 + ) + { + var result = await sender.Send( + new PreviewScheduleQuery(channelId, days), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task UpdateTemplate( Guid templateId, UpdateTemplateBody body, diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 1d5f07a..7b7de2c 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -125,6 +125,7 @@ app.MapAdminUserEndpoints(); app.MapMediaEndpoints(); app.MapShowEndpoints(); app.MapGenreEndpoints(); +app.MapInterstitialEndpoints(); app.MapCollectionEndpoints(); app.MapGroupEndpoints(); app.MapTemplateEndpoints(); diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 4583658..e8773ff 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -5,13 +5,7 @@ namespace TeleWave.Application.Broadcast; public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); /// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках). -public sealed record BumperSettingsDto( - BumperFont Font, - int MinIntervalMinutes, - BumperSelection Selection, - double ShowChangeChance, - double EpisodeChangeChance -); +public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection); /// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока. public sealed record BumperTextVariantDto( diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 27aa129..30e2740 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -1,80 +1,74 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.GetChannel; - -public sealed class GetChannelQueryHandler(IAppDbContext dbContext) - : IQueryHandler> -{ - public async Task> Handle( - GetChannelQuery query, - CancellationToken cancellationToken - ) - { - // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно - // (GetChannelTemplateQuery) — здесь только собственные свойства канала. - var channel = await dbContext - .Channels.AsNoTracking() - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var bumperTemplates = channel - .BumperTemplates.OrderBy(t => t.Position) - .Select(t => new BumperTemplateDto( - t.Id, - t.Position, - t.IsDefault, - t.Name, - t.BackgroundColor, - t.BackgroundColor2, - t.AccentColor, - t.TextColor, - t.BackgroundImageId, - t.AudioExtension is not null, - t.AudioDurationSeconds, - t.Variants.OrderBy(v => v.Position) - .Select(v => new BumperTextVariantDto( - v.Id, - v.Position, - v.Name, - v.Kind, - v.NowLabel, - v.NextLabel, - v.Line1, - v.Line2, - v.Trigger, - v.Weight - )) - .ToList() - )) - .ToList(); - - return Result.Success( - new ChannelDto( - channel.Id, - channel.Name, - channel.Slug, - channel.IsEnabled, - channel.Number, - channel.UtcOffsetMinutes, - channel.DayStartTime, - channel.TemplateId, - channel.BumpersEnabled, - new BumperSettingsDto( - channel.BumperFont, - channel.BumperMinIntervalMinutes, - channel.BumperSelection, - channel.BumperShowChangeChance, - channel.BumperEpisodeChangeChance - ), - bumperTemplates, - channel.FillerAssetId - ) - ); - } -} +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.GetChannel; + +public sealed class GetChannelQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + GetChannelQuery query, + CancellationToken cancellationToken + ) + { + // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно + // (GetChannelTemplateQuery) — здесь только собственные свойства канала. + var channel = await dbContext + .Channels.AsNoTracking() + .Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) + .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var bumperTemplates = channel + .BumperTemplates.OrderBy(t => t.Position) + .Select(t => new BumperTemplateDto( + t.Id, + t.Position, + t.IsDefault, + t.Name, + t.BackgroundColor, + t.BackgroundColor2, + t.AccentColor, + t.TextColor, + t.BackgroundImageId, + t.AudioExtension is not null, + t.AudioDurationSeconds, + t.Variants.OrderBy(v => v.Position) + .Select(v => new BumperTextVariantDto( + v.Id, + v.Position, + v.Name, + v.Kind, + v.NowLabel, + v.NextLabel, + v.Line1, + v.Line2, + v.Trigger, + v.Weight + )) + .ToList() + )) + .ToList(); + + return Result.Success( + new ChannelDto( + channel.Id, + channel.Name, + channel.Slug, + channel.IsEnabled, + channel.Number, + channel.UtcOffsetMinutes, + channel.DayStartTime, + channel.TemplateId, + channel.BumpersEnabled, + new BumperSettingsDto(channel.BumperFont, channel.BumperSelection), + bumperTemplates, + channel.FillerAssetId + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index b923620..e5c4f62 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -13,11 +13,6 @@ public sealed record UpdateChannelSettingsCommand( Guid? FillerAssetId ) : ICommand; -/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). -public sealed record BumperSettingsInput( - BumperFont Font, - int MinIntervalMinutes, - BumperSelection Selection, - double ShowChangeChance, - double EpisodeChangeChance -); +/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). Условия +/// показа сюда не входят — они задаются на элементе стыка. +public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index 1df2f03..4b78edd 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -36,13 +36,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) command.BumpersEnabled, command.FillerAssetId ); - channel.UpdateBumperSettings( - command.Bumper.Font, - command.Bumper.MinIntervalMinutes, - command.Bumper.Selection, - command.Bumper.ShowChangeChance, - command.Bumper.EpisodeChangeChance - ); + channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs index 15941fb..9089ce6 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -8,9 +8,5 @@ public sealed class UpdateChannelSettingsCommandValidator public UpdateChannelSettingsCommandValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(256); - - RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); - RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0); - RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0); } } diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs new file mode 100644 index 0000000..a2bbdc3 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Interstitials.ImportInterstitials; + +/// +/// Превращает готовые медиа-ассеты в ролики: по одному Show(Kind = Interstitial) на файл, +/// имя — имя файла без расширения. Ассеты, уже привязанные к какому-нибудь шоу, пропускаются — +/// так повторный импорт того же выделения не плодит дубли. +/// +public sealed record ImportInterstitialsCommand(IReadOnlyList MediaAssetIds) + : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs new file mode 100644 index 0000000..755ecac --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs @@ -0,0 +1,52 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Interstitials.ImportInterstitials; + +public sealed class ImportInterstitialsCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + ImportInterstitialsCommand command, + CancellationToken cancellationToken + ) + { + var ids = command.MediaAssetIds.Distinct().ToList(); + + var assets = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => ids.Contains(a.Id)) + .Select(a => new { a.Id, a.OriginalFileName }) + .ToListAsync(cancellationToken); + if (assets.Count == 0) + return Result.Failure(ShowErrors.AssetNotFound); + + var alreadyUsed = await dbContext + .Shows.SelectMany(s => s.Episodes) + .Where(e => ids.Contains(e.MediaAssetId)) + .Select(e => e.MediaAssetId) + .ToListAsync(cancellationToken); + var used = alreadyUsed.ToHashSet(); + + var created = 0; + foreach (var asset in assets.Where(a => !used.Contains(a.Id))) + { + var show = Show.Create(ClipName(asset.OriginalFileName), ShowKind.Interstitial); + show.AddEpisode(asset.Id); + dbContext.Shows.Add(show); + created++; + } + + return Result.Success(created); + } + + /// Имя ролика — имя файла без расширения; пустое (файл вида «.mp4») заменяем самим файлом. + private static string ClipName(string fileName) + { + var name = Path.GetFileNameWithoutExtension(fileName); + return string.IsNullOrWhiteSpace(name) ? fileName : name; + } +} diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs new file mode 100644 index 0000000..8e87ce9 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs @@ -0,0 +1,10 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.Interstitials.ImportInterstitials; + +public sealed class ImportInterstitialsCommandValidator + : AbstractValidator +{ + public ImportInterstitialsCommandValidator() => + RuleFor(x => x.MediaAssetIds).NotEmpty().Must(ids => ids.Count <= 500); +} diff --git a/backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs b/backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs new file mode 100644 index 0000000..38b191a --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs @@ -0,0 +1,28 @@ +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Library.Interstitials; + +/// +/// Ролик на экране «Ролики». Это то же Show(Kind = Interstitial), но показывается по-другому: +/// у ролика нет ни года, ни постера, ни серий — важна длительность, поэтому она приходит сразу, +/// а не вторым запросом за медиа-ассетом. +/// +public sealed record InterstitialDto( + Guid Id, + string Name, + Guid? MediaAssetId, + MediaAssetStatus? AssetStatus, + double? DurationSeconds, + DateTimeOffset CreatedAt +); + +/// +/// Рекламный блок — коллекция, целиком собранная из роликов. Отдельного типа под блок нет +/// (см. 3.7): блок и есть коллекция, здесь она показывается со своей суммарной длительностью. +/// +public sealed record InterstitialBlockDto( + Guid Id, + string Name, + int ItemCount, + double DurationSeconds +); diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs new file mode 100644 index 0000000..51b80b0 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; + +namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks; + +/// Коллекции, целиком собранные из роликов, — рекламные блоки экрана «Ролики». +public sealed record ListInterstitialBlocksQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs new file mode 100644 index 0000000..002d35b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs @@ -0,0 +1,55 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks; + +public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListInterstitialBlocksQuery query, + CancellationToken cancellationToken + ) + { + var collections = await dbContext + .Collections.AsNoTracking() + .Include(c => c.Items) + .OrderBy(c => c.Name) + .ToListAsync(cancellationToken); + + // «Блок» — не отдельная сущность, а признак состава: коллекция целиком из роликов. Смешанные + // коллекции (франшизы) остаются на своём экране и сюда не попадают. + var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList(); + var clips = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial) + .Select(s => new + { + s.Id, + AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(), + }) + .ToDictionaryAsync(s => s.Id, s => s.AssetIds, cancellationToken); + + var assetIds = clips.Values.SelectMany(ids => ids).Distinct().ToList(); + var durations = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => assetIds.Contains(a.Id) && a.Duration != null) + .Select(a => new { a.Id, a.Duration }) + .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken); + + return collections + .Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId))) + .Select(c => new InterstitialBlockDto( + c.Id, + c.Name, + c.Items.Count, + c.Items.Sum(i => + clips[i.ShowId] + .Sum(assetId => durations.TryGetValue(assetId, out var d) ? d : 0) + ) + )) + .ToList(); + } +} diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs new file mode 100644 index 0000000..3b55afe --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Library.Interstitials.ListInterstitials; + +public sealed record ListInterstitialsQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs new file mode 100644 index 0000000..b420a52 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs @@ -0,0 +1,60 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Interstitials.ListInterstitials; + +public sealed class ListInterstitialsQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListInterstitialsQuery query, + CancellationToken cancellationToken + ) + { + var shows = await dbContext + .Shows.AsNoTracking() + .Include(s => s.Episodes) + .Where(s => s.Kind == ShowKind.Interstitial) + .OrderBy(s => s.Name) + .ToListAsync(cancellationToken); + + // У ролика ровно одна «серия» — берём её ассет, чтобы показать длительность и статус обработки. + var assetIds = shows + .Select(s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).FirstOrDefault()) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + var assets = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => assetIds.Contains(a.Id)) + .Select(a => new + { + a.Id, + a.Status, + a.Duration, + }) + .ToDictionaryAsync(a => a.Id, cancellationToken); + + return shows + .Select(s => + { + var assetId = s + .Episodes.OrderBy(e => e.Position) + .Select(e => (Guid?)e.MediaAssetId) + .FirstOrDefault(); + var asset = + assetId is { } id && assets.TryGetValue(id, out var a) ? a : null; + return new InterstitialDto( + s.Id, + s.Name, + assetId, + asset?.Status, + asset?.Duration?.TotalSeconds, + s.CreatedAt + ); + }) + .ToList(); + } +} diff --git a/backend/src/TeleWave.Application/Library/ShowErrors.cs b/backend/src/TeleWave.Application/Library/ShowErrors.cs index 95d592f..e9f25df 100644 --- a/backend/src/TeleWave.Application/Library/ShowErrors.cs +++ b/backend/src/TeleWave.Application/Library/ShowErrors.cs @@ -13,7 +13,7 @@ public static class ShowErrors public static readonly Error SingleAlreadyHasEpisode = Error.Conflict( "Shows.SingleAlreadyHasEpisode", - "Полнометражка/разовый выпуск может содержать только одну серию." + "Больше одной серии бывает только у сериала." ); public static readonly Error AssetNotFound = Error.NotFound( diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 3c5812b..beb8d8d 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -146,6 +146,39 @@ public sealed class GridScheduleGenerator( return new GenerationReport(added, result.Warnings); } + /// + /// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет — + /// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной + /// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении. + /// + public async Task PreviewAsync( + Guid channelId, + DateTimeOffset from, + int days, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) + .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); + if (channel is null || channel.TemplateId is null) + return null; + + var template = await dbContext + .ScheduleTemplates.AsNoTracking() + .Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken); + if (template is null) + return null; + + var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays))); + var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken); + return Domain.Programming.Planning.SchedulePlanner.Plan(input, random); + } + /// /// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил — /// история показов берётся из самой ленты, отдельного журнала нет. diff --git a/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs new file mode 100644 index 0000000..97ade00 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs @@ -0,0 +1,33 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Planning.ApplyTemplate; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning.Preview; + +/// +/// Прогон генератора по текущим правилам без записи: что было бы в эфире, если применить сейчас. +/// Курсоры слотов не двигаются, поэтому предпросмотр можно жать сколько угодно раз. +/// +public sealed record PreviewScheduleQuery(Guid ChannelId, int Days = 1) + : IQuery>; + +/// Запись предполагаемой ленты. Заставка приходит без ассета — он рендерится при применении. +public sealed record PreviewItemDto( + PlannedItemKind Kind, + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + Guid? ShowId, + string? Title, + Guid? SlotId, + string? SlotTitle +); + +public sealed record SchedulePreviewDto( + DateTimeOffset FromUtc, + DateTimeOffset ToUtc, + /// Время канала: лента отдаётся в UTC, а показывается в нём. + int UtcOffsetMinutes, + IReadOnlyList Items, + IReadOnlyList Warnings +); diff --git a/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs new file mode 100644 index 0000000..a4834e1 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs @@ -0,0 +1,112 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Planning.ApplyTemplate; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning.Preview; + +public sealed class PreviewScheduleQueryHandler( + IAppDbContext dbContext, + GridScheduleGenerator generator +) : IQueryHandler> +{ + public async Task> Handle( + PreviewScheduleQuery query, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .Where(c => c.Id == query.ChannelId) + .Select(c => new { c.UtcOffsetMinutes }) + .FirstOrDefaultAsync(cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var from = DateTimeOffset.UtcNow; + var result = await generator.PreviewAsync( + query.ChannelId, + from, + query.Days, + cancellationToken + ); + if (result is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + var showNames = await LoadShowNamesAsync(result.Items, cancellationToken); + var slotTitles = await LoadSlotTitlesAsync(result.Items, cancellationToken); + + var items = result + .Items.Select(item => new PreviewItemDto( + item.Kind, + item.StartsAtUtc, + item.EndsAtUtc, + item.ShowId, + item.ShowId is { } showId && showNames.TryGetValue(showId, out var name) + ? name + : null, + item.SlotId, + item.SlotId is { } slotId && slotTitles.TryGetValue(slotId, out var title) + ? title + : null + )) + .ToList(); + + var to = items.Count == 0 ? from : items[^1].EndsAtUtc; + + return Result.Success( + new SchedulePreviewDto( + from, + to, + channel.UtcOffsetMinutes, + items, + result + .Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details)) + .ToList() + ) + ); + } + + private async Task> LoadShowNamesAsync( + IReadOnlyList items, + CancellationToken cancellationToken + ) + { + var ids = items + .Select(i => i.ShowId) + .Where(id => id is not null && id != Guid.Empty) + .Select(id => id!.Value) + .Distinct() + .ToList(); + if (ids.Count == 0) + return []; + + return await dbContext + .Shows.AsNoTracking() + .Where(s => ids.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + } + + private async Task> LoadSlotTitlesAsync( + IReadOnlyList items, + CancellationToken cancellationToken + ) + { + var ids = items + .Select(i => i.SlotId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + if (ids.Count == 0) + return []; + + return await dbContext + .Slots.AsNoTracking() + .Where(s => ids.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs index f2496a5..525f182 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs @@ -1,17 +1,17 @@ namespace TeleWave.Domain.Broadcast; -/// Как выбирать блок заставки на каждом переходе между шоу. +/// +/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу» +/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор. +/// public enum BumperSelection { - /// По кругу в порядке блоков (курсор ). - Rotation, + /// Случайный подблок на каждом переходе (равновероятно). + Random = 1, - /// Случайный блок на каждом переходе (равновероятно). - Random, + /// Всегда первый (дефолтный) подблок. + AlwaysFirst = 2, - /// Всегда первый (дефолтный) блок. - AlwaysFirst, - - /// Случайный блок с учётом веса подблока (). - WeightedRandom, + /// Случайный подблок с учётом веса (). + WeightedRandom = 3, } diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index 518d399..600757a 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -1,176 +1,145 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (, -/// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный -/// филлер и общие настройки заставок. -/// -public class Channel -{ - private readonly List _bumperTemplates = new(); - - public Guid Id { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Slug { get; private set; } = string.Empty; - public bool IsEnabled { get; private set; } - - /// Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи. - public DateTimeOffset EpochUtc { get; private set; } - - /// - /// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам - /// включается глобальным флагом настроек сайта; null — номер не задан. - /// - public int? Number { get; private set; } - - /// - /// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный - /// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке - /// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA. - /// - public int UtcOffsetMinutes { get; private set; } - - /// - /// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00 - /// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница. - /// - public TimeOnly DayStartTime { get; private set; } - - /// Активный шаблон сетки канала (один на канал). - public Guid? TemplateId { get; private set; } - - public const int DefaultUtcOffsetMinutes = 180; - public static readonly TimeOnly DefaultDayStartTime = new(6, 0); - - // ── Настройки ТВ-заставок. В срезе 2 условия показа переезжают в элементы стыка, - // здесь останется только общий для канала шрифт. ── - - /// Вставлять ли ТВ-заставки на переходах между разными шоу. - public bool BumpersEnabled { get; private set; } - - /// Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый). - public BumperSelection BumperSelection { get; private set; } - - /// Курсор ротации блоков заставок. - public int NextBumperIndex { get; private set; } - - public BumperFont BumperFont { get; private set; } - - /// Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе). - public int BumperMinIntervalMinutes { get; private set; } - - /// Вероятность заставки на смене шоу (0..1; 1 — на каждой смене, 0 — никогда). - public double BumperShowChangeChance { get; private set; } = 1.0; - - /// Вероятность заставки между блоками одного шоу (0..1; напр. 0.3 — примерно в 30% случаев). - public double BumperEpisodeChangeChance { get; private set; } = 1.0; - - private const string DefaultTemplateName = "Заставка 1"; - - /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). - public Guid? FillerAssetId { get; private set; } - - public DateTimeOffset CreatedAt { get; private set; } - - /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position. - public IReadOnlyList BumperTemplates => _bumperTemplates; - - private Channel() { } - - public static Channel Create(string name, string slug, DateTimeOffset epochUtc) - { - var channel = new Channel - { - Id = Guid.NewGuid(), - Name = name, - Slug = slug, - IsEnabled = true, - EpochUtc = epochUtc, - BumpersEnabled = false, - BumperSelection = BumperSelection.Rotation, - NextBumperIndex = 0, - BumperFont = BumperFont.Sans, - BumperMinIntervalMinutes = 0, - BumperShowChangeChance = 1.0, - BumperEpisodeChangeChance = 1.0, - UtcOffsetMinutes = DefaultUtcOffsetMinutes, - DayStartTime = DefaultDayStartTime, - CreatedAt = DateTimeOffset.UtcNow, - }; - // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл). - channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName)); - return channel; - } - - public void UpdateSettings( - string name, - bool isEnabled, - bool bumpersEnabled, - Guid? fillerAssetId - ) - { - Name = name; - IsEnabled = isEnabled; - BumpersEnabled = bumpersEnabled; - FillerAssetId = fillerAssetId; - } - - /// - /// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и - /// вероятности появления на смене шоу / между блоками одного шоу (0..1). - /// - public void UpdateBumperSettings( - BumperFont font, - int minIntervalMinutes, - BumperSelection selection, - double showChangeChance, - double episodeChangeChance - ) - { - BumperFont = font; - BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes); - BumperSelection = selection; - BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0); - BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0); - } - - /// Добавить блок заставки в конец списка. Возвращает созданный блок. - public BumperTemplate AddBumperTemplate(string name) - { - var nextPosition = - _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1; - var template = BumperTemplate.Create(Id, nextPosition, name); - _bumperTemplates.Add(template); - return template; - } - - public BumperTemplate? FindBumperTemplate(Guid templateId) => - _bumperTemplates.FirstOrDefault(t => t.Id == templateId); - - /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false. - public bool RemoveBumperTemplate(Guid templateId) - { - var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId); - if (template is null || template.IsDefault) - return false; - _bumperTemplates.Remove(template); - return true; - } - - /// Планировщик двигает курсор ротации блоков заставок по мере вставки. - public void SetNextBumperIndex(int index) => NextBumperIndex = index; - - /// Привязать активный шаблон сетки. - public void SetTemplate(Guid? templateId) => TemplateId = templateId; - - /// - /// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение - /// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём. - /// - public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime) - { - Number = number is > 0 ? number : null; - UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60); - DayStartTime = dayStartTime; - } -} +namespace TeleWave.Domain.Broadcast; + +/// +/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (, +/// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный +/// филлер и общие настройки заставок. +/// +public class Channel +{ + private readonly List _bumperTemplates = new(); + + public Guid Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public string Slug { get; private set; } = string.Empty; + public bool IsEnabled { get; private set; } + + /// Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи. + public DateTimeOffset EpochUtc { get; private set; } + + /// + /// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам + /// включается глобальным флагом настроек сайта; null — номер не задан. + /// + public int? Number { get; private set; } + + /// + /// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный + /// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке + /// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA. + /// + public int UtcOffsetMinutes { get; private set; } + + /// + /// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00 + /// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница. + /// + public TimeOnly DayStartTime { get; private set; } + + /// Активный шаблон сетки канала (один на канал). + public Guid? TemplateId { get; private set; } + + public const int DefaultUtcOffsetMinutes = 180; + public static readonly TimeOnly DefaultDayStartTime = new(6, 0); + + // ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями) + // живут в элементах стыка; на канале осталось только общее для всех заставок. ── + + /// Вставлять ли ТВ-заставки вообще: общий выключатель канала. + public bool BumpersEnabled { get; private set; } + + /// Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый). + public BumperSelection BumperSelection { get; private set; } + + public BumperFont BumperFont { get; private set; } + + private const string DefaultTemplateName = "Заставка 1"; + + /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). + public Guid? FillerAssetId { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position. + public IReadOnlyList BumperTemplates => _bumperTemplates; + + private Channel() { } + + public static Channel Create(string name, string slug, DateTimeOffset epochUtc) + { + var channel = new Channel + { + Id = Guid.NewGuid(), + Name = name, + Slug = slug, + IsEnabled = true, + EpochUtc = epochUtc, + BumpersEnabled = false, + BumperSelection = BumperSelection.WeightedRandom, + BumperFont = BumperFont.Sans, + UtcOffsetMinutes = DefaultUtcOffsetMinutes, + DayStartTime = DefaultDayStartTime, + CreatedAt = DateTimeOffset.UtcNow, + }; + // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл). + channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName)); + return channel; + } + + public void UpdateSettings( + string name, + bool isEnabled, + bool bumpersEnabled, + Guid? fillerAssetId + ) + { + Name = name; + IsEnabled = isEnabled; + BumpersEnabled = bumpersEnabled; + FillerAssetId = fillerAssetId; + } + + /// Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока. + public void UpdateBumperSettings(BumperFont font, BumperSelection selection) + { + BumperFont = font; + BumperSelection = selection; + } + + /// Добавить блок заставки в конец списка. Возвращает созданный блок. + public BumperTemplate AddBumperTemplate(string name) + { + var nextPosition = + _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1; + var template = BumperTemplate.Create(Id, nextPosition, name); + _bumperTemplates.Add(template); + return template; + } + + public BumperTemplate? FindBumperTemplate(Guid templateId) => + _bumperTemplates.FirstOrDefault(t => t.Id == templateId); + + /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false. + public bool RemoveBumperTemplate(Guid templateId) + { + var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId); + if (template is null || template.IsDefault) + return false; + _bumperTemplates.Remove(template); + return true; + } + + /// Привязать активный шаблон сетки. + public void SetTemplate(Guid? templateId) => TemplateId = templateId; + + /// + /// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение + /// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём. + /// + public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime) + { + Number = number is > 0 ? number : null; + UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60); + DayStartTime = dayStartTime; + } +} diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 23dfc5a..9d99c57 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -109,7 +109,7 @@ public class Show { if (!CanAddEpisode) throw new InvalidOperationException( - "Полнометражка (ShowKind.Single) может содержать только одну серию." + "Только сериал может содержать больше одной серии." ); var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1; @@ -127,7 +127,9 @@ public class Show return true; } - public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0; + /// Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно + /// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица. + public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; /// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null. public void ApplyMetadata( diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs new file mode 100644 index 0000000..9f00ba9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs @@ -0,0 +1,1368 @@ +// +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("20260726105315_DropDeadBumperSettings")] + partial class DropDeadBumperSettings + { + /// + 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("ChannelId") + .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("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + 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("ChannelId") + .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("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .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("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + 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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + 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("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("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("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("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("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("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + 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.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.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .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(); + }); + + 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.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.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + 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/20260726105315_DropDeadBumperSettings.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.cs new file mode 100644 index 0000000..8111e3a --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class DropDeadBumperSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет, + // и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам. + migrationBuilder.Sql( + """UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;""" + ); + + migrationBuilder.DropColumn( + name: "BumperEpisodeChangeChance", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperMinIntervalMinutes", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperShowChangeChance", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "NextBumperIndex", + table: "Channels"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BumperEpisodeChangeChance", + table: "Channels", + type: "double precision", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "BumperMinIntervalMinutes", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "BumperShowChangeChance", + table: "Channels", + type: "double precision", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "NextBumperIndex", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index ef20ec9..57f574e 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -318,21 +318,12 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); - b.Property("BumperEpisodeChangeChance") - .HasColumnType("double precision"); - b.Property("BumperFont") .HasColumnType("integer"); - b.Property("BumperMinIntervalMinutes") - .HasColumnType("integer"); - b.Property("BumperSelection") .HasColumnType("integer"); - b.Property("BumperShowChangeChance") - .HasColumnType("double precision"); - b.Property("BumpersEnabled") .HasColumnType("boolean"); @@ -356,9 +347,6 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); - b.Property("NextBumperIndex") - .HasColumnType("integer"); - b.Property("Number") .HasColumnType("integer"); diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs index 5ce3634..e265282 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs @@ -56,13 +56,7 @@ public class ChannelHandlersTests "c", true, true, - new BumperSettingsInput( - BumperFont.Sans, - 5, - BumperSelection.WeightedRandom, - 0.5, - 0.2 - ), + new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom), null ), CancellationToken.None @@ -72,8 +66,7 @@ public class ChannelHandlersTests await using var verify = fixture.New(); var stored = await verify.Channels.FindAsync(channel.Id); - Assert.Equal(0.5, stored!.BumperShowChangeChance); - Assert.Equal(0.2, stored.BumperEpisodeChangeChance); + Assert.Equal(BumperFont.Sans, stored!.BumperFont); Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection); } diff --git a/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs index 23de6fa..0712d73 100644 --- a/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs @@ -40,13 +40,7 @@ public class ValidatorTests public void UpdateChannelSettings_ChecksRanges() { var v = new UpdateChannelSettingsCommandValidator(); - var bumper = new BumperSettingsInput( - BumperFont.Sans, - 10, - BumperSelection.Rotation, - 0.5, - 0.5 - ); + var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom); var good = new UpdateChannelSettingsCommand( Guid.NewGuid(), @@ -59,9 +53,6 @@ public class ValidatorTests Assert.True(v.Validate(good).IsValid); Assert.False(v.Validate(good with { Name = "" }).IsValid); - Assert.False( - v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).IsValid - ); } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs index e841012..32dc603 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs @@ -20,9 +20,7 @@ public class ChannelTests Assert.True(channel.IsEnabled); Assert.Equal(Epoch, channel.EpochUtc); Assert.False(channel.BumpersEnabled); - Assert.Equal(BumperSelection.Rotation, channel.BumperSelection); - Assert.Equal(1.0, channel.BumperShowChangeChance); - Assert.Equal(1.0, channel.BumperEpisodeChangeChance); + Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection); Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes); Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime); @@ -46,32 +44,15 @@ public class ChannelTests Assert.Equal(filler, channel.FillerAssetId); } - [Theory] - [InlineData(-1, 2.0, -5.0, 1.0, 0.0)] - [InlineData(30, 0.3, 0.9, 0.3, 0.9)] - public void UpdateBumperSettings_ClampsChancesAndInterval( - int interval, - double show, - double episode, - double expectedShow, - double expectedEpisode - ) + [Fact] + public void UpdateBumperSettings_ChangesFontAndSelection() { var channel = NewChannel(); - channel.UpdateBumperSettings( - BumperFont.Serif, - interval, - BumperSelection.WeightedRandom, - show, - episode - ); + channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst); Assert.Equal(BumperFont.Serif, channel.BumperFont); - Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes); - Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection); - Assert.Equal(expectedShow, channel.BumperShowChangeChance); - Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance); + Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection); } [Fact] diff --git a/docs/tv-scheduler-tasks.md b/docs/tv-scheduler-tasks.md index 7734a77..73fb5a2 100644 --- a/docs/tv-scheduler-tasks.md +++ b/docs/tv-scheduler-tasks.md @@ -13,9 +13,10 @@ **Срез 1 закрыт целиком.** Канал вещает по сетке: библиотека с жанрами и коллекциями, группы, шаблон со слоями и слотами, генератор с трейсом, применение по кнопке, UI сетки. Старая ротация снесена. -**Срез 2 — бэкенд закрыт, UI нет.** Работают ролики, шаблоны стыков, раскладка врезок, рендер -заставок и API управления стыками; собрать канал с рекламой и заставками можно через HTTP, но не -из интерфейса. +**Срез 2 закрыт целиком.** Ролики со своим экраном и сборкой блоков, шаблоны стыков с редактором +цепочки, привязка стыков к слотам, предпросмотр без записи. Мёртвые настройки заставок +(`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут +в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации. **Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично. @@ -257,7 +258,7 @@ N новых позиций по фильтру». Новое значение `ShowKind`. Исключение таких шоу из обычного списка библиотеки и из запросов метаданных. -#### [ ] 2.2. Раздел «Ролики» +#### [x] 2.2. Раздел «Ролики» `Api` `Frontend` Отдельный экран (см. 6.7): плоский список с длительностями и превью, массовая загрузка, сборка @@ -289,13 +290,13 @@ N новых позиций по фильтру». `snapToMinutes` с отказом при превышении `maxDriftMinutes` (см. 4.3). Юнит-тесты на оба механизма и на их сочетание. -#### [ ] 2.7. Редактор стыка +#### [x] 2.7. Редактор стыка `Frontend` Горизонтальная цепочка с перетаскиванием элементов, линейка суммарной длительности, попап параметров (см. 6.3). -#### [ ] 2.8. Предпросмотр +#### [x] 2.8. Предпросмотр `Application` `Api` `Frontend` Прогон генератора на черновике правил **без записи и без продвижения курсоров**. Заставки — diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index cb829bc..9101949 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -19,9 +19,11 @@ import { } from './api' import { BumperCard } from './components/BumperCard' import { CollapsibleCard } from './components/CollapsibleCard' +import { JunctionsCard } from './components/JunctionsCard' import { LayerList, ScheduleGrid } from './components/ScheduleGrid' import { SchedulePreview } from './components/SchedulePreview' import { SettingsCard } from './components/SettingsCard' +import { TemplatePreview } from './components/TemplatePreview' import { SlotInspector, type SlotDraft } from './components/SlotInspector' export function ChannelDetail({ channelId }: { channelId: string }) { @@ -164,6 +166,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
+ {draft && ( - setDraft(null)} onChanged={invalidate} /> + setDraft(null)} + onChanged={invalidate} + /> )}
)} + + diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 1842e52..7f29a26 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -7,8 +7,13 @@ import type { ChannelDto, ChannelSummaryDto, CreatedIdResponse, + JunctionAmountMode, + JunctionConditions, + JunctionElementKind, + JunctionTemplateDto, LayerApplicability, ScheduleEntryDto, + SchedulePreviewDto, ScheduleTemplateDto, SlotDto, } from '@/shared/api/types' @@ -58,9 +63,17 @@ export function applyChannelTemplate(channelId: string) { }) } +/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */ +export function previewTemplate(channelId: string, days: number) { + const query = new URLSearchParams({ days: String(days) }) + return apiRequest( + `/admin/channels/${channelId}/template/preview?${query.toString()}`, + ) +} + export function updateTemplate( templateId: string, - body: { name: string; fallbackGroupId: string | null }, + body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null }, ) { return apiRequest(`/admin/templates/${templateId}`, { method: 'PUT', body }) } @@ -103,6 +116,70 @@ export function deleteSlot(slotId: string) { return apiRequest(`/admin/slots/${slotId}`, { method: 'DELETE' }) } +// ── Стыки канала ────────────────────────────────────────────────────────── + +export function listJunctions(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/junctions`) +} + +export function createJunction(channelId: string, name: string) { + return apiRequest(`/admin/channels/${channelId}/junctions`, { + method: 'POST', + body: { name }, + }) +} + +export function renameJunction(junctionId: string, name: string) { + return apiRequest(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } }) +} + +export function deleteJunction(junctionId: string) { + return apiRequest(`/admin/junctions/${junctionId}`, { method: 'DELETE' }) +} + +export function addJunctionElement(junctionId: string, kind: JunctionElementKind) { + return apiRequest(`/admin/junctions/${junctionId}/elements`, { + method: 'POST', + body: { kind }, + }) +} + +/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */ +export type JunctionElementBody = { + kind: JunctionElementKind + groupId: string | null + bumperTemplateId: string | null + amountMode: JunctionAmountMode + amountValue: number + isRequired: boolean + conditions: JunctionConditions | null +} + +export function updateJunctionElement( + junctionId: string, + elementId: string, + body: JunctionElementBody, +) { + return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { + method: 'PUT', + body, + }) +} + +export function removeJunctionElement(junctionId: string, elementId: string) { + return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { + method: 'DELETE', + }) +} + +/** Порядок врезок: не упомянутые остаются после перечисленных. */ +export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) { + return apiRequest(`/admin/junctions/${junctionId}/order`, { + method: 'PUT', + body: { elementIdsInOrder }, + }) +} + export type BumperTemplateStyleBody = { name: string backgroundColor: string diff --git a/frontend/src/features/admin/channels/components/BumperCard.tsx b/frontend/src/features/admin/channels/components/BumperCard.tsx index 6da414f..829c093 100644 --- a/frontend/src/features/admin/channels/components/BumperCard.tsx +++ b/frontend/src/features/admin/channels/components/BumperCard.tsx @@ -3,12 +3,10 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types' import { Button } from '@/shared/ui/button' -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 { addBumperTemplate, updateChannelSettings } from '../api' -import { clampChance } from '../lib/format' import { BumperTemplateEditor } from './BumperTemplateEditor' import { CollapsibleCard } from './CollapsibleCard' @@ -76,8 +74,8 @@ export function BumperCard({ - {/* Общие настройки */} -
+ {/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */} +
-
- - setField('minIntervalMinutes', Number(e.target.value))} - /> -
-
- - setField('showChangeChance', clampChance(e.target.value))} - /> - - {t('admin.channels.bumperShowChangeChanceHint')} - -
-
- - setField('episodeChangeChance', clampChance(e.target.value))} - /> - - {t('admin.channels.bumperEpisodeChangeChanceHint')} - -
+

{t('admin.channels.bumperConditionsHint')}

@@ -56,36 +55,3 @@ export function BumperPreviewPlayer({ ) } - -/** Мини-плеер одного превью: грузит HLS через hls.js с Bearer-токеном (admin-роут под JWT). */ -function PreviewVideo({ src }: { src: string }) { - const videoRef = useRef(null) - useEffect(() => { - const video = videoRef.current - if (!video) return - let hls: Hls | null = null - if (Hls.isSupported()) { - hls = new Hls({ - xhrSetup: (xhr) => { - const token = getAccessToken() - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) - }, - }) - hls.loadSource(src) - hls.attachMedia(video) - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = src - } - return () => { - hls?.destroy() - } - }, [src]) - return ( -
+ ) +} + +function Programme({ preview }: { preview: SchedulePreviewDto }) { + const { t } = useTranslation() + const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind)) + + if (items.length === 0) + return

{t('admin.channels.noSchedule')}

+ + return ( +
    + {items.map((item, index) => ( +
  • + + {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} + + + {item.title ?? t(`admin.channels.previewKinds.${item.kind}`)} + + {item.slotTitle && ( + {item.slotTitle} + )} +
  • + ))} +
+ ) +} + +/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */ +function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] { + const buckets = new Map() + for (const item of preview.items) { + if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue + const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes) + const hour = Date.UTC( + start.getUTCFullYear(), + start.getUTCMonth(), + start.getUTCDate(), + start.getUTCHours(), + ) + const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000 + buckets.set(hour, (buckets.get(hour) ?? 0) + minutes) + } + return [...buckets.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([hour, minutes]) => ({ hour: new Date(hour), minutes })) +} + +function Tape({ preview }: { preview: SchedulePreviewDto }) { + const { t } = useTranslation() + const load = useMemo(() => loadByHour(preview), [preview]) + const peak = Math.max(1, ...load.map((l) => l.minutes)) + + if (preview.items.length === 0) + return

{t('admin.channels.noSchedule')}

+ + return ( +
+ {load.length > 0 && ( +
+ + {t('admin.channels.previewLoad', { peak: Math.round(peak) })} + +
+ {load.map((bucket) => ( +
+ ))} +
+
+ )} + +
    + {preview.items.map((item, index) => ( + + ))} +
+
+ ) +} + +function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) { + const { t } = useTranslation() + const minutes = + (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000 + + return ( +
  • + + {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} + + + + {t(`admin.channels.previewKinds.${item.kind}`)} + + {item.title ?? ''} +
  • + ) +} diff --git a/frontend/src/features/admin/channels/lib/format.ts b/frontend/src/features/admin/channels/lib/format.ts index f4c14b4..e6a3515 100644 --- a/frontend/src/features/admin/channels/lib/format.ts +++ b/frontend/src/features/admin/channels/lib/format.ts @@ -18,16 +18,23 @@ export function formatMinute(minute: number | null) { return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` } +/** + * Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же: + * локальное время админа тут только запутало бы. + */ +export function channelTime(iso: string, utcOffsetMinutes: number): Date { + return new Date(new Date(iso).getTime() + utcOffsetMinutes * 60_000) +} + +/** «HH:MM» во времени канала. */ +export function formatChannelTime(iso: string, utcOffsetMinutes: number): string { + const d = channelTime(iso, utcOffsetMinutes) + return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}` +} + /** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */ export function cssColor(value: string): string { const v = value.trim() if (v.startsWith('0x')) return `#${v.slice(2)}` return v } - -/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */ -export function clampChance(value: string): number { - const n = Number(value) - if (Number.isNaN(n)) return 0 - return Math.min(1, Math.max(0, n)) -} diff --git a/frontend/src/features/admin/interstitials/BlockBuilder.tsx b/frontend/src/features/admin/interstitials/BlockBuilder.tsx new file mode 100644 index 0000000..2e0ed35 --- /dev/null +++ b/frontend/src/features/admin/interstitials/BlockBuilder.tsx @@ -0,0 +1,145 @@ +import { useMutation } from '@tanstack/react-query' +import { GripVertical, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { addCollectionShow, createCollection } from '@/features/admin/collections/api' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { cn } from '@/shared/lib/cn' +import { type ClipDragItem, readDragItem } from './dnd' +import { formatClock } from './format' + +/** + * Сборка рекламного блока: ролики перетаскиваются в упорядоченный список, под ним — суммарная + * длительность. Сохраняется обычной коллекцией — отдельной сущности «блок» в модели нет (см. 3.7). + */ +export function BlockBuilder({ + onSaved, + onError, +}: { + onSaved: () => void + onError: (error: unknown) => void +}) { + const { t } = useTranslation() + const [name, setName] = useState('') + const [items, setItems] = useState([]) + const [over, setOver] = useState(false) + const [dragged, setDragged] = useState(null) + + const total = items.reduce((sum, item) => sum + item.seconds, 0) + + const saveMutation = useMutation({ + mutationFn: async () => { + const { id } = await createCollection({ name: name.trim() }) + // Порядок задаётся порядком добавления: коллекция ставит позицию в конец. + for (const item of items) await addCollectionShow(id, item.id) + }, + onSuccess: () => { + setName('') + setItems([]) + onSaved() + }, + onError, + }) + + const drop = (event: React.DragEvent) => { + event.preventDefault() + setOver(false) + const item = readDragItem(event) + // Блок из блоков собрать нельзя: коллекция хранит шоу, а не вложенные коллекции. + if (!item || item.kind !== 'clip') return + setItems((current) => [...current, item]) + } + + /** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */ + const reorder = (target: number) => { + if (dragged === null || dragged === target) return + setItems((current) => { + const next = [...current] + const [moved] = next.splice(dragged, 1) + next.splice(target, 0, moved) + return next + }) + setDragged(null) + } + + return ( +
    +

    + {t('admin.interstitials.blockBuilder')} +

    + +
    { + e.preventDefault() + setOver(true) + }} + onDragLeave={() => setOver(false)} + onDrop={drop} + className={cn( + 'crt-panel flex min-h-32 flex-col rounded-md border border-dashed border-border', + over && 'border-primary bg-primary/5', + )} + > + {items.length === 0 ? ( +

    + {t('admin.interstitials.dropHint')} +

    + ) : ( +
      + {items.map((item, index) => ( +
    • setDragged(index)} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.stopPropagation() + reorder(index) + }} + className="flex items-center gap-2 px-3 py-1.5" + > + + {index + 1} + + {item.name} + + + {formatClock(item.seconds)} + + +
    • + ))} +
    + )} +
    + +
    + {t('admin.interstitials.blockTotal')} + {formatClock(total)} +
    + +
    + setName(e.target.value)} + /> + +
    +
    + ) +} diff --git a/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx b/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx new file mode 100644 index 0000000..4ad1fa1 --- /dev/null +++ b/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx @@ -0,0 +1,132 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { cn } from '@/shared/lib/cn' +import { readDragItem } from './dnd' +import { formatClock } from './format' + +/** + * Группы роликов собираются здесь же, а не в общем редакторе групп (см. 6.7): в выбранную группу + * перетаскиваются и отдельные ролики, и готовые блоки — стык умеет и то и другое. + */ +export function ClipGroupPanel({ onError }: { onError: (error: unknown) => void }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [selected, setSelected] = useState('') + const [newName, setNewName] = useState('') + const [over, setOver] = useState(false) + + const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] }) + } + + const createMutation = useMutation({ + mutationFn: () => createGroup({ name: newName.trim() }), + onSuccess: ({ id }) => { + setNewName('') + setSelected(id) + invalidate() + }, + onError, + }) + + const addMutation = useMutation({ + mutationFn: (element: { elementKind: 'Show' | 'Collection'; elementId: string }) => + addGroupElements(selected, [element]), + onSuccess: invalidate, + onError, + }) + + const group = groups?.find((g) => g.id === selected) + + const drop = (event: React.DragEvent) => { + event.preventDefault() + setOver(false) + const item = readDragItem(event) + if (!item || !selected) return + addMutation.mutate({ + elementKind: item.kind === 'block' ? 'Collection' : 'Show', + elementId: item.id, + }) + } + + return ( +
    +

    + {t('admin.interstitials.groups')} +

    + + + +
    { + e.preventDefault() + setOver(true) + }} + onDragLeave={() => setOver(false)} + onDrop={drop} + className={cn( + 'crt-panel flex min-h-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border px-3 py-4 text-center text-xs', + over && selected && 'border-primary bg-primary/5', + )} + > + {selected ? ( + <> + {t('admin.interstitials.dropToGroup')} + {group && ( + + {t('admin.groups.items')}: {group.itemCount} ·{' '} + {formatClock(group.totalDurationSeconds)} + + )} + {group && ( + + {t('admin.interstitials.openGroup')} + + )} + + ) : ( + {t('admin.interstitials.pickGroupFirst')} + )} +
    + +
    + setNewName(e.target.value)} + /> + +
    +
    + ) +} diff --git a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx new file mode 100644 index 0000000..169d4a4 --- /dev/null +++ b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx @@ -0,0 +1,282 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { GripVertical, Play, Trash2, Upload } from 'lucide-react' +import { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { deleteCollection } from '@/features/admin/collections/api' +import { useUploadStore } from '@/features/admin/media/upload-store' +import { deleteShow, renameShow } from '@/features/admin/shows/api' +import { HttpError } from '@/shared/api/client' +import type { InterstitialDto } from '@/shared/api/types' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { HlsVideo } from '@/shared/ui/hls-video' +import { Input } from '@/shared/ui/input' +import { toast } from '@/shared/ui/toast-store' +import { BlockBuilder } from './BlockBuilder' +import { ClipGroupPanel } from './ClipGroupPanel' +import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api' +import { setDragItem } from './dnd' +import { formatClock } from './format' + +/** + * Экран роликов (см. 6.7). Под капотом это `Show(Kind = Interstitial)` и коллекции, но сценарий + * другой: массовая загрузка, длительности вместо метаданных и сборка блока перетаскиванием. + */ +export function InterstitialsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const enqueue = useUploadStore((s) => s.enqueue) + const fileInput = useRef(null) + const [query, setQuery] = useState('') + const [preview, setPreview] = useState(null) + const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null) + + const { data: clips, isLoading } = useQuery({ + queryKey: ['admin', 'interstitials'], + queryFn: listInterstitials, + }) + const { data: blocks } = useQuery({ + queryKey: ['admin', 'interstitials', 'blocks'], + queryFn: listInterstitialBlocks, + }) + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] }) + } + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const renameMutation = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name), + onSuccess: () => { + setRenaming(null) + invalidate() + }, + onError, + }) + const deleteClipMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError }) + const deleteBlockMutation = useMutation({ + mutationFn: deleteCollection, + onSuccess: invalidate, + onError, + }) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + const all = clips ?? [] + return q ? all.filter((c) => c.name.toLowerCase().includes(q)) : all + }, [clips, query]) + + const pickFiles = (files: FileList | null) => { + if (!files || files.length === 0) return + void enqueue(Array.from(files), { interstitial: true }) + } + + return ( +
    +
    +

    {t('admin.interstitials.title')}

    + + { + pickFiles(e.target.files) + e.target.value = '' + }} + /> +
    + +

    {t('admin.interstitials.hint')}

    + +
    +
    + setQuery(e.target.value)} + /> + +
    + + + + + + + + + + + {isLoading && ( + + + + )} + {!isLoading && filtered.length === 0 && ( + + + + )} + {filtered.map((clip) => ( + + setDragItem(e, { + kind: 'clip', + id: clip.id, + name: clip.name, + seconds: clip.durationSeconds ?? 0, + }) + } + className="border-b border-border last:border-0" + > + + + + + + ))} + +
    {t('admin.interstitials.name')}{t('admin.interstitials.duration')}{t('admin.media.status')}{t('common.actions')}
    + {t('common.loading')} +
    + {t('admin.interstitials.empty')} +
    +
    + + {renaming?.id === clip.id ? ( + setRenaming({ id: clip.id, name: e.target.value })} + onBlur={() => + renaming.name.trim() && renaming.name !== clip.name + ? renameMutation.mutate({ id: clip.id, name: renaming.name.trim() }) + : setRenaming(null) + } + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Escape') setRenaming(null) + }} + /> + ) : ( + + )} +
    +
    + {formatClock(clip.durationSeconds)} + + + {clip.assetStatus + ? t(`admin.media.statuses.${clip.assetStatus}`) + : t('admin.interstitials.noAsset')} + + +
    + + +
    +
    +
    + +
    +

    + {t('admin.interstitials.blocks')} +

    +
    + {(blocks ?? []).length === 0 ? ( +

    + {t('admin.interstitials.noBlocks')} +

    + ) : ( +
      + {(blocks ?? []).map((block) => ( +
    • + setDragItem(e, { + kind: 'block', + id: block.id, + name: block.name, + seconds: block.durationSeconds, + }) + } + className="flex items-center gap-2 px-4 py-2" + > + + + {block.name} + + + {t('admin.interstitials.clipsCount', { count: block.itemCount })} + + + {formatClock(block.durationSeconds)} + + +
    • + ))} +
    + )} +
    +
    +
    + +
    + + +
    +
    + + !open && setPreview(null)}> + + + {preview?.name} + + {preview?.mediaAssetId && } + + +
    + ) +} diff --git a/frontend/src/features/admin/interstitials/api.ts b/frontend/src/features/admin/interstitials/api.ts new file mode 100644 index 0000000..c9a6638 --- /dev/null +++ b/frontend/src/features/admin/interstitials/api.ts @@ -0,0 +1,23 @@ +import { apiRequest } from '@/shared/api/client' +import type { InterstitialBlockDto, InterstitialDto } from '@/shared/api/types' + +export function listInterstitials() { + return apiRequest('/admin/interstitials') +} + +export function listInterstitialBlocks() { + return apiRequest('/admin/interstitials/blocks') +} + +/** Превращает загруженные файлы в ролики. Уже привязанные к шоу ассеты сервер пропускает. */ +export function importInterstitials(mediaAssetIds: string[]) { + return apiRequest<{ imported: number }>('/admin/interstitials/import', { + method: 'POST', + body: { mediaAssetIds }, + }) +} + +/** Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js. */ +export function mediaPreviewUrl(assetId: string) { + return `/api/admin/media/${assetId}/preview/index.m3u8` +} diff --git a/frontend/src/features/admin/interstitials/dnd.ts b/frontend/src/features/admin/interstitials/dnd.ts new file mode 100644 index 0000000..02377ce --- /dev/null +++ b/frontend/src/features/admin/interstitials/dnd.ts @@ -0,0 +1,27 @@ +/** + * Перетаскивание на экране роликов. В сборку блока едут только ролики, в группу — и ролики, + * и готовые блоки, поэтому в переносимых данных лежит вид элемента, а не только идентификатор. + */ +export type ClipDragItem = { + kind: 'clip' | 'block' + id: string + name: string + seconds: number +} + +const MIME = 'application/x-telewave-clip' + +export function setDragItem(event: React.DragEvent, item: ClipDragItem) { + event.dataTransfer.setData(MIME, JSON.stringify(item)) + event.dataTransfer.effectAllowed = 'copy' +} + +export function readDragItem(event: React.DragEvent): ClipDragItem | null { + const raw = event.dataTransfer.getData(MIME) + if (!raw) return null + try { + return JSON.parse(raw) as ClipDragItem + } catch { + return null + } +} diff --git a/frontend/src/features/admin/interstitials/format.ts b/frontend/src/features/admin/interstitials/format.ts new file mode 100644 index 0000000..69f0d60 --- /dev/null +++ b/frontend/src/features/admin/interstitials/format.ts @@ -0,0 +1,13 @@ +/** + * Длительность ролика — часами тут мерить нечего: «0:20», «2:40», «1:02:03». Прочерк вместо нуля, + * потому что «0:00» читается как ролик нулевой длины, а не как «ещё не обработан». + */ +export function formatClock(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined || seconds <= 0) return '—' + const total = Math.round(seconds) + const s = total % 60 + const m = Math.floor(total / 60) % 60 + const h = Math.floor(total / 3600) + const mm = h > 0 ? String(m).padStart(2, '0') : String(m) + return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}` +} diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts index 0dc9508..2321399 100644 --- a/frontend/src/features/admin/media/upload-store.ts +++ b/frontend/src/features/admin/media/upload-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { HttpError, refreshAccessToken } from '@/shared/api/client' import { queryClient } from '@/shared/api/query-client' +import { importInterstitials } from '@/features/admin/interstitials/api' import { addEpisode } from '@/features/admin/shows/api' import { toast } from '@/shared/ui/toast-store' import { listMedia, uploadMedia } from './api' @@ -33,11 +34,13 @@ type UploadStore = { export type EnqueueOptions = { showId?: string resolveShowId?: (file: File) => string | undefined + /** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */ + interstitial?: boolean } // Очередь и флаг живут вне React — загрузка продолжается при любой навигации. let counter = 0 -type Job = { id: string; file: File; showId?: string } +type Job = { id: string; file: File; showId?: string; interstitial?: boolean } const queue: Job[] = [] const controllers = new Map() const failed = new Map() // упавшие — для ручного повтора @@ -112,6 +115,14 @@ async function pump() { } catch { toast.error(`${job.file.name}: не удалось добавить в шоу`) } + } else if (job.interstitial) { + // Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается. + try { + await importInterstitials([created.id]) + void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] }) + } catch { + toast.error(`${job.file.name}: не удалось завести ролик`) + } } } else if (isAbort(lastError) || controller.signal.aborted) { // Отмена — тихо: элемент уже убран из списка. @@ -157,7 +168,7 @@ export const useUploadStore = create((set) => ({ const newItems: UploadItem[] = toAdd.map((file) => { const id = `u${++counter}` const showId = options?.resolveShowId?.(file) ?? options?.showId - queue.push({ id, file, showId }) + queue.push({ id, file, showId, interstitial: options?.interstitial }) return { id, name: file.name, percent: 0, status: 'queued' } }) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 321296d..b7f19da 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as AdminCollectionsRouteImport } from './routes/admin/collections import { Route as AdminGalleryRouteImport } from './routes/admin/gallery' import { Route as AdminGenresRouteImport } from './routes/admin/genres' import { Route as AdminGroupsRouteImport } from './routes/admin/groups' +import { Route as AdminInterstitialsRouteImport } from './routes/admin/interstitials' import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance' import { Route as AdminMediaRouteImport } from './routes/admin/media' import { Route as AdminRolesRouteImport } from './routes/admin/roles' @@ -96,6 +97,11 @@ const AdminGroupsRoute = AdminGroupsRouteImport.update({ path: '/groups', getParentRoute: () => AdminRoute, } as any) +const AdminInterstitialsRoute = AdminInterstitialsRouteImport.update({ + id: '/interstitials', + path: '/interstitials', + getParentRoute: () => AdminRoute, +} as any) const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({ id: '/maintenance', path: '/maintenance', @@ -180,6 +186,7 @@ export interface FileRoutesByFullPath { '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/groups': typeof AdminGroupsRouteWithChildren + '/admin/interstitials': typeof AdminInterstitialsRoute '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute @@ -204,6 +211,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRoute '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute + '/admin/interstitials': typeof AdminInterstitialsRoute '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute @@ -232,6 +240,7 @@ export interface FileRoutesById { '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/groups': typeof AdminGroupsRouteWithChildren + '/admin/interstitials': typeof AdminInterstitialsRoute '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute @@ -262,6 +271,7 @@ export interface FileRouteTypes { | '/admin/gallery' | '/admin/genres' | '/admin/groups' + | '/admin/interstitials' | '/admin/maintenance' | '/admin/media' | '/admin/roles' @@ -286,6 +296,7 @@ export interface FileRouteTypes { | '/settings' | '/admin/gallery' | '/admin/genres' + | '/admin/interstitials' | '/admin/maintenance' | '/admin/media' | '/admin/roles' @@ -313,6 +324,7 @@ export interface FileRouteTypes { | '/admin/gallery' | '/admin/genres' | '/admin/groups' + | '/admin/interstitials' | '/admin/maintenance' | '/admin/media' | '/admin/roles' @@ -425,6 +437,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminGroupsRouteImport parentRoute: typeof AdminRoute } + '/admin/interstitials': { + id: '/admin/interstitials' + path: '/interstitials' + fullPath: '/admin/interstitials' + preLoaderRoute: typeof AdminInterstitialsRouteImport + parentRoute: typeof AdminRoute + } '/admin/maintenance': { id: '/admin/maintenance' path: '/maintenance' @@ -587,6 +606,7 @@ interface AdminRouteChildren { AdminGalleryRoute: typeof AdminGalleryRoute AdminGenresRoute: typeof AdminGenresRoute AdminGroupsRoute: typeof AdminGroupsRouteWithChildren + AdminInterstitialsRoute: typeof AdminInterstitialsRoute AdminMaintenanceRoute: typeof AdminMaintenanceRoute AdminMediaRoute: typeof AdminMediaRoute AdminRolesRoute: typeof AdminRolesRoute @@ -602,6 +622,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminGalleryRoute: AdminGalleryRoute, AdminGenresRoute: AdminGenresRoute, AdminGroupsRoute: AdminGroupsRouteWithChildren, + AdminInterstitialsRoute: AdminInterstitialsRoute, AdminMaintenanceRoute: AdminMaintenanceRoute, AdminMediaRoute: AdminMediaRoute, AdminRolesRoute: AdminRolesRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index b0ec9dc..260a237 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -29,6 +29,13 @@ function AdminLayout() { > {t('admin.shows.title')} + + {t('admin.interstitials.title')} + (null) + + useEffect(() => { + const video = videoRef.current + if (!video) return + let hls: Hls | null = null + if (Hls.isSupported()) { + hls = new Hls({ + xhrSetup: (xhr) => { + const token = getAccessToken() + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) + }, + }) + hls.loadSource(src) + hls.attachMedia(video) + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = src + } + return () => { + hls?.destroy() + } + }, [src]) + + return ( +