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

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