Repository: streamlink/streamlink

Issue:
plugins.twitch: returns streams while channel is offline
### Checklist

- [X] This is a [plugin issue](https://streamlink.github.io/plugins.html) 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 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

streamlink 6.4.2

### Description

The --retry-streams switch seems to be broken on twitch.
I keep running `streamlink --retry-streams X` so I can record VODs of my favorite streamer. Last night, after their stream ended, streamlink kept exiting instead of retrying to record a stream.

Steps to reproduce:
`streamlink twitch.tv/any-offline-streamer best -o "/disk1/share1/twitch/{time}.ts" --retry-streams 5`

Expected behavior: streamlink detects no live stream and keeps on trying to record every 5 seconds.
What actually happens:
```
[stream.hls][error] Unable to open URL: https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8E[...]
[cli][error] Try 1/1: Could not open stream <TwitchHLSStream ['hls', 'https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8E[...]
(No data returned from stream)
error: Could not open stream <TwitchHLSStream ['hls', 'https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8E
[...] tried 1 times, exiting
```


### Debug log

```text
[cli][debug] OS:         Linux-5.10.0-26-amd64-x86_64-with-glibc2.31
[cli][debug] Python:     3.9.2
[cli][debug] OpenSSL:    OpenSSL 1.1.1w  11 Sep 2023
[cli][debug] Streamlink: 6.4.2
[cli][debug] Dependencies:
[cli][debug]  certifi: 2020.6.20
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.1
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.16.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.22.2
[cli][debug]  trio-websocket: 0.10.3
[cli][debug]  typing-extensions: 4.7.1
[cli][debug]  urllib3: 2.1.0
[cli][debug]  websocket-client: 1.4.2
[cli][debug] Arguments:
[cli][debug]  url=twitch.tv/streamername
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --output=/disk1/share1/twitch/{time}.ts
[cli][debug]  --retry-streams=5.0
[cli][debug]  --twitch-api-header=[('Authorization', 'OAuth oauthkey')]
[cli][info] Found matching plugin twitch for URL twitch.tv/streamername
[plugins.twitch][debug] Getting live HLS streams for streamername
[plugins.twitch][debug] {'adblock': False, 'geoblock_reason': '', 'hide_ads': False, 'server_ads': True, 'show_ads': True}
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p60, 1080p60 (best)
[cli][info] Opening stream: 1080p60 (hls)
[cli][info] Writing output to
/disk1/share1/twitch/2023-12-05_06-50-04.ts
[cli][debug] Checking file output
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][error] Unable to open URL: https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8Et7-cohsd9159aO2PPQ6Seos4y4HOvcb72tOtDdRNTj8hpKZlbvCMgSrL0JzJEezXEL7893RUjYW-NTfbZcZOXn19u5IrlV9sBJBFYrZp2tL0VAszcZNOI2TN-hBp3vdd-H2H19i6EcZUEMGjDjt_pdgVd9WX9OTQqPRqmK9I1GN8bOQ7yKXjQiwQPj_5puEfGNPAP-IIqZ6FsMlGykkectRfLsSQ0Cg4W5IkiHnoEVeY6DbalTBUZHoAlmENk6p-sO1iRCunKdFWZ7U9ac0uJn8yBybSTNUwYzV-uYJZuvbbZQxrGHjjCqRXzi_NDGNQQKwm0-vd1QUdSEKdBKwcqgpm-Dv8IZGupIYXwQZOS0nUesayUc0ENP4ELbMn-_R61RD9xJU_xnQq-0BjLuMvzscu4YG74LHXs5O2N114OdrX2nK--GqmF-QDoY3z28F2QTS1QmwKgjFeO0W2zxAczEEJcR-f-_VKZCumbNDwWPI7TViy79nyYAnC64377Js0m7bFO4wn-10y9WvokvkPhpxkgTlUwDJCKrXnGmcnefr1QNQ6U27U0Un3S6heB8KUL_STsDi3zZ7gYx58vCcvFU0v5LsNE2eBjCQnHA7Z0OQQBQXAo1nJKtG1f8xNAQQyXr8gJVZSTjn29a0BGT-AmhF3MpJIHKRV1kFCQOeA8jz8nU6eUms6eOWRrh1uvX1O-IjHNy82zvBA0t8cpxaGtnS_SVH1E81RcbG0YpPtH4lcs1i7yzQpIqPg1eLdvUjrVbPaIvRiaL-VIjLNPIKQ21V-hyoe5ALJzwT5I904WhoM5VDKLPcrcqh3JdE7IAEqCXVzLXdlc3QtMjCUCA.m3u8 (404 Client Error: Not Found for url: https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8Et7-cohsd9159aO2PPQ6Seos4y4HOvcb72tOtDdRNTj8hpKZlbvCMgSrL0JzJEezXEL7893RUjYW-NTfbZcZOXn19u5IrlV9sBJBFYrZp2tL0VAszcZNOI2TN-hBp3vdd-H2H19i6EcZUEMGjDjt_pdgVd9WX9OTQqPRqmK9I1GN8bOQ7yKXjQiwQPj_5puEfGNPAP-IIqZ6FsMlGykkectRfLsSQ0Cg4W5IkiHnoEVeY6DbalTBUZHoAlmENk6p-sO1iRCunKdFWZ7U9ac0uJn8yBybSTNUwYzV-uYJZuvbbZQxrGHjjCqRXzi_NDGNQQKwm0-vd1QUdSEKdBKwcqgpm-Dv8IZGupIYXwQZOS0nUesayUc0ENP4ELbMn-_R61RD9xJU_xnQq-0BjLuMvzscu4YG74LHXs5O2N114OdrX2nK--GqmF-QDoY3z28F2QTS1QmwKgjFeO0W2zxAczEEJcR-f-_VKZCumbNDwWPI7TViy79nyYAnC64377Js0m7bFO4wn-10y9WvokvkPhpxkgTlUwDJCKrXnGmcnefr1QNQ6U27U0Un3S6heB8KUL_STsDi3zZ7gYx58vCcvFU0v5LsNE2eBjCQnHA7Z0OQQBQXAo1nJKtG1f8xNAQQyXr8gJVZSTjn29a0BGT-AmhF3MpJIHKRV1kFCQOeA8jz8nU6eUms6eOWRrh1uvX1O-IjHNy82zvBA0t8cpxaGtnS_SVH1E81RcbG0YpPtH4lcs1i7yzQpIqPg1eLdvUjrVbPaIvRiaL-VIjLNPIKQ21V-hyoe5ALJzwT5I904WhoM5VDKLPcrcqh3JdE7IAEqCXVzLXdlc3QtMjCUCA.m3u8)
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
[cli][error] Try 1/1: Could not open stream <TwitchHLSStream ['hls', 'https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8Et7-cohsd9159aO2PPQ6Seos4y4HOvcb72tOtDdRNTj8hpKZlbvCMgSrL0JzJEezXEL7893RUjYW-NTfbZcZOXn19u5IrlV9sBJBFYrZp2tL0VAszcZNOI2TN-hBp3vdd-H2H19i6EcZUEMGjDjt_pdgVd9WX9OTQqPRqmK9I1GN8bOQ7yKXjQiwQPj_5puEfGNPAP-IIqZ6FsMlGykkectRfLsSQ0Cg4W5IkiHnoEVeY6DbalTBUZHoAlmENk6p-sO1iRCunKdFWZ7U9ac0uJn8yBybSTNUwYzV-uYJZuvbbZQxrGHjjCqRXzi_NDGNQQKwm0-vd1QUdSEKdBKwcqgpm-Dv8IZGupIYXwQZOS0nUesayUc0ENP4ELbMn-_R61RD9xJU_xnQq-0BjLuMvzscu4YG74LHXs5O2N114OdrX2nK--GqmF-QDoY3z28F2QTS1QmwKgjFeO0W2zxAczEEJcR-f-_VKZCumbNDwWPI7TViy79nyYAnC64377Js0m7bFO4wn-10y9WvokvkPhpxkgTlUwDJCKrXnGmcnefr1QNQ6U27U0Un3S6heB8KUL_STsDi3zZ7gYx58vCcvFU0v5LsNE2eBjCQnHA7Z0OQQBQXAo1nJKtG1f8xNAQQyXr8gJVZSTjn29a0BGT-AmhF3MpJIHKRV1kFCQOeA8jz8nU6eUms6eOWRrh1uvX1O-IjHNy82zvBA0t8cpxaGtnS_SVH1E81RcbG0YpPtH4lcs1i7yzQpIqPg1eLdvUjrVbPaIvRiaL-VIjLNPIKQ21V-hyoe5ALJzwT5I904WhoM5VDKLPcrcqh3JdE7IAEqCXVzLXdlc3QtMjCUCA.m3u8', 'https://usher.ttvnw.net/api/channel/hls/streamername.m3u8?player=twitchweb&p=17828&type=any&allow_source=true&allow_audio_only=true&allow_spectre=false&sig=e99f77d8cdd6aa3055787fddba62db0d8dddc3d6&token=%7B%22adblock%22%3Afalse%2C%22authorization%22%3A%7B%22forbidden%22%3Afalse%2C%22reason%22%3A%22%22%7D%2C%22blackout_enabled%22%3Afalse%2C%22channel%22%3A%22streamername%22%2C%22channel_id%22%3A38251312%2C%22chansub%22%3A%7B%22restricted_bitrates%22%3A%5B%5D%2C%22view_until%22%3A1924905600%7D%2C%22ci_gb%22%3Afalse%2C%22geoblock_reason%22%3A%22%22%2C%22device_id%22%3Anull%2C%22expires%22%3A1701756604%2C%22extended_history_allowed%22%3Afalse%2C%22game%22%3A%22%22%2C%22hide_ads%22%3Afalse%2C%22https_required%22%3Atrue%2C%22mature%22%3Afalse%2C%22partner%22%3Afalse%2C%22platform%22%3A%22web%22%2C%22player_type%22%3A%22embed%22%2C%22private%22%3A%7B%22allowed_to_view%22%3Atrue%7D%2C%22privileged%22%3Afalse%2C%22role%22%3A%22%22%2C%22server_ads%22%3Atrue%2C%22show_ads%22%3Atrue%2C%22subscriber%22%3Afalse%2C%22turbo%22%3Afalse%2C%22user_id%22%3A495478723%2C%22user_ip%22%3A%2285.67.13.26%22%2C%22version%22%3A2%7D&fast_bread=True']> (No data returned from stream)
error: Could not open stream <TwitchHLSStream ['hls', 'https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/Ct8Et7-cohsd9159aO2PPQ6Seos4y4HOvcb72tOtDdRNTj8hpKZlbvCMgSrL0JzJEezXEL7893RUjYW-NTfbZcZOXn19u5IrlV9sBJBFYrZp2tL0VAszcZNOI2TN-hBp3vdd-H2H19i6EcZUEMGjDjt_pdgVd9WX9OTQqPRqmK9I1GN8bOQ7yKXjQiwQPj_5puEfGNPAP-IIqZ6FsMlGykkectRfLsSQ0Cg4W5IkiHnoEVeY6DbalTBUZHoAlmENk6p-sO1iRCunKdFWZ7U9ac0uJn8yBybSTNUwYzV-uYJZuvbbZQxrGHjjCqRXzi_NDGNQQKwm0-vd1QUdSEKdBKwcqgpm-Dv8IZGupIYXwQZOS0nUesayUc0ENP4ELbMn-_R61RD9xJU_xnQq-0BjLuMvzscu4YG74LHXs5O2N114OdrX2nK--GqmF-QDoY3z28F2QTS1QmwKgjFeO0W2zxAczEEJcR-f-_VKZCumbNDwWPI7TViy79nyYAnC64377Js0m7bFO4wn-10y9WvokvkPhpxkgTlUwDJCKrXnGmcnefr1QNQ6U27U0Un3S6heB8KUL_STsDi3zZ7gYx58vCcvFU0v5LsNE2eBjCQnHA7Z0OQQBQXAo1nJKtG1f8xNAQQyXr8gJVZSTjn29a0BGT-AmhF3MpJIHKRV1kFCQOeA8jz8nU6eUms6eOWRrh1uvX1O-IjHNy82zvBA0t8cpxaGtnS_SVH1E81RcbG0YpPtH4lcs1i7yzQpIqPg1eLdvUjrVbPaIvRiaL-VIjLNPIKQ21V-hyoe5ALJzwT5I904WhoM5VDKLPcrcqh3JdE7IAEqCXVzLXdlc3QtMjCUCA.m3u8', 'https://usher.ttvnw.net/api/channel/hls/streamername.m3u8?player=twitchweb&p=17828&type=any&allow_source=true&allow_audio_only=true&allow_spectre=false&sig=e99f77d8cdd6aa3055787fddba62db0d8dddc3d6&token=%7B%22adblock%22%3Afalse%2C%22authorization%22%3A%7B%22forbidden%22%3Afalse%2C%22reason%22%3A%22%22%7D%2C%22blackout_enabled%22%3Afalse%2C%22channel%22%3A%22streamername%22%2C%22channel_id%22%3A38251312%2C%22chansub%22%3A%7B%22restricted_bitrates%22%3A%5B%5D%2C%22view_until%22%3A1924905600%7D%2C%22ci_gb%22%3Afalse%2C%22geoblock_reason%22%3A%22%22%2C%22device_id%22%3Anull%2C%22expires%22%3A1701756604%2C%22extended_history_allowed%22%3Afalse%2C%22game%22%3A%22%22%2C%22hide_ads%22%3Afalse%2C%22https_required%22%3Atrue%2C%22mature%22%3Afalse%2C%22partner%22%3Afalse%2C%22platform%22%3A%22web%22%2C%22player_type%22%3A%22embed%22%2C%22private%22%3A%7B%22allowed_to_view%22%3Atrue%7D%2C%22privileged%22%3Afalse%2C%22role%22%3A%22%22%2C%22server_ads%22%3Atrue%2C%22show_ads%22%3Atrue%2C%22subscriber%22%3Afalse%2C%22turbo%22%3Afalse%2C%22user_id%22%3A495478723%2C%22user_ip%22%3A%2285.67.13.26%22%2C%22version%22%3A2%7D&fast_bread=True']>, tried 1 times, exiting
[cli][info] Closing currently open stream...
```


Discussion:

Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 9063d6f6..3fd35c8c 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -495,8 +495,16 @@ class TwitchAPI:
         )
 
         return self.call(query, schema=validate.Schema(
-            {"data": {"user": {"stream": {"type": str}}}},
-            validate.get(("data", "user", "stream")),
+            {
+                "data": {
+                    "user": validate.none_or_all({
+                        "stream": validate.none_or_all({
+                            "id": str,
+                        }),
+                    }),
+                },
+            },
+            validate.get(("data", "user")),
         ))
 
 
@@ -640,7 +648,7 @@ class TwitchClientIntegrity:
 @pluginargument(
     "disable-reruns",
     action="store_true",
-    help="Do not open the stream if the target channel is currently broadcasting a rerun.",
+    help=argparse.SUPPRESS,
 )
 @pluginargument(
     "low-latency",
@@ -808,22 +816,19 @@ class Twitch(Plugin):
 
         return sig, token, restricted_bitrates
 
-    def _check_for_rerun(self):
-        if not self.options.get("disable_reruns"):
+    def _check_is_live(self) -> bool:
+        data = self.api.stream_metadata(self.channel)
+        if data is None:
+            log.error("Unknown channel")
+            return False
+        if data["stream"] is None:
+            log.error("Channel is offline")
             return False
 
-        try:
-            stream = self.api.stream_metadata(self.channel)
-            if stream["type"] != "live":
-                log.info("Reruns were disabled by command line option")
-                return True
-        except (PluginError, TypeError):
-            pass
-
-        return False
+        return True
 
     def _get_hls_streams_live(self):
-        if self._check_for_rerun():
+        if not self._check_is_live():
             return
 
         # only get the token once the channel has been resolved


Test patch:
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
index e150277c..58801420 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -396,6 +396,53 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
         assert self.thread.reader.worker.playlist_reload_time == pytest.approx(23 / 3)
 
 
+class TestTwitchIsLive:
+    @pytest.fixture()
+    def plugin(self, request: pytest.FixtureRequest):
+        return Twitch(Streamlink(), "https://twitch.tv/channelname")
+
+    @pytest.fixture()
+    def mock(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker):
+        mock = requests_mock.post("https://gql.twitch.tv/gql", **getattr(request, "param", {"json": {}}))
+        yield mock
+        assert mock.call_count > 0
+        payload = mock.last_request.json()  # type: ignore[union-attr]
+        assert tuple(sorted(payload.keys())) == ("extensions", "operationName", "variables")
+        assert payload.get("operationName") == "StreamMetadata"
+        assert payload.get("extensions") == {
+            "persistedQuery": {
+                "sha256Hash": "1c719a40e481453e5c48d9bb585d971b8b372f8ebb105b17076722264dfa5b3e",
+                "version": 1,
+            },
+        }
+        assert payload.get("variables") == {"channelLogin": "channelname"}
+
+    @pytest.mark.parametrize(("mock", "expected", "log"), [
+        pytest.param(
+            {"json": {"data": {"user": None}}},
+            False,
+            [("streamlink.plugins.twitch", "error", "Unknown channel")],
+            id="no-user",
+        ),
+        pytest.param(
+            {"json": {"data": {"user": {"stream": None}}}},
+            False,
+            [("streamlink.plugins.twitch", "error", "Channel is offline")],
+            id="no-stream",
+        ),
+        pytest.param(
+            {"json": {"data": {"user": {"stream": {"id": "1234567890"}}}}},
+            True,
+            [],
+            id="is-live",
+        ),
+    ], indirect=["mock"])
+    def test_is_live(self, plugin: Twitch, caplog: pytest.LogCaptureFixture, mock: rm.Mocker, expected: bool, log: list):
+        caplog.set_level(1, "streamlink")
+        assert plugin._check_is_live() is expected
+        assert [(record.name, record.levelname, record.message) for record in caplog.records] == log
+
+
 class TestTwitchAPIAccessToken:
     @pytest.fixture(autouse=True)
     def _client_integrity_token(self, monkeypatch: pytest.MonkeyPatch):
@@ -834,63 +881,3 @@ class TestTwitchMetadata:
         assert author is None
         assert category is None
         assert title is None
-
-
-@pytest.mark.parametrize(("stream_type", "offline", "disable", "expected", "logs"), [
-    pytest.param(
-        "live",
-        False,
-        True,
-        False,
-        [],
-        id="disable live",
-    ),
-    pytest.param(
-        "rerun",
-        False,
-        True,
-        True,
-        [("streamlink.plugins.twitch", "info", "Reruns were disabled by command line option")],
-        id="disable not live",
-    ),
-    pytest.param(
-        "live",
-        True,
-        True,
-        False,
-        [],
-        id="disable offline",
-    ),
-    pytest.param(
-        "rerun",
-        True,
-        False,
-        False,
-        [],
-        id="enable",
-    ),
-])
-def test_reruns(
-    monkeypatch: pytest.MonkeyPatch,
-    caplog: pytest.LogCaptureFixture,
-    session: Streamlink,
-    stream_type: str,
-    offline: bool,
-    disable: bool,
-    expected: bool,
-    logs: list,
-):
-    caplog.set_level(1, "streamlink")
-    mock_stream_metadata = Mock(return_value=None if offline else {"type": stream_type})
-    monkeypatch.setattr("streamlink.plugins.twitch.TwitchAPI.stream_metadata", mock_stream_metadata)
-
-    # noinspection PyTypeChecker
-    plugin: Twitch = Twitch(session, "https://www.twitch.tv/foo")
-    try:
-        plugin.options.set("disable-reruns", disable)
-        result = plugin._check_for_rerun()
-    finally:
-        plugin.options.clear()
-
-    assert result is expected
-    assert [(record.name, record.levelname, record.message) for record in caplog.records] == logs