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) { 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; } } /// 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; } /// yt-dlp 자식 프로세스 PATH 앞에 ToolsDir 를 추가 → 번들 deno.exe(EJS n 챌린지 해석용) 를 yt-dlp 가 자동 발겑. /// 시스템 PATH 는 건드리지 않고 이 프로세스에만 적용. ffmpeg/yt-dlp 등 다른 번들 도구 발견에도 보조. 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) } /// 프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬. public static async Task RunAsync(string exe, IEnumerable args, Action 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 { } } } /// stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). stderr 도 버퍼링하며 취소 시 트리 킬. public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync( string exe, IEnumerable 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 onLine, CancellationToken ct) { string? line; while ((line = await reader.ReadLineAsync(ct)) != null) { ct.ThrowIfCancellationRequested(); if (!string.IsNullOrWhiteSpace(line)) onLine(stream, line); } } }