using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using VideoDownloader.Core.Config; using VideoDownloader.Core.Models; namespace VideoDownloader.Core.Social; /// /// yt-dlp 기반 소셜 영상 다운로더(YouTube/Shorts/Instagram/X/TikTok/Facebook). /// 기존 HLS/DASH/Direct 다운로더 계약을 따른다(단, 페이지 URL 을 받는다). /// 진행률은 yt-dlp --newline 의 [download] 라인에서 퍼센트를 파싱. /// public class YtDlpDownloader { private readonly AppConfig _config; // [download] 12.3% of ~2.34MiB ... → (percent, size) private static readonly Regex ProgressRe = new(@"\[download\]\s+([0-9.]+)%\s+of\s+(\S+)", RegexOptions.Compiled); public YtDlpDownloader(AppConfig config) => _config = config; /// 영상 페이지 URL /// 확장자 없는 출력 경로 /// 선택한 화질/형식 옵션 /// Netscape cookies.txt 경로(null 이면 미사용) public async Task DownloadAsync( Uri pageUrl, string outputBase, FormatOption fmt, string? cookiesPath, IProgress? progress = null, CancellationToken ct = default) { string? dir = Path.GetDirectoryName(outputBase); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct) ?? throw new InvalidOperationException("yt-dlp 를 찾을 수 없습니다. 설정에서 경로를 지정하거나 PATH 에 추가하세요."); var ffmpegDir = YtDlpRunner.ResolveFfmpegDir(_config); // YouTube n 챌린지(EJS) 해석용 JS 런타임 확보. 없고 AutoInstallDeno 면 자동 설치. // Deno 누락이 치명적 오류는 아니므로(yt-dlp 가 경고 후 진행) 결과는 무시 — PATH 주입(YtDlpRunner) 으로 자동 발견. void ReportStatus(string msg) => progress?.Report(new DownloadProgress(0, 0, "downloading", -1, msg)); await DenoRunner.EnsureInstalledAsync(_config, ReportStatus, ct); var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients); // yt-dlp 실행 + 진행률 파싱. 갱신 후 재시도 시 동일 콜백을 재사용하므로 로컬 함수로 캡슐화. // (remuxing 은 실행마다 리셋 — 갱신 후 재시도에선 병합 단계가 처음부터 다시 시작될 수 있음.) async Task<(int Exit, StringBuilder Err)> RunOnceAsync(string exePath) { bool remuxing = false; var err = new StringBuilder(); int exit = await YtDlpRunner.RunAsync(exePath, args, (stream, line) => { if (stream == "err") err.AppendLine(line); // 실패 시 원인 노출용 if (line.StartsWith("[Merger]") || line.StartsWith("[ExtractAudio]") || line.Contains("Merging formats") || line.Contains("Deleting original file")) { remuxing = true; progress?.Report(new DownloadProgress(0, 0, "remuxing", -1, "병환/변환 중")); return; } var m = ProgressRe.Match(line); if (m.Success) { double pct = double.TryParse(m.Groups[1].Value, out var p) ? p : 0; string size = m.Groups[2].Value; progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{pct:0}% · {size}")); } else if (!remuxing) { // [download] Destination 등: 길이 미상 부정확 모드 progress?.Report(new DownloadProgress(0, 0, "downloading", -1, line.Trim())); } }, ct); return (exit, err); } var (result, errLog) = await RunOnceAsync(exe); string? recoverNote = null; // 자동 복구: YouTube 안티봇(n 챌린지/PO 토큰/서명 누락 등) 실패로 보이면 yt-dlp 를 최신화해 1회 재시도. // YouTube 가 서명 난독화를 바꿀 때마다 yt-dlp 유지보수자가 대응 릴리스를 내놓으므로, 최신화만으로 대부분 복구됨. if (result != 0 && _config.AutoUpdateYtDlp && LooksLikeAntiBotFailure(errLog)) { progress?.Report(new DownloadProgress(0, 0, "downloading", -1, "YouTube 차단 감지 — yt-dlp 갱신 확인 중")); var current = await YtDlpRunner.GetVersionAsync(exe, ct); var latest = await YtDlpRunner.GetLatestVersionAsync(ct); if (latest != null && latest != current) { progress?.Report(new DownloadProgress(0, 0, "downloading", -1, $"yt-dlp {current ?? "?"} → {latest} 갱신 후 재시도")); var updated = await YtDlpRunner.DownloadAsync(_config, null, ct); if (updated != null) { exe = updated; // Resolve 우선순위상 갱신된 번들 바이너리가 다음 호출부터 사용됨 (result, errLog) = await RunOnceAsync(exe); } } else if (latest != null) // latest == current: 이미 최신인데도 실패 → 갱신은 무의미 { recoverNote = $"이미 최신 yt-dlp({current})임에도 실패 — YouTube가 방금 바뀌었을 수 있음(잠시 후 재시도 권장)"; } } if (result != 0) { // stderr 마지막 6줄까지 노출 → 사용자가 실제 사유(봇체크/연령/쿠키 등) 를 바로 알 수 있게. var tail = errLog.ToString().Trim(); var shortMsg = tail.Length == 0 ? "(stderr 없음)" : string.Join(' ', tail.Split('\n').TakeLast(6)).Trim(); var msg = $"yt-dlp 실패(exit {result}): {shortMsg}"; if (recoverNote != null) msg += $" | {recoverNote}"; throw new InvalidOperationException(msg); } var final = outputBase + (fmt.IsAudio ? ".mp3" : ".mp4"); if (!File.Exists(final)) { // 예상과 다른 확장자(webm/mkv 등) 로 떨어졌을 가능성 → outputBase.* 중 가장 최근 파일 var alt = FindProduced(outputBase); if (alt == null) throw new InvalidOperationException("출력 파일을 찾을 수 없습니다. yt-dlp 로그를 확인하세요."); final = alt; } return final; } private static List BuildArgs(Uri pageUrl, string outputBase, FormatOption fmt, string? cookiesPath, string? ffmpegDir, string? playerClients) { var args = new List { "--no-playlist", "--no-update", "--newline", "-f", fmt.Selector, }; if (fmt.IsAudio) { args.AddRange(new[] { "--extract-audio", "--audio-format", "mp3", "--audio-quality", "0" }); } else { args.AddRange(new[] { "--merge-output-format", "mp4" }); } if (ffmpegDir != null) args.AddRange(new[] { "--ffmpeg-location", ffmpegDir }); if (!string.IsNullOrEmpty(cookiesPath)) args.AddRange(new[] { "--cookies", cookiesPath }); // YouTube player_client: 비워두면 yt-dlp 공식 기본값(PO 토큰에 안전한 visionos/android_vr/web, 유지보수자가 갱신) 사용. // 과거엔 android/web_sns/web 하드코딩으로 덮었으나, 2024년 이후 android/ios 는 PO 토큰 필수 + 쿠키 미지원, // web_sns 는 yt-dlp 에 없는 클라이언트라 대부분 무효 → 기본값보다 오히려 나빴음. // 로그인/연령제한 콘텐츠는 (BOM 수정된) 쿠키가 web 클라이언트로 전달되어 정상 작동. AddYoutubeClientArgs(args, playerClients); // YouTube n 챌린지(플레이어 서명 스로틀 파라미터 난독화) 해석 활성화. 없으면 포맷이 통째로 드랍되어 // "Only images are available" 로 MP4 다운로드 실패하거나, 살아남아도 수백 KiB/s 로 심하게 스로틀됨. AddRemoteComponentsArgs(args); args.AddRange(new[] { "-o", outputBase + ".%(ext)s" }); args.Add(pageUrl.ToString()); return args; } /// YouTube n 챌린지 해석용 EJS(External JS Solver) 리모트 컴포넌트 활성화. /// yt-dlp 가 GitHub 배포 챌린지 솔버 스크립트를 받아 JS 런타임(Deno, PATH 에 있으면 자동 감지) 으로 실행. /// yt-dlp 공식 권장 설정(https://github.com/yt-dlp/yt-dlp/wiki/EJS). 최초 1회만 다운로드 후 캐싱. private static void AddRemoteComponentsArgs(List args) => args.AddRange(new[] { "--remote-components", "ejs:github" }); /// YouTube player_client 오버라이드. null/빈 → 인자 생략 → yt-dlp 공식 기본값 사용(권장). /// 임의 값 지정 시 android/ios(PO 토큰 필요·쿠키 미지원) 와 존재하지 않는 web_sns 는 피할 것. private static void AddYoutubeClientArgs(List args, string? clients) { if (string.IsNullOrWhiteSpace(clients)) return; // 기본값 위임 args.AddRange(new[] { "--extractor-args", $"youtube:player_client={clients.Trim()}" }); } /// stderr 에서 YouTube 안티봇(n 챌린지/PO 토큰/서명) 실패 징후를 찾는다. /// yt-dlp 최신화로 복구 가능성이 높은 실패만 true → 진짜 가용성/설정 문제는 갱신·재시도를 피함. private static bool LooksLikeAntiBotFailure(StringBuilder err) { var s = err.ToString(); if (string.IsNullOrWhiteSpace(s)) return false; return s.Contains("n challenge", StringComparison.OrdinalIgnoreCase) // n 챌린지 해석 실패 || s.Contains("nsig", StringComparison.OrdinalIgnoreCase) // n signature 추출 실패 || s.Contains("Only images are available", StringComparison.OrdinalIgnoreCase) // 포맷 통째 드랍 || s.Contains("PO Token", StringComparison.OrdinalIgnoreCase) // PO 토큰 요구 || s.Contains("po_token", StringComparison.OrdinalIgnoreCase) || s.Contains("Requested format is not available", StringComparison.OrdinalIgnoreCase) || s.Contains("Unable to extract", StringComparison.OrdinalIgnoreCase); // 서명/player 추출 실패 } private static string? FindProduced(string outputBase) { var dir = Path.GetDirectoryName(outputBase); var name = Path.GetFileName(outputBase); if (string.IsNullOrEmpty(dir)) return null; FileInfo? best = null; foreach (var f in new DirectoryInfo(dir).EnumerateFiles(name + ".*")) { // yt-dlp 잔류(fragment/.part) 제외 if (f.Name.EndsWith(".part", StringComparison.OrdinalIgnoreCase) || f.Name.EndsWith(".ytdl", StringComparison.OrdinalIgnoreCase)) continue; if (best == null || f.LastWriteTime > best.LastWriteTime) best = f; } return best?.FullName; } /// yt-dlp -J 로 실제 사용 가능한 포맷을 조회해 화질 옵션을 만든다(정밀 드롭다운용). public async Task> FetchFormatsAsync(Uri pageUrl, string? cookiesPath, CancellationToken ct) { var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct); if (exe == null) return new(); var args = new List { "-J", "--no-playlist" }; if (!string.IsNullOrEmpty(cookiesPath)) args.AddRange(new[] { "--cookies", cookiesPath }); AddYoutubeClientArgs(args, _config.YoutubePlayerClients); AddRemoteComponentsArgs(args); // n 챌린지 미해석 시 일부 포맷이 누락될 수 있어 동일하게 활성화 args.Add(pageUrl.ToString()); try { var (exit, stdout, _) = await YtDlpRunner.RunCaptureAsync(exe, args, ct); if (exit != 0 || string.IsNullOrWhiteSpace(stdout)) return new(); return ParseFormats(stdout); } catch { return new(); } } private static List ParseFormats(string json) { var result = new List(); try { using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("formats", out var formats)) return result; var heights = new SortedSet(Comparer.Create((a, b) => b.CompareTo(a))); foreach (var f in formats.EnumerateArray()) { string vcodec = Get(f, "vcodec"); if (vcodec == "none" || vcodec == "") continue; // 오디오 전용 제외 if (!f.TryGetProperty("height", out var h)) continue; int height = h.ValueKind == JsonValueKind.Number ? h.GetInt32() : 0; if (height > 0) heights.Add(height); } result.Add(new FormatOption("최고 화질 (MP4)", "bv*+ba/b")); foreach (var hh in heights) { // 50fps 이상이면 라벨에 표기(대략). 정확 fps 매칭은 셀렉터로 보장되지 않아 정보용. string label = hh >= 1280 ? $"{hh}p" : $"{hh}p"; result.Add(new FormatOption(label, $"bv*[height<={hh}]+ba/b[height<={hh}]")); } result.Add(new FormatOption("오디오만 (MP3)", "ba", IsAudio: true)); } catch { } return result.Count > 1 ? result : new(); } private static string Get(JsonElement obj, string name) => obj.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; }