Skip to content

Commit

Permalink
M3U8 improvements (#917)
Browse files Browse the repository at this point in the history
* Add stream overload for M3U8.Parse

* Create initial M3U8 extension methods and related tests

* Account for new Twitch M3U8 response behavior where no framerate value is present

* Replace old VideoDownloader.GetQualityPlaylist quality filter with new extension method

* Sort streams before adding them to GUI dropdown, and use extension method instead of MediaInfo.Name

* Reduce duplicate code between M3U8.Parse overloads

* Fix test data

* Use StringBuilder instead of string interpolation for ExtMediaInfo.ToString and ExtStreamInfo.ToString

* Better use of string constants
  • Loading branch information
ScrubN authored Dec 30, 2023
1 parent 46be5a7 commit a985078
Show file tree
Hide file tree
Showing 9 changed files with 621 additions and 87 deletions.
152 changes: 152 additions & 0 deletions TwitchDownloaderCore.Tests/M3U8ExtensionTests.cs
Original file line number Diff line number Diff line change
@@ -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", "720p", 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<M3U8.Stream>());

Assert.Throws<ArgumentException>(() => m3u8.GetStreamOfQuality(""));
}
}
}
93 changes: 77 additions & 16 deletions TwitchDownloaderCore.Tests/M3U8Tests.cs
Original file line number Diff line number Diff line change
@@ -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" +
Expand All @@ -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);
Expand All @@ -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" +
Expand Down Expand Up @@ -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);
Expand All @@ -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" +
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" +
Expand Down Expand Up @@ -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);
Expand All @@ -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" +
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions TwitchDownloaderCore/Extensions/LinqExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace TwitchDownloaderCore.Extensions
{
public static class LinqExtensions
{
public static IEnumerable<TSource> WhereOnlyIf<TSource>(this IEnumerable<TSource> enumerable, Func<TSource, bool> predicate, bool shouldFilter)
{
if (shouldFilter)
{
return enumerable.Where(predicate);
}

return enumerable;
}
}
}
Loading

0 comments on commit a985078

Please sign in to comment.