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
154
VideoDownloader.Core/Dash/DashDownloader.cs
Normal file
154
VideoDownloader.Core/Dash/DashDownloader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
20
VideoDownloader.Core/Dash/DashModels.cs
Normal file
20
VideoDownloader.Core/Dash/DashModels.cs
Normal 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; }
|
||||
}
|
||||
196
VideoDownloader.Core/Dash/DashParser.cs
Normal file
196
VideoDownloader.Core/Dash/DashParser.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue