refactor(2): Core Platform abstractions (IProcessRunner/IMediaMuxer + Desktop impls) - Hls/Dash/YtDlp through injection seams, threading contract documented (66 tests GREEN)

This commit is contained in:
Yun Chan 2026-08-15 15:00:36 +09:00
parent 9eb988440f
commit 6df9f81b4a
6 changed files with 402 additions and 220 deletions

View file

@ -17,7 +17,11 @@ public class HlsDownloader
private readonly AppConfig _config;
private readonly ConcurrentDictionary<Uri, byte[]> _keys = new();
public HlsDownloader(HttpClient http, AppConfig config)
private readonly Platform.IMediaMuxer _muxer;
public HlsDownloader(HttpClient http, AppConfig config) : this(http, config, Platform.DesktopMediaMuxer.Default) { }
public HlsDownloader(HttpClient http, AppConfig config, Platform.IMediaMuxer muxer)
{
_http = http;
_config = config;
@ -182,7 +186,7 @@ public class HlsDownloader
string merged, string outputBase, bool isFmp4,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var ffmpeg = ResolveFfmpeg();
var ffmpeg = _muxer.ResolveFfmpeg(_config);
if (ffmpeg != null)
{
var final = outputBase + ".mp4";
@ -191,7 +195,7 @@ public class HlsDownloader
progress?.Report(new DownloadProgress(0, 0, "remuxing"));
try
{
await RunFfmpegAsync(ffmpeg, merged, final, ct);
await _muxer.RemuxAsync(ffmpeg, merged, final, ct);
if (File.Exists(final) && new FileInfo(final).Length > 0)
{
File.Delete(merged);
@ -213,49 +217,5 @@ public class HlsDownloader
return keep;
}
private string? ResolveFfmpeg()
{
if (!string.IsNullOrWhiteSpace(_config.FfmpegPath) && File.Exists(_config.FfmpegPath))
return _config.FfmpegPath;
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
var p = Path.Combine(d.Trim('"'), name);
if (File.Exists(p)) return p;
}
return null;
}
// #2 stderr/stdout 파이프를 동시에 드레인(데드락 방지), 종료 보장(좀비 방지), 실패 시 예외 throw
// #10 stdout 드레인 태스크도 관측 — 취소 시 미관측(faulted) 태스크가 되지 않도록 finally 에서 await.
private static async Task RunFfmpegAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
Process? proc = null;
Task<string>? stdoutTask = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output })
psi.ArgumentList.Add(a);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // stdout 도 드레인(데드락 방지)
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {proc.ExitCode}): {stderr.Trim()}");
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
// #10 드레인 태스크 관측 (취소/OCE/EOF 시 폭발 무시)
try { if (stdoutTask is not null) await stdoutTask; } catch { }
}
}
}