using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// Ход пакетного анализа очереди (ffprobe) для IProgress и UI.
public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount);
/// Поля завершённой задачи, которые восстанавливаются после повторного ffprobe (форма дорожек).
public readonly record struct DoneCompletionState(int Progress, string? LastRunId, bool IsProcessed, bool ProcessedInCurrentRun);
///
/// Асинхронный батч 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;
}
///
/// Повторный анализ текущего файла на диске (актуально после «Готово» или при отсутствии MediaAnalysis).
/// Пересобирает дорожки и план; если передан — статус остаётся «Готово».
///
public async Task RefreshItemMediaForTrackEditorAsync(
ConversionQueueItem item,
bool autoRemoveForeignTracks,
bool disableSubtitleDefault,
DoneCompletionState? preserveCompletion,
Func 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(), Array.Empty());
}
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 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 = 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 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 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");
}
}