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

@ -70,11 +70,12 @@ public static class YtDlpRunner
}
/// <summary>설치된 yt-dlp 버전 문자열("2026.03.17"). 알 수 없으면 null.</summary>
public static async Task<string?> GetVersionAsync(string exe, CancellationToken ct)
public static async Task<string?> GetVersionAsync(string exe, CancellationToken ct,
Platform.IProcessRunner? runner = null)
{
try
{
var (exit, stdout, _) = await RunCaptureAsync(exe, new[] { "--version" }, ct);
var (exit, stdout, _) = await RunCaptureAsync(exe, new[] { "--version" }, ct, runner);
if (exit != 0) return null;
var first = stdout.Trim().Split('\n', StringSplitOptions.TrimEntries)[0];
return string.IsNullOrWhiteSpace(first) ? null : first;
@ -126,89 +127,24 @@ public static class YtDlpRunner
return null;
}
/// <summary>yt-dlp 자식 프로세스 PATH 앞에 ToolsDir 를 추가 → 번들 deno.exe(EJS n 챌린지 해석용) 를 yt-dlp 가 자동 발겑.
/// 시스템 PATH 는 건드리지 않고 이 프로세스에만 적용. ffmpeg/yt-dlp 등 다른 번들 도구 발견에도 보조.</summary>
private static void PrependToolsToPath(ProcessStartInfo psi)
{
try
{
var cur = psi.Environment.TryGetValue("PATH", out var p) ? (p ?? "") : "";
psi.Environment["PATH"] = ToolsDir + Path.PathSeparator + cur;
}
catch { } // UseShellExecute=true 등 예외 시 무시(본 프로젝트에선 항상 false)
}
/// <summary>프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬.</summary>
/// <summary>프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬.
/// 재구성 ②: IProcessRunner 주입 가능(테스트/모바일) — 기본 DesktopProcessRunner.</summary>
public static async Task<int> RunAsync(string exe, IEnumerable<string> args,
Action<string, string> onLine, CancellationToken ct)
Action<string, string> onLine, CancellationToken ct, Platform.IProcessRunner? runner = null)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(exe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
PrependToolsToPath(psi); // 번들 deno.exe 를 yt-dlp 가 발견하도록(EJS n 챌린지 해석)
proc = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp 시작 실패");
await using var _ = ct.Register(() => { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } });
// stdout/stderr 를 별개 태스크로 동시 드레인(파이프 버퍼 차면 데드락).
var outTask = ReadLinesAsync(proc.StandardOutput, "out", onLine, ct);
var errTask = ReadLinesAsync(proc.StandardError, "err", onLine, ct);
await proc.WaitForExitAsync(ct);
await Task.WhenAll(outTask, errTask);
return proc.ExitCode;
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
var r = runner ?? Platform.DesktopProcessRunner.Default;
var res = await r.RunAsync(new Platform.ProcessSpec(exe, args.ToList(),
EnvPrefix: new Dictionary<string, string> { ["PATH"] = ToolsDir }), onLine, ct);
return res.ExitCode;
}
/// <summary>stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). stderr 도 버퍼링하며 취소 시 트리 킬.</summary>
/// <summary>stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). 재구성 ②: 러너 주입 가능.</summary>
public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync(
string exe, IEnumerable<string> args, CancellationToken ct)
string exe, IEnumerable<string> args, CancellationToken ct, Platform.IProcessRunner? runner = null)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(exe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
PrependToolsToPath(psi); // 번들 deno.exe 를 yt-dlp 가 발견하도록(EJS n 챌린지 해석)
proc = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp 시작 실패");
await using var _ = ct.Register(() => { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } });
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
return (proc.ExitCode, await stdoutTask, await stderrTask);
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
private static async Task ReadLinesAsync(StreamReader reader, string stream, Action<string, string> onLine, CancellationToken ct)
{
string? line;
while ((line = await reader.ReadLineAsync(ct)) != null)
{
ct.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(line)) onLine(stream, line);
}
var r = runner ?? Platform.DesktopProcessRunner.Default;
var res = await r.RunAsync(new Platform.ProcessSpec(exe, args.ToList(),
EnvPrefix: new Dictionary<string, string> { ["PATH"] = ToolsDir }), null, ct);
return (res.ExitCode, res.Stdout, res.Stderr);
}
}