757 lines
33 KiB
C#
757 lines
33 KiB
C#
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using EmbyToolbox.Models;
|
||
|
||
namespace EmbyToolbox.Services;
|
||
|
||
public sealed class ConversionExecutionService
|
||
{
|
||
private readonly LoggingService _logging;
|
||
private readonly FfmpegCommandBuilder _builder;
|
||
private readonly FfmpegService _ffmpeg;
|
||
private readonly FfprobeService _ffprobe;
|
||
private readonly FfmpegEncoderDiscoveryService _encoderDiscovery;
|
||
private readonly Func<string> _resolveHardwareAcceleration;
|
||
private readonly SafeFileReplaceService _replace;
|
||
private readonly ExternalFileCleanupService _cleanup;
|
||
|
||
public ConversionExecutionService(
|
||
LoggingService logging,
|
||
FfmpegCommandBuilder builder,
|
||
FfmpegService ffmpeg,
|
||
FfprobeService ffprobe,
|
||
FfmpegEncoderDiscoveryService encoderDiscovery,
|
||
Func<string> resolveHardwareAcceleration,
|
||
SafeFileReplaceService replace,
|
||
ExternalFileCleanupService cleanup)
|
||
{
|
||
_logging = logging;
|
||
_builder = builder;
|
||
_ffmpeg = ffmpeg;
|
||
_ffprobe = ffprobe;
|
||
_encoderDiscovery = encoderDiscovery;
|
||
_resolveHardwareAcceleration = resolveHardwareAcceleration;
|
||
_replace = replace;
|
||
_cleanup = cleanup;
|
||
}
|
||
|
||
public async Task RunQueueAsync(
|
||
IReadOnlyList<ConversionQueueItem> items,
|
||
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
|
||
string? tempRoot,
|
||
string runId,
|
||
Func<Action, Task> uiInvoke,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
_logging.Info($"старт обработки очереди: {items.Count}", "conversion.exec");
|
||
_ = _encoderDiscovery.GetAvailableEncoders(_logging);
|
||
var root = EnsureTempRoot(tempRoot);
|
||
foreach (var item in items)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
if (item.Status is not (ConversionQueueStatus.Ready or ConversionQueueStatus.Pending))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
await RunSingleAsync(item, resolveProfile, root, runId, uiInvoke, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
}
|
||
|
||
private async Task RunSingleAsync(
|
||
ConversionQueueItem item,
|
||
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
|
||
string tempRoot,
|
||
string runId,
|
||
Func<Action, Task> uiInvoke,
|
||
CancellationToken token)
|
||
{
|
||
var tempOut = string.Empty;
|
||
var finalPath = item.FullPath;
|
||
var targetContainer = string.Empty;
|
||
FfmpegCommand? ffmpegCmdForDisposableDumps = null;
|
||
try
|
||
{
|
||
if (item.IsSkipPlan)
|
||
{
|
||
await uiInvoke(
|
||
() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Done;
|
||
item.Progress = 100;
|
||
item.IsProcessed = true;
|
||
item.ProcessedInCurrentRun = true;
|
||
item.LastRunId = runId;
|
||
item.ErrorMessage = null;
|
||
item.ErrorDetails = null;
|
||
}).ConfigureAwait(false);
|
||
_logging.Info($"Файл пропущен: обработка не требуется. {item.FullPath}", "conversion.exec");
|
||
return;
|
||
}
|
||
|
||
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.{Guid.NewGuid():N}.tmp{Path.GetExtension(item.FileName)}");
|
||
await uiInvoke(() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Running;
|
||
item.Progress = 0;
|
||
item.IsProcessed = true;
|
||
item.ProcessedInCurrentRun = false;
|
||
item.LastRunId = runId;
|
||
item.ErrorMessage = null;
|
||
item.ErrorDetails = null;
|
||
}).ConfigureAwait(false);
|
||
|
||
var profile = item.EffectiveProfileSettings?.Profile
|
||
?? resolveProfile(item.Profile)
|
||
?? ConversionProfileMapping.EmbyFallback;
|
||
targetContainer = ResolveTargetContainer(item, profile);
|
||
finalPath = BuildFinalPath(item.FullPath, targetContainer);
|
||
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.__processing__{GetOutputExtension(targetContainer)}");
|
||
var selectedAcceleration = _resolveHardwareAcceleration();
|
||
var requiresVideoTranscode = RequiresVideoTranscode(item, profile);
|
||
var usedTsTimestampRetryTranscode = false;
|
||
|
||
if (MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName))
|
||
{
|
||
_logging.Info(
|
||
"MPEG-TS: включено исправление временных меток (genpts; avoid_negative_ts; muxdelay/muxpreload)",
|
||
"conversion.exec");
|
||
if (item.LastPlan?.RequiresTimestampFix == true)
|
||
{
|
||
_logging.Info(
|
||
"План: MPEG-TS → MKV — режим коррекции timestamps (при ошибке copy видео будет перекодирование)",
|
||
"conversion.exec");
|
||
}
|
||
}
|
||
|
||
var videoEncoder = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, requiresVideoTranscode);
|
||
var cmd = _builder.Build(item, profile, tempOut, videoEncoder, requiresVideoTranscode);
|
||
ffmpegCmdForDisposableDumps = cmd;
|
||
if (!string.Equals(Path.GetExtension(item.FullPath), Path.GetExtension(finalPath), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logging.Info($"Remux: {Path.GetExtension(item.FullPath).TrimStart('.').ToUpperInvariant()} -> {Path.GetExtension(finalPath).TrimStart('.').ToUpperInvariant()}", "conversion.exec");
|
||
}
|
||
_logging.Info($"Начало обработки файла: {item.FullPath}", "conversion.exec");
|
||
_logging.Info($"План:{Environment.NewLine}{item.PlanSummary}", "conversion.exec");
|
||
_logging.Info($"Временный файл результата: {tempOut}", "conversion.exec");
|
||
_logging.Info($"Финальный файл: {finalPath}", "conversion.exec");
|
||
LogVideoEncodeSession(item, profile, cmd, videoEncoder);
|
||
|
||
LogPlannedActions(item, profile, cmd, requiresVideoTranscode);
|
||
_logging.Info("FFmpeg: старт объединенной обработки файла", "conversion.exec", command: cmd.FullCommand);
|
||
var ffProgress = new Progress<FfmpegProgressSnapshot>(
|
||
p =>
|
||
{
|
||
_ = uiInvoke(
|
||
() =>
|
||
{
|
||
if (p.IsIndeterminate)
|
||
{
|
||
if (item.Progress < 1)
|
||
{
|
||
item.Progress = 1;
|
||
}
|
||
}
|
||
else if (p.Percent is { } encPct)
|
||
{
|
||
// 0..99% ffmpeg → очередь 0..89 (floor); ffmpeg 100% (progress=end) → 90
|
||
var mapped = encPct >= 100
|
||
? 90
|
||
: Math.Clamp((int)Math.Floor(encPct / 100.0 * 90.0), 0, 89);
|
||
item.Progress = mapped;
|
||
}
|
||
});
|
||
});
|
||
|
||
var result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
|
||
if (!result.Success
|
||
&& !requiresVideoTranscode
|
||
&& MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName)
|
||
&& MpegTsTimestampHelpers.LooksLikeTimestampMuxFailure(result.StdErr))
|
||
{
|
||
_logging.Warning(
|
||
"Video copy failed due to missing timestamps, retrying with video transcode",
|
||
"conversion.exec",
|
||
stderr: result.StdErr);
|
||
_logging.Info(
|
||
"MPEG-TS: повтор с перекодированием видео из-за ошибки временных меток",
|
||
"conversion.exec");
|
||
|
||
TryDeleteDisposableAttachmentDumpFiles(cmd);
|
||
TryDeleteTemp(tempOut);
|
||
tempOut = Path.Combine(
|
||
tempRoot,
|
||
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryTs__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
|
||
|
||
usedTsTimestampRetryTranscode = true;
|
||
var videoEncoderRetry = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, true);
|
||
cmd = _builder.Build(item, profile, tempOut, videoEncoderRetry, requiresVideoTranscode: false, forceVideoTranscode: true);
|
||
ffmpegCmdForDisposableDumps = cmd;
|
||
_logging.Info($"Новый временный файл (повтор): {tempOut}", "conversion.exec");
|
||
LogVideoEncodeSession(item, profile, cmd, videoEncoderRetry);
|
||
|
||
_logging.Info("FFmpeg: повтор с перекодированием видео", "conversion.exec", command: cmd.FullCommand);
|
||
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
|
||
}
|
||
|
||
if (!result.Success
|
||
&& requiresVideoTranscode
|
||
&& CommandArgumentsUseNvencVideo(cmd.ArgumentList)
|
||
&& LooksLikeNvencPipelineFailure(result.StdErr))
|
||
{
|
||
_logging.Warning(
|
||
"NVENC failed, retrying with libx264",
|
||
"conversion.exec",
|
||
stderr: result.StdErr);
|
||
TryDeleteDisposableAttachmentDumpFiles(cmd);
|
||
TryDeleteTemp(tempOut);
|
||
|
||
tempOut = Path.Combine(
|
||
tempRoot,
|
||
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryCpuNv__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
|
||
|
||
var cpuFallback = FfmpegCommandBuilder.CreateCpuFallbackVideoEncoder(item, profile);
|
||
cmd = _builder.Build(item, profile, tempOut, cpuFallback, requiresVideoTranscode);
|
||
ffmpegCmdForDisposableDumps = cmd;
|
||
|
||
LogVideoEncodeSession(item, profile, cmd, cpuFallback);
|
||
_logging.Info($"Новый временный файл (NVENC→CPU): {tempOut}", "conversion.exec");
|
||
_logging.Info("FFmpeg: повтор после сбоя NVENC (CPU кодер)", "conversion.exec", command: cmd.FullCommand);
|
||
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
|
||
}
|
||
|
||
if (!result.Success)
|
||
{
|
||
var err = string.IsNullOrWhiteSpace(result.StdErr) ? $"exit={result.ExitCode}" : ShortenForLog(result.StdErr, 2000);
|
||
_logging.Error($"FFmpeg завершен с ошибкой: {err}", "conversion.exec", stderr: result.StdErr);
|
||
var brief = string.IsNullOrWhiteSpace(result.StdErr)
|
||
? $"ffmpeg завершился с кодом {result.ExitCode}."
|
||
: ShortenForUiOneLine(result.StdErr.Trim(), 480);
|
||
var detail = string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim();
|
||
TryDeleteTemp(tempOut);
|
||
await uiInvoke(() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Error;
|
||
item.ErrorMessage = brief;
|
||
item.ErrorDetails = detail;
|
||
item.ProcessedInCurrentRun = false;
|
||
}).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
_logging.Info("FFmpeg завершен успешно", "conversion.exec");
|
||
await ValidateOutputAsync(tempOut, item, profile, requiresVideoTranscode || usedTsTimestampRetryTranscode, token).ConfigureAwait(false);
|
||
|
||
await uiInvoke(() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Copying;
|
||
item.Progress = 90;
|
||
}).ConfigureAwait(false);
|
||
_logging.Info("Начато копирование результата", "conversion.exec");
|
||
|
||
var sameRoot = string.Equals(Path.GetPathRoot(finalPath), Path.GetPathRoot(tempOut), StringComparison.OrdinalIgnoreCase);
|
||
if (sameRoot)
|
||
{
|
||
await uiInvoke(() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Replacing;
|
||
}).ConfigureAwait(false);
|
||
_logging.Info("Замена исходного файла", "conversion.exec");
|
||
}
|
||
|
||
var replaceProgress = new Progress<int>(
|
||
p =>
|
||
{
|
||
_ = uiInvoke(() => item.Progress = p);
|
||
});
|
||
await _replace.ReplaceAsync(item.FullPath, finalPath, tempOut, replaceProgress, token).ConfigureAwait(false);
|
||
_logging.Info("успешная замена исходника", "conversion.exec");
|
||
if (!string.Equals(item.FullPath, finalPath, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logging.Info("Старый файл удален после успешной замены", "conversion.exec");
|
||
}
|
||
|
||
var usedFonts = cmd.UsedExternalFiles
|
||
.Where(x => x.IsFont)
|
||
.DistinctBy(x => x.FullPath)
|
||
.Count();
|
||
if (usedFonts > 0)
|
||
{
|
||
_logging.Info($"Использованы шрифты: {usedFonts}", "conversion.exec");
|
||
_logging.Info("Шрифты оставлены на месте", "conversion.exec");
|
||
}
|
||
|
||
var moved = _cleanup.MoveUsedToUseless(finalPath, cmd.UsedExternalFiles);
|
||
if (moved.Count > 0)
|
||
{
|
||
_logging.Info($"Перемещены внешние файлы в useless: {moved.Count}", "conversion.exec");
|
||
}
|
||
|
||
await uiInvoke(() =>
|
||
{
|
||
item.UpdateOutputPath(finalPath, targetContainer);
|
||
item.Progress = 100;
|
||
item.Status = ConversionQueueStatus.Done;
|
||
item.ProcessedInCurrentRun = true;
|
||
}).ConfigureAwait(false);
|
||
_logging.Info($"Итоговый путь обновлен: {finalPath}", "conversion.exec");
|
||
_logging.Info("Файл обработан успешно", "conversion.exec");
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
_logging.Warning("отмена пользователем", "conversion.exec");
|
||
TryDeleteTemp(tempOut);
|
||
await uiInvoke(() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Cancelled;
|
||
item.ErrorMessage = "Отмена пользователем";
|
||
item.ErrorDetails = null;
|
||
}).ConfigureAwait(false);
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
TryDeleteTemp(tempOut);
|
||
_logging.Error($"ошибка обработки: {ex.Message}", "conversion.exec", ex);
|
||
await uiInvoke(() =>
|
||
{
|
||
item.Status = ConversionQueueStatus.Error;
|
||
item.ErrorMessage = ex.Message;
|
||
item.ErrorDetails = null;
|
||
}).ConfigureAwait(false);
|
||
}
|
||
finally
|
||
{
|
||
TryDeleteDisposableAttachmentDumpFiles(ffmpegCmdForDisposableDumps);
|
||
}
|
||
}
|
||
|
||
private static void TryDeleteDisposableAttachmentDumpFiles(FfmpegCommand? cmd)
|
||
{
|
||
if (cmd?.DisposableAttachmentDumpPaths is not { Count: > 0 })
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var path in cmd.DisposableAttachmentDumpPaths.Distinct(StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(path))
|
||
{
|
||
File.Delete(path);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// временные дампы для -attach после завершения ffmpeg
|
||
}
|
||
}
|
||
}
|
||
|
||
private void LogPlannedActions(ConversionQueueItem item, ConversionProfileSettingsEntry profile, FfmpegCommand cmd, bool requiresVideoTranscode)
|
||
{
|
||
var ovr = item.TaskOverride;
|
||
var targetContainer = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer;
|
||
var sourceContainer = item.MediaAnalysis?.ContainerFormat ?? "unknown";
|
||
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logging.Info($"FFmpeg: старт remux контейнера {sourceContainer} -> {targetContainer}", "conversion.exec");
|
||
}
|
||
|
||
var sourceVideo = item.MediaAnalysis?.PrimaryVideo?.CodecName ?? "unknown";
|
||
var targetVideo = string.IsNullOrWhiteSpace(ovr.TargetVideo) ? profile.Video : ovr.TargetVideo;
|
||
if (requiresVideoTranscode || ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert))
|
||
{
|
||
_logging.Info($"FFmpeg: старт конвертации видео {sourceVideo} -> {targetVideo}", "conversion.exec");
|
||
}
|
||
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("FFmpeg команда для файла:");
|
||
var mappedLines = 0;
|
||
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
sb.AppendLine($"- remux в {targetContainer}");
|
||
mappedLines++;
|
||
}
|
||
if (!requiresVideoTranscode && ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Keep))
|
||
{
|
||
sb.AppendLine("- video copy");
|
||
mappedLines++;
|
||
}
|
||
|
||
foreach (var t in ovr.TrackOverrides.OrderBy(x => x.StreamKind).ThenBy(x => x.StreamIndex))
|
||
{
|
||
var src = GetSourceStream(item, t);
|
||
var lang = FormatLang(src?.Language ?? t.Language);
|
||
if (t.Action == TrackActionKind.Convert && t.StreamKind == MediaStreamKind.Audio)
|
||
{
|
||
var fromCodec = src?.CodecName ?? "unknown";
|
||
var bitrate = string.IsNullOrWhiteSpace(t.AudioBitrateKbps) ? (string.IsNullOrWhiteSpace(ovr.TargetAudioBitrate) ? profile.Bitrate : ovr.TargetAudioBitrate) : t.AudioBitrateKbps!;
|
||
_logging.Info($"FFmpeg: старт конвертации audio #{DisplayIndex(t)} {fromCodec} -> aac {bitrate}", "conversion.exec");
|
||
sb.AppendLine($"- audio #{DisplayIndex(t)} convert to AAC {bitrate}");
|
||
mappedLines++;
|
||
continue;
|
||
}
|
||
|
||
if (t.Action == TrackActionKind.Remove && t.Source == SourceKind.Embedded && t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle)
|
||
{
|
||
var kind = t.StreamKind == MediaStreamKind.Audio ? "audio" : "subtitle";
|
||
var subKind = string.Empty;
|
||
if (t.StreamKind == MediaStreamKind.Subtitle &&
|
||
SubtitleCodecRules.IsTeletext(src?.CodecName))
|
||
{
|
||
subKind = " (teletext)";
|
||
}
|
||
|
||
_logging.Info($"FFmpeg: старт удаления дорожки {kind}{subKind} #{DisplayIndex(t)} ({lang})", "conversion.exec");
|
||
sb.AppendLine(subKind.Length > 0
|
||
? $"- remove teletext subtitle #{DisplayIndex(t)}"
|
||
: $"- remove {kind} #{DisplayIndex(t)}");
|
||
mappedLines++;
|
||
continue;
|
||
}
|
||
|
||
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
_logging.Info($"FFmpeg: старт добавления внешней аудиодорожки: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
|
||
sb.AppendLine("- add external audio");
|
||
mappedLines++;
|
||
continue;
|
||
}
|
||
|
||
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
_logging.Info($"FFmpeg: старт добавления внешних субтитров: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
|
||
sb.AppendLine("- add external subtitle");
|
||
mappedLines++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
var fonts = cmd.UsedExternalFiles.Where(f => f.IsFont).DistinctBy(f => f.FullPath).Count();
|
||
if (fonts > 0)
|
||
{
|
||
_logging.Info($"FFmpeg: старт встраивания шрифтов: {fonts} файлов", "conversion.exec");
|
||
sb.AppendLine($"- attach fonts: {fonts}");
|
||
mappedLines++;
|
||
}
|
||
|
||
if (mappedLines == 0)
|
||
{
|
||
sb.AppendLine("- copy streams");
|
||
}
|
||
|
||
_logging.Info(sb.ToString().TrimEnd(), "conversion.exec");
|
||
}
|
||
|
||
private static MediaStreamInfo? GetSourceStream(ConversionQueueItem item, TrackOverrideEntry t)
|
||
{
|
||
if (t.Source != SourceKind.Embedded || t.StreamIndex < 0 || item.MediaAnalysis is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return item.MediaAnalysis.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
|
||
}
|
||
|
||
private static int DisplayIndex(TrackOverrideEntry t) => t.StreamIndex >= 0 ? t.StreamIndex + 1 : -1;
|
||
|
||
private static string FormatLang(string? lang) => string.IsNullOrWhiteSpace(lang) ? "unknown" : lang.Trim();
|
||
|
||
private static string ShortenForUiOneLine(string text, int maxLen)
|
||
{
|
||
var one = text.ReplaceLineEndings(" ").Trim();
|
||
while (one.Contains(" ", StringComparison.Ordinal))
|
||
{
|
||
one = one.Replace(" ", " ", StringComparison.Ordinal);
|
||
}
|
||
|
||
return ShortenForLog(one, maxLen);
|
||
}
|
||
|
||
private static string ShortenForLog(string text, int maxLen)
|
||
{
|
||
if (text.Length <= maxLen)
|
||
{
|
||
return text;
|
||
}
|
||
|
||
return text[..maxLen] + "...";
|
||
}
|
||
|
||
private static string EnsureTempRoot(string? path)
|
||
{
|
||
var root = string.IsNullOrWhiteSpace(path)
|
||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EmbyToolbox", "Temp")
|
||
: path.Trim();
|
||
Directory.CreateDirectory(root);
|
||
return root;
|
||
}
|
||
|
||
private static void TryDeleteTemp(string path)
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(path))
|
||
{
|
||
File.Delete(path);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
private static string ResolveTargetContainer(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
|
||
{
|
||
var target = string.IsNullOrWhiteSpace(item.TaskOverride.TargetContainer) ? profile.Container : item.TaskOverride.TargetContainer;
|
||
return string.IsNullOrWhiteSpace(target) ? profile.Container : target.Trim();
|
||
}
|
||
|
||
private static string BuildFinalPath(string sourcePath, string targetContainer)
|
||
{
|
||
var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty;
|
||
var name = Path.GetFileNameWithoutExtension(sourcePath);
|
||
return Path.Combine(dir, name + GetOutputExtension(targetContainer));
|
||
}
|
||
|
||
private static string GetOutputExtension(string targetContainer)
|
||
{
|
||
if (targetContainer.Contains("mkv", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("matro", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return ".mkv";
|
||
}
|
||
|
||
if (targetContainer.Contains("mp4", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("mov", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return ".mp4";
|
||
}
|
||
|
||
return ".mkv";
|
||
}
|
||
|
||
private VideoEncoderSettings? ResolveVideoEncoderForItem(
|
||
ConversionQueueItem item,
|
||
ConversionProfileSettingsEntry profile,
|
||
string selectedAcceleration,
|
||
bool requiresVideoTranscode)
|
||
{
|
||
if (!requiresVideoTranscode)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo)
|
||
? profile.Video
|
||
: item.TaskOverride.TargetVideo;
|
||
return _encoderDiscovery.ResolveVideoEncoder(
|
||
selectedAcceleration,
|
||
targetVideo,
|
||
autoFallbackToCpu: true,
|
||
_logging);
|
||
}
|
||
|
||
private static bool RequiresVideoTranscode(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
|
||
{
|
||
if (item.MediaAnalysis is null)
|
||
{
|
||
return item.TaskOverride.TrackOverrides.Any(t =>
|
||
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
|
||
}
|
||
|
||
return ConversionPlanService.RequiresVideoTranscode(item.MediaAnalysis, profile, item.TaskOverride)
|
||
|| item.TaskOverride.TrackOverrides.Any(t =>
|
||
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
|
||
}
|
||
|
||
private async Task ValidateOutputAsync(
|
||
string outputPath,
|
||
ConversionQueueItem item,
|
||
ConversionProfileSettingsEntry profile,
|
||
bool requiresVideoTranscode,
|
||
CancellationToken token)
|
||
{
|
||
if (!requiresVideoTranscode)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var probe = await _ffprobe.AnalyzeAsync(outputPath, token).ConfigureAwait(false);
|
||
if (!probe.IsSuccess)
|
||
{
|
||
throw new InvalidOperationException("ffprobe результата завершился с ошибкой");
|
||
}
|
||
|
||
var parsed = MediaAnalysisParser.TryParse(probe.Json);
|
||
var video = parsed?.PrimaryVideo;
|
||
if (video is null)
|
||
{
|
||
throw new InvalidOperationException("в результирующем файле не найден видеопоток");
|
||
}
|
||
|
||
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
|
||
var expectedCodec = targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase)
|
||
|| targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase)
|
||
? "hevc"
|
||
: "h264";
|
||
var actualCodec = video.CodecName ?? string.Empty;
|
||
var codecOk = expectedCodec == "h264"
|
||
? actualCodec.Contains("h264", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("avc", StringComparison.OrdinalIgnoreCase)
|
||
: actualCodec.Contains("hevc", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("h265", StringComparison.OrdinalIgnoreCase);
|
||
if (!codecOk)
|
||
{
|
||
throw new InvalidOperationException($"проверка результата не пройдена: video codec={actualCodec}, ожидается {expectedCodec}");
|
||
}
|
||
|
||
var targetPixelUi = string.IsNullOrWhiteSpace(item.TaskOverride.TargetPixelFormat)
|
||
? profile.PixelFormat
|
||
: item.TaskOverride.TargetPixelFormat;
|
||
|
||
var expectedNorm = ResolveExpectedOutputPixNorm(targetPixelUi, expectedCodec);
|
||
if (expectedNorm is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var actualNorm = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(video.PixelFormat);
|
||
if (string.IsNullOrWhiteSpace(actualNorm))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"проверка результата не пройдена: pixel format недоступен (ffprobe: {video.PixelFormat ?? "(null)"})");
|
||
}
|
||
|
||
if (!PixelFormatsMatchForValidation(expectedNorm, actualNorm))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"проверка результата не пройдена: pix_fmt={video.PixelFormat}, ожидается {expectedNorm}");
|
||
}
|
||
}
|
||
|
||
private static bool IsPixelFormatUiOpen(string? s) =>
|
||
!string.IsNullOrWhiteSpace(s)
|
||
&& !s.Contains("без", StringComparison.OrdinalIgnoreCase)
|
||
&& !s.Contains("No change", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>Ожидаемый нормализованный pix_fmt для проверки; с «без изменений» типичное yuv420p для AVC/HEVC.</summary>
|
||
private static string? ResolveExpectedOutputPixNorm(string mergedPixelFmtUiFromProfile, string expectedCodecFourccStyle)
|
||
{
|
||
if (IsPixelFormatUiOpen(mergedPixelFmtUiFromProfile))
|
||
{
|
||
return FfmpegCommandBuilder.NormalizeFfprobePixelFormat(mergedPixelFmtUiFromProfile!.Trim());
|
||
}
|
||
|
||
if (expectedCodecFourccStyle.Equals("hevc", StringComparison.OrdinalIgnoreCase)
|
||
|| expectedCodecFourccStyle.Equals("h264", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "yuv420p";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void LogVideoEncodeSession(
|
||
ConversionQueueItem item,
|
||
ConversionProfileSettingsEntry profile,
|
||
FfmpegCommand cmd,
|
||
VideoEncoderSettings? encoderHint)
|
||
{
|
||
if (cmd.VideoEncoderForLog is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var pv = item.MediaAnalysis?.PrimaryVideo;
|
||
var srcPix = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(pv?.PixelFormat) ?? "?";
|
||
var srcCodec = string.IsNullOrWhiteSpace(pv?.CodecName) ? "?" : pv.CodecName;
|
||
var mergedPxUi = FfmpegCommandBuilder.ResolveMergedTargetPixelFormatUi(item.TaskOverride, profile);
|
||
|
||
string tgtEffPix;
|
||
var codecForEff = encoderHint?.Codec ?? cmd.VideoEncoderForLog;
|
||
tgtEffPix = FfmpegCommandBuilder.TryGetEffectiveVideoOutputPixFmt(mergedPxUi, codecForEff, out var effPx)
|
||
? effPx!
|
||
: "?";
|
||
|
||
var mergedVidRaw = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
|
||
var tgtVid = FormatMergedTargetVideoForLog(mergedVidRaw);
|
||
|
||
_logging.Info($"Video transcode: {srcCodec} / {srcPix} -> {tgtVid} / {tgtEffPix}", "conversion.exec");
|
||
if (cmd.AppliedVideoFilters.Count > 0)
|
||
{
|
||
_logging.Info($"Video filter: {string.Join(",", cmd.AppliedVideoFilters)}", "conversion.exec");
|
||
}
|
||
|
||
_logging.Info($"Encoder: {cmd.VideoEncoderForLog}", "conversion.exec");
|
||
}
|
||
|
||
private static string FormatMergedTargetVideoForLog(string mergedVideo)
|
||
{
|
||
var v = mergedVideo.Trim();
|
||
if (string.IsNullOrWhiteSpace(v))
|
||
{
|
||
return "?";
|
||
}
|
||
|
||
if (v.Contains("265", StringComparison.OrdinalIgnoreCase)
|
||
|| v.Contains("hevc", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "H.265 / HEVC";
|
||
}
|
||
|
||
if (v.Contains("264", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "H.264";
|
||
}
|
||
|
||
return v;
|
||
}
|
||
|
||
private static bool CommandArgumentsUseNvencVideo(IReadOnlyList<string> args)
|
||
{
|
||
for (var i = 0; i < args.Count - 1; i++)
|
||
{
|
||
if (!args[i].StartsWith("-c:v", StringComparison.Ordinal))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (args[i + 1].Contains("nvenc", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static bool LooksLikeNvencPipelineFailure(string stderr) =>
|
||
!string.IsNullOrWhiteSpace(stderr) &&
|
||
(stderr.Contains("Impossible to convert between the formats", StringComparison.OrdinalIgnoreCase)
|
||
|| stderr.Contains("auto_scale_", StringComparison.OrdinalIgnoreCase)
|
||
|| stderr.Contains("Error reinitializing filters", StringComparison.OrdinalIgnoreCase)
|
||
|| stderr.Contains("Function not implemented", StringComparison.OrdinalIgnoreCase)
|
||
|| (stderr.Contains("nvenc", StringComparison.OrdinalIgnoreCase)
|
||
&& stderr.Contains("Could not open encoder", StringComparison.OrdinalIgnoreCase)));
|
||
|
||
/// <summary>
|
||
/// ffprobe может вернуть yuvj420p (JPEG full-range) там, где в профиле задано yuv420p (limited);
|
||
/// по сути тот же 8-bit 4:2:0 планарный — для Emby/совместимости считаем допустимым совпадением.
|
||
/// </summary>
|
||
private static bool PixelFormatsMatchForValidation(string expectedNorm, string actualNorm)
|
||
{
|
||
if (string.Equals(expectedNorm, actualNorm, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
static bool IsYuv420pFamily(string p) =>
|
||
p.Equals("yuv420p", StringComparison.OrdinalIgnoreCase)
|
||
|| p.Equals("yuvj420p", StringComparison.OrdinalIgnoreCase);
|
||
|
||
return IsYuv420pFamily(expectedNorm) && IsYuv420pFamily(actualNorm);
|
||
}
|
||
}
|
||
|