using System.Diagnostics; using System.Globalization; using VideoDownloader.Core.Config; using VideoDownloader.Core.Models; namespace VideoDownloader.Core.Dash; /// /// DASH(MPD) 다운로더. MPD 를 사전 파싱해 (a) DRM 여부 검증, (b) 진행률용 총 길이 산출한 뒤 /// ffmpeg 에게 네이티브 DASH 입력(-i mpd)으로 맡김 — 비디오/오디오 표현 선택·조합·fMP4 처리를 /// ffmpeg가 알아서 하므로 수동 세그먼트 병합보다 훨씬 견고. 클리어(비DRM) 전용. /// public class DashDownloader { private readonly HttpClient _http; private readonly AppConfig _config; public DashDownloader(HttpClient http, AppConfig config) { _http = http; _config = config; } public async Task DownloadAsync(Uri mpdUrl, string outputBase, IProgress? progress = null, CancellationToken ct = default) { string? dir = Path.GetDirectoryName(outputBase); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); // 사전 검증: 파싱(또는 DRM) + 총 길이. double durationSec = 0; try { var xml = await _http.GetStringAsync(mpdUrl, ct); var manifest = DashParser.Parse(xml, mpdUrl); if (manifest == null) throw new InvalidOperationException("DASH 매니페스트 파싱 실패 (DRM 암호화이거나 미지원 형식)"); durationSec = manifest.DurationSec; } catch (OperationCanceledException) { throw; } catch (InvalidOperationException) { throw; } catch (Exception e) { throw new InvalidOperationException($"MPD 조회/파싱 실패: {e.Message}", e); } var ffmpeg = ResolveFfmpeg() ?? throw new InvalidOperationException("DASH 다운로드에는 ffmpeg가 필요합니다 (시스템 PATH 또는 설정의 ffmpeg 경로)."); var final = outputBase + ".mp4"; progress?.Report(new DownloadProgress(0, 0, "downloading", 0, "DASH 준비")); bool success = false; try { await RunFfmpegDashAsync(ffmpeg, mpdUrl, final, durationSec, progress, ct); if (!File.Exists(final) || new FileInfo(final).Length == 0) throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다."); success = true; progress?.Report(new DownloadProgress(0, 0, "downloading", 100, null)); return final; } finally { if (!success) { try { if (File.Exists(final)) File.Delete(final); } catch { } } } } /// 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; } }