+ if (_div!.SelectNodes("ns:p", nsMgr) == null || _div!.SelectNodes("ns:p", nsMgr)!.Count == 0)
+ {
+ foreach (XmlElement _tDiv in bodyNode.SelectNodes("ns:div", nsMgr)!)
+ {
+ var _p = xmlDoc.CreateDocumentFragment();
+ _p.InnerXml = _tDiv.OuterXml.Replace("
", "");
+ _div.AppendChild(_p);
+ }
+ }
+
+ // Parse
label
+ foreach (XmlElement _p in _div!.SelectNodes("ns:p", nsMgr)!)
+ {
+ var _begin = _p.GetAttribute("begin");
+ var _end = _p.GetAttribute("end");
+ var _region = _p.GetAttribute("region");
+ var _bgImg = _p.GetAttribute("smpte:backgroundImage");
+ // Handle namespace
+ foreach (XmlAttribute attr in _p.Attributes)
+ {
+ if (attr.LocalName == "begin") _begin = attr.Value;
+ else if (attr.LocalName == "end") _end = attr.Value;
+ else if (attr.LocalName == "region") _region = attr.Value;
+ }
+ var sub = new SubEntity
+ {
+ Begin = _begin,
+ End = _end,
+ Region = _region
+ };
+
+ if (string.IsNullOrEmpty(_bgImg))
+ {
+ var _spans = _p.ChildNodes;
+ // Collect
+ foreach (XmlNode _node in _spans)
+ {
+ if (_node.NodeType == XmlNodeType.Element)
+ {
+ var _span = (XmlElement)_node;
+ if (string.IsNullOrEmpty(_span.InnerText))
+ continue;
+ sub.Contents.Add(_span);
+ sub.ContentStrings.Add(_span.OuterXml);
+ }
+ else if (_node.NodeType == XmlNodeType.Text)
+ {
+ var _span = new XmlDocument().CreateElement("span");
+ _span.InnerText = _node.Value!;
+ sub.Contents.Add(_span);
+ sub.ContentStrings.Add(_span.OuterXml);
+ }
+ }
+ }
+ else
+ {
+ var id = _bgImg.Replace("#", "");
+ if (imageDic.TryGetValue(id, out var value))
+ {
+ var _span = new XmlDocument().CreateElement("span");
+ _span.InnerText = $"Base64::{value}";
+ sub.Contents.Add(_span);
+ sub.ContentStrings.Add(_span.OuterXml);
+ }
+ }
+
+ // Check if one has been splitted
+ var index = finalSubs.FindLastIndex(s => s.End == _begin && s.Region == _region && s.ContentStrings.SequenceEqual(sub.ContentStrings));
+ // Skip empty lines
+ if (sub.ContentStrings.Count <= 0)
+ continue;
+ // Extend
duration
+ if (index != -1)
+ finalSubs[index].End = sub.End;
+ else if (!finalSubs.Contains(sub))
+ finalSubs.Add(sub);
+ }
+ }
+
+
+ var dic = new Dictionary();
+ foreach (var sub in finalSubs)
+ {
+ var key = $"{sub.Begin} --> {sub.End}";
+ foreach (var item in sub.Contents)
+ {
+ if (dic.ContainsKey(key))
+ {
+ if (item.GetAttribute("tts:fontStyle") == "italic" || item.GetAttribute("tts:fontStyle") == "oblique")
+ dic[key] = $"{dic[key]}\r\n{GetTextFromElement(item)}";
+ else
+ dic[key] = $"{dic[key]}\r\n{GetTextFromElement(item)}";
+ }
+ else
+ {
+ if (item.GetAttribute("tts:fontStyle") == "italic" || item.GetAttribute("tts:fontStyle") == "oblique")
+ dic.Add(key, $"{GetTextFromElement(item)}");
+ else
+ dic.Add(key, GetTextFromElement(item));
+ }
+ }
+ }
+
+
+ var vtt = new StringBuilder();
+ vtt.AppendLine("WEBVTT");
+ foreach (var item in dic)
+ {
+ vtt.AppendLine(item.Key);
+ vtt.AppendLine(item.Value);
+ vtt.AppendLine();
+ }
+
+ return WebVttSub.Parse(vtt.ToString(), baseTimestamp);
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Mp4/MP4VttUtil.cs b/src/N_m3u8DL-RE.Parser/Mp4/MP4VttUtil.cs
new file mode 100644
index 0000000..b75e45a
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Mp4/MP4VttUtil.cs
@@ -0,0 +1,213 @@
+using N_m3u8DL_RE.Common.Entity;
+using System.Text;
+
+namespace Mp4SubtitleParser;
+
+public static class MP4VttUtil
+{
+ public static (bool, uint) CheckInit(byte[] data)
+ {
+ uint timescale = 0;
+ bool sawWVTT = false;
+
+ // parse init
+ new MP4Parser()
+ .Box("moov", MP4Parser.Children)
+ .Box("trak", MP4Parser.Children)
+ .Box("mdia", MP4Parser.Children)
+ .FullBox("mdhd", box =>
+ {
+ if (box.Version is not (0 or 1))
+ throw new Exception("MDHD version can only be 0 or 1");
+ timescale = MP4Parser.ParseMDHD(box.Reader, box.Version);
+ })
+ .Box("minf", MP4Parser.Children)
+ .Box("stbl", MP4Parser.Children)
+ .FullBox("stsd", MP4Parser.SampleDescription)
+ .Box("wvtt", _ => {
+ // A valid vtt init segment, though we have no actual subtitles yet.
+ sawWVTT = true;
+ })
+ .Parse(data);
+
+ return (sawWVTT, timescale);
+ }
+
+ public static WebVttSub ExtractSub(IEnumerable files, uint timescale)
+ {
+ if (timescale == 0)
+ throw new Exception("Missing timescale for VTT content!");
+
+ List cues = [];
+
+ foreach (var item in files)
+ {
+ var dataSeg = File.ReadAllBytes(item);
+
+ bool sawTFDT = false;
+ bool sawTRUN = false;
+ bool sawMDAT = false;
+ byte[]? rawPayload = null;
+ ulong baseTime = 0;
+ ulong defaultDuration = 0;
+ List presentations = [];
+
+
+ // parse media
+ new MP4Parser()
+ .Box("moof", MP4Parser.Children)
+ .Box("traf", MP4Parser.Children)
+ .FullBox("tfdt", box =>
+ {
+ sawTFDT = true;
+ if (box.Version is not (0 or 1))
+ throw new Exception("TFDT version can only be 0 or 1");
+ baseTime = MP4Parser.ParseTFDT(box.Reader, box.Version);
+ })
+ .FullBox("tfhd", box =>
+ {
+ if (box.Flags == 1000)
+ throw new Exception("A TFHD box should have a valid flags value");
+ defaultDuration = MP4Parser.ParseTFHD(box.Reader, box.Flags).DefaultSampleDuration;
+ })
+ .FullBox("trun", box =>
+ {
+ sawTRUN = true;
+ if (box.Version == 1000)
+ throw new Exception("A TRUN box should have a valid version value");
+ if (box.Flags == 1000)
+ throw new Exception("A TRUN box should have a valid flags value");
+ presentations = MP4Parser.ParseTRUN(box.Reader, box.Version, box.Flags).SampleData;
+ })
+ .Box("mdat", MP4Parser.AllData(data =>
+ {
+ if (sawMDAT)
+ throw new Exception("VTT cues in mp4 with multiple MDAT are not currently supported");
+ sawMDAT = true;
+ rawPayload = data;
+ }))
+ .Parse(dataSeg,/* partialOkay= */ false);
+
+ if (!sawMDAT && !sawTFDT && !sawTRUN)
+ {
+ throw new Exception("A required box is missing");
+ }
+
+ var currentTime = baseTime;
+ var reader = new BinaryReader2(new MemoryStream(rawPayload!));
+
+ foreach (var presentation in presentations)
+ {
+ var duration = presentation.SampleDuration == 0 ? defaultDuration : presentation.SampleDuration;
+ var startTime = presentation.SampleCompositionTimeOffset != 0 ?
+ baseTime + presentation.SampleCompositionTimeOffset :
+ currentTime;
+ currentTime = startTime + duration;
+ var totalSize = 0;
+ do
+ {
+ // Read the payload size.
+ var payloadSize = (int)reader.ReadUInt32();
+ totalSize += payloadSize;
+
+ // Skip the type.
+ var payloadType = reader.ReadUInt32();
+ var payloadName = MP4Parser.TypeToString(payloadType);
+
+ // Read the data payload.
+ byte[]? payload = null;
+ if (payloadName == "vttc")
+ {
+ if (payloadSize > 8)
+ {
+ payload = reader.ReadBytes(payloadSize - 8);
+ }
+ }
+ else if (payloadName == "vtte")
+ {
+ // It's a vtte, which is a vtt cue that is empty. Ignore any data that
+ // does exist.
+ reader.ReadBytes(payloadSize - 8);
+ }
+ else
+ {
+ Console.WriteLine($"Unknown box {payloadName}! Skipping!");
+ reader.ReadBytes(payloadSize - 8);
+ }
+
+ if (duration != 0)
+ {
+ if (payload != null)
+ {
+ var cue = ParseVTTC(
+ payload,
+ 0 + (double)startTime / timescale,
+ 0 + (double)currentTime / timescale);
+ // Check if same subtitle has been splitted
+ if (cue != null)
+ {
+ var index = cues.FindLastIndex(s => s.EndTime == cue.StartTime && s.Settings == cue.Settings && s.Payload == cue.Payload);
+ if (index != -1)
+ {
+ cues[index].EndTime = cue.EndTime;
+ }
+ else
+ {
+ cues.Add(cue);
+ }
+ }
+ }
+ }
+ else
+ {
+ throw new Exception("WVTT sample duration unknown, and no default found!");
+ }
+
+ if (!(presentation.SampleSize == 0 || totalSize <= presentation.SampleSize))
+ {
+ throw new Exception("The samples do not fit evenly into the sample sizes given in the TRUN box!");
+ }
+
+ } while (presentation.SampleSize != 0 && (totalSize < presentation.SampleSize));
+
+ if (reader.HasMoreData())
+ {
+ // throw new Exception("MDAT which contain VTT cues and non-VTT data are not currently supported!");
+ }
+ }
+ }
+
+ if (cues.Count > 0)
+ {
+ return new WebVttSub() { Cues = cues };
+ }
+ return new WebVttSub();
+ }
+
+ private static SubCue? ParseVTTC(byte[] data, double startTime, double endTime)
+ {
+ string payload = string.Empty;
+ string id = string.Empty;
+ string settings = string.Empty;
+ new MP4Parser()
+ .Box("payl", MP4Parser.AllData(data =>
+ {
+ payload = Encoding.UTF8.GetString(data);
+ }))
+ .Box("iden", MP4Parser.AllData(data =>
+ {
+ id = Encoding.UTF8.GetString(data);
+ }))
+ .Box("sttg", MP4Parser.AllData(data =>
+ {
+ settings = Encoding.UTF8.GetString(data);
+ }))
+ .Parse(data);
+
+ if (!string.IsNullOrEmpty(payload))
+ {
+ return new SubCue() { StartTime = TimeSpan.FromSeconds(startTime), EndTime = TimeSpan.FromSeconds(endTime), Payload = payload, Settings = settings };
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Mp4/MSSMoovProcessor.cs b/src/N_m3u8DL-RE.Parser/Mp4/MSSMoovProcessor.cs
new file mode 100644
index 0000000..7e16eb8
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Mp4/MSSMoovProcessor.cs
@@ -0,0 +1,869 @@
+using Mp4SubtitleParser;
+using N_m3u8DL_RE.Common.Entity;
+using N_m3u8DL_RE.Common.Util;
+using System.Text;
+using System.Text.RegularExpressions;
+
+// https://github.com/canalplus/rx-player/blob/48d1f845064cea5c5a3546d2c53b1855c2be149d/src/parsers/manifest/smooth/get_codecs.ts
+// https://github.dev/Dash-Industry-Forum/dash.js/blob/2aad3e79079b4de0bcd961ce6b4957103d98a621/src/mss/MssFragmentMoovProcessor.js
+// https://github.com/yt-dlp/yt-dlp/blob/3639df54c3298e35b5ae2a96a25bc4d3c38950d0/yt_dlp/downloader/ism.py
+// https://github.com/google/ExoPlayer/blob/a9444c880230d2c2c79097e89259ce0b9f80b87d/library/extractor/src/main/java/com/google/android/exoplayer2/video/HevcConfig.java#L38
+// https://github.com/sannies/mp4parser/blob/master/isoparser/src/main/java/org/mp4parser/boxes/iso14496/part15/HevcDecoderConfigurationRecord.java
+namespace N_m3u8DL_RE.Parser.Mp4;
+
+public partial class MSSMoovProcessor
+{
+ [GeneratedRegex(@"\(.*?)\<")]
+ private static partial Regex KIDRegex();
+
+ private static string StartCode = "00000001";
+ private StreamSpec StreamSpec;
+ private int TrackId = 2;
+ private string FourCC;
+ private string CodecPrivateData;
+ private int Timesacle;
+ private long Duration;
+ private string Language => StreamSpec.Language ?? "und";
+ private int Width => int.Parse((StreamSpec.Resolution ?? "0x0").Split('x').First());
+ private int Height => int.Parse((StreamSpec.Resolution ?? "0x0").Split('x').Last());
+ private string StreamType;
+ private int Channels;
+ private int BitsPerSample;
+ private int SamplingRate;
+ private int NalUnitLengthField;
+ private long CreationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ private bool IsProtection;
+ private string ProtectionSystemId;
+ private string ProtectionData;
+ private string? ProtecitonKID;
+ private string? ProtecitonKID_PR;
+ private byte[] UnityMatrix
+ {
+ get
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+ writer.WriteInt(0x10000);
+ writer.WriteInt(0);
+ writer.WriteInt(0);
+ writer.WriteInt(0);
+ writer.WriteInt(0x10000);
+ writer.WriteInt(0);
+ writer.WriteInt(0);
+ writer.WriteInt(0);
+ writer.WriteInt(0x40000000);
+ return stream.ToArray();
+ }
+ }
+ private static byte TRACK_ENABLED = 0x1;
+ private static byte TRACK_IN_MOVIE = 0x2;
+ private static byte TRACK_IN_PREVIEW = 0x4;
+ private static byte SELF_CONTAINED = 0x1;
+
+ private static List SupportedFourCC =
+ ["HVC1", "HEV1", "AACL", "AACH", "EC-3", "H264", "AVC1", "DAVC", "AVC1", "TTML", "DVHE", "DVH1"];
+
+ public MSSMoovProcessor(StreamSpec streamSpec)
+ {
+ this.StreamSpec = streamSpec;
+ var data = streamSpec.MSSData!;
+ this.NalUnitLengthField = data.NalUnitLengthField;
+ this.CodecPrivateData = data.CodecPrivateData;
+ this.FourCC = data.FourCC;
+ this.Timesacle = data.Timesacle;
+ this.Duration = data.Duration;
+ this.StreamType = data.Type;
+ this.Channels = data.Channels;
+ this.SamplingRate = data.SamplingRate;
+ this.BitsPerSample = data.BitsPerSample;
+ this.IsProtection = data.IsProtection;
+ this.ProtectionData = data.ProtectionData;
+ this.ProtectionSystemId = data.ProtectionSystemID;
+
+ // 需要手动生成CodecPrivateData
+ if (string.IsNullOrEmpty(CodecPrivateData))
+ {
+ GenCodecPrivateDataForAAC();
+ }
+
+ // 解析KID
+ if (IsProtection)
+ {
+ ExtractKID();
+ }
+ }
+
+ private static string[] HEVC_GENERAL_PROFILE_SPACE_STRINGS = ["", "A", "B", "C"];
+ private int SamplingFrequencyIndex(int samplingRate) => samplingRate switch
+ {
+ 96000 => 0x0,
+ 88200 => 0x1,
+ 64000 => 0x2,
+ 48000 => 0x3,
+ 44100 => 0x4,
+ 32000 => 0x5,
+ 24000 => 0x6,
+ 22050 => 0x7,
+ 16000 => 0x8,
+ 12000 => 0x9,
+ 11025 => 0xA,
+ 8000 => 0xB,
+ 7350 => 0xC,
+ _ => 0x0
+ };
+
+ private void GenCodecPrivateDataForAAC()
+ {
+ var objectType = 0x02; // AAC Main Low Complexity => object Type = 2
+ var indexFreq = SamplingFrequencyIndex(SamplingRate);
+
+ if (FourCC == "AACH")
+ {
+ // 4 bytes : XXXXX XXXX XXXX XXXX XXXXX XXX XXXXXXX
+ // ' ObjectType' 'Freq Index' 'Channels value' 'Extens Sampl Freq' 'ObjectType' 'GAS' 'alignment = 0'
+ objectType = 0x05; // High Efficiency AAC Profile = object Type = 5 SBR
+ var codecPrivateData = new byte[4];
+ var extensionSamplingFrequencyIndex = SamplingFrequencyIndex(SamplingRate * 2); // in HE AAC Extension Sampling frequence
+ // equals to SamplingRate*2
+ // Freq Index is present for 3 bits in the first byte, last bit is in the second
+ codecPrivateData[0] = (byte)((objectType << 3) | (indexFreq >> 1));
+ codecPrivateData[1] = (byte)((indexFreq << 7) | (Channels << 3) | (extensionSamplingFrequencyIndex >> 1));
+ codecPrivateData[2] = (byte)((extensionSamplingFrequencyIndex << 7) | (0x02 << 2)); // origin object type equals to 2 => AAC Main Low Complexity
+ codecPrivateData[3] = 0x0; // alignment bits
+
+ var arr16 = new ushort[2];
+ arr16[0] = (ushort)((codecPrivateData[0] << 8) + codecPrivateData[1]);
+ arr16[1] = (ushort)((codecPrivateData[2] << 8) + codecPrivateData[3]);
+
+ // convert decimal to hex value
+ this.CodecPrivateData = HexUtil.BytesToHex(BitConverter.GetBytes(arr16[0])).PadLeft(16, '0');
+ this.CodecPrivateData += HexUtil.BytesToHex(BitConverter.GetBytes(arr16[1])).PadLeft(16, '0');
+ }
+ else if (FourCC.StartsWith("AAC"))
+ {
+ // 2 bytes : XXXXX XXXX XXXX XXX
+ // ' ObjectType' 'Freq Index' 'Channels value' 'GAS = 000'
+ var codecPrivateData = new byte[2];
+ // Freq Index is present for 3 bits in the first byte, last bit is in the second
+ codecPrivateData[0] = (byte)((objectType << 3) | (indexFreq >> 1));
+ codecPrivateData[1] = (byte)((indexFreq << 7) | Channels << 3);
+ // put the 2 bytes in an 16 bits array
+ var arr16 = new ushort[1];
+ arr16[0] = (ushort)((codecPrivateData[0] << 8) + codecPrivateData[1]);
+
+ // convert decimal to hex value
+ this.CodecPrivateData = HexUtil.BytesToHex(BitConverter.GetBytes(arr16[0])).PadLeft(16, '0');
+ }
+ }
+
+ private void ExtractKID()
+ {
+ // playready
+ if (ProtectionSystemId.ToUpper() == "9A04F079-9840-4286-AB92-E65BE0885F95")
+ {
+ var bytes = HexUtil.HexToBytes(ProtectionData.Replace("00", ""));
+ var text = Encoding.ASCII.GetString(bytes);
+ var kidBytes = Convert.FromBase64String(KIDRegex().Match(text).Groups[1].Value);
+ // save kid for playready
+ this.ProtecitonKID_PR = HexUtil.BytesToHex(kidBytes);
+ // fix byte order
+ var reverse1 = new[] { kidBytes[3], kidBytes[2], kidBytes[1], kidBytes[0] };
+ var reverse2 = new[] { kidBytes[5], kidBytes[4], kidBytes[7], kidBytes[6] };
+ Array.Copy(reverse1, 0, kidBytes, 0, reverse1.Length);
+ Array.Copy(reverse2, 0, kidBytes, 4, reverse1.Length);
+ this.ProtecitonKID = HexUtil.BytesToHex(kidBytes);
+ }
+ // widevine
+ else if (ProtectionSystemId.ToUpper() == "EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED")
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ public static bool CanHandle(string fourCC) => SupportedFourCC.Contains(fourCC);
+
+ private byte[] Box(string boxType, byte[] payload)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteUInt(8 + (uint)payload.Length);
+ writer.Write(boxType);
+ writer.Write(payload);
+
+ return stream.ToArray();
+ }
+
+ private byte[] FullBox(string boxType, byte version, uint flags, byte[] payload)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.Write(version);
+ writer.WriteUInt(flags, offset: 1);
+ writer.Write(payload);
+
+ return Box(boxType, stream.ToArray());
+ }
+
+ private byte[] GenSinf(string codec)
+ {
+ var frmaBox = Box("frma", Encoding.ASCII.GetBytes(codec));
+
+ var sinfPayload = new List();
+ sinfPayload.AddRange(frmaBox);
+
+ var schmPayload = new List();
+ schmPayload.AddRange(Encoding.ASCII.GetBytes("cenc")); // scheme_type 'cenc' => common encryption
+ schmPayload.AddRange([0, 1, 0, 0]); // scheme_version Major version 1, Minor version 0
+ var schmBox = FullBox("schm", 0, 0, schmPayload.ToArray());
+
+ sinfPayload.AddRange(schmBox);
+
+ var tencPayload = new List();
+ tencPayload.AddRange([0, 0]);
+ tencPayload.Add(0x1); // default_IsProtected
+ tencPayload.Add(0x8); // default_Per_Sample_IV_size
+ tencPayload.AddRange(HexUtil.HexToBytes(ProtecitonKID)); // default_KID
+ // tencPayload.Add(0x8);// default_constant_IV_size
+ // tencPayload.AddRange(new byte[8]);// default_constant_IV
+ var tencBox = FullBox("tenc", 0, 0, tencPayload.ToArray());
+
+ var schiBox = Box("schi", tencBox);
+ sinfPayload.AddRange(schiBox);
+
+ var sinfBox = Box("sinf", sinfPayload.ToArray());
+
+ return sinfBox;
+ }
+
+ private byte[] GenFtyp()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.Write("isml"); // major brand
+ writer.WriteUInt(1); // minor version
+ writer.Write("iso5"); // compatible brand
+ writer.Write("iso6"); // compatible brand
+ writer.Write("piff"); // compatible brand
+ writer.Write("msdh"); // compatible brand
+
+ return Box("ftyp", stream.ToArray());
+ }
+
+ private byte[] GenMvhd()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteULong(CreationTime); // creation_time
+ writer.WriteULong(CreationTime); // modification_time
+ writer.WriteUInt(Timesacle); // timescale
+ writer.WriteULong(Duration); // duration
+ writer.WriteUShort(1, padding: 2); // rate
+ writer.WriteByte(1, padding: 1); // volume
+ writer.WriteUShort(0); // reserved
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+
+ writer.Write(UnityMatrix);
+
+ writer.WriteUInt(0); // pre defined
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+
+ writer.WriteUInt(0xffffffff); // next track id
+
+
+ return FullBox("mvhd", 1, 0, stream.ToArray());
+ }
+
+ private byte[] GenTkhd()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteULong(CreationTime); // creation_time
+ writer.WriteULong(CreationTime); // modification_time
+ writer.WriteUInt(TrackId); // track id
+ writer.WriteUInt(0); // reserved
+ writer.WriteULong(Duration); // duration
+ writer.WriteUInt(0); // reserved
+ writer.WriteUInt(0);
+ writer.WriteShort(0); // layer
+ writer.WriteShort(0); // alternate group
+ writer.WriteByte(StreamType == "audio" ? (byte)1 : (byte)0, padding: 1); // volume
+ writer.WriteUShort(0); // reserved
+
+ writer.Write(UnityMatrix);
+
+ writer.WriteUShort(Width, padding: 2); // width
+ writer.WriteUShort(Height, padding: 2); // height
+
+ return FullBox("tkhd", 1, (uint)TRACK_ENABLED | TRACK_IN_MOVIE | TRACK_IN_PREVIEW, stream.ToArray());
+ }
+
+
+ private byte[] GenMdhd()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteULong(CreationTime); // creation_time
+ writer.WriteULong(CreationTime); // modification_time
+ writer.WriteUInt(Timesacle); // timescale
+ writer.WriteULong(Duration); // duration
+ writer.WriteUShort((Language[0] - 0x60) << 10 | (Language[1] - 0x60) << 5 | (Language[2] - 0x60)); // language
+ writer.WriteUShort(0); // pre defined
+
+ return FullBox("mdhd", 1, 0, stream.ToArray());
+ }
+
+ private byte[] GenHdlr()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteUInt(0); // pre defined
+ if (StreamType == "audio") writer.Write("soun");
+ else if (StreamType == "video") writer.Write("vide");
+ else if (StreamType == "text") writer.Write("subt");
+ else throw new NotSupportedException();
+
+ writer.WriteUInt(0); // reserved
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+ writer.Write($"{StreamSpec.GroupId ?? "RE Handler"}\0"); // name
+
+ return FullBox("hdlr", 0, 0, stream.ToArray());
+ }
+
+ private byte[] GenMinf()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ var minfPayload = new List();
+ if (StreamType == "audio")
+ {
+ var smhd = new List();
+ smhd.Add(0); smhd.Add(0); // balance
+ smhd.Add(0); smhd.Add(0); // reserved
+
+ minfPayload.AddRange(FullBox("smhd", 0, 0, smhd.ToArray())); // Sound Media Header
+ }
+ else if (StreamType == "video")
+ {
+ var vmhd = new List();
+ vmhd.Add(0); vmhd.Add(0); // graphics mode
+ vmhd.Add(0); vmhd.Add(0); vmhd.Add(0); vmhd.Add(0); vmhd.Add(0); vmhd.Add(0);// opcolor
+
+ minfPayload.AddRange(FullBox("vmhd", 0, 1, vmhd.ToArray())); // Video Media Header
+ }
+ else if (StreamType == "text")
+ {
+ minfPayload.AddRange(FullBox("sthd", 0, 0, [])); // Subtitle Media Header
+ }
+ else
+ {
+ throw new NotSupportedException();
+ }
+
+ var drefPayload = new List();
+ drefPayload.Add(0); drefPayload.Add(0); drefPayload.Add(0); drefPayload.Add(1); // entry count
+ drefPayload.AddRange(FullBox("url ", 0, SELF_CONTAINED, [])); // Data Entry URL Box
+
+ var dinfPayload = FullBox("dref", 0, 0, drefPayload.ToArray()); // Data Reference Box
+ minfPayload.AddRange(Box("dinf", dinfPayload.ToArray())); // Data Information Box
+
+ return minfPayload.ToArray();
+ }
+
+ private byte[] GenEsds(byte[] audioSpecificConfig)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ // ESDS length = esds box header length (= 12) +
+ // ES_Descriptor header length (= 5) +
+ // DecoderConfigDescriptor header length (= 15) +
+ // decoderSpecificInfo header length (= 2) +
+ // AudioSpecificConfig length (= codecPrivateData length)
+ // esdsLength = 34 + len(audioSpecificConfig)
+
+ // ES_Descriptor (see ISO/IEC 14496-1 (Systems))
+ writer.WriteByte(0x03); // tag = 0x03 (ES_DescrTag)
+ writer.WriteByte((byte)(20 + audioSpecificConfig.Length)); // size
+ writer.WriteByte((byte)((TrackId & 0xFF00) >> 8)); // ES_ID = track_id
+ writer.WriteByte((byte)(TrackId & 0x00FF));
+ writer.WriteByte(0); // flags and streamPriority
+
+ // DecoderConfigDescriptor (see ISO/IEC 14496-1 (Systems))
+ writer.WriteByte(0x04); // tag = 0x04 (DecoderConfigDescrTag)
+ writer.WriteByte((byte)(15 + audioSpecificConfig.Length)); // size
+ writer.WriteByte(0x40); // objectTypeIndication = 0x40 (MPEG-4 AAC)
+ writer.WriteByte((0x05 << 2) | (0 << 1) | 1); // reserved = 1
+ writer.WriteByte(0xFF); // buffersizeDB = undefined
+ writer.WriteByte(0xFF);
+ writer.WriteByte(0xFF);
+
+ var bandwidth = StreamSpec.Bandwidth!;
+ writer.WriteByte((byte)((bandwidth & 0xFF000000) >> 24)); // maxBitrate
+ writer.WriteByte((byte)((bandwidth & 0x00FF0000) >> 16));
+ writer.WriteByte((byte)((bandwidth & 0x0000FF00) >> 8));
+ writer.WriteByte((byte)(bandwidth & 0x000000FF));
+ writer.WriteByte((byte)((bandwidth & 0xFF000000) >> 24)); // avgbitrate
+ writer.WriteByte((byte)((bandwidth & 0x00FF0000) >> 16));
+ writer.WriteByte((byte)((bandwidth & 0x0000FF00) >> 8));
+ writer.WriteByte((byte)(bandwidth & 0x000000FF));
+
+ // DecoderSpecificInfo (see ISO/IEC 14496-1 (Systems))
+ writer.WriteByte(0x05); // tag = 0x05 (DecSpecificInfoTag)
+ writer.WriteByte((byte)audioSpecificConfig.Length); // size
+ writer.Write(audioSpecificConfig); // AudioSpecificConfig bytes
+
+ return FullBox("esds", 0, 0, stream.ToArray());
+ }
+
+ private byte[] GetSampleEntryBox()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteByte(0); // reserved
+ writer.WriteByte(0);
+ writer.WriteByte(0);
+ writer.WriteByte(0);
+ writer.WriteByte(0);
+ writer.WriteByte(0);
+ writer.WriteUShort(1); // data reference index
+
+ if (StreamType == "audio")
+ {
+ writer.WriteUInt(0); // reserved2
+ writer.WriteUInt(0);
+ writer.WriteUShort(Channels); // channels
+ writer.WriteUShort(BitsPerSample); // bits_per_sample
+ writer.WriteUShort(0); // pre defined
+ writer.WriteUShort(0); // reserved3
+ writer.WriteUShort(SamplingRate, padding: 2); // sampling_rate
+
+ var audioSpecificConfig = HexUtil.HexToBytes(CodecPrivateData);
+ var esdsBox = GenEsds(audioSpecificConfig);
+ writer.Write(esdsBox);
+
+ if (FourCC.StartsWith("AAC"))
+ {
+ if (IsProtection)
+ {
+ var sinfBox = GenSinf("mp4a");
+ writer.Write(sinfBox);
+ return Box("enca", stream.ToArray()); // Encrypted Audio
+ }
+ return Box("mp4a", stream.ToArray());
+ }
+ if (FourCC == "EC-3")
+ {
+ if (IsProtection)
+ {
+ var sinfBox = GenSinf("ec-3");
+ writer.Write(sinfBox);
+ return Box("enca", stream.ToArray()); // Encrypted Audio
+ }
+ return Box("ec-3", stream.ToArray());
+ }
+ }
+ else if (StreamType == "video")
+ {
+ writer.WriteUShort(0); // pre defined
+ writer.WriteUShort(0); // reserved
+ writer.WriteUInt(0); // pre defined
+ writer.WriteUInt(0);
+ writer.WriteUInt(0);
+ writer.WriteUShort(Width); // width
+ writer.WriteUShort(Height); // height
+ writer.WriteUShort(0x48, padding: 2); // horiz resolution 72 dpi
+ writer.WriteUShort(0x48, padding: 2); // vert resolution 72 dpi
+ writer.WriteUInt(0); // reserved
+ writer.WriteUShort(1); // frame count
+ for (int i = 0; i < 32; i++) // compressor name
+ {
+ writer.WriteByte(0);
+ }
+ writer.WriteUShort(0x18); // depth
+ writer.WriteUShort(65535); // pre defined
+
+ var codecPrivateData = HexUtil.HexToBytes(CodecPrivateData);
+
+ if (FourCC is "H264" or "AVC1" or "DAVC")
+ {
+ var arr = CodecPrivateData.Split([StartCode], StringSplitOptions.RemoveEmptyEntries);
+ var sps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] & 0x1F) == 7));
+ var pps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] & 0x1F) == 8));
+ // make avcC
+ var avcC = GetAvcC(sps, pps);
+ writer.Write(avcC);
+ if (IsProtection)
+ {
+ var sinfBox = GenSinf("avc1");
+ writer.Write(sinfBox);
+ return Box("encv", stream.ToArray()); // Encrypted Video
+ }
+ return Box("avc1", stream.ToArray()); // AVC Simple Entry
+ }
+ if (FourCC is "HVC1" or "HEV1")
+ {
+ var arr = CodecPrivateData.Split([StartCode], StringSplitOptions.RemoveEmptyEntries);
+ var vps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] >> 1) == 0x20));
+ var sps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] >> 1) == 0x21));
+ var pps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] >> 1) == 0x22));
+ // make hvcC
+ var hvcC = GetHvcC(sps, pps, vps);
+ writer.Write(hvcC);
+ if (IsProtection)
+ {
+ var sinfBox = GenSinf("hvc1");
+ writer.Write(sinfBox);
+ return Box("encv", stream.ToArray()); // Encrypted Video
+ }
+ return Box("hvc1", stream.ToArray()); // HEVC Simple Entry
+ }
+ // 杜比视界也按照hevc处理
+ if (FourCC is "DVHE" or "DVH1")
+ {
+ var arr = CodecPrivateData.Split([StartCode], StringSplitOptions.RemoveEmptyEntries);
+ var vps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] >> 1) == 0x20));
+ var sps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] >> 1) == 0x21));
+ var pps = HexUtil.HexToBytes(arr.First(x => (HexUtil.HexToBytes(x[0..2])[0] >> 1) == 0x22));
+ // make hvcC
+ var hvcC = GetHvcC(sps, pps, vps, "dvh1");
+ writer.Write(hvcC);
+ if (IsProtection)
+ {
+ var sinfBox = GenSinf("dvh1");
+ writer.Write(sinfBox);
+ return Box("encv", stream.ToArray()); // Encrypted Video
+ }
+ return Box("dvh1", stream.ToArray()); // HEVC Simple Entry
+ }
+
+ throw new NotSupportedException();
+ }
+ else if (StreamType == "text")
+ {
+ if (FourCC == "TTML")
+ {
+ writer.Write("http://www.w3.org/ns/ttml\0"); // namespace
+ writer.Write("\0"); // schema location
+ writer.Write("\0"); // auxilary mime types(??)
+ return Box("stpp", stream.ToArray()); // TTML Simple Entry
+ }
+ throw new NotSupportedException();
+ }
+ else
+ {
+ throw new NotSupportedException();
+ }
+
+ throw new NotSupportedException();
+ }
+
+ private byte[] GetAvcC(byte[] sps, byte[] pps)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteByte(1); // configuration version
+ writer.Write(sps[1..4]); // avc profile indication + profile compatibility + avc level indication
+ writer.WriteByte((byte)(0xfc | (NalUnitLengthField - 1))); // complete representation (1) + reserved (11111) + length size minus one
+ writer.WriteByte(1); // reserved (0) + number of sps (0000001)
+ writer.WriteUShort(sps.Length);
+ writer.Write(sps);
+ writer.WriteByte(1); // number of pps
+ writer.WriteUShort(pps.Length);
+ writer.Write(pps);
+
+ return Box("avcC", stream.ToArray()); // AVC Decoder Configuration Record
+ }
+
+ private byte[] GetHvcC(byte[] sps, byte[] pps, byte[] vps, string code = "hvc1")
+ {
+ var oriSps = new List(sps);
+ // https://www.itu.int/rec/dologin.asp?lang=f&id=T-REC-H.265-201504-S!!PDF-E&type=items
+ // Read generalProfileSpace, generalTierFlag, generalProfileIdc,
+ // generalProfileCompatibilityFlags, constraintBytes, generalLevelIdc
+ // from sps
+ var encList = new List();
+ /**
+ * 处理payload, 有00 00 03 0,1,2,3的情况 统一换成00 00 XX 即丢弃03
+ * 注意:此处采用的逻辑是直接简单粗暴地判断列表末尾3字节,如果是0x000003就删掉最后的0x03,可能会导致以下情况
+ * 00 00 03 03 03 03 03 01 会被直接处理成 => 00 00 01
+ * 此处经过测试只有直接跳过才正常,如果处理成 00 00 03 03 03 03 01 是有问题的
+ *
+ * 测试的数据如下:
+ * 原始:42 01 01 01 60 00 00 03 00 90 00 00 03 00 00 03 00 96 a0 01 e0 20 06 61 65 95 9a 49 30 bf fc 0c 7c 0c 81 a8 08 08 08 20 00 00 03 00 20 00 00 03 03 01
+ * 处理后:42 01 01 01 60 00 00 00 90 00 00 00 00 00 96 A0 01 E0 20 06 61 65 95 9A 49 30 BF FC 0C 7C 0C 81 A8 08 08 08 20 00 00 00 20 00 00 01
+ */
+ using (var _reader = new BinaryReader(new MemoryStream(sps)))
+ {
+ while (_reader.BaseStream.Position < _reader.BaseStream.Length)
+ {
+ encList.Add(_reader.ReadByte());
+ if (encList is [.., 0x00, 0x00, 0x03])
+ {
+ encList.RemoveAt(encList.Count - 1);
+ }
+ }
+ }
+ sps = encList.ToArray();
+
+ using var reader = new BinaryReader2(new MemoryStream(sps));
+ reader.ReadBytes(2); // Skip 2 bytes unit header
+ var firstByte = reader.ReadByte();
+ var maxSubLayersMinus1 = (firstByte & 0xe) >> 1;
+ var nextByte = reader.ReadByte();
+ var generalProfileSpace = (nextByte & 0xc0) >> 6;
+ var generalTierFlag = (nextByte & 0x20) >> 5;
+ var generalProfileIdc = nextByte & 0x1f;
+ var generalProfileCompatibilityFlags = reader.ReadUInt32();
+ var constraintBytes = reader.ReadBytes(6);
+ var generalLevelIdc = reader.ReadByte();
+
+ /*var skipBit = 0;
+ for (int i = 0; i < maxSubLayersMinus1; i++)
+ {
+ skipBit += 2; // sub_layer_profile_present_flag sub_layer_level_present_flag
+ }
+ if (maxSubLayersMinus1 > 0)
+ {
+ for (int i = maxSubLayersMinus1; i < 8; i++)
+ {
+ skipBit += 2; // reserved_zero_2bits
+ }
+ }
+ for (int i = 0; i < maxSubLayersMinus1; i++)
+ {
+ skipBit += 2; // sub_layer_profile_present_flag sub_layer_level_present_flag
+ }*/
+
+ // 生成编码信息
+ var codecs = code +
+ $".{HEVC_GENERAL_PROFILE_SPACE_STRINGS[generalProfileSpace]}{generalProfileIdc}" +
+ $".{Convert.ToString(generalProfileCompatibilityFlags, 16)}" +
+ $".{(generalTierFlag == 1 ? 'H' : 'L')}{generalLevelIdc}" +
+ $".{HexUtil.BytesToHex(constraintBytes.Where(b => b != 0).ToArray())}";
+ StreamSpec.Codecs = codecs;
+
+
+ ///////////////////////
+
+
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ // var reserved1 = 0xF;
+
+ writer.WriteByte(1); // configuration version
+ writer.WriteByte((byte)((generalProfileSpace << 6) + (generalTierFlag == 1 ? 0x20 : 0) | generalProfileIdc)); // general_profile_space + general_tier_flag + general_profile_idc
+ writer.WriteUInt(generalProfileCompatibilityFlags); // general_profile_compatibility_flags
+ writer.Write(constraintBytes); // general_constraint_indicator_flags
+ writer.WriteByte((byte)generalProfileIdc); // general_level_idc
+ writer.WriteUShort(0xf000); // reserved + min_spatial_segmentation_idc
+ writer.WriteByte(0xfc); // reserved + parallelismType
+ writer.WriteByte(0 | 0xfc); // reserved + chromaFormat
+ writer.WriteByte(0 | 0xf8); // reserved + bitDepthLumaMinus8
+ writer.WriteByte(0 | 0xf8); // reserved + bitDepthChromaMinus8
+ writer.WriteUShort(0); // avgFrameRate
+ writer.WriteByte((byte)(0 << 6 | 0 << 3 | 0 << 2 | (NalUnitLengthField - 1))); // constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
+ writer.WriteByte(0x03); // numOfArrays (vps sps pps)
+
+ sps = oriSps.ToArray();
+ writer.WriteByte(0x20); // array_completeness + reserved + NAL_unit_type
+ writer.WriteUShort(1); // numNalus
+ writer.WriteUShort(vps.Length);
+ writer.Write(vps);
+ writer.WriteByte(0x21);
+ writer.WriteUShort(1); // numNalus
+ writer.WriteUShort(sps.Length);
+ writer.Write(sps);
+ writer.WriteByte(0x22);
+ writer.WriteUShort(1); // numNalus
+ writer.WriteUShort(pps.Length);
+ writer.Write(pps);
+
+ return Box("hvcC", stream.ToArray()); // HEVC Decoder Configuration Record
+ }
+
+ private byte[] GetStsd()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteUInt(1); // entry count
+ var sampleEntryData = GetSampleEntryBox();
+ writer.Write(sampleEntryData);
+
+ return stream.ToArray();
+ }
+
+ private byte[] GetMehd()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteULong(Duration);
+
+ return FullBox("mehd", 1, 0, stream.ToArray()); // Movie Extends Header Box
+ }
+ private byte[] GetTrex()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ writer.WriteUInt(TrackId); // track id
+ writer.WriteUInt(1); // default sample description index
+ writer.WriteUInt(0); // default sample duration
+ writer.WriteUInt(0); // default sample size
+ writer.WriteUInt(0); // default sample flags
+
+ return FullBox("trex", 0, 0, stream.ToArray()); // Track Extends Box
+ }
+
+ private byte[] GenPsshBoxForPlayReady()
+ {
+ using var _stream = new MemoryStream();
+ using var _writer = new BinaryWriter2(_stream);
+ var sysIdData = HexUtil.HexToBytes(ProtectionSystemId.Replace("-", ""));
+ var psshData = HexUtil.HexToBytes(ProtectionData);
+
+ _writer.Write(sysIdData); // SystemID 16 bytes
+ _writer.WriteUInt(psshData.Length); // Size of Data 4 bytes
+ _writer.Write(psshData); // Data
+ var psshBox = FullBox("pssh", 0, 0, _stream.ToArray());
+ return psshBox;
+ }
+
+ private byte[] GenPsshBoxForWideVine()
+ {
+ using var _stream = new MemoryStream();
+ using var _writer = new BinaryWriter2(_stream);
+ var sysIdData = HexUtil.HexToBytes("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".Replace("-", ""));
+ // var kid = HexUtil.HexToBytes(ProtecitonKID);
+
+ _writer.Write(sysIdData); // SystemID 16 bytes
+ var psshData = HexUtil.HexToBytes($"08011210{ProtecitonKID}1A046E647265220400000000");
+ _writer.WriteUInt(psshData.Length); // Size of Data 4 bytes
+ _writer.Write(psshData); // Data
+ var psshBox = FullBox("pssh", 0, 0, _stream.ToArray());
+ return psshBox;
+ }
+
+ private byte[] GenMoof()
+ {
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter2(stream);
+
+ // make senc
+ writer.WriteUInt(1); // sample_count
+ writer.Write(new byte[8]); // 8 bytes IV
+
+ var sencBox = FullBox("senc", 1, 0, stream.ToArray());
+
+ var moofBox = Box("moof", sencBox); // Movie Extends Box
+
+ return moofBox;
+ }
+
+ public byte[] GenHeader(byte[] firstSegment)
+ {
+ new MP4Parser()
+ .Box("moof", MP4Parser.Children)
+ .Box("traf", MP4Parser.Children)
+ .FullBox("tfhd", box =>
+ {
+ TrackId = (int)box.Reader.ReadUInt32();
+ })
+ .Parse(firstSegment);
+
+ return GenHeader();
+ }
+
+ public byte[] GenHeader()
+ {
+ using var stream = new MemoryStream();
+
+ var ftyp = GenFtyp(); // File Type Box
+ stream.Write(ftyp);
+
+ var moovPayload = GenMvhd(); // Movie Header Box
+
+ var trakPayload = GenTkhd(); // Track Header Box
+
+ var mdhdPayload = GenMdhd(); // Media Header Box
+
+ var hdlrPayload = GenHdlr(); // Handler Reference Box
+
+ var mdiaPayload = mdhdPayload.Concat(hdlrPayload).ToArray();
+
+ var minfPayload = GenMinf();
+
+
+ var sttsPayload = new byte[] { 0, 0, 0, 0 }; // entry count
+ var stblPayload = FullBox("stts", 0, 0, sttsPayload); // Decoding Time to Sample Box
+
+ var stscPayload = new byte[] { 0, 0, 0, 0 }; // entry count
+ var stscBox = FullBox("stsc", 0, 0, stscPayload); // Sample To Chunk Box
+
+ var stcoPayload = new byte[] { 0, 0, 0, 0 }; // entry count
+ var stcoBox = FullBox("stco", 0, 0, stcoPayload); // Chunk Offset Box
+
+ var stszPayload = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 }; // sample size, sample count
+ var stszBox = FullBox("stsz", 0, 0, stszPayload); // Sample Size Box
+
+ var stsdPayload = GetStsd();
+ var stsdBox = FullBox("stsd", 0, 0, stsdPayload); // Sample Description Box
+
+ stblPayload = stblPayload.Concat(stscBox).Concat(stcoBox).Concat(stszBox).Concat(stsdBox).ToArray();
+
+
+ var stblBox = Box("stbl", stblPayload); // Sample Table Box
+ minfPayload = minfPayload.Concat(stblBox).ToArray();
+
+ var minfBox = Box("minf", minfPayload); // Media Information Box
+ mdiaPayload = mdiaPayload.Concat(minfBox).ToArray();
+
+ var mdiaBox = Box("mdia", mdiaPayload); // Media Box
+ trakPayload = trakPayload.Concat(mdiaBox).ToArray();
+
+ var trakBox = Box("trak", trakPayload); // Track Box
+ moovPayload = moovPayload.Concat(trakBox).ToArray();
+
+ var mvexPayload = GetMehd();
+ var trexBox = GetTrex();
+ mvexPayload = mvexPayload.Concat(trexBox).ToArray();
+
+ var mvexBox = Box("mvex", mvexPayload); // Movie Extends Box
+ moovPayload = moovPayload.Concat(mvexBox).ToArray();
+
+ if (IsProtection)
+ {
+ var psshBox1 = GenPsshBoxForPlayReady();
+ var psshBox2 = GenPsshBoxForWideVine();
+ moovPayload = moovPayload.Concat(psshBox1).Concat(psshBox2).ToArray();
+ }
+
+ var moovBox = Box("moov", moovPayload); // Movie Box
+
+ stream.Write(moovBox);
+
+ // var moofBox = GenMoof(); // Movie Extends Box
+ // stream.Write(moofBox);
+
+ return stream.ToArray();
+ }
+}
\ No newline at end of file
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..f8dfdaf
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/N_m3u8DL-RE.Parser.csproj
@@ -0,0 +1,16 @@
+
+
+
+ library
+ net9.0
+ N_m3u8DL_RE.Parser
+ enable
+ preview
+ enable
+
+
+
+
+
+
+
diff --git a/src/N_m3u8DL-RE.Parser/Processor/ContentProcessor.cs b/src/N_m3u8DL-RE.Parser/Processor/ContentProcessor.cs
new file mode 100644
index 0000000..6e4c561
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Processor/ContentProcessor.cs
@@ -0,0 +1,10 @@
+using N_m3u8DL_RE.Common.Enum;
+using N_m3u8DL_RE.Parser.Config;
+
+namespace N_m3u8DL_RE.Parser.Processor;
+
+public abstract class ContentProcessor
+{
+ public abstract bool CanProcess(ExtractorType extractorType, string rawText, ParserConfig parserConfig);
+ public abstract string Process(string rawText, ParserConfig parserConfig);
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Processor/DASH/DefaultDASHContentProcessor.cs b/src/N_m3u8DL-RE.Parser/Processor/DASH/DefaultDASHContentProcessor.cs
new file mode 100644
index 0000000..b7aae0a
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Processor/DASH/DefaultDASHContentProcessor.cs
@@ -0,0 +1,26 @@
+using N_m3u8DL_RE.Common.Enum;
+using N_m3u8DL_RE.Common.Log;
+using N_m3u8DL_RE.Parser.Config;
+
+namespace N_m3u8DL_RE.Parser.Processor.DASH;
+
+///
+/// XG视频处理
+///
+public class DefaultDASHContentProcessor : ContentProcessor
+{
+ public override bool CanProcess(ExtractorType extractorType, string mpdContent, ParserConfig parserConfig)
+ {
+ if (extractorType != ExtractorType.MPEG_DASH) return false;
+
+ return mpdContent.Contains(" paserConfig.AppendUrlParams;
+
+ public override string Process(string oriUrl, ParserConfig paserConfig)
+ {
+ if (!oriUrl.StartsWith("http")) return oriUrl;
+
+ var uriFromConfig = new Uri(paserConfig.Url);
+ var uriFromConfigQuery = HttpUtility.ParseQueryString(uriFromConfig.Query);
+
+ var oldUri = new Uri(oriUrl);
+ var newQuery = HttpUtility.ParseQueryString(oldUri.Query);
+ foreach (var item in uriFromConfigQuery.AllKeys)
+ {
+ if (newQuery.AllKeys.Contains(item))
+ newQuery.Set(item, uriFromConfigQuery.Get(item));
+ else
+ newQuery.Add(item, uriFromConfigQuery.Get(item));
+ }
+
+ if (string.IsNullOrEmpty(newQuery.ToString())) return oriUrl;
+
+ Logger.Debug("Before: " + oriUrl);
+ oriUrl = (oldUri.GetLeftPart(UriPartial.Path) + "?" + newQuery).TrimEnd('?');
+ Logger.Debug("After: " + oriUrl);
+
+ return oriUrl;
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Processor/HLS/DefaultHLSContentProcessor.cs b/src/N_m3u8DL-RE.Parser/Processor/HLS/DefaultHLSContentProcessor.cs
new file mode 100644
index 0000000..684c3eb
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Processor/HLS/DefaultHLSContentProcessor.cs
@@ -0,0 +1,96 @@
+using N_m3u8DL_RE.Common.Enum;
+using N_m3u8DL_RE.Parser.Config;
+using N_m3u8DL_RE.Parser.Constants;
+using System.Text.RegularExpressions;
+
+namespace N_m3u8DL_RE.Parser.Processor.HLS;
+
+public partial class DefaultHLSContentProcessor : ContentProcessor
+{
+ [GeneratedRegex("#EXT-X-DISCONTINUITY\\s+#EXT-X-MAP:URI=\\\"(.*?)\\\",BYTERANGE=\\\"(.*?)\\\"")]
+ private static partial Regex YkDVRegex();
+ [GeneratedRegex("#EXT-X-MAP:URI=\\\".*?BUMPER/[\\s\\S]+?#EXT-X-DISCONTINUITY")]
+ private static partial Regex DNSPRegex();
+ [GeneratedRegex(@"#EXTINF:.*?,\s+.*BUMPER.*\s+?#EXT-X-DISCONTINUITY")]
+ private static partial Regex DNSPSubRegex();
+ [GeneratedRegex("(#EXTINF.*)(\\s+)(#EXT-X-KEY.*)")]
+ private static partial Regex OrderFixRegex();
+ [GeneratedRegex(@"#EXT-X-MAP.*\.apple\.com/")]
+ private static partial Regex ATVRegex();
+ [GeneratedRegex(@"(#EXT-X-KEY:[\s\S]*?)(#EXT-X-DISCONTINUITY|#EXT-X-ENDLIST)")]
+ private static partial Regex ATVRegex2();
+
+ public override bool CanProcess(ExtractorType extractorType, string rawText, ParserConfig parserConfig) => extractorType == ExtractorType.HLS;
+
+ public override string Process(string m3u8Content, ParserConfig parserConfig)
+ {
+ // 处理content以\r作为换行符的情况
+ if (m3u8Content.Contains('\r') && !m3u8Content.Contains('\n'))
+ {
+ m3u8Content = m3u8Content.Replace("\r", Environment.NewLine);
+ }
+
+ var m3u8Url = parserConfig.Url;
+ // YSP回放
+ 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);
+ }
+
+ // 针对YK #EXT-X-VERSION:7杜比视界片源修正
+ if (m3u8Content.Contains("#EXT-X-DISCONTINUITY") && m3u8Content.Contains("#EXT-X-MAP") && m3u8Content.Contains("ott.cibntv.net") && m3u8Content.Contains("ccode="))
+ {
+ var ykmap = YkDVRegex();
+ 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 = DNSPRegex();
+ if (ykmap.IsMatch(m3u8Content))
+ {
+ m3u8Content = m3u8Content.Replace(ykmap.Match(m3u8Content).Value, "#XXX");
+ }
+ }
+
+ // 针对Disney+字幕修正
+ if (m3u8Content.Contains("#EXT-X-DISCONTINUITY") && m3u8Content.Contains("seg_00000.vtt") && m3u8Url.Contains("media.dssott.com/"))
+ {
+ Regex ykmap = DNSPSubRegex();
+ 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/") || ATVRegex().IsMatch(m3u8Content)))
+ {
+ // 只取加密部分即可
+ Regex ykmap = ATVRegex2();
+ if (ykmap.IsMatch(m3u8Content))
+ {
+ m3u8Content = "#EXTM3U\r\n" + ykmap.Match(m3u8Content).Groups[1].Value + "\r\n#EXT-X-ENDLIST";
+ }
+ }
+
+ // 修复#EXT-X-KEY与#EXTINF出现次序异常问题
+ var regex = OrderFixRegex();
+ if (regex.IsMatch(m3u8Content))
+ {
+ m3u8Content = regex.Replace(m3u8Content, "$3$2$1");
+ }
+
+ return m3u8Content;
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Processor/HLS/DefaultHLSKeyProcessor.cs b/src/N_m3u8DL-RE.Parser/Processor/HLS/DefaultHLSKeyProcessor.cs
new file mode 100644
index 0000000..7e3eed0
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Processor/HLS/DefaultHLSKeyProcessor.cs
@@ -0,0 +1,110 @@
+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.Config;
+using N_m3u8DL_RE.Parser.Util;
+using Spectre.Console;
+
+namespace N_m3u8DL_RE.Parser.Processor.HLS;
+
+public class DefaultHLSKeyProcessor : KeyProcessor
+{
+ public override bool CanProcess(ExtractorType extractorType, string m3u8Url, string keyLine, string m3u8Content, ParserConfig paserConfig) => extractorType == ExtractorType.HLS;
+
+
+ public override EncryptInfo Process(string keyLine, string m3u8Url, string m3u8Content, ParserConfig parserConfig)
+ {
+ var iv = ParserUtil.GetAttribute(keyLine, "IV");
+ var method = ParserUtil.GetAttribute(keyLine, "METHOD");
+ var uri = ParserUtil.GetAttribute(keyLine, "URI");
+
+ Logger.Debug("METHOD:{},URI:{},IV:{}", method, uri, iv);
+
+ var encryptInfo = new EncryptInfo(method);
+
+ // IV
+ if (!string.IsNullOrEmpty(iv))
+ {
+ encryptInfo.IV = HexUtil.HexToBytes(iv);
+ }
+ // 自定义IV
+ if (parserConfig.CustomeIV is { Length: > 0 })
+ {
+ encryptInfo.IV = parserConfig.CustomeIV;
+ }
+
+ // KEY
+ try
+ {
+ if (parserConfig.CustomeKey is { Length: > 0 })
+ {
+ encryptInfo.Key = parserConfig.CustomeKey;
+ }
+ else if (uri.ToLower().StartsWith("base64:"))
+ {
+ encryptInfo.Key = Convert.FromBase64String(uri[7..]);
+ }
+ else if (uri.ToLower().StartsWith("data:;base64,"))
+ {
+ encryptInfo.Key = Convert.FromBase64String(uri[13..]);
+ }
+ else if (uri.ToLower().StartsWith("data:text/plain;base64,"))
+ {
+ encryptInfo.Key = Convert.FromBase64String(uri[23..]);
+ }
+ else if (File.Exists(uri))
+ {
+ encryptInfo.Key = File.ReadAllBytes(uri);
+ }
+ else if (!string.IsNullOrEmpty(uri))
+ {
+ var retryCount = parserConfig.KeyRetryCount;
+ var segUrl = PreProcessUrl(ParserUtil.CombineURL(m3u8Url, uri), parserConfig);
+ getHttpKey:
+ try
+ {
+ var bytes = HTTPUtil.GetBytesAsync(segUrl, parserConfig.Headers).Result;
+ encryptInfo.Key = bytes;
+ }
+ catch (Exception _ex) when (!_ex.Message.Contains("scheme is not supported."))
+ {
+ Logger.WarnMarkUp($"[grey]{_ex.Message.EscapeMarkup()} retryCount: {retryCount}[/]");
+ Thread.Sleep(1000);
+ if (retryCount-- > 0) goto getHttpKey;
+ throw;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ResString.cmd_loadKeyFailed + ": " + ex.Message);
+ encryptInfo.Method = EncryptMethod.UNKNOWN;
+ }
+
+ if (parserConfig.CustomMethod == null) return encryptInfo;
+
+ // 处理自定义加密方式
+ encryptInfo.Method = parserConfig.CustomMethod.Value;
+ Logger.Warn("METHOD changed from {} to {}", method, encryptInfo.Method);
+
+ return encryptInfo;
+ }
+
+ ///
+ /// 预处理URL
+ ///
+ private string PreProcessUrl(string url, ParserConfig parserConfig)
+ {
+ foreach (var p in parserConfig.UrlProcessors)
+ {
+ if (p.CanProcess(ExtractorType.HLS, url, parserConfig))
+ {
+ url = p.Process(url, parserConfig);
+ }
+ }
+
+ return url;
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Processor/KeyProcessor.cs b/src/N_m3u8DL-RE.Parser/Processor/KeyProcessor.cs
new file mode 100644
index 0000000..70b3cf2
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Processor/KeyProcessor.cs
@@ -0,0 +1,11 @@
+using N_m3u8DL_RE.Common.Entity;
+using N_m3u8DL_RE.Common.Enum;
+using N_m3u8DL_RE.Parser.Config;
+
+namespace N_m3u8DL_RE.Parser.Processor;
+
+public abstract class KeyProcessor
+{
+ public abstract bool CanProcess(ExtractorType extractorType, string keyLine, string m3u8Url, string m3u8Content, ParserConfig parserConfig);
+ public abstract EncryptInfo Process(string keyLine, string m3u8Url, string m3u8Content, ParserConfig parserConfig);
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/Processor/UrlProcessor.cs b/src/N_m3u8DL-RE.Parser/Processor/UrlProcessor.cs
new file mode 100644
index 0000000..1fa2f62
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Processor/UrlProcessor.cs
@@ -0,0 +1,10 @@
+using N_m3u8DL_RE.Common.Enum;
+using N_m3u8DL_RE.Parser.Config;
+
+namespace N_m3u8DL_RE.Parser.Processor;
+
+public abstract class UrlProcessor
+{
+ public abstract bool CanProcess(ExtractorType extractorType, string oriUrl, ParserConfig parserConfig);
+ public abstract string Process(string oriUrl, ParserConfig parserConfig);
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.Parser/StreamExtractor.cs b/src/N_m3u8DL-RE.Parser/StreamExtractor.cs
new file mode 100644
index 0000000..27966dc
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/StreamExtractor.cs
@@ -0,0 +1,144 @@
+using System.Diagnostics.CodeAnalysis;
+using N_m3u8DL_RE.Parser.Config;
+using N_m3u8DL_RE.Common.Entity;
+using N_m3u8DL_RE.Common.Log;
+using N_m3u8DL_RE.Common.Resource;
+using N_m3u8DL_RE.Parser.Constants;
+using N_m3u8DL_RE.Parser.Extractor;
+using N_m3u8DL_RE.Common.Util;
+using N_m3u8DL_RE.Common.Enum;
+
+namespace N_m3u8DL_RE.Parser;
+
+public class StreamExtractor
+{
+ public ExtractorType ExtractorType => extractor.ExtractorType;
+ private IExtractor extractor;
+ private ParserConfig parserConfig = new();
+ private string rawText;
+ private static SemaphoreSlim semaphore = new(1, 1);
+
+ public Dictionary RawFiles { get; set; } = new(); // 存储(文件名,文件内容)
+
+ public StreamExtractor(ParserConfig parserConfig)
+ {
+ this.parserConfig = parserConfig;
+ }
+
+ public async Task LoadSourceFromUrlAsync(string url)
+ {
+ Logger.Info(ResString.loadingUrl + url);
+ if (url.StartsWith("file:"))
+ {
+ var uri = new Uri(url);
+ this.rawText = await File.ReadAllTextAsync(uri.LocalPath);
+ parserConfig.OriginalUrl = parserConfig.Url = url;
+ }
+ else if (url.StartsWith("http"))
+ {
+ parserConfig.OriginalUrl = url;
+ (this.rawText, url) = await HTTPUtil.GetWebSourceAndNewUrlAsync(url, parserConfig.Headers);
+ parserConfig.Url = url;
+ }
+ else if (File.Exists(url))
+ {
+ url = Path.GetFullPath(url);
+ this.rawText = await File.ReadAllTextAsync(url);
+ parserConfig.OriginalUrl = parserConfig.Url = new Uri(url).AbsoluteUri;
+ }
+ this.rawText = rawText.Trim();
+ LoadSourceFromText(this.rawText);
+ }
+
+ [MemberNotNull(nameof(this.rawText), nameof(this.extractor))]
+ private void LoadSourceFromText(string rawText)
+ {
+ var rawType = "txt";
+ rawText = rawText.Trim();
+ this.rawText = rawText;
+ if (rawText.StartsWith(HLSTags.ext_m3u))
+ {
+ Logger.InfoMarkUp(ResString.matchHLS);
+ extractor = new HLSExtractor(parserConfig);
+ rawType = "m3u8";
+ }
+ else if (rawText.Contains("") && rawText.Contains("") && rawText.Contains("
+ /// 开始解析流媒体信息
+ ///
+ ///
+ public async Task> ExtractStreamsAsync()
+ {
+ try
+ {
+ await semaphore.WaitAsync();
+ Logger.Info(ResString.parsingStream);
+ return await extractor.ExtractStreamsAsync(rawText);
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+
+ ///
+ /// 根据规格说明填充媒体播放列表信息
+ ///
+ ///
+ public async Task FetchPlayListAsync(List streamSpecs)
+ {
+ try
+ {
+ await semaphore.WaitAsync();
+ Logger.Info(ResString.parsingStream);
+ await extractor.FetchPlayListAsync(streamSpecs);
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+
+ public async Task RefreshPlayListAsync(List streamSpecs)
+ {
+ try
+ {
+ await semaphore.WaitAsync();
+ await RetryUtil.WebRequestRetryAsync(async () =>
+ {
+ await extractor.RefreshPlayListAsync(streamSpecs);
+ return true;
+ }, retryDelayMilliseconds: 1000, maxRetries: 5);
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+}
\ No newline at end of file
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..4ed6af3
--- /dev/null
+++ b/src/N_m3u8DL-RE.Parser/Util/ParserUtil.cs
@@ -0,0 +1,114 @@
+using N_m3u8DL_RE.Parser.Constants;
+using System.Text.RegularExpressions;
+
+namespace N_m3u8DL_RE.Parser.Util;
+
+public static partial class ParserUtil
+{
+ [GeneratedRegex(@"\$Number%([^$]+)d\$")]
+ private static partial Regex VarsNumberRegex();
+
+ ///
+ /// 从以下文本中获取参数
+ /// #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[(line.IndexOf(':') + 1)..];
+
+ var index = -1;
+ var result = string.Empty;
+ if ((index = line.IndexOf(key + "=\"", StringComparison.Ordinal)) > -1)
+ {
+ var startIndex = index + (key + "=\"").Length;
+ var endIndex = startIndex + line[startIndex..].IndexOf('\"');
+ result = line[startIndex..endIndex];
+ }
+ else if ((index = line.IndexOf(key + "=", StringComparison.Ordinal)) > -1)
+ {
+ var startIndex = index + (key + "=").Length;
+ var endIndex = startIndex + line[startIndex..].IndexOf(',');
+ result = endIndex >= startIndex ? line[startIndex..endIndex] : line[startIndex..];
+ }
+
+ return result;
+ }
+
+ ///
+ /// 从如下文本中提取
+ /// [@]
+ ///
+ ///
+ /// n(length) o(start)
+ public static (long, long?) GetRange(string input)
+ {
+ var t = input.Split('@');
+ return t.Length switch
+ {
+ <= 0 => (0, null),
+ 1 => (Convert.ToInt64(t[0]), null),
+ 2 => (Convert.ToInt64(t[0]), Convert.ToInt64(t[1])),
+ _ => (0, null)
+ };
+ }
+
+ ///
+ /// 从100-300这种字符串中获取StartRange, ExpectLength信息
+ ///
+ ///
+ /// StartRange, ExpectLength
+ public static (long, long) ParseRange(string range)
+ {
+ var start = Convert.ToInt64(range.Split('-')[0]);
+ var end = Convert.ToInt64(range.Split('-')[1]);
+ return (start, end - start + 1);
+ }
+
+ ///
+ /// MPD SegmentTemplate替换
+ ///
+ ///
+ ///
+ ///
+ public static string ReplaceVars(string text, Dictionary keyValuePairs)
+ {
+ foreach (var item in keyValuePairs)
+ if (text.Contains(item.Key))
+ text = text.Replace(item.Key, item.Value!.ToString());
+
+ // 处理特殊形式数字 如 $Number%05d$
+ var regex = VarsNumberRegex();
+ if (regex.IsMatch(text) && keyValuePairs.TryGetValue(DASHTags.TemplateNumber, out var keyValuePair))
+ {
+ foreach (Match m in regex.Matches(text))
+ {
+ text = text.Replace(m.Value, keyValuePair?.ToString()?.PadLeft(Convert.ToInt32(m.Groups[1].Value), '0'));
+ }
+ }
+
+ return text;
+ }
+
+ ///
+ /// 拼接Baseurl和RelativeUrl
+ ///
+ /// Baseurl
+ /// RelativeUrl
+ ///
+ public static string CombineURL(string baseurl, string url)
+ {
+ if (string.IsNullOrEmpty(baseurl))
+ return url;
+
+ var uri1 = new Uri(baseurl); // 这里直接传完整的URL即可
+ var uri2 = new Uri(uri1, url);
+ url = uri2.ToString();
+
+ return url;
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE.sln b/src/N_m3u8DL-RE.sln
new file mode 100644
index 0000000..8954741
--- /dev/null
+++ b/src/N_m3u8DL-RE.sln
@@ -0,0 +1,38 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.3.32505.426
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "N_m3u8DL-RE", "N_m3u8DL-RE\N_m3u8DL-RE.csproj", "{E6915BF9-8306-4F62-B357-23430F0D80B5}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "N_m3u8DL-RE.Common", "N_m3u8DL-RE.Common\N_m3u8DL-RE.Common.csproj", "{18D8BC30-512C-4A07-BD4C-E96DF5CF6341}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "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
+ RESX_NeutralResourcesLanguage = en-US
+ SolutionGuid = {87F963D4-EA06-413D-9372-C726711C32B5}
+ EndGlobalSection
+EndGlobal
diff --git a/src/N_m3u8DL-RE/Column/DownloadSpeedColumn.cs b/src/N_m3u8DL-RE/Column/DownloadSpeedColumn.cs
new file mode 100644
index 0000000..54befb2
--- /dev/null
+++ b/src/N_m3u8DL-RE/Column/DownloadSpeedColumn.cs
@@ -0,0 +1,48 @@
+using N_m3u8DL_RE.Entity;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using System.Collections.Concurrent;
+using N_m3u8DL_RE.Common.Util;
+
+namespace N_m3u8DL_RE.Column;
+
+internal sealed class DownloadSpeedColumn : ProgressColumn
+{
+ private long _stopSpeed = 0;
+ private ConcurrentDictionary DateTimeStringDic = new();
+ protected override bool NoWrap => true;
+ private ConcurrentDictionary SpeedContainerDic { get; set; }
+
+ public DownloadSpeedColumn(ConcurrentDictionary SpeedContainerDic)
+ {
+ this.SpeedContainerDic = SpeedContainerDic;
+ }
+
+ public Style MyStyle { get; set; } = new Style(foreground: Color.Green);
+
+ public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
+ {
+ var taskId = task.Id;
+ var speedContainer = SpeedContainerDic[taskId];
+ var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ var flag = task.IsFinished || !task.IsStarted;
+ // 单文件下载汇报进度
+ if (!flag && speedContainer is { SingleSegment: true, ResponseLength: not null })
+ {
+ task.MaxValue = (double)speedContainer.ResponseLength;
+ task.Value = speedContainer.RDownloaded;
+ }
+ // 一秒汇报一次即可
+ if (DateTimeStringDic.TryGetValue(taskId, out var oldTime) && oldTime != now && !flag)
+ {
+ speedContainer.NowSpeed = speedContainer.Downloaded;
+ // 速度为0,计数增加
+ if (speedContainer.Downloaded <= _stopSpeed) { speedContainer.AddLowSpeedCount(); }
+ else speedContainer.ResetLowSpeedCount();
+ speedContainer.Reset();
+ }
+ DateTimeStringDic[taskId] = now;
+ var style = flag ? Style.Plain : MyStyle;
+ return flag ? new Text("-", style).Centered() : new Text(GlobalUtil.FormatFileSize(speedContainer.NowSpeed) + "ps" + (speedContainer.LowSpeedCount > 0 ? $"({speedContainer.LowSpeedCount})" : ""), style).Centered();
+ }
+}
diff --git a/src/N_m3u8DL-RE/Column/DownloadStatusColumn.cs b/src/N_m3u8DL-RE/Column/DownloadStatusColumn.cs
new file mode 100644
index 0000000..cbedb9d
--- /dev/null
+++ b/src/N_m3u8DL-RE/Column/DownloadStatusColumn.cs
@@ -0,0 +1,43 @@
+using N_m3u8DL_RE.Common.Util;
+using N_m3u8DL_RE.Entity;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using System.Collections.Concurrent;
+
+namespace N_m3u8DL_RE.Column;
+
+internal class DownloadStatusColumn : ProgressColumn
+{
+ private ConcurrentDictionary SpeedContainerDic { get; set; }
+ private ConcurrentDictionary DateTimeStringDic = new();
+ private ConcurrentDictionary SizeDic = new();
+ public Style MyStyle { get; set; } = new Style(foreground: Color.DarkCyan);
+ public Style FinishedStyle { get; set; } = new Style(foreground: Color.Green);
+
+ public DownloadStatusColumn(ConcurrentDictionary speedContainerDic)
+ {
+ this.SpeedContainerDic = speedContainerDic;
+ }
+
+ public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
+ {
+ if (task.Value == 0) return new Text("-", MyStyle).RightJustified();
+ var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+
+ var speedContainer = SpeedContainerDic[task.Id];
+ var size = speedContainer.RDownloaded;
+
+ // 一秒汇报一次即可
+ if (DateTimeStringDic.TryGetValue(task.Id, out var oldTime) && oldTime != now)
+ {
+ var totalSize = speedContainer.SingleSegment ? (speedContainer.ResponseLength ?? 0) : (long)(size / (task.Value / task.MaxValue));
+ SizeDic[task.Id] = $"{GlobalUtil.FormatFileSize(size)}/{GlobalUtil.FormatFileSize(totalSize)}";
+ }
+ DateTimeStringDic[task.Id] = now;
+ SizeDic.TryGetValue(task.Id, out var sizeStr);
+
+ if (task.IsFinished) sizeStr = GlobalUtil.FormatFileSize(size);
+
+ return new Text(sizeStr ?? "-", MyStyle).RightJustified();
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Column/MyPercentageColumn.cs b/src/N_m3u8DL-RE/Column/MyPercentageColumn.cs
new file mode 100644
index 0000000..2c74aae
--- /dev/null
+++ b/src/N_m3u8DL-RE/Column/MyPercentageColumn.cs
@@ -0,0 +1,25 @@
+using Spectre.Console.Rendering;
+using Spectre.Console;
+
+namespace N_m3u8DL_RE.Column;
+
+internal class MyPercentageColumn : ProgressColumn
+{
+ ///
+ /// Gets or sets the style for a non-complete task.
+ ///
+ public Style Style { get; set; } = Style.Plain;
+
+ ///
+ /// Gets or sets the style for a completed task.
+ ///
+ public Style CompletedStyle { get; set; } = new Style(foreground: Color.Green);
+
+ ///
+ public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
+ {
+ var percentage = task.Percentage;
+ var style = percentage == 100 ? CompletedStyle : Style ?? Style.Plain;
+ return new Text($"{task.Value}/{task.MaxValue} {percentage:F2}%", style).RightJustified();
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Column/RecordingDurationColumn.cs b/src/N_m3u8DL-RE/Column/RecordingDurationColumn.cs
new file mode 100644
index 0000000..cf887fb
--- /dev/null
+++ b/src/N_m3u8DL-RE/Column/RecordingDurationColumn.cs
@@ -0,0 +1,30 @@
+using N_m3u8DL_RE.Common.Util;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using System.Collections.Concurrent;
+
+namespace N_m3u8DL_RE.Column;
+
+internal class RecordingDurationColumn : ProgressColumn
+{
+ protected override bool NoWrap => true;
+ private ConcurrentDictionary _recodingDurDic;
+ private ConcurrentDictionary? _refreshedDurDic;
+ public Style GreyStyle { get; set; } = new Style(foreground: Color.Grey);
+ public Style MyStyle { get; set; } = new Style(foreground: Color.DarkGreen);
+ public RecordingDurationColumn(ConcurrentDictionary recodingDurDic)
+ {
+ _recodingDurDic = recodingDurDic;
+ }
+ public RecordingDurationColumn(ConcurrentDictionary recodingDurDic, ConcurrentDictionary refreshedDurDic)
+ {
+ _recodingDurDic = recodingDurDic;
+ _refreshedDurDic = refreshedDurDic;
+ }
+ public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
+ {
+ if (_refreshedDurDic == null)
+ return new Text($"{GlobalUtil.FormatTime(_recodingDurDic[task.Id])}", MyStyle).LeftJustified();
+ return new Text($"{GlobalUtil.FormatTime(_recodingDurDic[task.Id])}/{GlobalUtil.FormatTime(_refreshedDurDic[task.Id])}", GreyStyle);
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Column/RecordingSizeColumn.cs b/src/N_m3u8DL-RE/Column/RecordingSizeColumn.cs
new file mode 100644
index 0000000..9d56934
--- /dev/null
+++ b/src/N_m3u8DL-RE/Column/RecordingSizeColumn.cs
@@ -0,0 +1,32 @@
+using N_m3u8DL_RE.Common.Util;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using System.Collections.Concurrent;
+
+namespace N_m3u8DL_RE.Column;
+
+internal class RecordingSizeColumn : ProgressColumn
+{
+ protected override bool NoWrap => true;
+ private ConcurrentDictionary RecodingSizeDic = new(); // 临时的大小 每秒刷新用
+ private ConcurrentDictionary _recodingSizeDic;
+ private ConcurrentDictionary DateTimeStringDic = new();
+ public Style MyStyle { get; set; } = new Style(foreground: Color.DarkCyan);
+ public RecordingSizeColumn(ConcurrentDictionary recodingSizeDic)
+ {
+ _recodingSizeDic = recodingSizeDic;
+ }
+ public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
+ {
+ var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ var taskId = task.Id;
+ // 一秒汇报一次即可
+ if (DateTimeStringDic.TryGetValue(taskId, out var oldTime) && oldTime != now)
+ {
+ RecodingSizeDic[task.Id] = _recodingSizeDic[task.Id];
+ }
+ DateTimeStringDic[taskId] = now;
+ var flag = RecodingSizeDic.TryGetValue(taskId, out var size);
+ return new Text(GlobalUtil.FormatFileSize(flag ? size : 0), MyStyle).LeftJustified();
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Column/RecordingStatusColumn.cs b/src/N_m3u8DL-RE/Column/RecordingStatusColumn.cs
new file mode 100644
index 0000000..2925d22
--- /dev/null
+++ b/src/N_m3u8DL-RE/Column/RecordingStatusColumn.cs
@@ -0,0 +1,17 @@
+using Spectre.Console;
+using Spectre.Console.Rendering;
+
+namespace N_m3u8DL_RE.Column;
+
+internal class RecordingStatusColumn : ProgressColumn
+{
+ protected override bool NoWrap => true;
+ public Style MyStyle { get; set; } = new Style(foreground: Color.Default);
+ public Style FinishedStyle { get; set; } = new Style(foreground: Color.Yellow);
+ public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime)
+ {
+ if (task.IsFinished)
+ return new Text($"{task.Value}/{task.MaxValue} Waiting ", FinishedStyle).LeftJustified();
+ return new Text($"{task.Value}/{task.MaxValue} Recording", MyStyle).LeftJustified();
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/CommandLine/CommandInvoker.cs b/src/N_m3u8DL-RE/CommandLine/CommandInvoker.cs
new file mode 100644
index 0000000..010ef3e
--- /dev/null
+++ b/src/N_m3u8DL-RE/CommandLine/CommandInvoker.cs
@@ -0,0 +1,648 @@
+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.Entity;
+using N_m3u8DL_RE.Enum;
+using N_m3u8DL_RE.Util;
+using System.CommandLine;
+using System.CommandLine.Binding;
+using System.CommandLine.Builder;
+using System.CommandLine.Parsing;
+using System.Globalization;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace N_m3u8DL_RE.CommandLine;
+
+internal static partial class CommandInvoker
+{
+ public const string VERSION_INFO = "N_m3u8DL-RE (Beta version) 20241216";
+
+ [GeneratedRegex("((best|worst)\\d*|all)")]
+ private static partial Regex ForStrRegex();
+ [GeneratedRegex(@"(\d*)-(\d*)")]
+ private static partial Regex RangeRegex();
+ [GeneratedRegex(@"([\d\\.]+)(M|K)")]
+ private static partial Regex SpeedStrRegex();
+
+ private static readonly Argument Input = new(name: "input", description: ResString.cmd_Input);
+ private static readonly Option TmpDir = new(["--tmp-dir"], description: ResString.cmd_tmpDir);
+ private static readonly Option SaveDir = new(["--save-dir"], description: ResString.cmd_saveDir);
+ private static readonly Option SaveName = new(["--save-name"], description: ResString.cmd_saveName, parseArgument: ParseSaveName);
+ private static readonly Option SavePattern = new(["--save-pattern"], description: ResString.cmd_savePattern, getDefaultValue: () => "____");
+ private static readonly Option UILanguage = new Option(["--ui-language"], description: ResString.cmd_uiLanguage).FromAmong("en-US", "zh-CN", "zh-TW");
+ private static readonly Option UrlProcessorArgs = new(["--urlprocessor-args"], description: ResString.cmd_urlProcessorArgs);
+ private static readonly Option Keys = new(["--key"], description: ResString.cmd_keys) { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false };
+ private static readonly Option KeyTextFile = new(["--key-text-file"], description: ResString.cmd_keyText);
+ private static readonly Option> Headers = new(["-H", "--header"], description: ResString.cmd_header, parseArgument: ParseHeaders) { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false };
+ private static readonly Option LogLevel = new(name: "--log-level", description: ResString.cmd_logLevel, getDefaultValue: () => Common.Log.LogLevel.INFO);
+ private static readonly Option SubtitleFormat = new(name: "--sub-format", description: ResString.cmd_subFormat, getDefaultValue: () => Enum.SubtitleFormat.SRT);
+ private static readonly Option DisableUpdateCheck = new(["--disable-update-check"], description: ResString.cmd_disableUpdateCheck, getDefaultValue: () => false);
+ private static readonly Option AutoSelect = new(["--auto-select"], description: ResString.cmd_autoSelect, getDefaultValue: () => false);
+ private static readonly Option SubOnly = new(["--sub-only"], description: ResString.cmd_subOnly, getDefaultValue: () => false);
+ private static readonly Option ThreadCount = new(["--thread-count"], description: ResString.cmd_threadCount, getDefaultValue: () => Environment.ProcessorCount) { ArgumentHelpName = "number" };
+ private static readonly Option DownloadRetryCount = new(["--download-retry-count"], description: ResString.cmd_downloadRetryCount, getDefaultValue: () => 3) { ArgumentHelpName = "number" };
+ private static readonly Option HttpRequestTimeout = new(["--http-request-timeout"], description: ResString.cmd_httpRequestTimeout, getDefaultValue: () => 100) { ArgumentHelpName = "seconds" };
+ private static readonly Option SkipMerge = new(["--skip-merge"], description: ResString.cmd_skipMerge, getDefaultValue: () => false);
+ private static readonly Option SkipDownload = new(["--skip-download"], description: ResString.cmd_skipDownload, getDefaultValue: () => false);
+ private static readonly Option NoDateInfo = new(["--no-date-info"], description: ResString.cmd_noDateInfo, getDefaultValue: () => false);
+ private static readonly Option BinaryMerge = new(["--binary-merge"], description: ResString.cmd_binaryMerge, getDefaultValue: () => false);
+ private static readonly Option UseFFmpegConcatDemuxer = new(["--use-ffmpeg-concat-demuxer"], description: ResString.cmd_useFFmpegConcatDemuxer, getDefaultValue: () => false);
+ private static readonly Option DelAfterDone = new(["--del-after-done"], description: ResString.cmd_delAfterDone, getDefaultValue: () => true);
+ private static readonly Option AutoSubtitleFix = new(["--auto-subtitle-fix"], description: ResString.cmd_subtitleFix, getDefaultValue: () => true);
+ private static readonly Option CheckSegmentsCount = new(["--check-segments-count"], description: ResString.cmd_checkSegmentsCount, getDefaultValue: () => true);
+ private static readonly Option WriteMetaJson = new(["--write-meta-json"], description: ResString.cmd_writeMetaJson, getDefaultValue: () => true);
+ private static readonly Option AppendUrlParams = new(["--append-url-params"], description: ResString.cmd_appendUrlParams, getDefaultValue: () => false);
+ private static readonly Option MP4RealTimeDecryption = new (["--mp4-real-time-decryption"], description: ResString.cmd_MP4RealTimeDecryption, getDefaultValue: () => false);
+ private static readonly Option UseShakaPackager = new (["--use-shaka-packager"], description: ResString.cmd_useShakaPackager, getDefaultValue: () => false) { IsHidden = true };
+ private static readonly Option DecryptionEngine = new (["--decryption-engine"], description: ResString.cmd_decryptionEngine, getDefaultValue: () => DecryptEngine.MP4DECRYPT);
+ private static readonly Option ForceAnsiConsole = new(["--force-ansi-console"], description: ResString.cmd_forceAnsiConsole);
+ private static readonly Option NoAnsiColor = new(["--no-ansi-color"], description: ResString.cmd_noAnsiColor);
+ private static readonly Option DecryptionBinaryPath = new(["--decryption-binary-path"], description: ResString.cmd_decryptionBinaryPath) { ArgumentHelpName = "PATH" };
+ private static readonly Option FFmpegBinaryPath = new(["--ffmpeg-binary-path"], description: ResString.cmd_ffmpegBinaryPath) { ArgumentHelpName = "PATH" };
+ private static readonly Option BaseUrl = new(["--base-url"], description: ResString.cmd_baseUrl);
+ private static readonly Option ConcurrentDownload = new(["-mt", "--concurrent-download"], description: ResString.cmd_concurrentDownload, getDefaultValue: () => false);
+ private static readonly Option NoLog = new(["--no-log"], description: ResString.cmd_noLog, getDefaultValue: () => false);
+ private static readonly Option AllowHlsMultiExtMap = new(["--allow-hls-multi-ext-map"], description: ResString.cmd_allowHlsMultiExtMap, getDefaultValue: () => false);
+ private static readonly Option AdKeywords = new(["--ad-keyword"], description: ResString.cmd_adKeyword) { ArgumentHelpName = "REG" };
+ private static readonly Option MaxSpeed = new(["-R", "--max-speed"], description: ResString.cmd_maxSpeed, parseArgument: ParseSpeedLimit) { ArgumentHelpName = "SPEED" };
+
+
+ // 代理选项
+ private static readonly Option UseSystemProxy = new(["--use-system-proxy"], description: ResString.cmd_useSystemProxy, getDefaultValue: () => true);
+ private static readonly Option CustomProxy = new(["--custom-proxy"], description: ResString.cmd_customProxy, parseArgument: ParseProxy) { ArgumentHelpName = "URL" };
+
+ // 只下载部分分片
+ private static readonly Option CustomRange = new(["--custom-range"], description: ResString.cmd_customRange, parseArgument: ParseCustomRange) { ArgumentHelpName = "RANGE" };
+
+
+ // morehelp
+ private static readonly Option MoreHelp = new(["--morehelp"], description: ResString.cmd_moreHelp) { ArgumentHelpName = "OPTION" };
+
+ // 自定义KEY等
+ private static readonly Option CustomHLSMethod = new(name: "--custom-hls-method", description: ResString.cmd_customHLSMethod) { ArgumentHelpName = "METHOD" };
+ private static readonly Option CustomHLSKey = new(name: "--custom-hls-key", description: ResString.cmd_customHLSKey, parseArgument: ParseHLSCustomKey) { ArgumentHelpName = "FILE|HEX|BASE64" };
+ private static readonly Option CustomHLSIv = new(name: "--custom-hls-iv", description: ResString.cmd_customHLSIv, parseArgument: ParseHLSCustomKey) { ArgumentHelpName = "FILE|HEX|BASE64" };
+
+ // 任务开始时间
+ private static readonly Option TaskStartAt = new(["--task-start-at"], description: ResString.cmd_taskStartAt, parseArgument: ParseStartTime) { ArgumentHelpName = "yyyyMMddHHmmss" };
+
+
+ // 直播相关
+ private static readonly Option LivePerformAsVod = new(["--live-perform-as-vod"], description: ResString.cmd_livePerformAsVod, getDefaultValue: () => false);
+ private static readonly Option LiveRealTimeMerge = new(["--live-real-time-merge"], description: ResString.cmd_liveRealTimeMerge, getDefaultValue: () => false);
+ private static readonly Option LiveKeepSegments = new(["--live-keep-segments"], description: ResString.cmd_liveKeepSegments, getDefaultValue: () => true);
+ private static readonly Option LivePipeMux = new(["--live-pipe-mux"], description: ResString.cmd_livePipeMux, getDefaultValue: () => false);
+ private static readonly Option LiveRecordLimit = new(["--live-record-limit"], description: ResString.cmd_liveRecordLimit, parseArgument: ParseLiveLimit) { ArgumentHelpName = "HH:mm:ss" };
+ private static readonly Option LiveWaitTime = new(["--live-wait-time"], description: ResString.cmd_liveWaitTime) { ArgumentHelpName = "SEC" };
+ private static readonly Option LiveTakeCount = new(["--live-take-count"], description: ResString.cmd_liveTakeCount, getDefaultValue: () => 16) { ArgumentHelpName = "NUM" };
+ private static readonly Option LiveFixVttByAudio = new(["--live-fix-vtt-by-audio"], description: ResString.cmd_liveFixVttByAudio, getDefaultValue: () => false);
+
+
+ // 复杂命令行如下
+ private static readonly Option MuxAfterDone = new(["-M", "--mux-after-done"], description: ResString.cmd_muxAfterDone, parseArgument: ParseMuxAfterDone) { ArgumentHelpName = "OPTIONS" };
+ private static readonly Option> MuxImports = new("--mux-import", description: ResString.cmd_muxImport, parseArgument: ParseImports) { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false, ArgumentHelpName = "OPTIONS" };
+ private static readonly Option VideoFilter = new(["-sv", "--select-video"], description: ResString.cmd_selectVideo, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
+ private static readonly Option AudioFilter = new(["-sa", "--select-audio"], description: ResString.cmd_selectAudio, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
+ private static readonly Option SubtitleFilter = new(["-ss", "--select-subtitle"], description: ResString.cmd_selectSubtitle, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
+
+ private static readonly Option DropVideoFilter = new(["-dv", "--drop-video"], description: ResString.cmd_dropVideo, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
+ private static readonly Option DropAudioFilter = new(["-da", "--drop-audio"], description: ResString.cmd_dropAudio, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
+ private static readonly Option DropSubtitleFilter = new(["-ds", "--drop-subtitle"], description: ResString.cmd_dropSubtitle, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
+
+ ///
+ /// 解析下载速度限制
+ ///
+ ///
+ ///
+ private static long? ParseSpeedLimit(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value.ToUpper();
+ try
+ {
+ var reg = SpeedStrRegex();
+ if (!reg.IsMatch(input)) throw new ArgumentException($"Invalid Speed Limit: {input}");
+
+ var number = double.Parse(reg.Match(input).Groups[1].Value);
+ if (reg.Match(input).Groups[2].Value == "M")
+ return (long)(number * 1024 * 1024);
+ return (long)(number * 1024);
+ }
+ catch (Exception)
+ {
+ result.ErrorMessage = "error in parse SpeedLimit: " + input;
+ return null;
+ }
+ }
+
+ ///
+ /// 解析用户定义的下载范围
+ ///
+ ///
+ ///
+ ///
+ private static CustomRange? ParseCustomRange(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value;
+ // 支持的种类 0-100; 01:00:00-02:30:00; -300; 300-; 05:00-; -03:00;
+ try
+ {
+ if (string.IsNullOrEmpty(input))
+ return null;
+
+ var arr = input.Split('-');
+ if (arr.Length != 2)
+ throw new ArgumentException("Bad format!");
+
+ if (input.Contains(':'))
+ {
+ return new CustomRange()
+ {
+ InputStr = input,
+ StartSec = arr[0] == "" ? 0 : OtherUtil.ParseDur(arr[0]).TotalSeconds,
+ EndSec = arr[1] == "" ? double.MaxValue : OtherUtil.ParseDur(arr[1]).TotalSeconds,
+ };
+ }
+
+ if (RangeRegex().IsMatch(input))
+ {
+ var left = RangeRegex().Match(input).Groups[1].Value;
+ var right = RangeRegex().Match(input).Groups[2].Value;
+ return new CustomRange()
+ {
+ InputStr = input,
+ StartSegIndex = left == "" ? 0 : long.Parse(left),
+ EndSegIndex = right == "" ? long.MaxValue : long.Parse(right),
+ };
+ }
+
+ throw new ArgumentException("Bad format!");
+ }
+ catch (Exception ex)
+ {
+ result.ErrorMessage = $"error in parse CustomRange: " + ex.Message;
+ return null;
+ }
+ }
+
+ ///
+ /// 解析用户代理
+ ///
+ ///
+ ///
+ ///
+ private static WebProxy? ParseProxy(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value;
+ try
+ {
+ if (string.IsNullOrEmpty(input))
+ return null;
+
+ var uri = new Uri(input);
+ var proxy = new WebProxy(uri, true);
+ if (!string.IsNullOrEmpty(uri.UserInfo))
+ {
+ var infos = uri.UserInfo.Split(':');
+ proxy.Credentials = new NetworkCredential(infos.First(), infos.Last());
+ }
+ return proxy;
+ }
+ catch (Exception ex)
+ {
+ result.ErrorMessage = $"error in parse proxy: " + ex.Message;
+ return null;
+ }
+ }
+
+ ///
+ /// 解析自定义KEY
+ ///
+ ///
+ ///
+ private static byte[]? ParseHLSCustomKey(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value;
+ try
+ {
+ if (string.IsNullOrEmpty(input))
+ return null;
+ if (File.Exists(input))
+ return File.ReadAllBytes(input);
+ if (HexUtil.TryParseHexString(input, out byte[]? bytes))
+ return bytes;
+ return Convert.FromBase64String(input);
+ }
+ catch (Exception)
+ {
+ result.ErrorMessage = "error in parse hls custom key: " + input;
+ return null;
+ }
+ }
+
+ ///
+ /// 解析录制直播时长限制
+ ///
+ ///
+ ///
+ private static TimeSpan? ParseLiveLimit(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value;
+ try
+ {
+ return OtherUtil.ParseDur(input);
+ }
+ catch (Exception)
+ {
+ result.ErrorMessage = "error in parse LiveRecordLimit: " + input;
+ return null;
+ }
+ }
+
+ ///
+ /// 解析任务开始时间
+ ///
+ ///
+ ///
+ private static DateTime? ParseStartTime(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value;
+ try
+ {
+ CultureInfo provider = CultureInfo.InvariantCulture;
+ return DateTime.ParseExact(input, "yyyyMMddHHmmss", provider);
+ }
+ catch (Exception)
+ {
+ result.ErrorMessage = "error in parse TaskStartTime: " + input;
+ return null;
+ }
+ }
+
+ private static string? ParseSaveName(ArgumentResult result)
+ {
+ var input = result.Tokens[0].Value;
+ var newName = OtherUtil.GetValidFileName(input);
+ if (string.IsNullOrEmpty(newName))
+ {
+ result.ErrorMessage = "Invalid save name!";
+ return null;
+ }
+ return newName;
+ }
+
+ ///
+ /// 流过滤器
+ ///
+ ///
+ ///
+ private static StreamFilter? ParseStreamFilter(ArgumentResult result)
+ {
+ var streamFilter = new StreamFilter();
+ var input = result.Tokens[0].Value;
+ var p = new ComplexParamParser(input);
+
+
+ // 目标范围
+ var forStr = "";
+ if (input == ForStrRegex().Match(input).Value)
+ {
+ forStr = input;
+ }
+ else
+ {
+ forStr = p.GetValue("for") ?? "best";
+ if (forStr != ForStrRegex().Match(forStr).Value)
+ {
+ result.ErrorMessage = $"for={forStr} not valid";
+ return null;
+ }
+ }
+ streamFilter.For = forStr;
+
+ var id = p.GetValue("id");
+ if (!string.IsNullOrEmpty(id))
+ streamFilter.GroupIdReg = new Regex(id);
+
+ var lang = p.GetValue("lang");
+ if (!string.IsNullOrEmpty(lang))
+ streamFilter.LanguageReg = new Regex(lang);
+
+ var name = p.GetValue("name");
+ if (!string.IsNullOrEmpty(name))
+ streamFilter.NameReg = new Regex(name);
+
+ var codecs = p.GetValue("codecs");
+ if (!string.IsNullOrEmpty(codecs))
+ streamFilter.CodecsReg = new Regex(codecs);
+
+ var res = p.GetValue("res");
+ if (!string.IsNullOrEmpty(res))
+ streamFilter.ResolutionReg = new Regex(res);
+
+ var frame = p.GetValue("frame");
+ if (!string.IsNullOrEmpty(frame))
+ streamFilter.FrameRateReg = new Regex(frame);
+
+ var channel = p.GetValue("channel");
+ if (!string.IsNullOrEmpty(channel))
+ streamFilter.ChannelsReg = new Regex(channel);
+
+ var range = p.GetValue("range");
+ if (!string.IsNullOrEmpty(range))
+ streamFilter.VideoRangeReg = new Regex(range);
+
+ var url = p.GetValue("url");
+ if (!string.IsNullOrEmpty(url))
+ streamFilter.UrlReg = new Regex(url);
+
+ var segsMin = p.GetValue("segsMin");
+ if (!string.IsNullOrEmpty(segsMin))
+ streamFilter.SegmentsMinCount = long.Parse(segsMin);
+
+ var segsMax = p.GetValue("segsMax");
+ if (!string.IsNullOrEmpty(segsMax))
+ streamFilter.SegmentsMaxCount = long.Parse(segsMax);
+
+ var plistDurMin = p.GetValue("plistDurMin");
+ if (!string.IsNullOrEmpty(plistDurMin))
+ streamFilter.PlaylistMinDur = OtherUtil.ParseSeconds(plistDurMin);
+
+ var plistDurMax = p.GetValue("plistDurMax");
+ if (!string.IsNullOrEmpty(plistDurMax))
+ streamFilter.PlaylistMaxDur = OtherUtil.ParseSeconds(plistDurMax);
+
+ var bwMin = p.GetValue("bwMin");
+ if (!string.IsNullOrEmpty(bwMin))
+ streamFilter.BandwidthMin = int.Parse(bwMin) * 1000;
+
+ var bwMax = p.GetValue("bwMax");
+ if (!string.IsNullOrEmpty(bwMax))
+ streamFilter.BandwidthMax = int.Parse(bwMax) * 1000;
+
+ var role = p.GetValue("role");
+ if (System.Enum.TryParse(role, true, out RoleType roleType))
+ streamFilter.Role = roleType;
+
+ return streamFilter;
+ }
+
+ ///
+ /// 分割Header
+ ///
+ ///
+ ///
+ private static Dictionary ParseHeaders(ArgumentResult result)
+ {
+ var array = result.Tokens.Select(t => t.Value).ToArray();
+ return OtherUtil.SplitHeaderArrayToDic(array);
+ }
+
+ ///
+ /// 解析混流引入的外部文件
+ ///
+ ///
+ ///
+ private static List ParseImports(ArgumentResult result)
+ {
+ var imports = new List();
+
+ foreach (var item in result.Tokens)
+ {
+ var p = new ComplexParamParser(item.Value);
+ var path = p.GetValue("path") ?? item.Value; // 若未获取到,直接整个字符串作为path
+ var lang = p.GetValue("lang");
+ var name = p.GetValue("name");
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ {
+ result.ErrorMessage = "path empty or file not exists!";
+ return imports;
+ }
+ imports.Add(new OutputFile()
+ {
+ Index = 999,
+ FilePath = path,
+ LangCode = lang,
+ Description = name
+ });
+ }
+
+ return imports;
+ }
+
+ ///
+ /// 解析混流选项
+ ///
+ ///
+ ///
+ private static MuxOptions? ParseMuxAfterDone(ArgumentResult result)
+ {
+ var v = result.Tokens[0].Value;
+ var p = new ComplexParamParser(v);
+ // 混流格式
+ var format = p.GetValue("format") ?? v.Split(':')[0]; // 若未获取到,直接:前的字符串作为format解析
+ var parseResult = System.Enum.TryParse(format.ToUpperInvariant(), out MuxFormat muxFormat);
+ if (!parseResult)
+ {
+ result.ErrorMessage = $"format={format} not valid";
+ return null;
+ }
+ // 混流器
+ var muxer = p.GetValue("muxer") ?? "ffmpeg";
+ if (muxer != "ffmpeg" && muxer != "mkvmerge")
+ {
+ result.ErrorMessage = $"muxer={muxer} not valid";
+ return null;
+ }
+ // 混流器路径
+ var bin_path = p.GetValue("bin_path") ?? "auto";
+ if (string.IsNullOrEmpty(bin_path))
+ {
+ result.ErrorMessage = $"bin_path={bin_path} not valid";
+ return null;
+ }
+ // 是否删除
+ var keep = p.GetValue("keep") ?? "false";
+ if (keep != "true" && keep != "false")
+ {
+ result.ErrorMessage = $"keep={keep} not valid";
+ return null;
+ }
+ // 是否忽略字幕
+ var skipSub = p.GetValue("skip_sub") ?? "false";
+ if (skipSub != "true" && skipSub != "false")
+ {
+ result.ErrorMessage = $"skip_sub={keep} not valid";
+ return null;
+ }
+ // 冲突检测
+ if (muxer == "mkvmerge" && format == "mp4")
+ {
+ result.ErrorMessage = $"mkvmerge can not do mp4";
+ return null;
+ }
+ return new MuxOptions()
+ {
+ UseMkvmerge = muxer == "mkvmerge",
+ MuxFormat = muxFormat,
+ KeepFiles = keep == "true",
+ SkipSubtitle = skipSub == "true",
+ BinPath = bin_path == "auto" ? null : bin_path
+ };
+ }
+
+ class MyOptionBinder : BinderBase
+ {
+ protected override MyOption GetBoundValue(BindingContext bindingContext)
+ {
+ var option = new MyOption
+ {
+ Input = bindingContext.ParseResult.GetValueForArgument(Input),
+ ForceAnsiConsole = bindingContext.ParseResult.GetValueForOption(ForceAnsiConsole),
+ NoAnsiColor = bindingContext.ParseResult.GetValueForOption(NoAnsiColor),
+ LogLevel = bindingContext.ParseResult.GetValueForOption(LogLevel),
+ AutoSelect = bindingContext.ParseResult.GetValueForOption(AutoSelect),
+ DisableUpdateCheck = bindingContext.ParseResult.GetValueForOption(DisableUpdateCheck),
+ SkipMerge = bindingContext.ParseResult.GetValueForOption(SkipMerge),
+ BinaryMerge = bindingContext.ParseResult.GetValueForOption(BinaryMerge),
+ UseFFmpegConcatDemuxer = bindingContext.ParseResult.GetValueForOption(UseFFmpegConcatDemuxer),
+ DelAfterDone = bindingContext.ParseResult.GetValueForOption(DelAfterDone),
+ AutoSubtitleFix = bindingContext.ParseResult.GetValueForOption(AutoSubtitleFix),
+ CheckSegmentsCount = bindingContext.ParseResult.GetValueForOption(CheckSegmentsCount),
+ SubtitleFormat = bindingContext.ParseResult.GetValueForOption(SubtitleFormat),
+ SubOnly = bindingContext.ParseResult.GetValueForOption(SubOnly),
+ TmpDir = bindingContext.ParseResult.GetValueForOption(TmpDir),
+ SaveDir = bindingContext.ParseResult.GetValueForOption(SaveDir),
+ SaveName = bindingContext.ParseResult.GetValueForOption(SaveName),
+ ThreadCount = bindingContext.ParseResult.GetValueForOption(ThreadCount),
+ UILanguage = bindingContext.ParseResult.GetValueForOption(UILanguage),
+ SkipDownload = bindingContext.ParseResult.GetValueForOption(SkipDownload),
+ WriteMetaJson = bindingContext.ParseResult.GetValueForOption(WriteMetaJson),
+ AppendUrlParams = bindingContext.ParseResult.GetValueForOption(AppendUrlParams),
+ SavePattern = bindingContext.ParseResult.GetValueForOption(SavePattern),
+ Keys = bindingContext.ParseResult.GetValueForOption(Keys),
+ UrlProcessorArgs = bindingContext.ParseResult.GetValueForOption(UrlProcessorArgs),
+ MP4RealTimeDecryption = bindingContext.ParseResult.GetValueForOption(MP4RealTimeDecryption),
+ UseShakaPackager = bindingContext.ParseResult.GetValueForOption(UseShakaPackager),
+ DecryptionEngine = bindingContext.ParseResult.GetValueForOption(DecryptionEngine),
+ DecryptionBinaryPath = bindingContext.ParseResult.GetValueForOption(DecryptionBinaryPath),
+ FFmpegBinaryPath = bindingContext.ParseResult.GetValueForOption(FFmpegBinaryPath),
+ KeyTextFile = bindingContext.ParseResult.GetValueForOption(KeyTextFile),
+ DownloadRetryCount = bindingContext.ParseResult.GetValueForOption(DownloadRetryCount),
+ HttpRequestTimeout = bindingContext.ParseResult.GetValueForOption(HttpRequestTimeout),
+ BaseUrl = bindingContext.ParseResult.GetValueForOption(BaseUrl),
+ MuxImports = bindingContext.ParseResult.GetValueForOption(MuxImports),
+ ConcurrentDownload = bindingContext.ParseResult.GetValueForOption(ConcurrentDownload),
+ VideoFilter = bindingContext.ParseResult.GetValueForOption(VideoFilter),
+ AudioFilter = bindingContext.ParseResult.GetValueForOption(AudioFilter),
+ SubtitleFilter = bindingContext.ParseResult.GetValueForOption(SubtitleFilter),
+ DropVideoFilter = bindingContext.ParseResult.GetValueForOption(DropVideoFilter),
+ DropAudioFilter = bindingContext.ParseResult.GetValueForOption(DropAudioFilter),
+ DropSubtitleFilter = bindingContext.ParseResult.GetValueForOption(DropSubtitleFilter),
+ LiveRealTimeMerge = bindingContext.ParseResult.GetValueForOption(LiveRealTimeMerge),
+ LiveKeepSegments = bindingContext.ParseResult.GetValueForOption(LiveKeepSegments),
+ LiveRecordLimit = bindingContext.ParseResult.GetValueForOption(LiveRecordLimit),
+ TaskStartAt = bindingContext.ParseResult.GetValueForOption(TaskStartAt),
+ LivePerformAsVod = bindingContext.ParseResult.GetValueForOption(LivePerformAsVod),
+ LivePipeMux = bindingContext.ParseResult.GetValueForOption(LivePipeMux),
+ LiveFixVttByAudio = bindingContext.ParseResult.GetValueForOption(LiveFixVttByAudio),
+ UseSystemProxy = bindingContext.ParseResult.GetValueForOption(UseSystemProxy),
+ CustomProxy = bindingContext.ParseResult.GetValueForOption(CustomProxy),
+ CustomRange = bindingContext.ParseResult.GetValueForOption(CustomRange),
+ LiveWaitTime = bindingContext.ParseResult.GetValueForOption(LiveWaitTime),
+ LiveTakeCount = bindingContext.ParseResult.GetValueForOption(LiveTakeCount),
+ NoDateInfo = bindingContext.ParseResult.GetValueForOption(NoDateInfo),
+ NoLog = bindingContext.ParseResult.GetValueForOption(NoLog),
+ AllowHlsMultiExtMap = bindingContext.ParseResult.GetValueForOption(AllowHlsMultiExtMap),
+ AdKeywords = bindingContext.ParseResult.GetValueForOption(AdKeywords),
+ MaxSpeed = bindingContext.ParseResult.GetValueForOption(MaxSpeed),
+ };
+
+ if (bindingContext.ParseResult.HasOption(CustomHLSMethod)) option.CustomHLSMethod = bindingContext.ParseResult.GetValueForOption(CustomHLSMethod);
+ if (bindingContext.ParseResult.HasOption(CustomHLSKey)) option.CustomHLSKey = bindingContext.ParseResult.GetValueForOption(CustomHLSKey);
+ if (bindingContext.ParseResult.HasOption(CustomHLSIv)) option.CustomHLSIv = bindingContext.ParseResult.GetValueForOption(CustomHLSIv);
+
+ var parsedHeaders = bindingContext.ParseResult.GetValueForOption(Headers);
+ if (parsedHeaders != null)
+ option.Headers = parsedHeaders;
+
+
+ // 以用户选择语言为准优先
+ if (option.UILanguage != null)
+ {
+ CultureInfo.DefaultThreadCurrentCulture = new CultureInfo(option.UILanguage);
+ Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(option.UILanguage);
+ Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(option.UILanguage);
+ }
+
+ // 混流设置
+ var muxAfterDoneValue = bindingContext.ParseResult.GetValueForOption(MuxAfterDone);
+ if (muxAfterDoneValue == null) return option;
+
+ option.MuxAfterDone = true;
+ option.MuxOptions = muxAfterDoneValue;
+ if (muxAfterDoneValue.UseMkvmerge) option.MkvmergeBinaryPath = muxAfterDoneValue.BinPath;
+ else option.FFmpegBinaryPath ??= muxAfterDoneValue.BinPath;
+
+ return option;
+ }
+ }
+
+
+ public static async Task InvokeArgs(string[] args, Func action)
+ {
+ var argList = new List(args);
+ var index = -1;
+ if ((index = argList.IndexOf("--morehelp")) >= 0 && argList.Count > index + 1)
+ {
+ var option = argList[index + 1];
+ var msg = option switch
+ {
+ "mux-after-done" => ResString.cmd_muxAfterDone_more,
+ "mux-import" => ResString.cmd_muxImport_more,
+ "select-video" => ResString.cmd_selectVideo_more,
+ "select-audio" => ResString.cmd_selectAudio_more,
+ "select-subtitle" => ResString.cmd_selectSubtitle_more,
+ "custom-range" => ResString.cmd_custom_range,
+ _ => $"Option=\"{option}\" not found"
+ };
+ Console.WriteLine($"More Help:\r\n\r\n --{option}\r\n\r\n" + msg);
+ Environment.Exit(0);
+ }
+
+ var rootCommand = new RootCommand(VERSION_INFO)
+ {
+ Input, TmpDir, SaveDir, SaveName, BaseUrl, ThreadCount, DownloadRetryCount, HttpRequestTimeout, ForceAnsiConsole, NoAnsiColor,AutoSelect, SkipMerge, SkipDownload, CheckSegmentsCount,
+ BinaryMerge, UseFFmpegConcatDemuxer, DelAfterDone, NoDateInfo, NoLog, WriteMetaJson, AppendUrlParams, ConcurrentDownload, Headers, /**SavePattern,**/ SubOnly, SubtitleFormat, AutoSubtitleFix,
+ FFmpegBinaryPath,
+ LogLevel, UILanguage, UrlProcessorArgs, Keys, KeyTextFile, DecryptionEngine, DecryptionBinaryPath, UseShakaPackager, MP4RealTimeDecryption,
+ MaxSpeed,
+ MuxAfterDone,
+ CustomHLSMethod, CustomHLSKey, CustomHLSIv, UseSystemProxy, CustomProxy, CustomRange, TaskStartAt,
+ LivePerformAsVod, LiveRealTimeMerge, LiveKeepSegments, LivePipeMux, LiveFixVttByAudio, LiveRecordLimit, LiveWaitTime, LiveTakeCount,
+ MuxImports, VideoFilter, AudioFilter, SubtitleFilter, DropVideoFilter, DropAudioFilter, DropSubtitleFilter, AdKeywords, DisableUpdateCheck, AllowHlsMultiExtMap, MoreHelp
+ };
+
+ rootCommand.TreatUnmatchedTokensAsErrors = true;
+ rootCommand.SetHandler(async myOption => await action(myOption), new MyOptionBinder());
+
+ var parser = new CommandLineBuilder(rootCommand)
+ .UseDefaults()
+ .EnablePosixBundling(false)
+ .UseExceptionHandler((ex, context) =>
+ {
+ try { Console.CursorVisible = true; } catch { }
+ string msg = Logger.LogLevel == Common.Log.LogLevel.DEBUG ? ex.ToString() : ex.Message;
+#if DEBUG
+ msg = ex.ToString();
+#endif
+ Logger.Error(msg);
+ Thread.Sleep(3000);
+ Environment.Exit(1);
+ }, 1)
+ .Build();
+
+ return await parser.InvokeAsync(args);
+ }
+}
diff --git a/src/N_m3u8DL-RE/CommandLine/ComplexParamParser.cs b/src/N_m3u8DL-RE/CommandLine/ComplexParamParser.cs
new file mode 100644
index 0000000..f41f06a
--- /dev/null
+++ b/src/N_m3u8DL-RE/CommandLine/ComplexParamParser.cs
@@ -0,0 +1,56 @@
+using System.Text;
+
+namespace N_m3u8DL_RE.CommandLine;
+
+internal class ComplexParamParser
+{
+ private readonly string _arg;
+ public ComplexParamParser(string arg)
+ {
+ _arg = arg;
+ }
+
+ public string? GetValue(string key)
+ {
+ if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(_arg)) return null;
+
+ try
+ {
+ var index = _arg.IndexOf(key + "=", StringComparison.Ordinal);
+ if (index == -1) return (_arg.Contains(key) && _arg.EndsWith(key)) ? "true" : null;
+
+ var chars = _arg[(index + key.Length + 1)..].ToCharArray();
+ var result = new StringBuilder();
+ char last = '\0';
+ for (int i = 0; i < chars.Length; i++)
+ {
+ if (chars[i] == ':')
+ {
+ if (last == '\\')
+ {
+ result.Replace("\\", "");
+ last = chars[i];
+ result.Append(chars[i]);
+ }
+ else break;
+ }
+ else
+ {
+ last = chars[i];
+ result.Append(chars[i]);
+ }
+ }
+
+ var resultStr = result.ToString().Trim().Trim('\"').Trim('\'');
+
+ // 不应该有引号出现
+ if (resultStr.Contains('\"') || resultStr.Contains('\'')) throw new Exception();
+
+ return resultStr;
+ }
+ catch (Exception)
+ {
+ throw new ArgumentException($"Parse Argument [{key}] failed!");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/CommandLine/MyOption.cs b/src/N_m3u8DL-RE/CommandLine/MyOption.cs
new file mode 100644
index 0000000..a3b78b8
--- /dev/null
+++ b/src/N_m3u8DL-RE/CommandLine/MyOption.cs
@@ -0,0 +1,274 @@
+using N_m3u8DL_RE.Common.Enum;
+using N_m3u8DL_RE.Common.Log;
+using N_m3u8DL_RE.Entity;
+using N_m3u8DL_RE.Enum;
+using System.Net;
+
+namespace N_m3u8DL_RE.CommandLine;
+
+internal class MyOption
+{
+ ///
+ /// See: .
+ ///
+ public string Input { get; set; } = default!;
+ ///
+ /// See: .
+ ///
+ public Dictionary Headers { get; set; } = new Dictionary();
+ ///
+ /// See: .
+ ///
+ public string[]? AdKeywords { get; set; }
+ ///
+ /// See: .
+ ///
+ public long? MaxSpeed { get; set; }
+ ///
+ /// See: .
+ ///
+ public string[]? Keys { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? BaseUrl { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? KeyTextFile { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? UrlProcessorArgs { get; set; }
+ ///
+ /// See: .
+ ///
+ public LogLevel LogLevel { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool NoDateInfo { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool NoLog { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool AllowHlsMultiExtMap { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool AutoSelect { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool DisableUpdateCheck { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool SubOnly { get; set; }
+ ///
+ /// See: .
+ ///
+ public int ThreadCount { get; set; }
+ ///
+ /// See: .
+ ///
+ public int DownloadRetryCount { get; set; }
+ ///
+ /// See: .
+ ///
+ public double HttpRequestTimeout { get; set; }
+ ///
+ /// See: .
+ ///
+ public TimeSpan? LiveRecordLimit { get; set; }
+ ///
+ /// See: .
+ ///
+ public DateTime? TaskStartAt { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool SkipMerge { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool BinaryMerge { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool ForceAnsiConsole { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool NoAnsiColor { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool UseFFmpegConcatDemuxer { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool DelAfterDone { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool AutoSubtitleFix { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool CheckSegmentsCount { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool SkipDownload { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool WriteMetaJson { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool AppendUrlParams { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool MP4RealTimeDecryption { get; set; }
+ ///
+ /// See: .
+ ///
+ [Obsolete("Use DecryptionEngine instead")]
+ public bool UseShakaPackager { get; set; }
+ ///
+ /// See: .
+ ///
+ public DecryptEngine DecryptionEngine { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool MuxAfterDone { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool ConcurrentDownload { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool LiveRealTimeMerge { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool LiveKeepSegments { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool LivePerformAsVod { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool UseSystemProxy { get; set; }
+ ///
+ /// See: .
+ ///
+ public SubtitleFormat SubtitleFormat { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? TmpDir { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? SaveDir { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? SaveName { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? SavePattern { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? UILanguage { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? DecryptionBinaryPath { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? FFmpegBinaryPath { get; set; }
+ ///
+ /// See: .
+ ///
+ public string? MkvmergeBinaryPath { get; set; }
+ ///
+ /// See: .
+ ///
+ public List? MuxImports { get; set; }
+ ///
+ /// See: .
+ ///
+ public StreamFilter? VideoFilter { get; set; }
+ ///
+ /// See: .
+ ///
+ public StreamFilter? DropVideoFilter { get; set; }
+ ///
+ /// See: .
+ ///
+ public StreamFilter? AudioFilter { get; set; }
+ ///
+ /// See: .
+ ///
+ public StreamFilter? DropAudioFilter { get; set; }
+ ///
+ /// See: .
+ ///
+ public StreamFilter? SubtitleFilter { get; set; }
+ ///
+ /// See: .
+ ///
+ public StreamFilter? DropSubtitleFilter { get; set; }
+ ///
+ /// See: .
+ ///
+ public EncryptMethod? CustomHLSMethod { get; set; }
+ ///
+ /// See: .
+ ///
+ public byte[]? CustomHLSKey { get; set; }
+ ///
+ /// See: .
+ ///
+ public byte[]? CustomHLSIv { get; set; }
+ ///
+ /// See: .
+ ///
+ public WebProxy? CustomProxy { get; set; }
+ ///
+ /// See: .
+ ///
+ public CustomRange? CustomRange { get; set; }
+ ///
+ /// See: .
+ ///
+ public int? LiveWaitTime { get; set; }
+ ///
+ /// See: .
+ ///
+ public int LiveTakeCount { get; set; }
+ public MuxOptions? MuxOptions { get; set; }
+ // public bool LiveWriteHLS { get; set; } = true;
+ ///
+ /// See: .
+ ///
+ public bool LivePipeMux { get; set; }
+ ///
+ /// See: .
+ ///
+ public bool LiveFixVttByAudio { get; set; }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Config/DownloaderConfig.cs b/src/N_m3u8DL-RE/Config/DownloaderConfig.cs
new file mode 100644
index 0000000..ca1716e
--- /dev/null
+++ b/src/N_m3u8DL-RE/Config/DownloaderConfig.cs
@@ -0,0 +1,25 @@
+using N_m3u8DL_RE.CommandLine;
+
+namespace N_m3u8DL_RE.Config;
+
+internal class DownloaderConfig
+{
+ public required MyOption MyOptions { get; set; }
+
+ ///
+ /// 前置阶段生成的文件夹名
+ ///
+ public required string DirPrefix { get; set; }
+ ///
+ /// 文件名模板
+ ///
+ public string? SavePattern { get; set; }
+ ///
+ /// 校验响应头的文件大小和实际大小
+ ///
+ public bool CheckContentLength { get; set; } = true;
+ ///
+ /// 请求头
+ ///
+ public Dictionary Headers { get; set; } = new Dictionary();
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Crypto/AESUtil.cs b/src/N_m3u8DL-RE/Crypto/AESUtil.cs
new file mode 100644
index 0000000..f1f8259
--- /dev/null
+++ b/src/N_m3u8DL-RE/Crypto/AESUtil.cs
@@ -0,0 +1,38 @@
+using System.Security.Cryptography;
+
+namespace N_m3u8DL_RE.Crypto;
+
+internal static class AESUtil
+{
+ ///
+ /// AES-128解密,解密后原地替换文件
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static void AES128Decrypt(string filePath, byte[] keyByte, byte[] ivByte, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7)
+ {
+ var fileBytes = File.ReadAllBytes(filePath);
+ var decrypted = AES128Decrypt(fileBytes, keyByte, ivByte, mode, padding);
+ File.WriteAllBytes(filePath, decrypted);
+ }
+
+ public static byte[] AES128Decrypt(byte[] encryptedBuff, byte[] keyByte, byte[] ivByte, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7)
+ {
+ byte[] inBuff = encryptedBuff;
+
+ Aes dcpt = Aes.Create();
+ dcpt.BlockSize = 128;
+ dcpt.KeySize = 128;
+ dcpt.Key = keyByte;
+ dcpt.IV = ivByte;
+ dcpt.Mode = mode;
+ dcpt.Padding = padding;
+
+ ICryptoTransform cTransform = dcpt.CreateDecryptor();
+ byte[] resultArray = cTransform.TransformFinalBlock(inBuff, 0, inBuff.Length);
+ return resultArray;
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Crypto/CSChaCha20.cs b/src/N_m3u8DL-RE/Crypto/CSChaCha20.cs
new file mode 100644
index 0000000..1fa2ca1
--- /dev/null
+++ b/src/N_m3u8DL-RE/Crypto/CSChaCha20.cs
@@ -0,0 +1,661 @@
+/*
+ * Copyright (c) 2015, 2018 Scott Bennett
+ * (c) 2018-2021 Kaarlo Räihä
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using System.Runtime.CompilerServices; // For MethodImplOptions.AggressiveInlining
+
+namespace CSChaCha20
+{
+ ///
+ /// Class that can be used for ChaCha20 encryption / decryption
+ ///
+ public sealed class ChaCha20 : IDisposable
+ {
+ ///
+ /// Only allowed key lenght in bytes
+ ///
+ public const int allowedKeyLength = 32;
+
+ ///
+ /// Only allowed nonce lenght in bytes
+ ///
+ public const int allowedNonceLength = 12;
+
+ ///
+ /// How many bytes are processed per loop
+ ///
+ public const int processBytesAtTime = 64;
+
+ private const int stateLength = 16;
+
+ ///
+ /// The ChaCha20 state (aka "context")
+ ///
+ private readonly uint[] state = new uint[stateLength];
+
+ ///
+ /// Determines if the objects in this class have been disposed of. Set to true by the Dispose() method.
+ ///
+ private bool isDisposed = false;
+
+ ///
+ /// Set up a new ChaCha20 state. The lengths of the given parameters are checked before encryption happens.
+ ///
+ ///
+ /// See ChaCha20 Spec Section 2.4 for a detailed description of the inputs.
+ ///
+ ///
+ /// A 32-byte (256-bit) key, treated as a concatenation of eight 32-bit little-endian integers
+ ///
+ ///
+ /// A 12-byte (96-bit) nonce, treated as a concatenation of three 32-bit little-endian integers
+ ///
+ ///
+ /// A 4-byte (32-bit) block counter, treated as a 32-bit little-endian integer
+ ///
+ public ChaCha20(byte[] key, byte[] nonce, uint counter)
+ {
+ this.KeySetup(key);
+ this.IvSetup(nonce, counter);
+ }
+
+#if NET6_0_OR_GREATER
+
+ ///
+ /// Set up a new ChaCha20 state. The lengths of the given parameters are checked before encryption happens.
+ ///
+ ///
+ /// See ChaCha20 Spec Section 2.4 for a detailed description of the inputs.
+ ///
+ /// A 32-byte (256-bit) key, treated as a concatenation of eight 32-bit little-endian integers
+ /// A 12-byte (96-bit) nonce, treated as a concatenation of three 32-bit little-endian integers
+ /// A 4-byte (32-bit) block counter, treated as a 32-bit little-endian integer
+ public ChaCha20(ReadOnlySpan key, ReadOnlySpan nonce, uint counter)
+ {
+ this.KeySetup(key.ToArray());
+ this.IvSetup(nonce.ToArray(), counter);
+ }
+
+#endif // NET6_0_OR_GREATER
+
+ ///
+ /// The ChaCha20 state (aka "context"). Read-Only.
+ ///
+ public uint[] State
+ {
+ get
+ {
+ return this.state;
+ }
+ }
+
+
+ // These are the same constants defined in the reference implementation.
+ // http://cr.yp.to/streamciphers/timings/estreambench/submissions/salsa20/chacha8/ref/chacha.c
+ private static readonly byte[] sigma = Encoding.ASCII.GetBytes("expand 32-byte k");
+ private static readonly byte[] tau = Encoding.ASCII.GetBytes("expand 16-byte k");
+
+ ///
+ /// Set up the ChaCha state with the given key. A 32-byte key is required and enforced.
+ ///
+ ///
+ /// A 32-byte (256-bit) key, treated as a concatenation of eight 32-bit little-endian integers
+ ///
+ private void KeySetup(byte[] key)
+ {
+ if (key == null)
+ {
+ throw new ArgumentNullException("Key is null");
+ }
+
+ if (key.Length != allowedKeyLength)
+ {
+ throw new ArgumentException($"Key length must be {allowedKeyLength}. Actual: {key.Length}");
+ }
+
+ state[4] = Util.U8To32Little(key, 0);
+ state[5] = Util.U8To32Little(key, 4);
+ state[6] = Util.U8To32Little(key, 8);
+ state[7] = Util.U8To32Little(key, 12);
+
+ byte[] constants = (key.Length == allowedKeyLength) ? sigma : tau;
+ int keyIndex = key.Length - 16;
+
+ state[8] = Util.U8To32Little(key, keyIndex + 0);
+ state[9] = Util.U8To32Little(key, keyIndex + 4);
+ state[10] = Util.U8To32Little(key, keyIndex + 8);
+ state[11] = Util.U8To32Little(key, keyIndex + 12);
+
+ state[0] = Util.U8To32Little(constants, 0);
+ state[1] = Util.U8To32Little(constants, 4);
+ state[2] = Util.U8To32Little(constants, 8);
+ state[3] = Util.U8To32Little(constants, 12);
+ }
+
+ ///
+ /// Set up the ChaCha state with the given nonce (aka Initialization Vector or IV) and block counter. A 12-byte nonce and a 4-byte counter are required.
+ ///
+ ///
+ /// A 12-byte (96-bit) nonce, treated as a concatenation of three 32-bit little-endian integers
+ ///
+ ///
+ /// A 4-byte (32-bit) block counter, treated as a 32-bit little-endian integer
+ ///
+ private void IvSetup(byte[] nonce, uint counter)
+ {
+ if (nonce == null)
+ {
+ // There has already been some state set up. Clear it before exiting.
+ Dispose();
+ throw new ArgumentNullException("Nonce is null");
+ }
+
+ if (nonce.Length != allowedNonceLength)
+ {
+ // There has already been some state set up. Clear it before exiting.
+ Dispose();
+ throw new ArgumentException($"Nonce length must be {allowedNonceLength}. Actual: {nonce.Length}");
+ }
+
+ state[12] = counter;
+ state[13] = Util.U8To32Little(nonce, 0);
+ state[14] = Util.U8To32Little(nonce, 4);
+ state[15] = Util.U8To32Little(nonce, 8);
+ }
+
+
+ #region Encryption methods
+
+ ///
+ /// Encrypt arbitrary-length byte array (input), writing the resulting byte array to preallocated output buffer.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Output byte array, must have enough bytes
+ /// Input byte array
+ /// Number of bytes to encrypt
+ public void EncryptBytes(byte[] output, byte[] input, int numBytes)
+ {
+ this.WorkBytes(output, input, numBytes);
+ }
+
+ ///
+ /// Encrypt arbitrary-length byte stream (input), writing the resulting bytes to another stream (output)
+ ///
+ /// Output stream
+ /// Input stream
+ /// How many bytes to read and write at time, default is 1024
+ public void EncryptStream(Stream output, Stream input, int howManyBytesToProcessAtTime = 1024)
+ {
+ this.WorkStreams(output, input, howManyBytesToProcessAtTime);
+ }
+
+ ///
+ /// Async encrypt arbitrary-length byte stream (input), writing the resulting bytes to another stream (output)
+ ///
+ /// Output stream
+ /// Input stream
+ /// How many bytes to read and write at time, default is 1024
+ public async Task EncryptStreamAsync(Stream output, Stream input, int howManyBytesToProcessAtTime = 1024)
+ {
+ await this.WorkStreamsAsync(output, input, howManyBytesToProcessAtTime);
+ }
+
+ ///
+ /// Encrypt arbitrary-length byte array (input), writing the resulting byte array to preallocated output buffer.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Output byte array, must have enough bytes
+ /// Input byte array
+ public void EncryptBytes(byte[] output, byte[] input)
+ {
+ this.WorkBytes(output, input, input.Length);
+ }
+
+ ///
+ /// Encrypt arbitrary-length byte array (input), writing the resulting byte array that is allocated by method.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Input byte array
+ /// Number of bytes to encrypt
+ /// Byte array that contains encrypted bytes
+ public byte[] EncryptBytes(byte[] input, int numBytes)
+ {
+ byte[] returnArray = new byte[numBytes];
+ this.WorkBytes(returnArray, input, numBytes);
+ return returnArray;
+ }
+
+ ///
+ /// Encrypt arbitrary-length byte array (input), writing the resulting byte array that is allocated by method.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Input byte array
+ /// Byte array that contains encrypted bytes
+ public byte[] EncryptBytes(byte[] input)
+ {
+ byte[] returnArray = new byte[input.Length];
+ this.WorkBytes(returnArray, input, input.Length);
+ return returnArray;
+ }
+
+ ///
+ /// Encrypt string as UTF8 byte array, returns byte array that is allocated by method.
+ ///
+ /// Here you can NOT swap encrypt and decrypt methods, because of bytes-string transform
+ /// Input string
+ /// Byte array that contains encrypted bytes
+ public byte[] EncryptString(string input)
+ {
+ byte[] utf8Bytes = System.Text.Encoding.UTF8.GetBytes(input);
+ byte[] returnArray = new byte[utf8Bytes.Length];
+
+ this.WorkBytes(returnArray, utf8Bytes, utf8Bytes.Length);
+ return returnArray;
+ }
+
+ #endregion // Encryption methods
+
+
+ #region // Decryption methods
+
+ ///
+ /// Decrypt arbitrary-length byte array (input), writing the resulting byte array to the output buffer.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Output byte array
+ /// Input byte array
+ /// Number of bytes to decrypt
+ public void DecryptBytes(byte[] output, byte[] input, int numBytes)
+ {
+ this.WorkBytes(output, input, numBytes);
+ }
+
+ ///
+ /// Decrypt arbitrary-length byte stream (input), writing the resulting bytes to another stream (output)
+ ///
+ /// Output stream
+ /// Input stream
+ /// How many bytes to read and write at time, default is 1024
+ public void DecryptStream(Stream output, Stream input, int howManyBytesToProcessAtTime = 1024)
+ {
+ this.WorkStreams(output, input, howManyBytesToProcessAtTime);
+ }
+
+ ///
+ /// Async decrypt arbitrary-length byte stream (input), writing the resulting bytes to another stream (output)
+ ///
+ /// Output stream
+ /// Input stream
+ /// How many bytes to read and write at time, default is 1024
+ public async Task DecryptStreamAsync(Stream output, Stream input, int howManyBytesToProcessAtTime = 1024)
+ {
+ await this.WorkStreamsAsync(output, input, howManyBytesToProcessAtTime);
+ }
+
+ ///
+ /// Decrypt arbitrary-length byte array (input), writing the resulting byte array to preallocated output buffer.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Output byte array, must have enough bytes
+ /// Input byte array
+ public void DecryptBytes(byte[] output, byte[] input)
+ {
+ WorkBytes(output, input, input.Length);
+ }
+
+ ///
+ /// Decrypt arbitrary-length byte array (input), writing the resulting byte array that is allocated by method.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Input byte array
+ /// Number of bytes to encrypt
+ /// Byte array that contains decrypted bytes
+ public byte[] DecryptBytes(byte[] input, int numBytes)
+ {
+ byte[] returnArray = new byte[numBytes];
+ WorkBytes(returnArray, input, numBytes);
+ return returnArray;
+ }
+
+ ///
+ /// Decrypt arbitrary-length byte array (input), writing the resulting byte array that is allocated by method.
+ ///
+ /// Since this is symmetric operation, it doesn't really matter if you use Encrypt or Decrypt method
+ /// Input byte array
+ /// Byte array that contains decrypted bytes
+ public byte[] DecryptBytes(byte[] input)
+ {
+ byte[] returnArray = new byte[input.Length];
+ WorkBytes(returnArray, input, input.Length);
+ return returnArray;
+ }
+
+ ///
+ /// Decrypt UTF8 byte array to string.
+ ///
+ /// Here you can NOT swap encrypt and decrypt methods, because of bytes-string transform
+ /// Byte array
+ /// Byte array that contains encrypted bytes
+ public string DecryptUTF8ByteArray(byte[] input)
+ {
+ byte[] tempArray = new byte[input.Length];
+
+ WorkBytes(tempArray, input, input.Length);
+ return System.Text.Encoding.UTF8.GetString(tempArray);
+ }
+
+ #endregion // Decryption methods
+
+ private void WorkStreams(Stream output, Stream input, int howManyBytesToProcessAtTime = 1024)
+ {
+ int readBytes;
+
+ byte[] inputBuffer = new byte[howManyBytesToProcessAtTime];
+ byte[] outputBuffer = new byte[howManyBytesToProcessAtTime];
+
+ while ((readBytes = input.Read(inputBuffer, 0, howManyBytesToProcessAtTime)) > 0)
+ {
+ // Encrypt or decrypt
+ WorkBytes(output: outputBuffer, input: inputBuffer, numBytes: readBytes);
+
+ // Write buffer
+ output.Write(outputBuffer, 0, readBytes);
+ }
+ }
+
+ private async Task WorkStreamsAsync(Stream output, Stream input, int howManyBytesToProcessAtTime = 1024)
+ {
+ byte[] readBytesBuffer = new byte[howManyBytesToProcessAtTime];
+ byte[] writeBytesBuffer = new byte[howManyBytesToProcessAtTime];
+ int howManyBytesWereRead = await input.ReadAsync(readBytesBuffer, 0, howManyBytesToProcessAtTime);
+
+ while (howManyBytesWereRead > 0)
+ {
+ // Encrypt or decrypt
+ WorkBytes(output: writeBytesBuffer, input: readBytesBuffer, numBytes: howManyBytesWereRead);
+
+ // Write
+ await output.WriteAsync(writeBytesBuffer, 0, howManyBytesWereRead);
+
+ // Read more
+ howManyBytesWereRead = await input.ReadAsync(readBytesBuffer, 0, howManyBytesToProcessAtTime);
+ }
+ }
+
+ ///
+ /// Encrypt or decrypt an arbitrary-length byte array (input), writing the resulting byte array to the output buffer. The number of bytes to read from the input buffer is determined by numBytes.
+ ///
+ /// Output byte array
+ /// Input byte array
+ /// How many bytes to process
+ private void WorkBytes(byte[] output, byte[] input, int numBytes)
+ {
+ if (isDisposed)
+ {
+ throw new ObjectDisposedException("state", "The ChaCha state has been disposed");
+ }
+
+ if (input == null)
+ {
+ throw new ArgumentNullException("input", "Input cannot be null");
+ }
+
+ if (output == null)
+ {
+ throw new ArgumentNullException("output", "Output cannot be null");
+ }
+
+ if (numBytes < 0 || numBytes > input.Length)
+ {
+ throw new ArgumentOutOfRangeException("numBytes", "The number of bytes to read must be between [0..input.Length]");
+ }
+
+ if (output.Length < numBytes)
+ {
+ throw new ArgumentOutOfRangeException("output", $"Output byte array should be able to take at least {numBytes}");
+ }
+
+ uint[] x = new uint[stateLength]; // Working buffer
+ byte[] tmp = new byte[processBytesAtTime]; // Temporary buffer
+ int offset = 0;
+
+ while (numBytes > 0)
+ {
+ // Copy state to working buffer
+ Buffer.BlockCopy(this.state, 0, x, 0, stateLength * sizeof(uint));
+
+ for (int i = 0; i < 10; i++)
+ {
+ QuarterRound(x, 0, 4, 8, 12);
+ QuarterRound(x, 1, 5, 9, 13);
+ QuarterRound(x, 2, 6, 10, 14);
+ QuarterRound(x, 3, 7, 11, 15);
+
+ QuarterRound(x, 0, 5, 10, 15);
+ QuarterRound(x, 1, 6, 11, 12);
+ QuarterRound(x, 2, 7, 8, 13);
+ QuarterRound(x, 3, 4, 9, 14);
+ }
+
+ for (int i = 0; i < stateLength; i++)
+ {
+ Util.ToBytes(tmp, Util.Add(x[i], this.state[i]), 4 * i);
+ }
+
+ this.state[12] = Util.AddOne(state[12]);
+ if (this.state[12] <= 0)
+ {
+ /* Stopping at 2^70 bytes per nonce is the user's responsibility */
+ this.state[13] = Util.AddOne(state[13]);
+ }
+
+ // In case these are last bytes
+ if (numBytes <= processBytesAtTime)
+ {
+ for (int i = 0; i < numBytes; i++)
+ {
+ output[i + offset] = (byte)(input[i + offset] ^ tmp[i]);
+ }
+
+ return;
+ }
+
+ for (int i = 0; i < processBytesAtTime; i++)
+ {
+ output[i + offset] = (byte)(input[i + offset] ^ tmp[i]);
+ }
+
+ numBytes -= processBytesAtTime;
+ offset += processBytesAtTime;
+ }
+ }
+
+ ///
+ /// The ChaCha Quarter Round operation. It operates on four 32-bit unsigned integers within the given buffer at indices a, b, c, and d.
+ ///
+ ///
+ /// The ChaCha state does not have four integer numbers: it has 16. So the quarter-round operation works on only four of them -- hence the name. Each quarter round operates on four predetermined numbers in the ChaCha state.
+ /// See ChaCha20 Spec Sections 2.1 - 2.2.
+ ///
+ /// A ChaCha state (vector). Must contain 16 elements.
+ /// Index of the first number
+ /// Index of the second number
+ /// Index of the third number
+ /// Index of the fourth number
+ private static void QuarterRound(uint[] x, uint a, uint b, uint c, uint d)
+ {
+ x[a] = Util.Add(x[a], x[b]);
+ x[d] = Util.Rotate(Util.XOr(x[d], x[a]), 16);
+
+ x[c] = Util.Add(x[c], x[d]);
+ x[b] = Util.Rotate(Util.XOr(x[b], x[c]), 12);
+
+ x[a] = Util.Add(x[a], x[b]);
+ x[d] = Util.Rotate(Util.XOr(x[d], x[a]), 8);
+
+ x[c] = Util.Add(x[c], x[d]);
+ x[b] = Util.Rotate(Util.XOr(x[b], x[c]), 7);
+ }
+
+ #region Destructor and Disposer
+
+ ///
+ /// Clear and dispose of the internal state. The finalizer is only called if Dispose() was never called on this cipher.
+ ///
+ ~ChaCha20()
+ {
+ Dispose(false);
+ }
+
+ ///
+ /// Clear and dispose of the internal state. Also request the GC not to call the finalizer, because all cleanup has been taken care of.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ /*
+ * The Garbage Collector does not need to invoke the finalizer because Dispose(bool) has already done all the cleanup needed.
+ */
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// This method should only be invoked from Dispose() or the finalizer. This handles the actual cleanup of the resources.
+ ///
+ ///
+ /// Should be true if called by Dispose(); false if called by the finalizer
+ ///
+ private void Dispose(bool disposing)
+ {
+ if (!isDisposed)
+ {
+ if (disposing)
+ {
+ /* Cleanup managed objects by calling their Dispose() methods */
+ }
+
+ /* Cleanup any unmanaged objects here */
+ Array.Clear(state, 0, stateLength);
+ }
+
+ isDisposed = true;
+ }
+
+ #endregion // Destructor and Disposer
+ }
+
+ ///
+ /// Utilities that are used during compression
+ ///
+ public static class Util
+ {
+ ///
+ /// n-bit left rotation operation (towards the high bits) for 32-bit integers.
+ ///
+ ///
+ ///
+ /// The result of (v LEFTSHIFT c)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static uint Rotate(uint v, int c)
+ {
+ unchecked
+ {
+ return (v << c) | (v >> (32 - c));
+ }
+ }
+
+ ///
+ /// Unchecked integer exclusive or (XOR) operation.
+ ///
+ ///
+ ///
+ /// The result of (v XOR w)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static uint XOr(uint v, uint w)
+ {
+ return unchecked(v ^ w);
+ }
+
+ ///
+ /// Unchecked integer addition. The ChaCha spec defines certain operations to use 32-bit unsigned integer addition modulo 2^32.
+ ///
+ ///
+ /// See ChaCha20 Spec Section 2.1.
+ ///
+ ///
+ ///
+ /// The result of (v + w) modulo 2^32
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static uint Add(uint v, uint w)
+ {
+ return unchecked(v + w);
+ }
+
+ ///
+ /// Add 1 to the input parameter using unchecked integer addition. The ChaCha spec defines certain operations to use 32-bit unsigned integer addition modulo 2^32.
+ ///
+ ///
+ /// See ChaCha20 Spec Section 2.1.
+ ///
+ ///
+ /// The result of (v + 1) modulo 2^32
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static uint AddOne(uint v)
+ {
+ return unchecked(v + 1);
+ }
+
+ ///
+ /// Convert four bytes of the input buffer into an unsigned 32-bit integer, beginning at the inputOffset.
+ ///
+ ///
+ ///
+ /// An unsigned 32-bit integer
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static uint U8To32Little(byte[] p, int inputOffset)
+ {
+ unchecked
+ {
+ return ((uint)p[inputOffset]
+ | ((uint)p[inputOffset + 1] << 8)
+ | ((uint)p[inputOffset + 2] << 16)
+ | ((uint)p[inputOffset + 3] << 24));
+ }
+ }
+
+ ///
+ /// Serialize the input integer into the output buffer. The input integer will be split into 4 bytes and put into four sequential places in the output buffer, starting at the outputOffset.
+ ///
+ ///
+ ///
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void ToBytes(byte[] output, uint input, int outputOffset)
+ {
+ unchecked
+ {
+ output[outputOffset] = (byte)input;
+ output[outputOffset + 1] = (byte)(input >> 8);
+ output[outputOffset + 2] = (byte)(input >> 16);
+ output[outputOffset + 3] = (byte)(input >> 24);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Crypto/ChaCha20Util.cs b/src/N_m3u8DL-RE/Crypto/ChaCha20Util.cs
new file mode 100644
index 0000000..0784b03
--- /dev/null
+++ b/src/N_m3u8DL-RE/Crypto/ChaCha20Util.cs
@@ -0,0 +1,37 @@
+using CSChaCha20;
+
+namespace N_m3u8DL_RE.Crypto;
+
+internal static class ChaCha20Util
+{
+ public static byte[] DecryptPer1024Bytes(byte[] encryptedBuff, byte[] keyBytes, byte[] nonceBytes)
+ {
+ if (keyBytes.Length != 32)
+ throw new Exception("Key must be 32 bytes!");
+ if (nonceBytes.Length != 12 && nonceBytes.Length != 8)
+ throw new Exception("Key must be 12 or 8 bytes!");
+ if (nonceBytes.Length == 8)
+ nonceBytes = (new byte[4] { 0, 0, 0, 0 }).Concat(nonceBytes).ToArray();
+
+ var decStream = new MemoryStream();
+ using BinaryReader reader = new BinaryReader(new MemoryStream(encryptedBuff));
+ using (BinaryWriter writer = new BinaryWriter(decStream))
+ while (true)
+ {
+ var buffer = reader.ReadBytes(1024);
+ byte[] dec = new byte[buffer.Length];
+ if (buffer.Length > 0)
+ {
+ ChaCha20 forDecrypting = new ChaCha20(keyBytes, nonceBytes, 0);
+ forDecrypting.DecryptBytes(dec, buffer);
+ writer.Write(dec, 0, dec.Length);
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ return decStream.ToArray();
+ }
+}
\ No newline at end of file
diff --git a/src/N_m3u8DL-RE/Directory.Build.props b/src/N_m3u8DL-RE/Directory.Build.props
new file mode 100644
index 0000000..21df491
--- /dev/null
+++ b/src/N_m3u8DL-RE/Directory.Build.props
@@ -0,0 +1,25 @@
+
+
+
+ Speed
+ true
+ true
+ full
+ link
+ true
+ true
+ zh-CN;zh-TW;en-US
+ true
+ true
+ aarch64-linux-gnu-objcopy
+
+
+
+
+
+
+
+
+
diff --git a/src/N_m3u8DL-RE/DownloadManager/HTTPLiveRecordManager.cs b/src/N_m3u8DL-RE/DownloadManager/HTTPLiveRecordManager.cs
new file mode 100644
index 0000000..563691f
--- /dev/null
+++ b/src/N_m3u8DL-RE/DownloadManager/HTTPLiveRecordManager.cs
@@ -0,0 +1,242 @@
+using N_m3u8DL_RE.Column;
+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.Config;
+using N_m3u8DL_RE.Downloader;
+using N_m3u8DL_RE.Entity;
+using N_m3u8DL_RE.Parser;
+using N_m3u8DL_RE.Util;
+using Spectre.Console;
+using System.Collections.Concurrent;
+using System.Text;
+
+namespace N_m3u8DL_RE.DownloadManager;
+
+internal class HTTPLiveRecordManager
+{
+ IDownloader Downloader;
+ DownloaderConfig DownloaderConfig;
+ StreamExtractor StreamExtractor;
+ List SelectedSteams;
+ List OutputFiles = [];
+ DateTime NowDateTime;
+ DateTime? PublishDateTime;
+ bool STOP_FLAG = false;
+ bool READ_IFO = false;
+ ConcurrentDictionary RecordingDurDic = new(); // 已录制时长
+ ConcurrentDictionary RecordingSizeDic = new(); // 已录制大小
+ CancellationTokenSource CancellationTokenSource = new(); // 取消Wait
+ List InfoBuffer = new List(188 * 5000); // 5000个分包中解析信息,没有就算了
+
+ public HTTPLiveRecordManager(DownloaderConfig downloaderConfig, List selectedSteams, StreamExtractor streamExtractor)
+ {
+ this.DownloaderConfig = downloaderConfig;
+ Downloader = new SimpleDownloader(DownloaderConfig);
+ NowDateTime = DateTime.Now;
+ PublishDateTime = selectedSteams.FirstOrDefault()?.PublishTime;
+ StreamExtractor = streamExtractor;
+ SelectedSteams = selectedSteams;
+ }
+
+ private async Task RecordStreamAsync(StreamSpec streamSpec, ProgressTask task, SpeedContainer speedContainer)
+ {
+ task.MaxValue = 1;
+ task.StartTask();
+
+ var name = streamSpec.ToShortString();
+ var dirName = $"{DownloaderConfig.MyOptions.SaveName ?? NowDateTime.ToString("yyyy-MM-dd_HH-mm-ss")}_{task.Id}_{OtherUtil.GetValidFileName(streamSpec.GroupId ?? "", "-")}_{streamSpec.Codecs}_{streamSpec.Bandwidth}_{streamSpec.Language}";
+ var saveDir = DownloaderConfig.MyOptions.SaveDir ?? Environment.CurrentDirectory;
+ var saveName = DownloaderConfig.MyOptions.SaveName != null ? $"{DownloaderConfig.MyOptions.SaveName}.{streamSpec.Language}".TrimEnd('.') : dirName;
+
+ Logger.Debug($"dirName: {dirName}; saveDir: {saveDir}; saveName: {saveName}");
+
+ // 创建文件夹
+ if (!Directory.Exists(saveDir)) Directory.CreateDirectory(saveDir);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(streamSpec.Url));
+ request.Headers.ConnectionClose = false;
+ foreach (var item in DownloaderConfig.Headers)
+ {
+ request.Headers.TryAddWithoutValidation(item.Key, item.Value);
+ }
+ Logger.Debug(request.Headers.ToString());
+
+ using var response = await HTTPUtil.AppHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, CancellationTokenSource.Token);
+ response.EnsureSuccessStatusCode();
+
+ var output = Path.Combine(saveDir, saveName + ".ts");
+ using var stream = new FileStream(output, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
+ using var responseStream = await response.Content.ReadAsStreamAsync(CancellationTokenSource.Token);
+ var buffer = new byte[16 * 1024];
+ var size = 0;
+
+ // 计时器
+ _ = TimeCounterAsync();
+ // 读取INFO
+ _ = ReadInfoAsync();
+
+ try
+ {
+ while ((size = await responseStream.ReadAsync(buffer, CancellationTokenSource.Token)) > 0)
+ {
+ if (!READ_IFO && InfoBuffer.Count < 188 * 5000)
+ {
+ InfoBuffer.AddRange(buffer);
+ }
+ speedContainer.Add(size);
+ RecordingSizeDic[task.Id] += size;
+ await stream.WriteAsync(buffer, 0, size);
+ }
+ }
+ catch (OperationCanceledException oce) when (oce.CancellationToken == CancellationTokenSource.Token)
+ {
+ ;
+ }
+
+ Logger.InfoMarkUp("File Size: " + GlobalUtil.FormatFileSize(RecordingSizeDic[task.Id]));
+
+ return true;
+ }
+
+ public async Task ReadInfoAsync()
+ {
+ while (!STOP_FLAG && !READ_IFO)
+ {
+ await Task.Delay(200);
+ if (InfoBuffer.Count < 188 * 5000) continue;
+
+ ushort ConvertToUint16(IEnumerable