emby-toolbox/EmbyToolbox/Services/QueueAnalysisService.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

326 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Ход пакетного анализа очереди (ffprobe) для IProgress и UI.</summary>
public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount);
/// <summary>
/// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по <see cref="CancellationToken"/>,
/// обновление строки через <paramref name="uiInvoke"/> (UI).
/// </summary>
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<int> RunAsync(
IReadOnlyList<ConversionQueueItem> items,
Func<ConversionQueueItem, bool> isStillInQueue,
bool autoRemoveForeignTracks,
bool disableSubtitleDefault,
IProgress<QueueAnalysisProgress>? progress,
Func<Action, Task> 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<ConversionQueueItem, bool> isStillInQueue,
IProgress<QueueAnalysisProgress>? progress,
Func<Action, Task> 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<SidecarFile>(), Array.Empty<ExternalAudioFile>());
}
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<ConversionQueueItem, bool> isStillInQueue,
Func<Action, Task> 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");
}
}