Repository: streamlink/streamlink
Pull request #2724 (2019-12-03 11:19:44)

plugins.twitch: fix disable reruns
Fixes the issues mentioned in #2722

Please don't squash the commits when merging.


Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index c6fe3e38..80a6ad4e 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -96,6 +96,15 @@ _user_schema = validate.Schema(
     },
     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 @@ class Twitch(Plugin):
                     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 @@ class Twitch(Plugin):
         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 @@ class Twitch(Plugin):
         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 @@ class Twitch(Plugin):
 
             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 @@ class Twitch(Plugin):
                                                      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 @@ class Twitch(Plugin):
                            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 @@ class Twitch(Plugin):
     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 @@ class Twitch(Plugin):
             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 @@ class Twitch(Plugin):
             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
index b4ad80b9..849d2533 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -187,3 +187,88 @@ class TestTwitchHLSStream(unittest.TestCase):
             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)