using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using EmbyToolbox.Models; namespace EmbyToolbox.Services; public sealed class MergeService { private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly Func _tempDirectoryProvider; private readonly LoggingService _logging; private readonly FfprobeService _ffprobeService; private readonly ChapterBuilderService _chapterBuilderService; public MergeService( LoggingService logging, FfprobeService ffprobeService, ChapterBuilderService chapterBuilderService, Func? tempDirectoryProvider = null) { _logging = logging; _ffprobeService = ffprobeService; _chapterBuilderService = chapterBuilderService; _tempDirectoryProvider = tempDirectoryProvider ?? (() => null); } public async Task MergeAsync( IReadOnlyList orderedFiles, string outputPath, IProgress? progress, CancellationToken cancellationToken) { if (orderedFiles.Count < 2) { throw new InvalidOperationException("Для объединения нужно минимум 2 файла."); } if (string.IsNullOrWhiteSpace(outputPath)) { throw new InvalidOperationException("Не задан путь итогового файла."); } var configuredTempRoot = ResolveTempRoot(); var tempDirectory = Path.Combine(configuredTempRoot, "Merge", Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); var tempOutputPath = Path.Combine(tempDirectory, $"{Path.GetFileNameWithoutExtension(outputPath)}.mkv"); Directory.CreateDirectory(tempDirectory); try { _logging.Info($"объединение: подготовка файлов ({orderedFiles.Count}), temp={tempDirectory}", "merge"); var durations = new List(orderedFiles.Count); foreach (var file in orderedFiles) { durations.Add(await ResolveDurationSecondsAsync(file.FullPath, cancellationToken).ConfigureAwait(false)); } var totalDurationMs = Math.Max(1.0, durations.Sum() * 1000.0); var chapters = orderedFiles.Select(f => f.PartName).ToList(); var metadataText = _chapterBuilderService.BuildFfmetadata(chapters, durations); var concatListPath = Path.Combine(tempDirectory, "merge-list.txt"); var metadataPath = Path.Combine(tempDirectory, "chapters.ffmeta"); await File.WriteAllTextAsync(concatListPath, BuildConcatList(orderedFiles), Utf8NoBom, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(metadataPath, metadataText, Utf8NoBom, cancellationToken).ConfigureAwait(false); var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe"); if (!File.Exists(ffmpegPath)) { throw new FileNotFoundException($"ffmpeg не найден: {ffmpegPath}"); } var args = $"-hide_banner -y -progress pipe:1 -nostats -f concat -safe 0 -i \"{concatListPath}\" -i \"{metadataPath}\" -map 0 -map_metadata 1 -c copy \"{tempOutputPath}\""; _logging.Info($"объединение: запуск ffmpeg (temp output) -> {tempOutputPath}", "merge", command: $"{ffmpegPath} {args}"); var runResult = await RunFfmpegAsync(ffmpegPath, args, totalDurationMs, progress, cancellationToken).ConfigureAwait(false); if (runResult.ExitCode != 0) { throw new InvalidOperationException($"ffmpeg завершился с кодом {runResult.ExitCode}: {runResult.StdErr}"); } if (!File.Exists(tempOutputPath)) { throw new IOException("Временный итоговый файл не был создан."); } progress?.Report(94); await MoveTempOutputToFinalPathAsync(tempOutputPath, outputPath, cancellationToken).ConfigureAwait(false); progress?.Report(98); _logging.Info($"объединение завершено успешно: {outputPath}", "merge"); progress?.Report(100); } finally { try { if (Directory.Exists(tempDirectory)) { Directory.Delete(tempDirectory, recursive: true); } } catch { // ignore temp cleanup errors } } } private static Task MoveTempOutputToFinalPathAsync(string tempOutputPath, string finalOutputPath, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Directory.CreateDirectory(Path.GetDirectoryName(finalOutputPath)!); if (File.Exists(finalOutputPath)) { File.Delete(finalOutputPath); } File.Move(tempOutputPath, finalOutputPath); return Task.CompletedTask; } private string ResolveTempRoot() { var configured = _tempDirectoryProvider.Invoke(); if (!string.IsNullOrWhiteSpace(configured)) { return configured.Trim(); } return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EmbyToolbox", "Temp"); } private async Task ResolveDurationSecondsAsync(string filePath, CancellationToken cancellationToken) { var probeResult = await _ffprobeService.AnalyzeAsync(filePath, cancellationToken).ConfigureAwait(false); if (!probeResult.IsSuccess) { throw new InvalidOperationException($"ffprobe не смог получить длительность для '{filePath}': {probeResult.Error}"); } var parsed = MediaAnalysisParser.TryParse(probeResult.Json); var duration = parsed?.GetEffectiveDurationSeconds(); if (duration is not { } d || d <= 0.001) { throw new InvalidOperationException($"Не удалось определить длительность файла: {filePath}"); } return d; } private static string BuildConcatList(IEnumerable files) { var sb = new StringBuilder(); foreach (var file in files) { var escaped = file.FullPath.Replace("'", "'\\''", StringComparison.Ordinal); sb.Append("file '").Append(escaped).AppendLine("'"); } return sb.ToString(); } private static async Task<(int ExitCode, string StdErr)> RunFfmpegAsync( string executable, string arguments, double totalDurationMs, IProgress? progress, CancellationToken cancellationToken) { var startInfo = new ProcessStartInfo { FileName = executable, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; using var process = new Process { StartInfo = startInfo }; process.Start(); var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); using var killRegistration = cancellationToken.Register( static state => { try { if (state is Process p && !p.HasExited) { p.Kill(entireProcessTree: true); } } catch { // ignore } }, process, useSynchronizationContext: false); string? line; while ((line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) { if (!line.StartsWith("out_time_ms=", StringComparison.Ordinal)) { continue; } var raw = line["out_time_ms=".Length..]; if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var outTimeMicro)) { continue; } var percent = (int)Math.Clamp((outTimeMicro / 1000.0) / totalDurationMs * 100.0, 0.0, 100.0); progress?.Report(percent); } await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); var stdErr = await stderrTask.ConfigureAwait(false); return (process.ExitCode, stdErr); } }