From 770a4cb90fbd82363bbc75a978f80b1428a1df90 Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Fri, 15 Dec 2023 01:16:07 -0500 Subject: [PATCH 1/9] Add stream overload for M3U8.Parse --- TwitchDownloaderCore.Tests/M3U8Tests.cs | 93 ++++++++++++++++++++----- TwitchDownloaderCore/Tools/M3U8.cs | 79 ++++++++++++++++++++- 2 files changed, 153 insertions(+), 19 deletions(-) diff --git a/TwitchDownloaderCore.Tests/M3U8Tests.cs b/TwitchDownloaderCore.Tests/M3U8Tests.cs index 9251ba82..64785bfa 100644 --- a/TwitchDownloaderCore.Tests/M3U8Tests.cs +++ b/TwitchDownloaderCore.Tests/M3U8Tests.cs @@ -1,12 +1,15 @@ -using TwitchDownloaderCore.Tools; +using System.Text; +using TwitchDownloaderCore.Tools; namespace TwitchDownloaderCore.Tests { // ReSharper disable StringLiteralTypo public class M3U8Tests { - [Fact] - public void CorrectlyParsesTwitchM3U8OfTransportStreams() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CorrectlyParsesTwitchM3U8OfTransportStreams(bool useStream) { const string ExampleM3U8Twitch = "#EXTM3U" + @@ -25,7 +28,17 @@ public void CorrectlyParsesTwitchM3U8OfTransportStreams() "\n#EXTINF:10.000,\n40.ts\n#EXTINF:10.000,\n41.ts\n#EXTINF:10.000,\n42.ts\n#EXTINF:10.000,\n43.ts\n#EXTINF:10.000,\n44.ts\n#EXTINF:10.000,\n45.ts\n#EXTINF:10.000,\n46.ts\n#EXTINF:10.000,\n47.ts" + "\n#EXTINF:10.000,\n48.ts\n#EXTINF:10.000,\n49.ts\n#EXT-X-ENDLIST"; - var m3u8 = M3U8.Parse(ExampleM3U8Twitch); + M3U8 m3u8; + if (useStream) + { + var bytes = Encoding.Unicode.GetBytes(ExampleM3U8Twitch); + using var ms = new MemoryStream(bytes); + m3u8 = M3U8.Parse(ms, Encoding.Unicode); + } + else + { + m3u8 = M3U8.Parse(ExampleM3U8Twitch); + } Assert.Equal(3u, m3u8.FileMetadata.Version); Assert.Equal(10u, m3u8.FileMetadata.StreamTargetDuration); @@ -45,8 +58,10 @@ public void CorrectlyParsesTwitchM3U8OfTransportStreams() } } - [Fact] - public void CorrectlyParsesTwitchM3U8OfLiveStreams() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CorrectlyParsesTwitchM3U8OfLiveStreams(bool useStream) { const string ExampleM3U8Twitch = "#EXTM3U" + @@ -98,7 +113,17 @@ public void CorrectlyParsesTwitchM3U8OfLiveStreams() (DateTimeOffset.Parse("2023-09-17T02:32:16.242Z"), 2.000m, true, "https://video-edge-foo.bar.abs.hls.ttvnw.net/v1/segment/hij-567KLM_890.ts") }; - var m3u8 = M3U8.Parse(ExampleM3U8Twitch); + M3U8 m3u8; + if (useStream) + { + var bytes = Encoding.Unicode.GetBytes(ExampleM3U8Twitch); + using var ms = new MemoryStream(bytes); + m3u8 = M3U8.Parse(ms, Encoding.Unicode); + } + else + { + m3u8 = M3U8.Parse(ExampleM3U8Twitch); + } Assert.Equal(3u, m3u8.FileMetadata.Version); Assert.Equal(5u, m3u8.FileMetadata.StreamTargetDuration); @@ -120,8 +145,10 @@ public void CorrectlyParsesTwitchM3U8OfLiveStreams() } } - [Fact] - public void CorrectlyParsesTwitchM3U8OfPlaylists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CorrectlyParsesTwitchM3U8OfPlaylists(bool useStream) { const string ExampleM3U8Twitch = "#EXTM3U" + @@ -167,7 +194,17 @@ public void CorrectlyParsesTwitchM3U8OfPlaylists() "https://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/160p30/index-dvr.m3u8") }; - var m3u8 = M3U8.Parse(ExampleM3U8Twitch); + M3U8 m3u8; + if (useStream) + { + var bytes = Encoding.Unicode.GetBytes(ExampleM3U8Twitch); + using var ms = new MemoryStream(bytes); + m3u8 = M3U8.Parse(ms, Encoding.Unicode); + } + else + { + m3u8 = M3U8.Parse(ExampleM3U8Twitch); + } Assert.Equal(streams.Length, m3u8.Streams.Length); Assert.Equivalent(streams[0], m3u8.Streams[0], true); @@ -214,8 +251,10 @@ public void CorrectlyParsesTwitchM3U8StreamInfo(string streamInfoString, int ban Assert.Equal(framerate, streamInfo.Framerate); } - [Fact] - public void CorrectlyParsesKickM3U8OfTransportStreams() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CorrectlyParsesKickM3U8OfTransportStreams(bool useStream) { const string ExampleM3U8Kick = "#EXTM3U" + @@ -274,7 +313,17 @@ public void CorrectlyParsesKickM3U8OfTransportStreams() (DateTimeOffset.Parse("2023-11-16T05:35:07.97Z"), (1506068, 6462876), "506.ts") }; - var m3u8 = M3U8.Parse(ExampleM3U8Kick); + M3U8 m3u8; + if (useStream) + { + var bytes = Encoding.Unicode.GetBytes(ExampleM3U8Kick); + using var ms = new MemoryStream(bytes); + m3u8 = M3U8.Parse(ms, Encoding.Unicode); + } + else + { + m3u8 = M3U8.Parse(ExampleM3U8Kick); + } Assert.Equal(4u, m3u8.FileMetadata.Version); Assert.Equal(2u, m3u8.FileMetadata.StreamTargetDuration); @@ -293,8 +342,10 @@ public void CorrectlyParsesKickM3U8OfTransportStreams() } } - [Fact] - public void CorrectlyParsesKickM3U8OfPlaylists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CorrectlyParsesKickM3U8OfPlaylists(bool useStream) { const string ExampleM3U8Kick = "#EXTM3U" + @@ -335,7 +386,17 @@ public void CorrectlyParsesKickM3U8OfPlaylists() "160p30/playlist.m3u8") }; - var m3u8 = M3U8.Parse(ExampleM3U8Kick); + M3U8 m3u8; + if (useStream) + { + var bytes = Encoding.Unicode.GetBytes(ExampleM3U8Kick); + using var ms = new MemoryStream(bytes); + m3u8 = M3U8.Parse(ms, Encoding.Unicode); + } + else + { + m3u8 = M3U8.Parse(ExampleM3U8Kick); + } Assert.Equal(streams.Length, m3u8.Streams.Length); Assert.Equivalent(streams[0], m3u8.Streams[0], true); diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index 648b6ffb..47ade164 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -10,9 +10,81 @@ namespace TwitchDownloaderCore.Tools // ReSharper disable StringLiteralTypo public sealed record M3U8(M3U8.Metadata FileMetadata, M3U8.Stream[] Streams) { + public static M3U8 Parse(System.IO.Stream stream, Encoding streamEncoding, string basePath = "") + { + var sr = new StreamReader(stream, streamEncoding); + if (!ParsingHelpers.TryParseM3UHeader(sr.ReadLine(), out _)) + { + throw new FormatException("Invalid playlist, M3U header is missing."); + } + + var streams = new List(); + + Stream.ExtMediaInfo currentExtMediaInfo = null; + Stream.ExtStreamInfo currentExtStreamInfo = null; + + Metadata.Builder metadataBuilder = new(); + DateTimeOffset currentExtProgramDateTime = default; + Stream.ExtByteRange currentByteRange = default; + Stream.ExtPartInfo currentExtPartInfo = null; + + while (sr.ReadLine() is { } line) + { + if (line[0] != '#') + { + var path = Path.Combine(basePath, line); + streams.Add(new Stream(currentExtMediaInfo, currentExtStreamInfo, currentExtPartInfo, currentExtProgramDateTime, currentByteRange, path)); + currentExtMediaInfo = null; + currentExtStreamInfo = null; + currentExtProgramDateTime = default; + currentByteRange = default; + currentExtPartInfo = null; + + continue; + } + + const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; + const string STREAM_INFO_KEY = "#EXT-X-STREAM-INF:"; + const string PROGRAM_DATE_TIME_KEY = "#EXT-X-PROGRAM-DATE-TIME:"; + const string BYTE_RANGE_KEY = "#EXT-X-BYTERANGE:"; + const string PART_INFO_KEY = "#EXTINF:"; + const string END_LIST_KEY = "#EXT-X-ENDLIST"; + if (line.StartsWith(MEDIA_INFO_KEY)) + { + currentExtMediaInfo = Stream.ExtMediaInfo.Parse(line); + } + else if (line.StartsWith(STREAM_INFO_KEY)) + { + currentExtStreamInfo = Stream.ExtStreamInfo.Parse(line); + } + else if (line.StartsWith(PROGRAM_DATE_TIME_KEY)) + { + currentExtProgramDateTime = ParsingHelpers.ParseDateTimeOffset(line, PROGRAM_DATE_TIME_KEY); + } + else if (line.StartsWith(BYTE_RANGE_KEY)) + { + currentByteRange = Stream.ExtByteRange.Parse(line); + } + else if (line.StartsWith(PART_INFO_KEY)) + { + currentExtPartInfo = Stream.ExtPartInfo.Parse(line); + } + else if (line.StartsWith(END_LIST_KEY)) + { + break; + } + else + { + metadataBuilder.ParseAndAppend(line); + } + } + + return new M3U8(metadataBuilder.ToMetadata(), streams.ToArray()); + } + public static M3U8 Parse(ReadOnlySpan text, string basePath = "") { - if (!ParsingHelpers.TryParseM3UHeader(ref text)) + if (!ParsingHelpers.TryParseM3UHeader(text, out text)) { throw new FormatException("Invalid playlist, M3U header is missing."); } @@ -523,15 +595,16 @@ public static ExtPartInfo Parse(ReadOnlySpan text) private static class ParsingHelpers { - public static bool TryParseM3UHeader(ref ReadOnlySpan text) + public static bool TryParseM3UHeader(ReadOnlySpan text, out ReadOnlySpan textWithoutHeader) { const string M3U_HEADER = "#EXTM3U"; if (!text.StartsWith(M3U_HEADER)) { + textWithoutHeader = default; return false; } - text = text[7..].TrimStart(" \r\n"); + textWithoutHeader = text[7..].TrimStart(" \r\n"); return true; } From 39b9bcedbbc804d15276aff37383195098101c1b Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Tue, 19 Dec 2023 01:42:27 -0500 Subject: [PATCH 2/9] Create initial M3U8 extension methods and related tests --- .../M3U8ExtensionTests.cs | 152 ++++++++++++++++++ .../Extensions/LinqExtensions.cs | 19 +++ .../Extensions/M3U8Extensions.cs | 118 ++++++++++++++ .../Tools/M3U8StreamQualityComparer.cs | 40 +++++ 4 files changed, 329 insertions(+) create mode 100644 TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs create mode 100644 TwitchDownloaderCore/Extensions/LinqExtensions.cs create mode 100644 TwitchDownloaderCore/Extensions/M3U8Extensions.cs create mode 100644 TwitchDownloaderCore/Tools/M3U8StreamQualityComparer.cs diff --git a/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs b/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs new file mode 100644 index 00000000..acb323e8 --- /dev/null +++ b/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs @@ -0,0 +1,152 @@ +using TwitchDownloaderCore.Extensions; +using TwitchDownloaderCore.Tools; + +namespace TwitchDownloaderCore.Tests +{ + public static class M3U8ExtensionTests + { + [Theory] + [InlineData("720", "720p30")] + [InlineData("720p", "720p30")] + [InlineData("720p30", "720p30")] + [InlineData("1280x720", "720p30")] + [InlineData("1280x720p", "720p30")] + [InlineData("1280x720p30", "720p30")] + [InlineData("720p60", "720p60")] + [InlineData("1280x720p60", "720p60")] + [InlineData("1080", "1080p60")] + [InlineData("1080p", "1080p60")] + [InlineData("1080p60", "1080p60")] + [InlineData("1920x1080", "1080p60")] + [InlineData("1920x1080p", "1080p60")] + [InlineData("1920x1080p60", "1080p60")] + [InlineData("Source", "1080p60")] + public static void CorrectlyFindsStreamOfQualityFromLiveM3U8Response(string qualityString, string expectedPath) + { + var m3u8 = new M3U8(new M3U8.Metadata(), new[] + { + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "chunked", "1080p60 (source)", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1920, 1080), "chunked", 60), + "1080p60"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p60", "720p60", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p60", 60), + "720p60"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p30", "720p30", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p30", 30), + "720p30") + }); + + var selectedQuality = m3u8.GetStreamOfQuality(qualityString); + Assert.Equal(expectedPath, selectedQuality.Path); + } + + [Theory] + [InlineData("1080", "1080p60")] + [InlineData("1080p", "1080p60")] + [InlineData("1080p60", "1080p60")] + [InlineData("1920x1080", "1080p60")] + [InlineData("1920x1080p", "1080p60")] + [InlineData("1920x1080p60", "1080p60")] + [InlineData("720p60", "720p60")] + [InlineData("1280x720p60", "720p60")] + [InlineData("720", "720p30")] + [InlineData("720p", "720p30")] + [InlineData("720p30", "720p30")] + [InlineData("1280x720", "720p30")] + [InlineData("1280x720p", "720p30")] + [InlineData("1280x720p30", "720p30")] + [InlineData("audio", "audio_only")] + [InlineData("Audio", "audio_only")] + [InlineData("Audio Only", "audio_only")] + public static void CorrectlyFindsStreamOfQualityFromOldM3U8Response(string qualityString, string expectedPath) + { + var m3u8 = new M3U8(new M3U8.Metadata(), new[] + { + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "chunked", "Source", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1920, 1080), "chunked", 58.644M), + "1080p60"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p60", "720p60", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p60", 58.644M), + "720p60"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p30", "720p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p30", 28.814M), + "720p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "480p30", "480p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.42C01E,mp4a.40.2", (852, 480), "480p30", 30.159M), + "480p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "360p30", "360p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.42C01E,mp4a.40.2", (640, 360), "360p30", 30.159M), + "360p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "144p30", "144p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.42C00C,mp4a.40.2", (256, 144), "144p30", 30.159M), + "144p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "audio_only", "Audio Only", false, false), + new M3U8.Stream.ExtStreamInfo(0, 1, "mp4a.40.2", (256, 144), "audio_only", 0), + "audio_only") + }); + + var selectedQuality = m3u8.GetStreamOfQuality(qualityString); + Assert.Equal(expectedPath, selectedQuality.Path); + } + + [Theory] + [InlineData("480p60", "1080p60")] + [InlineData("852x480p60", "1080p60")] + [InlineData("Source", "1080p60")] + public static void ReturnsHighestQualityWhenDesiredQualityNotFoundForOldM3U8Response(string qualityString, string expectedPath) + { + var m3u8 = new M3U8(new M3U8.Metadata(), new[] + { + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "chunked", "Source", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1920, 1080), "chunked", 58.644M), + "1080p60"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p60", "720p60", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p60", 58.644M), + "720p60"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p30", "720p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p30", 28.814M), + "720p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "480p30", "480p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.42C01E,mp4a.40.2", (852, 480), "480p30", 30.159M), + "480p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "360p30", "360p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.42C01E,mp4a.40.2", (640, 360), "360p30", 30.159M), + "360p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "144p30", "144p", true, true), + new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.42C00C,mp4a.40.2", (256, 144), "144p30", 30.159M), + "144p30"), + new M3U8.Stream( + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "audio_only", "Audio Only", false, false), + new M3U8.Stream.ExtStreamInfo(0, 1, "mp4a.40.2", (256, 144), "audio_only", 0), + "audio_only") + }); + + var selectedQuality = m3u8.GetStreamOfQuality(qualityString); + Assert.Equal(expectedPath, selectedQuality.Path); + } + + [Fact] + public static void ThrowsWhenNoStreamsArePresent() + { + var m3u8 = new M3U8(new M3U8.Metadata(), Array.Empty()); + + Assert.Throws(() => m3u8.GetStreamOfQuality("")); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Extensions/LinqExtensions.cs b/TwitchDownloaderCore/Extensions/LinqExtensions.cs new file mode 100644 index 00000000..3edb62a6 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/LinqExtensions.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace TwitchDownloaderCore.Extensions +{ + public static class LinqExtensions + { + public static IEnumerable WhereOnlyIf(this IEnumerable enumerable, Func predicate, bool shouldFilter) + { + if (shouldFilter) + { + return enumerable.Where(predicate); + } + + return enumerable; + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Extensions/M3U8Extensions.cs b/TwitchDownloaderCore/Extensions/M3U8Extensions.cs new file mode 100644 index 00000000..dc2fcb70 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/M3U8Extensions.cs @@ -0,0 +1,118 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using TwitchDownloaderCore.Tools; + +namespace TwitchDownloaderCore.Extensions +{ + public static class M3U8Extensions + { + public static void SortStreamsByQuality(this M3U8 m3u8) + { + var streams = m3u8.Streams; + if (streams.Length == 0) + { + return; + } + + if (m3u8.Streams.Any(x => x.IsPlaylist)) + { + Array.Sort(m3u8.Streams, new M3U8StreamQualityComparer()); + } + } + + private static readonly Regex UserQualityStringRegex = new(@"(?:^|\s)(?:(?\d{3,4})x)?(?\d{3,4})p?(?\d{1,3})?(?:$|\s)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static M3U8.Stream GetStreamOfQuality(this M3U8 m3u8, string qualityString) + { + var streams = m3u8.Streams; + if (streams.Length == 0) + { + throw new ArgumentException(nameof(m3u8), "M3U8 does not contain any streams."); + } + + if (qualityString.Contains("audio", StringComparison.OrdinalIgnoreCase) && + streams.FirstOrDefault(x => x.MediaInfo.Name.Contains("audio", StringComparison.OrdinalIgnoreCase)) is { } audioStream) + { + return audioStream; + } + + if (!qualityString.Contains('x') && qualityString.Contains('p')) + { + foreach (var stream in streams) + { + if (qualityString.Equals(stream.StreamInfo.Video, StringComparison.OrdinalIgnoreCase) || qualityString.Equals(stream.MediaInfo.Name, StringComparison.OrdinalIgnoreCase)) + { + return stream; + } + } + } + + var qualityStringMatch = UserQualityStringRegex.Match(qualityString); + if (!qualityStringMatch.Success) + { + return streams.MaxBy(x => x.StreamInfo.Resolution.Width * x.StreamInfo.Resolution.Height * x.StreamInfo.Framerate); + } + + var desiredWidth = qualityStringMatch.Groups["Width"]; + var desiredHeight = qualityStringMatch.Groups["Height"]; + var desiredFramerate = qualityStringMatch.Groups["Framerate"]; + + var filteredStreams = streams + .WhereOnlyIf(x => x.StreamInfo.Resolution.Width == int.Parse(desiredWidth.ValueSpan), desiredWidth.Success) + .WhereOnlyIf(x => x.StreamInfo.Resolution.Height == int.Parse(desiredHeight.ValueSpan), desiredHeight.Success) + .WhereOnlyIf(x => Math.Abs(x.StreamInfo.Framerate - int.Parse(desiredFramerate.ValueSpan)) <= 2, desiredFramerate.Success) + .ToArray(); + + return filteredStreams.Length switch + { + 1 => filteredStreams[0], + 2 when !desiredFramerate.Success => filteredStreams.First(x => Math.Abs(x.StreamInfo.Framerate - 30) <= 2), + _ => streams.MaxBy(x => x.StreamInfo.Resolution.Width * x.StreamInfo.Resolution.Height * x.StreamInfo.Framerate) + }; + } + + /// + /// A representing the 's + /// and in the format of "{resolution}p{framerate}" or + /// + public static string GetResolutionFramerateString(this M3U8.Stream stream) + { + var mediaInfo = stream.MediaInfo; + if (mediaInfo.Name.Contains("audio", StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(mediaInfo.Name, @"\d{3,4}p\d{2,3}")) + { + return mediaInfo.Name; + } + + var streamInfo = stream.StreamInfo; + if (Regex.IsMatch(streamInfo.Video, @"\d{3,4}p\d{2,3}")) + { + return streamInfo.Video; + } + + if (Regex.IsMatch(mediaInfo.GroupId, @"\d{3,4}p\d{2,3}")) + { + return mediaInfo.GroupId; + } + + if (streamInfo.Resolution == default) + { + return ""; + } + + var frameHeight = streamInfo.Resolution.Height; + + if (streamInfo.Framerate == default) + { + return $"{frameHeight}p"; + } + + // Some M3U8 responses have framerate values up to 2fps more/less than the typical framerate. + var frameRate = (uint)(Math.Round(streamInfo.Framerate / 10) * 10); + + return $"{frameHeight}p{frameRate}"; + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/M3U8StreamQualityComparer.cs b/TwitchDownloaderCore/Tools/M3U8StreamQualityComparer.cs new file mode 100644 index 00000000..a9ec0a6a --- /dev/null +++ b/TwitchDownloaderCore/Tools/M3U8StreamQualityComparer.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace TwitchDownloaderCore.Tools +{ + public class M3U8StreamQualityComparer : IComparer + { + public int Compare(M3U8.Stream x, M3U8.Stream y) + { + if (x?.StreamInfo is null) + { + if (y?.StreamInfo is null) return 0; + return -1; + } + + if (y?.StreamInfo is null) return 1; + + var xResolution = x.StreamInfo.Resolution; + var yResolution = y.StreamInfo.Resolution; + var xTotalPixels = xResolution.Width * xResolution.Height; + var yTotalPixels = yResolution.Width * yResolution.Height; + + if (xTotalPixels < yTotalPixels) return 1; + if (xTotalPixels > yTotalPixels) return -1; + + var xFramerate = x.StreamInfo.Framerate; + var yFramerate = y.StreamInfo.Framerate; + + if (xFramerate < yFramerate) return 1; + if (xFramerate > yFramerate) return -1; + + var xBandwidth = x.StreamInfo.Bandwidth; + var yBandwidth = y.StreamInfo.Bandwidth; + + if (xBandwidth < yBandwidth) return 1; + if (xBandwidth > yBandwidth) return -1; + + return 1; + } + } +} \ No newline at end of file From 19fd92fbe599709364448c1073dd32179234ec38 Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Tue, 19 Dec 2023 01:47:30 -0500 Subject: [PATCH 3/9] Account for new Twitch M3U8 response behavior where no framerate value is present --- TwitchDownloaderCore/Tools/M3U8.cs | 8 +++++++ .../Tools/VideoSizeEstimator.cs | 4 ++-- TwitchDownloaderWPF/PageVodDownload.xaml.cs | 23 ++++++++++++------- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index 47ade164..a1acf2d9 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Text.RegularExpressions; using TwitchDownloaderCore.Extensions; namespace TwitchDownloaderCore.Tools @@ -542,6 +543,13 @@ public static ExtStreamInfo Parse(ReadOnlySpan text) text = text[(nextIndex + 1)..]; } while (true); + // Sometimes Twitch's M3U8 response lacks a Framerate value, among other things. We can just guess the framerate using the Video value. + if (streamInfo.Framerate == 0 && Regex.IsMatch(streamInfo.Video, @"p\d+$", RegexOptions.RightToLeft)) + { + var index = streamInfo.Video.LastIndexOf('p'); + streamInfo.Framerate = int.Parse(streamInfo.Video.AsSpan(index + 1)); + } + return streamInfo; } } diff --git a/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs b/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs index eec8badb..a3d4dee2 100644 --- a/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs +++ b/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs @@ -7,8 +7,8 @@ public static class VideoSizeEstimator public static string StringifyByteCount(long sizeInBytes) { const long ONE_KIBIBYTE = 1024; - const long ONE_MEBIBYTE = 1_048_576; - const long ONE_GIBIBYTE = 1_073_741_824; + const long ONE_MEBIBYTE = ONE_KIBIBYTE * 1024; + const long ONE_GIBIBYTE = ONE_MEBIBYTE * 1024; return sizeInBytes switch { diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml.cs b/TwitchDownloaderWPF/PageVodDownload.xaml.cs index 841de879..ec44883e 100644 --- a/TwitchDownloaderWPF/PageVodDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageVodDownload.xaml.cs @@ -224,26 +224,33 @@ private void UpdateVideoSizeEstimates() ? new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value) : vodLength; - for (int i = 0; i < comboQuality.Items.Count; i++) + for (var i = 0; i < comboQuality.Items.Count; i++) { var qualityWithSize = (string)comboQuality.Items[i]; - var quality = GetQualityWithoutSize(qualityWithSize).ToString(); - int bandwidth = videoQualities[quality].bandwidth; + var quality = GetQualityWithoutSize(qualityWithSize); + var bandwidth = videoQualities[quality].bandwidth; var sizeInBytes = VideoSizeEstimator.EstimateVideoSize(bandwidth, cropStart, cropEnd); - var newVideoSize = VideoSizeEstimator.StringifyByteCount(sizeInBytes); - comboQuality.Items[i] = $"{quality} - {newVideoSize}"; + if (sizeInBytes == 0) + { + comboQuality.Items[i] = quality; + } + else + { + var newVideoSize = VideoSizeEstimator.StringifyByteCount(sizeInBytes); + comboQuality.Items[i] = $"{quality} - {newVideoSize}"; + } } comboQuality.SelectedIndex = selectedIndex; } - private static ReadOnlySpan GetQualityWithoutSize(string qualityWithSize) + private static string GetQualityWithoutSize(string qualityWithSize) { var qualityIndex = qualityWithSize.LastIndexOf(" - ", StringComparison.Ordinal); return qualityIndex == -1 - ? qualityWithSize.AsSpan() - : qualityWithSize.AsSpan(0, qualityIndex); + ? qualityWithSize + : qualityWithSize[..qualityIndex]; } private void OnProgressChanged(ProgressReport progress) From 8c1c897cd8afcc7eb0a644f53c59382f9ae22822 Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Tue, 19 Dec 2023 01:49:16 -0500 Subject: [PATCH 4/9] Replace old VideoDownloader.GetQualityPlaylist quality filter with new extension method --- TwitchDownloaderCore/VideoDownloader.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/TwitchDownloaderCore/VideoDownloader.cs b/TwitchDownloaderCore/VideoDownloader.cs index d24df926..3d1a5539 100644 --- a/TwitchDownloaderCore/VideoDownloader.cs +++ b/TwitchDownloaderCore/VideoDownloader.cs @@ -11,6 +11,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using TwitchDownloaderCore.Extensions; using TwitchDownloaderCore.Options; using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects.Gql; @@ -572,17 +573,7 @@ private static Range GetStreamListCrop(IList streamList, VideoDownl var m3u8 = M3U8.Parse(playlistString); - for (var i = m3u8.Streams.Length - 1; i >= 0; i--) - { - var m3u8Stream = m3u8.Streams[i]; - if (m3u8Stream.MediaInfo.Name.StartsWith(downloadOptions.Quality, StringComparison.OrdinalIgnoreCase)) - { - return m3u8.Streams[i]; - } - } - - // Unable to find specified quality, default to highest quality - return m3u8.Streams[0]; + return m3u8.GetStreamOfQuality(downloadOptions.Quality); } /// From e00b99aed555e6fd52b54fd48968d2b81fe2cd27 Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Tue, 19 Dec 2023 01:50:22 -0500 Subject: [PATCH 5/9] Sort streams before adding them to GUI dropdown, and use extension method instead of MediaInfo.Name --- TwitchDownloaderWPF/PageVodDownload.xaml.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml.cs b/TwitchDownloaderWPF/PageVodDownload.xaml.cs index ec44883e..f9823226 100644 --- a/TwitchDownloaderWPF/PageVodDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageVodDownload.xaml.cs @@ -14,6 +14,7 @@ using System.Windows.Media.Imaging; using System.Windows.Navigation; using TwitchDownloaderCore; +using TwitchDownloaderCore.Extensions; using TwitchDownloaderCore.Options; using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects.Gql; @@ -116,14 +117,16 @@ private async Task GetVideoInfo() } var videoPlaylist = M3U8.Parse(playlistString); + videoPlaylist.SortStreamsByQuality(); //Add video qualities to combo quality foreach (var stream in videoPlaylist.Streams) { - if (!videoQualities.ContainsKey(stream.MediaInfo.Name)) + var userFriendlyName = stream.GetResolutionFramerateString(); + if (!videoQualities.ContainsKey(userFriendlyName)) { - videoQualities.Add(stream.MediaInfo.Name, (stream.Path, stream.StreamInfo.Bandwidth)); - comboQuality.Items.Add(stream.MediaInfo.Name); + videoQualities.Add(userFriendlyName, (stream.Path, stream.StreamInfo.Bandwidth)); + comboQuality.Items.Add(userFriendlyName); } } From dd2f583cf22478130c10a1ebcbeee85c88d6442d Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Tue, 19 Dec 2023 02:06:47 -0500 Subject: [PATCH 6/9] Reduce duplicate code between M3U8.Parse overloads --- TwitchDownloaderCore/Tools/M3U8.cs | 108 ++++++++++++----------------- 1 file changed, 45 insertions(+), 63 deletions(-) diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index a1acf2d9..b3c8c1fc 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -44,40 +44,10 @@ public static M3U8 Parse(System.IO.Stream stream, Encoding streamEncoding, strin continue; } - const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; - const string STREAM_INFO_KEY = "#EXT-X-STREAM-INF:"; - const string PROGRAM_DATE_TIME_KEY = "#EXT-X-PROGRAM-DATE-TIME:"; - const string BYTE_RANGE_KEY = "#EXT-X-BYTERANGE:"; - const string PART_INFO_KEY = "#EXTINF:"; - const string END_LIST_KEY = "#EXT-X-ENDLIST"; - if (line.StartsWith(MEDIA_INFO_KEY)) - { - currentExtMediaInfo = Stream.ExtMediaInfo.Parse(line); - } - else if (line.StartsWith(STREAM_INFO_KEY)) - { - currentExtStreamInfo = Stream.ExtStreamInfo.Parse(line); - } - else if (line.StartsWith(PROGRAM_DATE_TIME_KEY)) - { - currentExtProgramDateTime = ParsingHelpers.ParseDateTimeOffset(line, PROGRAM_DATE_TIME_KEY); - } - else if (line.StartsWith(BYTE_RANGE_KEY)) - { - currentByteRange = Stream.ExtByteRange.Parse(line); - } - else if (line.StartsWith(PART_INFO_KEY)) - { - currentExtPartInfo = Stream.ExtPartInfo.Parse(line); - } - else if (line.StartsWith(END_LIST_KEY)) + if (!ParseM3U8Key(line, metadataBuilder, ref currentExtMediaInfo, ref currentExtStreamInfo, ref currentExtProgramDateTime, ref currentByteRange, ref currentExtPartInfo)) { break; } - else - { - metadataBuilder.ParseAndAppend(line); - } } return new M3U8(metadataBuilder.ToMetadata(), streams.ToArray()); @@ -136,49 +106,61 @@ public static M3U8 Parse(ReadOnlySpan text, string basePath = "") continue; } - const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; - const string STREAM_INFO_KEY = "#EXT-X-STREAM-INF:"; - const string PROGRAM_DATE_TIME_KEY = "#EXT-X-PROGRAM-DATE-TIME:"; - const string BYTE_RANGE_KEY = "#EXT-X-BYTERANGE:"; - const string PART_INFO_KEY = "#EXTINF:"; - const string END_LIST_KEY = "#EXT-X-ENDLIST"; - if (workingSlice.StartsWith(MEDIA_INFO_KEY)) - { - currentExtMediaInfo = Stream.ExtMediaInfo.Parse(workingSlice); - } - else if (workingSlice.StartsWith(STREAM_INFO_KEY)) - { - currentExtStreamInfo = Stream.ExtStreamInfo.Parse(workingSlice); - } - else if (workingSlice.StartsWith(PROGRAM_DATE_TIME_KEY)) - { - currentExtProgramDateTime = ParsingHelpers.ParseDateTimeOffset(workingSlice, PROGRAM_DATE_TIME_KEY); - } - else if (workingSlice.StartsWith(BYTE_RANGE_KEY)) - { - currentByteRange = Stream.ExtByteRange.Parse(workingSlice); - } - else if (workingSlice.StartsWith(PART_INFO_KEY)) - { - currentExtPartInfo = Stream.ExtPartInfo.Parse(workingSlice); - } - else if (workingSlice.StartsWith(END_LIST_KEY)) + if (!ParseM3U8Key(workingSlice, metadataBuilder, ref currentExtMediaInfo, ref currentExtStreamInfo, ref currentExtProgramDateTime, ref currentByteRange, ref currentExtPartInfo)) { break; } - else - { - metadataBuilder.ParseAndAppend(workingSlice); - } if (lineEnd == -1) + { break; - + } } while ((textStart += lineEnd) < textEnd); return new M3U8(metadataBuilder.ToMetadata(), streams.ToArray()); } + private static bool ParseM3U8Key(ReadOnlySpan text, Metadata.Builder metadataBuilder, ref Stream.ExtMediaInfo extMediaInfo, ref Stream.ExtStreamInfo extStreamInfo, + ref DateTimeOffset extProgramDateTime, ref Stream.ExtByteRange byteRange, ref Stream.ExtPartInfo extPartInfo) + { + const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; + const string STREAM_INFO_KEY = "#EXT-X-STREAM-INF:"; + const string PROGRAM_DATE_TIME_KEY = "#EXT-X-PROGRAM-DATE-TIME:"; + const string BYTE_RANGE_KEY = "#EXT-X-BYTERANGE:"; + const string PART_INFO_KEY = "#EXTINF:"; + const string END_LIST_KEY = "#EXT-X-ENDLIST"; + if (text.StartsWith(MEDIA_INFO_KEY)) + { + extMediaInfo = Stream.ExtMediaInfo.Parse(text); + } + else if (text.StartsWith(STREAM_INFO_KEY)) + { + extStreamInfo = Stream.ExtStreamInfo.Parse(text); + } + else if (text.StartsWith(PROGRAM_DATE_TIME_KEY)) + { + extProgramDateTime = ParsingHelpers.ParseDateTimeOffset(text, PROGRAM_DATE_TIME_KEY); + } + else if (text.StartsWith(BYTE_RANGE_KEY)) + { + byteRange = Stream.ExtByteRange.Parse(text); + } + else if (text.StartsWith(PART_INFO_KEY)) + { + extPartInfo = Stream.ExtPartInfo.Parse(text); + } + else if (text.StartsWith(END_LIST_KEY)) + { + return false; + } + else + { + metadataBuilder.ParseAndAppend(text); + } + + return true; + } + public sealed record Metadata { public enum PlaylistType From 9ed312eec7efd1e49f298ebbc57041ceb80973fd Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:13:39 -0500 Subject: [PATCH 7/9] Fix test data --- TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs | 2 +- TwitchDownloaderCore/Extensions/M3U8Extensions.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs b/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs index acb323e8..eebac9a6 100644 --- a/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs +++ b/TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs @@ -34,7 +34,7 @@ public static void CorrectlyFindsStreamOfQualityFromLiveM3U8Response(string qual new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p60", 60), "720p60"), new M3U8.Stream( - new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p30", "720p30", true, true), + new M3U8.Stream.ExtMediaInfo(M3U8.Stream.ExtMediaInfo.MediaType.Video, "720p30", "720p", true, true), new M3U8.Stream.ExtStreamInfo(0, 1, "avc1.4D401F,mp4a.40.2", (1280, 720), "720p30", 30), "720p30") }); diff --git a/TwitchDownloaderCore/Extensions/M3U8Extensions.cs b/TwitchDownloaderCore/Extensions/M3U8Extensions.cs index dc2fcb70..afb4dd44 100644 --- a/TwitchDownloaderCore/Extensions/M3U8Extensions.cs +++ b/TwitchDownloaderCore/Extensions/M3U8Extensions.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using TwitchDownloaderCore.Tools; From c9cbd092a9cdbab5708188aa76d18f36afe048e3 Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:57:59 -0500 Subject: [PATCH 8/9] Use StringBuilder instead of string interpolation for ExtMediaInfo.ToString and ExtStreamInfo.ToString --- TwitchDownloaderCore/Tools/M3U8.cs | 82 ++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index b3c8c1fc..317008cc 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -366,12 +366,42 @@ public ExtMediaInfo(MediaType type, string groupId, string name, bool autoSelect public override string ToString() { + var sb = new StringBuilder("#EXT-X-MEDIA:"); + + if (Type != MediaType.Unknown) + { + sb.Append("TYPE="); + sb.Append(Type.ToString().ToUpper()); + sb.Append(","); + } + + if (GroupId != null) + { + sb.Append("GROUP-ID=\""); + sb.Append(GroupId); + sb.Append("\","); + } + + if (Name != null) + { + sb.Append("NAME=\""); + sb.Append(Name); + sb.Append("\","); + } + + sb.Append("AUTOSELECT="); + sb.Append(BooleanToWord(AutoSelect)); + sb.Append(","); + + sb.Append("DEFAULT="); + sb.Append(BooleanToWord(Default)); + + return sb.ToString(); + static string BooleanToWord(bool b) { return b ? "YES" : "NO"; } - - return $"#EXT-X-MEDIA:TYPE={Type.ToString().ToUpper()},GROUP-ID=\"{GroupId}\",NAME=\"{Name}\",AUTOSELECT={BooleanToWord(AutoSelect)},DEFAULT={BooleanToWord(Default)}"; } public static ExtMediaInfo Parse(ReadOnlySpan text) @@ -474,7 +504,53 @@ public ExtStreamInfo(int programId, int bandwidth, string codecs, StreamResoluti public string Video { get; private set; } public decimal Framerate { get; private set; } - public override string ToString() => $"#EXT-X-STREAM-INF:PROGRAM-ID={ProgramId},BANDWIDTH={Bandwidth},CODECS=\"{Codecs}\",RESOLUTION={Resolution},VIDEO=\"{Video}\",FRAME-RATE={Framerate}"; + public override string ToString() + { + var sb = new StringBuilder("#EXT-X-STREAM-INF:"); + + if (ProgramId != default) + { + sb.Append("PROGRAM-ID="); + sb.Append(ProgramId); + sb.Append(","); + } + + if (Bandwidth != default) + { + sb.Append("BANDWIDTH="); + sb.Append(Bandwidth); + sb.Append(","); + } + + if (Codecs != null) + { + sb.Append("CODECS=\""); + sb.Append(Codecs); + sb.Append("\","); + } + + if (Resolution != default) + { + sb.Append("RESOLUTION="); + sb.Append(Resolution.ToString()); + sb.Append(","); + } + + if (Video != null) + { + sb.Append("VIDEO=\""); + sb.Append(Video); + sb.Append("\","); + } + + if (Framerate != default) + { + sb.Append("FRAME-RATE="); + sb.Append(Framerate); + } + + return sb.ToString(); + } public static ExtStreamInfo Parse(ReadOnlySpan text) { From 77feddae22d72a21403f47f43a2407176020f171 Mon Sep 17 00:00:00 2001 From: ScrubN <72096833+ScrubN@users.noreply.github.com> Date: Fri, 29 Dec 2023 23:17:40 -0500 Subject: [PATCH 9/9] Better use of string constants --- TwitchDownloaderCore/Tools/M3U8.cs | 43 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index 317008cc..7814097a 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -123,17 +123,13 @@ public static M3U8 Parse(ReadOnlySpan text, string basePath = "") private static bool ParseM3U8Key(ReadOnlySpan text, Metadata.Builder metadataBuilder, ref Stream.ExtMediaInfo extMediaInfo, ref Stream.ExtStreamInfo extStreamInfo, ref DateTimeOffset extProgramDateTime, ref Stream.ExtByteRange byteRange, ref Stream.ExtPartInfo extPartInfo) { - const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; - const string STREAM_INFO_KEY = "#EXT-X-STREAM-INF:"; const string PROGRAM_DATE_TIME_KEY = "#EXT-X-PROGRAM-DATE-TIME:"; - const string BYTE_RANGE_KEY = "#EXT-X-BYTERANGE:"; - const string PART_INFO_KEY = "#EXTINF:"; const string END_LIST_KEY = "#EXT-X-ENDLIST"; - if (text.StartsWith(MEDIA_INFO_KEY)) + if (text.StartsWith(Stream.ExtMediaInfo.MEDIA_INFO_KEY)) { extMediaInfo = Stream.ExtMediaInfo.Parse(text); } - else if (text.StartsWith(STREAM_INFO_KEY)) + else if (text.StartsWith(Stream.ExtStreamInfo.STREAM_INFO_KEY)) { extStreamInfo = Stream.ExtStreamInfo.Parse(text); } @@ -141,11 +137,11 @@ private static bool ParseM3U8Key(ReadOnlySpan text, Metadata.Builder metad { extProgramDateTime = ParsingHelpers.ParseDateTimeOffset(text, PROGRAM_DATE_TIME_KEY); } - else if (text.StartsWith(BYTE_RANGE_KEY)) + else if (text.StartsWith(Stream.ExtByteRange.BYTE_RANGE_KEY)) { byteRange = Stream.ExtByteRange.Parse(text); } - else if (text.StartsWith(PART_INFO_KEY)) + else if (text.StartsWith(Stream.ExtPartInfo.PART_INFO_KEY)) { extPartInfo = Stream.ExtPartInfo.Parse(text); } @@ -316,12 +312,13 @@ public override string ToString() public readonly record struct ExtByteRange(uint Start, uint Length) { - public static implicit operator ExtByteRange((uint start, uint length) tuple) => new(tuple.start, tuple.length); - public override string ToString() => $"#EXT-X-BYTERANGE:{Start}@{Length}"; + internal const string BYTE_RANGE_KEY = "#EXT-X-BYTERANGE:"; + + public override string ToString() => $"{BYTE_RANGE_KEY}{Start}@{Length}"; public static ExtByteRange Parse(ReadOnlySpan text) { - if (text.StartsWith("#EXT-X-BYTERANGE:")) + if (text.StartsWith(BYTE_RANGE_KEY)) text = text[17..]; var separatorIndex = text.IndexOf('@'); @@ -336,6 +333,8 @@ public static ExtByteRange Parse(ReadOnlySpan text) return new ExtByteRange(start, end); } + + public static implicit operator ExtByteRange((uint start, uint length) tuple) => new(tuple.start, tuple.length); } public sealed class ExtMediaInfo @@ -347,6 +346,8 @@ public enum MediaType Audio } + internal const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; + private ExtMediaInfo() { } public ExtMediaInfo(MediaType type, string groupId, string name, bool autoSelect, bool @default) @@ -366,7 +367,7 @@ public ExtMediaInfo(MediaType type, string groupId, string name, bool autoSelect public override string ToString() { - var sb = new StringBuilder("#EXT-X-MEDIA:"); + var sb = new StringBuilder(MEDIA_INFO_KEY); if (Type != MediaType.Unknown) { @@ -408,7 +409,7 @@ public static ExtMediaInfo Parse(ReadOnlySpan text) { var mediaInfo = new ExtMediaInfo(); - if (text.StartsWith("#EXT-X-MEDIA:")) + if (text.StartsWith(MEDIA_INFO_KEY)) text = text[13..]; const string KEY_TYPE = "TYPE="; @@ -462,8 +463,6 @@ public sealed record ExtStreamInfo { public readonly record struct StreamResolution(uint Width, uint Height) { - public static implicit operator StreamResolution((uint width, uint height) tuple) => new(tuple.width, tuple.height); - public override string ToString() => $"{Width}x{Height}"; public static StreamResolution Parse(ReadOnlySpan text) @@ -483,8 +482,12 @@ public static StreamResolution Parse(ReadOnlySpan text) return new StreamResolution(width, height); } + + public static implicit operator StreamResolution((uint width, uint height) tuple) => new(tuple.width, tuple.height); } + internal const string STREAM_INFO_KEY = "#EXT-X-STREAM-INF:"; + private ExtStreamInfo() { } public ExtStreamInfo(int programId, int bandwidth, string codecs, StreamResolution resolution, string video, decimal framerate) @@ -506,7 +509,7 @@ public ExtStreamInfo(int programId, int bandwidth, string codecs, StreamResoluti public override string ToString() { - var sb = new StringBuilder("#EXT-X-STREAM-INF:"); + var sb = new StringBuilder(STREAM_INFO_KEY); if (ProgramId != default) { @@ -556,7 +559,7 @@ public static ExtStreamInfo Parse(ReadOnlySpan text) { var streamInfo = new ExtStreamInfo(); - if (text.StartsWith("#EXT-X-STREAM-INF:")) + if (text.StartsWith(STREAM_INFO_KEY)) text = text[18..]; const string KEY_PROGRAM_ID = "PROGRAM-ID="; @@ -614,6 +617,8 @@ public static ExtStreamInfo Parse(ReadOnlySpan text) public sealed record ExtPartInfo { + internal const string PART_INFO_KEY = "#EXTINF:"; + private ExtPartInfo() { } public ExtPartInfo(decimal duration, bool live) @@ -625,13 +630,13 @@ public ExtPartInfo(decimal duration, bool live) public decimal Duration { get; private set; } public bool Live { get; private set; } - public override string ToString() => $"#EXTINF:{Duration},{(Live ? "live" : "")}"; + public override string ToString() => $"{PART_INFO_KEY}{Duration},{(Live ? "live" : "")}"; public static ExtPartInfo Parse(ReadOnlySpan text) { var partInfo = new ExtPartInfo(); - if (text.StartsWith("#EXTINF:")) + if (text.StartsWith(PART_INFO_KEY)) text = text[8..]; do