From 19fa23b6195688998e8822ee6e9e4c26017bc2cc Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 00:56:04 +0300 Subject: [PATCH] Refactor various components to improve code clarity and maintainability. Update ListUsersQueryHandler to utilize UserListFilter for parameter handling. Refactor BumperSpecFactory and related classes to encapsulate input parameters into dedicated records, enhancing readability. Adjust MediaAsset and Slot classes to streamline content updates with new content models. Improve BumperRenderBackgroundService and MediaProcessingBackgroundService to use MediaReadyInfo for asset readiness, ensuring consistent parameter management across the application. --- backend/src/TeleWave.Api/Program.cs | 6 +- .../Users/ListUsers/ListUsersQueryHandler.cs | 16 ++-- .../Broadcast/Bumpers/BumperSpecFactory.cs | 16 ++-- .../Broadcast/Bumpers/BumperSpecInputs.cs | 15 ++++ .../Broadcast/Bumpers/BumperSpecLoader.cs | 12 +-- .../RenderBumperPreviewCommandHandler.cs | 63 +++----------- .../UpdateBumperTextVariantCommandHandler.cs | 13 +-- .../Common/Interfaces/IIdentityService.cs | 8 +- .../Common/Interfaces/UserListFilter.cs | 16 ++++ .../ListInterstitialBlocksQueryHandler.cs | 11 ++- .../ListMedia/ListMediaAssetsQueryHandler.cs | 4 + .../Planning/GridScheduleGenerator.cs | 16 ++-- .../CopyTemplateCommandHandler.cs | 22 ++--- .../Programming/Templates/SlotWriter.cs | 22 ++--- .../Broadcast/BumperTextContent.cs | 14 +++ .../Broadcast/BumperTextVariant.cs | 21 ++--- .../Broadcast/ScheduleEntry.cs | 16 ++-- .../Broadcast/ScheduleEntryOrigin.cs | 15 ++++ .../src/TeleWave.Domain/Media/MediaAsset.cs | 27 ++---- .../TeleWave.Domain/Media/MediaReadyInfo.cs | 17 ++++ .../src/TeleWave.Domain/Programming/Slot.cs | 33 +++---- .../Programming/SlotContent.cs | 22 +++++ .../Identity/IdentityService.cs | 10 +-- .../Media/BumperRenderBackgroundService.cs | 18 ++-- .../Media/FfmpegBumperRenderer.cs | 84 +++++++++--------- .../Media/MediaProcessingBackgroundService.cs | 18 ++-- .../Media/MediaStatsTests.cs | 4 +- .../Broadcast/BumperTextVariantTests.cs | 9 +- .../Media/MediaAssetTests.cs | 26 +++--- .../Programming/ScheduleTemplateTests.cs | 36 ++++---- .../GridScheduleGeneratorIntegrationTests.cs | 36 ++++---- .../TemplateOperationsIntegrationTests.cs | 20 +++-- .../TransactionIntegrationTests.cs | 4 +- .../features/admin/channels/ChannelDetail.tsx | 6 +- .../channels/components/JunctionsCard.tsx | 25 ++++-- .../channels/components/SchedulePreview.tsx | 78 ++++++++++------- .../channels/components/TemplatePreview.tsx | 17 ++-- .../features/admin/images/ImageGallery.tsx | 14 +-- .../features/admin/interstitials/format.ts | 3 +- .../features/admin/media/UploadSnackbar.tsx | 10 +-- .../src/features/admin/shows/ShowDetail.tsx | 14 ++- .../features/admin/shows/ShowMetadataCard.tsx | 33 ++++--- frontend/src/features/auth/LoginForm.tsx | 10 +-- frontend/src/features/streaming/AirPage.tsx | 86 +++++++++++-------- frontend/src/features/streaming/api.ts | 3 +- frontend/src/shared/lib/table-sort.ts | 4 +- frontend/src/shared/ui/sortable.tsx | 6 +- 47 files changed, 556 insertions(+), 423 deletions(-) create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/UserListFilter.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ScheduleEntryOrigin.cs create mode 100644 backend/src/TeleWave.Domain/Media/MediaReadyInfo.cs create mode 100644 backend/src/TeleWave.Domain/Programming/SlotContent.cs diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index ce1196b..8e15967 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -32,8 +32,8 @@ builder.Services.AddSerilog( // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно // перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя -// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback; -// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). +// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback, +// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; @@ -143,6 +143,6 @@ app.UseDefaultFiles(); app.UseStaticFiles(); app.MapFallbackToFile("index.html"); -// Раньше здесь объявлялся `public partial class Program;` — чтобы WebApplicationTestFactory видела +// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела // сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним. await app.RunAsync(); diff --git a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs index 34bdd43..f1452d4 100644 --- a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs +++ b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs @@ -12,13 +12,15 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService) CancellationToken cancellationToken ) => identityService.ListUsersAsync( - query.Page, - query.PageSize, - query.Search, - query.RoleId, - query.IsBlocked, - query.Sort, - query.Desc, + new UserListFilter( + query.Page, + query.PageSize, + query.Search, + query.RoleId, + query.IsBlocked, + query.Sort, + query.Desc + ), cancellationToken ); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs index 8a229f8..b8078c2 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs @@ -15,11 +15,7 @@ public static class BumperSpecFactory BumperTemplate template, BumperTextVariant variant, int alignedDurationSeconds, - string fromName, - string toName, - string? audioPath, - string? posterAbsolutePath, - string? backgroundAbsolutePath + BumperSpecInputs inputs ) { var free = variant.Kind == BumperTextKind.Free; @@ -33,12 +29,12 @@ public static class BumperSpecFactory template.TextColor, font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans, free ? "" : variant.NowLabel, - free ? "" : fromName, + free ? "" : inputs.FromName, free ? "" : variant.NextLabel, - free ? "" : toName, - backgroundAbsolutePath, - audioPath, - posterAbsolutePath, + free ? "" : inputs.ToName, + inputs.BackgroundAbsolutePath, + inputs.AudioPath, + inputs.PosterAbsolutePath, free, variant.Line1, variant.Line2 diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs new file mode 100644 index 0000000..9b7fdbc --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs @@ -0,0 +1,15 @@ +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их +/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а +/// только раскладывает их по спецификации. +/// +public sealed record BumperSpecInputs( + string FromName, + string ToName, + string? AudioPath = null, + /// Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно. + string? PosterAbsolutePath = null, + string? BackgroundAbsolutePath = null +); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs index 0ce8159..2b3a80b 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs @@ -72,11 +72,13 @@ public sealed class BumperSpecLoader( template, variant, aligned, - names.GetValueOrDefault(cache.FromShowId, "…"), - names.GetValueOrDefault(cache.ToShowId, "…"), - bumperStorage.AudioPath(template.Id, template.AudioExtension), - posterPath, - bgPath + new BumperSpecInputs( + names.GetValueOrDefault(cache.FromShowId, "…"), + names.GetValueOrDefault(cache.ToShowId, "…"), + bumperStorage.AudioPath(template.Id, template.AudioExtension), + posterPath, + bgPath + ) ); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index f5dc5c9..fd06250 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -43,9 +43,6 @@ public sealed class RenderBumperPreviewCommandHandler( return Result.Failure(ChannelErrors.BumperTemplateNotFound); var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); - var fontFile = - channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; - var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken); var seconds = template.AudioDurationSeconds is { } d and > 0 ? d @@ -55,18 +52,25 @@ public sealed class RenderBumperPreviewCommandHandler( ); var audioPath = storage.AudioPath(template.Id, template.AudioExtension); + // Постер зависит от конкретного «следующего» шоу — в превью его не подставляем. + var inputs = new BumperSpecInputs( + fromName, + toName, + audioPath, + PosterAbsolutePath: null, + backgroundPath + ); + // Рендерим каждый подблок в свой ассет-превью (id по подблоку). foreach (var variant in template.Variants.OrderBy(v => v.Position)) { - var spec = BuildSpec( - variant, + var spec = BumperSpecFactory.Build( + _bumper, + channel.BumperFont, template, + variant, aligned, - fontFile, - backgroundPath, - audioPath, - fromName, - toName + inputs ); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); } @@ -92,45 +96,6 @@ public sealed class RenderBumperPreviewCommandHandler( return extension is null ? null : imageStore.ResolvePath(imageId, extension); } - /// - /// Спецификация рендера одного подблока. В режиме подписи и - /// названия шоу гасятся: там на экране произвольные строки, а не «Сейчас/Далее». - /// - private BumperRenderSpec BuildSpec( - BumperTextVariant variant, - BumperTemplate template, - int alignedSeconds, - string fontFile, - string? backgroundPath, - string? audioPath, - string fromName, - string toName - ) - { - var free = variant.Kind == BumperTextKind.Free; - return new BumperRenderSpec( - alignedSeconds, - _bumper.Width, - _bumper.Height, - template.BackgroundColor, - template.BackgroundColor2, - template.AccentColor, - template.TextColor, - fontFile, - free ? "" : variant.NowLabel, - free ? "" : fromName, - free ? "" : variant.NextLabel, - free ? "" : toName, - backgroundPath, - audioPath, - // Постер зависит от конкретного «следующего» шоу — в превью не подставляем. - null, - free, - variant.Line1, - variant.Line2 - ); - } - /// /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала: /// так превью показывает реальные названия этого канала, а не случайные из библиотеки. diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs index 465febd..90ef437 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Broadcast.Bumpers; @@ -31,11 +32,13 @@ public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContex variant.Update( command.Name.Trim(), - command.Kind, - command.NowLabel, - command.NextLabel, - command.Line1, - command.Line2, + new BumperTextContent( + command.Kind, + command.NowLabel, + command.NextLabel, + command.Line1, + command.Line2 + ), command.Trigger, command.Weight ); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs index 486d5ce..c1b7d0a 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs @@ -57,13 +57,7 @@ public interface IIdentityService Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken); Task> ListUsersAsync( - int page, - int pageSize, - string? search, - Guid? roleId, - bool? isBlocked, - string? sort, - bool desc, + UserListFilter filter, CancellationToken cancellationToken ); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/UserListFilter.cs b/backend/src/TeleWave.Application/Common/Interfaces/UserListFilter.cs new file mode 100644 index 0000000..4e649fa --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/UserListFilter.cs @@ -0,0 +1,16 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Параметры выборки пользователей: страница, фильтры и сортировка. Отдельный тип, а не запрос +/// CQRS: список пользователей живёт в Identity (вне IAppDbContext), и порт не должен зависеть +/// от конкретной фичи. +/// +public sealed record UserListFilter( + int Page, + int PageSize, + string? Search, + Guid? RoleId, + bool? IsBlocked, + string? Sort = null, + bool Desc = false +); diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs index 002d35b..c89d3e3 100644 --- a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs @@ -39,16 +39,19 @@ public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext) .Select(a => new { a.Id, a.Duration }) .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken); + // Длительность шоу-ролика считаем один раз: одно и то же шоу встречается в нескольких блоках. + var clipSeconds = clips.ToDictionary( + pair => pair.Key, + pair => pair.Value.Sum((Guid assetId) => durations.GetValueOrDefault(assetId, 0d)) + ); + 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) - ) + c.Items.Sum(i => clipSeconds[i.ShowId]) )) .ToList(); } diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs index 1216837..1deb369 100644 --- a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs @@ -25,7 +25,11 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext) // ToLower().Contains переводится в LIKE lower(...) — регистронезависимо и без привязки к // Npgsql-специфичному ILike (Application не ссылается на провайдер). var term = query.Search.Trim().ToLower(); + // CA1862 (Contains со StringComparison) здесь неприменим: это дерево выражений EF, а + // перегрузку с StringComparison провайдер в SQL не переводит — будет исключение в рантайме. +#pragma warning disable CA1862 q = q.Where(x => x.OriginalFileName.ToLower().Contains(term)); +#pragma warning restore CA1862 } var total = await q.CountAsync(cancellationToken); diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index f705af0..9159743 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -142,13 +142,15 @@ public sealed class GridScheduleGenerator( ToEntryKind(item.Kind), item.StartsAtUtc, item.EndsAtUtc, - item.ShowId, - item.UnitIndex, - item.SlotId, - item.Trace is null - ? null - : JsonSerializer.Serialize(item.Trace, TraceJsonOptions), - item.CollectionId + new ScheduleEntryOrigin( + item.ShowId, + item.UnitIndex, + item.SlotId, + item.Trace is null + ? null + : JsonSerializer.Serialize(item.Trace, TraceJsonOptions), + item.CollectionId + ) ) ); added++; diff --git a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs index 21d54d7..4c6e385 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs @@ -208,16 +208,18 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) slot.SnapToMinutes ); copySlot.UpdateContent( - slot.Title, - slot.SlotKind, - slot.GroupId, - slot.StrategyJson, - slot.RepeatSourceJson, - slot.BlockMode, - slot.BlockValue, - slot.OverflowPolicy, - Map(slot.JunctionBetweenId, junctionMap), - Map(slot.JunctionAfterId, junctionMap) + new SlotContent( + slot.Title, + slot.SlotKind, + slot.GroupId, + slot.StrategyJson, + slot.RepeatSourceJson, + slot.BlockMode, + slot.BlockValue, + slot.OverflowPolicy, + Map(slot.JunctionBetweenId, junctionMap), + Map(slot.JunctionAfterId, junctionMap) + ) ); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs index dc7be6d..ba291ca 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs @@ -66,16 +66,18 @@ public sealed class SlotWriter(IAppDbContext dbContext) input.SnapToMinutes ); target.UpdateContent( - input.Title, - input.SlotKind, - input.GroupId, - input.Strategy?.ToJson(), - input.RepeatSource?.ToJson(), - input.BlockMode, - input.BlockValue, - input.OverflowPolicy, - input.JunctionBetweenId, - input.JunctionAfterId + new SlotContent( + input.Title, + input.SlotKind, + input.GroupId, + input.Strategy?.ToJson(), + input.RepeatSource?.ToJson(), + input.BlockMode, + input.BlockValue, + input.OverflowPolicy, + input.JunctionBetweenId, + input.JunctionAfterId + ) ); return Result.Success(); diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs new file mode 100644 index 0000000..59ce1a3 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Текстовое наполнение подблока заставки. Наборы полей взаимоисключающие: при +/// работают подписи, при — +/// произвольные строки; неиспользуемые просто хранятся, чтобы переключение режима не теряло ввод. +/// +public sealed record BumperTextContent( + BumperTextKind Kind, + string NowLabel, + string NextLabel, + string Line1, + string Line2 +); diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs index ee8503d..4fd5dc2 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs @@ -58,23 +58,14 @@ public class BumperTextVariant CreatedAt = DateTimeOffset.UtcNow, }; - public void Update( - string name, - BumperTextKind kind, - string nowLabel, - string nextLabel, - string line1, - string line2, - BumperTrigger trigger, - int weight - ) + public void Update(string name, BumperTextContent text, BumperTrigger trigger, int weight) { Name = name; - Kind = kind; - NowLabel = nowLabel; - NextLabel = nextLabel; - Line1 = line1; - Line2 = line2; + Kind = text.Kind; + NowLabel = text.NowLabel; + NextLabel = text.NextLabel; + Line1 = text.Line1; + Line2 = text.Line2; Trigger = trigger; Weight = Math.Max(0, weight); } diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs index 69504b6..61a8f5d 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -47,11 +47,7 @@ public class ScheduleEntry ScheduleEntryKind kind, DateTimeOffset startsAtUtc, DateTimeOffset endsAtUtc, - Guid? showId, - int? episodeIndex, - Guid? slotId, - string? traceJson, - Guid? collectionId = null + ScheduleEntryOrigin origin ) => new() { @@ -61,11 +57,11 @@ public class ScheduleEntry Kind = kind, StartsAtUtc = startsAtUtc, EndsAtUtc = endsAtUtc, - ShowId = showId, - EpisodeIndex = episodeIndex, - SlotId = slotId, - TraceJson = traceJson, - CollectionId = collectionId, + ShowId = origin.ShowId, + EpisodeIndex = origin.EpisodeIndex, + SlotId = origin.SlotId, + TraceJson = origin.TraceJson, + CollectionId = origin.CollectionId, }; public static ScheduleEntry Program( diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryOrigin.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryOrigin.cs new file mode 100644 index 0000000..78f2a05 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryOrigin.cs @@ -0,0 +1,15 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Происхождение записи расписания: чем она порождена и в рамках чего. На эфирную математику не +/// влияет — нужно EPG (/) и отладке сетки +/// (/). +/// +public sealed record ScheduleEntryOrigin( + Guid? ShowId, + int? EpisodeIndex, + Guid? SlotId, + string? TraceJson, + /// Коллекция (франшиза), частью которой шла запись, или null. + Guid? CollectionId = null +); diff --git a/backend/src/TeleWave.Domain/Media/MediaAsset.cs b/backend/src/TeleWave.Domain/Media/MediaAsset.cs index ded3869..4359b2b 100644 --- a/backend/src/TeleWave.Domain/Media/MediaAsset.cs +++ b/backend/src/TeleWave.Domain/Media/MediaAsset.cs @@ -90,26 +90,17 @@ public class MediaAsset Touch(); } - public void MarkReady( - TimeSpan duration, - int segmentSeconds, - int segmentCount, - int width, - int height, - string videoCodec, - string audioCodec, - string relativePath - ) + public void MarkReady(MediaReadyInfo info) { Status = MediaAssetStatus.Ready; - Duration = duration; - SegmentSeconds = segmentSeconds; - SegmentCount = segmentCount; - Width = width; - Height = height; - VideoCodec = videoCodec; - AudioCodec = audioCodec; - RelativePath = relativePath; + Duration = info.Duration; + SegmentSeconds = info.SegmentSeconds; + SegmentCount = info.SegmentCount; + Width = info.Width; + Height = info.Height; + VideoCodec = info.VideoCodec; + AudioCodec = info.AudioCodec; + RelativePath = info.RelativePath; ErrorMessage = null; if (ProcessingStartedAt is { } startedAt) { diff --git a/backend/src/TeleWave.Domain/Media/MediaReadyInfo.cs b/backend/src/TeleWave.Domain/Media/MediaReadyInfo.cs new file mode 100644 index 0000000..d89b82c --- /dev/null +++ b/backend/src/TeleWave.Domain/Media/MediaReadyInfo.cs @@ -0,0 +1,17 @@ +namespace TeleWave.Domain.Media; + +/// +/// Результат обработки ассета: что получилось после нарезки в HLS. Домен эти значения не вычисляет — +/// их приносит обработчик медиа (ffmpeg), а лишь фиксирует. +/// +public sealed record MediaReadyInfo( + TimeSpan Duration, + int SegmentSeconds, + int SegmentCount, + int Width, + int Height, + string VideoCodec, + string AudioCodec, + /// Путь к каталогу ассета относительно корня хранилища. + string RelativePath +); diff --git a/backend/src/TeleWave.Domain/Programming/Slot.cs b/backend/src/TeleWave.Domain/Programming/Slot.cs index 5ce08f1..1a7c8bf 100644 --- a/backend/src/TeleWave.Domain/Programming/Slot.cs +++ b/backend/src/TeleWave.Domain/Programming/Slot.cs @@ -108,32 +108,21 @@ public class Slot } /// Правит наполнение слота: чем, в каком объёме и с какими врезками. - public void UpdateContent( - string title, - SlotKind slotKind, - Guid? groupId, - string? strategyJson, - string? repeatSourceJson, - SlotBlockMode blockMode, - int blockValue, - OverflowPolicy overflowPolicy, - Guid? junctionBetweenId = null, - Guid? junctionAfterId = null - ) + public void UpdateContent(SlotContent content) { - JunctionBetweenId = junctionBetweenId; - JunctionAfterId = junctionAfterId; - Title = title.Trim(); - SlotKind = slotKind; - BlockMode = blockMode; - BlockValue = Math.Max(1, blockValue); - OverflowPolicy = overflowPolicy; + JunctionBetweenId = content.JunctionBetweenId; + JunctionAfterId = content.JunctionAfterId; + Title = content.Title.Trim(); + SlotKind = content.SlotKind; + BlockMode = content.BlockMode; + BlockValue = Math.Max(1, content.BlockValue); + OverflowPolicy = content.OverflowPolicy; // Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют, // и оставленный от прежнего типа мусор потом читался бы генератором как настройка. - GroupId = slotKind == SlotKind.Content ? groupId : null; - StrategyJson = slotKind == SlotKind.Content ? strategyJson : null; - RepeatSourceJson = slotKind == SlotKind.Repeat ? repeatSourceJson : null; + GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null; + StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null; + RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null; } /// Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток. diff --git a/backend/src/TeleWave.Domain/Programming/SlotContent.cs b/backend/src/TeleWave.Domain/Programming/SlotContent.cs new file mode 100644 index 0000000..f880bbe --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/SlotContent.cs @@ -0,0 +1,22 @@ +namespace TeleWave.Domain.Programming; + +/// +/// Наполнение слота: чем заполнять эфир, в каком объёме и с какими врезками. Отделено от расписания +/// слота () — это два независимых набора настроек, и правятся они +/// в редакторе тоже раздельно. +/// +public sealed record SlotContent( + string Title, + SlotKind SlotKind, + /// Группа контента — только для , иначе гасится. + Guid? GroupId, + string? StrategyJson, + string? RepeatSourceJson, + SlotBlockMode BlockMode, + int BlockValue, + OverflowPolicy OverflowPolicy, + /// Стык между единицами внутри блока (null — врезок внутри блока нет). + Guid? JunctionBetweenId = null, + /// Стык в конце блока (null — берётся стык шаблона по умолчанию). + Guid? JunctionAfterId = null +); diff --git a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs index 48461be..7b05b38 100644 --- a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs @@ -185,16 +185,12 @@ internal sealed class IdentityService( } public async Task> ListUsersAsync( - int page, - int pageSize, - string? search, - Guid? roleId, - bool? isBlocked, - string? sort, - bool desc, + UserListFilter filter, CancellationToken cancellationToken ) { + var (page, pageSize, search, roleId, isBlocked, sort, desc) = filter; + var query = from user in dbContext.Users join userRole in dbContext.UserRoles on user.Id equals userRole.UserId into userRoles diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs index 304875c..1b983e9 100644 --- a/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs @@ -60,14 +60,16 @@ internal sealed class BumperRenderBackgroundService( job.AssetId, asset => asset.MarkReady( - render.Duration, - render.SegmentSeconds, - render.SegmentCount, - render.Width, - render.Height, - "h264", - "aac", - render.RelativePath + new MediaReadyInfo( + render.Duration, + render.SegmentSeconds, + render.SegmentCount, + render.Width, + render.Height, + "h264", + "aac", + render.RelativePath + ) ), cancellationToken ); diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index 84870d4..053206c 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -21,6 +21,23 @@ public sealed class FfmpegBumperRenderer( private readonly StorageOptions _storage = storageOptions.Value; private readonly MediaOptions _media = mediaOptions.Value; + /// + /// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи, + /// свободные строки) ffmpeg читает через textfile= с expansion=none: иначе запятая, + /// ;, [ или ] в тексте ломают (или инъектируют звенья в) цепочку + /// -filter_complex. + /// + private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel) + { + public static TextFiles In(string assetDir) => + new( + Path.Combine(assetDir, "now.txt"), + Path.Combine(assetDir, "next.txt"), + Path.Combine(assetDir, "nowlabel.txt"), + Path.Combine(assetDir, "nextlabel.txt") + ); + } + public async Task RenderAsync( Guid assetId, BumperRenderSpec spec, @@ -36,31 +53,24 @@ public sealed class FfmpegBumperRenderer( Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(assetDir); - // Динамический текст (названия шоу / свободные строки) пишем в файлы и читаем через textfile= - // с expansion=none — так произвольные символы/кириллица не ломают синтаксис фильтра. - var nowFile = Path.Combine(assetDir, "now.txt"); - var nextFile = Path.Combine(assetDir, "next.txt"); + // Названия шоу / свободные строки. + var text = TextFiles.In(assetDir); var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle; var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle; - await File.WriteAllTextAsync(nowFile, line1, new UTF8Encoding(false), cancellationToken); - await File.WriteAllTextAsync(nextFile, line2, new UTF8Encoding(false), cancellationToken); + await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken); + await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken); - // Подписи «Сейчас/Далее» тоже пользователь-редактируемы (валидатор ограничивает только длину), - // поэтому их так же читаем через textfile=, а не подставляем в text= инлайн: иначе запятая/`;`/`[`/`]` - // в подписи ломают (или инъектируют звенья в) цепочку -filter_complex. Нужны лишь в режиме - // «Сейчас/Далее» (не FreeText), где рисуются подписи. - var nowLabelFile = Path.Combine(assetDir, "nowlabel.txt"); - var nextLabelFile = Path.Combine(assetDir, "nextlabel.txt"); + // Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют. if (!spec.FreeText) { await File.WriteAllTextAsync( - nowLabelFile, + text.NowLabel, spec.NowLabel, new UTF8Encoding(false), cancellationToken ); await File.WriteAllTextAsync( - nextLabelFile, + text.NextLabel, spec.NextLabel, new UTF8Encoding(false), cancellationToken @@ -69,16 +79,7 @@ public sealed class FfmpegBumperRenderer( try { - var args = BuildArgs( - assetDir, - seg, - target, - nowFile, - nextFile, - nowLabelFile, - nextLabelFile, - spec - ); + var args = BuildArgs(assetDir, seg, target, text, spec); var result = await ProcessRunner.RunAsync( _media.FfmpegPath, args, @@ -114,10 +115,10 @@ public sealed class FfmpegBumperRenderer( } finally { - TryDelete(nowFile); - TryDelete(nextFile); - TryDelete(nowLabelFile); - TryDelete(nextLabelFile); + TryDelete(text.Now); + TryDelete(text.Next); + TryDelete(text.NowLabel); + TryDelete(text.NextLabel); } } @@ -125,10 +126,7 @@ public sealed class FfmpegBumperRenderer( string assetDir, int seg, int target, - string nowFile, - string nextFile, - string nowLabelFile, - string nextLabelFile, + TextFiles text, BumperRenderSpec spec ) { @@ -211,29 +209,31 @@ public sealed class FfmpegBumperRenderer( var line2Y = line1Y + (int)(line2Size * 1.2); vchain .Append(',') - .Append(DrawTitle(font, nowFile, spec.AccentColor, line1Size, line1Y, 0.2)); + .Append(DrawTitle(font, text.Now, spec.AccentColor, line1Size, line1Y, 0.2)); vchain .Append(',') - .Append(DrawTitle(font, nextFile, spec.TextColor, line2Size, line2Y, 0.5)); + .Append(DrawTitle(font, text.Next, spec.TextColor, line2Size, line2Y, 0.5)); } else { var nowSize = FitSize(spec.NowTitle, titleSize, textWidth); var nextSize = FitSize(spec.NextTitle, titleSize, textWidth); - vchain - .Append(',') - .Append(DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2)); - vchain - .Append(',') - .Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3)); vchain .Append(',') .Append( - DrawLabel(font, nextLabelFile, spec.AccentColor, labelSize, nextLabelY, 1.0) + DrawLabel(font, text.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2) ); vchain .Append(',') - .Append(DrawTitle(font, nextFile, spec.TextColor, nextSize, nextTitleY, 1.1)); + .Append(DrawTitle(font, text.Now, spec.TextColor, nowSize, nowTitleY, 0.3)); + vchain + .Append(',') + .Append( + DrawLabel(font, text.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0) + ); + vchain + .Append(',') + .Append(DrawTitle(font, text.Next, spec.TextColor, nextSize, nextTitleY, 1.1)); } vchain.Append("[v]"); diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs index 55df6e4..83f1d5f 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs @@ -53,14 +53,16 @@ internal sealed class MediaProcessingBackgroundService( job.AssetId, asset => asset.MarkReady( - result.Duration, - result.SegmentSeconds, - result.SegmentCount, - result.Width, - result.Height, - result.VideoCodec, - result.AudioCodec, - result.RelativePath + new MediaReadyInfo( + result.Duration, + result.SegmentSeconds, + result.SegmentCount, + result.Width, + result.Height, + result.VideoCodec, + result.AudioCodec, + result.RelativePath + ) ), cancellationToken ); diff --git a/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs index 9a21404..50b7c10 100644 --- a/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs @@ -21,7 +21,9 @@ public class MediaStatsTests { var a = Pending(name); a.MarkProcessing(); - a.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x"); + a.MarkReady( + new MediaReadyInfo(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x") + ); return a; } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs index 04deadc..54cdf5d 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs @@ -28,7 +28,12 @@ public class BumperTextVariantTests { var v = NewVariant(); - v.Update("Name", BumperTextKind.Free, "NOW", "NEXT", "l1", "l2", BumperTrigger.Both, -5); + v.Update( + "Name", + new BumperTextContent(BumperTextKind.Free, "NOW", "NEXT", "l1", "l2"), + BumperTrigger.Both, + -5 + ); Assert.Equal("Name", v.Name); Assert.Equal(BumperTextKind.Free, v.Kind); @@ -48,7 +53,7 @@ public class BumperTextVariantTests public void Matches_FollowsTrigger(BumperTrigger trigger, bool isShowChange, bool expected) { var v = NewVariant(); - v.Update("n", BumperTextKind.NowNext, "a", "b", "", "", trigger, 1); + v.Update("n", new BumperTextContent(BumperTextKind.NowNext, "a", "b", "", ""), trigger, 1); Assert.Equal(expected, v.Matches(isShowChange)); } diff --git a/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs b/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs index e06a771..1b7328a 100644 --- a/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs @@ -35,14 +35,16 @@ public class MediaAssetTests var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload); asset.MarkReady( - TimeSpan.FromSeconds(120), - segmentSeconds: 2, - segmentCount: 60, - width: 1920, - height: 1080, - videoCodec: "h264", - audioCodec: "aac", - relativePath: "assets/abc" + new MediaReadyInfo( + TimeSpan.FromSeconds(120), + SegmentSeconds: 2, + SegmentCount: 60, + Width: 1920, + Height: 1080, + VideoCodec: "h264", + AudioCodec: "aac", + RelativePath: "assets/abc" + ) ); Assert.Equal(MediaAssetStatus.Ready, asset.Status); @@ -64,7 +66,9 @@ public class MediaAssetTests Assert.NotNull(asset.ProcessingStartedAt); Assert.Null(asset.ProcessingDuration); - asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc"); + asset.MarkReady( + new MediaReadyInfo(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc") + ); Assert.NotNull(asset.ProcessingDuration); Assert.True(asset.ProcessingDuration >= TimeSpan.Zero); @@ -75,7 +79,9 @@ public class MediaAssetTests { var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload); - asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc"); + asset.MarkReady( + new MediaReadyInfo(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc") + ); Assert.Null(asset.ProcessingDuration); } diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs index 5e20ae9..38e7ed0 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/ScheduleTemplateTests.cs @@ -113,25 +113,29 @@ public class GridLayerOverlapTests var layer = NewLayer(); var slot = layer.AddSlot("Кино", new TimeOnly(20, 0), 90); slot.UpdateContent( - "Кино", - SlotKind.Content, - Guid.NewGuid(), - "{\"type\":\"sequential\"}", - null, - SlotBlockMode.Count, - 1, - OverflowPolicy.ContinueNext + new SlotContent( + "Кино", + SlotKind.Content, + Guid.NewGuid(), + "{\"type\":\"sequential\"}", + null, + SlotBlockMode.Count, + 1, + OverflowPolicy.ContinueNext + ) ); slot.UpdateContent( - "Конец вещания", - SlotKind.SignOff, - Guid.NewGuid(), - "{\"type\":\"sequential\"}", - null, - SlotBlockMode.FillSlot, - 1, - OverflowPolicy.ContinueNext + new SlotContent( + "Конец вещания", + SlotKind.SignOff, + Guid.NewGuid(), + "{\"type\":\"sequential\"}", + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) ); // Иначе генератор прочитал бы настройки, оставшиеся от прежнего типа слота. diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs index 729dbdd..b57d1c3 100644 --- a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs @@ -249,14 +249,16 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur var layer = template.AddLayer("Базовый", 10); var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); slot.UpdateContent( - slot.Title, - SlotKind.Content, - group.Id, - new SlotStrategy(SlotStrategyType.Sequential).ToJson(), - null, - SlotBlockMode.FillSlot, - 1, - OverflowPolicy.ContinueNext + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + new SlotStrategy(SlotStrategyType.Sequential).ToJson(), + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) ); channel.SetTemplate(template.Id); // Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить». @@ -275,14 +277,16 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload); asset.MarkProcessing(); asset.MarkReady( - duration, - segmentSeconds: 2, - segmentCount: (int)(duration.TotalSeconds / 2), - width: 1920, - height: 1080, - videoCodec: "h264", - audioCodec: "aac", - relativePath: $"segments/{asset.Id}" + new MediaReadyInfo( + duration, + SegmentSeconds: 2, + SegmentCount: (int)(duration.TotalSeconds / 2), + Width: 1920, + Height: 1080, + VideoCodec: "h264", + AudioCodec: "aac", + RelativePath: $"segments/{asset.Id}" + ) ); db.MediaAssets.Add(asset); return asset; diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs index 68b6304..fc57d9f 100644 --- a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -150,15 +150,17 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) var layer = sourceTemplate.AddLayer("Прайм", 10); var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); slot.UpdateContent( - slot.Title, - SlotKind.Content, - group.Id, - new SlotStrategy(SlotStrategyType.Sequential).ToJson(), - null, - SlotBlockMode.FillSlot, - 1, - OverflowPolicy.ContinueNext, - junctionAfterId: junction.Id + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + new SlotStrategy(SlotStrategyType.Sequential).ToJson(), + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext, + JunctionAfterId: junction.Id + ) ); sourceTemplate.SetDefaultJunction(junction.Id); source.SetTemplate(sourceTemplate.Id); diff --git a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs index 82e67f6..b7fe261 100644 --- a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs @@ -52,7 +52,9 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture) var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch); var show = Show.Create("Show", ShowKind.Series); var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload); - asset.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x"); + asset.MarkReady( + new MediaReadyInfo(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x") + ); show.AddEpisode(asset.Id); var entry = ScheduleEntry.Program( channel.Id, diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index f87e7dc..f894fca 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -66,8 +66,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) { setApplyOpen(false) toast.success(t('admin.channels.applied', { count: result.added })) // Предупреждения показываем по одному: каждое указывает на конкретный слот. - for (const warning of result.warnings) - toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`) + for (const warning of result.warnings) { + const kind = t(`admin.channels.warnings.${warning.kind}`) + toast.error(`${kind}: ${warning.details}`) + } invalidate() }, onError, diff --git a/frontend/src/features/admin/channels/components/JunctionsCard.tsx b/frontend/src/features/admin/channels/components/JunctionsCard.tsx index bc446f6..5a7c11b 100644 --- a/frontend/src/features/admin/channels/components/JunctionsCard.tsx +++ b/frontend/src/features/admin/channels/components/JunctionsCard.tsx @@ -39,6 +39,23 @@ const KIND_COLORS: Record = { Filler: 'bg-muted-foreground/40', } +type Translate = ReturnType['t'] + +/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */ +function elementSuffix(element: JunctionElementDto, t: Translate) { + if (element.kind === 'Bumper') + return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : '' + + const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : '' + return ` ×${element.amountValue}${units}` +} + +/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */ +function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) { + const kind = t(`admin.channels.junctionKinds.${element.kind}`) + return `${kind} · ${formatClock(seconds)}` +} + /** * Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы * группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо @@ -280,11 +297,7 @@ function JunctionChain({ )} > {t(`admin.channels.junctionKinds.${element.kind}`)} - {element.kind === 'Bumper' - ? element.bumperTemplateName - ? ` · ${element.bumperTemplateName}` - : '' - : ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`} + {elementSuffix(element, t)} {element.isRequired && ' *'} @@ -303,7 +316,7 @@ function JunctionChain({ key={element.id} className={KIND_COLORS[element.kind]} style={{ width: `${(estimates[index].seconds / total) * 100}%` }} - title={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`} + title={elementTitle(element, estimates[index].seconds, t)} /> ))} diff --git a/frontend/src/features/admin/channels/components/SchedulePreview.tsx b/frontend/src/features/admin/channels/components/SchedulePreview.tsx index 45cadd8..df162a1 100644 --- a/frontend/src/features/admin/channels/components/SchedulePreview.tsx +++ b/frontend/src/features/admin/channels/components/SchedulePreview.tsx @@ -4,6 +4,53 @@ import type { ScheduleEntryDto } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { formatTime } from '../lib/format' +/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */ +function EntryLabel({ entry }: { entry: ScheduleEntryDto }) { + const { t } = useTranslation() + + if (entry.kind === 'Ad') return {t('air.ad')} + + if (entry.kind === 'Bumper') + return ( + + + {t('air.bumper')} + + {(entry.bumperName || entry.bumperText) && ( + + {entry.bumperName} + {entry.bumperName && entry.bumperText ? ' · ' : ''} + {entry.bumperText} + + )} + + ) + + return ( + + {entry.showName ?? '—'} + + + ) +} + +/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */ +function EpisodeSuffix({ entry }: { entry: ScheduleEntryDto }) { + const { t } = useTranslation() + + if (entry.seasonEpisode) + return · {entry.seasonEpisode} + + if (entry.episodeIndex == null) return null + + return ( + + {' '} + · {t('air.episode')} {entry.episodeIndex + 1} + + ) +} + export function SchedulePreview({ entries, onShowTrace, @@ -22,36 +69,7 @@ export function SchedulePreview({ {formatTime(e.startsAtUtc)} - {e.kind === 'Ad' ? ( - {t('air.ad')} - ) : e.kind === 'Bumper' ? ( - - - {t('air.bumper')} - - {(e.bumperName || e.bumperText) && ( - - {e.bumperName} - {e.bumperName && e.bumperText ? ' · ' : ''} - {e.bumperText} - - )} - - ) : ( - - {e.showName ?? '—'} - {e.seasonEpisode ? ( - · {e.seasonEpisode} - ) : ( - e.episodeIndex != null && ( - - {' '} - · {t('air.episode')} {e.episodeIndex + 1} - - ) - )} - - )} + diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index 4595eb8..e5afa4b 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -347,20 +347,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged })} - {s.expected == null ? ( -

- {t('admin.metadata.missingUnknown')} -

- ) : s.missing.length === 0 ? ( -

- {t('admin.metadata.missingNone')} -

- ) : ( -

- {t('admin.metadata.missingList')}:{' '} - {s.missing.join(', ')} -

- )} + ))} @@ -370,3 +357,21 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged ) } + +/** Итог по сезону: чего не хватает — или что полный состав сезона неизвестен. */ +function SeasonGapNote({ gap }: { gap: MissingEpisodesReport['seasons'][number] }) { + const { t } = useTranslation() + + if (gap.expected == null) + return

{t('admin.metadata.missingUnknown')}

+ + if (gap.missing.length === 0) + return

{t('admin.metadata.missingNone')}

+ + return ( +

+ {t('admin.metadata.missingList')}:{' '} + {gap.missing.join(', ')} +

+ ) +} diff --git a/frontend/src/features/auth/LoginForm.tsx b/frontend/src/features/auth/LoginForm.tsx index e19e0a6..5bb53fe 100644 --- a/frontend/src/features/auth/LoginForm.tsx +++ b/frontend/src/features/auth/LoginForm.tsx @@ -30,12 +30,10 @@ export function LoginForm({ onSuccess }: { onSuccess: () => void }) { applyAuthResponse(auth) onSuccess() } catch (error) { - const message = - error instanceof HttpError && error.status === 401 - ? t('auth.invalidCredentials') - : error instanceof HttpError && error.status === 403 - ? t('auth.blocked') - : t('auth.genericError') + const status = error instanceof HttpError ? error.status : 0 + let message = t('auth.genericError') + if (status === 401) message = t('auth.invalidCredentials') + else if (status === 403) message = t('auth.blocked') toast.error(message) } } diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index 7c48c05..95a71db 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -14,6 +14,29 @@ function formatTime(iso: string) { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } +/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */ +function EntryThumb({ entry }: { entry: PublicEpgEntryDto | undefined }) { + if (entry?.episodeStillImageId) + return ( + + ) + + if (entry?.showPosterImageId) + return ( + + ) + + return null +} + export function AirPage() { const { t } = useTranslation() const [selected, setSelected] = useState(null) @@ -194,49 +217,38 @@ export function AirPage() {
- {selected && watchReady ? ( - playerError ? ( -
- -
-

{t('air.offline')}

-

{t('air.offlineHint')}

-
- -
- ) : ( - - ) - ) : ( + {/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */} + {(!selected || !watchReady) && (
)} + {selected && watchReady && playerError && ( +
+ +
+

{t('air.offline')}

+

{t('air.offlineHint')}

+
+ +
+ )} + {selected && watchReady && !playerError && ( + + )}
{current && (
- {currentEntry?.episodeStillImageId ? ( - - ) : currentEntry?.showPosterImageId ? ( - - ) : null} +
{t('air.now')} diff --git a/frontend/src/features/streaming/api.ts b/frontend/src/features/streaming/api.ts index 472ee6f..59abbf7 100644 --- a/frontend/src/features/streaming/api.ts +++ b/frontend/src/features/streaming/api.ts @@ -25,5 +25,6 @@ export function getEpg(slug: string, from?: Date, to?: Date) { if (from) query.set('from', from.toISOString()) if (to) query.set('to', to.toISOString()) const qs = query.toString() - return apiRequest(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`) + const suffix = qs ? `?${qs}` : '' + return apiRequest(`/channels/${slug}/epg${suffix}`) } diff --git a/frontend/src/shared/lib/table-sort.ts b/frontend/src/shared/lib/table-sort.ts index 8f240be..3f7f349 100644 --- a/frontend/src/shared/lib/table-sort.ts +++ b/frontend/src/shared/lib/table-sort.ts @@ -32,6 +32,8 @@ export function sortRows( if (av == null) return 1 if (bv == null) return -1 if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir - return (av < bv ? -1 : av > bv ? 1 : 0) * dir + if (av < bv) return -dir + if (av > bv) return dir + return 0 }) } diff --git a/frontend/src/shared/ui/sortable.tsx b/frontend/src/shared/ui/sortable.tsx index 2c5fc8b..52ef439 100644 --- a/frontend/src/shared/ui/sortable.tsx +++ b/frontend/src/shared/ui/sortable.tsx @@ -17,13 +17,15 @@ export function SortHeader({ className?: string }) { const active = sort.key === sortKey - const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp + const direction = sort.desc ? 'descending' : 'ascending' + let Icon = ChevronsUpDown + if (active) Icon = sort.desc ? ArrowDown : ArrowUp return ( // aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет, // и скринридер там его просто не прочтёт.