150 lines
7 KiB
C#
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);
|
|
}
|
|
}
|