From 6df9f81b4a7de1a70254f78886883f54bb411eba Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 15 Aug 2026 15:00:36 +0900 Subject: [PATCH] refactor(2): Core Platform abstractions (IProcessRunner/IMediaMuxer + Desktop impls) - Hls/Dash/YtDlp through injection seams, threading contract documented (66 tests GREEN) --- VideoDownloader.Core/Dash/DashDownloader.cs | 100 +--------- VideoDownloader.Core/Hls/HlsDownloader.cs | 54 +----- VideoDownloader.Core/Platform/MediaMuxer.cs | 111 +++++++++++ .../Platform/ProcessModels.cs | 83 ++++++++ VideoDownloader.Core/Social/YtDlpRunner.cs | 96 ++-------- .../Platform/PlatformAbstractionTests.cs | 178 ++++++++++++++++++ 6 files changed, 402 insertions(+), 220 deletions(-) create mode 100644 VideoDownloader.Core/Platform/MediaMuxer.cs create mode 100644 VideoDownloader.Core/Platform/ProcessModels.cs create mode 100644 VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs diff --git a/VideoDownloader.Core/Dash/DashDownloader.cs b/VideoDownloader.Core/Dash/DashDownloader.cs index 8223d10..0763678 100644 --- a/VideoDownloader.Core/Dash/DashDownloader.cs +++ b/VideoDownloader.Core/Dash/DashDownloader.cs @@ -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 DownloadAsync(Uri mpdUrl, string outputBase, IProgress? 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 } } - /// ffmpeg -i mpdUrl -c copy out.mp4. -progress pipe:1 로 time= 를 파싱해 진행률. - private async Task RunFfmpegDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, - IProgress? 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 { } - } - } - - /// #6 HttpClient 에 설정된 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달. - /// -user_agent/-referer/-headers: 토큰·Referer 인증 CDN(maccms 계열) 에서 403 을 막는다. - 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; - } } diff --git a/VideoDownloader.Core/Hls/HlsDownloader.cs b/VideoDownloader.Core/Hls/HlsDownloader.cs index 3b8e7de..39c9ddb 100644 --- a/VideoDownloader.Core/Hls/HlsDownloader.cs +++ b/VideoDownloader.Core/Hls/HlsDownloader.cs @@ -17,7 +17,11 @@ public class HlsDownloader private readonly AppConfig _config; private readonly ConcurrentDictionary _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? 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? 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 { } - } - } } diff --git a/VideoDownloader.Core/Platform/MediaMuxer.cs b/VideoDownloader.Core/Platform/MediaMuxer.cs new file mode 100644 index 0000000..eb315aa --- /dev/null +++ b/VideoDownloader.Core/Platform/MediaMuxer.cs @@ -0,0 +1,111 @@ +using System.Net.Http; +using VideoDownloader.Core.Config; +using VideoDownloader.Core.Models; + +namespace VideoDownloader.Core.Platform; + +/// +/// 미디어 병합/리먹스 추상화(재구성 ②). ffmpeg 서브프로세스가 필요한 두 지점 +/// (HLS 세그먼트 리먹스 · DASH ffmpeg-native 다운로드)를 캡슐화한다. +/// 모바일 호스트는 네이티브 구현으로 대체 주입 가능. +/// +public interface IMediaMuxer +{ + /// ffmpeg -c copy 리먹스(HLS .ts→.mp4, fMP4 병합). 실패 시 stderr 포함 예외. + Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct); + + /// ffmpeg -i <mpd> DASH 네이티브 다운로드. -progress pipe:1 진행률 파싱. + Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, + HttpClient http, IProgress? progress, CancellationToken ct); + + /// ffmpeg 실행 파일 해결(설정 → PATH). 없으면 null. + string? ResolveFfmpeg(AppConfig config); +} + +/// 데스크톱 구현 — IProcessRunner 기반. 기존 Hls/Dash 다운로더의 인자·진행 파싱을 그대로 보존. +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? progress, CancellationToken ct) + { + var args = new List + { + "-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; + } + + /// HttpClient 의 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달(토큰 CDN 403 방지). + private static void AddFfmpegHttpOptions(List 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"); + } + } + } +} diff --git a/VideoDownloader.Core/Platform/ProcessModels.cs b/VideoDownloader.Core/Platform/ProcessModels.cs new file mode 100644 index 0000000..40c02bc --- /dev/null +++ b/VideoDownloader.Core/Platform/ProcessModels.cs @@ -0,0 +1,83 @@ +using System.Text; +namespace VideoDownloader.Core.Platform; + +/// 프로세스 실행 명세. EnvPrefix 값은 기존 값 앞에 붙는다(PATH 선두 주입용). +public sealed record ProcessSpec( + string ExePath, + IReadOnlyList Args, + IReadOnlyDictionary? EnvPrefix = null); + +/// 프로세스 실행 결과. Stdout/Stderr 은 전체 누적(라인 탭과 별개). +public sealed record ProcessResult(int ExitCode, string Stdout, string Stderr); + +/// +/// 프로세스 실행 추상화(재구성 ② / M0 전제). 모바일(iOS)에선 구현체를 주입하지 않고 +/// 데스크톱(Win/macOS/Linux)은 DesktopProcessRunner 사용. 단일 프리미티브로 +/// yt-dlp 라인 스트리밍 · -J 캡처 · ffmpeg 리먹스/DASH 진행 파싱을 모두 커버한다. +/// +public interface IProcessRunner +{ + /// 실행. onLine?(stream:"out"|"err", line) 실시간 탭. 취소 시 트리 킬. + Task RunAsync(ProcessSpec spec, Action? onLine, CancellationToken ct); +} + +/// 데스크톱 구현 — 기존 YtDlpRunner/HlsDownloader 의 검증된 안전 규칙: +/// stdout/stderr 동시 드레인(데드락 방지), 취소/실패 시 entireProcessTree 킬, 인자 배열 전달. +public sealed class DesktopProcessRunner : IProcessRunner +{ + public static DesktopProcessRunner Default { get; } = new(); + + public async Task RunAsync(ProcessSpec spec, Action? 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? onLine, CancellationToken ct) + { + string? line; + while ((line = await reader.ReadLineAsync(ct)) != null) + { + acc.AppendLine(line); + if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line); + } + } +} diff --git a/VideoDownloader.Core/Social/YtDlpRunner.cs b/VideoDownloader.Core/Social/YtDlpRunner.cs index 2ab851b..9d9ad92 100644 --- a/VideoDownloader.Core/Social/YtDlpRunner.cs +++ b/VideoDownloader.Core/Social/YtDlpRunner.cs @@ -70,11 +70,12 @@ public static class YtDlpRunner } /// 설치된 yt-dlp 버전 문자열("2026.03.17"). 알 수 없으면 null. - public static async Task GetVersionAsync(string exe, CancellationToken ct) + public static async Task 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; } - /// yt-dlp 자식 프로세스 PATH 앞에 ToolsDir 를 추가 → 번들 deno.exe(EJS n 챌린지 해석용) 를 yt-dlp 가 자동 발겑. - /// 시스템 PATH 는 건드리지 않고 이 프로세스에만 적용. ffmpeg/yt-dlp 등 다른 번들 도구 발견에도 보조. - 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) - } - - /// 프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬. + /// 프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬. + /// 재구성 ②: IProcessRunner 주입 가능(테스트/모바일) — 기본 DesktopProcessRunner. public static async Task RunAsync(string exe, IEnumerable args, - Action onLine, CancellationToken ct) + Action 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 { ["PATH"] = ToolsDir }), onLine, ct); + return res.ExitCode; } - /// stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). stderr 도 버퍼링하며 취소 시 트리 킬. + /// stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). 재구성 ②: 러너 주입 가능. public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync( - string exe, IEnumerable args, CancellationToken ct) + string exe, IEnumerable 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 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 { ["PATH"] = ToolsDir }), null, ct); + return (res.ExitCode, res.Stdout, res.Stderr); } } diff --git a/VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs b/VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs new file mode 100644 index 0000000..4331466 --- /dev/null +++ b/VideoDownloader.Tests/Platform/PlatformAbstractionTests.cs @@ -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?, CancellationToken, Task> Behavior = + (_, _, _) => Task.FromResult(new ProcessResult(0, "", "")); + + public Task RunAsync(ProcessSpec spec, Action? 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 { ["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( + () => 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(); + 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); + } + + /// Progress<T> 는 SyncContext 에 큐잉되어 레이스가 나므로 동기 기록용 구현. + private sealed class SyncProgress : IProgress + { + public readonly List 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( + () => 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); + } +}