Repository: streamlink/streamlink

Issue:
--title switch with variable arguments prevents Twitch rerun vod from playing
<!--
Thanks for reporting a bug!
USE THE TEMPLATE. Otherwise your bug report may be rejected.

First, see the contribution guidelines:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Also check the list of open and closed bug reports:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Bug Report

<!-- Replace [ ] with [x] in order to check the box -->
- [x] This is a bug report and I have read the contribution guidelines.


### Description

<!-- Explain the bug as thoroughly as you can. Don't leave out information which is necessary for us to reproduce and debug this issue. --> If you use the --title switch with arguments that contain variables like {author}, {category}, or {title}, streamlink cannot play certain twitch streams (reruns, I think)

### Expected / Actual behavior

<!-- What do you expect to happen, and what is actually happening? -->
I expect streamlink to open the twitch stream in my player of choice and start playing. Instead, I get errors, nothing plays, and streamlink quits.

### Reproduction steps / Explicit stream URLs to test

<!-- How can we reproduce this? Please note the exact steps below using the list format supplied. If you need more steps please add them. -->

1. Find a twitch channel that is playing reruns, for example: reckful
2. Launch using a command like: streamlink --loglevel debug --verbose-player --player=mpv --title "{author}" twitch.tv/reckful best
3. See log output


### Log output

<!--
TEXT LOG OUTPUT IS REQUIRED for a bug report!
Use the `--loglevel debug` parameter and avoid using parameters which suppress log output.
https://streamlink.github.io/cli.html#cmdoption-l

Make sure to **remove usernames and passwords**
You can copy the output to https://gist.github.com/ or paste it below.
-->

```
[azizLIGHT@fractal ~]$ /home/azizLIGHT/Documents/Scripts/streamlink-pip/bin/streamlink --loglevel debug --verbose-player --player=mpv --title "{author}" twitch.tv/reckful best
[cli][debug] OS:         Linux-5.4.52-1-MANJARO-x86_64-with-glibc2.2.5
[cli][debug] Python:     3.8.3
[cli][debug] Streamlink: 1.5.0+31.g1913115
[cli][debug] Requests(2.24.0), Socks(1.7.1), Websocket(0.57.0)
[cli][info] Found matching plugin twitch for URL twitch.tv/reckful
[plugin.twitch][debug] Getting live HLS streams for reckful
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p, 720p60_alt, 720p60 (best)
[cli][info] Opening stream: 720p60 (hls)
[stream.hls][debug] Reloading playlist
[stream.hls][debug] First Sequence: 3689; Last Sequence: 3702
[stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 3700; End Sequence: None
[stream.hls][debug] Adding segment 3700 to queue
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] Adding segment 3701 to queue
[stream.hls][debug] Adding segment 3702 to queue
[stream.hls][debug] Download of segment 3700 complete
[stream.hls][debug] Download of segment 3701 complete
[stream.hls][debug] Download of segment 3702 complete
[cli][info] Closing currently open stream...
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
Traceback (most recent call last):
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/bin/streamlink", line 11, in <module>
    load_entry_point('streamlink==1.5.0+31.g1913115', 'console_scripts', 'streamlink')()
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 1027, in main
    handle_url()
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 602, in handle_url
    handle_stream(plugin, streams, stream_name)
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 455, in handle_stream
    success = output_stream(plugin, stream)
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 316, in output_stream
    output = create_output(plugin)
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 114, in create_output
    title = create_title(plugin)
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 148, in create_title
    title = LazyFormatter.format(
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink/utils/lazy_formatter.py", line 22, in format
    return string.Formatter().vformat(fmt, (), cls(**lazy_props))
  File "/usr/lib/python3.8/string.py", line 167, in vformat
    result, _ = self._vformat(format_string, args, kwargs, used_args, 2)
  File "/usr/lib/python3.8/string.py", line 207, in _vformat
    obj, arg_used = self.get_field(field_name, args, kwargs)
  File "/usr/lib/python3.8/string.py", line 272, in get_field
    obj = self.get_value(first, args, kwargs)
  File "/usr/lib/python3.8/string.py", line 229, in get_value
    return kwargs[key]
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink/utils/lazy_formatter.py", line 11, in __getitem__
    return value()
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink_cli/main.py", line 151, in <lambda>
    author=lambda: plugin.get_author() or DEFAULT_STREAM_METADATA["author"],
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink/plugins/twitch.py", line 422, in get_author
    self._get_metadata()
  File "/home/azizLIGHT/Documents/Scripts/streamlink-pip/lib/python3.8/site-packages/streamlink/plugins/twitch.py", line 410, in _get_metadata
    api_res = self.api.streams(self.channel_id)["stream"]["channel"]
TypeError: 'NoneType' object is not subscriptable
[azizLIGHT@fractal ~]$ 
```


### Additional comments, screenshots, etc.
Normally I use --title "{author} - {category} - {title} - (\${{video-bitrate}})" in my command, but any of those will cause streamlink to fail when trying to watch reckful stream. It does not seem to do it for regular live twitch channels, maybe it only does it to rerun channels. I am not sure. This was streamlink installed to a venv using [these instructions](https://streamlink.github.io/install.html#virtual-environment)


[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)



Discussion:
Stream launches fine without the --title switch: 
`streamlink --player="mpv --keep-open=yes --ontop --autofit=22%" --player-no-close --twitch-disable-ads --twitch-low-latency http://twitch.tv/reckful best`

and also launches fine with the --title switch, IF the arguments are NOT variables:
`streamlink --player="mpv --keep-open=yes --ontop --autofit=22%" --player-no-close --twitch-disable-ads --twitch-low-latency --title="test" http://twitch.tv/reckful best
`
This is because that data is not available from the API where we expect it to be. I'm not sure if this is something we'll consider fixing. @bastimeyer I feel like we discussed this in the past regarding rebroadcasts and that they work differently than a VOD or live stream and we said we wouldn't fix issues with them due to how rare they are, but I can't find the discussion now. Do you remember?
This channel is not "live" (yes, I know why). The Twitch API doesn't return stream data in those cases. That might be a bug with kraken v5, I don't know, but I wouldn't be surprised either, because kraken v5 is really messy, and helix I believe still a shitshow, despite promises of improvements earlier this year.

Streamlink doesn't set the `stream_type` query parameter when requesting streams by channel ID, as documented here:
https://dev.twitch.tv/docs/v5/reference/streams#get-stream-by-user

It doesn't make a difference though, as you can see in the following example:

```bash
LOGIN=reckful
ID=$(curl -s \
      -H "Accept: application/vnd.twitchtv.v5+json" \
      -H "Client-ID: pwkzresl8kj2rdj6g7bvxl9ys1wly3j" \
      "https://api.twitch.tv/kraken/users?login=${LOGIN}" \
      | jq -r '.users[0]._id'
)
```

**No `stream_type`**
```bash
curl -s \
  -H "Accept: application/vnd.twitchtv.v5+json" \
  -H "Client-ID: pwkzresl8kj2rdj6g7bvxl9ys1wly3j" \
  "https://api.twitch.tv/kraken/streams/${ID}" \
  | jq
```
```json
{
  "stream": null
}
```

**With `stream_type=all`**
```bash
curl -s \
  -H "Accept: application/vnd.twitchtv.v5+json" \
  -H "Client-ID: pwkzresl8kj2rdj6g7bvxl9ys1wly3j" \
  "https://api.twitch.tv/kraken/streams/${ID}?stream_type=all" \
  | jq
```
```json
{
  "stream": null
}
```

This is an issue on the Twitch API and we can't do anything about when they don't provide the correct data. It doesn't mean though that the plugin should raise a `TypeError`. The method for reading the stream's metadata currently doesn't check for missing data and also doesn't use a validation schema. This bug needs to be fixed.
https://github.com/streamlink/streamlink/blame/21a3067c1790f355849af1d973cf3970262e2eae/src/streamlink/plugins/twitch.py#L401-L413

The overall state of the Twitch plugin in regards to the API queries and stuff around that is very much outdated and hasn't been touched in several years. When the `--title` parameter was added two years ago, this should have been implemented way cleaner. When I get the time, I will look into refactoring the plugin's `TwitchAPI`, `UsherService` and overall plugin methods.

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
@@ -396,6 +396,39 @@ class Twitch(Plugin):
         )
     )
 
+    _schema_metadata_empty = validate.transform(lambda _: (None,) * 3)
+    _schema_metadata_channel = validate.Schema(
+        {
+            "stream": validate.any(
+                validate.all(
+                    {"channel": {
+                        "display_name": validate.text,
+                        "game": validate.text,
+                        "status": validate.text
+                    }},
+                    validate.get("channel"),
+                    validate.transform(lambda ch: (ch["display_name"], ch["status"], ch["game"]))
+                ),
+                validate.all(None, _schema_metadata_empty)
+            )
+        },
+        validate.get("stream")
+    )
+    _schema_metadata_video = validate.Schema(validate.any(
+        validate.all(
+            {
+                "title": validate.text,
+                "game": validate.text,
+                "channel": validate.all(
+                    {"display_name": validate.text},
+                    validate.get("display_name")
+                )
+            },
+            validate.transform(lambda data: (data["channel"], data["title"], data["game"]))
+        ),
+        validate.all({}, _schema_metadata_empty)
+    ))
+
     @classmethod
     def stream_weight(cls, key):
         weight = QUALITY_WEIGHTS.get(key)
@@ -410,17 +443,11 @@ def can_handle_url(cls, url):
 
     def _get_metadata(self):
         if self.video_id:
-            api_res = self.api.videos(self.video_id)
-            self.title = api_res["title"]
-            self.author = api_res["channel"]["display_name"]
-            self.category = api_res["game"]
+            (self.author, self.title, self.category) = self.api.videos(self.video_id, schema=self._schema_metadata_video)
         elif self.clip_name:
             self._get_clips()
         elif self._channel:
-            api_res = self.api.streams(self.channel_id)["stream"]["channel"]
-            self.title = api_res["status"]
-            self.author = api_res["display_name"]
-            self.category = api_res["game"]
+            (self.author, self.title, self.category) = self.api.streams(self.channel_id, schema=self._schema_metadata_channel)
 
     def get_title(self):
         if self.title is None:


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
@@ -7,6 +7,7 @@
 from tests.mock import MagicMock, call, patch
 
 from streamlink import Streamlink
+from streamlink.plugin import PluginError
 from streamlink.plugins.twitch import Twitch, TwitchHLSStream
 
 
@@ -274,6 +275,81 @@ def test_hls_low_latency_no_prefetch_disable_ads_has_preroll(self, mock_log):
         ])
 
 
+class TestTwitchMetadata(unittest.TestCase):
+    def setUp(self):
+        self.mock = requests_mock.Mocker()
+        self.mock.start()
+
+    def tearDown(self):
+        self.mock.stop()
+
+    def subject(self, url):
+        session = Streamlink()
+        Twitch.bind(session, "tests.plugins.test_twitch")
+        plugin = Twitch(url)
+        return plugin.get_author(), plugin.get_title(), plugin.get_category()
+
+    def subject_channel(self, data=True, failure=False):
+        self.mock.get(
+            "https://api.twitch.tv/kraken/users.json?login=foo",
+            json={"users": [{"_id": 1234}]}
+        )
+        self.mock.get(
+            "https://api.twitch.tv/kraken/streams/1234.json",
+            status_code=200 if not failure else 404,
+            json={"stream": None} if not data else {"stream": {
+                "channel": {
+                    "display_name": "channel name",
+                    "status": "channel status",
+                    "game": "channel game"
+                }
+            }}
+        )
+        return self.subject("https://twitch.tv/foo")
+
+    def subject_video(self, data=True, failure=False):
+        self.mock.get(
+            "https://api.twitch.tv/kraken/videos/1337.json",
+            status_code=200 if not failure else 404,
+            json={} if not data else {
+                "title": "video title",
+                "game": "video game",
+                "channel": {
+                    "display_name": "channel name"
+                }
+            }
+        )
+        return self.subject("https://twitch.tv/videos/1337")
+
+    def test_metadata_channel_exists(self):
+        author, title, category = self.subject_channel()
+        self.assertEqual(author, "channel name")
+        self.assertEqual(title, "channel status")
+        self.assertEqual(category, "channel game")
+
+    def test_metadata_channel_missing(self):
+        metadata = self.subject_channel(data=False)
+        self.assertEqual(metadata, (None, None, None))
+
+    def test_metadata_channel_invalid(self):
+        with self.assertRaises(PluginError):
+            self.subject_channel(failure=True)
+
+    def test_metadata_video_exists(self):
+        author, title, category = self.subject_video()
+        self.assertEqual(author, "channel name")
+        self.assertEqual(title, "video title")
+        self.assertEqual(category, "video game")
+
+    def test_metadata_video_missing(self):
+        metadata = self.subject_video(data=False)
+        self.assertEqual(metadata, (None, None, None))
+
+    def test_metadata_video_invalid(self):
+        with self.assertRaises(PluginError):
+            self.subject_video(failure=True)
+
+
 @patch("streamlink.plugins.twitch.log")
 class TestTwitchReruns(unittest.TestCase):
     log_call = call("Reruns were disabled by command line option")