Repository: streamlink/streamlink

Issue:
Allow to disable twitch reruns
This adds the option to disable twitch reruns similar to disabling hosted streams.

~I know the check for the `stream_type` could also be directly done in `_get_hls_streams`. If you like that better tell me and I can change it :)~

Edit: I decided to change it. This should have less changes and I think my previous solution broke the tests for the skip-ads option.

Closes #2721 


Discussion:
# [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/2722?src=pr&el=h1) Report
> Merging [#2722](https://codecov.io/gh/streamlink/streamlink/pull/2722?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/9253fc5128da0c7fb01375de9c0d051f7f835b61?src=pr&el=desc) will **decrease** coverage by `0.01%`.
> The diff coverage is `20%`.


```diff
@@            Coverage Diff             @@
##           master    #2722      +/-   ##
==========================================
- Coverage   52.47%   52.46%   -0.02%     
==========================================
  Files         246      246              
  Lines       15431    15436       +5     
==========================================
+ Hits         8098     8099       +1     
- Misses       7333     7337       +4
```

Thanks @skulblakka, I didn't realize the reruns were flagged by the stream type.
Thank you!
There also a problem occurs when stream is offline
![1](https://user-images.githubusercontent.com/51099894/69998846-55e3dc80-1568-11ea-8c62-2baced39c4df.png)

@skulblakka Do you have time to try and fix this at some point? Otherwise maybe we should revert since this is kind of hard to test. I only know of a few streamers like followgrubby that regularly have reruns. Seems like the items bastimeyer noted might be biting us in the butt a bit here, might be worth adding some tests as well? I didn't think about how poorly patched together the API is when I merged this.
I'm sorry for the mess I caused.

@bastimeyer Like I mentioned I assumed the OP of the issue was talking about reruns made via the Rerun-Feature. That's why I didn't consider "live"-streams that broadcasted a video. Getting streams by type was [removed March 2018](https://discuss.dev.twitch.tv/t/new-twitch-api-deprecating-get-streams-by-type/14554) on Helix (although the field there is only called `type`) and other values than `live` were removed from the documentation. Do you think filtering by `stream_type` is enough or should I also add `broadcast_platform`?

@gravyboat I have to leave for work now but I will try fixing this in the evening (and will also see about some tests).
@skulblakka There is no need to apologize or any reason to stress about it. Your contribution and desire to implement this is appreciated. There's no release currently planned so we're not in a rush, I just wanted to clarify whether you wanted to work on it or if you were going to be too busy to do so and we should back it out. Don't pressure yourself or inconvenience your personal time.
I'll submit a PR with a fix later. Currently adding some tests... No pressure...

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
@@ -96,6 +96,15 @@
     },
     validate.get("display_name")
 )
+_stream_schema = validate.Schema(
+    {
+        "stream": validate.any(None, {
+            "stream_type": validate.any("live", "rerun"),
+            "broadcast_platform": validate.any("live", "rerun")
+        })
+    },
+    validate.get("stream")
+)
 _video_schema = validate.Schema(
     {
         "chunks": {
@@ -450,7 +459,7 @@ def __init__(self, url):
                     self.video_type = self.params["video"][0]
                     self.video_id = self.params["video"][1:]
                 except IndexError:
-                    self.logger.debug("Invalid video param: {0}", self.params["video"])
+                    log.debug("Invalid video param: {0}".format(self.params["video"]))
             self._channel = self.params.get("channel")
         elif self.subdomain == "clips":
             # clip share URL
@@ -520,27 +529,25 @@ def _authenticate(self):
         cookies = self.options.get("cookie")
 
         if oauth_token:
-            self.logger.info("Attempting to authenticate using OAuth token")
+            log.info("Attempting to authenticate using OAuth token")
             self.api.oauth_token = oauth_token
             user = self.api.user(schema=_user_schema)
 
             if user:
-                self.logger.info("Successfully logged in as {0}", user)
+                log.info("Successfully logged in as {0}".format(user))
             else:
-                self.logger.error("Failed to authenticate, the access token "
-                                  "is invalid or missing required scope")
+                log.error("Failed to authenticate, the access token is invalid or missing required scope")
         elif cookies:
-            self.logger.info("Attempting to authenticate using cookies")
+            log.info("Attempting to authenticate using cookies")
 
             self.api.add_cookies(cookies)
             self.api.oauth_token = self.api.token(schema=_viewer_token_schema)
             login = self.api.viewer_info(schema=_viewer_info_schema)
 
             if login:
-                self.logger.info("Successfully logged in as {0}", login)
+                log.info("Successfully logged in as {0}".format(login))
             else:
-                self.logger.error("Failed to authenticate, your cookies "
-                                  "may have expired")
+                log.error("Failed to authenticate, your cookies may have expired")
 
     def _create_playlist_streams(self, videos):
         start_offset = int(videos.get("start_offset", 0))
@@ -550,9 +557,7 @@ def _create_playlist_streams(self, videos):
         for quality, chunks in videos.get("chunks").items():
             if not chunks:
                 if videos.get("restrictions", {}).get(quality) == "chansub":
-                    self.logger.warning("The quality '{0}' is not available "
-                                        "since it requires a subscription.",
-                                        quality)
+                    log.warning("The quality '{0}' is not available since it requires a subscription.".format(quality))
                 continue
 
             # Rename 'live' to 'source'
@@ -561,8 +566,7 @@ def _create_playlist_streams(self, videos):
 
             chunks_filtered = list(filter(lambda c: c["url"], chunks))
             if len(chunks) != len(chunks_filtered):
-                self.logger.warning("The video '{0}' contains invalid chunks. "
-                                    "There will be missing data.", quality)
+                log.warning("The video '{0}' contains invalid chunks. There will be missing data.".format(quality))
                 chunks = chunks_filtered
 
             chunks_duration = sum(c.get("length") for c in chunks)
@@ -583,8 +587,7 @@ def _create_playlist_streams(self, videos):
                                                      start_offset,
                                                      stop_offset)
                 except StreamError as err:
-                    self.logger.error("Error while creating video '{0}': {1}",
-                                      quality, err)
+                    log.error("Error while creating video '{0}': {1}".format(quality, err))
                     continue
 
             streams[quality] = stream
@@ -650,7 +653,7 @@ def _create_video_clip(self, chunks, start_offset, stop_offset):
                            tags=playlist_tags, duration=playlist_duration)
 
     def _get_video_streams(self):
-        self.logger.debug("Getting video steams for {0} (type={1})".format(self.video_id, self.video_type))
+        log.debug("Getting video steams for {0} (type={1})".format(self.video_id, self.video_type))
         self._authenticate()
 
         if self.video_type == "b":
@@ -700,29 +703,31 @@ def _access_token(self, type="live"):
     def _check_for_host(self):
         host_info = self.api.hosted_channel(include_logins=1, host=self.channel_id).json()["hosts"][0]
         if "target_login" in host_info and host_info["target_login"].lower() != self.channel.lower():
-            self.logger.info("{0} is hosting {1}".format(self.channel, host_info["target_login"]))
+            log.info("{0} is hosting {1}".format(self.channel, host_info["target_login"]))
             return host_info["target_login"]
 
     def _check_for_rerun(self):
-        return self.api.streams(self.channel_id)["stream"]["stream_type"] == "rerun"
+        stream = self.api.streams(self.channel_id, schema=_stream_schema)
+
+        return stream and (stream["stream_type"] == "rerun" or stream["broadcast_platform"] == "rerun")
 
     def _get_hls_streams(self, stream_type="live"):
-        self.logger.debug("Getting {0} HLS streams for {1}".format(stream_type, self.channel))
+        log.debug("Getting {0} HLS streams for {1}".format(stream_type, self.channel))
         self._authenticate()
         self._hosted_chain.append(self.channel)
 
         if stream_type == "live":
-            if self._check_for_rerun() and self.options.get("disable_reruns"):
-                self.logger.info("Reruns were disabled by command line option")
+            if self.options.get("disable_reruns") and self._check_for_rerun():
+                log.info("Reruns were disabled by command line option")
                 return {}
 
             hosted_channel = self._check_for_host()
             if hosted_channel and self.options.get("disable_hosting"):
-                self.logger.info("hosting was disabled by command line option")
+                log.info("hosting was disabled by command line option")
             elif hosted_channel:
-                self.logger.info("switching to {0}", hosted_channel)
+                log.info("switching to {0}".format(hosted_channel))
                 if hosted_channel in self._hosted_chain:
-                    self.logger.error(
+                    log.error(
                         u"A loop of hosted channels has been detected, "
                         "cannot find a playable stream. ({0})".format(
                             u" -> ".join(self._hosted_chain + [hosted_channel])))
@@ -737,7 +742,7 @@ def _get_hls_streams(self, stream_type="live"):
             sig, token = self._access_token(stream_type)
             url = self.usher.video(self.video_id, nauthsig=sig, nauth=token)
         else:
-            self.logger.debug("Unknown HLS stream type: {0}".format(stream_type))
+            log.debug("Unknown HLS stream type: {0}".format(stream_type))
             return {}
 
         time_offset = self.params.get("t", 0)
@@ -767,9 +772,7 @@ def _get_hls_streams(self, stream_type="live"):
             token = parse_json(token, schema=_token_schema)
             for name in token["restricted_bitrates"]:
                 if name not in streams:
-                    self.logger.warning("The quality '{0}' is not available "
-                                        "since it requires a subscription.",
-                                        name)
+                    log.warning("The quality '{0}' is not available since it requires a subscription.".format(name))
         except PluginError:
             pass
 


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
@@ -187,3 +187,88 @@ def test_hls_scte35_in_between(self, mock_logging):
             call("Will skip ads beginning with segment 6"),
             call("Will stop skipping ads beginning with segment 14")
         ])
+
+
+@patch("streamlink.plugins.twitch.log")
+class TestTwitchReruns(unittest.TestCase):
+    log_call = call("Reruns were disabled by command line option")
+
+    class StopError(Exception):
+        """Stop when trying to get an access token in _get_hls_streams..."""
+
+    @patch("streamlink.plugins.twitch.Twitch._authenticate", return_value=None)
+    @patch("streamlink.plugins.twitch.Twitch._check_for_host", return_value=None)
+    @patch("streamlink.plugins.twitch.Twitch._access_token", side_effect=StopError())
+    def start(self, *mocked, **params):
+        with requests_mock.Mocker() as mock:
+            mocked_users = mock.get(
+                "https://api.twitch.tv/kraken/users.json?login=foo",
+                json={"users": [{"_id": 1234}]}
+            )
+            mocked_stream = mock.get(
+                "https://api.twitch.tv/kraken/streams/1234.json",
+                json={"stream": None} if params.pop("offline", False) else {"stream": {
+                    "stream_type": params.pop("stream_type", "rerun"),
+                    "broadcast_platform": params.pop("broadcast_platform", "rerun")
+                }}
+            )
+
+            session = Streamlink()
+            Twitch.bind(session, "tests.plugins.test_twitch")
+            plugin = Twitch("https://www.twitch.tv/foo")
+            plugin.options.set("disable-reruns", params.pop("disable", True))
+            try:
+                streams = plugin.streams()
+            except TestTwitchReruns.StopError:
+                streams = None
+                pass
+
+            return streams, mocked_users, mocked_stream, mocked[0]
+
+    def test_disable_reruns(self, mock_log):
+        streams, api_users, api_stream, access_token = self.start(stream_type="rerun", broadcast_platform="rerun")
+        self.assertTrue(api_users.called_once)
+        self.assertTrue(api_stream.called_once)
+        self.assertFalse(access_token.called)
+        self.assertDictEqual(streams, {})
+        self.assertIn(self.log_call, mock_log.info.call_args_list)
+
+    def test_disable_reruns_mixed(self, mock_log):
+        streams, api_users, api_stream, access_token = self.start(stream_type="rerun", broadcast_platform="live")
+        self.assertTrue(api_users.called_once)
+        self.assertTrue(api_stream.called_once)
+        self.assertFalse(access_token.called)
+        self.assertDictEqual(streams, {})
+        self.assertIn(self.log_call, mock_log.info.call_args_list)
+
+    def test_disable_reruns_mixed2(self, mock_log):
+        streams, api_users, api_stream, access_token = self.start(stream_type="live", broadcast_platform="rerun")
+        self.assertTrue(api_users.called_once)
+        self.assertTrue(api_stream.called_once)
+        self.assertFalse(access_token.called)
+        self.assertDictEqual(streams, {})
+        self.assertIn(self.log_call, mock_log.info.call_args_list)
+
+    def test_disable_reruns_live(self, mock_log):
+        streams, api_users, api_stream, access_token = self.start(stream_type="live", broadcast_platform="live")
+        self.assertTrue(api_users.called_once)
+        self.assertTrue(api_stream.called_once)
+        self.assertTrue(access_token.called_once)
+        self.assertIsNone(streams)
+        self.assertNotIn(self.log_call, mock_log.info.call_args_list)
+
+    def test_disable_reruns_offline(self, mock_log):
+        streams, api_users, api_stream, access_token = self.start(offline=True)
+        self.assertTrue(api_users.called_once)
+        self.assertTrue(api_stream.called_once)
+        self.assertTrue(access_token.called_once)
+        self.assertIsNone(streams)
+        self.assertNotIn(self.log_call, mock_log.info.call_args_list)
+
+    def test_enable_reruns(self, mock_log):
+        streams, api_users, api_stream, access_token = self.start(disable=False)
+        self.assertFalse(api_users.called)
+        self.assertFalse(api_stream.called)
+        self.assertTrue(access_token.called_once)
+        self.assertIsNone(streams)
+        self.assertNotIn(self.log_call, mock_log.info.call_args_list)