1import datetime
2import logging
3import re
4from uuid import uuid4
5
6from streamlink.plugin import Plugin, PluginArgument, PluginArguments, PluginError
7from streamlink.plugin.api import validate
8from streamlink.stream import HLSStream
9
10log = logging.getLogger(__name__)
11
12STREAM_WEIGHTS = {
13    "low": 240,
14    "mid": 420,
15    "high": 720,
16    "ultra": 1080,
17}
18STREAM_NAMES = {
19    "120k": "low",
20    "328k": "mid",
21    "864k": "high"
22}
23
24
25def parse_timestamp(ts):
26    """Takes ISO 8601 format(string) and converts into a utc datetime(naive)"""
27    return (
28        datetime.datetime.strptime(ts[:-7], "%Y-%m-%dT%H:%M:%S")
29        + datetime.timedelta(hours=int(ts[-5:-3]), minutes=int(ts[-2:]))
30        * int(ts[-6:-5] + "1")
31    )
32
33
34_url_re = re.compile(r"""
35    http(s)?://(\w+\.)?crunchyroll\.
36    (?:
37        com|de|es|fr|co.jp
38    )
39    (?:
40        /(en-gb|es|es-es|pt-pt|pt-br|fr|de|ar|it|ru)
41    )?
42    (?:/[^/&?]+)?
43    /[^/&?]+-(?P<media_id>\d+)
44""", re.VERBOSE)
45
46_api_schema = validate.Schema({
47    "error": bool,
48    validate.optional("code"): validate.text,
49    validate.optional("message"): validate.text,
50    validate.optional("data"): object,
51})
52_media_schema = validate.Schema(
53    {
54        "stream_data": validate.any(
55            None,
56            {
57                "streams": validate.all(
58                    [{
59                        "quality": validate.any(validate.text, None),
60                        "url": validate.url(
61                            scheme="http",
62                            path=validate.endswith(".m3u8")
63                        ),
64                        validate.optional("video_encode_id"): validate.text
65                    }]
66                )
67            }
68        )
69    },
70    validate.get("stream_data")
71)
72_login_schema = validate.Schema({
73    "auth": validate.any(validate.text, None),
74    "expires": validate.all(
75        validate.text,
76        validate.transform(parse_timestamp)
77    ),
78    "user": {
79        "username": validate.any(validate.text, None),
80        "email": validate.text
81    }
82})
83_session_schema = validate.Schema(
84    {
85        "session_id": validate.text
86    },
87    validate.get("session_id")
88)
89
90
91class CrunchyrollAPIError(Exception):
92    """Exception thrown by the Crunchyroll API when an error occurs"""
93
94    def __init__(self, msg, code):
95        Exception.__init__(self, msg)
96        self.msg = msg
97        self.code = code
98
99
100class CrunchyrollAPI:
101    _api_url = "https://api.crunchyroll.com/{0}.0.json"
102    _default_locale = "en_US"
103    _user_agent = "Dalvik/1.6.0 (Linux; U; Android 4.4.2; Android SDK built for x86 Build/KK)"
104    _version_code = 444
105    _version_name = "2.1.10"
106    _access_token = "WveH9VkPLrXvuNm"
107    _access_type = "com.crunchyroll.crunchyroid"
108
109    def __init__(self, cache, session, session_id=None, locale=_default_locale):
110        """Abstract the API to access to Crunchyroll data.
111
112        Can take saved credentials to use on it's calls to the API.
113        """
114        self.cache = cache
115        self.session = session
116        self.session_id = session_id
117        if self.session_id:  # if the session ID is setup don't use the cached auth token
118            self.auth = None
119        else:
120            self.auth = cache.get("auth")
121        self.device_id = cache.get("device_id") or self.generate_device_id()
122        self.locale = locale
123        self.headers = {
124            "X-Android-Device-Is-GoogleTV": "0",
125            "X-Android-Device-Product": "google_sdk_x86",
126            "X-Android-Device-Model": "Android SDK built for x86",
127            "Using-Brightcove-Player": "1",
128            "X-Android-Release": "4.4.2",
129            "X-Android-SDK": "19",
130            "X-Android-Application-Version-Name": self._version_name,
131            "X-Android-Application-Version-Code": str(self._version_code),
132            'User-Agent': self._user_agent
133        }
134
135    def _api_call(self, entrypoint, params=None, schema=None):
136        """Makes a call against the api.
137
138        :param entrypoint: API method to call.
139        :param params: parameters to include in the request data.
140        :param schema: schema to use to validate the data
141        """
142        url = self._api_url.format(entrypoint)
143
144        # Default params
145        params = params or {}
146        if self.session_id:
147            params.update({
148                "session_id": self.session_id
149            })
150        else:
151            params.update({
152                "device_id": self.device_id,
153                "device_type": self._access_type,
154                "access_token": self._access_token,
155                "version": self._version_code
156            })
157        params.update({
158            "locale": self.locale.replace('_', ''),
159        })
160
161        if self.session_id:
162            params["session_id"] = self.session_id
163
164        # The certificate used by Crunchyroll cannot be verified in some environments.
165        res = self.session.http.post(url, data=params, headers=self.headers, verify=False)
166        json_res = self.session.http.json(res, schema=_api_schema)
167
168        if json_res["error"]:
169            err_msg = json_res.get("message", "Unknown error")
170            err_code = json_res.get("code", "unknown_error")
171            raise CrunchyrollAPIError(err_msg, err_code)
172
173        data = json_res.get("data")
174        if schema:
175            data = schema.validate(data, name="API response")
176
177        return data
178
179    def generate_device_id(self):
180        device_id = str(uuid4())
181        # cache the device id
182        self.cache.set("device_id", 365 * 24 * 60 * 60)
183        log.debug("Device ID: {0}".format(device_id))
184        return device_id
185
186    def start_session(self):
187        """
188            Starts a session against Crunchyroll's server.
189            Is recommended that you call this method before making any other calls
190            to make sure you have a valid session against the server.
191        """
192        params = {}
193        if self.auth:
194            params["auth"] = self.auth
195        self.session_id = self._api_call("start_session", params, schema=_session_schema)
196        log.debug("Session created with ID: {0}".format(self.session_id))
197        return self.session_id
198
199    def login(self, username, password):
200        """
201            Authenticates the session to be able to access restricted data from
202            the server (e.g. premium restricted videos).
203        """
204        params = {
205            "account": username,
206            "password": password
207        }
208
209        login = self._api_call("login", params, schema=_login_schema)
210        self.auth = login["auth"]
211        self.cache.set("auth", login["auth"], expires_at=login["expires"])
212        return login
213
214    def authenticate(self):
215        try:
216            data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
217        except CrunchyrollAPIError:
218            self.auth = None
219            self.cache.set("auth", None, expires_at=0)
220            log.warning("Saved credentials have expired")
221            return
222
223        log.debug("Credentials expire at: {}".format(data["expires"]))
224        self.cache.set("auth", self.auth, expires_at=data["expires"])
225        return data
226
227    def get_info(self, media_id, fields=None, schema=None):
228        """
229            Returns the data for a certain media item.
230
231            :param media_id: id that identifies the media item to be accessed.
232            :param fields: list of the media"s field to be returned. By default the
233            API returns some fields, but others are not returned unless they are
234            explicity asked for. I have no real documentation on the fields, but
235            they all seem to start with the "media." prefix (e.g. media.name,
236            media.stream_data).
237            :param schema: validation schema to use
238        """
239        params = {"media_id": media_id}
240
241        if fields:
242            params["fields"] = ",".join(fields)
243
244        return self._api_call("info", params, schema=schema)
245
246
247class Crunchyroll(Plugin):
248    arguments = PluginArguments(
249        PluginArgument(
250            "username",
251            metavar="USERNAME",
252            requires=["password"],
253            help="A Crunchyroll username to allow access to restricted streams."
254        ),
255        PluginArgument(
256            "password",
257            sensitive=True,
258            metavar="PASSWORD",
259            nargs="?",
260            const=None,
261            default=None,
262            help="""
263            A Crunchyroll password for use with --crunchyroll-username.
264
265            If left blank you will be prompted.
266            """
267        ),
268        PluginArgument(
269            "purge-credentials",
270            action="store_true",
271            help="""
272            Purge cached Crunchyroll credentials to initiate a new session
273            and reauthenticate.
274            """
275        ),
276        PluginArgument(
277            "session-id",
278            sensitive=True,
279            metavar="SESSION_ID",
280            help="""
281            Set a specific session ID for crunchyroll, can be used to bypass
282            region restrictions. If using an authenticated session ID, it is
283            recommended that the authentication parameters be omitted as the
284            session ID is account specific.
285
286            Note: The session ID will be overwritten if authentication is used
287            and the session ID does not match the account.
288            """
289        )
290    )
291
292    @classmethod
293    def can_handle_url(self, url):
294        return _url_re.match(url)
295
296    @classmethod
297    def stream_weight(cls, key):
298        weight = STREAM_WEIGHTS.get(key)
299        if weight:
300            return weight, "crunchyroll"
301
302        return Plugin.stream_weight(key)
303
304    def _get_streams(self):
305        api = self._create_api()
306        match = _url_re.match(self.url)
307        media_id = int(match.group("media_id"))
308
309        try:
310            # the media.stream_data field is required, no stream data is returned otherwise
311            info = api.get_info(media_id, fields=["media.stream_data"], schema=_media_schema)
312        except CrunchyrollAPIError as err:
313            raise PluginError(f"Media lookup error: {err.msg}")
314
315        if not info:
316            return
317
318        streams = {}
319
320        # The adaptive quality stream sometimes a subset of all the other streams listed, ultra is no included
321        has_adaptive = any([s["quality"] == "adaptive" for s in info["streams"]])
322        if has_adaptive:
323            log.debug("Loading streams from adaptive playlist")
324            for stream in filter(lambda x: x["quality"] == "adaptive", info["streams"]):
325                for q, s in HLSStream.parse_variant_playlist(self.session, stream["url"]).items():
326                    # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams
327                    name = STREAM_NAMES.get(q, q)
328                    streams[name] = s
329
330        # If there is no adaptive quality stream then parse each individual result
331        for stream in info["streams"]:
332            if stream["quality"] != "adaptive":
333                # the video_encode_id indicates that the stream is not a variant playlist
334                if "video_encode_id" in stream:
335                    streams[stream["quality"]] = HLSStream(self.session, stream["url"])
336                else:
337                    # otherwise the stream url is actually a list of stream qualities
338                    for q, s in HLSStream.parse_variant_playlist(self.session, stream["url"]).items():
339                        # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams
340                        name = STREAM_NAMES.get(q, q)
341                        streams[name] = s
342
343        return streams
344
345    def _create_api(self):
346        """Creates a new CrunchyrollAPI object, initiates it's session and
347        tries to authenticate it either by using saved credentials or the
348        user's username and password.
349        """
350        if self.options.get("purge_credentials"):
351            self.cache.set("session_id", None, 0)
352            self.cache.set("auth", None, 0)
353            self.cache.set("session_id", None, 0)
354
355        # use the crunchyroll locale as an override, for backwards compatibility
356        locale = self.get_option("locale") or self.session.localization.language_code
357        api = CrunchyrollAPI(self.cache,
358                             self.session,
359                             session_id=self.get_option("session_id"),
360                             locale=locale)
361
362        if not self.get_option("session_id"):
363            log.debug(f"Creating session with locale: {locale}")
364            api.start_session()
365
366            if api.auth:
367                log.debug("Using saved credentials")
368                login = api.authenticate()
369                if login:
370                    login_name = login["user"]["username"] or login["user"]["email"]
371                    log.info(f"Successfully logged in as '{login_name}'")
372            if not api.auth and self.options.get("username"):
373                try:
374                    log.debug("Attempting to login using username and password")
375                    api.login(self.options.get("username"),
376                              self.options.get("password"))
377                    login = api.authenticate()
378                    login_name = login["user"]["username"] or login["user"]["email"]
379                    log.info(f"Logged in as '{login_name}'")
380
381                except CrunchyrollAPIError as err:
382                    raise PluginError(f"Authentication error: {err.msg}")
383            if not api.auth:
384                log.warning(
385                    "No authentication provided, you won't be able to access "
386                    "premium restricted content"
387                )
388
389        return api
390
391
392__plugin__ = Crunchyroll
393