Repository: streamlink/streamlink

Issue:
Crunchyroll unable to validate API responce.
This has a issue on livestreamer but not here see: https://github.com/chrippa/livestreamer/issues/1492

It may only affect newer content as erased ep. 1-4 work fine but ep. 5+ result in 

> MacBook:~ owner$ streamlink http://www.crunchyroll.com/erased/episode-5-getaway-691801 best
> [cli][info] Found matching plugin crunchyroll for URL http://www.crunchyroll.com/erased/episode-5-getaway-691801
> [plugin.crunchyroll][warning] No authentication provided, you won't be able to access premium restricted content
> error: Unable to validate API response: Unable to validate key 'stream_data': {u'hardsub_lang': u'enUS', u'format': u'hls', u'streams': [{u'url': u'http://serve.cxcdn.net/s/v/1xupsfbghtu0it3/m/a161c1b1d20712dc542b8104a0729401/master.m3u8?v=fcd87a7116303e39f71df0e96ca09a1e&k=MUNHWkhabDZveDk5VGRaQnFkYUFscVFmdHJRPV97ImEiOiI5MSwsamFKUCxlblVTLjEiLCJjIjoxNDc0OTEyNjU0LCJkIjoiY3JhbmltZSIsImciOiJaWiIsImgiOiIxeHVwc2ZiZ2h0dTBpdDMiLCJsIjo3MjAwLCJwIjoiMSIsInIiOiJjMzBkODIiLCJzIjoxNDc1ODEsInQiOjE0NzU3MTE5MTIsInYiOjN9', u'expires': u'2016-10-06T04:57:05+00:00', u'quality': u'adaptive'}, {u'url': u'http://serve.cxcdn.net/s/v/1xupsfbghtu0it3/m/4e405bc9b9d829183da8832912d0e31e/jaJP.m3u8?v=fcd87a7116303e39f71df0e96ca09a1e&k=d3hGSFJEd3lONVZYaEhBT0xPU3hGSHZ1THFVPV97ImEiOiI5MSwxLGphSlAsZW5VUy4xIiwiYyI6MTQ3NDkxMjY1NCwiZCI6ImNyYW5pbWUiLCJnIjoiWloiLCJoIjoiMXh1cHNmYmdodHUwaXQzIiwibCI6NzIwMCwicCI6IjEiLCJyIjoiYzMwZDgyIiwicyI6OTYzMTAxLCJ0IjoxNDc1NzExOTEyLCJ2IjozfQ', u'width': u'432', u'expires': u'2016-10-06T04:57:05+00:00', u'quality': None, u'height': u'240'}, {u'url': u'http://serve.cxcdn.net/s/v/1xupsfbghtu0it3/m/cd9946094d0e536db3ae6650154af915/jaJP.m3u8?v=fcd87a7116303e39f71df0e96ca09a1e&k=dGRWYTdLK1FjaGltcnRSdEYvcTJyaFdpNUpNPV97ImEiOiI5MSwyLGphSlAsZW5VUy4xIiwiYyI6MTQ3NDkxMjY1NCwiZCI6ImNyYW5pbWUiLCJnIjoiWloiLCJoIjoiMXh1cHNmYmdodHUwaXQzIiwibCI6NzIwMCwicCI6IjEiLCJyIjoiYzMwZDgyIiwicyI6OTE4MDcxLCJ0IjoxNDc1NzExOTEyLCJ2IjozfQ', u'width': u'640', u'expires': u'2016-10-06T04:57:05+00:00', u'quality': u'low', u'height': u'360'}, {u'url': u'http://serve.cxcdn.net/s/v/1xupsfbghtu0it3/m/63d92074b50a72d9bc0d14c8995844b9/jaJP.m3u8?v=fcd87a7116303e39f71df0e96ca09a1e&k=WTBkUnZwbEZnMFExRlFlL1ZYT2x6THBYdVdVPV97ImEiOiI5MSwzLGphSlAsZW5VUy4xIiwiYyI6MTQ3NDkxMjY1NCwiZCI6ImNyYW5pbWUiLCJnIjoiWloiLCJoIjoiMXh1cHNmYmdodHUwaXQzIiwibCI6NzIwMCwicCI6IjEiLCJyIjoiYzMwZDgyIiwicyI6ODY3NjIyLCJ0IjoxNDc1NzExOTEyLCJ2IjozfQ', u'width': u'848', u'expires': u'2016-10-06T04:57:05+00:00', u'quality': u'mid', u'height': u'480'}], u'audio_lang': u'jaJP'} does not equal None or Unable to validate key 'streams': Unable to validate key 'quality': Type of None should be 'basestring' but is 'NoneType'



Discussion:
If this is a bug and not crunchyroll registering the video on their end, we should consider implementing some of the code from [here](https://github.com/aheadley/python-crunchyroll), which could be easier than trying to diagnose the crunchyroll plugin.

Even with a """""hotfix""""" (me using validate.transform to turn None into "none"), I still got this out, so there seem to be more underlying issues even.

> ╭─ashlynn@arch ~  
> ╰─➤  streamlink 'http://www.crunchyroll.com/yu-gi-oh-5ds/episode-58-the-destiny-ahead-dark-king-the-ruler-of-hell-667003' best
> [cli][info] Found matching plugin crunchyroll for URL http://www.crunchyroll.com/yu-gi-oh-5ds/episode-58-the-destiny-ahead-dark-king-the-ruler-of-hell-667003
> [plugin.crunchyroll][warning] No authentication provided, you won't be able to access premium restricted content
> [cli][info] Available streams: none, low (worst), mid (best)
> [cli][info] Opening stream: mid (hls)
> [cli][error] Could not open stream: Attempted to play a variant playlist, use 'hlsvariant://http://serve.cxcdn.net/s/v/qk3vxe6ojlocp3k/m/0bcf3b9a953ab4b6aa460796669c9f03/jaJP.m3u8?v=53d5ef6a41b7b9ef4e06844c543d92b0&k=d2J0aWFtczhOcFZsTTJ3STVyb0pWWU5JbEhjPV97ImEiOiI5MSwzLGphSlAsZW5VUy4xIiwiYyI6MTQ3NDkxMjExNCwiZCI6ImNyYW5pbWUiLCJnIjoiWloiLCJoIjoicWszdnhlNm9qbG9jcDNrIiwibCI6NzIwMCwicCI6IjEiLCJyIjoiYzMwZDgyIiwicyI6NTgzNjI5LCJ0IjoxNDc1NzY2Mjc0LCJ2IjozfQ' instead


Resolving patch:
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -58,13 +58,12 @@ def parse_timestamp(ts):
             {
                 "streams": validate.all(
                     [{
-                        "quality": validate.text,
+                        "quality": validate.any(validate.text, None),
                         "url": validate.url(
                             scheme="http",
                             path=validate.endswith(".m3u8")
                         )
-                    }],
-                    validate.filter(lambda s: s["quality"] != "adaptive")
+                    }]
                 )
             }
         )
@@ -227,11 +226,24 @@ def _get_streams(self):
         if not info:
             return
 
-        # TODO: Use dict comprehension here after dropping Python 2.6 support.
-        return dict(
-            (stream["quality"], HLSStream(self.session, stream["url"]))
-            for stream in info["streams"]
-        )
+        # The adaptive quality stream contains a superset of all the other streams listeed
+        has_adaptive = any([s[u"quality"] == u"adaptive" for s in info[u"streams"]])
+        if has_adaptive:
+            self.logger.debug(u"Loading streams from adaptive playlist")
+            for stream in filter(lambda x: x[u"quality"] == u"adaptive", info[u"streams"]):
+                return HLSStream.parse_variant_playlist(self.session, stream["url"])
+        else:
+            streams = {}
+            # If there is no adaptive quality stream then parse each individual result
+            for stream in info[u"streams"]:
+                # the video_encode_id indicates that the stream is not a variant playlist
+                if u"video_encode_id" in stream:
+                    streams[stream[u"quality"]] = HLSStream(self.session, stream[u"url"])
+                else:
+                    # otherwise the stream url is actually a list of stream qualities
+                    streams.update(HLSStream.parse_variant_playlist(self.session, stream[u"url"]))
+
+            return streams
 
     def _get_device_id(self):
         """Returns the saved device id or creates a new one and saves it."""


Test patch: