Repository: streamlink/streamlink

Issue:
plugins.twitch: `source` not always considered `best`
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

It seems that in some cases Twitch is returning `Source` (as opposed to e.g. `Source (1080p60)`) as the stream quality name, and this is not being picked up by the synonym `best`.  This can be seen also in the quality selection menu in twitch.tv itself.

I know that Twitch used to do something like this a while ago, but these streams are from a few days ago.

An example of the normal (correct) behaviour:
```
[~]$ streamlink https://www.twitch.tv/videos/1511444066 best
[cli][info] Found matching plugin twitch for URL https://www.twitch.tv/videos/1511444066
[cli][info] Available streams: audio, 160p (worst), 360p, 480p, 720p60, 1080p60 (best)
[cli][info] Opening stream: 1080p60 (hls)
```

An example of the "new" (incorrect) behaviour:
```
[~]$ streamlink https://www.twitch.tv/videos/1510768607 best
[cli][info] Found matching plugin twitch for URL https://www.twitch.tv/videos/1510768607
[cli][info] Available streams: source, audio, 160p30 (worst), 360p30, 480p30, 720p30, 720p60 (best)
[cli][info] Opening stream: 720p60 (hls)
```
(in this case `source` is actually 1080p60)

I'm not sure how widespread this is on Twitch and whether it's an issue that's here to stay, but we could probably safely assume that any stream with "source" in the name is the best available, unless there's a nuance I'm missing.

I found this to be the case with a version 3 of streamlink, but I've updated to 4.1.0 via homebrew and it's the same there.

### Debug log

```text
[~]$ streamlink --loglevel debug https://www.twitch.tv/videos/1510768607 best
[cli][debug] OS:         macOS 12.4
[cli][debug] Python:     3.10.4
[cli][debug] Streamlink: 4.1.0
[cli][debug] Requests(2.27.1), Socks(1.7.1), Websocket(1.3.2)
[cli][debug] Arguments:
[cli][debug]  url=https://www.twitch.tv/videos/1510768607
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player-passthrough=['hls']
[cli][debug]  --default-stream=['best']
[cli][info] Found matching plugin twitch for URL https://www.twitch.tv/videos/1510768607
[plugins.twitch][debug] Getting HLS streams for video ID 1510768607
[utils.l10n][debug] Language code: en_GB
[cli][info] Available streams: source, audio, 160p30 (worst), 360p30, 480p30, 720p30, 720p60 (best)
[cli][info] Opening stream: 720p60 (hls)
[cli][info] Starting player: /Applications/VLC.app/Contents/MacOS/VLC
[cli.output][debug] Calling: /Applications/VLC.app/Contents/MacOS/VLC --input-title-format https://www.twitch.tv/videos/1510768607 https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/720p60/index-dvr.m3u8
```



Discussion:
The reason for this is that streams are ranked by their "name", and not by the "resolution" attribute. For most streams on Twitch, the streams are named after their resolution, but here, the input stream is named "Source", and not after its resolution, namely "1920x1080". That's why it gets ignored when ranking the streams.

The Twitch plugin needs to change its stream weighting method.

```
$ curl -sSL (streamlink --stream-url https://www.twitch.tv/videos/1510768607)
#EXTM3U
#EXT-X-TWITCH-INFO:ORIGIN="s3",B="false",REGION="EU",USER-IP="REDACTED",SERVING-ID="403b750570374f6d95c1359ba8b5a23a",CLUSTER="metro_vod",USER-COUNTRY="DE",MANIFEST-CLUSTER="metro_vod"
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="chunked",NAME="Source",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:BANDWIDTH=2830316,CODECS="avc1.64002A,mp4a.40.2",RESOLUTION=1920x1080,VIDEO="chunked"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/chunked/index-dvr.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="720p60",NAME="720p60",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:BANDWIDTH=3070556,CODECS="avc1.4D401F,mp4a.40.2",RESOLUTION=1280x720,VIDEO="720p60"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/720p60/index-dvr.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="720p30",NAME="720p30",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:BANDWIDTH=2166929,CODECS="avc1.4D401F,mp4a.40.2",RESOLUTION=1280x720,VIDEO="720p30"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/720p30/index-dvr.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="480p30",NAME="480p30",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:BANDWIDTH=1417102,CODECS="avc1.4D401E,mp4a.40.2",RESOLUTION=852x480,VIDEO="480p30"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/480p30/index-dvr.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="audio_only",NAME="Audio Only",AUTOSELECT=NO,DEFAULT=NO
#EXT-X-STREAM-INF:BANDWIDTH=216931,CODECS="mp4a.40.2",VIDEO="audio_only"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/audio_only/index-dvr.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="360p30",NAME="360p30",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:BANDWIDTH=694948,CODECS="avc1.4D401E,mp4a.40.2",RESOLUTION=640x360,VIDEO="360p30"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/360p30/index-dvr.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="160p30",NAME="160p30",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:BANDWIDTH=285241,CODECS="avc1.4D400C,mp4a.40.2",RESOLUTION=284x160,VIDEO="160p30"
https://d1ymi26ma8va5x.cloudfront.net/22d09dbe54b85b4fa0e7_sips__45632383932_1655889465/160p30/index-dvr.m3u8
```

You can always select multiple streams though, eg. `source,best`:

```
$ streamlink https://www.twitch.tv/videos/1510768607 source,best
[cli][info] Found matching plugin twitch for URL https://www.twitch.tv/videos/1510768607
[cli][info] Available streams: source, audio, 160p30 (worst), 360p30, 480p30, 720p30, 720p60 (best)
[cli][info] Opening stream: source (hls)
```

Another workaround is passing the resolved HLS URL to Streamlink again and setting the `name_key` HLS parameter for selecting a different method of naming the streams which they then can get properly ranked by:

```
$ streamlink "hls://$(streamlink --stream-url https://www.twitch.tv/videos/1510768607) name_key=pixels"
[cli][info] Found matching plugin hls for URL ... name_key=pixels
Available streams: audio, 160p (worst), 360p, 480p, 720p_alt, 720p, 1080p (best)
```

https://streamlink.github.io/cli/protocols.html#protocol-parameters

----

I'm not a fan of the whole stream ranking+selection implementation of Streamlink. This hasn't been changed since the Livestreamer fork and is in need of an update, which would be quite a big breaking change.

The stream ranking works like this: when Streamlink calls a plugin's `Plugin.streams()` method, it gets a mapping of `names->streams` for ranking the streams after calling `self._get_streams()`. This is bad, because those names are either taken directly from the various `HLSStream.parse_variant_playlist()` calls (with default `name_key` parameters - unless explicitly changed by a plugin) or `DASHStream.parse_manifest()` calls, or they are custom names for simple `HTTPStreams` or custom stream implementations set by the Plugin implementation. This makes it impossible to change the ranking afterwards, and it also is really awkward in terms of removing/renaming stream duplicates.

What should be done instead is having a proper stream metadata API and making plugins return simple list of streams. The ranking can then be done via the properly annotated metadata.

----

I'll have a look at changing the weighting of the Twitch streams later.

Resolving patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -8,6 +8,7 @@
 import json
 import logging
 import re
+import sys
 from datetime import datetime
 from random import random
 from typing import List, NamedTuple, Optional
@@ -528,6 +529,12 @@ class Twitch(Plugin):
         )
     )
 
+    @classmethod
+    def stream_weight(cls, stream):
+        if stream == "source":
+            return sys.maxsize, stream
+        return super().stream_weight(stream)
+
     def __init__(self, url):
         super().__init__(url)
         match = self.match.groupdict()


Test patch:
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -8,6 +8,7 @@
 from streamlink.plugins.twitch import Twitch, TwitchHLSStream, TwitchHLSStreamReader, TwitchHLSStreamWriter
 from tests.mixins.stream_hls import EventedHLSStreamWriter, Playlist, Segment as _Segment, Tag, TestMixinStreamHLS
 from tests.plugins import PluginCanHandleUrl
+from tests.resources import text
 
 
 class TestPluginCanHandleUrlTwitch(PluginCanHandleUrl):
@@ -95,6 +96,25 @@ class _TwitchHLSStream(TwitchHLSStream):
     __reader__ = _TwitchHLSStreamReader
 
 
+def test_stream_weight():
+    session = Streamlink()
+    Twitch.bind(session, "tests.plugins.test_twitch")
+    plugin = Twitch("http://twitch.tv/foo")
+
+    with text("hls/test_master_twitch_vod.m3u8") as fh:
+        playlist = fh.read()
+    with requests_mock.Mocker() as mocker:
+        mocker.register_uri(requests_mock.ANY, requests_mock.ANY, exc=requests_mock.exceptions.InvalidRequest)
+        mocker.request(method="GET", url="http://mocked/master.m3u8", text=playlist)
+        streams = TwitchHLSStream.parse_variant_playlist(session, "http://mocked/master.m3u8")
+    with patch.object(plugin, "_get_streams", return_value=streams):
+        data = plugin.streams()
+
+    assert list(data.keys()) == ["audio", "160p30", "360p30", "480p30", "720p30", "720p60", "source", "worst", "best"]
+    assert data["best"] is data["source"]
+    assert data["worst"] is data["160p30"]
+
+
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", MagicMock(return_value=True))
 class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
     __stream__ = _TwitchHLSStream
diff --git a/tests/resources/hls/test_master_twitch_vod.m3u8 b/tests/resources/hls/test_master_twitch_vod.m3u8
new file mode 100644
--- /dev/null
+++ b/tests/resources/hls/test_master_twitch_vod.m3u8
@@ -0,0 +1,22 @@
+#EXTM3U
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="chunked",NAME="Source",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:BANDWIDTH=2830316,CODECS="avc1.64002A,mp4a.40.2",RESOLUTION=1920x1080,VIDEO="chunked"
+source.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="720p60",NAME="720p60",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:BANDWIDTH=3070556,CODECS="avc1.4D401F,mp4a.40.2",RESOLUTION=1280x720,VIDEO="720p60"
+720p60.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="720p30",NAME="720p30",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:BANDWIDTH=2166929,CODECS="avc1.4D401F,mp4a.40.2",RESOLUTION=1280x720,VIDEO="720p30"
+720p30.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="480p30",NAME="480p30",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:BANDWIDTH=1417102,CODECS="avc1.4D401E,mp4a.40.2",RESOLUTION=852x480,VIDEO="480p30"
+480p30.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="audio_only",NAME="Audio Only",AUTOSELECT=NO,DEFAULT=NO
+#EXT-X-STREAM-INF:BANDWIDTH=216931,CODECS="mp4a.40.2",VIDEO="audio_only"
+audio_only.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="360p30",NAME="360p30",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:BANDWIDTH=694948,CODECS="avc1.4D401E,mp4a.40.2",RESOLUTION=640x360,VIDEO="360p30"
+360p30.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="160p30",NAME="160p30",AUTOSELECT=YES,DEFAULT=YES
+#EXT-X-STREAM-INF:BANDWIDTH=285241,CODECS="avc1.4D400C,mp4a.40.2",RESOLUTION=284x160,VIDEO="160p30"
+160p30.m3u8