emby-toolbox/EmbyToolbox/Services/QueueAnalysisService.cs
2026-05-16 20:28:49 +05:00

443 lines
17 KiB
C#
Raw Permalink 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.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Ход пакетного анализа очереди (ffprobe) для IProgress и UI.</summary>
public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount);
/// <summary>Поля завершённой задачи, которые восстанавливаются после повторного ffprobe (форма дорожек).</summary>
public readonly record struct DoneCompletionState(int Progress, string? LastRunId, bool IsProcessed, bool ProcessedInCurrentRun);
/// <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;
}
/// <summary>
/// Повторный анализ текущего файла на диске (актуально после «Готово» или при отсутствии MediaAnalysis).
/// Пересобирает дорожки и план; если передан <paramref name="preserveCompletion"/> — статус остаётся «Готово».
/// </summary>
public async Task<bool> RefreshItemMediaForTrackEditorAsync(
ConversionQueueItem item,
bool autoRemoveForeignTracks,
bool disableSubtitleDefault,
DoneCompletionState? preserveCompletion,
Func<Action, Task> uiInvoke,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(item.FullPath) || !File.Exists(item.FullPath))
{
_logging.Warning($"редактор дорожек: файл не найден — {item.FullPath}", "conversion.queue");
return false;
}
FfprobeResult result;
try
{
result = await _ffprobe.AnalyzeAsync(item.FullPath, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return false;
}
if (!result.IsSuccess)
{
_logging.Error(
$"редактор дорожек: ffprobe — {result.Error} — {item.FullPath}",
"conversion.ffprobe",
stderr: result.StdErr);
return false;
}
var media = MediaAnalysisParser.TryParse(result.Json);
if (media is null)
{
_logging.Error($"редактор дорожек: неверный JSON ffprobe — {item.FullPath}", "conversion.ffprobe");
return false;
}
SidecarDiscoveryResult discovery;
try
{
discovery = await _sidecar.DiscoverAsync(item.FullPath, _ffprobe, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
_logging.Error($"редактор дорожек: sidecar — {ex.Message} — {item.FullPath}", "conversion.sidecar", ex);
discovery = new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
}
var audio = FfprobeAudioInfoParser.TryParse(result.Json) ?? new FfprobeAudioInfo(0, null, true);
await uiInvoke(
() =>
{
var profile = ResolveEffectiveProfile(item);
item.TaskOverride.TrackOverrides.Clear();
TrackOverrideSeeder.EnsureDefaults(
item.TaskOverride,
media,
discovery.Sidecars,
profile,
autoRemoveForeignTracks,
discovery.ExternalAudioFiles,
item.FullPath,
sidecarTitleResolver: null,
logging: _logging,
disableSubtitleDefault: disableSubtitleDefault);
var plan = _planService.Build(media, discovery.Sidecars, profile, item.TaskOverride, discovery.ExternalAudioFiles);
item.SetSuccessfulMediaAnalysis(
media,
discovery.Sidecars,
discovery.ExternalAudioFiles,
plan,
audio.AudioStreamCount,
audio.AudioSizeMbTotal,
audio.IsPartial);
if (preserveCompletion is { } d)
{
item.Status = ConversionQueueStatus.Done;
item.Progress = d.Progress;
item.LastRunId = d.LastRunId;
item.IsProcessed = d.IsProcessed;
item.ProcessedInCurrentRun = d.ProcessedInCurrentRun;
}
else
{
item.Status = ConversionQueueStatus.Pending;
item.Progress = 0;
item.ErrorMessage = null;
item.ErrorDetails = null;
}
})
.ConfigureAwait(false);
return true;
}
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 = ResolveEffectiveProfile(item);
// При повторном анализе 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 ConversionProfileSettingsEntry ResolveEffectiveProfile(ConversionQueueItem item) =>
item.EffectiveProfileSettings?.Profile
?? _profile.GetProfile(item.Profile)
?? ConversionProfileMapping.EmbyFallback;
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");
}
}