From 6828d11952cf03ebe378ceae392a1858fabd530a Mon Sep 17 00:00:00 2001 From: nilaoda Date: Sun, 19 Jun 2022 00:49:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90m3u8=E5=9F=BA=E6=9C=AC?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/N_m3u8DL-RE.Common/Config/ParserConfig.cs | 35 ++ src/N_m3u8DL-RE.Common/Entity/EncryptInfo.cs | 20 + src/N_m3u8DL-RE.Common/Entity/MediaPart.cs | 14 + src/N_m3u8DL-RE.Common/Entity/MediaSegment.cs | 25 + src/N_m3u8DL-RE.Common/Entity/Playlist.cs | 21 + src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs | 63 +++ src/N_m3u8DL-RE.Common/Enum/Choise.cs | 14 + src/N_m3u8DL-RE.Common/Enum/EncryptMethod.cs | 17 + src/N_m3u8DL-RE.Common/Enum/MediaType.cs | 16 + .../JsonConverter/BytesBase64Converter.cs | 17 + .../N_m3u8DL-RE.Common.csproj | 35 ++ .../Resource/ResString.Designer.cs | 180 ++++++ .../Resource/ResString.en-US.resx | 139 +++++ .../Resource/ResString.resx | 139 +++++ .../Resource/ResString.zh-TW.resx | 139 +++++ src/N_m3u8DL-RE.Common/Util/GlobalUtil.cs | 26 + src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs | 86 +++ src/N_m3u8DL-RE.Common/Util/HexUtil.cs | 29 + src/N_m3u8DL-RE.Parser/Constants/HLSTags.cs | 38 ++ .../Extractor/HLSExtractor.cs | 534 ++++++++++++++++++ .../Extractor/IExtractor.cs | 17 + .../N_m3u8DL-RE.Parser.csproj | 14 + src/N_m3u8DL-RE.Parser/StreamExtractor.cs | 69 +++ src/N_m3u8DL-RE.Parser/Util/ParserUtil.cs | 78 +++ src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs | 67 +++ src/N_m3u8DL-RE.sln | 37 ++ src/N_m3u8DL-RE/Directory.Build.props | 24 + src/N_m3u8DL-RE/N_m3u8DL-RE.csproj | 19 + src/N_m3u8DL-RE/Program.cs | 102 ++++ src/N_m3u8DL-RE/rd.xml | 21 + 30 files changed, 2035 insertions(+) create mode 100644 src/N_m3u8DL-RE.Common/Config/ParserConfig.cs create mode 100644 src/N_m3u8DL-RE.Common/Entity/EncryptInfo.cs create mode 100644 src/N_m3u8DL-RE.Common/Entity/MediaPart.cs create mode 100644 src/N_m3u8DL-RE.Common/Entity/MediaSegment.cs create mode 100644 src/N_m3u8DL-RE.Common/Entity/Playlist.cs create mode 100644 src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs create mode 100644 src/N_m3u8DL-RE.Common/Enum/Choise.cs create mode 100644 src/N_m3u8DL-RE.Common/Enum/EncryptMethod.cs create mode 100644 src/N_m3u8DL-RE.Common/Enum/MediaType.cs create mode 100644 src/N_m3u8DL-RE.Common/JsonConverter/BytesBase64Converter.cs create mode 100644 src/N_m3u8DL-RE.Common/N_m3u8DL-RE.Common.csproj create mode 100644 src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs create mode 100644 src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx create mode 100644 src/N_m3u8DL-RE.Common/Resource/ResString.resx create mode 100644 src/N_m3u8DL-RE.Common/Resource/ResString.zh-TW.resx create mode 100644 src/N_m3u8DL-RE.Common/Util/GlobalUtil.cs create mode 100644 src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs create mode 100644 src/N_m3u8DL-RE.Common/Util/HexUtil.cs create mode 100644 src/N_m3u8DL-RE.Parser/Constants/HLSTags.cs create mode 100644 src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs create mode 100644 src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs create mode 100644 src/N_m3u8DL-RE.Parser/N_m3u8DL-RE.Parser.csproj create mode 100644 src/N_m3u8DL-RE.Parser/StreamExtractor.cs create mode 100644 src/N_m3u8DL-RE.Parser/Util/ParserUtil.cs create mode 100644 src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs create mode 100644 src/N_m3u8DL-RE.sln create mode 100644 src/N_m3u8DL-RE/Directory.Build.props create mode 100644 src/N_m3u8DL-RE/N_m3u8DL-RE.csproj create mode 100644 src/N_m3u8DL-RE/Program.cs create mode 100644 src/N_m3u8DL-RE/rd.xml diff --git a/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs b/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs new file mode 100644 index 0000000..f59d9b3 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Config/ParserConfig.cs @@ -0,0 +1,35 @@ +using N_m3u8DL_RE.Common.Enum; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Config +{ + public class ParserConfig + { + public string Url { get; set; } + public string BaseUrl { get; set; } + public Dictionary Headers { get; set; } = new Dictionary() + { + ["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" + }; + + /// + /// 自定义的加密方式 默认AES_128_CBC + /// + public EncryptMethod CustomMethod { get; set; } = EncryptMethod.AES_128; + + /// + /// 自定义的解密KEY + /// + public byte[]? CustomeKey { get; set; } + + /// + /// 自定义的解密IV + /// + public byte[]? CustomeIV { get; set; } + + } +} diff --git a/src/N_m3u8DL-RE.Common/Entity/EncryptInfo.cs b/src/N_m3u8DL-RE.Common/Entity/EncryptInfo.cs new file mode 100644 index 0000000..a4eca05 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Entity/EncryptInfo.cs @@ -0,0 +1,20 @@ +using N_m3u8DL_RE.Common.Enum; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Entity +{ + public class EncryptInfo + { + /// + /// 加密方式,默认无加密 + /// + public EncryptMethod Method { get; set; } = EncryptMethod.NONE; + + public byte[]? Key { get; set; } + public byte[]? IV { get; set; } + } +} diff --git a/src/N_m3u8DL-RE.Common/Entity/MediaPart.cs b/src/N_m3u8DL-RE.Common/Entity/MediaPart.cs new file mode 100644 index 0000000..b86fa64 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Entity/MediaPart.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Entity +{ + //主要处理 EXT-X-DISCONTINUITY + public class MediaPart + { + public List MediaSegments { get; set; } = new List(); + } +} diff --git a/src/N_m3u8DL-RE.Common/Entity/MediaSegment.cs b/src/N_m3u8DL-RE.Common/Entity/MediaSegment.cs new file mode 100644 index 0000000..aa5fcd3 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Entity/MediaSegment.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Entity +{ + public class MediaSegment + { + public int Index { get; set; } + + public int TargetDuration { get; set; } + public double Duration { get; set; } + public string? Title { get; set; } + + public long StartRange { get; set; } = 0L; + public long StopRange { get => StartRange + ExpectLength - 1; } + public long ExpectLength { get; set; } = -1L; + + public EncryptInfo EncryptInfo { get; set; } = new EncryptInfo(); + + public string Url { get; set; } + } +} diff --git a/src/N_m3u8DL-RE.Common/Entity/Playlist.cs b/src/N_m3u8DL-RE.Common/Entity/Playlist.cs new file mode 100644 index 0000000..eb5880d --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Entity/Playlist.cs @@ -0,0 +1,21 @@ +using N_m3u8DL_RE.Common.Enum; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Entity +{ + public class Playlist + { + //对应Url信息 + public string Url { get; set; } + //是否直播 + public bool IsLive { get; set; } + //INIT信息 + public MediaSegment? MediaInit { get; set; } + //分片信息 + public List MediaParts { get; set; } = new List(); + } +} diff --git a/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs b/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs new file mode 100644 index 0000000..ffcbe99 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Entity/StreamSpec.cs @@ -0,0 +1,63 @@ +using N_m3u8DL_RE.Common.Enum; +using Spectre.Console; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Entity +{ + public class StreamSpec + { + public MediaType? MediaType { get; set; } + public string? GroupId { get; set; } + public string? Language { get; set; } + public string? Name { get; set; } + public Choise? Default { get; set; } + + //基本信息 + public int? Bandwidth { get; set; } + public string? Codecs { get; set; } + public string? Resolution { get; set; } + public double? FrameRate { get; set; } + public string? Channels { get; set; } + + + //外部轨道GroupId (后续寻找对应轨道信息) + public string? AudioId { get; set; } + public string? VideoId { get; set; } + public string? SubtitleId { get; set; } + + public string Url { get; set; } + + public Playlist Playlist { get; set; } + + public override string ToString() + { + var encStr = string.Empty; + + //增加加密标志 + if (Playlist != null && Playlist.MediaParts.Any(m => m.MediaSegments.Any(s => s.EncryptInfo.Method != EncryptMethod.NONE))) + { + encStr = "[red]*[/] "; + } + + 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(); + } + 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(); + } + 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(); + } + } + } +} diff --git a/src/N_m3u8DL-RE.Common/Enum/Choise.cs b/src/N_m3u8DL-RE.Common/Enum/Choise.cs new file mode 100644 index 0000000..edba103 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Enum/Choise.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Enum +{ + public enum Choise + { + YES = 1, + NO = 0 + } +} diff --git a/src/N_m3u8DL-RE.Common/Enum/EncryptMethod.cs b/src/N_m3u8DL-RE.Common/Enum/EncryptMethod.cs new file mode 100644 index 0000000..77bc442 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Enum/EncryptMethod.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Enum +{ + public enum EncryptMethod + { + NONE, + AES_128, + AES_128_ECB, + SAMPLE_AES, + UNKNOWN + } +} diff --git a/src/N_m3u8DL-RE.Common/Enum/MediaType.cs b/src/N_m3u8DL-RE.Common/Enum/MediaType.cs new file mode 100644 index 0000000..3857763 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Enum/MediaType.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Enum +{ + public enum MediaType + { + AUDIO = 0, + VIDEO = 1, + SUBTITLES = 2, + CLOSED_CAPTIONS = 3 + } +} diff --git a/src/N_m3u8DL-RE.Common/JsonConverter/BytesBase64Converter.cs b/src/N_m3u8DL-RE.Common/JsonConverter/BytesBase64Converter.cs new file mode 100644 index 0000000..f0ae02a --- /dev/null +++ b/src/N_m3u8DL-RE.Common/JsonConverter/BytesBase64Converter.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.JsonConverter +{ + internal class BytesBase64Converter : JsonConverter + { + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetBytesFromBase64(); + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) => writer.WriteStringValue(Convert.ToBase64String(value)); + } +} diff --git a/src/N_m3u8DL-RE.Common/N_m3u8DL-RE.Common.csproj b/src/N_m3u8DL-RE.Common/N_m3u8DL-RE.Common.csproj new file mode 100644 index 0000000..4c184bd --- /dev/null +++ b/src/N_m3u8DL-RE.Common/N_m3u8DL-RE.Common.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + N_m3u8DL_RE.Common + enable + enable + + + + + + + + + True + True + ResString.resx + + + + + + PublicResXFileCodeGenerator + + + PublicResXFileCodeGenerator + ResString.Designer.cs + + + PublicResXFileCodeGenerator + + + + diff --git a/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs b/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs new file mode 100644 index 0000000..3780251 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.Designer.cs @@ -0,0 +1,180 @@ +//------------------------------------------------------------------------------ +// +// 此代码由工具生成。 +// 运行时版本:4.0.30319.42000 +// +// 对此文件的更改可能会导致不正确的行为,并且如果 +// 重新生成代码,这些更改将会丢失。 +// +//------------------------------------------------------------------------------ + +namespace N_m3u8DL_RE.Common.Resource { + using System; + + + /// + /// 一个强类型的资源类,用于查找本地化的字符串等。 + /// + // 此类是由 StronglyTypedResourceBuilder + // 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。 + // 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen + // (以 /str 作为命令选项),或重新生成 VS 项目。 + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class ResString { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ResString() { + } + + /// + /// 返回此类使用的缓存的 ResourceManager 实例。 + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("N_m3u8DL_RE.Common.Resource.ResString", typeof(ResString).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// 重写当前线程的 CurrentUICulture 属性,对 + /// 使用此强类型资源类的所有资源查找执行重写。 + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// 查找类似 错误的m3u8 的本地化字符串。 + /// + public static string badM3u8 { + get { + return ResourceManager.GetString("badM3u8", resourceCulture); + } + } + + /// + /// 查找类似 获取: 的本地化字符串。 + /// + public static string fetch { + get { + return ResourceManager.GetString("fetch", resourceCulture); + } + } + + /// + /// 查找类似 检测到直播流 的本地化字符串。 + /// + public static string liveFound { + get { + return ResourceManager.GetString("liveFound", resourceCulture); + } + } + + /// + /// 查找类似 加载URL: 的本地化字符串。 + /// + public static string loadingUrl { + get { + return ResourceManager.GetString("loadingUrl", resourceCulture); + } + } + + /// + /// 查找类似 检测到Master列表,开始解析全部流信息 的本地化字符串。 + /// + public static string masterM3u8Found { + get { + return ResourceManager.GetString("masterM3u8Found", resourceCulture); + } + } + + /// + /// 查找类似 内容匹配: [white on deepskyblue1]HTTP Live Streaming[/] 的本地化字符串。 + /// + public static string matchHLS { + get { + return ResourceManager.GetString("matchHLS", resourceCulture); + } + } + + /// + /// 查找类似 当前输入不受支持: 的本地化字符串。 + /// + public static string notSupported { + get { + return ResourceManager.GetString("notSupported", resourceCulture); + } + } + + /// + /// 查找类似 [grey](按键盘上下键以浏览更多内容)[/] 的本地化字符串。 + /// + public static string promptChoiceText { + get { + return ResourceManager.GetString("promptChoiceText", resourceCulture); + } + } + + /// + /// 查找类似 (按 [blue]空格键[/] 选择流, [green]回车键[/] 完成选择) 的本地化字符串。 + /// + public static string promptInfo { + get { + return ResourceManager.GetString("promptInfo", resourceCulture); + } + } + + /// + /// 查找类似 请选择 [green]你要下载的内容[/]: 的本地化字符串。 + /// + public static string promptTitle { + get { + return ResourceManager.GetString("promptTitle", resourceCulture); + } + } + + /// + /// 查找类似 已选择的流: 的本地化字符串。 + /// + public static string selectedStream { + get { + return ResourceManager.GetString("selectedStream", resourceCulture); + } + } + + /// + /// 查找类似 已解析, 共计 {} 条媒体流, 基本流 {} 条, 可选音频流 {} 条, 可选字幕流 {} 条 的本地化字符串。 + /// + public static string streamsInfo { + get { + return ResourceManager.GetString("streamsInfo", resourceCulture); + } + } + + /// + /// 查找类似 写出meta.json 的本地化字符串。 + /// + public static string writeJson { + get { + return ResourceManager.GetString("writeJson", resourceCulture); + } + } + } +} diff --git a/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx b/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx new file mode 100644 index 0000000..6efdea9 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.en-US.resx @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Bad m3u8 + + + Input not supported: + + + Loading URL: + + + Content Matched: [white on deepskyblue1]HTTP Live Streaming[/] + + + Master List detected, try parse all streams + + + Fetch: + + + Please select [green]what you want to download[/]: + + + [grey](Move up and down to reveal more streams)[/] + + + (Press [blue]<space>[/] to toggle a stream, [green]<enter>[/] to accept) + + + Extracted, there are {} streams, with {} basic streams, {} audio streams, {} subtitle streams + + + Live stream found + + + Selected Streams: + + + Writing meta.json + + \ 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 new file mode 100644 index 0000000..34dd309 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.resx @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 错误的m3u8 + + + 当前输入不受支持: + + + 加载URL: + + + 内容匹配: [white on deepskyblue1]HTTP Live Streaming[/] + + + 检测到Master列表,开始解析全部流信息 + + + 获取: + + + 请选择 [green]你要下载的内容[/]: + + + [grey](按键盘上下键以浏览更多内容)[/] + + + (按 [blue]空格键[/] 选择流, [green]回车键[/] 完成选择) + + + 已解析, 共计 {} 条媒体流, 基本流 {} 条, 可选音频流 {} 条, 可选字幕流 {} 条 + + + 检测到直播流 + + + 已选择的流: + + + 写出meta.json + + \ 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 new file mode 100644 index 0000000..4f5c1de --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Resource/ResString.zh-TW.resx @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 錯誤的m3u8 + + + 當前輸入不受支持: + + + 加載URL: + + + 內容匹配: [white on deepskyblue1]HTTP Live Streaming[/] + + + 檢測到Master列表,開始解析全部流信息 + + + 獲取: + + + 請選擇 [green]你要下載的內容[/]: + + + [grey](按鍵盤上下鍵以瀏覽更多內容)[/] + + + (按 [blue]空格鍵[/] 選擇流, [green]回車鍵[/] 完成選擇) + + + 已解析, 共計 {} 條媒體流, 基本流 {} 條, 可選音頻流 {} 條, 可選字幕流 {} 條 + + + 檢測到直播流 + + + 已選擇的流: + + + 寫出meta.json + + \ No newline at end of file diff --git a/src/N_m3u8DL-RE.Common/Util/GlobalUtil.cs b/src/N_m3u8DL-RE.Common/Util/GlobalUtil.cs new file mode 100644 index 0000000..0d64542 --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Util/GlobalUtil.cs @@ -0,0 +1,26 @@ +using N_m3u8DL_RE.Common.JsonConverter; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Util +{ + public class GlobalUtil + { + public static string ConvertToJson(object o) + { + var options = new JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(), new BytesBase64Converter() } + }; + return JsonSerializer.Serialize(o, options); + } + } +} diff --git a/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs b/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs new file mode 100644 index 0000000..ddd6abf --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Util/HTTPUtil.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Cache; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using N_m3u8DL_RE.Common.Log; +using N_m3u8DL_RE.Common.Resource; + +namespace N_m3u8DL_RE.Common.Util +{ + public class HTTPUtil + { + + public static readonly HttpClient AppHttpClient = new(new HttpClientHandler + { + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.All, + ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true + }) + { + Timeout = TimeSpan.FromMinutes(2) + }; + + public static async Task DoGetAsync(string url, Dictionary? headers = null) + { + Logger.Debug(ResString.fetch + url); + using var webRequest = new HttpRequestMessage(HttpMethod.Get, url); + webRequest.Headers.TryAddWithoutValidation("Accept-Encoding", "gzip, deflate"); + webRequest.Headers.CacheControl = CacheControlHeaderValue.Parse("no-cache"); + webRequest.Headers.Connection.Clear(); + if (headers != null) + { + foreach (var item in headers) + { + webRequest.Headers.TryAddWithoutValidation(item.Key, item.Value); + } + } + Logger.Debug(webRequest.Headers.ToString()); + var webResponse = (await AppHttpClient.SendAsync(webRequest, HttpCompletionOption.ResponseHeadersRead)).EnsureSuccessStatusCode(); + return webResponse; + } + + public static async Task GetBytesAsync(string url, Dictionary? headers = null) + { + byte[] bytes = new byte[0]; + var webResponse = await DoGetAsync(url, headers); + bytes = await webResponse.Content.ReadAsByteArrayAsync(); + Logger.Debug(HexUtil.BytesToHex(bytes, " ")); + return bytes; + } + + public static async Task GetWebSourceAsync(string url, Dictionary? headers = null) + { + string htmlCode = string.Empty; + var webResponse = await DoGetAsync(url, headers); + htmlCode = await webResponse.Content.ReadAsStringAsync(); + Logger.Debug(htmlCode); + return htmlCode; + } + + public static async Task GetPostResponseAsync(string Url, byte[] postData) + { + string htmlCode = string.Empty; + using HttpRequestMessage request = new(HttpMethod.Post, Url); + request.Headers.TryAddWithoutValidation("Content-Type", "application/json"); + request.Headers.TryAddWithoutValidation("Content-Length", postData.Length.ToString()); + request.Content = new ByteArrayContent(postData); + var webResponse = await AppHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + htmlCode = await webResponse.Content.ReadAsStringAsync(); + return htmlCode; + } + } +} diff --git a/src/N_m3u8DL-RE.Common/Util/HexUtil.cs b/src/N_m3u8DL-RE.Common/Util/HexUtil.cs new file mode 100644 index 0000000..e5f0b0b --- /dev/null +++ b/src/N_m3u8DL-RE.Common/Util/HexUtil.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Common.Util +{ + public class HexUtil + { + public static string BytesToHex(byte[] data, string split = "") + { + return BitConverter.ToString(data).Replace("-", split); + } + + public static byte[] HexToBytes(string hex) + { + hex = hex.Trim(); + if (hex.StartsWith("0x") || hex.StartsWith("0X")) + hex = hex.Substring(2); + byte[] bytes = new byte[hex.Length / 2]; + + for (int i = 0; i < hex.Length; i += 2) + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + + return bytes; + } + } +} diff --git a/src/N_m3u8DL-RE.Parser/Constants/HLSTags.cs b/src/N_m3u8DL-RE.Parser/Constants/HLSTags.cs new file mode 100644 index 0000000..d27d3c8 --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/Constants/HLSTags.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Parser.Constants +{ + internal class HLSTags + { + public static string ext_m3u = "#EXTM3U"; + public static string ext_x_targetduration = "#EXT-X-TARGETDURATION"; + public static string ext_x_media_sequence = "#EXT-X-MEDIA-SEQUENCE"; + public static string ext_x_discontinuity_sequence = "#EXT-X-DISCONTINUITY-SEQUENCE"; + public static string ext_x_program_date_time = "#EXT-X-PROGRAM-DATE-TIME"; + public static string ext_x_media = "#EXT-X-MEDIA"; + public static string ext_x_playlist_type = "#EXT-X-PLAYLIST-TYPE"; + public static string ext_x_key = "#EXT-X-KEY"; + public static string ext_x_stream_inf = "#EXT-X-STREAM-INF"; + public static string ext_x_version = "#EXT-X-VERSION"; + public static string ext_x_allow_cache = "#EXT-X-ALLOW-CACHE"; + public static string ext_x_endlist = "#EXT-X-ENDLIST"; + public static string extinf = "#EXTINF"; + public static string ext_i_frames_only = "#EXT-X-I-FRAMES-ONLY"; + public static string ext_x_byterange = "#EXT-X-BYTERANGE"; + public static string ext_x_i_frame_stream_inf = "#EXT-X-I-FRAME-STREAM-INF"; + public static string ext_x_discontinuity = "#EXT-X-DISCONTINUITY"; + public static string ext_x_cue_out_start = "#EXT-X-CUE-OUT"; + public static string ext_x_cue_out = "#EXT-X-CUE-OUT-CONT"; + public static string ext_is_independent_segments = "#EXT-X-INDEPENDENT-SEGMENTS"; + public static string ext_x_scte35 = "#EXT-OATCLS-SCTE35"; + public static string ext_x_cue_start = "#EXT-X-CUE-OUT"; + public static string ext_x_cue_end = "#EXT-X-CUE-IN"; + public static string ext_x_cue_span = "#EXT-X-CUE-SPAN"; + public static string ext_x_map = "#EXT-X-MAP"; + public static string ext_x_start = "#EXT-X-START"; + } +} diff --git a/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs b/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs new file mode 100644 index 0000000..d74407f --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/Extractor/HLSExtractor.cs @@ -0,0 +1,534 @@ +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.Constants; +using N_m3u8DL_RE.Parser.Util; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Parser.Extractor +{ + internal class HLSExtractor : IExtractor + { + private string M3u8Url = string.Empty; + private string BaseUrl = string.Empty; + private string M3u8Content = string.Empty; + + public ParserConfig ParserConfig { get; set; } + + private HLSExtractor() { } + + public HLSExtractor(ParserConfig parserConfig) + { + this.ParserConfig = parserConfig; + this.M3u8Url = parserConfig.Url ?? string.Empty; + if (!string.IsNullOrEmpty(parserConfig.BaseUrl)) + this.BaseUrl = parserConfig.BaseUrl; + else + this.BaseUrl = this.M3u8Url; + } + + /// + /// 预处理m3u8内容 + /// + private void PreProcessContent() + { + M3u8Content = M3u8Content.Trim(); + if (!M3u8Content.StartsWith(HLSTags.ext_m3u)) + { + throw new Exception(ResString.badM3u8); + } + + //央视频回放 + if (M3u8Url.Contains("tlivecloud-playback-cdn.ysp.cctv.cn") && M3u8Url.Contains("endtime=")) + { + M3u8Content += Environment.NewLine + HLSTags.ext_x_endlist; + } + + //IMOOC + if (M3u8Url.Contains("imooc.com/")) + { + //M3u8Content = DecodeImooc.DecodeM3u8(M3u8Content); + } + + //iqy + if (M3u8Content.StartsWith("{\"payload\"")) + { + // + } + + //针对优酷#EXT-X-VERSION:7杜比视界片源修正 + if (M3u8Content.Contains("#EXT-X-DISCONTINUITY") && M3u8Content.Contains("#EXT-X-MAP") && M3u8Content.Contains("ott.cibntv.net") && M3u8Content.Contains("ccode=")) + { + Regex ykmap = new Regex("#EXT-X-DISCONTINUITY\\s+#EXT-X-MAP:URI=\\\"(.*?)\\\",BYTERANGE=\\\"(.*?)\\\""); + foreach (Match m in ykmap.Matches(M3u8Content)) + { + M3u8Content = M3u8Content.Replace(m.Value, $"#EXTINF:0.000000,\n#EXT-X-BYTERANGE:{m.Groups[2].Value}\n{m.Groups[1].Value}"); + } + } + + //针对Disney+修正 + if (M3u8Content.Contains("#EXT-X-DISCONTINUITY") && M3u8Content.Contains("#EXT-X-MAP") && M3u8Url.Contains("media.dssott.com/")) + { + Regex ykmap = new Regex("#EXT-X-MAP:URI=\\\".*?BUMPER/[\\s\\S]+?#EXT-X-DISCONTINUITY"); + if (ykmap.IsMatch(M3u8Content)) + { + M3u8Content = M3u8Content.Replace(ykmap.Match(M3u8Content).Value, "#XXX"); + } + } + + //针对AppleTv修正 + if (M3u8Content.Contains("#EXT-X-DISCONTINUITY") && M3u8Content.Contains("#EXT-X-MAP") && (M3u8Url.Contains(".apple.com/") || Regex.IsMatch(M3u8Content, "#EXT-X-MAP.*\\.apple\\.com/"))) + { + //只取加密部分即可 + Regex ykmap = new Regex("(#EXT-X-KEY:[\\s\\S]*?)(#EXT-X-DISCONTINUITY|#EXT-X-ENDLIST)"); + if (ykmap.IsMatch(M3u8Content)) + { + M3u8Content = "#EXTM3U\r\n" + ykmap.Match(M3u8Content).Groups[1].Value + "\r\n#EXT-X-ENDLIST"; + } + } + + //修复#EXT-X-KEY与#EXTINF出现次序异常问题 + if (Regex.IsMatch(M3u8Content, "(#EXTINF.*)(\\s+)(#EXT-X-KEY.*)")) + { + M3u8Content = Regex.Replace(M3u8Content, "(#EXTINF.*)(\\s+)(#EXT-X-KEY.*)", "$3$2$1"); + } + } + + /// + /// 预处理URL + /// + 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"))) + { + url += new Regex("\\?.*").Match(M3u8Url).Value; + } + return url; + } + + private bool IsMaster() + { + return M3u8Content.Contains(HLSTags.ext_x_stream_inf); + } + + private async Task> ParseMasterListAsync() + { + List streams = new List(); + + using StringReader sr = new StringReader(M3u8Content); + string line; + bool expectPlaylist = false; + StreamSpec streamSpec = new(); + + while ((line = sr.ReadLine()) != null) + { + if (string.IsNullOrEmpty(line)) + continue; + + if (line.StartsWith(HLSTags.ext_x_stream_inf)) + { + streamSpec = new(); + var bandwidth = string.IsNullOrEmpty(ParserUtil.GetAttribute(line, "BANDWIDTH")) ? ParserUtil.GetAttribute(line, "AVERAGE-BANDWIDTH") : ParserUtil.GetAttribute(line, "BANDWIDTH"); + streamSpec.Bandwidth = Convert.ToInt32(bandwidth); + streamSpec.Codecs = ParserUtil.GetAttribute(line, "CODECS"); + streamSpec.Resolution = ParserUtil.GetAttribute(line, "RESOLUTION"); + + var frameRate = ParserUtil.GetAttribute(line, "FRAME-RATE"); + if (!string.IsNullOrEmpty(frameRate)) + streamSpec.FrameRate = Convert.ToDouble(frameRate); + + var audioId = ParserUtil.GetAttribute(line, "AUDIO"); + if (!string.IsNullOrEmpty(audioId)) + streamSpec.AudioId = audioId; + + var videoId = ParserUtil.GetAttribute(line, "VIDEO"); + if (!string.IsNullOrEmpty(videoId)) + streamSpec.VideoId = videoId; + + var subtitleId = ParserUtil.GetAttribute(line, "SUBTITLES"); + if (!string.IsNullOrEmpty(subtitleId)) + streamSpec.SubtitleId = subtitleId; + + expectPlaylist = true; + } + else if (line.StartsWith(HLSTags.ext_x_media)) + { + streamSpec = new(); + var type = ParserUtil.GetAttribute(line, "TYPE").Replace("-", "_"); + if (Enum.TryParse(type, out var mediaType)) + { + streamSpec.MediaType = mediaType; + } + + //跳过CLOSED_CAPTIONS类型(目前不支持) + if (streamSpec.MediaType == MediaType.CLOSED_CAPTIONS) + { + continue; + } + + var url = ParserUtil.GetAttribute(line, "URI"); + + /** + * The URI attribute of the EXT-X-MEDIA tag is REQUIRED if the media + type is SUBTITLES, but OPTIONAL if the media type is VIDEO or AUDIO. + If the media type is VIDEO or AUDIO, a missing URI attribute + indicates that the media data for this Rendition is included in the + Media Playlist of any EXT-X-STREAM-INF tag referencing this EXT- + X-MEDIA tag. If the media TYPE is AUDIO and the URI attribute is + missing, clients MUST assume that the audio data for this Rendition + is present in every video Rendition specified by the EXT-X-STREAM-INF + tag. + + 此处直接忽略URI属性为空的情况 + */ + if (string.IsNullOrEmpty(url)) + { + continue; + } + + url = ParserUtil.CombineURL(BaseUrl, url); + streamSpec.Url = PreProcessUrl(url); + + var groupId = ParserUtil.GetAttribute(line, "GROUP-ID"); + streamSpec.GroupId = groupId; + + var lang = ParserUtil.GetAttribute(line, "LANGUAGE"); + if (!string.IsNullOrEmpty(lang)) + streamSpec.Language = lang; + + var name = ParserUtil.GetAttribute(line, "NAME"); + if (!string.IsNullOrEmpty(name)) + streamSpec.Name = name; + + var def = ParserUtil.GetAttribute(line, "DEFAULT"); + if (Enum.TryParse(type, out var defaultChoise)) + { + streamSpec.Default = defaultChoise; + } + + var channels = ParserUtil.GetAttribute(line, "CHANNELS"); + if (!string.IsNullOrEmpty(channels)) + streamSpec.Channels = channels; + + streams.Add(streamSpec); + } + else if (line.StartsWith("#")) + { + continue; + } + else if (expectPlaylist) + { + var url = ParserUtil.CombineURL(BaseUrl, line); + streamSpec.Url = PreProcessUrl(url); + expectPlaylist = false; + streams.Add(streamSpec); + } + } + + return streams; + } + + private async Task ParseListAsync() + { + //标记是否已清除优酷广告分片 + bool hasAd = false; + + using StringReader sr = new StringReader(M3u8Content); + string line; + bool expectSegment = false; + bool isEndlist = false; + int segIndex = 0; + bool isAd = false; + int startIndex; + + Playlist playlist = new(); + List mediaParts = new(); + + //当前的加密信息 + EncryptInfo currentEncryptInfo = new(); + //上次读取到的加密行,#EXT-X-KEY:…… + string lastKeyLine = ""; + + MediaPart mediaPart = new(); + MediaSegment segment = new(); + List segments = new(); + + + while ((line = sr.ReadLine()) != null) + { + if (string.IsNullOrEmpty(line)) + continue; + + //只下载部分字节 + if (line.StartsWith(HLSTags.ext_x_byterange)) + { + var p = ParserUtil.GetAttribute(line); + var (n, o) = ParserUtil.GetRange(p); + segment.ExpectLength = n; + segment.StartRange = o ?? segments.Last().StartRange + segments.Last().ExpectLength; + expectSegment = true; + } + //国家地理去广告 + else if (line.StartsWith("#UPLYNK-SEGMENT")) + { + if (line.Contains(",ad")) + isAd = true; + else if (line.Contains(",segment")) + isAd = false; + } + //国家地理去广告 + else if (isAd) + { + continue; + } + //解析定义的分段长度 + else if (line.StartsWith(HLSTags.ext_x_targetduration)) + { + segment.Duration = Convert.ToDouble(ParserUtil.GetAttribute(line)); + } + //解析起始编号 + else if (line.StartsWith(HLSTags.ext_x_media_sequence)) + { + segIndex = Convert.ToInt32(ParserUtil.GetAttribute(line)); + startIndex = segIndex; + } + //program date time + else if (line.StartsWith(HLSTags.ext_x_program_date_time)) + { + // + } + //解析不连续标记,需要单独合并(timestamp不同) + else if (line.StartsWith(HLSTags.ext_x_discontinuity)) + { + //修复优酷去除广告后的遗留问题 + if (hasAd && mediaParts.Count > 0) + { + segments = mediaParts[mediaParts.Count - 1].MediaSegments; + mediaParts.RemoveAt(mediaParts.Count - 1); + hasAd = false; + continue; + } + //常规情况的#EXT-X-DISCONTINUITY标记,新建part + if (!hasAd && segments.Count > 1) + { + mediaParts.Add(new MediaPart() + { + MediaSegments = segments, + }); + segments = new(); + } + } + //解析KEY + else if (line.StartsWith(HLSTags.ext_x_key)) + { + var iv = ParserUtil.GetAttribute(line, "IV"); + var method = ParserUtil.GetAttribute(line, "METHOD"); + var uri = ParserUtil.GetAttribute(line, "URI"); + var uri_last = ParserUtil.GetAttribute(lastKeyLine, "URI"); + + //自定义KEY情况 判断是否需要读取IV + if (line.Contains("IV=0x") && ParserConfig.CustomeKey != null && ParserConfig.CustomeIV == null) + { + currentEncryptInfo.Method = ParserConfig.CustomMethod; + currentEncryptInfo.Key = ParserConfig.CustomeKey; + currentEncryptInfo.IV = HexUtil.HexToBytes(iv); + } + //如果KEY URL相同,不进行重复解析 + if (uri != uri_last) + { + //解析key + currentEncryptInfo.Key = await ParseKeyAsync(uri); + //加密方式 + if (Enum.TryParse(method.Replace("-", "_"), out EncryptMethod m)) + { + currentEncryptInfo.Method = m; + } + else + { + currentEncryptInfo.Method = EncryptMethod.UNKNOWN; + } + //没有读取到IV,自己生成 + if (string.IsNullOrEmpty(iv)) + { + currentEncryptInfo.IV = HexUtil.HexToBytes(Convert.ToString(segIndex, 16).PadLeft(32, '0')); + } + else + { + currentEncryptInfo.IV = HexUtil.HexToBytes(iv); + } + } + lastKeyLine = line; + } + //解析分片时长 + else if (line.StartsWith(HLSTags.extinf)) + { + string[] tmp = ParserUtil.GetAttribute(line).Split(','); + segment.Duration = Convert.ToDouble(tmp[0]); + segment.Index = segIndex; + //是否有加密,有的话写入KEY和IV + if (currentEncryptInfo.Method != EncryptMethod.NONE) + { + segment.EncryptInfo.Method = currentEncryptInfo.Method; + segment.EncryptInfo.Key = currentEncryptInfo.Key; + segment.EncryptInfo.IV = currentEncryptInfo.IV; + } + expectSegment = true; + segIndex++; + } + //m3u8主体结束 + else if (line.StartsWith(HLSTags.ext_x_endlist)) + { + if (segments.Count > 0) + { + mediaParts.Add(new MediaPart() + { + MediaSegments = segments + }); + } + segments = new(); + isEndlist = true; + } + //#EXT-X-MAP + else if (line.StartsWith(HLSTags.ext_x_map)) + { + if (playlist.MediaInit == null) + { + playlist.MediaInit = new MediaSegment() + { + Url = PreProcessUrl(ParserUtil.CombineURL(BaseUrl, ParserUtil.GetAttribute(line, "URI"))), + }; + if (line.Contains("BYTERANGE")) + { + var p = ParserUtil.GetAttribute(line, "BYTERANGE"); + var (n, o) = ParserUtil.GetRange(p); + segment.ExpectLength = n; + segment.StartRange = o ?? 0L; + } + } + //遇到了其他的map,说明已经不是一个视频了,全部丢弃即可 + else + { + if (segments.Count > 0) + { + mediaParts.Add(new MediaPart() + { + MediaSegments = segments + }); + } + segments = new(); + isEndlist = true; + break; + } + } + //评论行不解析 + else if (line.StartsWith("#")) continue; + //空白行不解析 + else if (line.StartsWith("\r\n")) continue; + //解析分片的地址 + else if (expectSegment) + { + var segUrl = PreProcessUrl(ParserUtil.CombineURL(BaseUrl, line)); + segment.Url = segUrl; + segments.Add(segment); + segment = new(); + //优酷的广告分段则清除此分片 + //需要注意,遇到广告说明程序对上文的#EXT-X-DISCONTINUITY做出的动作是不必要的, + //其实上下文是同一种编码,需要恢复到原先的part上 + if (segUrl.Contains("ccode=") && segUrl.Contains("/ad/") && segUrl.Contains("duration=")) + { + segments.RemoveAt(segments.Count - 1); + segIndex--; + hasAd = true; + } + //优酷广告(4K分辨率测试) + if (segUrl.Contains("ccode=0902") && segUrl.Contains("duration=")) + { + segments.RemoveAt(segments.Count - 1); + segIndex--; + hasAd = true; + } + expectSegment = false; + } + } + + //直播的情况,无法遇到m3u8结束标记,需要手动将segments加入parts + if (!isEndlist) + { + mediaParts.Add(new MediaPart() + { + MediaSegments = segments + }); + } + + playlist.MediaParts = mediaParts; + playlist.IsLive = !isEndlist; + + return playlist; + } + + private async Task ParseKeyAsync(string uri) + { + var segUrl = PreProcessUrl(ParserUtil.CombineURL(BaseUrl, uri)); + var bytes = await HTTPUtil.GetBytesAsync(segUrl, ParserConfig.Headers); + return bytes; + } + + public async Task> ExtractStreamsAsync(string rawText) + { + this.M3u8Content = rawText; + this.PreProcessContent(); + if (IsMaster()) + { + 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 + { + return new List() + { + new StreamSpec() + { + Url = ParserConfig.Url, + Playlist = await ParseListAsync() + } + }; + } + } + + private async Task LoadM3u8FromUrlAsync(string url) + { + //Logger.Info(ResString.loadingUrl + url); + if (url.StartsWith("file:")) + { + var uri = new Uri(url); + this.M3u8Content = File.ReadAllText(uri.LocalPath); + } + else if (url.StartsWith("http")) + { + this.M3u8Content = await HTTPUtil.GetWebSourceAsync(url, ParserConfig.Headers); + } + this.M3u8Url = this.BaseUrl = url; + this.PreProcessContent(); + } + } +} diff --git a/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs b/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs new file mode 100644 index 0000000..50b84e6 --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/Extractor/IExtractor.cs @@ -0,0 +1,17 @@ +using N_m3u8DL_RE.Common.Config; +using N_m3u8DL_RE.Common.Entity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Parser.Extractor +{ + internal interface IExtractor + { + ParserConfig ParserConfig { get; set; } + + Task> ExtractStreamsAsync(string rawText); + } +} diff --git a/src/N_m3u8DL-RE.Parser/N_m3u8DL-RE.Parser.csproj b/src/N_m3u8DL-RE.Parser/N_m3u8DL-RE.Parser.csproj new file mode 100644 index 0000000..8846f53 --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/N_m3u8DL-RE.Parser.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + N_m3u8DL_RE.Parser + enable + enable + + + + + + + diff --git a/src/N_m3u8DL-RE.Parser/StreamExtractor.cs b/src/N_m3u8DL-RE.Parser/StreamExtractor.cs new file mode 100644 index 0000000..99a46f6 --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/StreamExtractor.cs @@ -0,0 +1,69 @@ +using N_m3u8DL_RE.Common.Config; +using N_m3u8DL_RE.Common.Entity; +using N_m3u8DL_RE.Common.Log; +using N_m3u8DL_RE.Common.Resource; +using N_m3u8DL_RE.Common.Util; +using N_m3u8DL_RE.Parser.Constants; +using N_m3u8DL_RE.Parser.Extractor; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Parser +{ + public class StreamExtractor + { + private IExtractor extractor; + private ParserConfig parserConfig = new ParserConfig(); + private string rawText; + + public StreamExtractor(ParserConfig parserConfig) + { + this.parserConfig = parserConfig; + } + + public void LoadSourceFromUrl(string url) + { + Logger.Info(ResString.loadingUrl + url); + if (url.StartsWith("file:")) + { + var uri = new Uri(url); + this.rawText = File.ReadAllText(uri.LocalPath); + parserConfig.Url = url; + } + else if (url.StartsWith("http")) + { + this.rawText = HTTPUtil.GetWebSourceAsync(url, parserConfig.Headers).Result; + parserConfig.Url = url; + } + this.rawText = rawText.Trim(); + LoadSourceFromText(this.rawText); + } + + public void LoadSourceFromText(string rawText) + { + rawText = rawText.Trim(); + this.rawText = rawText; + if (rawText.StartsWith(HLSTags.ext_m3u)) + { + Logger.InfoMarkUp(ResString.matchHLS); + extractor = new HLSExtractor(parserConfig); + } + else if (rawText.StartsWith("..")) + { + + } + else + { + throw new Exception(ResString.notSupported); + } + } + + public Task> ExtractStreamsAsync() + { + return extractor.ExtractStreamsAsync(rawText); + } + } +} diff --git a/src/N_m3u8DL-RE.Parser/Util/ParserUtil.cs b/src/N_m3u8DL-RE.Parser/Util/ParserUtil.cs new file mode 100644 index 0000000..80ccd91 --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/Util/ParserUtil.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Parser.Util +{ + internal class ParserUtil + { + /// + /// 从以下文本中获取参数 + /// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2149280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720" + /// + /// 等待被解析的一行文本 + /// 留空则获取第一个英文冒号后的全部字符 + /// + public static string GetAttribute(string line, string key = "") + { + line = line.Trim(); + if (key == "") + return line.Substring(line.IndexOf(':') + 1); + + if (line.Contains(key + "=\"")) + { + return Regex.Match(line, key + "=\"([^\"]*)\"").Groups[1].Value; + } + else if (line.Contains(key + "=")) + { + return Regex.Match(line, key + "=([^,]*)").Groups[1].Value; + } + + return string.Empty; + } + + /// + /// 从如下文本中提取 + /// [@] + /// + /// + /// n(length) o(start) + public static (long, long?) GetRange(string input) + { + var t = input.Split('@'); + if (t.Length > 0) + { + if (t.Length == 1) + { + return (Convert.ToInt64(t[0]), null); + } + if (t.Length == 2) + { + return (Convert.ToInt64(t[0]), Convert.ToInt64(t[1])); + } + } + return (0, null); + } + + /// + /// 拼接Baseurl和RelativeUrl + /// + /// Baseurl + /// RelativeUrl + /// + public static string CombineURL(string baseurl, string url) + { + if (string.IsNullOrEmpty(baseurl)) + return url; + + Uri uri1 = new Uri(baseurl); //这里直接传完整的URL即可 + Uri uri2 = new Uri(uri1, url); + url = uri2.ToString(); + + return url; + } + } +} diff --git a/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs b/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs new file mode 100644 index 0000000..ce4026b --- /dev/null +++ b/src/N_m3u8DL-RE.Parser/Util/PromptUtil.cs @@ -0,0 +1,67 @@ +using N_m3u8DL_RE.Common.Entity; +using N_m3u8DL_RE.Common.Enum; +using N_m3u8DL_RE.Common.Resource; +using Spectre.Console; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace N_m3u8DL_RE.Parser.Util +{ + public class PromptUtil + { + public static List SelectStreams(IEnumerable lists) + { + //基本流 + var basicStreams = lists.Where(x => x.MediaType == null); + //可选音频轨道 + var audios = lists.Where(x => x.MediaType == MediaType.AUDIO); + //可选字幕轨道 + var subs = lists.Where(x => x.MediaType == MediaType.SUBTITLES); + + var prompt = new MultiSelectionPrompt() + .Title(ResString.promptTitle) + .UseConverter(x => + { + if (x.Name != null && x.Name.StartsWith("__")) + return $"[darkslategray1]{x.Name.Substring(2)}[/]"; + else + return x.ToString().EscapeMarkup().RemoveMarkup(); + }) + .Required() + .PageSize(10) + .MoreChoicesText(ResString.promptChoiceText) + .InstructionsText(ResString.promptInfo) + .AddChoiceGroup(new StreamSpec() { Name = "__Basic" }, basicStreams); + + //默认选中第一个 + var first = lists.First(); + prompt.Select(first); + + if (audios.Any()) + { + prompt.AddChoiceGroup(new StreamSpec() { Name = "__Audio" }, audios); + //默认音轨 + if (first.AudioId != null) + { + prompt.Select(audios.First(a => a.GroupId == first.AudioId)); + } + } + if (subs.Any()) + { + prompt.AddChoiceGroup(new StreamSpec() { Name = "__Subtitle" }, subs); + //默认字幕轨 + if (first.SubtitleId != null) + { + prompt.Select(subs.First(s => s.GroupId == first.SubtitleId)); + } + } + //多选 + var selectedStreams = AnsiConsole.Prompt(prompt); + + return selectedStreams; + } + } +} diff --git a/src/N_m3u8DL-RE.sln b/src/N_m3u8DL-RE.sln new file mode 100644 index 0000000..085c7a3 --- /dev/null +++ b/src/N_m3u8DL-RE.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32505.426 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "N_m3u8DL-RE", "N_m3u8DL-RE\N_m3u8DL-RE.csproj", "{E6915BF9-8306-4F62-B357-23430F0D80B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "N_m3u8DL-RE.Common", "N_m3u8DL-RE.Common\N_m3u8DL-RE.Common.csproj", "{18D8BC30-512C-4A07-BD4C-E96DF5CF6341}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "N_m3u8DL-RE.Parser", "N_m3u8DL-RE.Parser\N_m3u8DL-RE.Parser.csproj", "{0DA02925-AF3A-4598-AF01-91AE5539FCA1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E6915BF9-8306-4F62-B357-23430F0D80B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6915BF9-8306-4F62-B357-23430F0D80B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6915BF9-8306-4F62-B357-23430F0D80B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6915BF9-8306-4F62-B357-23430F0D80B5}.Release|Any CPU.Build.0 = Release|Any CPU + {18D8BC30-512C-4A07-BD4C-E96DF5CF6341}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18D8BC30-512C-4A07-BD4C-E96DF5CF6341}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18D8BC30-512C-4A07-BD4C-E96DF5CF6341}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18D8BC30-512C-4A07-BD4C-E96DF5CF6341}.Release|Any CPU.Build.0 = Release|Any CPU + {0DA02925-AF3A-4598-AF01-91AE5539FCA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DA02925-AF3A-4598-AF01-91AE5539FCA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DA02925-AF3A-4598-AF01-91AE5539FCA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DA02925-AF3A-4598-AF01-91AE5539FCA1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {87F963D4-EA06-413D-9372-C726711C32B5} + EndGlobalSection +EndGlobal diff --git a/src/N_m3u8DL-RE/Directory.Build.props b/src/N_m3u8DL-RE/Directory.Build.props new file mode 100644 index 0000000..d50d822 --- /dev/null +++ b/src/N_m3u8DL-RE/Directory.Build.props @@ -0,0 +1,24 @@ + + + + Speed + true + true + Link + link + 7.0.0-* + + + + + + + + + + + + + + + diff --git a/src/N_m3u8DL-RE/N_m3u8DL-RE.csproj b/src/N_m3u8DL-RE/N_m3u8DL-RE.csproj new file mode 100644 index 0000000..19ccf75 --- /dev/null +++ b/src/N_m3u8DL-RE/N_m3u8DL-RE.csproj @@ -0,0 +1,19 @@ + + + + Exe + net6.0 + N_m3u8DL_RE + enable + enable + + + + + + + + + + + diff --git a/src/N_m3u8DL-RE/Program.cs b/src/N_m3u8DL-RE/Program.cs new file mode 100644 index 0000000..0ab34f8 --- /dev/null +++ b/src/N_m3u8DL-RE/Program.cs @@ -0,0 +1,102 @@ +using N_m3u8DL_RE.Common.Config; +using N_m3u8DL_RE.Common.Entity; +using N_m3u8DL_RE.Common.Enum; +using N_m3u8DL_RE.Common.Util; +using N_m3u8DL_RE.Parser; +using Spectre.Console; +using System.Text.Json; +using System.Text.Json.Serialization; +using N_m3u8DL_RE.Common.Resource; +using N_m3u8DL_RE.Common.Log; +using System.Globalization; +using System.Text; +using N_m3u8DL_RE.Parser.Util; + +namespace N_m3u8DL_RE +{ + internal class Program + { + + static async Task Main(string[] args) + { + string loc = "en-US"; + string currLoc = Thread.CurrentThread.CurrentUICulture.Name; + if (currLoc == "zh-TW" || currLoc == "zh-HK" || currLoc == "zh-MO") loc = "zh-TW"; + else if (currLoc == "zh-CN" || currLoc == "zh-SG") loc = "zh-CN"; + //设置语言 + CultureInfo.DefaultThreadCurrentCulture = new CultureInfo(loc); + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(loc); + + try + { + //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"; + + if (string.IsNullOrEmpty(url)) + { + url = AnsiConsole.Ask("Input [green]URL[/]: "); + } + + //流提取器配置 + var extractor = new StreamExtractor(config); + extractor.LoadSourceFromUrl(url); + + var streams = await extractor.ExtractStreamsAsync(); + //全部媒体 + var lists = streams.OrderByDescending(p => p.Bandwidth); + //基本流 + var basicStreams = lists.Where(x => x.MediaType == null); + //可选音频轨道 + var audios = lists.Where(x => x.MediaType == MediaType.AUDIO); + //可选字幕轨道 + var subs = lists.Where(x => x.MediaType == MediaType.SUBTITLES); + + Logger.Warn(ResString.writeJson); + await File.WriteAllTextAsync("meta.json", GlobalUtil.ConvertToJson(lists), Encoding.UTF8); + + Logger.Info(ResString.streamsInfo, lists.Count(), basicStreams.Count(), audios.Count(), subs.Count()); + + if (streams.Count > 1) + { + + 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()); + } + } + else if (streams.Count == 1) + { + var playlist = streams.First().Playlist; + if (playlist.IsLive) + { + Logger.Warn(ResString.liveFound); + } + //Print(playlist); + } + else + { + throw new Exception("解析失败"); + } + } + catch (Exception ex) + { + Logger.Error(ex.ToString()); + } + //Console.ReadKey(); + } + + static void Print(object o) + { + Console.WriteLine(GlobalUtil.ConvertToJson(o)); + } + } +} \ No newline at end of file diff --git a/src/N_m3u8DL-RE/rd.xml b/src/N_m3u8DL-RE/rd.xml new file mode 100644 index 0000000..398d5c5 --- /dev/null +++ b/src/N_m3u8DL-RE/rd.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file