Skip to content

Commit

Permalink
Fix M3U8.ToString() being culture dependent (#996)
Browse files Browse the repository at this point in the history
* Fix M3U8.ToString() being culture dependent

* Leave a trailing comma
  • Loading branch information
ScrubN authored Mar 20, 2024
1 parent 69f5695 commit 9a6ad9a
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 12 deletions.
66 changes: 64 additions & 2 deletions TwitchDownloaderCore.Tests/ToolTests/M3U8Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormatException>(() => M3U8.Stream.ExtByteRange.Parse(byteRangeString));
}
Expand All @@ -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<FormatException>(() => 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);
}
}
}
66 changes: 56 additions & 10 deletions TwitchDownloaderCore/Tools/M3U8.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -292,9 +295,9 @@ private void ParseAndAppendCore(ReadOnlySpan<char> 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}");
Expand Down Expand Up @@ -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() { }
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -490,9 +496,9 @@ public static ExtMediaInfo Parse(ReadOnlySpan<char> 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}");
Expand Down Expand Up @@ -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<char> text)
{
Expand Down Expand Up @@ -806,7 +827,7 @@ public static DateTimeOffset ParseDateTimeOffset(ReadOnlySpan<char> 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)
Expand Down Expand Up @@ -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<char> end)
public static void AppendIfNotDefault(StringBuilder sb, string keyName, Stream.ExtStreamInfo.StreamResolution value, ReadOnlySpan<char> end)
{
if (value == default)
return;
Expand All @@ -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)
};
}
}
}

0 comments on commit 9a6ad9a

Please sign in to comment.