Refactor RenderBumperPreviewCommandHandler to streamline background path resolution and rendering specification. Introduce helper methods for improved readability and maintainability. Update FindMissingEpisodesQueryHandler and RefreshShowEpisodesMetadataCommandHandler to encapsulate episode number resolution logic. Enhance CopyTemplateCommandHandler by modularizing junction copying and slot handling for better clarity. Improve FfmpegMediaProcessor by separating duration and stream parsing into dedicated methods. Refactor MediaClaimingBackgroundService to simplify idle waiting logic. Update GenreSeeder to modularize alias addition logic, enhancing code organization.
ci / build-backend (push) Successful in 1m29s
ci / build-frontend (push) Successful in 1m4s
ci / tests (push) Successful in 1m58s
ci / sonar (push) Successful in 3m34s

This commit is contained in:
Leonid Pershin
2026-07-26 23:31:56 +03:00
parent 69d1319564
commit 59cc453026
11 changed files with 544 additions and 362 deletions
@@ -74,28 +74,7 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> log
createdGenres++;
}
// Само название и slug тоже годятся как варианты написания — провайдеры отдают то одно, то другое.
foreach (var alias in seed.Aliases.Append(seed.Slug).Append(seed.Name))
{
var normalized = GenreAlias.Normalize(alias);
if (normalized.Length == 0)
continue;
if (takenAliases.TryGetValue(normalized, out var owner))
{
if (owner != seed.Slug)
logger.LogDebug(
"Genre alias '{Alias}' already belongs to '{Owner}' — skipped",
normalized,
owner
);
continue;
}
genre.AddAlias(normalized);
takenAliases[normalized] = seed.Slug;
createdAliases++;
}
createdAliases += AddAliases(genre, seed, takenAliases);
}
if (createdGenres == 0 && createdAliases == 0)
@@ -108,4 +87,39 @@ public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> log
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);
var root = doc.RootElement;
double duration = 0;
if (
root.TryGetProperty("format", out var format)
&& format.TryGetProperty("duration", out var durEl)
&& double.TryParse(
durEl.GetString(),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsed
)
)
duration = parsed;
int width = 0,
height = 0;
var hasVideo = false;
// Языки аудиодорожек в порядке их следования — индекс в списке = порядковый номер 0:a:N.
var audioLanguages = new List<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);
}
}
}
var duration = ParseDuration(root);
var (width, height, audioLanguages) = ParseStreams(root);
if (duration <= 0)
throw new InvalidOperationException("Не удалось определить длительность файла.");
if (!hasVideo || width <= 0 || height <= 0)
if (width <= 0 || height <= 0)
throw new InvalidOperationException("В файле не найдена видеодорожка.");
return new ProbeInfo(duration, width, height, audioLanguages);
}
/// <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(
CancellationToken cancellationToken
)
@@ -106,17 +106,7 @@ internal abstract class MediaClaimingBackgroundService<TJob>(
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
}
// Работы нет — ждём сигнала о новой либо периодического опроса.
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
wake.CancelAfter(IdlePoll);
try
{
await WaitForWorkAsync(wake.Token);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
// Тайм-аут опроса — просто перепроверяем БД.
}
await WaitForNextRoundAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -140,6 +130,25 @@ internal abstract class MediaClaimingBackgroundService<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>
protected async Task FailAsync(
Guid assetId,
@@ -57,15 +57,15 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
foreach (var entityType in builder.Model.GetEntityTypes())
{
if (
entityType.ClrType.Namespace?.StartsWith(