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.
This commit is contained in:
+63
-30
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
|
||||
private async Task<string?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Спецификация рендера одного подблока. В режиме <see cref="BumperTextKind.Free"/> подписи и
|
||||
/// названия шоу гасятся: там на экране произвольные строки, а не «Сейчас/Далее».
|
||||
/// </summary>
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
||||
|
||||
+38
-21
@@ -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<int, HashSet<int>>();
|
||||
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<SeasonGapDto>();
|
||||
foreach (var season in loadedBySeason.Keys.OrderBy(s => s))
|
||||
@@ -83,4 +63,41 @@ public sealed class FindMissingEpisodesQueryHandler(
|
||||
|
||||
return Result.Success(new MissingEpisodesReport(seasons));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Загруженные номера серий по сезонам — только те сезоны, где есть хотя бы одна серия. Номера
|
||||
/// берутся проставленные, а при их отсутствии распознаются из имени файла (здесь, в отличие от
|
||||
/// обновления метаданных, распознанное не сохраняется: запрос ничего не меняет).
|
||||
/// </summary>
|
||||
private static Dictionary<int, HashSet<int>> GroupLoadedBySeason(
|
||||
Show show,
|
||||
IReadOnlyDictionary<Guid, string> assetNames
|
||||
)
|
||||
{
|
||||
var loadedBySeason = new Dictionary<int, HashSet<int>>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Сезон и номер серии: проставленные либо распознанные из имени файла; null — не вышло.</summary>
|
||||
private static (int Season, int Episode)? ResolveNumbers(
|
||||
ShowEpisode episode,
|
||||
IReadOnlyDictionary<Guid, string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+53
-39
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сезон и номер серии: сперва проставленные, иначе распознанные из имени файла (и тогда сразу
|
||||
/// сохраняются на серии). null — распознать не удалось, серию пропускаем.
|
||||
/// </summary>
|
||||
private static (int Season, int Episode)? ResolveNumbers(
|
||||
ShowEpisode episode,
|
||||
IReadOnlyDictionary<Guid, string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Скачивает кадр серии и регистрирует его в общем реестре изображений. null — кадра нет либо
|
||||
/// скачать не удалось; это не повод отменять применение остальных метаданных.
|
||||
/// </summary>
|
||||
private async Task<Guid?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||
|
||||
+131
-71
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+22
-15
@@ -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}»."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user