using System.Globalization; using System.Xml.Linq; namespace VideoDownloader.Core.Dash; /// /// MPD(DASH 매니페스트) 파서. 클리어(비DRM) 콘텐츠의 최고화질 비디오(+오디오) 트랙을 구성. /// 지원: SegmentTemplate($Number$/SegmentTimeline/duration) + SegmentList + BaseURL 누적. /// 미지원: DRM(ContentProtection), SegmentBase(바이트레인지), 다중 Period. /// 네임스페이스 무관(LocalName 기반 조회). /// 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 }; } /// 하나의 AdaptationSet 에서 최고(또는 최저)화질 비DRM 표현을 골라 세그먼트 묶음 생성. 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(); 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(); 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 Children(XElement? p, string local) => p?.Elements().Where(e => e.Name.LocalName == local) ?? Enumerable.Empty(); private static string? Attr(XElement e, string n) => (string?)e.Attribute(n); private static string? Val(XElement? e) => e == null ? null : e.Value.Trim(); /// $Number$ 값(세그먼트 순번) /// $Time$ 값(미디어 시작시각, SegmentTimeline 누적 t/d 또는 n*duration) 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; } }