Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)

This commit is contained in:
Yun Chan 2026-08-15 11:59:25 +09:00
commit 21b7c4d8bd
60 changed files with 6581 additions and 0 deletions

View file

@ -0,0 +1,70 @@
namespace VideoDownloader.Core.Config;
/// <summary>앱 설정 (JSON 직렬화). %AppData%/VideoDownloader/config.json 에 저장.</summary>
public class AppConfig
{
public string OutputFolder { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "VideoDownloader");
/// <summary>홈 버튼이 이동할 URL</summary>
public string HomeUrl { get; set; } = "https://www.google.com";
public int Concurrency { get; set; } = 8;
public int Retries { get; set; } = 6;
/// <summary>동시에 실행할 다운로드(항목) 수 상한. 세그먼트 단위 Concurrency 와 별개.</summary>
public int MaxConcurrentDownloads { get; set; } = 3;
/// <summary>감지/다운로드에 반응할 형식 키: hls/dash/mp4/webm/mkv/m4v/mov/avi/audio</summary>
public List<string> EnabledFormats { get; set; } = new()
{ "hls", "dash", "mp4", "webm", "mkv", "m4v", "mov", "avi", "audio" };
/// <summary>직링크 감지 최소 크기(MB). 이보다 작으면(포스터 등) 무시.</summary>
public long MinSizeMB { get; set; } = 2;
/// <summary>null 이면 시스템 PATH 의 ffmpeg 사용</summary>
public string? FfmpegPath { get; set; }
/// <summary>yt-dlp 실행 파일 경로(null → 번들/PATH/자동다운로드). 소셜 영상(YouTube·Instagram·X) 다운로드용.</summary>
public string? YtDlpPath { get; set; }
/// <summary>소셜 다운로드 시 로그인 콘텐츠(인스타·X·멤버 영상 등) 를 위해 브라우저 쿠키를 yt-dlp 에 전달.</summary>
public bool UseCookiesForSocial { get; set; } = true;
/// <summary>yt-dlp 가 없으면 %AppData%/VideoDownloader/tools 로 자동 다운로드.</summary>
public bool AutoInstallYtDlp { get; set; } = true;
/// <summary>YouTube 안티봇 실패(n 챌린지/PO 토큰/서명 누락 등) 감지 시 yt-dlp 를 최신 버전으로 갱신해 1회 재시도.
/// YouTube 가 난독화를 바꿀 때 yt-dlp 는 보통 몇 시간~며칠 내 대응 릴리스를 내보내므로, 최신화만으로 대부분 복구됨.</summary>
public bool AutoUpdateYtDlp { get; set; } = true;
/// <summary>Deno(JS 런타임) 실행 파일 경로(null → 번들/PATH/자동다운로드). yt-dlp 의 EJS(n 챌린지 해석) 실행에 필요.</summary>
public string? DenoPath { get; set; }
/// <summary>Deno 가 없으면 %AppData%/VideoDownloader/tools 로 자동 다운로드. YouTube n 챌린지(EJS) 해석에 필수.</summary>
public bool AutoInstallDeno { get; set; } = true;
/// <summary>소셜 카드의 기본 화질 프리셋 키: best/1080/720/480/360/audio.</summary>
public string DefaultSocialQuality { get; set; } = "best";
/// <summary>YouTube player_client 오버라이드(예: "tv,android_vr,web"). null/빈 → yt-dlp 공식 기본값 사용(권장).
/// 2026년 기준 android/ios 는 GVS/Player PO 토큰이 필요하고 쿠키를 지원하지 않으며, web_sns 는
/// yt-dlp 에 존재하지 않는 클라이언트라 비워두는 것이 가장 안정적임.</summary>
public string? YoutubePlayerClients { get; set; }
public string UserAgent { get; set; } =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
/// <summary>테마: "dark" (기본) 또는 "light".</summary>
public string Theme { get; set; } = "dark";
/// <summary>우측 패널 펼침 폭(px). 접기/펼치기 + 드래그 리사이즈 후 저장.</summary>
public double RightPanelWidth { get; set; } = 420;
/// <summary>우측 패널 접힘 여부.</summary>
public bool RightPanelCollapsed { get; set; } = false;
/// <summary>개인정보 보호(InPrivate) 모드: 쿠키·세션·localStorage 를 디스크에 저장하지 않는다. 적용 시 앱 재시작.</summary>
public bool PrivateMode { get; set; } = false;
}

View file

@ -0,0 +1,154 @@
using System.Diagnostics;
using System.Globalization;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Dash;
/// <summary>
/// DASH(MPD) 다운로더. MPD 를 사전 파싱해 (a) DRM 여부 검증, (b) 진행률용 총 길이 산출한 뒤
/// ffmpeg 에게 네이티브 DASH 입력(-i mpd)으로 맡김 — 비디오/오디오 표현 선택·조합·fMP4 처리를
/// ffmpeg가 알아서 하므로 수동 세그먼트 병합보다 훨씬 견고. 클리어(비DRM) 전용.
/// </summary>
public class DashDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
public DashDownloader(HttpClient http, AppConfig config) { _http = http; _config = config; }
public async Task<string> DownloadAsync(Uri mpdUrl, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// 사전 검증: 파싱(또는 DRM) + 총 길이.
double durationSec = 0;
try
{
var xml = await _http.GetStringAsync(mpdUrl, ct);
var manifest = DashParser.Parse(xml, mpdUrl);
if (manifest == null)
throw new InvalidOperationException("DASH 매니페스트 파싱 실패 (DRM 암호화이거나 미지원 형식)");
durationSec = manifest.DurationSec;
}
catch (OperationCanceledException) { throw; }
catch (InvalidOperationException) { throw; }
catch (Exception e)
{
throw new InvalidOperationException($"MPD 조회/파싱 실패: {e.Message}", e);
}
var ffmpeg = ResolveFfmpeg()
?? throw new InvalidOperationException("DASH 다운로드에는 ffmpeg가 필요합니다 (시스템 PATH 또는 설정의 ffmpeg 경로).");
var final = outputBase + ".mp4";
progress?.Report(new DownloadProgress(0, 0, "downloading", 0, "DASH 준비"));
bool success = false;
try
{
await RunFfmpegDashAsync(ffmpeg, mpdUrl, final, durationSec, progress, ct);
if (!File.Exists(final) || new FileInfo(final).Length == 0)
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
success = true;
progress?.Report(new DownloadProgress(0, 0, "downloading", 100, null));
return final;
}
finally
{
if (!success) { try { if (File.Exists(final)) File.Delete(final); } catch { } }
}
}
/// <summary>ffmpeg -i mpdUrl -c copy out.mp4. -progress pipe:1 로 time= 를 파싱해 진행률.</summary>
private async Task RunFfmpegDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false, CreateNoWindow = true,
RedirectStandardError = true, RedirectStandardOutput = true,
};
psi.ArgumentList.Add("-y");
psi.ArgumentList.Add("-hide_banner");
psi.ArgumentList.Add("-loglevel"); psi.ArgumentList.Add("error");
psi.ArgumentList.Add("-progress"); psi.ArgumentList.Add("pipe:1");
// #6 HTTP 옵션은 -i 앞(per-input AVOption) — .NET 사전검증에 쓴 UA/Referer/쿠키를 ffmpeg 에 전달.
// 토큰/Referer 인증 CDN(maccms 계열) 에서 ffmpeg 기본 UA 로 403 이 나는 것을 방지.
AddFfmpegHttpOptions(psi, _http);
psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(mpdUrl.ToString());
psi.ArgumentList.Add("-c"); psi.ArgumentList.Add("copy");
psi.ArgumentList.Add("-movflags"); psi.ArgumentList.Add("+faststart");
psi.ArgumentList.Add(output);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
string? line;
while ((line = await proc.StandardOutput.ReadLineAsync(ct)) != null)
{
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal) && durationSec > 0)
{
if (long.TryParse(line.AsSpan("out_time_ms=".Length), out long us) && us >= 0)
{
var pct = Math.Min(99, us / 1_000_000.0 * 100.0 / durationSec);
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{us / 1_000_000.0:F0}/{durationSec:F0}s"));
}
}
if (line == "progress=end") break;
}
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
{
var msg = string.IsNullOrWhiteSpace(stderr) ? $"exit {proc.ExitCode}" : stderr.Trim();
throw new InvalidOperationException($"ffmpeg DASH 다운로드 실패: {msg}");
}
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
/// <summary>#6 HttpClient 에 설정된 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달.
/// -user_agent/-referer/-headers: 토큰·Referer 인증 CDN(maccms 계열) 에서 403 을 막는다.</summary>
private static void AddFfmpegHttpOptions(ProcessStartInfo psi, HttpClient http)
{
var ua = http.DefaultRequestHeaders.UserAgent.ToString();
if (!string.IsNullOrWhiteSpace(ua)) { psi.ArgumentList.Add("-user_agent"); psi.ArgumentList.Add(ua); }
if (http.DefaultRequestHeaders.Referrer is { } refUri)
{
psi.ArgumentList.Add("-referer");
psi.ArgumentList.Add(refUri.IsAbsoluteUri ? refUri.AbsoluteUri : refUri.ToString());
}
if (http.DefaultRequestHeaders.TryGetValues("Cookie", out var cookies))
{
var cookieHeader = string.Join("; ", cookies);
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
psi.ArgumentList.Add("-headers");
// ffmpeg -headers: 각 헤더를 \r\n 으로 종료한 전체 값.
psi.ArgumentList.Add("Cookie: " + cookieHeader + "\r\n");
}
}
}
private string? ResolveFfmpeg()
{
if (!string.IsNullOrWhiteSpace(_config.FfmpegPath) && File.Exists(_config.FfmpegPath))
return _config.FfmpegPath;
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
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;
}
}

View file

@ -0,0 +1,20 @@
namespace VideoDownloader.Core.Dash;
public record DashSegment(Uri Url, int Index);
/// <summary>하나의 AdaptationSet(비디오 또는 오디오) 최고화질 표현의 세그먼트 묶음.</summary>
public sealed class DashTrack
{
public Uri? InitUrl { get; init; }
public IReadOnlyList<DashSegment> Segments { get; init; } = Array.Empty<DashSegment>();
public bool IsFmp4 => InitUrl != null;
}
/// <summary>DASH(MPD) 파싱 결과 — 클리어(비DRM) 비디오(필수) + 오디오(선택) 트랙.</summary>
public sealed class DashManifest
{
public DashTrack? Video { get; init; }
public DashTrack? Audio { get; init; }
/// <summary>총 재생시간(초). 진행률 산정용.</summary>
public double DurationSec { get; init; }
}

View file

@ -0,0 +1,196 @@
using System.Globalization;
using System.Xml.Linq;
namespace VideoDownloader.Core.Dash;
/// <summary>
/// MPD(DASH 매니페스트) 파서. 클리어(비DRM) 콘텐츠의 최고화질 비디오(+오디오) 트랙을 구성.
/// 지원: SegmentTemplate($Number$/SegmentTimeline/duration) + SegmentList + BaseURL 누적.
/// 미지원: DRM(ContentProtection), SegmentBase(바이트레인지), 다중 Period.
/// 네임스페이스 무관(LocalName 기반 조회).
/// </summary>
public static class DashParser
{
public static DashManifest? Parse(string xml, Uri mpdUrl, bool lowestRep = false)
{
XDocument doc;
try { doc = XDocument.Parse(xml); }
catch { return null; }
var mpd = doc.Root;
if (mpd == null || mpd.Name.LocalName != "MPD") return null;
var period = Child(mpd, "Period");
if (period == null || Child(period, "ContentProtection") != null) return null; // DRM
var mpdBase = Combine(mpdUrl, Val(Child(mpd, "BaseURL")));
var periodBase = Combine(mpdBase, Val(Child(period, "BaseURL")));
// 세그먼트 수 산정용 기간: Period@duration > MPD@mediaPresentationDuration > MPD@duration
var periodSec = ParseDuration(Attr(period, "duration"));
if (periodSec <= 0) periodSec = ParseDuration(Attr(mpd, "mediaPresentationDuration"));
if (periodSec <= 0) periodSec = ParseDuration(Attr(mpd, "duration"));
DashTrack? video = null, audio = null;
foreach (var s in Children(period, "AdaptationSet"))
{
if (Child(s, "ContentProtection") != null) continue; // DRM 셋
var ct = (Attr(s, "contentType") ?? Attr(s, "mimeType") ?? "").ToLowerInvariant();
var track = BuildTrack(s, periodSec, periodBase, lowestRep);
if (track == null) continue;
if (video == null && ct.Contains("video")) video = track;
else if (audio == null && ct.Contains("audio")) audio = track;
else if (video == null) video = track; // contentType 없으면 첫 트랙을 비디오로
}
if (video == null) return null;
return new DashManifest { Video = video, Audio = audio, DurationSec = periodSec };
}
/// <summary>하나의 AdaptationSet 에서 최고(또는 최저)화질 비DRM 표현을 골라 세그먼트 묶음 생성.</summary>
private static DashTrack? BuildTrack(XElement set, double periodSec, Uri periodBase, bool lowestRep)
{
var setBase = Combine(periodBase, Val(Child(set, "BaseURL")));
var setTemplate = Child(set, "SegmentTemplate");
XElement? rep = null; long bestBw = -1;
foreach (var r in Children(set, "Representation"))
{
if (Child(r, "ContentProtection") != null) continue;
var bw = (long?)r.Attribute("bandwidth") ?? 0;
if (rep == null || (lowestRep ? bw < bestBw : bw > bestBw)) { bestBw = bw; rep = r; }
}
if (rep == null) return null;
var repBase = Combine(setBase, Val(Child(rep, "BaseURL")));
var tmpl = Child(rep, "SegmentTemplate") ?? setTemplate;
var repId = Attr(rep, "id") ?? "0";
var bwStr = bestBw.ToString();
// SegmentList
var segList = Child(rep, "SegmentList") ?? Child(set, "SegmentList");
if (segList != null)
{
var segs = new List<DashSegment>(); int i = 0;
foreach (var su in Children(segList, "SegmentURL"))
{
var m = Attr(su, "media");
if (m != null) segs.Add(new DashSegment(Combine(repBase, m), i++));
}
var init = Attr(segList, "initialization");
return new DashTrack { InitUrl = init != null ? Combine(repBase, init) : null, Segments = segs };
}
// SegmentTemplate
if (tmpl != null)
{
var initT = Attr(tmpl, "initialization");
var mediaT = Attr(tmpl, "media");
if (string.IsNullOrEmpty(mediaT)) return null;
var startNumber = (long?)tmpl.Attribute("startNumber") ?? 1;
var tmplBase = Combine(repBase, Val(Child(tmpl, "BaseURL")));
Uri? initUrl = initT != null ? Combine(tmplBase, Subst(initT, repId, bwStr, startNumber, 0)) : null;
var segs = new List<DashSegment>();
var timeline = Child(tmpl, "SegmentTimeline");
if (timeline != null)
{
// #8 $Time$ 는 미디어 시작시간(t/d 누적) 이지 세그먼트 번호가 아님(ISO/IEC 23009-1).
// t 속성은 타임라인을 해당 절대시각으로 재설정.
// #9 r=-1 은 "Period 끝까지 반복" — count=r+1=0 으로 0개를 내던 버그 수정.
var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
long endTime = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale) : long.MaxValue;
long number = startNumber; int idx = 0; long time = 0;
const long MaxSegs = 200000; // 폭주 방지 상한
foreach (var se in Children(timeline, "S"))
{
var tAttr = (long?)se.Attribute("t");
if (tAttr.HasValue) time = tAttr.Value; // t: 타임라인 재설정(절대 시각)
var d = (long?)se.Attribute("d") ?? 0;
var r = (long?)se.Attribute("r") ?? 0;
if (d <= 0) continue;
if (r < 0)
{
// r=-1: 다음 세그먼트 시작이 Period 끝(endTime) 에 달하거나 넘으면 정지.
while (idx < MaxSegs && time < endTime)
{
segs.Add(new DashSegment(Combine(tmplBase, Subst(mediaT, repId, bwStr, number, time)), idx++));
number++; time += d;
}
}
else
{
for (long k = 0; k < r + 1 && idx < MaxSegs; k++)
{
segs.Add(new DashSegment(Combine(tmplBase, Subst(mediaT, repId, bwStr, number, time)), idx++));
number++; time += d;
}
}
}
}
else
{
var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
var duration = (long?)tmpl.Attribute("duration") ?? 0;
if (duration > 0)
{
long total = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale / (double)duration) : 0;
if (total <= 0) total = 1;
for (long n = 0; n < total; n++)
segs.Add(new DashSegment(Combine(tmplBase, Subst(mediaT, repId, bwStr, startNumber + n, n * duration)), (int)n));
}
}
return new DashTrack { InitUrl = initUrl, Segments = segs };
}
// BaseURL 단일 파일 표현
var repBaseOnly = Val(Child(rep, "BaseURL"));
if (!string.IsNullOrEmpty(repBaseOnly))
return new DashTrack { InitUrl = null, Segments = new[] { new DashSegment(Combine(setBase, repBaseOnly), 0) } };
return null;
}
private static XElement? Child(XElement? p, string local) =>
p?.Elements().FirstOrDefault(e => e.Name.LocalName == local);
private static IEnumerable<XElement> Children(XElement? p, string local) =>
p?.Elements().Where(e => e.Name.LocalName == local) ?? Enumerable.Empty<XElement>();
private static string? Attr(XElement e, string n) => (string?)e.Attribute(n);
private static string? Val(XElement? e) => e == null ? null : e.Value.Trim();
/// <param name="number">$Number$ 값(세그먼트 순번)</param>
/// <param name="time">$Time$ 값(미디어 시작시각, SegmentTimeline 누적 t/d 또는 n*duration)</param>
private static string Subst(string template, string repId, string bw, long number, long time)
=> template.Replace("$RepresentationID$", repId)
.Replace("$Bandwidth$", bw)
.Replace("$Number$", number.ToString())
.Replace("$Time$", time.ToString());
private static Uri Combine(Uri? baseUri, string? rel)
{
if (string.IsNullOrEmpty(rel)) return baseUri!;
if (Uri.IsWellFormedUriString(rel, UriKind.Absolute)) return new Uri(rel);
if (baseUri == null) return new Uri(rel, UriKind.Relative);
return new Uri(baseUri, rel);
}
private static double ParseDuration(string? d)
{
if (string.IsNullOrEmpty(d)) return 0;
var s = d.Trim();
if (!s.StartsWith("PT", StringComparison.OrdinalIgnoreCase)) return 0;
s = s.Substring(2);
double total = 0; var num = "";
foreach (var ch in s)
{
if (char.IsDigit(ch) || ch == '.') num += ch;
else if (ch is 'H' or 'M' or 'S' && double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{
total += ch == 'H' ? v * 3600 : ch == 'M' ? v * 60 : v;
num = "";
}
}
return total;
}
}

View file

@ -0,0 +1,156 @@
using System.Net;
using System.Net.Http.Headers;
using System.Threading;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>
/// 직링크 미디어(mp4/webm/mkv/m4v/mov/m4a/mp3/ogg...) 다운로드.
/// - .part 임시파일에 쓰고 완료 시 이동
/// - Range 헤더로 이어받기 (서버 지원 시)
/// - 일시 오류 재시도 + 백오프 (영구 4xx 제외)
/// </summary>
public class DirectDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
public DirectDownloader(HttpClient http, AppConfig config)
{
_http = http;
_config = config;
}
public async Task<string> DownloadAsync(
Uri url, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var part = outputBase + ".part";
Exception? last = null;
string ext = GuessExt("", url);
long total = -1;
for (int attempt = 1; attempt <= _config.Retries; attempt++)
{
ct.ThrowIfCancellationRequested();
long written = File.Exists(part) ? new FileInfo(part).Length : 0;
TimeSpan? retryAfter = null; // 서버가 Retry-After 를 주면 그만큼 대기(#12)
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (written > 0) req.Headers.Range = new RangeHeaderValue(written, null);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
// 이미 완전히 받은 경우
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
return Finalize(part, outputBase, ext);
// 영구 4xx (408/429 제외) → 재시도 의미 없음
var code = (int)resp.StatusCode;
bool permanent = code >= 400 && code < 500
&& resp.StatusCode != HttpStatusCode.RequestTimeout
&& resp.StatusCode != HttpStatusCode.TooManyRequests;
// #12 429/503 은 서버가 정한 쿨다운(Retry-After) 존중 — resp 가 dispose 되기 전에 캡처
if ((code == 429 || code == 503) && resp.Headers.RetryAfter is { } ra)
retryAfter = ra.Delta ?? (ra.Date is { } d ? d - DateTimeOffset.Now : null);
resp.EnsureSuccessStatusCode();
bool partial = resp.StatusCode == HttpStatusCode.PartialContent;
if (!partial) written = 0; // 서버가 range 무시 → 처음부터
var mediaType = resp.Content.Headers.ContentType?.MediaType ?? "";
ext = GuessExt(mediaType, url);
if (resp.Content.Headers.ContentLength is long cl)
total = partial ? written + cl : cl;
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var fs = new FileStream(part, partial ? FileMode.Append : FileMode.Create,
FileAccess.Write, FileShare.None, bufferSize: 65536, useAsync: true);
var buf = new byte[65536];
int n;
while ((n = await stream.ReadAsync(buf, ct)) > 0)
{
fs.Write(buf, 0, n);
written += n;
double pct = total > 0 ? written * 100.0 / total : -1;
string detail = total > 0
? $"{written / 1048576.0:F1}/{total / 1048576.0:F1} MB"
: $"{written / 1048576.0:F1} MB";
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail));
}
await fs.FlushAsync(ct);
return Finalize(part, outputBase, ext);
}
catch (OperationCanceledException) { throw; }
catch (HttpRequestException hre)
{
last = hre;
var c = hre.StatusCode is { } s ? (int)s : 0;
bool perm = c >= 400 && c < 500 && c != 408 && c != 429;
if (perm || attempt == _config.Retries) break;
await DelayAsync(retryAfter, attempt, ct);
}
catch (Exception e)
{
last = e;
if (attempt == _config.Retries) break;
await DelayAsync(retryAfter, attempt, ct);
}
}
throw last!;
}
private static string Finalize(string part, string outputBase, string ext)
{
var final = outputBase + ext;
// 대상 파일이 탐색기(썸네일)/백신에 의해 잠깐 잠겨있으면 File.Move 가
// "being used by another process" 로 실패하므로 잠시 대기하며 재시도.
for (int i = 0; ; i++)
{
try { File.Move(part, final, overwrite: true); return final; }
catch (IOException) when (i < 10) { Thread.Sleep(250); }
}
}
/// <summary>#12 재시도 대기: 서버 Retry-After(있으면, 최대 60s 캡) 를 우선, 없으면 지수 백오프.</summary>
private static async Task DelayAsync(TimeSpan? retryAfter, int attempt, CancellationToken ct)
{
var delay = retryAfter ?? TimeSpan.FromMilliseconds(Math.Min(1500 * attempt, 10000));
if (delay > TimeSpan.FromSeconds(60)) delay = TimeSpan.FromSeconds(60);
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
try { await Task.Delay(delay, ct); } catch { }
}
private static readonly string[] KnownExts =
{ ".mp4", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".flac", ".wav", ".ts" };
private static string GuessExt(string mediaType, Uri url)
{
var path = url.AbsolutePath.ToLowerInvariant();
foreach (var k in KnownExts)
if (path.EndsWith(k)) return k;
return mediaType.ToLowerInvariant() switch
{
"video/mp4" or "video/mp4v-es" => ".mp4",
"video/webm" => ".webm",
"video/x-matroska" => ".mkv",
"video/quicktime" => ".mov",
"video/x-msvideo" => ".avi",
"audio/mp4" or "audio/x-m4a" => ".m4a",
"audio/mpeg" => ".mp3",
"audio/ogg" or "application/ogg" => ".ogg",
"audio/webm" => ".webm",
"audio/wav" or "audio/x-wav" => ".wav",
"audio/flac" => ".flac",
_ => ".mp4",
};
}
}

View file

@ -0,0 +1,261 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>
/// HLS 다운로더: 마스터→최고화질 변환, 세그먼트 병렬 다운로드+이어받기,
/// AES-128 복호화, fMP4(#EXT-X-MAP) 처리, 병합, ffmpeg 리먹스.
/// </summary>
public class HlsDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
private readonly ConcurrentDictionary<Uri, byte[]> _keys = new();
public HlsDownloader(HttpClient http, AppConfig config)
{
_http = http;
_config = config;
}
/// <param name="outputBase">확장자 없는 출력 경로. 최종 파일은 outputBase + ".mp4"</param>
public async Task<string> DownloadAsync(
Uri playlistUrl, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
var partsDir = outputBase + ".parts";
Directory.CreateDirectory(partsDir);
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
bool success = false;
try
{
// 재생목록 해석 (마스터면 최고 화질 변환 따라가기)
var parsed = M3u8Parser.Parse(await _http.GetStringAsync(playlistUrl, ct), playlistUrl);
int level = 0;
while (parsed.Type == PlaylistType.Master)
{
if (parsed.Variants.Count == 0)
throw new InvalidOperationException("마스터 재생목록에 변환이 없습니다.");
var best = parsed.Variants.OrderByDescending(v => v.Bandwidth).First();
parsed = M3u8Parser.Parse(await _http.GetStringAsync(best.Url, ct), best.Url);
if (++level > 5) throw new InvalidOperationException("재생목록 중첩이 너무 깊습니다.");
}
var segs = parsed.Segments;
if (segs.Count == 0) throw new InvalidOperationException("다운로드할 세그먼트가 없습니다.");
// fMP4 초기화 세그먼트
string? initPath = null;
if (parsed.InitSegment != null)
{
initPath = Path.Combine(partsDir, "init.fmp4");
await DownloadWithRetryAsync(parsed.InitSegment, initPath + ".tmp", ct);
File.Move(initPath + ".tmp", initPath, overwrite: true);
}
// 키 사전 로드 (merge 단계에서 동기 복호화)
foreach (var ku in segs.Select(s => s.Key?.KeyUri).Where(u => u != null).Distinct()!)
await GetKeyAsync(ku!, ct);
// 세그먼트 병렬 다운로드 (이어받기)
progress?.Report(new DownloadProgress(0, segs.Count, "downloading"));
int skipped = segs.Count(s =>
File.Exists(SegmentPath(partsDir, s)) && new FileInfo(SegmentPath(partsDir, s)).Length > 0);
var done = new StrongBox<int>(skipped);
var opts = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _config.Concurrency), CancellationToken = ct };
await Parallel.ForEachAsync(segs, opts, async (seg, token) =>
{
var path = SegmentPath(partsDir, seg);
if (!(File.Exists(path) && new FileInfo(path).Length > 0))
{
await DownloadWithRetryAsync(seg.Url, path + ".tmp", token);
File.Move(path + ".tmp", path, overwrite: true);
}
var d = Interlocked.Increment(ref done.Value);
progress?.Report(new DownloadProgress(d, segs.Count, "downloading"));
});
// 병합 (init + 세그먼트 순서대로, 필요시 복호화)
progress?.Report(new DownloadProgress(segs.Count, segs.Count, "merging"));
bool isFmp4 = initPath != null;
var merged = Path.GetFullPath(outputBase + ".merged" + (isFmp4 ? ".mp4" : ".ts"));
using (var outFs = File.Create(merged))
{
if (initPath != null && File.Exists(initPath))
outFs.Write(File.ReadAllBytes(initPath));
foreach (var seg in segs.OrderBy(s => s.Index))
{
var data = File.ReadAllBytes(SegmentPath(partsDir, seg));
if (seg.Key != null) data = DecryptSegment(data, seg);
outFs.Write(data);
}
}
var result = await RemuxAsync(merged, outputBase, isFmp4, progress, ct);
success = true;
return result;
}
finally
{
// #6 성공 시 parts 디렉토리 정리 (실패/취소 시 호출자가 정리)
try { if (success && Directory.Exists(partsDir)) Directory.Delete(partsDir, recursive: true); }
catch { }
}
}
private static string SegmentPath(string partsDir, Segment seg) =>
Path.Combine(partsDir, $"{seg.Index:000000}.seg");
// ---- 다운로드 / 재시도 ----
private async Task DownloadWithRetryAsync(Uri url, string path, CancellationToken ct)
{
Exception? last = null;
for (int attempt = 1; attempt <= _config.Retries; attempt++)
{
try
{
using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
await using var fs = File.Create(path);
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
await stream.CopyToAsync(fs, 65536, ct);
return;
}
catch (OperationCanceledException) { throw; }
catch (Exception e)
{
last = e;
if (attempt == _config.Retries) break;
try { await Task.Delay(Math.Min(1500 * attempt, 10000), ct); } catch { }
}
}
throw last!;
}
// ---- AES-128 ----
private async Task GetKeyAsync(Uri keyUri, CancellationToken ct)
{
if (_keys.ContainsKey(keyUri)) return;
var key = await _http.GetByteArrayAsync(keyUri, ct);
_keys[keyUri] = key;
}
private byte[] DecryptSegment(byte[] data, Segment seg)
{
var key = seg.Key!;
var keyBytes = _keys[key.KeyUri!];
var iv = ResolveIv(key, seg.Sequence);
using var aes = Aes.Create();
aes.Key = keyBytes;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.None;
using var dec = aes.CreateDecryptor();
return dec.TransformFinalBlock(data, 0, data.Length);
}
private static byte[] ResolveIv(KeyInfo key, long seq)
{
var iv = key.Iv;
if (!string.IsNullOrEmpty(iv))
{
var hex = iv.Replace("0x", "").Replace(" ", "");
return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex);
}
// IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로
var b = new byte[16];
var seqBytes = BitConverter.GetBytes(seq); // little-endian on x64
if (BitConverter.IsLittleEndian) Array.Reverse(seqBytes);
Array.Copy(seqBytes, 0, b, 16 - seqBytes.Length, seqBytes.Length);
return b;
}
// ---- 리먹스 ----
private async Task<string> RemuxAsync(
string merged, string outputBase, bool isFmp4,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var ffmpeg = ResolveFfmpeg();
if (ffmpeg != null)
{
var final = outputBase + ".mp4";
if (Path.GetFullPath(final) == Path.GetFullPath(merged))
final = outputBase + ".out.mp4";
progress?.Report(new DownloadProgress(0, 0, "remuxing"));
try
{
await RunFfmpegAsync(ffmpeg, merged, final, ct);
if (File.Exists(final) && new FileInfo(final).Length > 0)
{
File.Delete(merged);
return final;
}
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
}
catch (OperationCanceledException) { throw; }
catch
{
// #2 리먹스 실패 → partial mp4 정리 후 예외 전파 (item.Error 로 사용자 노출)
try { if (File.Exists(final)) File.Delete(final); } catch { }
throw;
}
}
// ffmpeg 없음 → 병합본을 그대로(.ts/.mp4)
var keep = outputBase + (isFmp4 ? ".mp4" : ".ts");
File.Move(merged, keep, overwrite: true);
return keep;
}
private string? ResolveFfmpeg()
{
if (!string.IsNullOrWhiteSpace(_config.FfmpegPath) && File.Exists(_config.FfmpegPath))
return _config.FfmpegPath;
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
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;
}
// #2 stderr/stdout 파이프를 동시에 드레인(데드락 방지), 종료 보장(좀비 방지), 실패 시 예외 throw
// #10 stdout 드레인 태스크도 관측 — 취소 시 미관측(faulted) 태스크가 되지 않도록 finally 에서 await.
private static async Task RunFfmpegAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
Process? proc = null;
Task<string>? stdoutTask = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output })
psi.ArgumentList.Add(a);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // stdout 도 드레인(데드락 방지)
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {proc.ExitCode}): {stderr.Trim()}");
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
// #10 드레인 태스크 관측 (취소/OCE/EOF 시 폭발 무시)
try { if (stdoutTask is not null) await stdoutTask; } catch { }
}
}
}

View file

@ -0,0 +1,101 @@
using System.Text.RegularExpressions;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>m3u8(마스터/미디어) 재생목록 파서. Python parse_m3u8 의 C# 포팅.</summary>
public static class M3u8Parser
{
// KEY="VAL",KEY2=VAL 형태 속성 파싱
private static readonly Regex AttrRe =
new(@"(?<k>[A-Z0-9\-]+)=(\""(?<q>[^""]*)\"")|(?<k2>[A-Z0-9\-]+)=(?<v>[^,]*)",
RegexOptions.Compiled);
private static Dictionary<string, string> ParseAttrs(string s)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in AttrRe.Matches(s))
{
var key = m.Groups["k"].Success ? m.Groups["k"].Value : m.Groups["k2"].Value;
var val = m.Groups["q"].Success ? m.Groups["q"].Value : m.Groups["v"].Value;
d[key] = val;
}
return d;
}
private static string GetValue(this Dictionary<string, string> d, string key, string def = "") =>
d.TryGetValue(key, out var v) ? v : def;
public static ParsedPlaylist Parse(string text, Uri baseUrl)
{
var variants = new List<Variant>();
var segments = new List<Segment>();
Uri? init = null;
bool isMaster = false;
Dictionary<string, string>? pendingVariant = null;
KeyInfo? curKey = null;
long seq = 0;
int idx = 0;
foreach (var raw in text.Split('\n'))
{
var line = raw.Trim();
if (string.IsNullOrEmpty(line)) continue;
if (line.StartsWith("#EXT-X-STREAM-INF:"))
{
pendingVariant = ParseAttrs(line.Substring("#EXT-X-STREAM-INF:".Length));
continue;
}
if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:"))
{
if (long.TryParse(line.Split(':', 2)[1].Trim(), out var s)) seq = s;
continue;
}
if (line.StartsWith("#EXT-X-KEY:"))
{
var a = ParseAttrs(line.Substring("#EXT-X-KEY:".Length));
var method = a.GetValue("METHOD", "NONE").ToUpperInvariant();
curKey = method == "AES-128"
? new KeyInfo(
a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) ? new Uri(baseUrl, u) : null,
a.GetValue("IV"))
: null;
continue;
}
if (line.StartsWith("#EXT-X-MAP:"))
{
var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length));
if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u))
init = new Uri(baseUrl, u);
continue;
}
if (line.StartsWith("#")) continue;
// URL 라인
var abs = new Uri(baseUrl, line);
if (pendingVariant != null)
{
int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw);
variants.Add(new Variant(abs, bw,
pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS")));
pendingVariant = null;
isMaster = true;
}
else
{
segments.Add(new Segment(abs, idx, seq, curKey));
idx++; seq++;
}
}
return new ParsedPlaylist
{
Type = isMaster ? PlaylistType.Master : PlaylistType.Media,
Variants = variants,
Segments = segments,
InitSegment = init,
};
}
}

View file

@ -0,0 +1,8 @@
namespace VideoDownloader.Core.Models;
/// <summary>다운로드 진행 상태. Stage: downloading / merging / remuxing / done</summary>
public record DownloadProgress(int Completed, int Total, string Stage, double OverridePercent = -1, string? Detail = null)
{
// HLS: Completed/Total 로 계산 / 직링크: OverridePercent 사용
public double Percent => OverridePercent >= 0 ? OverridePercent : (Total > 0 ? Completed * 100.0 / Total : 0);
}

View file

@ -0,0 +1,21 @@
namespace VideoDownloader.Core.Models;
public enum PlaylistType { Master, Media }
/// <summary>마스터 재생목록의 변환(화질) 항목</summary>
public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs);
/// <summary>HLS AES-128 키 정보 (EXT-X-KEY)</summary>
public record KeyInfo(Uri? KeyUri, string? Iv);
/// <summary>미디어 세그먼트</summary>
public record Segment(Uri Url, int Index, long Sequence, KeyInfo? Key);
/// <summary>m3u8 파싱 결과</summary>
public record ParsedPlaylist
{
public PlaylistType Type { get; init; }
public IReadOnlyList<Variant> Variants { get; init; } = Array.Empty<Variant>();
public IReadOnlyList<Segment> Segments { get; init; } = Array.Empty<Segment>();
public Uri? InitSegment { get; init; } // #EXT-X-MAP (fMP4 초기화 세그먼트)
}

View file

@ -0,0 +1,51 @@
using System.Text;
namespace VideoDownloader.Core.Social;
/// <summary>
/// yt-dlp 가 읽을 수 있는 Netscape cookies.txt 형식으로 쿠키를 내보낸다.
/// 로그인/연령제한/멤버 콘텐츠(인스타·X 등) 를 위해 앱(WebView2) 의 세션 쿠키를 yt-dlp 에 전달.
/// </summary>
public static class CookieJarWriter
{
// ★ BOM 금지: Encoding.UTF8 은 EF BB BF(BOM) 를 붙이는데, yt-dlp 가 첫 줄
// "# Netscape HTTP Cookie File" 헤더를 인식하지 못해("does not look like a Netscape format
// cookies file" + "invalid length 1") 쿠키 파일 전체를 거부한다.
// → preamble 을 emit 하지 않는 UTF-8 인코딩 사용.
private static readonly Encoding NoBomUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
public static void Write(string path, IEnumerable<CookieRecord> cookies)
{
var sb = new StringBuilder();
sb.AppendLine("# Netscape HTTP Cookie File");
sb.AppendLine("# This is generated by VideoDownloader. Do not edit.");
foreach (var c in cookies)
{
// 필드: domain | flag(도메인 전체 적용) | path | secure | expiration | name | value
string domain = Sanitize(c.Domain);
if (string.IsNullOrEmpty(domain)) continue; // 도메인 없는 쿠키는 7-필드 레이아웃 보호를 위해 건너뜀
string name = Sanitize(c.Name);
string value = Sanitize(c.Value);
if (name.Length == 0 && value.Length == 0) continue;
bool subdomains = domain.StartsWith('.');
// HttpOnly 쿠키는 줄 앞에 "#HttpOnly_" 접두 — yt-dlp 가 http_only 플래그로 인식.
// (YouTube 의 __Secure-* / SID 계열 인증 쿠키가 대부분 HttpOnly.)
if (c.HttpOnly) sb.Append("#HttpOnly_");
sb.Append(domain).Append('\t');
sb.Append(subdomains ? "TRUE" : "FALSE").Append('\t');
sb.Append(string.IsNullOrEmpty(c.Path) ? "/" : Sanitize(c.Path)).Append('\t');
sb.Append(c.Secure ? "TRUE" : "FALSE").Append('\t');
sb.Append(c.Expires > 0 ? c.Expires : 0).Append('\t');
sb.Append(name).Append('\t');
sb.Append(value);
sb.AppendLine();
}
File.WriteAllText(path, sb.ToString(), NoBomUtf8);
}
/// <summary>필드 구분자(tab) 와 줄바꿈(CR/LF) 이 값 안에 들어가 7-필드 레이아웃이 깨지는 것을 방지.</summary>
private static string Sanitize(string s) =>
string.IsNullOrEmpty(s) ? s : s.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
}

View file

@ -0,0 +1,4 @@
namespace VideoDownloader.Core.Social;
/// <summary>WebView2 종속 없는 쿠키 표현. App 계층에서 CoreWebView2Cookie → CookieRecord 로 매핑.</summary>
public record CookieRecord(string Domain, string Path, string Name, string Value, bool Secure, bool HttpOnly, long Expires);

View file

@ -0,0 +1,105 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using VideoDownloader.Core.Config;
namespace VideoDownloader.Core.Social;
/// <summary>
/// Deno(JS 런타임) 해결(설정 오버라이드 → 번들 → PATH → 자동 다운로드) +
/// 안전한 다운로드(GitHub Releases zip → deno.exe 추출). yt-dlp 의 EJS(n 챌린지 해석) 실행에 필요.
/// YtDlpRunner.RunAsync 가 자식 프로세스 PATH 에 ToolsDir 를 추가하므로, 번들 deno.exe 는 yt-dlp 가 자동 발견.
/// </summary>
public static class DenoRunner
{
// yt-dlp 와 동일한 tools 디렉터리를 공유 → PATH 주입 한 번으로 yt-dlp/ffmpeg/deno 모두 발견.
public static string ToolsDir => YtDlpRunner.ToolsDir;
private static string BundledExe => OperatingSystem.IsWindows() ? "deno.exe" : "deno";
public static string BundledPath => Path.Combine(ToolsDir, BundledExe);
private static readonly HttpClient Downloader = new(new HttpClientHandler { AllowAutoRedirect = true })
{ Timeout = TimeSpan.FromMinutes(5) };
/// <summary>Deno 는 Windows ARM 네이티브 빌드가 없어 x64 에뮬레이션. macOS/Linux 는 아키텍처별 에셋.</summary>
private static string AssetName
{
get
{
bool arm = RuntimeInformation.ProcessArchitecture is Architecture.Arm64;
if (OperatingSystem.IsWindows()) return "deno-x86_64-pc-windows-msvc.zip";
if (OperatingSystem.IsMacOS()) return arm ? "deno-aarch64-apple-darwin.zip" : "deno-x86_64-apple-darwin.zip";
return arm ? "deno-aarch64-unknown-linux-gnu.zip" : "deno-x86_64-unknown-linux-gnu.zip";
}
}
private static string ReleaseUrl => $"https://github.com/denoland/deno/releases/latest/download/{AssetName}";
/// <summary>Deno 경로 해결. 못 찾으면 null.</summary>
public static string? Resolve(AppConfig config)
{
if (!string.IsNullOrWhiteSpace(config.DenoPath) && File.Exists(config.DenoPath))
return config.DenoPath;
try { if (File.Exists(BundledPath)) return BundledPath; } catch { }
return FindOnPath(OperatingSystem.IsWindows() ? "deno.exe" : "deno");
}
/// <summary>Deno 가 없고 AutoInstallDeno 면 번들 디렉터리로 자동 다운로드. 경로 반환(실패 시 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.AutoInstallDeno) return null;
return await DownloadAsync(config, log, ct);
}
/// <summary>Deno 를 강제로(재)다운로드해 번들 디렉터리에 설치.</summary>
public static async Task<string?> DownloadAsync(AppConfig config, Action<string>? log, CancellationToken ct)
{
var tmpZip = BundledPath + ".zip";
try
{
Directory.CreateDirectory(ToolsDir);
log?.Invoke("Deno 다운로드 중...");
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(tmpZip, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true))
{
await src.CopyToAsync(dst, 65536, ct);
await dst.FlushAsync(ct);
}
// zip 에서 deno 실행파일 엔트리만 추출(압축 풀면 deno/deno.exe 단일 파일).
try { if (File.Exists(BundledPath)) File.Delete(BundledPath); } catch { }
using var archive = new ZipArchive(new FileStream(tmpZip, FileMode.Open, FileAccess.Read, FileShare.None, 65536, useAsync: true), ZipArchiveMode.Read);
var entry = archive.Entries.FirstOrDefault(e => e.Name.Equals(BundledExe, StringComparison.OrdinalIgnoreCase));
if (entry == null) { log?.Invoke("Deno 추출 실패: zip 에 실행파일이 없음"); return null; }
await using var es = entry.Open();
await using var fs = new FileStream(BundledPath, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
await es.CopyToAsync(fs, 65536, ct);
await fs.FlushAsync(ct);
log?.Invoke($"Deno 설치 완료: {BundledPath}");
return BundledPath;
}
catch (Exception e)
{
log?.Invoke($"Deno 다운로드 실패: {e.Message}");
return null;
}
finally
{
try { if (File.Exists(tmpZip)) File.Delete(tmpZip); } catch { }
}
}
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;
}
}

View file

@ -0,0 +1,8 @@
namespace VideoDownloader.Core.Social;
/// <summary>yt-dlp 화질/형식 선택 옵션. Selector 는 yt-dlp 의 -f 인자.</summary>
/// <param name="Label">드롭다운에 표시할 이름(예: "1080p")</param>
/// <param name="Selector">yt-dlp 포맷 셀렉터(예: "bv*[height&lt;=1080]+ba/b")</param>
/// <param name="IsAudio">true 면 오디오만 추출(--extract-audio --audio-format mp3)</param>
/// <param name="Source">"preset"(정적) 또는 "fetched"(정밀 조회 결과)</param>
public record FormatOption(string Label, string Selector, bool IsAudio = false, string Source = "preset");

View file

@ -0,0 +1,28 @@
namespace VideoDownloader.Core.Social;
/// <summary>정적 화질 프리셋. 카드에 즉시 표시(네트워크 조회 불필요). yt-dlp 포맷 셀렉터 기반.</summary>
public static class SocialPresets
{
public static readonly List<FormatOption> All = new()
{
new("최고 화질 (MP4)", "bv*+ba/b", IsAudio: false),
new("1080p", "bv*[height<=1080]+ba/b[height<=1080]/b[height<=1080]"),
new("720p", "bv*[height<=720]+ba/b[height<=720]/b[height<=720]"),
new("480p", "bv*[height<=480]+ba/b[height<=480]/b[height<=480]"),
new("360p", "bv*[height<=360]+ba/b[height<=360]/b[height<=360]"),
new("오디오만 (MP3)", "ba", IsAudio: true),
};
/// <summary>설정의 기본 화질 키(best/1080/720/480/360/audio) 로 기본 옵션을 찾는다.</summary>
public static FormatOption DefaultFor(string? key)
{
var k = (key ?? "best").Trim().ToLowerInvariant();
foreach (var o in All)
{
if (k == "best" && o.Label.StartsWith("최고")) return o;
if (k == "audio" && o.IsAudio) return o;
if (o.Label.StartsWith(k + "p", StringComparison.Ordinal)) return o;
}
return All[0];
}
}

View file

@ -0,0 +1,259 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Social;
/// <summary>
/// yt-dlp 기반 소셜 영상 다운로더(YouTube/Shorts/Instagram/X/TikTok/Facebook).
/// 기존 HLS/DASH/Direct 다운로더 계약을 따른다(단, 페이지 URL 을 받는다).
/// 진행률은 yt-dlp --newline 의 [download] 라인에서 퍼센트를 파싱.
/// </summary>
public class YtDlpDownloader
{
private readonly AppConfig _config;
// [download] 12.3% of ~2.34MiB ... → (percent, size)
private static readonly Regex ProgressRe =
new(@"\[download\]\s+([0-9.]+)%\s+of\s+(\S+)", RegexOptions.Compiled);
public YtDlpDownloader(AppConfig config) => _config = config;
/// <param name="pageUrl">영상 페이지 URL</param>
/// <param name="outputBase">확장자 없는 출력 경로</param>
/// <param name="fmt">선택한 화질/형식 옵션</param>
/// <param name="cookiesPath">Netscape cookies.txt 경로(null 이면 미사용)</param>
public async Task<string> DownloadAsync(
Uri pageUrl, string outputBase, FormatOption fmt, string? cookiesPath,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct)
?? throw new InvalidOperationException("yt-dlp 를 찾을 수 없습니다. 설정에서 경로를 지정하거나 PATH 에 추가하세요.");
var ffmpegDir = YtDlpRunner.ResolveFfmpegDir(_config);
// YouTube n 챌린지(EJS) 해석용 JS 런타임 확보. 없고 AutoInstallDeno 면 자동 설치.
// Deno 누락이 치명적 오류는 아니므로(yt-dlp 가 경고 후 진행) 결과는 무시 — PATH 주입(YtDlpRunner) 으로 자동 발견.
void ReportStatus(string msg) => progress?.Report(new DownloadProgress(0, 0, "downloading", -1, msg));
await DenoRunner.EnsureInstalledAsync(_config, ReportStatus, ct);
var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients);
// yt-dlp 실행 + 진행률 파싱. 갱신 후 재시도 시 동일 콜백을 재사용하므로 로컬 함수로 캡슐화.
// (remuxing 은 실행마다 리셋 — 갱신 후 재시도에선 병합 단계가 처음부터 다시 시작될 수 있음.)
async Task<(int Exit, StringBuilder Err)> RunOnceAsync(string exePath)
{
bool remuxing = false;
var err = new StringBuilder();
int exit = await YtDlpRunner.RunAsync(exePath, args, (stream, line) =>
{
if (stream == "err") err.AppendLine(line); // 실패 시 원인 노출용
if (line.StartsWith("[Merger]") || line.StartsWith("[ExtractAudio]")
|| line.Contains("Merging formats") || line.Contains("Deleting original file"))
{
remuxing = true;
progress?.Report(new DownloadProgress(0, 0, "remuxing", -1, "병환/변환 중"));
return;
}
var m = ProgressRe.Match(line);
if (m.Success)
{
double pct = double.TryParse(m.Groups[1].Value, out var p) ? p : 0;
string size = m.Groups[2].Value;
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{pct:0}% · {size}"));
}
else if (!remuxing)
{
// [download] Destination 등: 길이 미상 부정확 모드
progress?.Report(new DownloadProgress(0, 0, "downloading", -1, line.Trim()));
}
}, ct);
return (exit, err);
}
var (result, errLog) = await RunOnceAsync(exe);
string? recoverNote = null;
// 자동 복구: YouTube 안티봇(n 챌린지/PO 토큰/서명 누락 등) 실패로 보이면 yt-dlp 를 최신화해 1회 재시도.
// YouTube 가 서명 난독화를 바꿀 때마다 yt-dlp 유지보수자가 대응 릴리스를 내놓으므로, 최신화만으로 대부분 복구됨.
if (result != 0 && _config.AutoUpdateYtDlp && LooksLikeAntiBotFailure(errLog))
{
progress?.Report(new DownloadProgress(0, 0, "downloading", -1, "YouTube 차단 감지 — yt-dlp 갱신 확인 중"));
var current = await YtDlpRunner.GetVersionAsync(exe, ct);
var latest = await YtDlpRunner.GetLatestVersionAsync(ct);
if (latest != null && latest != current)
{
progress?.Report(new DownloadProgress(0, 0, "downloading", -1,
$"yt-dlp {current ?? "?"} → {latest} 갱신 후 재시도"));
var updated = await YtDlpRunner.DownloadAsync(_config, null, ct);
if (updated != null)
{
exe = updated; // Resolve 우선순위상 갱신된 번들 바이너리가 다음 호출부터 사용됨
(result, errLog) = await RunOnceAsync(exe);
}
}
else if (latest != null) // latest == current: 이미 최신인데도 실패 → 갱신은 무의미
{
recoverNote = $"이미 최신 yt-dlp({current})임에도 실패 — YouTube가 방금 바뀌었을 수 있음(잠시 후 재시도 권장)";
}
}
if (result != 0)
{
// stderr 마지막 6줄까지 노출 → 사용자가 실제 사유(봇체크/연령/쿠키 등) 를 바로 알 수 있게.
var tail = errLog.ToString().Trim();
var shortMsg = tail.Length == 0 ? "(stderr 없음)" : string.Join(' ', tail.Split('\n').TakeLast(6)).Trim();
var msg = $"yt-dlp 실패(exit {result}): {shortMsg}";
if (recoverNote != null) msg += $" | {recoverNote}";
throw new InvalidOperationException(msg);
}
var final = outputBase + (fmt.IsAudio ? ".mp3" : ".mp4");
if (!File.Exists(final))
{
// 예상과 다른 확장자(webm/mkv 등) 로 떨어졌을 가능성 → outputBase.* 중 가장 최근 파일
var alt = FindProduced(outputBase);
if (alt == null)
throw new InvalidOperationException("출력 파일을 찾을 수 없습니다. yt-dlp 로그를 확인하세요.");
final = alt;
}
return final;
}
private static List<string> BuildArgs(Uri pageUrl, string outputBase, FormatOption fmt,
string? cookiesPath, string? ffmpegDir, string? playerClients)
{
var args = new List<string>
{
"--no-playlist",
"--no-update",
"--newline",
"-f", fmt.Selector,
};
if (fmt.IsAudio)
{
args.AddRange(new[] { "--extract-audio", "--audio-format", "mp3", "--audio-quality", "0" });
}
else
{
args.AddRange(new[] { "--merge-output-format", "mp4" });
}
if (ffmpegDir != null) args.AddRange(new[] { "--ffmpeg-location", ffmpegDir });
if (!string.IsNullOrEmpty(cookiesPath)) args.AddRange(new[] { "--cookies", cookiesPath });
// YouTube player_client: 비워두면 yt-dlp 공식 기본값(PO 토큰에 안전한 visionos/android_vr/web, 유지보수자가 갱신) 사용.
// 과거엔 android/web_sns/web 하드코딩으로 덮었으나, 2024년 이후 android/ios 는 PO 토큰 필수 + 쿠키 미지원,
// web_sns 는 yt-dlp 에 없는 클라이언트라 대부분 무효 → 기본값보다 오히려 나빴음.
// 로그인/연령제한 콘텐츠는 (BOM 수정된) 쿠키가 web 클라이언트로 전달되어 정상 작동.
AddYoutubeClientArgs(args, playerClients);
// YouTube n 챌린지(플레이어 서명 스로틀 파라미터 난독화) 해석 활성화. 없으면 포맷이 통째로 드랍되어
// "Only images are available" 로 MP4 다운로드 실패하거나, 살아남아도 수백 KiB/s 로 심하게 스로틀됨.
AddRemoteComponentsArgs(args);
args.AddRange(new[] { "-o", outputBase + ".%(ext)s" });
args.Add(pageUrl.ToString());
return args;
}
/// <summary>YouTube n 챌린지 해석용 EJS(External JS Solver) 리모트 컴포넌트 활성화.
/// yt-dlp 가 GitHub 배포 챌린지 솔버 스크립트를 받아 JS 런타임(Deno, PATH 에 있으면 자동 감지) 으로 실행.
/// yt-dlp 공식 권장 설정(https://github.com/yt-dlp/yt-dlp/wiki/EJS). 최초 1회만 다운로드 후 캐싱.</summary>
private static void AddRemoteComponentsArgs(List<string> args) =>
args.AddRange(new[] { "--remote-components", "ejs:github" });
/// <summary>YouTube player_client 오버라이드. null/빈 → 인자 생략 → yt-dlp 공식 기본값 사용(권장).
/// 임의 값 지정 시 android/ios(PO 토큰 필요·쿠키 미지원) 와 존재하지 않는 web_sns 는 피할 것.</summary>
private static void AddYoutubeClientArgs(List<string> args, string? clients)
{
if (string.IsNullOrWhiteSpace(clients)) return; // 기본값 위임
args.AddRange(new[] { "--extractor-args", $"youtube:player_client={clients.Trim()}" });
}
/// <summary>stderr 에서 YouTube 안티봇(n 챌린지/PO 토큰/서명) 실패 징후를 찾는다.
/// yt-dlp 최신화로 복구 가능성이 높은 실패만 true → 진짜 가용성/설정 문제는 갱신·재시도를 피함.</summary>
private static bool LooksLikeAntiBotFailure(StringBuilder err)
{
var s = err.ToString();
if (string.IsNullOrWhiteSpace(s)) return false;
return s.Contains("n challenge", StringComparison.OrdinalIgnoreCase) // n 챌린지 해석 실패
|| s.Contains("nsig", StringComparison.OrdinalIgnoreCase) // n signature 추출 실패
|| s.Contains("Only images are available", StringComparison.OrdinalIgnoreCase) // 포맷 통째 드랍
|| s.Contains("PO Token", StringComparison.OrdinalIgnoreCase) // PO 토큰 요구
|| s.Contains("po_token", StringComparison.OrdinalIgnoreCase)
|| s.Contains("Requested format is not available", StringComparison.OrdinalIgnoreCase)
|| s.Contains("Unable to extract", StringComparison.OrdinalIgnoreCase); // 서명/player 추출 실패
}
private static string? FindProduced(string outputBase)
{
var dir = Path.GetDirectoryName(outputBase);
var name = Path.GetFileName(outputBase);
if (string.IsNullOrEmpty(dir)) return null;
FileInfo? best = null;
foreach (var f in new DirectoryInfo(dir).EnumerateFiles(name + ".*"))
{
// yt-dlp 잔류(fragment/.part) 제외
if (f.Name.EndsWith(".part", StringComparison.OrdinalIgnoreCase) ||
f.Name.EndsWith(".ytdl", StringComparison.OrdinalIgnoreCase)) continue;
if (best == null || f.LastWriteTime > best.LastWriteTime) best = f;
}
return best?.FullName;
}
/// <summary>yt-dlp -J 로 실제 사용 가능한 포맷을 조회해 화질 옵션을 만든다(정밀 드롭다운용).</summary>
public async Task<List<FormatOption>> FetchFormatsAsync(Uri pageUrl, string? cookiesPath, CancellationToken ct)
{
var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct);
if (exe == null) return new();
var args = new List<string> { "-J", "--no-playlist" };
if (!string.IsNullOrEmpty(cookiesPath)) args.AddRange(new[] { "--cookies", cookiesPath });
AddYoutubeClientArgs(args, _config.YoutubePlayerClients);
AddRemoteComponentsArgs(args); // n 챌린지 미해석 시 일부 포맷이 누락될 수 있어 동일하게 활성화
args.Add(pageUrl.ToString());
try
{
var (exit, stdout, _) = await YtDlpRunner.RunCaptureAsync(exe, args, ct);
if (exit != 0 || string.IsNullOrWhiteSpace(stdout)) return new();
return ParseFormats(stdout);
}
catch { return new(); }
}
private static List<FormatOption> ParseFormats(string json)
{
var result = new List<FormatOption>();
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("formats", out var formats)) return result;
var heights = new SortedSet<int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
foreach (var f in formats.EnumerateArray())
{
string vcodec = Get(f, "vcodec");
if (vcodec == "none" || vcodec == "") continue; // 오디오 전용 제외
if (!f.TryGetProperty("height", out var h)) continue;
int height = h.ValueKind == JsonValueKind.Number ? h.GetInt32() : 0;
if (height > 0) heights.Add(height);
}
result.Add(new FormatOption("최고 화질 (MP4)", "bv*+ba/b"));
foreach (var hh in heights)
{
// 50fps 이상이면 라벨에 표기(대략). 정확 fps 매칭은 셀렉터로 보장되지 않아 정보용.
string label = hh >= 1280 ? $"{hh}p" : $"{hh}p";
result.Add(new FormatOption(label, $"bv*[height<={hh}]+ba/b[height<={hh}]"));
}
result.Add(new FormatOption("오디오만 (MP3)", "ba", IsAudio: true));
}
catch { }
return result.Count > 1 ? result : new();
}
private static string Get(JsonElement obj, string name) =>
obj.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
}

View 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);
}
}
}

View file

@ -0,0 +1,50 @@
using System.Text;
namespace VideoDownloader.Core.Util;
public static class FileNameUtil
{
private static readonly char[] Invalid = Path.GetInvalidFileNameChars();
// 무의미한 제네릭 이름 (이름 추출 시 건너뜀)
private static readonly HashSet<string> GenericNames = new(StringComparer.OrdinalIgnoreCase)
{
"index", "playlist", "playlists", "master", "chunklist", "main", "play",
"media", "video", "stream", "streaming", "vod", "output", "file", "hls",
"dash", "manifest", "seg", "segment", "test", "default"
};
/// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary>
public static string Sanitize(string? name, int maxLen = 120)
{
if (string.IsNullOrWhiteSpace(name)) return "video";
var extra = new[] { '"', '<', '>', '|', '*', '?' };
var sb = new StringBuilder();
foreach (var c in name.Trim())
{
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0) sb.Append('_');
else sb.Append(c);
}
var result = sb.ToString().Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video";
return result.Length > maxLen ? result[..maxLen].Trim() : result;
}
/// <summary>미디어 URL 에서 '진짜 파일명'을 추출 (페이지 타이틀 사용 X).</summary>
public static string DeriveName(Uri url)
{
var segs = url.Segments
.Select(s => Uri.UnescapeDataString(s).Trim('/'))
.Where(s => s.Length > 0)
.ToArray();
for (int i = segs.Length - 1; i >= 0; i--)
{
var withoutExt = Path.GetFileNameWithoutExtension(segs[i]);
if (string.IsNullOrWhiteSpace(withoutExt)) continue;
if (GenericNames.Contains(withoutExt)) continue; // index.m3u8 등은 건너뜀
return Sanitize(withoutExt);
}
return Sanitize(url.Host); // fallback: 호스트명
}
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>