using System.Diagnostics; using System.Text.Json; using VideoDownloader.Core.Config; namespace VideoDownloader.Core.Social; /// /// yt-dlp 실행 파일 해결(설정 오버라이드 → 번들 → PATH → 자동 다운로드) + /// 안전한 프로세스 실행(stdout/stderr 라인 단위 동시 드레인, 취소 시 트리 킬, exit 검증). /// HlsDownloader.RunFfmpegAsync(#2 패턴) 과 동일한 안전 규칙을 따른다. /// 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"; /// yt-dlp 경로를 해결한다. 못 찾으면 null. 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"); } /// yt-dlp 가 없고 AutoInstallYtDlp 면 번들 디렉토리로 자동 다운로드. 경로 반환(실패 시 null). public static async Task EnsureInstalledAsync(AppConfig config, Action? log, CancellationToken ct) { var found = Resolve(config); if (found != null) return found; if (!config.AutoInstallYtDlp) return null; return await DownloadAsync(config, log, ct); } /// yt-dlp 를 강제로(재)다운로드해 번들 디렉토리에 설치. public static async Task DownloadAsync(AppConfig config, Action? 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; } } /// 설치된 yt-dlp 버전 문자열("2026.03.17"). 알 수 없으면 null. public static async Task GetVersionAsync(string exe, CancellationToken ct, Platform.IProcessRunner? runner = null) { try { var (exit, stdout, _) = await RunCaptureAsync(exe, new[] { "--version" }, ct, runner); if (exit != 0) return null; var first = stdout.Trim().Split('\n', StringSplitOptions.TrimEntries)[0]; return string.IsNullOrWhiteSpace(first) ? null : first; } catch { return null; } } /// GitHub Releases 의 최신 yt-dlp 버전(태그). 조회 실패 시 null. public static async Task 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; } } /// ffmpeg 위치(디렉토리) 를 찾는다(--ffmpeg-location). 없으면 null. 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; } /// 프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬. /// 재구성 ②: IProcessRunner 주입 가능(테스트/모바일) — 기본 DesktopProcessRunner. public static async Task RunAsync(string exe, IEnumerable args, Action onLine, CancellationToken ct, Platform.IProcessRunner? runner = null) { var r = runner ?? Platform.DesktopProcessRunner.Default; var res = await r.RunAsync(new Platform.ProcessSpec(exe, args.ToList(), EnvPrefix: new Dictionary { ["PATH"] = ToolsDir }), onLine, ct); return res.ExitCode; } /// stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). 재구성 ②: 러너 주입 가능. public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync( string exe, IEnumerable args, CancellationToken ct, Platform.IProcessRunner? runner = null) { var r = runner ?? Platform.DesktopProcessRunner.Default; var res = await r.RunAsync(new Platform.ProcessSpec(exe, args.ToList(), EnvPrefix: new Dictionary { ["PATH"] = ToolsDir }), null, ct); return (res.ExitCode, res.Stdout, res.Stderr); } }