Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

231 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}