From 9a6ad9af138ca1a93c3fb4770c3d63731de52365 Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:25:22 -0400 Subject: [PATCH] Fix M3U8.ToString() being culture dependent (#996) * Fix M3U8.ToString() being culture dependent * Leave a trailing comma --- .../ToolTests/M3U8Tests.cs | 66 ++++++++++++++++++- TwitchDownloaderCore/Tools/M3U8.cs | 66 ++++++++++++++++--- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs b/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs index c0819f15..ba1f09eb 100644 --- a/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs +++ b/TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs @@ -491,7 +491,7 @@ public void CorrectlyParsesByteRange(uint start, uint length, string byteRangeSt [InlineData("429496729500@1")] [InlineData("1@429496729500")] [InlineData("42949672950000")] - public void CorrectlyThrowsFormatExceptionForBadByteRangeString(string byteRangeString) + public void ThrowsFormatExceptionForBadByteRangeString(string byteRangeString) { Assert.Throws(() => M3U8.Stream.ExtByteRange.Parse(byteRangeString)); } @@ -512,9 +512,71 @@ public void CorrectlyParsesResolution(uint start, uint length, string byteRangeS [InlineData("429496729500x1")] [InlineData("1x429496729500")] [InlineData("42949672950000")] - public void CorrectlyThrowsFormatExceptionForBadResolutionString(string byteRangeString) + public void ThrowsFormatExceptionForBadResolutionString(string byteRangeString) { Assert.Throws(() => M3U8.Stream.ExtStreamInfo.StreamResolution.Parse(byteRangeString)); } + + + [Theory] + [InlineData("en-GB")] + [InlineData("tr-TR")] + [InlineData("ru-RU")] + public void CorrectlyStringifiesInvariantOfCulture(string culture) + { + const string EXAMPLE_M3U8 = + "#EXTM3U" + + "\n#EXT-X-TWITCH-INFO:ORIGIN=\"s3\",B=\"false\",REGION=\"NA\",USER-IP=\"255.255.255.255\",SERVING-ID=\"123abc456def789ghi012jkl345mno67\",CLUSTER=\"cloudfront_vod\",USER-COUNTRY=\"US\",MANIFEST-CLUSTER=\"cloudfront_vod\"" + + "\n#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"chunked\",NAME=\"1080p60\",AUTOSELECT=NO,DEFAULT=NO" + + "\n#EXT-X-STREAM-INF:BANDWIDTH=5898203,CODECS=\"avc1.64002A,mp4a.40.2\",RESOLUTION=1920x1080,VIDEO=\"chunked\",FRAME-RATE=59.995" + + "\nhttps://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/chunked/index-dvr.m3u8" + + "\n#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"720p60\",NAME=\"720p60\",AUTOSELECT=YES,DEFAULT=YES" + + "\n#EXT-X-STREAM-INF:BANDWIDTH=3443956,CODECS=\"avc1.4D0020,mp4a.40.2\",RESOLUTION=1280x720,VIDEO=\"720p60\",FRAME-RATE=59.995" + + "\nhttps://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/720p60/index-dvr.m3u8" + + "\n#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"480p30\",NAME=\"480p\",AUTOSELECT=YES,DEFAULT=YES" + + "\n#EXT-X-STREAM-INF:BANDWIDTH=1454397,CODECS=\"avc1.4D001F,mp4a.40.2\",RESOLUTION=852x480,VIDEO=\"480p30\",FRAME-RATE=29.998" + + "\nhttps://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/480p30/index-dvr.m3u8" + + "\n#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"audio_only\",NAME=\"Audio Only\",AUTOSELECT=NO,DEFAULT=NO" + + "\n#EXT-X-STREAM-INF:BANDWIDTH=220328,CODECS=\"mp4a.40.2\",VIDEO=\"audio_only\"" + + "\nhttps://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/audio_only/index-dvr.m3u8" + + "\n#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"360p30\",NAME=\"360p\",AUTOSELECT=YES,DEFAULT=YES" + + "\n#EXT-X-STREAM-INF:BANDWIDTH=708016,CODECS=\"avc1.4D001E,mp4a.40.2\",RESOLUTION=640x360,VIDEO=\"360p30\",FRAME-RATE=29.998" + + "\nhttps://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/360p30/index-dvr.m3u8" + + "\n#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"160p30\",NAME=\"160p\",AUTOSELECT=YES,DEFAULT=YES" + + "\n#EXT-X-STREAM-INF:BANDWIDTH=288409,CODECS=\"avc1.4D000C,mp4a.40.2\",RESOLUTION=284x160,VIDEO=\"160p30\",FRAME-RATE=29.998" + + "\nhttps://abc123def456gh.cloudfront.net/123abc456def789ghi01_streamer42_12345678901_1234567890/160p30/index-dvr.m3u8" + + "\n#EXT-X-VERSION:4" + + "\n#EXT-X-MEDIA-SEQUENCE:0" + + "\n#EXT-X-TARGETDURATION:2" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:07.97Z\n#EXT-X-BYTERANGE:1601196@6470396\n#EXTINF:2.000,\n500.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:09.97Z\n#EXT-X-BYTERANGE:1588224@0\n#EXTINF:2.000,\n501.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:11.97Z\n#EXT-X-BYTERANGE:1579200@1588224\n#EXTINF:2.000,\n501.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:13.97Z\n#EXT-X-BYTERANGE:1646128@3167424\n#EXTINF:2.000,\n501.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:15.97Z\n#EXT-X-BYTERANGE:1587472@4813552\n#EXTINF:2.000,\n501.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:17.97Z\n#EXT-X-BYTERANGE:1594052@6401024\n#EXTINF:2.000,\n501.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:19.97Z\n#EXT-X-BYTERANGE:1851236@0\n#EXTINF:2.000,\n502.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:21.97Z\n#EXT-X-BYTERANGE:1437448@1851236\n#EXTINF:2.000,\n502.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:23.97Z\n#EXT-X-BYTERANGE:1535960@3288684\n#EXTINF:2.000,\n502.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:25.97Z\n#EXT-X-BYTERANGE:1568672@4824644\n#EXTINF:2.000,\n502.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:27.97Z\n#EXT-X-BYTERANGE:1625824@6393316\n#EXTINF:2.000,\n502.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:29.97Z\n#EXT-X-BYTERANGE:1583524@0\n#EXTINF:2.000,\n503.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:31.97Z\n#EXT-X-BYTERANGE:1597060@1583524\n#EXTINF:2.000,\n503.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:33.97Z\n#EXT-X-BYTERANGE:1642368@3180584\n#EXTINF:2.000,\n503.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:35.97Z\n#EXT-X-BYTERANGE:1556076@4822952\n#EXTINF:2.000,\n503.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:37.97Z\n#EXT-X-BYTERANGE:1669252@6379028\n#EXTINF:2.000,\n503.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:39.97Z\n#EXT-X-BYTERANGE:1544984@0\n#EXTINF:2.000,\n504.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:41.97Z\n#EXT-X-BYTERANGE:1601384@1544984\n#EXTINF:2.000,\n504.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:43.97Z\n#EXT-X-BYTERANGE:1672260@3146368\n#EXTINF:2.000,\n504.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:45.97Z\n#EXT-X-BYTERANGE:1623192@4818628\n#EXTINF:2.000,\n504.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:47.97Z\n#EXT-X-BYTERANGE:1526748@6441820\n#EXTINF:2.000,\n504.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:49.97Z\n#EXT-X-BYTERANGE:1731668@0\n#EXTINF:2.000,\n505.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:51.97Z\n#EXT-X-BYTERANGE:1454368@1731668\n#EXTINF:2.000,\n505.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:53.97Z\n#EXT-X-BYTERANGE:1572432@3186036\n#EXTINF:2.000,\n505.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:55.97Z\n#EXT-X-BYTERANGE:1625824@4758468\n#EXTINF:2.000,\n505.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:57.97Z\n#EXT-X-BYTERANGE:1616988@6384292\n#EXTINF:2.000,\n505.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:34:59.97Z\n#EXT-X-BYTERANGE:1632028@0\n#EXTINF:2.000,\n506.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:35:01.97Z\n#EXT-X-BYTERANGE:1543668@1632028\n#EXTINF:2.000,\n506.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:35:03.97Z\n#EXT-X-BYTERANGE:1768140@3175696\n#EXTINF:2.000,\n506.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:35:05.97Z\n#EXT-X-BYTERANGE:1519040@4943836\n#EXTINF:2.000,\n506.ts" + + "\n#EXT-X-PROGRAM-DATE-TIME:2023-11-16T05:35:07.97Z\n#EXT-X-BYTERANGE:1506068@6462876\n#EXTINF:2.000,\n506.ts\n#EXT-X-ENDLIST"; + + var m3u8 = M3U8.Parse(EXAMPLE_M3U8); + + var oldCulture = CultureInfo.CurrentCulture; + + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + var stringExpected = m3u8.ToString(); + CultureInfo.CurrentCulture = new CultureInfo(culture); + var stringActual = m3u8.ToString(); + + CultureInfo.CurrentCulture = oldCulture; + + Assert.Equal(stringExpected, stringActual); + } } } \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/M3U8.cs b/TwitchDownloaderCore/Tools/M3U8.cs index 1391701a..7c94d65f 100644 --- a/TwitchDownloaderCore/Tools/M3U8.cs +++ b/TwitchDownloaderCore/Tools/M3U8.cs @@ -202,6 +202,9 @@ public enum PlaylistType Event } + internal const string PLAYLIST_TYPE_VOD = "VOD"; + internal const string PLAYLIST_TYPE_EVENT = "EVENT"; + private const string TARGET_VERSION_KEY = "#EXT-X-VERSION:"; private const string TARGET_DURATION_KEY = "#EXT-X-TARGETDURATION:"; private const string PLAYLIST_TYPE_KEY = "#EXT-X-PLAYLIST-TYPE:"; @@ -236,7 +239,7 @@ public override string ToString() if (Type != PlaylistType.Unknown) { sb.Append(PLAYLIST_TYPE_KEY); - sb.Append(Type.ToString().ToUpper()); + sb.Append(Type.AsString()); sb.Append(itemSeparator); } @@ -292,9 +295,9 @@ private void ParseAndAppendCore(ReadOnlySpan text) { _metadata ??= new Metadata(); var temp = text[PLAYLIST_TYPE_KEY.Length..]; - if (temp.StartsWith("VOD")) + if (temp.StartsWith(PLAYLIST_TYPE_VOD)) _metadata.Type = PlaylistType.Vod; - else if (temp.StartsWith("EVENT")) + else if (temp.StartsWith(PLAYLIST_TYPE_EVENT)) _metadata.Type = PlaylistType.Event; else throw new FormatException($"Unable to parse PlaylistType from: {text}"); @@ -422,6 +425,9 @@ public enum MediaType Audio } + internal const string MEDIA_TYPE_VIDEO = "VIDEO"; + internal const string MEDIA_TYPE_AUDIO = "AUDIO"; + internal const string MEDIA_INFO_KEY = "#EXT-X-MEDIA:"; private ExtMediaInfo() { } @@ -449,7 +455,7 @@ public override string ToString() if (Type != MediaType.Unknown) { sb.Append("TYPE="); - sb.Append(Type.ToString().ToUpper()); + sb.Append(Type.AsString()); sb.Append(keyValueSeparator); } @@ -490,9 +496,9 @@ public static ExtMediaInfo Parse(ReadOnlySpan text) if (text.StartsWith(KEY_TYPE)) { var temp = text[KEY_TYPE.Length..]; - if (temp.StartsWith("VIDEO")) + if (temp.StartsWith(MEDIA_TYPE_VIDEO)) mediaInfo.Type = MediaType.Video; - else if (temp.StartsWith("AUDIO")) + else if (temp.StartsWith(MEDIA_TYPE_AUDIO)) mediaInfo.Type = MediaType.Audio; else throw new FormatException($"Unable to parse MediaType from: {text}"); @@ -663,7 +669,22 @@ public ExtPartInfo(decimal duration, bool live) public decimal Duration { get; private set; } public bool Live { get; private set; } - public override string ToString() => $"{PART_INFO_KEY}{Duration},{(Live ? "live" : "")}"; + public override string ToString() + { + var sb = new StringBuilder(PART_INFO_KEY); + + sb.Append(Duration.ToString(CultureInfo.InvariantCulture)); + + // Twitch leaves a trailing comma, so we will too. + sb.Append(','); + + if (Live) + { + sb.Append("live"); + } + + return sb.ToString(); + } public static ExtPartInfo Parse(ReadOnlySpan text) { @@ -806,7 +827,7 @@ public static DateTimeOffset ParseDateTimeOffset(ReadOnlySpan text, ReadOn var temp = text[keyName.Length..]; temp = temp[..NextKeyStart(temp)]; - if (DateTimeOffset.TryParse(temp, out var dateTimeOffset)) + if (DateTimeOffset.TryParse(temp, null, DateTimeStyles.AssumeUniversal, out var dateTimeOffset)) return dateTimeOffset; if (!strict) @@ -854,11 +875,11 @@ public static void AppendIfNotDefault(StringBuilder sb, string keyName, decimal return; sb.Append(keyName); - sb.Append(value); + sb.Append(value.ToString(CultureInfo.InvariantCulture)); sb.Append(end); } - public static void AppendIfNotDefault(StringBuilder sb, string keyName, M3U8.Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan end) + public static void AppendIfNotDefault(StringBuilder sb, string keyName, Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan end) { if (value == default) return; @@ -885,4 +906,29 @@ public static void AppendStringIfNotNullOrEmpty(StringBuilder sb, string keyName } } } + + public static class EnumExtensions + { + public static string AsString(this M3U8.Stream.ExtMediaInfo.MediaType mediaType) + { + return mediaType switch + { + M3U8.Stream.ExtMediaInfo.MediaType.Unknown => null, + M3U8.Stream.ExtMediaInfo.MediaType.Video => M3U8.Stream.ExtMediaInfo.MEDIA_TYPE_VIDEO, + M3U8.Stream.ExtMediaInfo.MediaType.Audio => M3U8.Stream.ExtMediaInfo.MEDIA_TYPE_AUDIO, + _ => throw new ArgumentOutOfRangeException(nameof(mediaType), mediaType, null) + }; + } + + public static string AsString(this M3U8.Metadata.PlaylistType playlistType) + { + return playlistType switch + { + M3U8.Metadata.PlaylistType.Unknown => null, + M3U8.Metadata.PlaylistType.Vod => M3U8.Metadata.PLAYLIST_TYPE_VOD, + M3U8.Metadata.PlaylistType.Event => M3U8.Metadata.PLAYLIST_TYPE_EVENT, + _ => throw new ArgumentOutOfRangeException(nameof(playlistType), playlistType, null) + }; + } + } } \ No newline at end of file