From 59cc453026ebc5da397468902b7d7b61eef1dd0b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 23:31:56 +0300 Subject: [PATCH] Refactor RenderBumperPreviewCommandHandler to streamline background path resolution and rendering specification. Introduce helper methods for improved readability and maintainability. Update FindMissingEpisodesQueryHandler and RefreshShowEpisodesMetadataCommandHandler to encapsulate episode number resolution logic. Enhance CopyTemplateCommandHandler by modularizing junction copying and slot handling for better clarity. Improve FfmpegMediaProcessor by separating duration and stream parsing into dedicated methods. Refactor MediaClaimingBackgroundService to simplify idle waiting logic. Update GenreSeeder to modularize alias addition logic, enhancing code organization. --- .../RenderBumperPreviewCommandHandler.cs | 93 +++++--- .../FindMissingEpisodesQueryHandler.cs | 59 +++-- ...freshShowEpisodesMetadataCommandHandler.cs | 92 ++++---- .../Programming/Planning/GroupExpander.cs | 175 +++++++-------- .../CopyTemplateCommandHandler.cs | 202 ++++++++++++------ .../Validate/ValidateTemplateQueryHandler.cs | 37 ++-- .../Programming/Planning/SchedulePlanner.cs | 47 ++-- .../Library/GenreSeeder.cs | 58 +++-- .../Media/FfmpegMediaProcessor.cs | 104 +++++---- .../Media/MediaClaimingBackgroundService.cs | 31 ++- .../Persistence/AppDbContext.cs | 8 +- 11 files changed, 544 insertions(+), 362 deletions(-) diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index 430823e..f5dc5c9 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -46,19 +46,7 @@ public sealed class RenderBumperPreviewCommandHandler( var fontFile = channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; - // Фон блока — из общего реестра по id (общий для всех подблоков). - string? backgroundPath = null; - if (template.BackgroundImageId is { } bgId) - { - var bgExt = await dbContext - .Images.AsNoTracking() - .Where(i => i.Id == bgId) - .Select(i => i.FileExtension) - .FirstOrDefaultAsync(cancellationToken); - if (bgExt is not null) - backgroundPath = imageStore.ResolvePath(bgId, bgExt); - } - + var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken); var seconds = template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds; @@ -70,27 +58,15 @@ public sealed class RenderBumperPreviewCommandHandler( // Рендерим каждый подблок в свой ассет-превью (id по подблоку). foreach (var variant in template.Variants.OrderBy(v => v.Position)) { - var free = variant.Kind == BumperTextKind.Free; - var spec = new BumperRenderSpec( + var spec = BuildSpec( + variant, + template, aligned, - _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 + fromName, + toName ); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); } @@ -98,6 +74,63 @@ public sealed class RenderBumperPreviewCommandHandler( return Result.Success(); } + /// Путь к фон-картинке блока в общем реестре или null, если она не привязана. + private async Task ResolveBackgroundPathAsync( + BumperTemplate template, + CancellationToken cancellationToken + ) + { + if (template.BackgroundImageId is not { } imageId) + return null; + + var extension = await dbContext + .Images.AsNoTracking() + .Where(i => i.Id == imageId) + .Select(i => i.FileExtension) + .FirstOrDefaultAsync(cancellationToken); + + 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/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs index f70c8ea..9b6ba31 100644 --- a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs @@ -44,27 +44,7 @@ public sealed class FindMissingEpisodesQueryHandler( .Select(a => new { a.Id, a.OriginalFileName }) .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); - // Загруженные номера серий по сезонам (только сезоны с хотя бы одной серией). - var loadedBySeason = new Dictionary>(); - foreach (var episode in show.Episodes) - { - var season = episode.Season; - var number = episode.Episode; - if ( - (season is null || number is null) - && names.TryGetValue(episode.MediaAssetId, out var name) - ) - { - if (EpisodeName.Parse(name) is { } parsed) - (season, number) = (parsed.Season, parsed.Episode); - } - if (season is null || number is null) - continue; - - if (!loadedBySeason.TryGetValue(season.Value, out var set)) - loadedBySeason[season.Value] = set = []; - set.Add(number.Value); - } + var loadedBySeason = GroupLoadedBySeason(show, names); var seasons = new List(); foreach (var season in loadedBySeason.Keys.OrderBy(s => s)) @@ -83,4 +63,41 @@ public sealed class FindMissingEpisodesQueryHandler( return Result.Success(new MissingEpisodesReport(seasons)); } + + /// + /// Загруженные номера серий по сезонам — только те сезоны, где есть хотя бы одна серия. Номера + /// берутся проставленные, а при их отсутствии распознаются из имени файла (здесь, в отличие от + /// обновления метаданных, распознанное не сохраняется: запрос ничего не меняет). + /// + private static Dictionary> GroupLoadedBySeason( + Show show, + IReadOnlyDictionary assetNames + ) + { + var loadedBySeason = new Dictionary>(); + foreach (var episode in show.Episodes) + { + if (ResolveNumbers(episode, assetNames) is not { } numbers) + continue; + + if (!loadedBySeason.TryGetValue(numbers.Season, out var set)) + loadedBySeason[numbers.Season] = set = []; + set.Add(numbers.Episode); + } + return loadedBySeason; + } + + /// Сезон и номер серии: проставленные либо распознанные из имени файла; null — не вышло. + private static (int Season, int Episode)? ResolveNumbers( + ShowEpisode episode, + IReadOnlyDictionary assetNames + ) + { + if (episode.Season is { } season && episode.Episode is { } number) + return (season, number); + + return assetNames.TryGetValue(episode.MediaAssetId, out var name) + ? EpisodeName.Parse(name) + : null; + } } diff --git a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs index 87f7469..b0dcd5b 100644 --- a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs @@ -48,58 +48,72 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler( var updated = 0; foreach (var episode in show.Episodes) { - var season = episode.Season; - var number = episode.Episode; - if (season is null || number is null) - { - if ( - names.TryGetValue(episode.MediaAssetId, out var name) - && EpisodeName.Parse(name) is { } parsed - ) - { - season = parsed.Season; - number = parsed.Episode; - episode.SetNumbers(season, number); - } - } - if (season is null || number is null) + if (ResolveNumbers(episode, names) is not { } numbers) continue; var meta = await provider.GetEpisodeAsync( show.MetadataExternalId, - season.Value, - number.Value, + numbers.Season, + numbers.Episode, cancellationToken ); if (meta is null) continue; - Guid? stillImageId = null; - if (!string.IsNullOrEmpty(meta.StillUrl)) - { - var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken); - if (downloaded is not null) - { - var image = Image.Create( - ImageCategory.EpisodeStill, - downloaded.Extension, - meta.Title - ); - dbContext.Images.Add(image); - await imageStore.SaveAsync( - image.Id, - downloaded.Extension, - downloaded.Content, - cancellationToken - ); - stillImageId = image.Id; - } - } - + var stillImageId = await SaveStillAsync(meta, cancellationToken); episode.ApplyMetadata(meta.Title, meta.Overview, stillImageId, meta.AirDate); updated++; } return Result.Success(updated); } + + /// + /// Сезон и номер серии: сперва проставленные, иначе распознанные из имени файла (и тогда сразу + /// сохраняются на серии). null — распознать не удалось, серию пропускаем. + /// + private static (int Season, int Episode)? ResolveNumbers( + ShowEpisode episode, + IReadOnlyDictionary assetNames + ) + { + if (episode.Season is { } season && episode.Episode is { } number) + return (season, number); + + if ( + !assetNames.TryGetValue(episode.MediaAssetId, out var name) + || EpisodeName.Parse(name) is not { } parsed + ) + return null; + + episode.SetNumbers(parsed.Season, parsed.Episode); + return parsed; + } + + /// + /// Скачивает кадр серии и регистрирует его в общем реестре изображений. null — кадра нет либо + /// скачать не удалось; это не повод отменять применение остальных метаданных. + /// + private async Task SaveStillAsync( + EpisodeMetadata meta, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrEmpty(meta.StillUrl)) + return null; + + var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken); + if (downloaded is null) + return null; + + var image = Image.Create(ImageCategory.EpisodeStill, downloaded.Extension, meta.Title); + dbContext.Images.Add(image); + await imageStore.SaveAsync( + image.Id, + downloaded.Extension, + downloaded.Content, + cancellationToken + ); + return image.Id; + } } diff --git a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs index 20a734c..ff3d8da 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs @@ -59,21 +59,19 @@ public sealed class GroupExpander(IAppDbContext dbContext) .Distinct() .ToList(); - var episodes = await dbContext + var loaded = await dbContext .Shows.AsNoTracking() .Where(s => showIds.Contains(s.Id)) .Select(s => new { s.Id, s.Audience, - Episodes = s - .Episodes.OrderBy(e => e.Position) - .Select(e => new { e.MediaAssetId, e.Position }) - .ToList(), + AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(), }) - .ToDictionaryAsync(s => s.Id, cancellationToken); + .ToListAsync(cancellationToken); + var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds)); - var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList(); + var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList(); var durations = await dbContext .MediaAssets.AsNoTracking() .Where(a => @@ -90,90 +88,97 @@ public sealed class GroupExpander(IAppDbContext dbContext) cancellationToken ); - List UnitsOfShow(Guid showId) - { - if (!episodes.TryGetValue(showId, out var show)) - return []; - - return show - .Episodes.Where(e => durations.ContainsKey(e.MediaAssetId)) - .Select((e, index) => new PlanningUnit( - e.MediaAssetId, - durations[e.MediaAssetId], - showId, - index - )) - .ToList(); - } + var data = new ExpansionData( + shows, + durations, + lastPlayed, + recentPlays, + collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId) + ); foreach (var group in groups) - { - var elements = new List(); - - foreach (var item in group.Items.OrderBy(i => i.Position)) - { - if (item.ElementKind == GroupElementKind.Show) - { - elements.Add( - new PlanningElement( - item.ElementKind, - item.ElementId, - item.Weight, - item.Position, - UnitsOfShow(item.ElementId), - lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null, - episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null, - recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null - ) - ); - continue; - } - - var partShowIds = collectionParts - .Where(p => p.CollectionId == item.ElementId) - .Select(p => p.ShowId) - .ToList(); - - var units = partShowIds.SelectMany(UnitsOfShow).ToList(); - - elements.Add( - new PlanningElement( - item.ElementKind, - item.ElementId, - item.Weight, - item.Position, - units, - // Остывание коллекции считается по самой свежей из её частей: показ любой - // из них означает, что франшиза недавно была в эфире. - partShowIds - .Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null) - .Where(p => p is not null) - .DefaultIfEmpty(null) - .Max(), - // Рейтинг коллекции — строжайший среди частей: франшиза идёт целиком, - // и одна взрослая часть делает взрослой всю. - partShowIds - .Select(id => - episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null - ) - .Where(a => a is not null) - .DefaultIfEmpty(null) - .Max(), - partShowIds - .SelectMany(id => - recentPlays.TryGetValue(id, out var p) ? p : [] - ) - .ToList() - ) - ); - } - - result[group.Id] = elements; - } + result[group.Id] = group + .Items.OrderBy(i => i.Position) + .Select(item => + item.ElementKind == GroupElementKind.Show + ? data.ShowElement(item) + : data.CollectionElement(item) + ) + .ToList(); return result; } + /// Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа. + private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList AssetIds); + + /// + /// Справочники одного прогона. Сборка элементов вынесена сюда из : + /// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком. + /// В БД отсюда не ходят — всё уже загружено. + /// + private sealed record ExpansionData( + IReadOnlyDictionary Shows, + IReadOnlyDictionary Durations, + IReadOnlyDictionary LastPlayed, + IReadOnlyDictionary> RecentPlays, + ILookup CollectionParts + ) + { + public PlanningElement ShowElement(GroupItem item) => + new( + item.ElementKind, + item.ElementId, + item.Weight, + item.Position, + UnitsOf(item.ElementId), + LastPlayedOf(item.ElementId), + AudienceOf(item.ElementId), + RecentPlaysOf(item.ElementId) + ); + + /// + /// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей + /// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший + /// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю). + /// + public PlanningElement CollectionElement(GroupItem item) + { + var parts = CollectionParts[item.ElementId].ToList(); + return new PlanningElement( + item.ElementKind, + item.ElementId, + item.Weight, + item.Position, + parts.SelectMany(UnitsOf).ToList(), + parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(), + parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(), + parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList() + ); + } + + /// Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа. + private List UnitsOf(Guid showId) => + Shows.TryGetValue(showId, out var show) + ? show + .AssetIds.Where(Durations.ContainsKey) + .Select( + (assetId, index) => + new PlanningUnit(assetId, Durations[assetId], showId, index) + ) + .ToList() + : []; + + private DateTimeOffset? LastPlayedOf(Guid showId) => + LastPlayed.TryGetValue(showId, out var at) ? at : null; + + private ShowAudience? AudienceOf(Guid showId) => + Shows.TryGetValue(showId, out var show) ? show.Audience : null; + + private IReadOnlyList? RecentPlaysOf(Guid showId) => + RecentPlays.TryGetValue(showId, out var plays) ? plays : null; + } + /// /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное diff --git a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs index 82d9721..21d54d7 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs @@ -48,44 +48,12 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) .Select(t => new { t.Id, t.Name }) .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); - var junctionMap = new Dictionary(); - var droppedBumperRefs = 0; - - foreach (var junction in sourceJunctions) - { - var copy = JunctionTemplate.Create(target.Id, junction.Name); - junctionMap[junction.Id] = copy.Id; - - foreach (var element in junction.Elements.OrderBy(e => e.Position)) - { - var created = copy.AddElement(element.Kind); - - Guid? bumperTemplateId = null; - if (element.Kind == JunctionElementKind.Bumper) - { - if ( - element.BumperTemplateId is { } sourceId - && sourceBumperNames.TryGetValue(sourceId, out var name) - && bumperByName.TryGetValue(name, out var mapped) - ) - bumperTemplateId = mapped; - else - droppedBumperRefs++; - } - - created.Update( - element.Kind, - element.GroupId, - bumperTemplateId, - element.AmountMode, - element.AmountValue, - element.IsRequired, - element.ConditionsJson - ); - } - - dbContext.JunctionTemplates.Add(copy); - } + var (junctionMap, droppedBumperRefs) = CopyJunctions( + sourceJunctions, + target.Id, + sourceBumperNames, + bumperByName + ); // Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя, // а «добавить поверх» дало бы кашу из пересекающихся слотов. @@ -105,6 +73,94 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault)) copyTemplate.SetDefaultJunction(mappedDefault); + var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap); + + dbContext.ScheduleTemplates.Add(copyTemplate); + target.SetTemplate(copyTemplate.Id); + + return Result.Success( + new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs) + ); + } + + /// + /// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему + /// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки. + /// + private (Dictionary Map, int DroppedBumperRefs) CopyJunctions( + IReadOnlyList sourceJunctions, + Guid targetChannelId, + IReadOnlyDictionary sourceBumperNames, + IReadOnlyDictionary targetBumperByName + ) + { + var map = new Dictionary(); + var dropped = 0; + + foreach (var junction in sourceJunctions) + { + var copy = JunctionTemplate.Create(targetChannelId, junction.Name); + map[junction.Id] = copy.Id; + + foreach (var element in junction.Elements.OrderBy(e => e.Position)) + { + var bumperTemplateId = MapBumper( + element, + sourceBumperNames, + targetBumperByName, + ref dropped + ); + copy.AddElement(element.Kind) + .Update( + element.Kind, + element.GroupId, + bumperTemplateId, + element.AmountMode, + element.AmountValue, + element.IsRequired, + element.ConditionsJson + ); + } + + dbContext.JunctionTemplates.Add(copy); + } + + return (map, dropped); + } + + /// + /// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале + /// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки, + /// и это попадает в отчёт. + /// + private static Guid? MapBumper( + JunctionElement element, + IReadOnlyDictionary sourceBumperNames, + IReadOnlyDictionary targetBumperByName, + ref int dropped + ) + { + if (element.Kind != JunctionElementKind.Bumper) + return null; + + if ( + element.BumperTemplateId is { } sourceId + && sourceBumperNames.TryGetValue(sourceId, out var name) + && targetBumperByName.TryGetValue(name, out var mapped) + ) + return mapped; + + dropped++; + return null; + } + + /// Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано. + private static (int Layers, int Slots) CopyGrid( + ScheduleTemplate source, + ScheduleTemplate copyTemplate, + IReadOnlyDictionary junctionMap + ) + { var layers = 0; var slots = 0; @@ -120,44 +176,48 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) foreach (var slot in layer.Slots) { - var copySlot = copyLayer.AddSlot( - slot.Title, - slot.TargetStart, - slot.TargetDurationMinutes, - slot.Daypart, - slot.SlotKind, - slot.Weekday - ); - copySlot.UpdateTiming( - slot.Weekday, - slot.TargetStart, - slot.TargetDurationMinutes, - slot.Daypart, - slot.IsAnchor, - slot.MaxDriftMinutes, - 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) - ); + CopySlot(slot, copyLayer, junctionMap); slots++; } } - dbContext.ScheduleTemplates.Add(copyTemplate); - target.SetTemplate(copyTemplate.Id); + return (layers, slots); + } - return Result.Success( - new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs) + private static void CopySlot( + Slot slot, + GridLayer copyLayer, + IReadOnlyDictionary junctionMap + ) + { + var copySlot = copyLayer.AddSlot( + slot.Title, + slot.TargetStart, + slot.TargetDurationMinutes, + slot.Daypart, + slot.SlotKind, + slot.Weekday + ); + copySlot.UpdateTiming( + slot.Weekday, + slot.TargetStart, + slot.TargetDurationMinutes, + slot.Daypart, + slot.IsAnchor, + slot.MaxDriftMinutes, + 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) ); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs index 917da3b..f42c4bb 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs @@ -154,24 +154,31 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext) { var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList(); + // Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний, + // и «телом» внешнего цикла его читает только компилятор. for (var i = 0; i < slots.Count; i++) - for (var j = i + 1; j < slots.Count; j++) { - var a = slots[i]; - var b = slots[j]; - if (!SameDays(a, b)) - continue; + for (var j = i + 1; j < slots.Count; j++) + { + var a = slots[i]; + var b = slots[j]; + if (!SameDays(a, b)) + continue; - var aFrom = OffsetInDay(a.TargetStart, dayStart); - var bFrom = OffsetInDay(b.TargetStart, dayStart); - if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes) - yield return new TemplateIssueDto( - TemplateIssueKind.SlotOverlap, - TemplateIssueSeverity.Warning, - layer.Id, - b.Id, - $"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»." - ); + var aFrom = OffsetInDay(a.TargetStart, dayStart); + var bFrom = OffsetInDay(b.TargetStart, dayStart); + if ( + aFrom < bFrom + b.TargetDurationMinutes + && bFrom < aFrom + a.TargetDurationMinutes + ) + yield return new TemplateIssueDto( + TemplateIssueKind.SlotOverlap, + TemplateIssueSeverity.Warning, + layer.Id, + b.Id, + $"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»." + ); + } } } diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index 16ca63a..2e2cfdd 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -244,27 +244,7 @@ public static class SchedulePlanner var pick = ElementSelector.Select(slot, cursor, random); if (pick is null) { - // Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть, - // но не подходит по правилам, и админу надо чинить правило, а не состав группы. - var filteredOut = - slot.Elements.Any(e => e.Units.Count > 0) - && !slot.Elements.Any(e => - e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e) - ); - - warnings.Add( - filteredOut - ? new PlanningWarning( - PlanningWarningKind.CandidatesFiltered, - slot.SlotId, - "Возрастной потолок отсёк всех кандидатов — место закрыл фон." - ) - : new PlanningWarning( - PlanningWarningKind.SlotEmpty, - slot.SlotId, - "Слот не дал контента — место закрыл фон." - ) - ); + warnings.Add(NoCandidatesWarning(slot)); return FillWithFallback( cursor, Min(slot.TargetEndUtc, limit), @@ -365,6 +345,31 @@ public static class SchedulePlanner return cursor; } + /// + /// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором + /// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав + /// группы. + /// + private static PlanningWarning NoCandidatesWarning(PlanningSlot slot) + { + var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0); + var hasAllowed = slot.Elements.Any(e => + e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e) + ); + + return hasPlayable && !hasAllowed + ? new PlanningWarning( + PlanningWarningKind.CandidatesFiltered, + slot.SlotId, + "Возрастной потолок отсёк всех кандидатов — место закрыл фон." + ) + : new PlanningWarning( + PlanningWarningKind.SlotEmpty, + slot.SlotId, + "Слот не дал контента — место закрыл фон." + ); + } + /// /// Влезает ли ещё одна единица в бюджет слота. бюджет /// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь. diff --git a/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs b/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs index a81e415..f4070e1 100644 --- a/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs +++ b/backend/src/TeleWave.Infrastructure/Library/GenreSeeder.cs @@ -74,28 +74,7 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger log createdGenres++; } - // Само название и slug тоже годятся как варианты написания — провайдеры отдают то одно, то другое. - foreach (var alias in seed.Aliases.Append(seed.Slug).Append(seed.Name)) - { - var normalized = GenreAlias.Normalize(alias); - if (normalized.Length == 0) - continue; - - if (takenAliases.TryGetValue(normalized, out var owner)) - { - if (owner != seed.Slug) - logger.LogDebug( - "Genre alias '{Alias}' already belongs to '{Owner}' — skipped", - normalized, - owner - ); - continue; - } - - genre.AddAlias(normalized); - takenAliases[normalized] = seed.Slug; - createdAliases++; - } + createdAliases += AddAliases(genre, seed, takenAliases); } if (createdGenres == 0 && createdAliases == 0) @@ -108,4 +87,39 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger log createdAliases ); } + + /// + /// Дописывает жанру недостающие варианты написания. Само название и slug тоже годятся — + /// провайдеры отдают то одно, то другое. Занятый другим жанром вариант пропускается: один + /// и тот же псевдоним не может вести в два жанра, иначе сопоставление стало бы недетерминированным. + /// Возвращает число добавленных. + /// + private int AddAliases(Genre genre, GenreSeed seed, Dictionary takenAliases) + { + var added = 0; + + foreach (var alias in seed.Aliases.Append(seed.Slug).Append(seed.Name)) + { + var normalized = GenreAlias.Normalize(alias); + if (normalized.Length == 0) + continue; + + if (takenAliases.TryGetValue(normalized, out var owner)) + { + if (owner != seed.Slug) + logger.LogDebug( + "Genre alias '{Alias}' already belongs to '{Owner}' — skipped", + normalized, + owner + ); + continue; + } + + genre.AddAlias(normalized); + takenAliases[normalized] = seed.Slug; + added++; + } + + return added; + } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs index dab5d05..1888629 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs @@ -198,57 +198,75 @@ public sealed class FfmpegMediaProcessor( using var doc = JsonDocument.Parse(result.StdOut); var root = doc.RootElement; - double duration = 0; - if ( - root.TryGetProperty("format", out var format) - && format.TryGetProperty("duration", out var durEl) - && double.TryParse( - durEl.GetString(), - NumberStyles.Float, - CultureInfo.InvariantCulture, - out var parsed - ) - ) - duration = parsed; - - int width = 0, - height = 0; - var hasVideo = false; - // Языки аудиодорожек в порядке их следования — индекс в списке = порядковый номер 0:a:N. - var audioLanguages = new List(); - if (root.TryGetProperty("streams", out var streams)) - { - foreach (var stream in streams.EnumerateArray()) - { - if (!stream.TryGetProperty("codec_type", out var typeEl)) - continue; - var codecType = typeEl.GetString(); - if (codecType == "video" && !hasVideo) - { - hasVideo = true; - width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0; - height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0; - } - else if (codecType == "audio") - { - var lang = - stream.TryGetProperty("tags", out var tags) - && tags.TryGetProperty("language", out var langEl) - ? langEl.GetString() - : null; - audioLanguages.Add(lang); - } - } - } + var duration = ParseDuration(root); + var (width, height, audioLanguages) = ParseStreams(root); if (duration <= 0) throw new InvalidOperationException("Не удалось определить длительность файла."); - if (!hasVideo || width <= 0 || height <= 0) + if (width <= 0 || height <= 0) throw new InvalidOperationException("В файле не найдена видеодорожка."); return new ProbeInfo(duration, width, height, audioLanguages); } + /// Длительность из секции format; 0 — поля нет либо оно не разбирается. + private static double ParseDuration(JsonElement root) => + root.TryGetProperty("format", out var format) + && format.TryGetProperty("duration", out var durationElement) + && double.TryParse( + durationElement.GetString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var parsed + ) + ? parsed + : 0; + + /// + /// Размер кадра по первой видеодорожке и языки аудиодорожек в порядке их следования — индекс + /// в списке равен порядковому номеру дорожки в 0:a:N. Нулевой размер означает, что видео + /// в файле не нашлось. + /// + private static (int Width, int Height, List AudioLanguages) ParseStreams( + JsonElement root + ) + { + int width = 0, + height = 0; + // Размер берём у первой видеодорожки и больше не переписываем — даже если у неё нулевые + // размеры: так вело себя прежнее условие, и «починить» его здесь значило бы поменять + // поведение под видом рефакторинга. + var hasVideo = false; + var audioLanguages = new List(); + + if (!root.TryGetProperty("streams", out var streams)) + return (width, height, audioLanguages); + + foreach (var stream in streams.EnumerateArray()) + { + var codecType = stream.TryGetProperty("codec_type", out var typeElement) + ? typeElement.GetString() + : null; + + if (codecType == "video" && !hasVideo) + { + hasVideo = true; + width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0; + height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0; + } + else if (codecType == "audio") + audioLanguages.Add(LanguageOf(stream)); + } + + return (width, height, audioLanguages); + } + + private static string? LanguageOf(JsonElement stream) => + stream.TryGetProperty("tags", out var tags) + && tags.TryGetProperty("language", out var language) + ? language.GetString() + : null; + private async Task> GetPreferredLanguagesAsync( CancellationToken cancellationToken ) diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs index 80c1dd5..0a6b0a7 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs @@ -106,17 +106,7 @@ internal abstract class MediaClaimingBackgroundService( _ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default); } - // Работы нет — ждём сигнала о новой либо периодического опроса. - using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); - wake.CancelAfter(IdlePoll); - try - { - await WaitForWorkAsync(wake.Token); - } - catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) - { - // Тайм-аут опроса — просто перепроверяем БД. - } + await WaitForNextRoundAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -140,6 +130,25 @@ internal abstract class MediaClaimingBackgroundService( } } + /// + /// Работы нет — ждём сигнала о новой либо истечения периода опроса. Тайм-аут здесь штатный + /// выход, а не сбой: отмена по остановке сервиса пробрасывается наружу, отмена по тайм-ауту + /// гасится и приводит к обычной перепроверке БД. + /// + private async Task WaitForNextRoundAsync(CancellationToken stoppingToken) + { + using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + wake.CancelAfter(IdlePoll); + try + { + await WaitForWorkAsync(wake.Token); + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + // Тайм-аут опроса — просто перепроверяем БД. + } + } + /// Помечает ассет провалившимся. Вызывается воркером из его обработчика ошибок. protected async Task FailAsync( Guid assetId, diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index f3bb9b1..d8b8fed 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -57,15 +57,15 @@ public class AppDbContext(DbContextOptions options) return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken); } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); // Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный // ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию // отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0». - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + foreach (var entityType in builder.Model.GetEntityTypes()) { if ( entityType.ClrType.Namespace?.StartsWith(