diff --git a/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs b/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs index f59d9b3..a48dc6d 100644 --- a/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs +++ b/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs @@ -31,5 +31,14 @@ namespace N_m3u8DL_RE.Common.Config /// public byte[]? CustomeIV { get; set; } + /// + /// 组装视频分段的URL时,是否要把原本URL后的参数也加上去 + /// 如 Base URL = "http://xxx.com/playlist.m3u8?hmac=xxx&token=xxx" + /// 相对路径 = clip_01.ts + /// 如果 AppendUrlParams=false,得 http://xxx.com/clip_01.ts + /// 如果 AppendUrlParams=true,得 http://xxx.com/clip_01.ts?hmac=xxx&token=xxx + /// + public bool AppendUrlParams { get; set; } = false; + } } diff --git a/src/N_m3u8DL-RE.Common/Entity/Playlist.cs b/src/N_m3u8DL-RE.Common/Entity/Playlist.cs index eb5880d..0265166 100644 --- a/src/N_m3u8DL-RE.Common/Entity/Playlist.cs +++ b/src/N_m3u8DL-RE.Common/Entity/Playlist.cs @@ -12,7 +12,7 @@ namespace N_m3u8DL_RE.Common.Entity //对应Url信息 public string Url { get; set; } //是否直播 - public bool IsLive { get; set; } + public bool IsLive { get; set; } = false; //INIT信息 public MediaSegment? MediaInit { get; set; } //分片信息 diff --git a/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs b/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs index ffcbe99..7198c79 100644 --- a/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs +++ b/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs @@ -35,6 +35,8 @@ namespace N_m3u8DL_RE.Common.Entity public override string ToString() { + var prefixStr = ""; + var returnStr = ""; var encStr = string.Empty; //增加加密标志 @@ -45,19 +47,30 @@ namespace N_m3u8DL_RE.Common.Entity if (MediaType == Enum.MediaType.AUDIO) { - var d = $"{GroupId} | {Name} | {Language} | {(Channels != null ? Channels + "CH" : "")} | {(Playlist != null ? Playlist.MediaParts.Sum(x => x.MediaSegments.Count) + " Segments" : "")}".Replace("| |", "|"); - return $"[deepskyblue3]Aud[/] {encStr}" + d.EscapeMarkup().Trim().Trim('|').Trim(); + prefixStr = $"[deepskyblue3]Aud[/] {encStr}"; + var d = $"{GroupId} | {(Bandwidth != null ? (Bandwidth / 1000) + " Kbps" : "")} | {Name} | {Language} | {(Channels != null ? Channels + "CH" : "")} | {(Playlist != null ? Playlist.MediaParts.Sum(x => x.MediaSegments.Count) + " Segments" : "")}"; + returnStr = d.EscapeMarkup(); } else if (MediaType == Enum.MediaType.SUBTITLES) { - var d = $"{GroupId} | {Language} | {Name} | {(Playlist != null ? Playlist.MediaParts.Sum(x => x.MediaSegments.Count) + " Segments" : "")}".Replace("| |", "|"); - return $"[deepskyblue3_1]Sub[/] {encStr}" + d.EscapeMarkup().Trim().Trim('|').Trim(); + prefixStr = $"[deepskyblue3_1]Sub[/] {encStr}"; + var d = $"{GroupId} | {Language} | {Name} | {(Playlist != null ? Playlist.MediaParts.Sum(x => x.MediaSegments.Count) + " Segments" : "")}"; + returnStr = d.EscapeMarkup(); } else { - var d = $"{Resolution} | {Bandwidth / 1000} Kbps | {FrameRate} | {Codecs} | {(Playlist != null ? Playlist.MediaParts.Sum(x => x.MediaSegments.Count) + " Segments" : "")}".Replace("| |", "|"); - return $"[aqua]Vid[/] {encStr}" + d.EscapeMarkup().Trim().Trim('|').Trim(); + prefixStr = $"[aqua]Vid[/] {encStr}"; + var d = $"{Resolution} | {Bandwidth / 1000} Kbps | {Name} | {FrameRate} | {Codecs} | {(Playlist != null ? Playlist.MediaParts.Sum(x => x.MediaSegments.Count) + " Segments" : "")}"; + returnStr = d.EscapeMarkup(); } + + returnStr = prefixStr + returnStr.Trim().Trim('|').Trim(); + while (returnStr.Contains("| |")) + { + returnStr = returnStr.Replace("| |", "|"); + } + + return returnStr; } } } diff --git a/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs b/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs index 3780251..eab35b4 100644 --- a/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs @@ -69,6 +69,15 @@ namespace N_m3u8DL_RE.Common.Resource { } } + /// + /// 查找类似 验证最后一个分片有效性 的本地化字符串。 + /// + public static string checkingLast { + get { + return ResourceManager.GetString("checkingLast", resourceCulture); + } + } + /// /// 查找类似 获取: 的本地化字符串。 /// @@ -105,6 +114,15 @@ namespace N_m3u8DL_RE.Common.Resource { } } + /// + /// 查找类似 内容匹配: [white on mediumorchid1]Dynamic Adaptive Streaming over HTTP[/] 的本地化字符串。 + /// + public static string matchDASH { + get { + return ResourceManager.GetString("matchDASH", resourceCulture); + } + } + /// /// 查找类似 内容匹配: [white on deepskyblue1]HTTP Live Streaming[/] 的本地化字符串。 /// @@ -123,6 +141,15 @@ namespace N_m3u8DL_RE.Common.Resource { } } + /// + /// 查找类似 正在解析媒体信息... 的本地化字符串。 + /// + public static string parsingStream { + get { + return ResourceManager.GetString("parsingStream", resourceCulture); + } + } + /// /// 查找类似 [grey](按键盘上下键以浏览更多内容)[/] 的本地化字符串。 /// diff --git a/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx b/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx index 6efdea9..335f124 100644 --- a/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx @@ -136,4 +136,13 @@ Writing meta.json + + Content Matched: [white on mediumorchid1]Dynamic Adaptive Streaming over HTTP[/] + + + Verifying the validity of the last segment + + + Parsing streams... + \ No newline at end of file diff --git a/src/N_m3u8DL-RE.Common/Resource/ResString.resx b/src/N_m3u8DL-RE.Common/Resource/ResString.resx index 34dd309..a7e8d14 100644 --- a/src/N_m3u8DL-RE.Common/Resource/ResString.resx +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.resx @@ -136,4 +136,13 @@ 写出meta.json + + 内容匹配: [white on mediumorchid1]Dynamic Adaptive Streaming over HTTP[/] + + + 验证最后一个分片有效性 + + + 正在解析媒体信息... + \ No newline at end of file diff --git a/src/N_m3u8DL-RE.Common/Resource/ResString.zh-TW.resx b/src/N_m3u8DL-RE.Common/Resource/ResString.zh-TW.resx index 4f5c1de..8f07b5c 100644 --- a/src/N_m3u8DL-RE.Common/Resource/ResString.zh-TW.resx +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.zh-TW.resx @@ -136,4 +136,13 @@ 寫出meta.json + + 內容匹配: [white on mediumorchid1]Dynamic Adaptive Streaming over HTTP[/] + + + 驗證最後一個分片有效性 + + + 正在解析媒體信息... + \ No newline at end of file diff --git a/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs b/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs index ddd6abf..da64b33 100644 --- a/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs +++ b/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs @@ -34,7 +34,7 @@ namespace N_m3u8DL_RE.Common.Util Timeout = TimeSpan.FromMinutes(2) }; - public static async Task DoGetAsync(string url, Dictionary? headers = null) + private static async Task DoGetAsync(string url, Dictionary? headers = null) { Logger.Debug(ResString.fetch + url); using var webRequest = new HttpRequestMessage(HttpMethod.Get, url); diff --git a/src/N_m3u8DL-RE.Parser/Extractor/DASHExtractor.cs b/src/N_m3u8DL-RE.Parser/Extractor/DASHExtractor.cs new file mode 100644 index 0000000..e65180d --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/Extractor/DASHExtractor.cs @@ -0,0 +1,740 @@ +using N_m3u8DL_RE.Common.Config; +using N_m3u8DL_RE.Common.Entity; +using N_m3u8DL_RE.Common.Enum; +using N_m3u8DL_RE.Common.Log; +using N_m3u8DL_RE.Common.Resource; +using N_m3u8DL_RE.Common.Util; +using N_m3u8DL_RE.Parser.Util; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml; + +namespace N_m3u8DL_RE.Parser.Extractor +{ + //code from https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/extractor/common.py#L2076 + internal class DASHExtractor : IExtractor + { + private string MpdUrl = string.Empty; + private string BaseUrl = string.Empty; + private string MpdContent = string.Empty; + + public ParserConfig ParserConfig { get; set; } + + public DASHExtractor(ParserConfig parserConfig) + { + this.ParserConfig = parserConfig; + this.MpdUrl = parserConfig.Url ?? string.Empty; + if (!string.IsNullOrEmpty(parserConfig.BaseUrl)) + this.BaseUrl = parserConfig.BaseUrl; + else + this.BaseUrl = this.MpdUrl; + } + + + public async Task> ExtractStreamsAsync(string rawText) + { + this.MpdContent = rawText; + this.PreProcessContent(); + + + XmlDocument mpdDoc = new XmlDocument(); + mpdDoc.LoadXml(MpdContent); + + XmlNode xn = null; + //Select MPD node + foreach (XmlNode node in mpdDoc.ChildNodes) + { + if (node.NodeType == XmlNodeType.Element && node.Name == "MPD") + { + xn = node; + break; + } + } + + var mediaPresentationDuration = ((XmlElement)xn).GetAttribute("mediaPresentationDuration"); + var ns = ((XmlElement)xn).GetAttribute("xmlns"); + + XmlNamespaceManager nsMgr = new XmlNamespaceManager(mpdDoc.NameTable); + nsMgr.AddNamespace("ns", ns); + + TimeSpan ts = XmlConvert.ToTimeSpan(mediaPresentationDuration); //时长 + + //读取在MPD开头定义的,并替换本身的URL + var baseNode = xn.SelectSingleNode("ns:BaseURL", nsMgr); + if (baseNode != null) + { + if (MpdUrl.Contains("kkbox.com.tw/")) + { + var badUrl = baseNode.InnerText; + var goodUrl = badUrl.Replace("//https:%2F%2F", "//"); + BaseUrl = goodUrl; + } + else + { + BaseUrl = baseNode.InnerText; + } + } + + var formatList = new List(); //存放所有音视频清晰度 + var periodIndex = 0; //解决同一个period且同id导致被重复添加分片 + + + foreach (XmlElement period in xn.SelectNodes("ns:Period", nsMgr)) + { + periodIndex++; + var periodDuration = string.IsNullOrEmpty(period.GetAttribute("duration")) ? XmlConvert.ToTimeSpan(mediaPresentationDuration) : XmlConvert.ToTimeSpan(period.GetAttribute("duration")); + var periodMsInfo = ExtractMultisegmentInfo(period, nsMgr, new JsonObject() + { + ["StartNumber"] = 1, + ["Timescale"] = 1 + }); + foreach (XmlElement adaptationSet in period.SelectNodes("ns:AdaptationSet", nsMgr)) + { + var adaptionSetMsInfo = ExtractMultisegmentInfo(adaptationSet, nsMgr, periodMsInfo); + foreach (XmlElement representation in adaptationSet.SelectNodes("ns:Representation", nsMgr)) + { + string GetAttribute(string key) + { + var v1 = representation.GetAttribute(key); + if (string.IsNullOrEmpty(v1)) + return adaptationSet.GetAttribute(key); + return v1; + } + + var mimeType = GetAttribute("mimeType"); + var contentType = mimeType.Split('/')[0]; + if (contentType == "video" || contentType == "audio" || contentType == "text") + { + var baseUrl = ""; + bool CheckBaseUrl() + { + return Regex.IsMatch(baseUrl, @"^https?://"); + } + + var list = new List() + { + representation.ChildNodes, + adaptationSet.ChildNodes, + period.ChildNodes, + mpdDoc.ChildNodes + }; + + foreach (XmlNodeList xmlNodeList in list) + { + foreach (XmlNode node in xmlNodeList) + { + if (node.Name == "BaseURL") + { + baseUrl = node.InnerText + baseUrl; + if (CheckBaseUrl()) break; + } + } + if (CheckBaseUrl()) break; + } + + string GetBaseUrl(string url) + { + if (url.Contains("?")) + url = url.Remove(url.LastIndexOf('?')); + url = url.Substring(0, url.LastIndexOf('/') + 1); + return url; + } + + var mpdBaseUrl = string.IsNullOrEmpty(BaseUrl) ? GetBaseUrl(MpdUrl) : BaseUrl; + if (!string.IsNullOrEmpty(mpdBaseUrl) && !CheckBaseUrl()) + { + if (!mpdBaseUrl.EndsWith("/") && !baseUrl.StartsWith("/")) + { + mpdBaseUrl += "/"; + } + baseUrl = ParserUtil.CombineURL(mpdBaseUrl, baseUrl); + } + var representationId = GetAttribute("id"); + var lang = GetAttribute("lang"); + var bandwidth = IntOrNull(GetAttribute("bandwidth")); + var frameRate = GetAttribute("frameRate"); + if (frameRate.Contains("/")) + { + var d = Convert.ToDouble(frameRate.Split('/')[0]) / Convert.ToDouble(frameRate.Split('/')[1]); + frameRate = d.ToString("0.000"); + } + var f = new JsonObject() + { + ["PeriodIndex"] = periodIndex, + ["ContentType"] = contentType, + ["FormatId"] = representationId, + ["ManifestUrl"] = MpdUrl, + ["Width"] = IntOrNull(GetAttribute("width")), + ["Height"] = IntOrNull(GetAttribute("height")), + ["Tbr"] = (int)DoubleOrNull(bandwidth), + ["Asr"] = IntOrNull(GetAttribute("audioSamplingRate")), + ["Fps"] = DoubleOrNull(frameRate), + ["Language"] = lang, + ["Codecs"] = GetAttribute("codecs") + }; + + var representationMsInfo = ExtractMultisegmentInfo(representation, nsMgr, adaptionSetMsInfo); + + string PrepareTemplate(string templateName, string[] identifiers) + { + var tmpl = representationMsInfo?[templateName]?.GetValue(); + var t = new StringBuilder(); + var inTemplate = false; + foreach (var ch in tmpl) + { + t.Append(ch); + if (ch == '$') + { + inTemplate = !inTemplate; + } + else if (ch == '%' && !inTemplate) + { + t.Append(ch); + } + } + var str = t.ToString(); + str = str.Replace("$RepresentationID$", representationId); + str = Regex.Replace(str, "\\$(" + string.Join("|", identifiers) + ")\\$", "{{$1}}"); + str = Regex.Replace(str, "\\$(" + string.Join("|", identifiers) + ")%([^$]+)d\\$", "{{$1}}{0:D$2}"); + str = str.Replace("$$", "$"); + return str; + } + + string PadNumber(string template, string key, long value) + { + string ReplaceFirst(string text, string search, string replace) + { + int pos = text.IndexOf(search); + if (pos < 0) + { + return text; + } + return text.Substring(0, pos) + replace + text.Substring(pos + search.Length); + } + + template = template.Replace("{{" + key + "}}", ""); + var m = Regex.Match(template, "{0:D(\\d+)}"); + return ReplaceFirst(template, m.Value, value.ToString("0".PadRight(Convert.ToInt32(m.Groups[1].Value), '0'))); + } + + if (representationMsInfo.ContainsKey("Initialization")) + { + var initializationTemplate = PrepareTemplate("Initialization", new string[] { "Bandwidth" }); + var initializationUrl = ""; + if (initializationTemplate.Contains("{0:D")) + { + if (initializationTemplate.Contains("{{Bandwidth}}")) + initializationUrl = PadNumber(initializationTemplate, "Bandwidth", bandwidth); + } + else + { + initializationUrl = initializationTemplate.Replace("{{Bandwidth}}", bandwidth.ToString()); + } + representationMsInfo["InitializationUrl"] = ParserUtil.CombineURL(baseUrl, initializationUrl); + } + + string LocationKey(string location) + { + return Regex.IsMatch(location, "^https?://") ? "url" : "path"; + } + + if (!representationMsInfo.ContainsKey("SegmentUrls") && representationMsInfo.ContainsKey("Media")) + { + var mediaTemplate = PrepareTemplate("Media", new string[] { "Number", "Bandwidth", "Time" }); + var mediaLocationKey = LocationKey(mediaTemplate); + + if (mediaTemplate.Contains("{{Number") && !representationMsInfo.ContainsKey("S")) + { + var segmentDuration = 0.0; + if (!representationMsInfo.ContainsKey("TotalNumber") && representationMsInfo.ContainsKey("SegmentDuration")) + { + segmentDuration = DoubleOrNull(representationMsInfo["SegmentDuration"].GetValue(), representationMsInfo["Timescale"].GetValue()); + representationMsInfo["TotalNumber"] = (int)Math.Ceiling(periodDuration.TotalSeconds / segmentDuration); + } + var fragments = new JsonArray(); + for (int i = representationMsInfo["StartNumber"].GetValue(); i < representationMsInfo["StartNumber"].GetValue() + representationMsInfo["TotalNumber"].GetValue(); i++) + { + var segUrl = ""; + if (mediaTemplate.Contains("{0:D")) + { + if (mediaTemplate.Contains("{{Bandwidth}}")) + segUrl = PadNumber(mediaTemplate, "Bandwidth", bandwidth); + if (mediaTemplate.Contains("{{Number}}")) + segUrl = PadNumber(mediaTemplate, "Number", i); + } + else + { + segUrl = mediaTemplate.Replace("{{Bandwidth}}", bandwidth.ToString()); + segUrl = segUrl.Replace("{{Number}}", i.ToString()); + } + fragments.Add(new JsonObject() + { + [mediaLocationKey] = ParserUtil.CombineURL(baseUrl, segUrl), + ["duration"] = segmentDuration + }); + } + representationMsInfo["Fragments"] = fragments; + } + else + { + var fragments = new JsonArray(); + + var segmentTime = 0L; + var segmentD = 0L; + var segmentNumber = representationMsInfo["StartNumber"].GetValue(); + + void addSegmentUrl() + { + var segUrl = ""; + if (mediaTemplate.Contains("{0:D")) + { + if (mediaTemplate.Contains("{{Bandwidth}}")) + segUrl = PadNumber(mediaTemplate, "Bandwidth", bandwidth); + if (mediaTemplate.Contains("{{Number}}")) + segUrl = PadNumber(mediaTemplate, "Number", segmentNumber); + if (mediaTemplate.Contains("{{Time}}")) + segUrl = PadNumber(mediaTemplate, "Time", segmentTime); + } + else + { + segUrl = mediaTemplate.Replace("{{Bandwidth}}", bandwidth.ToString()); + segUrl = segUrl.Replace("{{Number}}", segmentNumber.ToString()); + segUrl = segUrl.Replace("{{Time}}", segmentTime.ToString()); + } + fragments.Add(new JsonObject() + { + [mediaLocationKey] = ParserUtil.CombineURL(baseUrl, segUrl), + ["duration"] = DoubleOrNull(segmentD, representationMsInfo["Timescale"].GetValue()) + }); + } + + if (representationMsInfo.ContainsKey("S")) + { + var array = representationMsInfo["S"].GetValue(); + for (int i = 0; i < array.Count; i++) + { + var s = array[i]; + segmentTime = s["t"].GetValue() == 0L ? segmentTime : s["t"].GetValue(); + segmentD = s["d"].GetValue(); + addSegmentUrl(); + segmentNumber++; + for (int j = 0; j < s["r"].GetValue(); j++) + { + segmentTime += segmentD; + addSegmentUrl(); + segmentNumber++; + } + segmentTime += segmentD; + } + } + representationMsInfo["Fragments"] = fragments; + } + } + else if (representationMsInfo.ContainsKey("SegmentUrls") && representationMsInfo.ContainsKey("S")) + { + var fragments = new JsonArray(); + + var segmentIndex = 0; + var timescale = representationMsInfo["Timescale"].GetValue(); + foreach (var s in representationMsInfo["S"].GetValue()) + { + var duration = DoubleOrNull(s["d"], timescale); + for (int j = 0; j < s["r"].GetValue() + 1; j++) + { + var segmentUri = representationMsInfo["SegmentUrls"][segmentIndex].GetValue(); + fragments.Add(new JsonObject() + { + [LocationKey(segmentUri)] = ParserUtil.CombineURL(baseUrl, segmentUri), + ["duration"] = duration + }); + segmentIndex++; + } + } + + representationMsInfo["Fragments"] = fragments; + } + else if (representationMsInfo.ContainsKey("SegmentUrls")) + { + var fragments = new JsonArray(); + + var segmentDuration = DoubleOrNull(representationMsInfo["SegmentDuration"].GetValue(), representationMsInfo.ContainsKey("SegmentDuration") ? representationMsInfo["Timescale"].GetValue() : 1); + foreach (var jsonNode in representationMsInfo["SegmentUrls"].GetValue()) + { + var segmentUrl = jsonNode.GetValue(); + if (segmentDuration != -1) + { + fragments.Add(new JsonObject() + { + [LocationKey(segmentUrl)] = ParserUtil.CombineURL(baseUrl, segmentUrl), + ["duration"] = segmentDuration + }); + } + else + { + fragments.Add(new JsonObject() + { + [LocationKey(segmentUrl)] = ParserUtil.CombineURL(baseUrl, segmentUrl) + }); + } + } + + representationMsInfo["Fragments"] = fragments; + } + + if (representationMsInfo.ContainsKey("Fragments")) + { + f["Url"] = string.IsNullOrEmpty(MpdUrl) ? baseUrl : MpdUrl; + f["FragmentBaseUrl"] = baseUrl; + if (representationMsInfo.ContainsKey("InitializationUrl")) + { + f["InitializationUrl"] = ParserUtil.CombineURL(baseUrl, representationMsInfo["InitializationUrl"].GetValue()); + if (f["InitializationUrl"].GetValue().StartsWith("$$Range")) + { + f["InitializationUrl"] = ParserUtil.CombineURL(baseUrl, f["InitializationUrl"].GetValue()); + } + f["Fragments"] = JsonArray.Parse(representationMsInfo["Fragments"].ToJsonString()); + } + } + else + { + //整段mp4 + f["Fragments"] = new JsonArray() { + new JsonObject() + { + ["url"] = baseUrl, + ["duration"] = ts.TotalSeconds + } + }; + } + + //处理同一ID分散在不同Period的情况 + if (formatList.Any(_f => _f["FormatId"].ToJsonString() == f["FormatId"].ToJsonString() && _f["Width"].ToJsonString() == f["Width"].ToJsonString() && _f["ContentType"].ToJsonString() == f["ContentType"].ToJsonString())) + { + for (int i = 0; i < formatList.Count; i++) + { + //参数相同但不在同一个Period才可以 + if (formatList[i]["FormatId"].ToJsonString() == f["FormatId"].ToJsonString() && formatList[i]["Width"].ToJsonString() == f["Width"].ToJsonString() && formatList[i]["ContentType"].ToJsonString() == f["ContentType"].ToJsonString() && formatList[i]["PeriodIndex"].ToJsonString() != f["PeriodIndex"].ToJsonString()) + { + var array = formatList[i]["Fragments"].AsArray(); + foreach (var item in f["Fragments"].AsArray()) + { + array.Add(item); + } + formatList[i]["Fragments"] = array; + break; + } + } + } + else + { + formatList.Add(f); + } + } + } + } + } + + var streamList = new List(); + foreach (var item in formatList) + { + //基本信息 + StreamSpec streamSpec = new(); + streamSpec.Name = item["FormatId"].GetValue(); + streamSpec.Bandwidth = item["Tbr"].GetValue(); + streamSpec.Codecs = item["Codecs"].GetValue(); + streamSpec.Language = item["Language"].GetValue(); + streamSpec.FrameRate = item["Fps"].GetValue(); + streamSpec.Resolution = item["Width"].GetValue() != -1 ? $"{item["Width"]}x{item["Height"]}" : ""; + streamSpec.Url = MpdUrl; + streamSpec.MediaType = item["ContentType"].GetValue() switch + { + "text" => MediaType.SUBTITLES, + "audio" => MediaType.AUDIO, + _ => null + }; + + //组装分片 + Playlist playlist = new(); + List segments = new(); + //Initial URL + if (item.ContainsKey("InitializationUrl")) + { + var initUrl = item["InitializationUrl"].GetValue(); + if (Regex.IsMatch(initUrl, "\\$\\$Range=(\\d+)-(\\d+)")) + { + var match = Regex.Match(initUrl, "\\$\\$Range=(\\d+)-(\\d+)"); + string rangeStr = match.Value; + long start = Convert.ToInt64(match.Groups[1].Value); + long end = Convert.ToInt64(match.Groups[2].Value); + playlist.MediaInit = new MediaSegment() + { + EncryptInfo = new EncryptInfo() + { + Method = EncryptMethod.UNKNOWN + }, + Url = PreProcessUrl(initUrl.Replace(rangeStr, "")), + StartRange = start, + ExpectLength = end - start + 1 + }; + } + else + { + playlist.MediaInit = new MediaSegment() + { + Url = PreProcessUrl(initUrl) + }; + } + } + //分片地址 + var fragments = item["Fragments"].AsArray(); + var index = 0; + foreach (var fragment in fragments) + { + var seg = fragment.AsObject(); + var dur = seg.ContainsKey("duration") ? seg["duration"].GetValue() : 0.0; + var url = seg.ContainsKey("url") ? seg["url"].GetValue() : seg["path"].GetValue(); + url = PreProcessUrl(url); + MediaSegment mediaSegment = new() + { + Index = index, + Duration = dur, + Url = url, + }; + + if (Regex.IsMatch(url, "\\$\\$Range=(\\d+)-(\\d+)")) + { + var match = Regex.Match(url, "\\$\\$Range=(\\d+)-(\\d+)"); + string rangeStr = match.Value; + long start = Convert.ToInt64(match.Groups[1].Value); + long end = Convert.ToInt64(match.Groups[2].Value); + mediaSegment.StartRange = start; + mediaSegment.ExpectLength = end - start + 1; + } + + segments.Add(mediaSegment); + index++; + } + playlist.MediaParts = new List() + { + new MediaPart() + { + MediaSegments = segments + } + }; + + //统一添加EncryptInfo + playlist.MediaInit.EncryptInfo = new EncryptInfo() + { + Method = EncryptMethod.UNKNOWN + }; + foreach (var seg in playlist.MediaParts[0].MediaSegments) + { + seg.EncryptInfo = new EncryptInfo() + { + Method = EncryptMethod.UNKNOWN + }; + } + streamSpec.Playlist = playlist; + streamList.Add(streamSpec); + } + return streamList; + } + + static bool CheckValid(string url) + { + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(new Uri(url)); + request.Timeout = 120000; + HttpWebResponse response = (HttpWebResponse)request.GetResponse(); + if (((int)response.StatusCode).ToString().StartsWith("2")) return true; + else return false; + } + catch (Exception) { return false; } + } + + static double DoubleOrNull(object text, int scale = 1) + { + try + { + return Convert.ToDouble(text) / scale; + } + catch (Exception) + { + return -1; + } + } + + static int IntOrNull(string text, int scale = 1) + { + try + { + return Convert.ToInt32(text) / scale; + } + catch (Exception) + { + return -1; + } + } + + private JsonObject ExtractMultisegmentInfo(XmlElement period, XmlNamespaceManager nsMgr, JsonObject jsonObject) + { + var MultisegmentInfo = new JsonObject(); + foreach (var item in jsonObject) + { + MultisegmentInfo[item.Key] = JsonNode.Parse(item.Value.ToJsonString()); + } + void ExtractCommon(XmlNode source) + { + var sourceE = (XmlElement)source; + var segmentTimeline = source.SelectSingleNode("ns:SegmentTimeline", nsMgr); + if (segmentTimeline != null) + { + var sE = segmentTimeline.SelectNodes("ns:S", nsMgr); + if (sE?.Count > 0) + { + MultisegmentInfo["TotalNumber"] = 0; + var SList = new JsonArray(); + foreach (XmlElement s in sE) + { + var r = string.IsNullOrEmpty(s.GetAttribute("r")) ? 0 : Convert.ToInt64(s.GetAttribute("r")); + MultisegmentInfo["TotalNumber"] = MultisegmentInfo["TotalNumber"]?.GetValue() + 1 + r; + SList.Add(new JsonObject() + { + ["t"] = string.IsNullOrEmpty(s.GetAttribute("t")) ? 0 : Convert.ToInt64(s.GetAttribute("t")), + ["d"] = Convert.ToInt64(s.GetAttribute("d")), + ["r"] = r + }); + } + MultisegmentInfo.Add("S", SList); + } + } + var startNumber = sourceE.GetAttribute("startNumber"); + if (!string.IsNullOrEmpty(startNumber)) + { + MultisegmentInfo["StartNumber"] = Convert.ToInt32(startNumber); + } + var timescale = sourceE.GetAttribute("timescale"); + if (!string.IsNullOrEmpty(timescale)) + { + MultisegmentInfo["Timescale"] = Convert.ToInt32(timescale); + } + var segmentDuration = sourceE.GetAttribute("duration"); + if (!string.IsNullOrEmpty(segmentDuration)) + { + MultisegmentInfo["SegmentDuration"] = Convert.ToDouble(segmentDuration); + } + } + + void ExtractInitialization(XmlNode source) + { + var initialization = source.SelectSingleNode("ns:Initialization", nsMgr); + if (initialization != null) + { + MultisegmentInfo["InitializationUrl"] = ((XmlElement)initialization).GetAttribute("sourceURL"); + if (((XmlElement)initialization).HasAttribute("range")) + { + MultisegmentInfo["InitializationUrl"] += "$$Range=" + ((XmlElement)initialization).GetAttribute("range"); + } + } + } + + var segmentList = period.SelectSingleNode("ns:SegmentList", nsMgr); + if (segmentList != null) + { + ExtractCommon(segmentList); + ExtractInitialization(segmentList); + var segmentUrlsE = segmentList.SelectNodes("ns:SegmentURL", nsMgr); + var urls = new JsonArray(); + foreach (XmlElement segment in segmentUrlsE) + { + if (segment.HasAttribute("mediaRange")) + { + urls.Add("$$Range=" + segment.GetAttribute("mediaRange")); + } + else + { + urls.Add(segment.GetAttribute("media")); + } + } + MultisegmentInfo["SegmentUrls"] = urls; + } + else + { + var segmentTemplate = period.SelectSingleNode("ns:SegmentTemplate", nsMgr); + if (segmentTemplate != null) + { + ExtractCommon(segmentTemplate); + var media = ((XmlElement)segmentTemplate).GetAttribute("media"); + if (!string.IsNullOrEmpty(media)) + { + MultisegmentInfo["Media"] = media; + } + var initialization = ((XmlElement)segmentTemplate).GetAttribute("initialization"); + if (!string.IsNullOrEmpty(initialization)) + { + MultisegmentInfo["Initialization"] = initialization; + } + else + { + ExtractInitialization(segmentTemplate); + } + } + } + + return MultisegmentInfo; + } + + /// + /// 预处理URL + /// + private string PreProcessUrl(string url) + { + if (ParserConfig.AppendUrlParams) + { + url += new Regex("\\?.*").Match(MpdUrl).Value; + } + + return url; + } + + private void PreProcessContent() + { + //XiGua + if (this.MpdContent.Contains(" streamSpecs) + { + //MPD不需要重新去读取内容,只判断一下最后的分片是否有效即可 + foreach (var item in streamSpecs) + { + //检测最后一片的有效性 + if (item.Playlist.MediaParts[0].MediaSegments.Count > 1) + { + var last = item.Playlist.MediaParts[0].MediaSegments.Last(); + var secondToLast = item.Playlist.MediaParts[0].MediaSegments[item.Playlist.MediaParts[0].MediaSegments.Count - 2]; + var urlLast = last.Url; + var urlSecondToLast = secondToLast.Url; + //普通分段才判断 + if (urlLast.StartsWith("http") && !Regex.IsMatch(urlLast, "\\$\\$Range=(\\d+)-(\\d+)")) + { + Logger.Warn(ResString.checkingLast + $"({(item.MediaType == MediaType.AUDIO ? "Audio" : (item.MediaType == MediaType.SUBTITLES ? "Sub" : "Video"))})"); + //倒数第二段正常,倒数第一段不正常 + if (CheckValid(urlSecondToLast) && !CheckValid(urlLast)) + item.Playlist.MediaParts[0].MediaSegments.RemoveAt(item.Playlist.MediaParts[0].MediaSegments.Count - 1); + } + } + } + } + } +} diff --git a/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs b/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs index d74407f..c522fb5 100644 --- a/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs +++ b/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs @@ -107,14 +107,11 @@ namespace N_m3u8DL_RE.Parser.Extractor /// private string PreProcessUrl(string url) { - if (url.Contains("?__gda__")) - { - url += new Regex("\\?__gda__.*").Match(M3u8Url).Value; - } - if (M3u8Url.Contains("//dlsc.hcs.cmvideo.cn") && (url.EndsWith(".ts") || url.EndsWith(".mp4"))) + if (ParserConfig.AppendUrlParams) { url += new Regex("\\?.*").Match(M3u8Url).Value; } + return url; } @@ -480,9 +477,16 @@ namespace N_m3u8DL_RE.Parser.Extractor private async Task ParseKeyAsync(string uri) { - var segUrl = PreProcessUrl(ParserUtil.CombineURL(BaseUrl, uri)); - var bytes = await HTTPUtil.GetBytesAsync(segUrl, ParserConfig.Headers); - return bytes; + if (uri.ToLower().StartsWith("base64:")) + { + return Convert.FromBase64String(uri.Substring(7)); + } + else + { + var segUrl = PreProcessUrl(ParserUtil.CombineURL(BaseUrl, uri)); + var bytes = await HTTPUtil.GetBytesAsync(segUrl, ParserConfig.Headers); + return bytes; + } } public async Task> ExtractStreamsAsync(string rawText) @@ -494,12 +498,6 @@ namespace N_m3u8DL_RE.Parser.Extractor Logger.Warn(ResString.masterM3u8Found); var lists = await ParseMasterListAsync(); lists = lists.DistinctBy(p => p.Url).ToList(); - for (int i = 0; i < lists.Count; i++) - { - //重新加载m3u8 - await LoadM3u8FromUrlAsync(lists[i].Url); - lists[i].Playlist = await ParseListAsync(); - } return lists; } else @@ -530,5 +528,15 @@ namespace N_m3u8DL_RE.Parser.Extractor this.M3u8Url = this.BaseUrl = url; this.PreProcessContent(); } + + public async Task FetchPlayListAsync(List lists) + { + for (int i = 0; i < lists.Count; i++) + { + //重新加载m3u8 + await LoadM3u8FromUrlAsync(lists[i].Url); + lists[i].Playlist = await ParseListAsync(); + } + } } } diff --git a/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs b/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs index 50b84e6..9c40a7b 100644 --- a/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs +++ b/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs @@ -13,5 +13,7 @@ namespace N_m3u8DL_RE.Parser.Extractor ParserConfig ParserConfig { get; set; } Task> ExtractStreamsAsync(string rawText); + + Task FetchPlayListAsync(List streamSpecs); } } diff --git a/src/N_m3u8DL-RE.Parser/StreamExtractor.cs b/src/N_m3u8DL-RE.Parser/StreamExtractor.cs index 99a46f6..a2360e2 100644 --- a/src/N_m3u8DL-RE.Parser/StreamExtractor.cs +++ b/src/N_m3u8DL-RE.Parser/StreamExtractor.cs @@ -38,6 +38,11 @@ namespace N_m3u8DL_RE.Parser this.rawText = HTTPUtil.GetWebSourceAsync(url, parserConfig.Headers).Result; parserConfig.Url = url; } + else if (File.Exists(url)) + { + this.rawText = File.ReadAllText(url); + parserConfig.Url = new Uri(url).AbsoluteUri; + } this.rawText = rawText.Trim(); LoadSourceFromText(this.rawText); } @@ -51,9 +56,10 @@ namespace N_m3u8DL_RE.Parser Logger.InfoMarkUp(ResString.matchHLS); extractor = new HLSExtractor(parserConfig); } - else if (rawText.StartsWith("..")) + else if (rawText.Contains("> ExtractStreamsAsync() + /// + /// 开始解析流媒体信息 + /// + /// + public async Task> ExtractStreamsAsync() { - return extractor.ExtractStreamsAsync(rawText); + Logger.Info(ResString.parsingStream); + return await extractor.ExtractStreamsAsync(rawText); + } + + /// + /// 根据规格说明填充媒体播放列表信息 + /// + /// + public async Task FetchPlayListAsync(List streamSpecs) + { + Logger.Info(ResString.parsingStream); + await extractor.FetchPlayListAsync(streamSpecs); } } } diff --git a/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs b/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs index ce4026b..4755bcd 100644 --- a/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs +++ b/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs @@ -14,6 +14,9 @@ namespace N_m3u8DL_RE.Parser.Util { public static List SelectStreams(IEnumerable lists) { + if (lists.Count() == 1) + return new List(lists); + //基本流 var basicStreams = lists.Where(x => x.MediaType == null); //可选音频轨道 diff --git a/src/N_m3u8DL-RE/Program.cs b/src/N_m3u8DL-RE/Program.cs index 0ab34f8..f2d9b0b 100644 --- a/src/N_m3u8DL-RE/Program.cs +++ b/src/N_m3u8DL-RE/Program.cs @@ -32,8 +32,8 @@ namespace N_m3u8DL_RE //Logger.LogLevel = LogLevel.DEBUG; var config = new ParserConfig(); var url = string.Empty; - //url = "http://playertest.longtailvideo.com/adaptive/oceans_aes/oceans_aes.m3u8"; - url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8"; + url = "http://playertest.longtailvideo.com/adaptive/oceans_aes/oceans_aes.m3u8"; + //url = "https://bitmovin-a.akamaihd.net/content/MI201109210084_1/mpds/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.mpd"; if (string.IsNullOrEmpty(url)) { @@ -44,7 +44,14 @@ namespace N_m3u8DL_RE var extractor = new StreamExtractor(config); extractor.LoadSourceFromUrl(url); + //解析流信息 var streams = await extractor.ExtractStreamsAsync(); + + if (streams.Count == 0) + { + throw new Exception("解析失败"); + } + //全部媒体 var lists = streams.OrderByDescending(p => p.Bandwidth); //基本流 @@ -59,33 +66,23 @@ namespace N_m3u8DL_RE Logger.Info(ResString.streamsInfo, lists.Count(), basicStreams.Count(), audios.Count(), subs.Count()); - if (streams.Count > 1) + foreach (var item in lists) { - - foreach (var item in lists) Logger.InfoMarkUp(item.ToString()); - - var selectedStreams = PromptUtil.SelectStreams(lists); - - Logger.Info(ResString.selectedStream); - await File.WriteAllTextAsync("meta_selected.json", GlobalUtil.ConvertToJson(selectedStreams), Encoding.UTF8); - foreach (var item in selectedStreams) - { - Logger.InfoMarkUp(item.ToString()); - } + Logger.InfoMarkUp(item.ToString()); } - else if (streams.Count == 1) + + //展示交互式选择框 + var selectedStreams = PromptUtil.SelectStreams(lists); + //加载playlist + Logger.Info(ResString.selectedStream); + await extractor.FetchPlayListAsync(selectedStreams); + Logger.Warn(ResString.writeJson); + await File.WriteAllTextAsync("meta_selected.json", GlobalUtil.ConvertToJson(selectedStreams), Encoding.UTF8); + foreach (var item in selectedStreams) { - var playlist = streams.First().Playlist; - if (playlist.IsLive) - { - Logger.Warn(ResString.liveFound); - } - //Print(playlist); - } - else - { - throw new Exception("解析失败"); + Logger.InfoMarkUp(item.ToString()); } + } catch (Exception ex) {