using System.Linq; using EmbyToolbox.Models; namespace EmbyToolbox.Services; /// Ход пакетного анализа очереди (ffprobe) для IProgress и UI. public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount); /// /// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по , /// обновление строки через (UI). /// public sealed class QueueAnalysisService { private const int MaxParallel = 2; private readonly FfprobeService _ffprobe; private readonly LoggingService _logging; private readonly SidecarDiscoveryService _sidecar; private readonly ConversionPlanService _planService; private readonly IProfileSettingsProvider _profile; public QueueAnalysisService( FfprobeService ffprobe, LoggingService logging, SidecarDiscoveryService sidecar, ConversionPlanService planService, IProfileSettingsProvider profile) { _ffprobe = ffprobe; _logging = logging; _sidecar = sidecar; _planService = planService; _profile = profile; } public async Task RunAsync( IReadOnlyList items, Func isStillInQueue, bool autoRemoveForeignTracks, bool disableSubtitleDefault, IProgress? progress, Func uiInvoke, CancellationToken cancellationToken) { if (items.Count == 0) { return 0; } var total = items.Count; var lockObj = new object(); var state = new ProgressState(); var sem = new SemaphoreSlim(MaxParallel, MaxParallel); var tasks = items .Select( item => ProcessOneAsync( item, isStillInQueue, progress, uiInvoke, autoRemoveForeignTracks, disableSubtitleDefault, sem, lockObj, total, state, cancellationToken)) .ToArray(); await Task.WhenAll(tasks).ConfigureAwait(false); return state.ErrorCount; } private async Task ProcessOneAsync( ConversionQueueItem item, Func isStillInQueue, IProgress? progress, Func uiInvoke, bool autoRemoveForeignTracks, bool disableSubtitleDefault, SemaphoreSlim sem, object lockObj, int total, ProgressState state, CancellationToken cancellationToken) { var acquired = false; var itemAnalysisFailed = false; try { try { await sem.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false); return; } acquired = true; if (cancellationToken.IsCancellationRequested) { await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false); return; } FfprobeResult result; try { result = await _ffprobe.AnalyzeAsync(item.FullPath, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false); return; } if (cancellationToken.IsCancellationRequested) { await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false); return; } SidecarDiscoveryResult discovery; try { discovery = await _sidecar.DiscoverAsync(item.FullPath, _ffprobe, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false); return; } catch (Exception ex) { _logging.Error($"поиск sidecar: {ex.Message} — {item.FullPath}", "conversion.sidecar", ex); discovery = new SidecarDiscoveryResult(Array.Empty(), Array.Empty()); } var media = result.IsSuccess ? MediaAnalysisParser.TryParse(result.Json) : null; var failed = false; await uiInvoke( () => { if (!isStillInQueue(item)) { return; } if (item.Status != ConversionQueueStatus.Analyzing) { return; } if (!result.IsSuccess) { failed = true; item.PlanSummary = "Ошибка анализа"; item.Status = ConversionQueueStatus.Error; item.Progress = 0; item.ErrorMessage = string.IsNullOrWhiteSpace(result.Error) ? "Ошибка ffprobe." : result.Error.Trim(); item.ErrorDetails = string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim(); _logging.Error($"ffprobe: {result.Error} — {item.FullPath}", "conversion.ffprobe"); return; } if (media is null) { failed = true; item.PlanSummary = "Ошибка анализа"; item.Status = ConversionQueueStatus.Error; item.Progress = 0; item.ErrorMessage = "Не удалось разобрать ответ ffprobe (неверный JSON)."; item.ErrorDetails = !string.IsNullOrWhiteSpace(result.StdErr) ? result.StdErr.Trim() : (string.IsNullOrWhiteSpace(result.Json) ? null : result.Json.Trim()); _logging.Error($"ffprobe: неверный JSON (media) — {item.FullPath}", "conversion.ffprobe"); return; } var audio = FfprobeAudioInfoParser.TryParse(result.Json) ?? new FfprobeAudioInfo(0, null, true); var side = discovery.Sidecars; var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; // При повторном анализе sidecar-набор может измениться (добавили/удалили внешние файлы). // Пересобираем список дорожек, чтобы не держать устаревшие external entries. item.TaskOverride.TrackOverrides.Clear(); TrackOverrideSeeder.EnsureDefaults( item.TaskOverride, media, side, profile, autoRemoveForeignTracks, discovery.ExternalAudioFiles, item.FullPath, sidecarTitleResolver: null, logging: _logging, disableSubtitleDefault: disableSubtitleDefault); if (autoRemoveForeignTracks) { var removedForeign = item.TaskOverride.TrackOverrides.Count(t => t.Source == SourceKind.Embedded && t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle && t.Action == TrackActionKind.Remove && IsForeignLanguageForAutoRemove(t.Language)); if (removedForeign > 0) { _logging.Info($"автоудаление иностранных дорожек: {removedForeign} ({item.FullPath})", "conversion.queue"); } } var plan = _planService.Build(media, side, profile, item.TaskOverride, discovery.ExternalAudioFiles); item.SetSuccessfulMediaAnalysis( media, side, discovery.ExternalAudioFiles, plan, audio.AudioStreamCount, audio.AudioSizeMbTotal, audio.IsPartial); item.Status = ConversionQueueStatus.Pending; item.Progress = 0; }) .ConfigureAwait(false); if (failed) { itemAnalysisFailed = true; } } catch (Exception ex) when (ex is not OperationCanceledException) { itemAnalysisFailed = true; _logging.Error($"ffprobe очереди: {ex.Message}", "conversion.ffprobe", ex); await uiInvoke( () => { if (!isStillInQueue(item) || item.Status != ConversionQueueStatus.Analyzing) { return; } item.PlanSummary = "Ошибка анализа"; item.Status = ConversionQueueStatus.Error; item.Progress = 0; item.ErrorMessage = ex.Message; item.ErrorDetails = null; }) .ConfigureAwait(false); } finally { int p; int e; lock (lockObj) { state.ProcessedCount++; if (itemAnalysisFailed) { state.ErrorCount++; } p = state.ProcessedCount; e = state.ErrorCount; } progress?.Report(new QueueAnalysisProgress(p, total, e)); if (acquired) { sem.Release(); } } } private static async Task MarkCancelledOnUiAsync( ConversionQueueItem item, Func isStillInQueue, Func uiInvoke) { await uiInvoke( () => { if (!isStillInQueue(item)) { return; } if (item.Status != ConversionQueueStatus.Analyzing) { return; } item.PlanSummary = "Анализ отменён"; item.Status = ConversionQueueStatus.Cancelled; item.Progress = 0; }) .ConfigureAwait(false); } private sealed class ProgressState { public int ProcessedCount; public int ErrorCount; } private static bool IsForeignLanguageForAutoRemove(string? language) { if (string.IsNullOrWhiteSpace(language)) { return false; } var lang = language.Trim().ToLowerInvariant(); if (lang is "und" or "unknown" or "?") { return false; } return lang is not ("rus" or "ru"); } }