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

@ -15,7 +15,11 @@ public class DashDownloader
private readonly HttpClient _http;
private readonly AppConfig _config;
public DashDownloader(HttpClient http, AppConfig config) { _http = http; _config = config; }
private readonly Platform.IMediaMuxer _muxer;
public DashDownloader(HttpClient http, AppConfig config) : this(http, config, Platform.DesktopMediaMuxer.Default) { }
public DashDownloader(HttpClient http, AppConfig config, Platform.IMediaMuxer muxer) { _http = http; _config = config; _muxer = muxer; }
public async Task<string> DownloadAsync(Uri mpdUrl, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
@ -40,7 +44,7 @@ public class DashDownloader
throw new InvalidOperationException($"MPD 조회/파싱 실패: {e.Message}", e);
}
var ffmpeg = ResolveFfmpeg()
var ffmpeg = _muxer.ResolveFfmpeg(_config)
?? throw new InvalidOperationException("DASH 다운로드에는 ffmpeg가 필요합니다 (시스템 PATH 또는 설정의 ffmpeg 경로).");
var final = outputBase + ".mp4";
@ -48,7 +52,7 @@ public class DashDownloader
bool success = false;
try
{
await RunFfmpegDashAsync(ffmpeg, mpdUrl, final, durationSec, progress, ct);
await _muxer.DownloadDashAsync(ffmpeg, mpdUrl, final, durationSec, _http, progress, ct);
if (!File.Exists(final) || new FileInfo(final).Length == 0)
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
success = true;
@ -61,94 +65,4 @@ public class DashDownloader
}
}
/// <summary>ffmpeg -i mpdUrl -c copy out.mp4. -progress pipe:1 로 time= 를 파싱해 진행률.</summary>
private async Task RunFfmpegDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false, CreateNoWindow = true,
RedirectStandardError = true, RedirectStandardOutput = true,
};
psi.ArgumentList.Add("-y");
psi.ArgumentList.Add("-hide_banner");
psi.ArgumentList.Add("-loglevel"); psi.ArgumentList.Add("error");
psi.ArgumentList.Add("-progress"); psi.ArgumentList.Add("pipe:1");
// #6 HTTP 옵션은 -i 앞(per-input AVOption) — .NET 사전검증에 쓴 UA/Referer/쿠키를 ffmpeg 에 전달.
// 토큰/Referer 인증 CDN(maccms 계열) 에서 ffmpeg 기본 UA 로 403 이 나는 것을 방지.
AddFfmpegHttpOptions(psi, _http);
psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(mpdUrl.ToString());
psi.ArgumentList.Add("-c"); psi.ArgumentList.Add("copy");
psi.ArgumentList.Add("-movflags"); psi.ArgumentList.Add("+faststart");
psi.ArgumentList.Add(output);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
string? line;
while ((line = await proc.StandardOutput.ReadLineAsync(ct)) != null)
{
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal) && durationSec > 0)
{
if (long.TryParse(line.AsSpan("out_time_ms=".Length), out long us) && us >= 0)
{
var pct = Math.Min(99, us / 1_000_000.0 * 100.0 / durationSec);
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{us / 1_000_000.0:F0}/{durationSec:F0}s"));
}
}
if (line == "progress=end") break;
}
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
{
var msg = string.IsNullOrWhiteSpace(stderr) ? $"exit {proc.ExitCode}" : stderr.Trim();
throw new InvalidOperationException($"ffmpeg DASH 다운로드 실패: {msg}");
}
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
/// <summary>#6 HttpClient 에 설정된 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달.
/// -user_agent/-referer/-headers: 토큰·Referer 인증 CDN(maccms 계열) 에서 403 을 막는다.</summary>
private static void AddFfmpegHttpOptions(ProcessStartInfo psi, HttpClient http)
{
var ua = http.DefaultRequestHeaders.UserAgent.ToString();
if (!string.IsNullOrWhiteSpace(ua)) { psi.ArgumentList.Add("-user_agent"); psi.ArgumentList.Add(ua); }
if (http.DefaultRequestHeaders.Referrer is { } refUri)
{
psi.ArgumentList.Add("-referer");
psi.ArgumentList.Add(refUri.IsAbsoluteUri ? refUri.AbsoluteUri : refUri.ToString());
}
if (http.DefaultRequestHeaders.TryGetValues("Cookie", out var cookies))
{
var cookieHeader = string.Join("; ", cookies);
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
psi.ArgumentList.Add("-headers");
// ffmpeg -headers: 각 헤더를 \r\n 으로 종료한 전체 값.
psi.ArgumentList.Add("Cookie: " + cookieHeader + "\r\n");
}
}
}
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;
}
}

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 { }
}
}
}

View file

@ -0,0 +1,111 @@
using System.Net.Http;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Platform;
/// <summary>
/// 미디어 병합/리먹스 추상화(재구성 ②). ffmpeg 서브프로세스가 필요한 두 지점
/// (HLS 세그먼트 리먹스 · DASH ffmpeg-native 다운로드)를 캡슐화한다.
/// 모바일 호스트는 네이티브 구현으로 대체 주입 가능.
/// </summary>
public interface IMediaMuxer
{
/// <summary>ffmpeg -c copy 리먹스(HLS .ts→.mp4, fMP4 병합). 실패 시 stderr 포함 예외.</summary>
Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct);
/// <summary>ffmpeg -i &lt;mpd&gt; DASH 네이티브 다운로드. -progress pipe:1 진행률 파싱.</summary>
Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct);
/// <summary>ffmpeg 실행 파일 해결(설정 → PATH). 없으면 null.</summary>
string? ResolveFfmpeg(AppConfig config);
}
/// <summary>데스크톱 구현 — IProcessRunner 기반. 기존 Hls/Dash 다운로더의 인자·진행 파싱을 그대로 보존.</summary>
public sealed class DesktopMediaMuxer : IMediaMuxer
{
public static DesktopMediaMuxer Default { get; } = new(DesktopProcessRunner.Default);
private readonly IProcessRunner _runner;
public DesktopMediaMuxer(IProcessRunner runner) => _runner = runner;
public async Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg,
new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output }), null, ct);
if (res.ExitCode != 0)
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {res.ExitCode}): {res.Stderr.Trim()}");
}
public async Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var args = new List<string>
{
"-y", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1",
};
AddFfmpegHttpOptions(args, http);
args.Add("-i");
args.Add(mpdUrl.ToString());
args.Add("-c");
args.Add("copy");
args.Add("-movflags");
args.Add("+faststart");
args.Add(output);
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), (stream, line) =>
{
if (stream != "out" || durationSec <= 0) return;
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal)
&& long.TryParse(line.AsSpan("out_time_ms=".Length), out var us) && us >= 0)
{
var pct = Math.Min(99, us / 1_000_000.0 * 100.0 / durationSec);
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{us / 1_000_000.0:F0}/{durationSec:F0}s"));
}
}, ct);
if (res.ExitCode != 0)
{
var msg = string.IsNullOrWhiteSpace(res.Stderr) ? $"exit {res.ExitCode}" : res.Stderr.Trim();
throw new InvalidOperationException($"ffmpeg DASH 다운로드 실패: {msg}");
}
}
public string? ResolveFfmpeg(AppConfig config)
{
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;
}
/// <summary>HttpClient 의 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달(토큰 CDN 403 방지).</summary>
private static void AddFfmpegHttpOptions(List<string> args, HttpClient http)
{
var ua = http.DefaultRequestHeaders.UserAgent.ToString();
if (!string.IsNullOrWhiteSpace(ua)) { args.Add("-user_agent"); args.Add(ua); }
if (http.DefaultRequestHeaders.Referrer is { } refUri)
{
args.Add("-referer");
args.Add(refUri.IsAbsoluteUri ? refUri.AbsoluteUri : refUri.ToString());
}
if (http.DefaultRequestHeaders.TryGetValues("Cookie", out var cookies))
{
var cookieHeader = string.Join("; ", cookies);
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
args.Add("-headers");
// ffmpeg -headers: 각 헤더를 \r\n 으로 종료한 전체 값.
args.Add("Cookie: " + cookieHeader + "\r\n");
}
}
}
}

View file

@ -0,0 +1,83 @@
using System.Text;
namespace VideoDownloader.Core.Platform;
/// <summary>프로세스 실행 명세. EnvPrefix 값은 기존 값 앞에 붙는다(PATH 선두 주입용).</summary>
public sealed record ProcessSpec(
string ExePath,
IReadOnlyList<string> Args,
IReadOnlyDictionary<string, string>? EnvPrefix = null);
/// <summary>프로세스 실행 결과. Stdout/Stderr 은 전체 누적(라인 탭과 별개).</summary>
public sealed record ProcessResult(int ExitCode, string Stdout, string Stderr);
/// <summary>
/// 프로세스 실행 추상화(재구성 ② / M0 전제). 모바일(iOS)에선 구현체를 주입하지 않고
/// 데스크톱(Win/macOS/Linux)은 DesktopProcessRunner 사용. 단일 프리미티브로
/// yt-dlp 라인 스트리밍 · -J 캡처 · ffmpeg 리먹스/DASH 진행 파싱을 모두 커버한다.
/// </summary>
public interface IProcessRunner
{
/// <summary>실행. onLine?(stream:"out"|"err", line) 실시간 탭. 취소 시 트리 킬.</summary>
Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct);
}
/// <summary>데스크톱 구현 — 기존 YtDlpRunner/HlsDownloader 의 검증된 안전 규칙:
/// stdout/stderr 동시 드레인(데드락 방지), 취소/실패 시 entireProcessTree 킬, 인자 배열 전달.</summary>
public sealed class DesktopProcessRunner : IProcessRunner
{
public static DesktopProcessRunner Default { get; } = new();
public async Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
System.Diagnostics.Process? proc = null;
try
{
var psi = new System.Diagnostics.ProcessStartInfo(spec.ExePath)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in spec.Args) psi.ArgumentList.Add(a);
if (spec.EnvPrefix != null)
{
foreach (var (key, prefix) in spec.EnvPrefix)
{
var cur = psi.Environment.TryGetValue(key, out var existing) ? (existing ?? "") : "";
psi.Environment[key] = prefix + Path.PathSeparator + cur;
}
}
proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException($"{spec.ExePath} 시작 실패");
await using var _ = ct.Register(() =>
{
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
});
var stdout = new System.Text.StringBuilder();
var stderr = new System.Text.StringBuilder();
var outTask = TapAsync(proc.StandardOutput, "out", stdout, onLine, ct);
var errTask = TapAsync(proc.StandardError, "err", stderr, onLine, ct);
await proc.WaitForExitAsync(ct);
await Task.WhenAll(outTask, errTask);
return new ProcessResult(proc.ExitCode, stdout.ToString(), stderr.ToString());
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
private static async Task TapAsync(StreamReader reader, string stream, StringBuilder acc,
Action<string, string>? onLine, CancellationToken ct)
{
string? line;
while ((line = await reader.ReadLineAsync(ct)) != null)
{
acc.AppendLine(line);
if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line);
}
}
}

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

View file

@ -0,0 +1,178 @@
using System.Net.Http;
using VideoDownloader.Core.Platform;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Social;
namespace VideoDownloader.Tests.Platform;
// ── Fake ─────────────────────────────────────────────────────────────
public sealed class FakeProcessRunner : IProcessRunner
{
public ProcessSpec? LastSpec;
public Func<ProcessSpec, Action<string, string>?, CancellationToken, Task<ProcessResult>> Behavior =
(_, _, _) => Task.FromResult(new ProcessResult(0, "", ""));
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
LastSpec = spec;
return Behavior(spec, onLine, ct);
}
}
// 재구성 ②: Core Platform 추상화 — 모바일(M0) 전제가 되는 프로세스/먹서 주입 검증.
public class ProcessRunnerAbstractionTests
{
[Fact]
public async Task DesktopProcessRunner_명령_실행과_캡처_Windows()
{
if (!OperatingSystem.IsWindows()) return;
var runner = new DesktopProcessRunner();
var res = await runner.RunAsync(
new ProcessSpec("cmd.exe", new[] { "/c", "echo hello-vd" }), null, CancellationToken.None);
Assert.Equal(0, res.ExitCode);
Assert.Contains("hello-vd", res.Stdout);
}
[Fact]
public async Task DesktopProcessRunner_종료코드_전파()
{
if (!OperatingSystem.IsWindows()) return;
var runner = new DesktopProcessRunner();
var res = await runner.RunAsync(
new ProcessSpec("cmd.exe", new[] { "/c", "exit 3" }), null, CancellationToken.None);
Assert.Equal(3, res.ExitCode);
}
[Fact]
public async Task DesktopProcessRunner_라인_탭_스트림_구분()
{
if (!OperatingSystem.IsWindows()) return;
// 인라인 리다이렉션 인용 문제를 피해 스크립트 파일로 stdout/stderr 분리 생성
var script = Path.Combine(Path.GetTempPath(), $"vd_tap_{Guid.NewGuid():N}.cmd");
File.WriteAllText(script, "@echo off\r\necho out-line\r\necho err-line 1>&2\r\n");
try
{
var runner = new DesktopProcessRunner();
var taps = new System.Collections.Concurrent.ConcurrentQueue<(string, string)>();
var res = await runner.RunAsync(
new ProcessSpec("cmd.exe", new[] { "/c", script }),
(s, l) => taps.Enqueue((s, l)), CancellationToken.None);
Assert.Equal(0, res.ExitCode);
Assert.Contains(taps.ToArray(), t => t.Item1 == "out" && t.Item2.Contains("out-line"));
Assert.Contains(taps.ToArray(), t => t.Item1 == "err" && t.Item2.Contains("err-line"));
Assert.Contains("err-line", res.Stderr);
}
finally { try { File.Delete(script); } catch { } }
}
[Fact]
public async Task DesktopProcessRunner_EnvPrefix로_PATH_선두_주입()
{
if (!OperatingSystem.IsWindows()) return;
var runner = new DesktopProcessRunner();
var marker = Path.GetTempPath(); // PATH 앞에 아무 값 — cmd 는 무시하지만 실행 자체는 검증
var res = await runner.RunAsync(
new ProcessSpec("cmd.exe", new[] { "/c", "echo ok" },
EnvPrefix: new Dictionary<string, string> { ["PATH"] = marker }),
null, CancellationToken.None);
Assert.Equal(0, res.ExitCode);
}
}
public class MediaMuxerAbstractionTests
{
private static string Ffmpeg => OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
[Fact]
public async Task RemuxAsync는_copy_리먹스_인자를_만든다()
{
var fake = new FakeProcessRunner();
var muxer = new DesktopMediaMuxer(fake);
await muxer.RemuxAsync(Ffmpeg, "in.ts", "out.mp4", CancellationToken.None);
var spec = fake.LastSpec!;
Assert.Equal(Ffmpeg, spec.ExePath);
Assert.Equal(new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", "in.ts", "-c", "copy", "out.mp4" },
spec.Args);
}
[Fact]
public async Task RemuxAsync_실패시_stderr_포함_예외()
{
var fake = new FakeProcessRunner
{ Behavior = (_, _, _) => Task.FromResult(new ProcessResult(1, "", "boom-err")) };
var muxer = new DesktopMediaMuxer(fake);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => muxer.RemuxAsync(Ffmpeg, "in.ts", "out.mp4", CancellationToken.None));
Assert.Contains("boom-err", ex.Message);
}
[Fact]
public async Task DownloadDashAsync는_진행률을_파싱해_리포트한다()
{
var fake = new FakeProcessRunner
{
Behavior = (_, onLine, _) =>
{
onLine?.Invoke("out", "out_time_ms=2500000"); // 2.5s / 10s = 25%
onLine?.Invoke("out", "progress=end");
return Task.FromResult(new ProcessResult(0, "", ""));
}
};
var muxer = new DesktopMediaMuxer(fake);
var progress = new SyncProgress<VideoDownloader.Core.Models.DownloadProgress>();
await muxer.DownloadDashAsync(Ffmpeg, new Uri("https://x.test/a.mpd"), "out.mp4",
durationSec: 10, http: new HttpClient(), progress: progress, CancellationToken.None);
var spec = fake.LastSpec!;
Assert.Contains("-progress", spec.Args);
Assert.Contains("pipe:1", spec.Args);
Assert.Contains("https://x.test/a.mpd", spec.Args);
Assert.Contains("+faststart", spec.Args);
Assert.Contains(progress.Items, r => Math.Abs(r.Percent - 25.0) < 0.01);
}
/// <summary>Progress&lt;T&gt; 는 SyncContext 에 큐잉되어 레이스가 나므로 동기 기록용 구현.</summary>
private sealed class SyncProgress<T> : IProgress<T>
{
public readonly List<T> Items = new();
public void Report(T value) => Items.Add(value);
}
[Fact]
public async Task DownloadDashAsync_실패시_예외()
{
var fake = new FakeProcessRunner
{ Behavior = (_, _, _) => Task.FromResult(new ProcessResult(2, "", "dash-fail")) };
var muxer = new DesktopMediaMuxer(fake);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => muxer.DownloadDashAsync(Ffmpeg, new Uri("https://x.test/a.mpd"), "o.mp4", 10,
new HttpClient(), null, CancellationToken.None));
Assert.Contains("dash-fail", ex.Message);
}
}
public class YtDlpRunnerAbstractionTests
{
[Fact]
public async Task GetVersionAsync은_주입된_러너로_version_인자와_출력을_해석한다()
{
var fake = new FakeProcessRunner
{ Behavior = (spec, _, _) => Task.FromResult(new ProcessResult(0, "2026.07.04\n", "")) };
var version = await YtDlpRunner.GetVersionAsync("yt-dlp.exe", CancellationToken.None, fake);
Assert.Equal("2026.07.04", version);
Assert.Contains("--version", fake.LastSpec!.Args);
Assert.Equal("yt-dlp.exe", fake.LastSpec.ExePath);
}
[Fact]
public async Task RunCaptureAsync도_주입된_러너를_사용한다()
{
var fake = new FakeProcessRunner
{ Behavior = (_, _, _) => Task.FromResult(new ProcessResult(0, "{\"id\":\"x\"}", "")) };
var (exit, stdout, _) = await YtDlpRunner.RunCaptureAsync(
"yt-dlp.exe", new[] { "-J", "https://youtu.be/x" }, CancellationToken.None, fake);
Assert.Equal(0, exit);
Assert.Contains("{\"id\"", stdout);
}
}