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 его нет, // и скринридер там его просто не прочтёт.