231 lines
8.7 KiB
C#
231 lines
8.7 KiB
C#
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<string?> _tempDirectoryProvider;
|
||
private readonly LoggingService _logging;
|
||
private readonly FfprobeService _ffprobeService;
|
||
private readonly ChapterBuilderService _chapterBuilderService;
|
||
|
||
public MergeService(
|
||
LoggingService logging,
|
||
FfprobeService ffprobeService,
|
||
ChapterBuilderService chapterBuilderService,
|
||
Func<string?>? tempDirectoryProvider = null)
|
||
{
|
||
_logging = logging;
|
||
_ffprobeService = ffprobeService;
|
||
_chapterBuilderService = chapterBuilderService;
|
||
_tempDirectoryProvider = tempDirectoryProvider ?? (() => null);
|
||
}
|
||
|
||
public async Task MergeAsync(
|
||
IReadOnlyList<MergeFileItem> orderedFiles,
|
||
string outputPath,
|
||
IProgress<int>? 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<double>(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<double> 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<MergeFileItem> 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<int>? 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);
|
||
}
|
||
}
|