250 lines
11 KiB
C#
250 lines
11 KiB
C#
using System.Globalization;
|
|
using System.Xml.Linq;
|
|
|
|
namespace Paca.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;
|
|
if (timescale <= 0) 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;
|
|
if (timescale <= 0) 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();
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex NumberFormatRe =
|
|
new(@"\$Number(?:%0(?<w>\d+)d)?\$", System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
private static readonly System.Text.RegularExpressions.Regex TimeFormatRe =
|
|
new(@"\$Time(?:%0(?<w>\d+)d)?\$", System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
/// <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)
|
|
{
|
|
var res = template.Replace("$RepresentationID$", repId)
|
|
.Replace("$Bandwidth$", bw);
|
|
|
|
res = NumberFormatRe.Replace(res, m =>
|
|
{
|
|
if (m.Groups["w"].Success && int.TryParse(m.Groups["w"].Value, out var w))
|
|
return number.ToString("D" + w, CultureInfo.InvariantCulture);
|
|
return number.ToString(CultureInfo.InvariantCulture);
|
|
});
|
|
|
|
res = TimeFormatRe.Replace(res, m =>
|
|
{
|
|
if (m.Groups["w"].Success && int.TryParse(m.Groups["w"].Value, out var w))
|
|
return time.ToString("D" + w, CultureInfo.InvariantCulture);
|
|
return time.ToString(CultureInfo.InvariantCulture);
|
|
});
|
|
|
|
return res;
|
|
}
|
|
|
|
private static Uri Combine(Uri? baseUri, string? rel)
|
|
{
|
|
if (string.IsNullOrEmpty(rel)) return baseUri ?? new Uri("about:blank");
|
|
if (Uri.TryCreate(rel, UriKind.Absolute, out var abs)) return abs;
|
|
if (baseUri == null || !baseUri.IsAbsoluteUri) return new Uri(rel, UriKind.RelativeOrAbsolute);
|
|
return Uri.TryCreate(baseUri, rel, out var combined) ? combined : new Uri(rel, UriKind.RelativeOrAbsolute);
|
|
}
|
|
|
|
internal static double ParseDuration(string? d)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(d)) return 0;
|
|
try
|
|
{
|
|
return System.Xml.XmlConvert.ToTimeSpan(d.Trim()).TotalSeconds;
|
|
}
|
|
catch
|
|
{
|
|
// 폴백: ISO 8601 Duration (P[n]Y[n]M[n]DT[n]H[n]M[n]S) 수동 파싱
|
|
var s = d.Trim().ToUpperInvariant();
|
|
if (!s.StartsWith("P")) return 0;
|
|
s = s.Substring(1); // 'P' 제거
|
|
|
|
bool inTime = false;
|
|
double total = 0;
|
|
var num = "";
|
|
foreach (var ch in s)
|
|
{
|
|
if (ch == 'T')
|
|
{
|
|
inTime = true;
|
|
continue;
|
|
}
|
|
|
|
if (char.IsDigit(ch) || ch == '.')
|
|
{
|
|
num += ch;
|
|
}
|
|
else if (double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
|
|
{
|
|
if (!inTime)
|
|
{
|
|
if (ch == 'D') total += v * 86400;
|
|
else if (ch == 'W') total += v * 604800;
|
|
else if (ch == 'Y') total += v * 31536000;
|
|
}
|
|
else
|
|
{
|
|
if (ch == 'H') total += v * 3600;
|
|
else if (ch == 'M') total += v * 60;
|
|
else if (ch == 'S') total += v;
|
|
}
|
|
num = "";
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
}
|
|
}
|