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:
+52
-19
@@ -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,9 +58,58 @@ 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,
|
||||||
|
fontFile,
|
||||||
|
backgroundPath,
|
||||||
|
audioPath,
|
||||||
|
fromName,
|
||||||
|
toName
|
||||||
|
);
|
||||||
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Width,
|
||||||
_bumper.Height,
|
_bumper.Height,
|
||||||
template.BackgroundColor,
|
template.BackgroundColor,
|
||||||
@@ -92,10 +129,6 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
variant.Line1,
|
variant.Line1,
|
||||||
variant.Line2
|
variant.Line2
|
||||||
);
|
);
|
||||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-35
@@ -48,43 +48,65 @@ 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))
|
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);
|
var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken);
|
||||||
if (downloaded is not null)
|
if (downloaded is null)
|
||||||
{
|
return null;
|
||||||
var image = Image.Create(
|
|
||||||
ImageCategory.EpisodeStill,
|
var image = Image.Create(ImageCategory.EpisodeStill, downloaded.Extension, meta.Title);
|
||||||
downloaded.Extension,
|
|
||||||
meta.Title
|
|
||||||
);
|
|
||||||
dbContext.Images.Add(image);
|
dbContext.Images.Add(image);
|
||||||
await imageStore.SaveAsync(
|
await imageStore.SaveAsync(
|
||||||
image.Id,
|
image.Id,
|
||||||
@@ -92,14 +114,6 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler(
|
|||||||
downloaded.Content,
|
downloaded.Content,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
stillImageId = image.Id;
|
return image.Id;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
episode.ApplyMetadata(meta.Title, meta.Overview, stillImageId, meta.AirDate);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Success(updated);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,88 +88,95 @@ 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(
|
|
||||||
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();
|
.ToList();
|
||||||
|
|
||||||
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
elements.Add(
|
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
|
||||||
new PlanningElement(
|
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.ElementKind,
|
||||||
item.ElementId,
|
item.ElementId,
|
||||||
item.Weight,
|
item.Weight,
|
||||||
item.Position,
|
item.Position,
|
||||||
units,
|
UnitsOf(item.ElementId),
|
||||||
// Остывание коллекции считается по самой свежей из её частей: показ любой
|
LastPlayedOf(item.ElementId),
|
||||||
// из них означает, что франшиза недавно была в эфире.
|
AudienceOf(item.ElementId),
|
||||||
partShowIds
|
RecentPlaysOf(item.ElementId)
|
||||||
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
|
);
|
||||||
.Where(p => p is not null)
|
|
||||||
.DefaultIfEmpty(null)
|
/// <summary>
|
||||||
.Max(),
|
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
|
||||||
// Рейтинг коллекции — строжайший среди частей: франшиза идёт целиком,
|
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
|
||||||
// и одна взрослая часть делает взрослой всю.
|
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
|
||||||
partShowIds
|
/// </summary>
|
||||||
.Select(id =>
|
public PlanningElement CollectionElement(GroupItem item)
|
||||||
episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null
|
{
|
||||||
)
|
var parts = CollectionParts[item.ElementId].ToList();
|
||||||
.Where(a => a is not null)
|
return new PlanningElement(
|
||||||
.DefaultIfEmpty(null)
|
item.ElementKind,
|
||||||
.Max(),
|
item.ElementId,
|
||||||
partShowIds
|
item.Weight,
|
||||||
.SelectMany(id =>
|
item.Position,
|
||||||
recentPlays.TryGetValue(id, out var p) ? p : []
|
parts.SelectMany(UnitsOf).ToList(),
|
||||||
)
|
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
|
||||||
.ToList()
|
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
|
||||||
)
|
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
result[group.Id] = elements;
|
/// <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()
|
||||||
|
: [];
|
||||||
|
|
||||||
return result;
|
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>
|
||||||
|
|||||||
+107
-47
@@ -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;
|
||||||
|
|
||||||
@@ -119,6 +175,20 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
layers++;
|
layers++;
|
||||||
|
|
||||||
foreach (var slot in layer.Slots)
|
foreach (var slot in layer.Slots)
|
||||||
|
{
|
||||||
|
CopySlot(slot, copyLayer, junctionMap);
|
||||||
|
slots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (layers, slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopySlot(
|
||||||
|
Slot slot,
|
||||||
|
GridLayer copyLayer,
|
||||||
|
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var copySlot = copyLayer.AddSlot(
|
var copySlot = copyLayer.AddSlot(
|
||||||
slot.Title,
|
slot.Title,
|
||||||
@@ -149,16 +219,6 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
Map(slot.JunctionBetweenId, junctionMap),
|
Map(slot.JunctionBetweenId, junctionMap),
|
||||||
Map(slot.JunctionAfterId, junctionMap)
|
Map(slot.JunctionAfterId, junctionMap)
|
||||||
);
|
);
|
||||||
slots++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
|
||||||
target.SetTemplate(copyTemplate.Id);
|
|
||||||
|
|
||||||
return Result.Success(
|
|
||||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||||
|
|||||||
+8
-1
@@ -154,7 +154,10 @@ 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++)
|
for (var j = i + 1; j < slots.Count; j++)
|
||||||
{
|
{
|
||||||
var a = slots[i];
|
var a = slots[i];
|
||||||
@@ -164,7 +167,10 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
|||||||
|
|
||||||
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 (
|
||||||
|
aFrom < bFrom + b.TargetDurationMinutes
|
||||||
|
&& bFrom < aFrom + a.TargetDurationMinutes
|
||||||
|
)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.SlotOverlap,
|
TemplateIssueKind.SlotOverlap,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
@@ -174,6 +180,7 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||||
private static bool SameDays(Slot a, Slot b) =>
|
private static bool SameDays(Slot a, Slot b) =>
|
||||||
|
|||||||
@@ -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,7 +74,30 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> log
|
|||||||
createdGenres++;
|
createdGenres++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Само название и slug тоже годятся как варианты написания — провайдеры отдают то одно, то другое.
|
createdAliases += AddAliases(genre, seed, takenAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdGenres == 0 && createdAliases == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Seeded genres: {Genres} new, {Aliases} new aliases",
|
||||||
|
createdGenres,
|
||||||
|
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))
|
foreach (var alias in seed.Aliases.Append(seed.Slug).Append(seed.Name))
|
||||||
{
|
{
|
||||||
var normalized = GenreAlias.Normalize(alias);
|
var normalized = GenreAlias.Normalize(alias);
|
||||||
@@ -94,18 +117,9 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> log
|
|||||||
|
|
||||||
genre.AddAlias(normalized);
|
genre.AddAlias(normalized);
|
||||||
takenAliases[normalized] = seed.Slug;
|
takenAliases[normalized] = seed.Slug;
|
||||||
createdAliases++;
|
added++;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (createdGenres == 0 && createdAliases == 0)
|
return added;
|
||||||
return;
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation(
|
|
||||||
"Seeded genres: {Genres} new, {Aliases} new aliases",
|
|
||||||
createdGenres,
|
|
||||||
createdAliases
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,31 +198,56 @@ 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);
|
||||||
|
|
||||||
|
if (duration <= 0)
|
||||||
|
throw new InvalidOperationException("Не удалось определить длительность файла.");
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
throw new InvalidOperationException("В файле не найдена видеодорожка.");
|
||||||
|
|
||||||
|
return new ProbeInfo(duration, width, height, audioLanguages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Длительность из секции format; 0 — поля нет либо оно не разбирается.</summary>
|
||||||
|
private static double ParseDuration(JsonElement root) =>
|
||||||
root.TryGetProperty("format", out var format)
|
root.TryGetProperty("format", out var format)
|
||||||
&& format.TryGetProperty("duration", out var durEl)
|
&& format.TryGetProperty("duration", out var durationElement)
|
||||||
&& double.TryParse(
|
&& double.TryParse(
|
||||||
durEl.GetString(),
|
durationElement.GetString(),
|
||||||
NumberStyles.Float,
|
NumberStyles.Float,
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
out var parsed
|
out var parsed
|
||||||
)
|
)
|
||||||
)
|
? parsed
|
||||||
duration = parsed;
|
: 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Размер кадра по первой видеодорожке и языки аудиодорожек в порядке их следования — индекс
|
||||||
|
/// в списке равен порядковому номеру дорожки в <c>0:a:N</c>. Нулевой размер означает, что видео
|
||||||
|
/// в файле не нашлось.
|
||||||
|
/// </summary>
|
||||||
|
private static (int Width, int Height, List<string?> AudioLanguages) ParseStreams(
|
||||||
|
JsonElement root
|
||||||
|
)
|
||||||
|
{
|
||||||
int width = 0,
|
int width = 0,
|
||||||
height = 0;
|
height = 0;
|
||||||
|
// Размер берём у первой видеодорожки и больше не переписываем — даже если у неё нулевые
|
||||||
|
// размеры: так вело себя прежнее условие, и «починить» его здесь значило бы поменять
|
||||||
|
// поведение под видом рефакторинга.
|
||||||
var hasVideo = false;
|
var hasVideo = false;
|
||||||
// Языки аудиодорожек в порядке их следования — индекс в списке = порядковый номер 0:a:N.
|
|
||||||
var audioLanguages = new List<string?>();
|
var audioLanguages = new List<string?>();
|
||||||
if (root.TryGetProperty("streams", out var streams))
|
|
||||||
{
|
if (!root.TryGetProperty("streams", out var streams))
|
||||||
|
return (width, height, audioLanguages);
|
||||||
|
|
||||||
foreach (var stream in streams.EnumerateArray())
|
foreach (var stream in streams.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (!stream.TryGetProperty("codec_type", out var typeEl))
|
var codecType = stream.TryGetProperty("codec_type", out var typeElement)
|
||||||
continue;
|
? typeElement.GetString()
|
||||||
var codecType = typeEl.GetString();
|
: null;
|
||||||
|
|
||||||
if (codecType == "video" && !hasVideo)
|
if (codecType == "video" && !hasVideo)
|
||||||
{
|
{
|
||||||
hasVideo = true;
|
hasVideo = true;
|
||||||
@@ -230,24 +255,17 @@ public sealed class FfmpegMediaProcessor(
|
|||||||
height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0;
|
height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0;
|
||||||
}
|
}
|
||||||
else if (codecType == "audio")
|
else if (codecType == "audio")
|
||||||
{
|
audioLanguages.Add(LanguageOf(stream));
|
||||||
var lang =
|
}
|
||||||
|
|
||||||
|
return (width, height, audioLanguages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? LanguageOf(JsonElement stream) =>
|
||||||
stream.TryGetProperty("tags", out var tags)
|
stream.TryGetProperty("tags", out var tags)
|
||||||
&& tags.TryGetProperty("language", out var langEl)
|
&& tags.TryGetProperty("language", out var language)
|
||||||
? langEl.GetString()
|
? language.GetString()
|
||||||
: null;
|
: null;
|
||||||
audioLanguages.Add(lang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration <= 0)
|
|
||||||
throw new InvalidOperationException("Не удалось определить длительность файла.");
|
|
||||||
if (!hasVideo || width <= 0 || height <= 0)
|
|
||||||
throw new InvalidOperationException("В файле не найдена видеодорожка.");
|
|
||||||
|
|
||||||
return new ProbeInfo(duration, width, height, audioLanguages);
|
|
||||||
}
|
|
||||||
|
|
||||||
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