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