Repository: streamlink/streamlink (python)

Issue:
plugins.twitch: audio_only and framerates missing

### Checklist

- [x] This is a bug report and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [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 bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [x] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Collaboration

- [x] [I will provide feedback should a pull request be opened with a fix for the problem](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback)

### Streamlink version

streamlink 8.2.0+42.gd42750ab

### Description

````
> streamlink --no-config --version
streamlink 8.2.0+42.gd42750ab
````

````
> streamlink --stream-url https://www.twitch.tv/Quin69 audio_only
error: The specified stream(s) 'audio_only' could not be found.
       Available streams: 160k (worst), 160p, 360p, 480p, 720p, 1080p (best)
````

I am not sure if it is a general problem because of the refactors or if it is a twitch only issue.  What i can say streamlink 8.2.0 still offers audio_only while current git doesn't.
Thanks for the amazing work with this little tool.

### Debug/trace log

```text
> streamlink --loglevel=trace --stream-url https://www.twitch.tv/Quin69 audio_only
error: The specified stream(s) 'audio_only' could not be found.
       Available streams: 160k (worst), 160p, 360p, 480p, 720p, 1080p (best)
```


Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index ef242d95778..47d15fb3a4f 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -45,6 +45,7 @@
     HLSStreamWorker,
     HLSStreamWriter,
     M3U8Parser,
+    Media,
     parse_tag,
 )
 from streamlink.stream.http import HTTPStream
@@ -153,6 +154,28 @@ def get_segment(self, uri: str, **data) -> TwitchHLSSegment:
 
         return segment
 
+    def get_playlist(self, *args, **kwargs):
+        streaminf = self._streaminf or {}
+        playlist = super().get_playlist(*args, **kwargs)
+        # backwards compatibility for stream names on Usher v2
+        if not playlist.media and (name := streaminf.get("IVS-NAME")):
+            is_audio_only = name in ("audio_only", "audio")  # live + VOD
+            media = Media(
+                uri=None,
+                type="VIDEO",
+                group_id=name,
+                language=None,
+                name=name,
+                default=not is_audio_only,
+                autoselect=not is_audio_only,
+                forced=False,
+                characteristics=None,
+            )
+            playlist.stream_info.video = name
+            playlist.media.append(media)
+
+        return playlist
+
     def _is_segment_ad(self, date: datetime | None, title: str | None = None) -> bool:
         return (
             title is not None and "Amazon" in title


Test patch:
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
index 30b22a23534..37751907554 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -189,6 +189,15 @@ def test_stream_weight(requests_mock: rm.Mocker, session: Streamlink):
     assert data["worst"] is data["160p30"]
 
 
+def test_multivariant(session: Streamlink, requests_mock: rm.Mocker):
+    with text("hls/test_multivariant_twitch_usher_v2.m3u8") as fd:
+        content = fd.read()
+        requests_mock.get("http://mocked/multivariant.m3u8", text=content)
+        streams = TwitchHLSStream.parse_variant_playlist(session, "http://mocked/multivariant.m3u8")
+
+    assert sorted(streams.keys()) == ["1080p60", "160p", "360p", "480p", "720p60", "audio_only"]
+
+
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", MagicMock(return_value=True))
 class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
     __stream__ = _TwitchHLSStream
diff --git a/tests/resources/hls/test_multivariant_twitch_usher_v2.m3u8 b/tests/resources/hls/test_multivariant_twitch_usher_v2.m3u8
new file mode 100644
index 00000000000..809bdad776a
--- /dev/null
+++ b/tests/resources/hls/test_multivariant_twitch_usher_v2.m3u8
@@ -0,0 +1,13 @@
+#EXTM3U
+#EXT-X-STREAM-INF:BANDWIDTH=3422999,RESOLUTION=1280x720,CODECS="avc1.4D401F,mp4a.40.2",FRAME-RATE=60.000,STABLE-VARIANT-ID="720p60",IVS-NAME="720p60",IVS-VARIANT-SOURCE="transcode"
+https://mocked/720p60.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1427999,RESOLUTION=852x480,CODECS="avc1.4D401F,mp4a.40.2",FRAME-RATE=30.000,STABLE-VARIANT-ID="480p30",IVS-NAME="480p",IVS-VARIANT-SOURCE="transcode"
+https://mocked/480p,m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=630000,RESOLUTION=640x360,CODECS="avc1.4D401F,mp4a.40.2",FRAME-RATE=30.000,STABLE-VARIANT-ID="360p30",IVS-NAME="360p",IVS-VARIANT-SOURCE="transcode"
+https://mocked/360p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=230000,RESOLUTION=284x160,CODECS="avc1.4D401F,mp4a.40.2",FRAME-RATE=30.000,STABLE-VARIANT-ID="160p30",IVS-NAME="160p",IVS-VARIANT-SOURCE="transcode"
+https://mocked/160p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=160000,CODECS="mp4a.40.2",STABLE-VARIANT-ID="audio_only",IVS-NAME="audio_only",IVS-VARIANT-SOURCE="transcode"
+https://mocked/audio_only.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=9146586,RESOLUTION=1920x1080,CODECS="avc1.64002A,mp4a.40.2",FRAME-RATE=60.000,STABLE-VARIANT-ID="1080p60",IVS-NAME="1080p60",IVS-VARIANT-SOURCE="source"
+https://mocked/1080p60.m3u8