443 lines
17 KiB
C#
443 lines
17 KiB
C#
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");
|
||
}
|
||
}
|