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(