Repository: streamlink/streamlink
Pull request #418 (2017-01-13 18:39:00)

Adultswin VOD fix for live show replays
Fixes the issues with the previous PR https://github.com/streamlink/streamlink/pull/406#issuecomment-272434885 along with #417 which removes the DRM streams. 

Tested URLs:
- adultswim.com/videos/aqua-teen-hunger-force/vampirus
- adultswim.com/videos/streams/toonami-pre-flight/friday-january-6th-2017 (some DRMed streams)
- adultswim.com/videos/streams/toonami
- adultswim.com/videos/specials/the-adult-swim-golf-classic-extended

codecov-io: ## [Current coverage](https://codecov.io/gh/streamlink/streamlink/pull/418?src=pr) is 47.84% (diff: 13.63%)
> Merging [#418](https://codecov.io/gh/streamlink/streamlink/pull/418?src=pr) into [master](https://codecov.io/gh/streamlink/streamlink/branch/master?src=pr) will decrease coverage by **0.18%**

```diff
@@             master       #418   diff @@
==========================================
  Files            57         57          
  Lines          4885       4908    +23   
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
+ Hits           2346       2348     +2   
- Misses         2539       2560    +21   
  Partials          0          0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [c446d47...c47523a](https://codecov.io/gh/streamlink/streamlink/compare/c446d47a946921bac128fed2ed99ab96f226fe13...c47523a45c89baecd9525760ad480cbd2cc48600?src=pr)
gravyboat: Looks good to me, thanks @beardypig.

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 0a900a08..4b4ad90b 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -12,7 +12,7 @@ is limited.
 =================== ==================== ===== ===== ===========================
 Name                URL(s)               Live  VOD   Notes
 =================== ==================== ===== ===== ===========================
-adultswim           adultswim.com        Yes   No    Streams may be geo-restricted, VOD streams are encrypted
+adultswim           adultswim.com        Yes   Yes   Streams may be geo-restricted, some VOD streams are protected by DRM.
 afreeca             afreecatv.com        Yes   No
 afreecatv           afreeca.tv           Yes   No
 aftonbladet         aftonbladet.se       Yes   Yes
diff --git a/src/streamlink/plugin/api/mapper.py b/src/streamlink/plugin/api/mapper.py
index b91f110a..bedeff4f 100644
--- a/src/streamlink/plugin/api/mapper.py
+++ b/src/streamlink/plugin/api/mapper.py
@@ -41,9 +41,12 @@ class StreamMapper(object):
                 yield value
             else:
                 try:
-                    # TODO: Replace with "yield from" when dropping Python 2.
-                    for __ in value:
-                        yield __
+                    if isinstance(value, dict):
+                        for __ in value.items():
+                            yield __
+                    else:
+                        for __ in value:
+                            yield __
                 except TypeError:
                     # Non-iterable returned
                     continue
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 0fc94922..abb0128b 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -257,6 +257,10 @@ class Plugin(object):
             if stream_type not in stream_types:
                 continue
 
+            # drop _alt from any stream names
+            if name.endswith("_alt"):
+                name = name[:-len("_alt")]
+
             existing = streams.get(name)
             if existing:
                 existing_stream_type = type(existing).shortname()
diff --git a/src/streamlink/plugins/adultswim.py b/src/streamlink/plugins/adultswim.py
index 0286426d..e3a7e873 100644
--- a/src/streamlink/plugins/adultswim.py
+++ b/src/streamlink/plugins/adultswim.py
@@ -1,9 +1,9 @@
-#!/usr/bin/env python
 import re
-from pprint import pprint
 
 from streamlink.plugin import Plugin
+from streamlink.plugin.api import StreamMapper
 from streamlink.plugin.api import http, validate
+from streamlink.plugin.api import useragents
 from streamlink.stream import HDSStream
 from streamlink.stream import HLSStream
 from streamlink.stream import HTTPStream
@@ -11,16 +11,24 @@ from streamlink.utils import parse_json, parse_xml
 
 
 class AdultSwim(Plugin):
-    _user_agent = (
-        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) "
-        "Chrome/43.0.2357.65 Safari/537.36")
     API_URL = "http://www.adultswim.com/videos/api/v2/videos/{id}?fields=stream"
     vod_api = " http://www.adultswim.com/videos/api/v0/assets"
 
-    _url_re = re.compile(r"https?://(?:www\.)?adultswim\.com/videos/([^/]*)/?(.*?)/?$")
+    url_re = re.compile(r"""https?://(?:www\.)?adultswim\.com/videos
+            (?:/(streams))?
+            (?:/([^/]+))?
+            (?:/([^/]+))?
+            """, re.VERBOSE)
     _stream_data_re = re.compile(r"(?:__)?AS_INITIAL_DATA(?:__)? = (\{.*?});", re.M | re.DOTALL)
 
-    live_schema = validate.Schema({u"streams": {validate.text: {u"stream": validate.text}}})
+    live_schema = validate.Schema({
+        u"streams": {
+            validate.text: {u"stream": validate.text,
+                            u"archiveEpisodes": [{
+                                u"id": validate.text,
+                                u"slug": validate.text
+                            }]}}
+    })
     vod_id_schema = validate.Schema({u"show": {u"sluggedVideo": {u"id": validate.text}}},
                                     validate.transform(lambda x: x["show"]["sluggedVideo"]["id"]))
     _api_schema = validate.Schema({
@@ -33,72 +41,102 @@ class AdultSwim(Plugin):
         validate.all(
             validate.xml_findall(".//files/file"),
             [validate.xml_element,
-                validate.transform(lambda v: {"bitrate": v.attrib.get("bitrate"), "url": v.text})
-            ]
+             validate.transform(lambda v: {"bitrate": v.attrib.get("bitrate"), "url": v.text})
+             ]
         )
     )
 
     @classmethod
     def can_handle_url(cls, url):
-        match = AdultSwim._url_re.match(url)
+        match = AdultSwim.url_re.match(url)
         return match is not None
 
-    def _get_vod_streams(self, stream_data, show, episode, platform="desktop"):
+    def _make_hls_hds_stream(self, func, stream, *args, **kwargs):
+        return func(self.session, stream["url"], *args, **kwargs)
+
+    def _get_show_streams(self, stream_data, show, episode, platform="desktop"):
         video_id = parse_json(stream_data.group(1), schema=self.vod_id_schema)
         res = http.get(self.vod_api, params={"platform": platform, "id": video_id})
-        streams = parse_xml(res.text, schema=self._vod_api_schema)
-        urls = set()
-        for stream in streams:
-            bitrate, url = stream["bitrate"], stream["url"]
-            if url not in urls:
-                if url.endswith(".mp4"):
-                    yield "{0}k".format(bitrate), HTTPStream(self.session, url)
-                elif url.endswith(".m3u8"):
-                    for s in HLSStream.parse_variant_playlist(self.session, url).items():
-                        yield s
-                elif url.endswith(".f4m"):
-                    for s in HDSStream.parse_manifest(self.session, url, is_akamai=True).items():
-                        yield s
-                urls.add(stream["url"])
-
-    def _get_live_stream(self, stream_data, stream_name):
-            # parse the stream info as json
-            stream_info = parse_json(stream_data.group(1), schema=self.live_schema)
-            # get the stream ID
-            stream_id = stream_info[u"streams"][stream_name][u"stream"]
-
-            if stream_id:
-                api_url = self.API_URL.format(id=stream_id)
-
-                res = http.get(api_url, headers={"User-Agent": self._user_agent})
-                stream_data = http.json(res, schema=self._api_schema)
-
-                for asset in stream_data[u'data'][u'stream'][u'assets']:
-                    for n, s in HLSStream.parse_variant_playlist(self.session, asset[u"url"]).items():
-                        yield n, s
 
-            else:
-                self.logger.error("Couldn't find the stream ID for this stream: {}".format(stream_name))
+        # create a unique list of the stream manifest URLs
+        streams = []
+        urldups = []
+        for stream in parse_xml(res.text, schema=self._vod_api_schema):
+            if stream["url"] not in urldups:
+                streams.append(stream)
+                urldups.append(stream["url"])
+
+        mapper = StreamMapper(lambda fmt, strm: strm["url"].endswith(fmt))
+        mapper.map(".m3u8", self._make_hls_hds_stream, HLSStream.parse_variant_playlist)
+        mapper.map(".f4m", self._make_hls_hds_stream, HDSStream.parse_manifest, is_akamai=True)
+        mapper.map(".mp4", lambda s: (s["bitrate"]+"k", HTTPStream(self.session, s["url"])))
+
+        for q, s in mapper(streams):
+            yield q, s
+
+    def _get_live_stream(self, stream_data, show, episode=None):
+        # parse the stream info as json
+        stream_info = parse_json(stream_data.group(1), schema=self.live_schema)
+        # get the stream ID
+        stream_id = None
+        if episode:
+            for epi in stream_info[u"streams"][show][u"archiveEpisodes"]:
+                if epi[u"slug"] == episode:
+                    stream_id = epi[u"id"]
+        else:
+            stream_id = stream_info[u"streams"][show][u"stream"]
+
+        if stream_id:
+            api_url = self.API_URL.format(id=stream_id)
+
+            res = http.get(api_url, headers={"User-Agent": useragents.SAFARI_8})
+            stream_data = http.json(res, schema=self._api_schema)
+
+            mapper = StreamMapper(lambda fmt, surl: surl.endswith(fmt))
+            mapper.map(".m3u8", HLSStream.parse_variant_playlist, self.session)
+            mapper.map(".f4m", HDSStream.parse_manifest, self.session)
+
+            stream_urls = [asset[u"url"] for asset in stream_data[u'data'][u'stream'][u'assets']]
+            for q, s in mapper(stream_urls):
+                yield q, s
+
+        else:
+            self.logger.error("Couldn't find the stream ID for this stream: {}".format(show))
 
     def _get_streams(self):
         # get the page
-        url_match = self._url_re.match(self.url)
-        show_name = url_match.group(1)
-        stream_name = url_match.group(2) or "live-stream"
+        url_match = self.url_re.match(self.url)
+        live_stream, show_name, episode_name = url_match.groups()
+        if live_stream:
+            show_name = show_name or "live-stream"
 
-        res = http.get(self.url, headers={"User-Agent": self._user_agent})
+        res = http.get(self.url, headers={"User-Agent": useragents.SAFARI_8})
         # find the big blob of stream info in the page
         stream_data = self._stream_data_re.search(res.text)
 
         if stream_data:
-            if show_name == "streams":
-                self.logger.debug("Loading LIVE streams for: {0}", stream_name)
-                return self._get_live_stream(stream_data, stream_name)
+            if live_stream:
+                if episode_name:
+                    self.logger.debug("Loading replay of LIVE streams for: {0}/{1}", show_name, episode_name)
+                else:
+                    self.logger.debug("Loading LIVE streams for: {0}", show_name)
+                streams = self._get_live_stream(stream_data, show_name, episode_name)
             else:
-                self.logger.debug("Loading VOD streams for: {0}/{1}", show_name, stream_name)
-                return self._get_vod_streams(stream_data, show_name, stream_name)
+                self.logger.debug("Loading VOD streams for: {0}/{1}", show_name, episode_name)
+                streams = self._get_show_streams(stream_data, show_name, episode_name)
+
+            # De-dup the streams, some of the mobile streams overlap the desktop streams
+            dups = set()
+            for q, s in streams:
+                if hasattr(s, "args") and "url" in s.args:
+                    if s.args["url"] not in dups:
+                        yield q, s
+                        dups.add(s.args["url"])
+                else:
+                    yield q, s
+
         else:
-            self.logger.error("Couldn't find the stream data for this stream: {}".format(stream_name))
+            self.logger.error("Couldn't find the stream data for this stream: {}".format(show_name))
 
 
 __plugin__ = AdultSwim


Test patch:
diff --git a/tests/test_plugin_adultswim.py b/tests/test_plugin_adultswim.py
index 202b7995..0a3d3400 100644
--- a/tests/test_plugin_adultswim.py
+++ b/tests/test_plugin_adultswim.py
@@ -1,6 +1,10 @@
-import unittest
+import sys
 
 from streamlink.plugins.adultswim import AdultSwim
+if sys.version_info[0:2] == (2, 6):
+    import unittest2 as unittest
+else:
+    import unittest
 
 
 class TestPluginAdultSwim(unittest.TestCase):
@@ -10,7 +14,33 @@ class TestPluginAdultSwim(unittest.TestCase):
         self.assertTrue(AdultSwim.can_handle_url("http://www.adultswim.com/videos/streams/"))
         self.assertTrue(AdultSwim.can_handle_url("http://www.adultswim.com/videos/streams/last-stream-on-the-left"))
         self.assertTrue(AdultSwim.can_handle_url("http://www.adultswim.com/videos/specials/the-adult-swim-golf-classic-extended/"))
+        self.assertTrue(AdultSwim.can_handle_url("http://www.adultswim.com/videos/streams/toonami-pre-flight/friday-december-30th-2016"))
 
         # shouldn't match
         self.assertFalse(AdultSwim.can_handle_url("http://www.tvcatchup.com/"))
         self.assertFalse(AdultSwim.can_handle_url("http://www.youtube.com/"))
+
+    def _test_regex(self, url, expected):
+        m = AdultSwim.url_re.match(url)
+        self.assertIsNotNone(m)
+        self.assertListEqual(expected, list(m.groups()))
+
+    def test_regex_live_stream(self):
+        self._test_regex("http://www.adultswim.com/videos/streams/toonami",
+                         ["streams", "toonami", None])
+
+    def test_regex_live_stream_default(self):
+        self._test_regex("http://www.adultswim.com/videos/streams/",
+                         ["streams", None, None])
+
+    def test_regex_special_vod(self):
+        self._test_regex("http://www.adultswim.com/videos/specials/the-adult-swim-golf-classic-extended/",
+                         [None, "specials", "the-adult-swim-golf-classic-extended"])
+
+    def test_regex_live_replay(self):
+        self._test_regex("http://www.adultswim.com/videos/streams/toonami-pre-flight/friday-december-30th-2016",
+                         ["streams", "toonami-pre-flight", "friday-december-30th-2016"])
+
+    def test_regex_show_vod(self):
+        self._test_regex("http://www.adultswim.com/videos/aqua-teen-hunger-force/vampirus/",
+                         [None, "aqua-teen-hunger-force", "vampirus"])