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.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 1m4s
ci / tests (push) Successful in 1m58s
ci / sonar (push) Successful in 3m34s

This commit is contained in:
Leonid Pershin
2026-07-26 23:31:56 +03:00
parent 69d1319564
commit 59cc453026
11 changed files with 544 additions and 362 deletions
@@ -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<PlanningUnit> 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<PlanningElement>();
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;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> 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)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
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()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> 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<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
@@ -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<Guid, Guid>();
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)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
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);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> 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;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> 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<Guid, Guid> 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)
);
}
@@ -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}»."
);
}
}
}