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, _config.SubtitleLanguage);
// 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, string? subtitleLang = null)
{
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);
// UC-D20 자막 함께 받기 — 지정 언어가 있으면 다운로드+임베드
if (!string.IsNullOrWhiteSpace(subtitleLang))
args.AddRange(new[] { "--write-subs", "--sub-langs", subtitleLang, "--embed-subs" });
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() ?? "" : "";
}