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 ( +