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
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