We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Crunchyroll can't find 1080p streams
The current plugin seems to only find streams up to 720p.
However I tested the old plugin and it seems to be working again I tested the links I used in #70 and a few more but couldn't reproduce the issue with the old plugin.
More testing may be needed but it's more than likely safe to revert back.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `src/streamlink/plugins/crunchyroll.py`
Content:
```
1 import random
2 import re
3 import string
4 import datetime
5 
6 from streamlink.plugin import Plugin, PluginError, PluginOptions
7 from streamlink.plugin.api import http, validate
8 from streamlink.stream import HLSStream
9 
10 API_URL = "https://api.crunchyroll.com/{0}.0.json"
11 API_DEFAULT_LOCALE = "en_US"
12 API_USER_AGENT = "Mozilla/5.0 (iPhone; iPhone OS 8.3.0; {})"
13 API_HEADERS = {
14     "Host": "api.crunchyroll.com",
15     "Accept-Encoding": "gzip, deflate",
16     "Accept": "*/*",
17     "Content-Type": "application/x-www-form-urlencoded"
18 }
19 API_VERSION = "2313.8"
20 API_ACCESS_TOKEN = "QWjz212GspMHH9h"
21 API_DEVICE_TYPE = "com.crunchyroll.iphone"
22 STREAM_WEIGHTS = {
23     "low": 240,
24     "mid": 420,
25     "high": 720,
26     "ultra": 1080,
27 }
28 
29 
30 def parse_timestamp(ts):
31     """Takes ISO 8601 format(string) and converts into a utc datetime(naive)"""
32     return (
33         datetime.datetime.strptime(ts[:-7], "%Y-%m-%dT%H:%M:%S") +
34         datetime.timedelta(hours=int(ts[-5:-3]), minutes=int(ts[-2:])) *
35         int(ts[-6:-5] + "1")
36     )
37 
38 
39 _url_re = re.compile("""
40     http(s)?://(\w+\.)?crunchyroll\.
41     (?:
42         com|de|es|fr|co.jp
43     )
44     /[^/&?]+
45     /[^/&?]+-(?P<media_id>\d+)
46 """, re.VERBOSE)
47 
48 _api_schema = validate.Schema({
49     "error": bool,
50     validate.optional("code"): validate.text,
51     validate.optional("message"): validate.text,
52     validate.optional("data"): object,
53 })
54 _media_schema = validate.Schema(
55     {
56         "stream_data": validate.any(
57             None,
58             {
59                 "streams": validate.all(
60                     [{
61                         "quality": validate.any(validate.text, None),
62                         "url": validate.url(
63                             scheme="http",
64                             path=validate.endswith(".m3u8")
65                         )
66                     }]
67                 )
68             }
69         )
70     },
71     validate.get("stream_data")
72 )
73 _login_schema = validate.Schema({
74     "auth": validate.text,
75     "expires": validate.all(
76         validate.text,
77         validate.transform(parse_timestamp)
78     ),
79     "user": {
80         "username": validate.text
81     }
82 })
83 _session_schema = validate.Schema(
84     {
85         "session_id": validate.text
86     },
87     validate.get("session_id")
88 )
89 
90 
91 class CrunchyrollAPIError(Exception):
92     """Exception thrown by the Crunchyroll API when an error occurs"""
93     def __init__(self, msg, code):
94         Exception.__init__(self, msg)
95         self.msg = msg
96         self.code = code
97 
98 
99 class CrunchyrollAPI(object):
100     def __init__(self, session_id=None, auth=None, locale=API_DEFAULT_LOCALE):
101         """Abstract the API to access to Crunchyroll data.
102 
103         Can take saved credentials to use on it's calls to the API.
104         """
105         self.session_id = session_id
106         self.auth = auth
107         self.locale = locale
108 
109     def _api_call(self, entrypoint, params, schema=None):
110         """Makes a call against the api.
111 
112         :param entrypoint: API method to call.
113         :param params: parameters to include in the request data.
114         :param schema: schema to use to validate the data
115         """
116         url = API_URL.format(entrypoint)
117 
118         # Default params
119         params = dict(params)
120         params.update({
121             "version": API_VERSION,
122             "locale": self.locale.replace('_', ''),
123         })
124 
125         if self.session_id:
126             params["session_id"] = self.session_id
127 
128         # Headers
129         headers = dict(API_HEADERS)
130         headers['User-Agent'] = API_USER_AGENT.format(self.locale)
131 
132         # The certificate used by Crunchyroll cannot be verified in some environments.
133         res = http.get(url, params=params, headers=headers, verify=False)
134         json_res = http.json(res, schema=_api_schema)
135 
136         if json_res["error"]:
137             err_msg = json_res.get("message", "Unknown error")
138             err_code = json_res.get("code", "unknown_error")
139             raise CrunchyrollAPIError(err_msg, err_code)
140 
141         data = json_res.get("data")
142         if schema:
143             data = schema.validate(data, name="API response")
144 
145         return data
146 
147     def start_session(self, device_id, **kwargs):
148         """Starts a session against Crunchyroll's server.
149 
150         Is recommended that you call this method before making any other calls
151         to make sure you have a valid session against the server.
152         """
153         params = {
154             "device_id": device_id,
155             "device_type": API_DEVICE_TYPE,
156             "access_token": API_ACCESS_TOKEN,
157         }
158 
159         if self.auth:
160             params["auth"] = self.auth
161 
162         return self._api_call("start_session", params, **kwargs)
163 
164     def login(self, username, password, **kwargs):
165         """Authenticates the session to be able to access restricted data from
166         the server (e.g. premium restricted videos).
167         """
168         params = {
169             "account": username,
170             "password": password
171         }
172 
173         return self._api_call("login", params, **kwargs)
174 
175     def get_info(self, media_id, fields=None, **kwargs):
176         """Returns the data for a certain media item.
177 
178         :param media_id: id that identifies the media item to be accessed.
179         :param fields: list of the media"s field to be returned. By default the
180         API returns some fields, but others are not returned unless they are
181         explicity asked for. I have no real documentation on the fields, but
182         they all seem to start with the "media." prefix (e.g. media.name,
183         media.stream_data).
184         """
185         params = {
186             "media_id": media_id
187         }
188 
189         if fields:
190             params["fields"] = ",".join(fields)
191 
192         return self._api_call("info", params, **kwargs)
193 
194 
195 class Crunchyroll(Plugin):
196     options = PluginOptions({
197         "username": None,
198         "password": None,
199         "purge_credentials": None,
200         "locale": API_DEFAULT_LOCALE
201     })
202 
203     @classmethod
204     def can_handle_url(self, url):
205         return _url_re.match(url)
206 
207     @classmethod
208     def stream_weight(cls, key):
209         weight = STREAM_WEIGHTS.get(key)
210         if weight:
211             return weight, "crunchyroll"
212 
213         return Plugin.stream_weight(key)
214 
215     def _get_streams(self):
216         api = self._create_api()
217         match = _url_re.match(self.url)
218         media_id = int(match.group("media_id"))
219 
220         try:
221             info = api.get_info(media_id, fields=["media.stream_data"],
222                                 schema=_media_schema)
223         except CrunchyrollAPIError as err:
224             raise PluginError(u"Media lookup error: {0}".format(err.msg))
225 
226         if not info:
227             return
228 
229         # The adaptive quality stream contains a superset of all the other streams listeed
230         has_adaptive = any([s[u"quality"] == u"adaptive" for s in info[u"streams"]])
231         if has_adaptive:
232             self.logger.debug(u"Loading streams from adaptive playlist")
233             for stream in filter(lambda x: x[u"quality"] == u"adaptive", info[u"streams"]):
234                 return HLSStream.parse_variant_playlist(self.session, stream["url"])
235         else:
236             streams = {}
237             # If there is no adaptive quality stream then parse each individual result
238             for stream in info[u"streams"]:
239                 # the video_encode_id indicates that the stream is not a variant playlist
240                 if u"video_encode_id" in stream:
241                     streams[stream[u"quality"]] = HLSStream(self.session, stream[u"url"])
242                 else:
243                     # otherwise the stream url is actually a list of stream qualities
244                     streams.update(HLSStream.parse_variant_playlist(self.session, stream[u"url"]))
245 
246             return streams
247 
248     def _get_device_id(self):
249         """Returns the saved device id or creates a new one and saves it."""
250         device_id = self.cache.get("device_id")
251 
252         if not device_id:
253             # Create a random device id and cache it for a year
254             char_set = string.ascii_letters + string.digits
255             device_id = "".join(random.sample(char_set, 32))
256             self.cache.set("device_id", device_id, 365 * 24 * 60 * 60)
257 
258         return device_id
259 
260     def _create_api(self):
261         """Creates a new CrunchyrollAPI object, initiates it's session and
262         tries to authenticate it either by using saved credentials or the
263         user's username and password.
264         """
265         if self.options.get("purge_credentials"):
266             self.cache.set("session_id", None, 0)
267             self.cache.set("auth", None, 0)
268 
269         current_time = datetime.datetime.utcnow()
270         device_id = self._get_device_id()
271         locale = self.options.get("locale")
272         api = CrunchyrollAPI(
273             self.cache.get("session_id"), self.cache.get("auth"), locale
274         )
275 
276         self.logger.debug("Creating session")
277         try:
278             api.session_id = api.start_session(device_id, schema=_session_schema)
279         except CrunchyrollAPIError as err:
280             if err.code == "bad_session":
281                 self.logger.debug("Current session has expired, creating a new one")
282                 api = CrunchyrollAPI(locale=locale)
283                 api.session_id = api.start_session(device_id, schema=_session_schema)
284             else:
285                 raise err
286 
287         # Save session and hope it lasts for a few hours
288         self.cache.set("session_id", api.session_id, 4 * 60 * 60)
289         self.logger.debug("Session created")
290 
291         if api.auth:
292             self.logger.debug("Using saved credentials")
293         elif self.options.get("username"):
294             try:
295                 self.logger.info("Attempting to login using username and password")
296                 login = api.login(
297                     self.options.get("username"),
298                     self.options.get("password"),
299                     schema=_login_schema
300                 )
301                 api.auth = login["auth"]
302 
303                 self.logger.info("Successfully logged in as '{0}'",
304                                  login["user"]["username"])
305 
306                 expires = (login["expires"] - current_time).total_seconds()
307                 self.cache.set("auth", login["auth"], expires)
308             except CrunchyrollAPIError as err:
309                 raise PluginError(u"Authentication error: {0}".format(err.msg))
310         else:
311             self.logger.warning(
312                 "No authentication provided, you won't be able to access "
313                 "premium restricted content"
314             )
315 
316         return api
317 
318 __plugin__ = Crunchyroll
319
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

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
@@ -25,6 +25,11 @@
     "high": 720,
     "ultra": 1080,
 }
+STREAM_NAMES = {
+    "120k": "low",
+    "328k": "mid",
+    "864k": "high"
+}
 
 
 def parse_timestamp(ts):
@@ -62,7 +67,8 @@
                         "url": validate.url(
                             scheme="http",
                             path=validate.endswith(".m3u8")
-                        )
+                        ),
+                        validate.optional("video_encode_id"): validate.text
                     }]
                 )
             }
@@ -77,7 +83,8 @@
         validate.transform(parse_timestamp)
     ),
     "user": {
-        "username": validate.text
+        "username": validate.any(validate.text, None),
+        "email": validate.text
     }
 })
 _session_schema = validate.Schema(
@@ -226,24 +233,32 @@
         if not info:
             return
 
-        # The adaptive quality stream contains a superset of all the other streams listeed
+        streams = {}
+
+        # The adaptive quality stream sometimes a subset of all the other streams listed, ultra is no included
         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"]:
+                for q, s in HLSStream.parse_variant_playlist(self.session, stream[u"url"]).items():
+                    # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams
+                    name = STREAM_NAMES.get(q, q)
+                    streams[name] = s
+
+        # If there is no adaptive quality stream then parse each individual result
+        for stream in info[u"streams"]:
+            if stream[u"quality"] != u"adaptive":
                 # 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"]))
+                    for q, s in HLSStream.parse_variant_playlist(self.session, stream[u"url"]).items():
+                        # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams
+                        name = STREAM_NAMES.get(q, q)
+                        streams[name] = s
 
-            return streams
+        return streams
 
     def _get_device_id(self):
         """Returns the saved device id or creates a new one and saves it."""
@@ -301,7 +316,7 @@
                 api.auth = login["auth"]
 
                 self.logger.info("Successfully logged in as '{0}'",
-                                 login["user"]["username"])
+                                 login["user"]["username"] or login["user"]["email"])
 
                 expires = (login["expires"] - current_time).total_seconds()
                 self.cache.set("auth", login["auth"], expires)

{"golden_diff": "diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py\n--- a/src/streamlink/plugins/crunchyroll.py\n+++ b/src/streamlink/plugins/crunchyroll.py\n@@ -25,6 +25,11 @@\n     \"high\": 720,\n     \"ultra\": 1080,\n }\n+STREAM_NAMES = {\n+    \"120k\": \"low\",\n+    \"328k\": \"mid\",\n+    \"864k\": \"high\"\n+}\n \n \n def parse_timestamp(ts):\n@@ -62,7 +67,8 @@\n                         \"url\": validate.url(\n                             scheme=\"http\",\n                             path=validate.endswith(\".m3u8\")\n-                        )\n+                        ),\n+                        validate.optional(\"video_encode_id\"): validate.text\n                     }]\n                 )\n             }\n@@ -77,7 +83,8 @@\n         validate.transform(parse_timestamp)\n     ),\n     \"user\": {\n-        \"username\": validate.text\n+        \"username\": validate.any(validate.text, None),\n+        \"email\": validate.text\n     }\n })\n _session_schema = validate.Schema(\n@@ -226,24 +233,32 @@\n         if not info:\n             return\n \n-        # The adaptive quality stream contains a superset of all the other streams listeed\n+        streams = {}\n+\n+        # The adaptive quality stream sometimes a subset of all the other streams listed, ultra is no included\n         has_adaptive = any([s[u\"quality\"] == u\"adaptive\" for s in info[u\"streams\"]])\n         if has_adaptive:\n             self.logger.debug(u\"Loading streams from adaptive playlist\")\n             for stream in filter(lambda x: x[u\"quality\"] == u\"adaptive\", info[u\"streams\"]):\n-                return HLSStream.parse_variant_playlist(self.session, stream[\"url\"])\n-        else:\n-            streams = {}\n-            # If there is no adaptive quality stream then parse each individual result\n-            for stream in info[u\"streams\"]:\n+                for q, s in HLSStream.parse_variant_playlist(self.session, stream[u\"url\"]).items():\n+                    # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams\n+                    name = STREAM_NAMES.get(q, q)\n+                    streams[name] = s\n+\n+        # If there is no adaptive quality stream then parse each individual result\n+        for stream in info[u\"streams\"]:\n+            if stream[u\"quality\"] != u\"adaptive\":\n                 # the video_encode_id indicates that the stream is not a variant playlist\n                 if u\"video_encode_id\" in stream:\n                     streams[stream[u\"quality\"]] = HLSStream(self.session, stream[u\"url\"])\n                 else:\n                     # otherwise the stream url is actually a list of stream qualities\n-                    streams.update(HLSStream.parse_variant_playlist(self.session, stream[u\"url\"]))\n+                    for q, s in HLSStream.parse_variant_playlist(self.session, stream[u\"url\"]).items():\n+                        # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams\n+                        name = STREAM_NAMES.get(q, q)\n+                        streams[name] = s\n \n-            return streams\n+        return streams\n \n     def _get_device_id(self):\n         \"\"\"Returns the saved device id or creates a new one and saves it.\"\"\"\n@@ -301,7 +316,7 @@\n                 api.auth = login[\"auth\"]\n \n                 self.logger.info(\"Successfully logged in as '{0}'\",\n-                                 login[\"user\"][\"username\"])\n+                                 login[\"user\"][\"username\"] or login[\"user\"][\"email\"])\n \n                 expires = (login[\"expires\"] - current_time).total_seconds()\n                 self.cache.set(\"auth\", login[\"auth\"], expires)\n", "issue": "Crunchyroll can't find 1080p streams\nThe current plugin seems to only find streams up to 720p.\r\nHowever I tested the old plugin and it seems to be working again I tested the links I used in #70 and a few more but couldn't reproduce the issue with the old plugin.\r\nMore testing may be needed but it's more than likely safe to revert back.\n", "before_files": [{"content": "import random\nimport re\nimport string\nimport datetime\n\nfrom streamlink.plugin import Plugin, PluginError, PluginOptions\nfrom streamlink.plugin.api import http, validate\nfrom streamlink.stream import HLSStream\n\nAPI_URL = \"https://api.crunchyroll.com/{0}.0.json\"\nAPI_DEFAULT_LOCALE = \"en_US\"\nAPI_USER_AGENT = \"Mozilla/5.0 (iPhone; iPhone OS 8.3.0; {})\"\nAPI_HEADERS = {\n    \"Host\": \"api.crunchyroll.com\",\n    \"Accept-Encoding\": \"gzip, deflate\",\n    \"Accept\": \"*/*\",\n    \"Content-Type\": \"application/x-www-form-urlencoded\"\n}\nAPI_VERSION = \"2313.8\"\nAPI_ACCESS_TOKEN = \"QWjz212GspMHH9h\"\nAPI_DEVICE_TYPE = \"com.crunchyroll.iphone\"\nSTREAM_WEIGHTS = {\n    \"low\": 240,\n    \"mid\": 420,\n    \"high\": 720,\n    \"ultra\": 1080,\n}\n\n\ndef parse_timestamp(ts):\n    \"\"\"Takes ISO 8601 format(string) and converts into a utc datetime(naive)\"\"\"\n    return (\n        datetime.datetime.strptime(ts[:-7], \"%Y-%m-%dT%H:%M:%S\") +\n        datetime.timedelta(hours=int(ts[-5:-3]), minutes=int(ts[-2:])) *\n        int(ts[-6:-5] + \"1\")\n    )\n\n\n_url_re = re.compile(\"\"\"\n    http(s)?://(\\w+\\.)?crunchyroll\\.\n    (?:\n        com|de|es|fr|co.jp\n    )\n    /[^/&?]+\n    /[^/&?]+-(?P<media_id>\\d+)\n\"\"\", re.VERBOSE)\n\n_api_schema = validate.Schema({\n    \"error\": bool,\n    validate.optional(\"code\"): validate.text,\n    validate.optional(\"message\"): validate.text,\n    validate.optional(\"data\"): object,\n})\n_media_schema = validate.Schema(\n    {\n        \"stream_data\": validate.any(\n            None,\n            {\n                \"streams\": validate.all(\n                    [{\n                        \"quality\": validate.any(validate.text, None),\n                        \"url\": validate.url(\n                            scheme=\"http\",\n                            path=validate.endswith(\".m3u8\")\n                        )\n                    }]\n                )\n            }\n        )\n    },\n    validate.get(\"stream_data\")\n)\n_login_schema = validate.Schema({\n    \"auth\": validate.text,\n    \"expires\": validate.all(\n        validate.text,\n        validate.transform(parse_timestamp)\n    ),\n    \"user\": {\n        \"username\": validate.text\n    }\n})\n_session_schema = validate.Schema(\n    {\n        \"session_id\": validate.text\n    },\n    validate.get(\"session_id\")\n)\n\n\nclass CrunchyrollAPIError(Exception):\n    \"\"\"Exception thrown by the Crunchyroll API when an error occurs\"\"\"\n    def __init__(self, msg, code):\n        Exception.__init__(self, msg)\n        self.msg = msg\n        self.code = code\n\n\nclass CrunchyrollAPI(object):\n    def __init__(self, session_id=None, auth=None, locale=API_DEFAULT_LOCALE):\n        \"\"\"Abstract the API to access to Crunchyroll data.\n\n        Can take saved credentials to use on it's calls to the API.\n        \"\"\"\n        self.session_id = session_id\n        self.auth = auth\n        self.locale = locale\n\n    def _api_call(self, entrypoint, params, schema=None):\n        \"\"\"Makes a call against the api.\n\n        :param entrypoint: API method to call.\n        :param params: parameters to include in the request data.\n        :param schema: schema to use to validate the data\n        \"\"\"\n        url = API_URL.format(entrypoint)\n\n        # Default params\n        params = dict(params)\n        params.update({\n            \"version\": API_VERSION,\n            \"locale\": self.locale.replace('_', ''),\n        })\n\n        if self.session_id:\n            params[\"session_id\"] = self.session_id\n\n        # Headers\n        headers = dict(API_HEADERS)\n        headers['User-Agent'] = API_USER_AGENT.format(self.locale)\n\n        # The certificate used by Crunchyroll cannot be verified in some environments.\n        res = http.get(url, params=params, headers=headers, verify=False)\n        json_res = http.json(res, schema=_api_schema)\n\n        if json_res[\"error\"]:\n            err_msg = json_res.get(\"message\", \"Unknown error\")\n            err_code = json_res.get(\"code\", \"unknown_error\")\n            raise CrunchyrollAPIError(err_msg, err_code)\n\n        data = json_res.get(\"data\")\n        if schema:\n            data = schema.validate(data, name=\"API response\")\n\n        return data\n\n    def start_session(self, device_id, **kwargs):\n        \"\"\"Starts a session against Crunchyroll's server.\n\n        Is recommended that you call this method before making any other calls\n        to make sure you have a valid session against the server.\n        \"\"\"\n        params = {\n            \"device_id\": device_id,\n            \"device_type\": API_DEVICE_TYPE,\n            \"access_token\": API_ACCESS_TOKEN,\n        }\n\n        if self.auth:\n            params[\"auth\"] = self.auth\n\n        return self._api_call(\"start_session\", params, **kwargs)\n\n    def login(self, username, password, **kwargs):\n        \"\"\"Authenticates the session to be able to access restricted data from\n        the server (e.g. premium restricted videos).\n        \"\"\"\n        params = {\n            \"account\": username,\n            \"password\": password\n        }\n\n        return self._api_call(\"login\", params, **kwargs)\n\n    def get_info(self, media_id, fields=None, **kwargs):\n        \"\"\"Returns the data for a certain media item.\n\n        :param media_id: id that identifies the media item to be accessed.\n        :param fields: list of the media\"s field to be returned. By default the\n        API returns some fields, but others are not returned unless they are\n        explicity asked for. I have no real documentation on the fields, but\n        they all seem to start with the \"media.\" prefix (e.g. media.name,\n        media.stream_data).\n        \"\"\"\n        params = {\n            \"media_id\": media_id\n        }\n\n        if fields:\n            params[\"fields\"] = \",\".join(fields)\n\n        return self._api_call(\"info\", params, **kwargs)\n\n\nclass Crunchyroll(Plugin):\n    options = PluginOptions({\n        \"username\": None,\n        \"password\": None,\n        \"purge_credentials\": None,\n        \"locale\": API_DEFAULT_LOCALE\n    })\n\n    @classmethod\n    def can_handle_url(self, url):\n        return _url_re.match(url)\n\n    @classmethod\n    def stream_weight(cls, key):\n        weight = STREAM_WEIGHTS.get(key)\n        if weight:\n            return weight, \"crunchyroll\"\n\n        return Plugin.stream_weight(key)\n\n    def _get_streams(self):\n        api = self._create_api()\n        match = _url_re.match(self.url)\n        media_id = int(match.group(\"media_id\"))\n\n        try:\n            info = api.get_info(media_id, fields=[\"media.stream_data\"],\n                                schema=_media_schema)\n        except CrunchyrollAPIError as err:\n            raise PluginError(u\"Media lookup error: {0}\".format(err.msg))\n\n        if not info:\n            return\n\n        # The adaptive quality stream contains a superset of all the other streams listeed\n        has_adaptive = any([s[u\"quality\"] == u\"adaptive\" for s in info[u\"streams\"]])\n        if has_adaptive:\n            self.logger.debug(u\"Loading streams from adaptive playlist\")\n            for stream in filter(lambda x: x[u\"quality\"] == u\"adaptive\", info[u\"streams\"]):\n                return HLSStream.parse_variant_playlist(self.session, stream[\"url\"])\n        else:\n            streams = {}\n            # If there is no adaptive quality stream then parse each individual result\n            for stream in info[u\"streams\"]:\n                # the video_encode_id indicates that the stream is not a variant playlist\n                if u\"video_encode_id\" in stream:\n                    streams[stream[u\"quality\"]] = HLSStream(self.session, stream[u\"url\"])\n                else:\n                    # otherwise the stream url is actually a list of stream qualities\n                    streams.update(HLSStream.parse_variant_playlist(self.session, stream[u\"url\"]))\n\n            return streams\n\n    def _get_device_id(self):\n        \"\"\"Returns the saved device id or creates a new one and saves it.\"\"\"\n        device_id = self.cache.get(\"device_id\")\n\n        if not device_id:\n            # Create a random device id and cache it for a year\n            char_set = string.ascii_letters + string.digits\n            device_id = \"\".join(random.sample(char_set, 32))\n            self.cache.set(\"device_id\", device_id, 365 * 24 * 60 * 60)\n\n        return device_id\n\n    def _create_api(self):\n        \"\"\"Creates a new CrunchyrollAPI object, initiates it's session and\n        tries to authenticate it either by using saved credentials or the\n        user's username and password.\n        \"\"\"\n        if self.options.get(\"purge_credentials\"):\n            self.cache.set(\"session_id\", None, 0)\n            self.cache.set(\"auth\", None, 0)\n\n        current_time = datetime.datetime.utcnow()\n        device_id = self._get_device_id()\n        locale = self.options.get(\"locale\")\n        api = CrunchyrollAPI(\n            self.cache.get(\"session_id\"), self.cache.get(\"auth\"), locale\n        )\n\n        self.logger.debug(\"Creating session\")\n        try:\n            api.session_id = api.start_session(device_id, schema=_session_schema)\n        except CrunchyrollAPIError as err:\n            if err.code == \"bad_session\":\n                self.logger.debug(\"Current session has expired, creating a new one\")\n                api = CrunchyrollAPI(locale=locale)\n                api.session_id = api.start_session(device_id, schema=_session_schema)\n            else:\n                raise err\n\n        # Save session and hope it lasts for a few hours\n        self.cache.set(\"session_id\", api.session_id, 4 * 60 * 60)\n        self.logger.debug(\"Session created\")\n\n        if api.auth:\n            self.logger.debug(\"Using saved credentials\")\n        elif self.options.get(\"username\"):\n            try:\n                self.logger.info(\"Attempting to login using username and password\")\n                login = api.login(\n                    self.options.get(\"username\"),\n                    self.options.get(\"password\"),\n                    schema=_login_schema\n                )\n                api.auth = login[\"auth\"]\n\n                self.logger.info(\"Successfully logged in as '{0}'\",\n                                 login[\"user\"][\"username\"])\n\n                expires = (login[\"expires\"] - current_time).total_seconds()\n                self.cache.set(\"auth\", login[\"auth\"], expires)\n            except CrunchyrollAPIError as err:\n                raise PluginError(u\"Authentication error: {0}\".format(err.msg))\n        else:\n            self.logger.warning(\n                \"No authentication provided, you won't be able to access \"\n                \"premium restricted content\"\n            )\n\n        return api\n\n__plugin__ = Crunchyroll\n", "path": "src/streamlink/plugins/crunchyroll.py"}], "after_files": [{"content": "import random\nimport re\nimport string\nimport datetime\n\nfrom streamlink.plugin import Plugin, PluginError, PluginOptions\nfrom streamlink.plugin.api import http, validate\nfrom streamlink.stream import HLSStream\n\nAPI_URL = \"https://api.crunchyroll.com/{0}.0.json\"\nAPI_DEFAULT_LOCALE = \"en_US\"\nAPI_USER_AGENT = \"Mozilla/5.0 (iPhone; iPhone OS 8.3.0; {})\"\nAPI_HEADERS = {\n    \"Host\": \"api.crunchyroll.com\",\n    \"Accept-Encoding\": \"gzip, deflate\",\n    \"Accept\": \"*/*\",\n    \"Content-Type\": \"application/x-www-form-urlencoded\"\n}\nAPI_VERSION = \"2313.8\"\nAPI_ACCESS_TOKEN = \"QWjz212GspMHH9h\"\nAPI_DEVICE_TYPE = \"com.crunchyroll.iphone\"\nSTREAM_WEIGHTS = {\n    \"low\": 240,\n    \"mid\": 420,\n    \"high\": 720,\n    \"ultra\": 1080,\n}\nSTREAM_NAMES = {\n    \"120k\": \"low\",\n    \"328k\": \"mid\",\n    \"864k\": \"high\"\n}\n\n\ndef parse_timestamp(ts):\n    \"\"\"Takes ISO 8601 format(string) and converts into a utc datetime(naive)\"\"\"\n    return (\n        datetime.datetime.strptime(ts[:-7], \"%Y-%m-%dT%H:%M:%S\") +\n        datetime.timedelta(hours=int(ts[-5:-3]), minutes=int(ts[-2:])) *\n        int(ts[-6:-5] + \"1\")\n    )\n\n\n_url_re = re.compile(\"\"\"\n    http(s)?://(\\w+\\.)?crunchyroll\\.\n    (?:\n        com|de|es|fr|co.jp\n    )\n    /[^/&?]+\n    /[^/&?]+-(?P<media_id>\\d+)\n\"\"\", re.VERBOSE)\n\n_api_schema = validate.Schema({\n    \"error\": bool,\n    validate.optional(\"code\"): validate.text,\n    validate.optional(\"message\"): validate.text,\n    validate.optional(\"data\"): object,\n})\n_media_schema = validate.Schema(\n    {\n        \"stream_data\": validate.any(\n            None,\n            {\n                \"streams\": validate.all(\n                    [{\n                        \"quality\": validate.any(validate.text, None),\n                        \"url\": validate.url(\n                            scheme=\"http\",\n                            path=validate.endswith(\".m3u8\")\n                        ),\n                        validate.optional(\"video_encode_id\"): validate.text\n                    }]\n                )\n            }\n        )\n    },\n    validate.get(\"stream_data\")\n)\n_login_schema = validate.Schema({\n    \"auth\": validate.text,\n    \"expires\": validate.all(\n        validate.text,\n        validate.transform(parse_timestamp)\n    ),\n    \"user\": {\n        \"username\": validate.any(validate.text, None),\n        \"email\": validate.text\n    }\n})\n_session_schema = validate.Schema(\n    {\n        \"session_id\": validate.text\n    },\n    validate.get(\"session_id\")\n)\n\n\nclass CrunchyrollAPIError(Exception):\n    \"\"\"Exception thrown by the Crunchyroll API when an error occurs\"\"\"\n    def __init__(self, msg, code):\n        Exception.__init__(self, msg)\n        self.msg = msg\n        self.code = code\n\n\nclass CrunchyrollAPI(object):\n    def __init__(self, session_id=None, auth=None, locale=API_DEFAULT_LOCALE):\n        \"\"\"Abstract the API to access to Crunchyroll data.\n\n        Can take saved credentials to use on it's calls to the API.\n        \"\"\"\n        self.session_id = session_id\n        self.auth = auth\n        self.locale = locale\n\n    def _api_call(self, entrypoint, params, schema=None):\n        \"\"\"Makes a call against the api.\n\n        :param entrypoint: API method to call.\n        :param params: parameters to include in the request data.\n        :param schema: schema to use to validate the data\n        \"\"\"\n        url = API_URL.format(entrypoint)\n\n        # Default params\n        params = dict(params)\n        params.update({\n            \"version\": API_VERSION,\n            \"locale\": self.locale.replace('_', ''),\n        })\n\n        if self.session_id:\n            params[\"session_id\"] = self.session_id\n\n        # Headers\n        headers = dict(API_HEADERS)\n        headers['User-Agent'] = API_USER_AGENT.format(self.locale)\n\n        # The certificate used by Crunchyroll cannot be verified in some environments.\n        res = http.get(url, params=params, headers=headers, verify=False)\n        json_res = http.json(res, schema=_api_schema)\n\n        if json_res[\"error\"]:\n            err_msg = json_res.get(\"message\", \"Unknown error\")\n            err_code = json_res.get(\"code\", \"unknown_error\")\n            raise CrunchyrollAPIError(err_msg, err_code)\n\n        data = json_res.get(\"data\")\n        if schema:\n            data = schema.validate(data, name=\"API response\")\n\n        return data\n\n    def start_session(self, device_id, **kwargs):\n        \"\"\"Starts a session against Crunchyroll's server.\n\n        Is recommended that you call this method before making any other calls\n        to make sure you have a valid session against the server.\n        \"\"\"\n        params = {\n            \"device_id\": device_id,\n            \"device_type\": API_DEVICE_TYPE,\n            \"access_token\": API_ACCESS_TOKEN,\n        }\n\n        if self.auth:\n            params[\"auth\"] = self.auth\n\n        return self._api_call(\"start_session\", params, **kwargs)\n\n    def login(self, username, password, **kwargs):\n        \"\"\"Authenticates the session to be able to access restricted data from\n        the server (e.g. premium restricted videos).\n        \"\"\"\n        params = {\n            \"account\": username,\n            \"password\": password\n        }\n\n        return self._api_call(\"login\", params, **kwargs)\n\n    def get_info(self, media_id, fields=None, **kwargs):\n        \"\"\"Returns the data for a certain media item.\n\n        :param media_id: id that identifies the media item to be accessed.\n        :param fields: list of the media\"s field to be returned. By default the\n        API returns some fields, but others are not returned unless they are\n        explicity asked for. I have no real documentation on the fields, but\n        they all seem to start with the \"media.\" prefix (e.g. media.name,\n        media.stream_data).\n        \"\"\"\n        params = {\n            \"media_id\": media_id\n        }\n\n        if fields:\n            params[\"fields\"] = \",\".join(fields)\n\n        return self._api_call(\"info\", params, **kwargs)\n\n\nclass Crunchyroll(Plugin):\n    options = PluginOptions({\n        \"username\": None,\n        \"password\": None,\n        \"purge_credentials\": None,\n        \"locale\": API_DEFAULT_LOCALE\n    })\n\n    @classmethod\n    def can_handle_url(self, url):\n        return _url_re.match(url)\n\n    @classmethod\n    def stream_weight(cls, key):\n        weight = STREAM_WEIGHTS.get(key)\n        if weight:\n            return weight, \"crunchyroll\"\n\n        return Plugin.stream_weight(key)\n\n    def _get_streams(self):\n        api = self._create_api()\n        match = _url_re.match(self.url)\n        media_id = int(match.group(\"media_id\"))\n\n        try:\n            info = api.get_info(media_id, fields=[\"media.stream_data\"],\n                                schema=_media_schema)\n        except CrunchyrollAPIError as err:\n            raise PluginError(u\"Media lookup error: {0}\".format(err.msg))\n\n        if not info:\n            return\n\n        streams = {}\n\n        # The adaptive quality stream sometimes a subset of all the other streams listed, ultra is no included\n        has_adaptive = any([s[u\"quality\"] == u\"adaptive\" for s in info[u\"streams\"]])\n        if has_adaptive:\n            self.logger.debug(u\"Loading streams from adaptive playlist\")\n            for stream in filter(lambda x: x[u\"quality\"] == u\"adaptive\", info[u\"streams\"]):\n                for q, s in HLSStream.parse_variant_playlist(self.session, stream[u\"url\"]).items():\n                    # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams\n                    name = STREAM_NAMES.get(q, q)\n                    streams[name] = s\n\n        # If there is no adaptive quality stream then parse each individual result\n        for stream in info[u\"streams\"]:\n            if stream[u\"quality\"] != u\"adaptive\":\n                # the video_encode_id indicates that the stream is not a variant playlist\n                if u\"video_encode_id\" in stream:\n                    streams[stream[u\"quality\"]] = HLSStream(self.session, stream[u\"url\"])\n                else:\n                    # otherwise the stream url is actually a list of stream qualities\n                    for q, s in HLSStream.parse_variant_playlist(self.session, stream[u\"url\"]).items():\n                        # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams\n                        name = STREAM_NAMES.get(q, q)\n                        streams[name] = s\n\n        return streams\n\n    def _get_device_id(self):\n        \"\"\"Returns the saved device id or creates a new one and saves it.\"\"\"\n        device_id = self.cache.get(\"device_id\")\n\n        if not device_id:\n            # Create a random device id and cache it for a year\n            char_set = string.ascii_letters + string.digits\n            device_id = \"\".join(random.sample(char_set, 32))\n            self.cache.set(\"device_id\", device_id, 365 * 24 * 60 * 60)\n\n        return device_id\n\n    def _create_api(self):\n        \"\"\"Creates a new CrunchyrollAPI object, initiates it's session and\n        tries to authenticate it either by using saved credentials or the\n        user's username and password.\n        \"\"\"\n        if self.options.get(\"purge_credentials\"):\n            self.cache.set(\"session_id\", None, 0)\n            self.cache.set(\"auth\", None, 0)\n\n        current_time = datetime.datetime.utcnow()\n        device_id = self._get_device_id()\n        locale = self.options.get(\"locale\")\n        api = CrunchyrollAPI(\n            self.cache.get(\"session_id\"), self.cache.get(\"auth\"), locale\n        )\n\n        self.logger.debug(\"Creating session\")\n        try:\n            api.session_id = api.start_session(device_id, schema=_session_schema)\n        except CrunchyrollAPIError as err:\n            if err.code == \"bad_session\":\n                self.logger.debug(\"Current session has expired, creating a new one\")\n                api = CrunchyrollAPI(locale=locale)\n                api.session_id = api.start_session(device_id, schema=_session_schema)\n            else:\n                raise err\n\n        # Save session and hope it lasts for a few hours\n        self.cache.set(\"session_id\", api.session_id, 4 * 60 * 60)\n        self.logger.debug(\"Session created\")\n\n        if api.auth:\n            self.logger.debug(\"Using saved credentials\")\n        elif self.options.get(\"username\"):\n            try:\n                self.logger.info(\"Attempting to login using username and password\")\n                login = api.login(\n                    self.options.get(\"username\"),\n                    self.options.get(\"password\"),\n                    schema=_login_schema\n                )\n                api.auth = login[\"auth\"]\n\n                self.logger.info(\"Successfully logged in as '{0}'\",\n                                 login[\"user\"][\"username\"] or login[\"user\"][\"email\"])\n\n                expires = (login[\"expires\"] - current_time).total_seconds()\n                self.cache.set(\"auth\", login[\"auth\"], expires)\n            except CrunchyrollAPIError as err:\n                raise PluginError(u\"Authentication error: {0}\".format(err.msg))\n        else:\n            self.logger.warning(\n                \"No authentication provided, you won't be able to access \"\n                \"premium restricted content\"\n            )\n\n        return api\n\n__plugin__ = Crunchyroll\n", "path": "src/streamlink/plugins/crunchyroll.py"}]}