Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)
This commit is contained in:
commit
21b7c4d8bd
60 changed files with 6581 additions and 0 deletions
51
VideoDownloader.Core/Social/CookieJarWriter.cs
Normal file
51
VideoDownloader.Core/Social/CookieJarWriter.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System.Text;
|
||||
|
||||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>
|
||||
/// yt-dlp 가 읽을 수 있는 Netscape cookies.txt 형식으로 쿠키를 내보낸다.
|
||||
/// 로그인/연령제한/멤버 콘텐츠(인스타·X 등) 를 위해 앱(WebView2) 의 세션 쿠키를 yt-dlp 에 전달.
|
||||
/// </summary>
|
||||
public static class CookieJarWriter
|
||||
{
|
||||
// ★ BOM 금지: Encoding.UTF8 은 EF BB BF(BOM) 를 붙이는데, yt-dlp 가 첫 줄
|
||||
// "# Netscape HTTP Cookie File" 헤더를 인식하지 못해("does not look like a Netscape format
|
||||
// cookies file" + "invalid length 1") 쿠키 파일 전체를 거부한다.
|
||||
// → preamble 을 emit 하지 않는 UTF-8 인코딩 사용.
|
||||
private static readonly Encoding NoBomUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
public static void Write(string path, IEnumerable<CookieRecord> cookies)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Netscape HTTP Cookie File");
|
||||
sb.AppendLine("# This is generated by VideoDownloader. Do not edit.");
|
||||
foreach (var c in cookies)
|
||||
{
|
||||
// 필드: domain | flag(도메인 전체 적용) | path | secure | expiration | name | value
|
||||
string domain = Sanitize(c.Domain);
|
||||
if (string.IsNullOrEmpty(domain)) continue; // 도메인 없는 쿠키는 7-필드 레이아웃 보호를 위해 건너뜀
|
||||
string name = Sanitize(c.Name);
|
||||
string value = Sanitize(c.Value);
|
||||
if (name.Length == 0 && value.Length == 0) continue;
|
||||
|
||||
bool subdomains = domain.StartsWith('.');
|
||||
|
||||
// HttpOnly 쿠키는 줄 앞에 "#HttpOnly_" 접두 — yt-dlp 가 http_only 플래그로 인식.
|
||||
// (YouTube 의 __Secure-* / SID 계열 인증 쿠키가 대부분 HttpOnly.)
|
||||
if (c.HttpOnly) sb.Append("#HttpOnly_");
|
||||
sb.Append(domain).Append('\t');
|
||||
sb.Append(subdomains ? "TRUE" : "FALSE").Append('\t');
|
||||
sb.Append(string.IsNullOrEmpty(c.Path) ? "/" : Sanitize(c.Path)).Append('\t');
|
||||
sb.Append(c.Secure ? "TRUE" : "FALSE").Append('\t');
|
||||
sb.Append(c.Expires > 0 ? c.Expires : 0).Append('\t');
|
||||
sb.Append(name).Append('\t');
|
||||
sb.Append(value);
|
||||
sb.AppendLine();
|
||||
}
|
||||
File.WriteAllText(path, sb.ToString(), NoBomUtf8);
|
||||
}
|
||||
|
||||
/// <summary>필드 구분자(tab) 와 줄바꿈(CR/LF) 이 값 안에 들어가 7-필드 레이아웃이 깨지는 것을 방지.</summary>
|
||||
private static string Sanitize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : s.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
|
||||
}
|
||||
4
VideoDownloader.Core/Social/CookieRecord.cs
Normal file
4
VideoDownloader.Core/Social/CookieRecord.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>WebView2 종속 없는 쿠키 표현. App 계층에서 CoreWebView2Cookie → CookieRecord 로 매핑.</summary>
|
||||
public record CookieRecord(string Domain, string Path, string Name, string Value, bool Secure, bool HttpOnly, long Expires);
|
||||
105
VideoDownloader.Core/Social/DenoRunner.cs
Normal file
105
VideoDownloader.Core/Social/DenoRunner.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using VideoDownloader.Core.Config;
|
||||
|
||||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>
|
||||
/// Deno(JS 런타임) 해결(설정 오버라이드 → 번들 → PATH → 자동 다운로드) +
|
||||
/// 안전한 다운로드(GitHub Releases zip → deno.exe 추출). yt-dlp 의 EJS(n 챌린지 해석) 실행에 필요.
|
||||
/// YtDlpRunner.RunAsync 가 자식 프로세스 PATH 에 ToolsDir 를 추가하므로, 번들 deno.exe 는 yt-dlp 가 자동 발견.
|
||||
/// </summary>
|
||||
public static class DenoRunner
|
||||
{
|
||||
// yt-dlp 와 동일한 tools 디렉터리를 공유 → PATH 주입 한 번으로 yt-dlp/ffmpeg/deno 모두 발견.
|
||||
public static string ToolsDir => YtDlpRunner.ToolsDir;
|
||||
|
||||
private static string BundledExe => OperatingSystem.IsWindows() ? "deno.exe" : "deno";
|
||||
public static string BundledPath => Path.Combine(ToolsDir, BundledExe);
|
||||
|
||||
private static readonly HttpClient Downloader = new(new HttpClientHandler { AllowAutoRedirect = true })
|
||||
{ Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
/// <summary>Deno 는 Windows ARM 네이티브 빌드가 없어 x64 에뮬레이션. macOS/Linux 는 아키텍처별 에셋.</summary>
|
||||
private static string AssetName
|
||||
{
|
||||
get
|
||||
{
|
||||
bool arm = RuntimeInformation.ProcessArchitecture is Architecture.Arm64;
|
||||
if (OperatingSystem.IsWindows()) return "deno-x86_64-pc-windows-msvc.zip";
|
||||
if (OperatingSystem.IsMacOS()) return arm ? "deno-aarch64-apple-darwin.zip" : "deno-x86_64-apple-darwin.zip";
|
||||
return arm ? "deno-aarch64-unknown-linux-gnu.zip" : "deno-x86_64-unknown-linux-gnu.zip";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReleaseUrl => $"https://github.com/denoland/deno/releases/latest/download/{AssetName}";
|
||||
|
||||
/// <summary>Deno 경로 해결. 못 찾으면 null.</summary>
|
||||
public static string? Resolve(AppConfig config)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(config.DenoPath) && File.Exists(config.DenoPath))
|
||||
return config.DenoPath;
|
||||
try { if (File.Exists(BundledPath)) return BundledPath; } catch { }
|
||||
return FindOnPath(OperatingSystem.IsWindows() ? "deno.exe" : "deno");
|
||||
}
|
||||
|
||||
/// <summary>Deno 가 없고 AutoInstallDeno 면 번들 디렉터리로 자동 다운로드. 경로 반환(실패 시 null).</summary>
|
||||
public static async Task<string?> EnsureInstalledAsync(AppConfig config, Action<string>? log, CancellationToken ct)
|
||||
{
|
||||
var found = Resolve(config);
|
||||
if (found != null) return found;
|
||||
if (!config.AutoInstallDeno) return null;
|
||||
return await DownloadAsync(config, log, ct);
|
||||
}
|
||||
|
||||
/// <summary>Deno 를 강제로(재)다운로드해 번들 디렉터리에 설치.</summary>
|
||||
public static async Task<string?> DownloadAsync(AppConfig config, Action<string>? log, CancellationToken ct)
|
||||
{
|
||||
var tmpZip = BundledPath + ".zip";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ToolsDir);
|
||||
log?.Invoke("Deno 다운로드 중...");
|
||||
using var resp = await Downloader.GetAsync(ReleaseUrl, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
await using (var src = await resp.Content.ReadAsStreamAsync(ct))
|
||||
await using (var dst = new FileStream(tmpZip, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true))
|
||||
{
|
||||
await src.CopyToAsync(dst, 65536, ct);
|
||||
await dst.FlushAsync(ct);
|
||||
}
|
||||
|
||||
// zip 에서 deno 실행파일 엔트리만 추출(압축 풀면 deno/deno.exe 단일 파일).
|
||||
try { if (File.Exists(BundledPath)) File.Delete(BundledPath); } catch { }
|
||||
using var archive = new ZipArchive(new FileStream(tmpZip, FileMode.Open, FileAccess.Read, FileShare.None, 65536, useAsync: true), ZipArchiveMode.Read);
|
||||
var entry = archive.Entries.FirstOrDefault(e => e.Name.Equals(BundledExe, StringComparison.OrdinalIgnoreCase));
|
||||
if (entry == null) { log?.Invoke("Deno 추출 실패: zip 에 실행파일이 없음"); return null; }
|
||||
await using var es = entry.Open();
|
||||
await using var fs = new FileStream(BundledPath, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
|
||||
await es.CopyToAsync(fs, 65536, ct);
|
||||
await fs.FlushAsync(ct);
|
||||
|
||||
log?.Invoke($"Deno 설치 완료: {BundledPath}");
|
||||
return BundledPath;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log?.Invoke($"Deno 다운로드 실패: {e.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tmpZip)) File.Delete(tmpZip); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindOnPath(string name)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
8
VideoDownloader.Core/Social/FormatOption.cs
Normal file
8
VideoDownloader.Core/Social/FormatOption.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>yt-dlp 화질/형식 선택 옵션. Selector 는 yt-dlp 의 -f 인자.</summary>
|
||||
/// <param name="Label">드롭다운에 표시할 이름(예: "1080p")</param>
|
||||
/// <param name="Selector">yt-dlp 포맷 셀렉터(예: "bv*[height<=1080]+ba/b")</param>
|
||||
/// <param name="IsAudio">true 면 오디오만 추출(--extract-audio --audio-format mp3)</param>
|
||||
/// <param name="Source">"preset"(정적) 또는 "fetched"(정밀 조회 결과)</param>
|
||||
public record FormatOption(string Label, string Selector, bool IsAudio = false, string Source = "preset");
|
||||
28
VideoDownloader.Core/Social/SocialPresets.cs
Normal file
28
VideoDownloader.Core/Social/SocialPresets.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>정적 화질 프리셋. 카드에 즉시 표시(네트워크 조회 불필요). yt-dlp 포맷 셀렉터 기반.</summary>
|
||||
public static class SocialPresets
|
||||
{
|
||||
public static readonly List<FormatOption> All = new()
|
||||
{
|
||||
new("최고 화질 (MP4)", "bv*+ba/b", IsAudio: false),
|
||||
new("1080p", "bv*[height<=1080]+ba/b[height<=1080]/b[height<=1080]"),
|
||||
new("720p", "bv*[height<=720]+ba/b[height<=720]/b[height<=720]"),
|
||||
new("480p", "bv*[height<=480]+ba/b[height<=480]/b[height<=480]"),
|
||||
new("360p", "bv*[height<=360]+ba/b[height<=360]/b[height<=360]"),
|
||||
new("오디오만 (MP3)", "ba", IsAudio: true),
|
||||
};
|
||||
|
||||
/// <summary>설정의 기본 화질 키(best/1080/720/480/360/audio) 로 기본 옵션을 찾는다.</summary>
|
||||
public static FormatOption DefaultFor(string? key)
|
||||
{
|
||||
var k = (key ?? "best").Trim().ToLowerInvariant();
|
||||
foreach (var o in All)
|
||||
{
|
||||
if (k == "best" && o.Label.StartsWith("최고")) return o;
|
||||
if (k == "audio" && o.IsAudio) return o;
|
||||
if (o.Label.StartsWith(k + "p", StringComparison.Ordinal)) return o;
|
||||
}
|
||||
return All[0];
|
||||
}
|
||||
}
|
||||
259
VideoDownloader.Core/Social/YtDlpDownloader.cs
Normal file
259
VideoDownloader.Core/Social/YtDlpDownloader.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Core.Models;
|
||||
|
||||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>
|
||||
/// yt-dlp 기반 소셜 영상 다운로더(YouTube/Shorts/Instagram/X/TikTok/Facebook).
|
||||
/// 기존 HLS/DASH/Direct 다운로더 계약을 따른다(단, 페이지 URL 을 받는다).
|
||||
/// 진행률은 yt-dlp --newline 의 [download] 라인에서 퍼센트를 파싱.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <param name="pageUrl">영상 페이지 URL</param>
|
||||
/// <param name="outputBase">확장자 없는 출력 경로</param>
|
||||
/// <param name="fmt">선택한 화질/형식 옵션</param>
|
||||
/// <param name="cookiesPath">Netscape cookies.txt 경로(null 이면 미사용)</param>
|
||||
public async Task<string> DownloadAsync(
|
||||
Uri pageUrl, string outputBase, FormatOption fmt, string? cookiesPath,
|
||||
IProgress<DownloadProgress>? 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<string> BuildArgs(Uri pageUrl, string outputBase, FormatOption fmt,
|
||||
string? cookiesPath, string? ffmpegDir, string? playerClients)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"--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;
|
||||
}
|
||||
|
||||
/// <summary>YouTube n 챌린지 해석용 EJS(External JS Solver) 리모트 컴포넌트 활성화.
|
||||
/// yt-dlp 가 GitHub 배포 챌린지 솔버 스크립트를 받아 JS 런타임(Deno, PATH 에 있으면 자동 감지) 으로 실행.
|
||||
/// yt-dlp 공식 권장 설정(https://github.com/yt-dlp/yt-dlp/wiki/EJS). 최초 1회만 다운로드 후 캐싱.</summary>
|
||||
private static void AddRemoteComponentsArgs(List<string> args) =>
|
||||
args.AddRange(new[] { "--remote-components", "ejs:github" });
|
||||
|
||||
/// <summary>YouTube player_client 오버라이드. null/빈 → 인자 생략 → yt-dlp 공식 기본값 사용(권장).
|
||||
/// 임의 값 지정 시 android/ios(PO 토큰 필요·쿠키 미지원) 와 존재하지 않는 web_sns 는 피할 것.</summary>
|
||||
private static void AddYoutubeClientArgs(List<string> args, string? clients)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clients)) return; // 기본값 위임
|
||||
args.AddRange(new[] { "--extractor-args", $"youtube:player_client={clients.Trim()}" });
|
||||
}
|
||||
|
||||
/// <summary>stderr 에서 YouTube 안티봇(n 챌린지/PO 토큰/서명) 실패 징후를 찾는다.
|
||||
/// yt-dlp 최신화로 복구 가능성이 높은 실패만 true → 진짜 가용성/설정 문제는 갱신·재시도를 피함.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>yt-dlp -J 로 실제 사용 가능한 포맷을 조회해 화질 옵션을 만든다(정밀 드롭다운용).</summary>
|
||||
public async Task<List<FormatOption>> FetchFormatsAsync(Uri pageUrl, string? cookiesPath, CancellationToken ct)
|
||||
{
|
||||
var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct);
|
||||
if (exe == null) return new();
|
||||
|
||||
var args = new List<string> { "-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<FormatOption> ParseFormats(string json)
|
||||
{
|
||||
var result = new List<FormatOption>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("formats", out var formats)) return result;
|
||||
|
||||
var heights = new SortedSet<int>(Comparer<int>.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() ?? "" : "";
|
||||
}
|
||||
214
VideoDownloader.Core/Social/YtDlpRunner.cs
Normal file
214
VideoDownloader.Core/Social/YtDlpRunner.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using VideoDownloader.Core.Config;
|
||||
|
||||
namespace VideoDownloader.Core.Social;
|
||||
|
||||
/// <summary>
|
||||
/// yt-dlp 실행 파일 해결(설정 오버라이드 → 번들 → PATH → 자동 다운로드) +
|
||||
/// 안전한 프로세스 실행(stdout/stderr 라인 단위 동시 드레인, 취소 시 트리 킬, exit 검증).
|
||||
/// HlsDownloader.RunFfmpegAsync(#2 패턴) 과 동일한 안전 규칙을 따른다.
|
||||
/// </summary>
|
||||
public static class YtDlpRunner
|
||||
{
|
||||
public static string ToolsDir => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "tools");
|
||||
|
||||
private static string BundledPath => Path.Combine(ToolsDir,
|
||||
OperatingSystem.IsWindows() ? "yt-dlp.exe" : "yt-dlp");
|
||||
|
||||
private static readonly HttpClient Downloader = new(new HttpClientHandler { AllowAutoRedirect = true })
|
||||
{ Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
private const string ReleaseUrl = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe";
|
||||
|
||||
// GitHub Releases API — 최신 태그(버전) 조회용. yt-dlp 태그는 "2026.03.17" 형식(--version 출력과 동일).
|
||||
private const string LatestReleaseApiUrl = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest";
|
||||
|
||||
/// <summary>yt-dlp 경로를 해결한다. 못 찾으면 null.</summary>
|
||||
public static string? Resolve(AppConfig config)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(config.YtDlpPath) && File.Exists(config.YtDlpPath))
|
||||
return config.YtDlpPath;
|
||||
try { if (File.Exists(BundledPath)) return BundledPath; } catch { }
|
||||
return FindOnPath(OperatingSystem.IsWindows() ? "yt-dlp.exe" : "yt-dlp");
|
||||
}
|
||||
|
||||
/// <summary>yt-dlp 가 없고 AutoInstallYtDlp 면 번들 디렉토리로 자동 다운로드. 경로 반환(실패 시 null).</summary>
|
||||
public static async Task<string?> EnsureInstalledAsync(AppConfig config, Action<string>? log, CancellationToken ct)
|
||||
{
|
||||
var found = Resolve(config);
|
||||
if (found != null) return found;
|
||||
if (!config.AutoInstallYtDlp) return null;
|
||||
return await DownloadAsync(config, log, ct);
|
||||
}
|
||||
|
||||
/// <summary>yt-dlp 를 강제로(재)다운로드해 번들 디렉토리에 설치.</summary>
|
||||
public static async Task<string?> DownloadAsync(AppConfig config, Action<string>? log, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ToolsDir);
|
||||
try { if (File.Exists(BundledPath)) File.Delete(BundledPath); } catch { }
|
||||
var tmp = BundledPath + ".tmp";
|
||||
log?.Invoke("yt-dlp 다운로드 중...");
|
||||
using var resp = await Downloader.GetAsync(ReleaseUrl, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
|
||||
await src.CopyToAsync(dst, 65536, ct);
|
||||
await dst.FlushAsync(ct);
|
||||
File.Move(tmp, BundledPath, overwrite: true);
|
||||
log?.Invoke($"yt-dlp 설치 완료: {BundledPath}");
|
||||
return BundledPath;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log?.Invoke($"yt-dlp 다운로드 실패: {e.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>설치된 yt-dlp 버전 문자열("2026.03.17"). 알 수 없으면 null.</summary>
|
||||
public static async Task<string?> GetVersionAsync(string exe, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (exit, stdout, _) = await RunCaptureAsync(exe, new[] { "--version" }, ct);
|
||||
if (exit != 0) return null;
|
||||
var first = stdout.Trim().Split('\n', StringSplitOptions.TrimEntries)[0];
|
||||
return string.IsNullOrWhiteSpace(first) ? null : first;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>GitHub Releases 의 최신 yt-dlp 버전(태그). 조회 실패 시 null.</summary>
|
||||
public static async Task<string?> GetLatestVersionAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, LatestReleaseApiUrl);
|
||||
req.Headers.UserAgent.ParseAdd("VideoDownloader"); // GitHub API 요구 헤더
|
||||
req.Headers.Accept.ParseAdd("application/vnd.github+json");
|
||||
using var resp = await Downloader.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("tag_name", out var tag) &&
|
||||
tag.ValueKind == JsonValueKind.String)
|
||||
return tag.GetString()?.Trim().TrimStart('v'); // 태그 "v2026.03.17" → "2026.03.17"
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>ffmpeg 위치(디렉토리) 를 찾는다(--ffmpeg-location). 없으면 null.</summary>
|
||||
public static string? ResolveFfmpegDir(AppConfig config)
|
||||
{
|
||||
string? exe = null;
|
||||
if (!string.IsNullOrWhiteSpace(config.FfmpegPath) && File.Exists(config.FfmpegPath))
|
||||
exe = config.FfmpegPath;
|
||||
else
|
||||
{
|
||||
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
exe = FindOnPath(name);
|
||||
}
|
||||
return exe == null ? null : Path.GetDirectoryName(exe);
|
||||
}
|
||||
|
||||
private static string? FindOnPath(string name)
|
||||
{
|
||||
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>yt-dlp 자식 프로세스 PATH 앞에 ToolsDir 를 추가 → 번들 deno.exe(EJS n 챌린지 해석용) 를 yt-dlp 가 자동 발겑.
|
||||
/// 시스템 PATH 는 건드리지 않고 이 프로세스에만 적용. ffmpeg/yt-dlp 등 다른 번들 도구 발견에도 보조.</summary>
|
||||
private static void PrependToolsToPath(ProcessStartInfo psi)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cur = psi.Environment.TryGetValue("PATH", out var p) ? (p ?? "") : "";
|
||||
psi.Environment["PATH"] = ToolsDir + Path.PathSeparator + cur;
|
||||
}
|
||||
catch { } // UseShellExecute=true 등 예외 시 무시(본 프로젝트에선 항상 false)
|
||||
}
|
||||
|
||||
/// <summary>프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬.</summary>
|
||||
public static async Task<int> RunAsync(string exe, IEnumerable<string> args,
|
||||
Action<string, string> onLine, CancellationToken ct)
|
||||
{
|
||||
Process? proc = null;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
PrependToolsToPath(psi); // 번들 deno.exe 를 yt-dlp 가 발견하도록(EJS n 챌린지 해석)
|
||||
|
||||
proc = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp 시작 실패");
|
||||
await using var _ = ct.Register(() => { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } });
|
||||
|
||||
// stdout/stderr 를 별개 태스크로 동시 드레인(파이프 버퍼 차면 데드락).
|
||||
var outTask = ReadLinesAsync(proc.StandardOutput, "out", onLine, ct);
|
||||
var errTask = ReadLinesAsync(proc.StandardError, "err", onLine, ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
await Task.WhenAll(outTask, errTask);
|
||||
return proc.ExitCode;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). stderr 도 버퍼링하며 취소 시 트리 킬.</summary>
|
||||
public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync(
|
||||
string exe, IEnumerable<string> args, CancellationToken ct)
|
||||
{
|
||||
Process? proc = null;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
PrependToolsToPath(psi); // 번들 deno.exe 를 yt-dlp 가 발견하도록(EJS n 챌린지 해석)
|
||||
|
||||
proc = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp 시작 실패");
|
||||
await using var _ = ct.Register(() => { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } });
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
return (proc.ExitCode, await stdoutTask, await stderrTask);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadLinesAsync(StreamReader reader, string stream, Action<string, string> onLine, CancellationToken ct)
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(ct)) != null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!string.IsNullOrWhiteSpace(line)) onLine(stream, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue