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(