releases/VideoDownloader.Core/Social/YtDlpRunner.cs

150 lines
7 KiB
C#

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,
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; }
}
/// <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>프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬.
/// 재구성 ②: IProcessRunner 주입 가능(테스트/모바일) — 기본 DesktopProcessRunner.</summary>
public static async Task<int> RunAsync(string exe, IEnumerable<string> args,
Action<string, string> 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<string, string> { ["PATH"] = ToolsDir }), onLine, ct);
return res.ExitCode;
}
/// <summary>stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). 재구성 ②: 러너 주입 가능.</summary>
public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync(
string exe, IEnumerable<string> 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<string, string> { ["PATH"] = ToolsDir }), null, ct);
return (res.ExitCode, res.Stdout, res.Stderr);
}
}