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 =
|
var fontFile =
|
||||||
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||||
|
|
||||||
// Фон блока — из общего реестра по id (общий для всех подблоков).
|
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
||||||
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 seconds = template.AudioDurationSeconds is { } d and > 0
|
var seconds = template.AudioDurationSeconds is { } d and > 0
|
||||||
? d
|
? d
|
||||||
: DefaultBumperDurationSeconds;
|
: DefaultBumperDurationSeconds;
|
||||||
@@ -70,27 +58,15 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||||
{
|
{
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
var spec = BuildSpec(
|
||||||
var spec = new BumperRenderSpec(
|
variant,
|
||||||
|
template,
|
||||||
aligned,
|
aligned,
|
||||||
_bumper.Width,
|
|
||||||
_bumper.Height,
|
|
||||||
template.BackgroundColor,
|
|
||||||
template.BackgroundColor2,
|
|
||||||
template.AccentColor,
|
|
||||||
template.TextColor,
|
|
||||||
fontFile,
|
fontFile,
|
||||||
free ? "" : variant.NowLabel,
|
|
||||||
free ? "" : fromName,
|
|
||||||
free ? "" : variant.NextLabel,
|
|
||||||
free ? "" : toName,
|
|
||||||
backgroundPath,
|
backgroundPath,
|
||||||
audioPath,
|
audioPath,
|
||||||
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
fromName,
|
||||||
null,
|
toName
|
||||||
free,
|
|
||||||
variant.Line1,
|
|
||||||
variant.Line2
|
|
||||||
);
|
);
|
||||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -98,6 +74,63 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
return Result.Success();
|
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>
|
/// <summary>
|
||||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
||||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
||||||
|
|||||||
+38
-21
@@ -44,27 +44,7 @@ public sealed class FindMissingEpisodesQueryHandler(
|
|||||||
.Select(a => new { a.Id, a.OriginalFileName })
|
.Select(a => new { a.Id, a.OriginalFileName })
|
||||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||||
|
|
||||||
// Загруженные номера серий по сезонам (только сезоны с хотя бы одной серией).
|
var loadedBySeason = GroupLoadedBySeason(show, names);
|
||||||
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 seasons = new List<SeasonGapDto>();
|
var seasons = new List<SeasonGapDto>();
|
||||||
foreach (var season in loadedBySeason.Keys.OrderBy(s => s))
|
foreach (var season in loadedBySeason.Keys.OrderBy(s => s))
|
||||||
@@ -83,4 +63,41 @@ public sealed class FindMissingEpisodesQueryHandler(
|
|||||||
|
|
||||||
return Result.Success(new MissingEpisodesReport(seasons));
|
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;
|
var updated = 0;
|
||||||
foreach (var episode in show.Episodes)
|
foreach (var episode in show.Episodes)
|
||||||
{
|
{
|
||||||
var season = episode.Season;
|
if (ResolveNumbers(episode, names) is not { } numbers)
|
||||||
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)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var meta = await provider.GetEpisodeAsync(
|
var meta = await provider.GetEpisodeAsync(
|
||||||
show.MetadataExternalId,
|
show.MetadataExternalId,
|
||||||
season.Value,
|
numbers.Season,
|
||||||
number.Value,
|
numbers.Episode,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (meta is null)
|
if (meta is null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
Guid? stillImageId = null;
|
var stillImageId = await SaveStillAsync(meta, cancellationToken);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
episode.ApplyMetadata(meta.Title, meta.Overview, stillImageId, meta.AirDate);
|
episode.ApplyMetadata(meta.Title, meta.Overview, stillImageId, meta.AirDate);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success(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()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var episodes = await dbContext
|
var loaded = await dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id))
|
.Where(s => showIds.Contains(s.Id))
|
||||||
.Select(s => new
|
.Select(s => new
|
||||||
{
|
{
|
||||||
s.Id,
|
s.Id,
|
||||||
s.Audience,
|
s.Audience,
|
||||||
Episodes = s
|
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
|
||||||
.Episodes.OrderBy(e => e.Position)
|
|
||||||
.Select(e => new { e.MediaAssetId, e.Position })
|
|
||||||
.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
|
var durations = await dbContext
|
||||||
.MediaAssets.AsNoTracking()
|
.MediaAssets.AsNoTracking()
|
||||||
.Where(a =>
|
.Where(a =>
|
||||||
@@ -90,90 +88,97 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
List<PlanningUnit> UnitsOfShow(Guid showId)
|
var data = new ExpansionData(
|
||||||
{
|
shows,
|
||||||
if (!episodes.TryGetValue(showId, out var show))
|
durations,
|
||||||
return [];
|
lastPlayed,
|
||||||
|
recentPlays,
|
||||||
return show
|
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
|
||||||
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
|
);
|
||||||
.Select((e, index) => new PlanningUnit(
|
|
||||||
e.MediaAssetId,
|
|
||||||
durations[e.MediaAssetId],
|
|
||||||
showId,
|
|
||||||
index
|
|
||||||
))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var group in groups)
|
foreach (var group in groups)
|
||||||
{
|
result[group.Id] = group
|
||||||
var elements = new List<PlanningElement>();
|
.Items.OrderBy(i => i.Position)
|
||||||
|
.Select(item =>
|
||||||
foreach (var item in group.Items.OrderBy(i => i.Position))
|
item.ElementKind == GroupElementKind.Show
|
||||||
{
|
? data.ShowElement(item)
|
||||||
if (item.ElementKind == GroupElementKind.Show)
|
: data.CollectionElement(item)
|
||||||
{
|
)
|
||||||
elements.Add(
|
.ToList();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||||
|
|||||||
+131
-71
@@ -48,44 +48,12 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
.Select(t => new { t.Id, t.Name })
|
.Select(t => new { t.Id, t.Name })
|
||||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||||
|
|
||||||
var junctionMap = new Dictionary<Guid, Guid>();
|
var (junctionMap, droppedBumperRefs) = CopyJunctions(
|
||||||
var droppedBumperRefs = 0;
|
sourceJunctions,
|
||||||
|
target.Id,
|
||||||
foreach (var junction in sourceJunctions)
|
sourceBumperNames,
|
||||||
{
|
bumperByName
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||||
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||||
@@ -105,6 +73,94 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault))
|
if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault))
|
||||||
copyTemplate.SetDefaultJunction(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 layers = 0;
|
||||||
var slots = 0;
|
var slots = 0;
|
||||||
|
|
||||||
@@ -120,44 +176,48 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
|
|
||||||
foreach (var slot in layer.Slots)
|
foreach (var slot in layer.Slots)
|
||||||
{
|
{
|
||||||
var copySlot = copyLayer.AddSlot(
|
CopySlot(slot, copyLayer, junctionMap);
|
||||||
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)
|
|
||||||
);
|
|
||||||
slots++;
|
slots++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
return (layers, slots);
|
||||||
target.SetTemplate(copyTemplate.Id);
|
}
|
||||||
|
|
||||||
return Result.Success(
|
private static void CopySlot(
|
||||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
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();
|
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||||
|
|
||||||
|
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
|
||||||
|
// и «телом» внешнего цикла его читает только компилятор.
|
||||||
for (var i = 0; i < slots.Count; i++)
|
for (var i = 0; i < slots.Count; i++)
|
||||||
for (var j = i + 1; j < slots.Count; j++)
|
|
||||||
{
|
{
|
||||||
var a = slots[i];
|
for (var j = i + 1; j < slots.Count; j++)
|
||||||
var b = slots[j];
|
{
|
||||||
if (!SameDays(a, b))
|
var a = slots[i];
|
||||||
continue;
|
var b = slots[j];
|
||||||
|
if (!SameDays(a, b))
|
||||||
|
continue;
|
||||||
|
|
||||||
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||||
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||||
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
|
if (
|
||||||
yield return new TemplateIssueDto(
|
aFrom < bFrom + b.TargetDurationMinutes
|
||||||
TemplateIssueKind.SlotOverlap,
|
&& bFrom < aFrom + a.TargetDurationMinutes
|
||||||
TemplateIssueSeverity.Warning,
|
)
|
||||||
layer.Id,
|
yield return new TemplateIssueDto(
|
||||||
b.Id,
|
TemplateIssueKind.SlotOverlap,
|
||||||
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
TemplateIssueSeverity.Warning,
|
||||||
);
|
layer.Id,
|
||||||
|
b.Id,
|
||||||
|
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,27 +244,7 @@ public static class SchedulePlanner
|
|||||||
var pick = ElementSelector.Select(slot, cursor, random);
|
var pick = ElementSelector.Select(slot, cursor, random);
|
||||||
if (pick is null)
|
if (pick is null)
|
||||||
{
|
{
|
||||||
// Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
|
warnings.Add(NoCandidatesWarning(slot));
|
||||||
// но не подходит по правилам, и админу надо чинить правило, а не состав группы.
|
|
||||||
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,
|
|
||||||
"Слот не дал контента — место закрыл фон."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return FillWithFallback(
|
return FillWithFallback(
|
||||||
cursor,
|
cursor,
|
||||||
Min(slot.TargetEndUtc, limit),
|
Min(slot.TargetEndUtc, limit),
|
||||||
@@ -365,6 +345,31 @@ public static class SchedulePlanner
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
|
||||||
|
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
|
||||||
|
/// группы.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
"Слот не дал контента — место закрыл фон."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
|
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
|
||||||
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
|
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
|
||||||
|
|||||||
@@ -74,28 +74,7 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> log
|
|||||||
createdGenres++;
|
createdGenres++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Само название и slug тоже годятся как варианты написания — провайдеры отдают то одно, то другое.
|
createdAliases += AddAliases(genre, seed, takenAliases);
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (createdGenres == 0 && createdAliases == 0)
|
if (createdGenres == 0 && createdAliases == 0)
|
||||||
@@ -108,4 +87,39 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> log
|
|||||||
createdAliases
|
createdAliases
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дописывает жанру недостающие варианты написания. Само название и slug тоже годятся —
|
||||||
|
/// провайдеры отдают то одно, то другое. Занятый другим жанром вариант пропускается: один
|
||||||
|
/// и тот же псевдоним не может вести в два жанра, иначе сопоставление стало бы недетерминированным.
|
||||||
|
/// Возвращает число добавленных.
|
||||||
|
/// </summary>
|
||||||
|
private int AddAliases(Genre genre, GenreSeed seed, Dictionary<string, string> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,57 +198,75 @@ public sealed class FfmpegMediaProcessor(
|
|||||||
using var doc = JsonDocument.Parse(result.StdOut);
|
using var doc = JsonDocument.Parse(result.StdOut);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
double duration = 0;
|
var duration = ParseDuration(root);
|
||||||
if (
|
var (width, height, audioLanguages) = ParseStreams(root);
|
||||||
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<string?>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration <= 0)
|
if (duration <= 0)
|
||||||
throw new InvalidOperationException("Не удалось определить длительность файла.");
|
throw new InvalidOperationException("Не удалось определить длительность файла.");
|
||||||
if (!hasVideo || width <= 0 || height <= 0)
|
if (width <= 0 || height <= 0)
|
||||||
throw new InvalidOperationException("В файле не найдена видеодорожка.");
|
throw new InvalidOperationException("В файле не найдена видеодорожка.");
|
||||||
|
|
||||||
return new ProbeInfo(duration, width, height, audioLanguages);
|
return new ProbeInfo(duration, width, height, audioLanguages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Длительность из секции format; 0 — поля нет либо оно не разбирается.</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Размер кадра по первой видеодорожке и языки аудиодорожек в порядке их следования — индекс
|
||||||
|
/// в списке равен порядковому номеру дорожки в <c>0:a:N</c>. Нулевой размер означает, что видео
|
||||||
|
/// в файле не нашлось.
|
||||||
|
/// </summary>
|
||||||
|
private static (int Width, int Height, List<string?> AudioLanguages) ParseStreams(
|
||||||
|
JsonElement root
|
||||||
|
)
|
||||||
|
{
|
||||||
|
int width = 0,
|
||||||
|
height = 0;
|
||||||
|
// Размер берём у первой видеодорожки и больше не переписываем — даже если у неё нулевые
|
||||||
|
// размеры: так вело себя прежнее условие, и «починить» его здесь значило бы поменять
|
||||||
|
// поведение под видом рефакторинга.
|
||||||
|
var hasVideo = false;
|
||||||
|
var audioLanguages = new List<string?>();
|
||||||
|
|
||||||
|
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<IReadOnlyList<string>> GetPreferredLanguagesAsync(
|
private async Task<IReadOnlyList<string>> GetPreferredLanguagesAsync(
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -106,17 +106,7 @@ internal abstract class MediaClaimingBackgroundService<TJob>(
|
|||||||
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
|
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Работы нет — ждём сигнала о новой либо периодического опроса.
|
await WaitForNextRoundAsync(stoppingToken);
|
||||||
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
|
||||||
wake.CancelAfter(IdlePoll);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await WaitForWorkAsync(wake.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Тайм-аут опроса — просто перепроверяем БД.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -140,6 +130,25 @@ internal abstract class MediaClaimingBackgroundService<TJob>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Работы нет — ждём сигнала о новой либо истечения периода опроса. Тайм-аут здесь штатный
|
||||||
|
/// выход, а не сбой: отмена по остановке сервиса пробрасывается наружу, отмена по тайм-ауту
|
||||||
|
/// гасится и приводит к обычной перепроверке БД.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Тайм-аут опроса — просто перепроверяем БД.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Помечает ассет провалившимся. Вызывается воркером из его обработчика ошибок.</summary>
|
/// <summary>Помечает ассет провалившимся. Вызывается воркером из его обработчика ошибок.</summary>
|
||||||
protected async Task FailAsync(
|
protected async Task FailAsync(
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
|
|||||||
@@ -57,15 +57,15 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
|
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);
|
base.OnModelCreating(builder);
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||||
|
|
||||||
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
|
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
|
||||||
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
|
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
|
||||||
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
|
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
|
||||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||||
{
|
{
|
||||||
if (
|
if (
|
||||||
entityType.ClrType.Namespace?.StartsWith(
|
entityType.ClrType.Namespace?.StartsWith(
|
||||||
|
|||||||
Reference in New Issue
Block a user