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:
parent
9eb988440f
commit
6df9f81b4a
6 changed files with 402 additions and 220 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue