4 from .common
import InfoExtractor
5 from ..networking
import Request
6 from ..networking
.exceptions
import HTTPError
24 class CrunchyrollBaseIE(InfoExtractor
):
25 _BASE_URL
= 'https://www.crunchyroll.com'
26 _API_BASE
= 'https://api.crunchyroll.com'
27 _NETRC_MACHINE
= 'crunchyroll'
28 _SWITCH_USER_AGENT
= 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
33 _BASIC_AUTH
= 'Basic ' + base64
.b64encode(':'.join((
34 't-kdgp2h8c3jub8fn0fq',
35 'yfLDfMfrYvKXh4JXS1LEI2cCqu1v5Wan',
52 def _set_auth_info(self
, response
):
53 CrunchyrollBaseIE
._IS
_PREMIUM
= 'cr_premium' in traverse_obj(response
, ('access_token', {jwt_decode_hs256}
, 'benefits', ...))
54 CrunchyrollBaseIE
._AUTH
_HEADERS
= {'Authorization': response
['token_type'] + ' ' + response
['access_token']}
55 CrunchyrollBaseIE
._AUTH
_EXPIRY
= time_seconds(seconds
=traverse_obj(response
, ('expires_in', {float_or_none}
), default
=300) - 10)
57 def _request_token(self
, headers
, data
, note
='Requesting token', errnote
='Failed to request token'):
59 return self
._download
_json
(
60 f
'{self._BASE_URL}/auth/v1/token', None, note
=note
, errnote
=errnote
,
61 headers
=headers
, data
=urlencode_postdata(data
), impersonate
=True)
62 except ExtractorError
as error
:
63 if not isinstance(error
.cause
, HTTPError
) or error
.cause
.status
!= 403:
65 if target
:= error
.cause
.response
.extensions
.get('impersonate'):
66 raise ExtractorError(f
'Got HTTP Error 403 when using impersonate target "{target}"')
68 'Request blocked by Cloudflare. '
69 'Install the required impersonation dependency if possible, '
70 'or else navigate to Crunchyroll in your browser, '
71 'then pass the fresh cookies (with --cookies-from-browser or --cookies) '
72 'and your browser\'s User-Agent (with --user-agent)', expected
=True)
74 def _perform_login(self
, username
, password
):
75 if not CrunchyrollBaseIE
._REFRESH
_TOKEN
:
76 CrunchyrollBaseIE
._REFRESH
_TOKEN
= self
.cache
.load(self
._NETRC
_MACHINE
, username
)
77 if CrunchyrollBaseIE
._REFRESH
_TOKEN
:
81 login_response
= self
._request
_token
(
82 headers
={'Authorization': self
._BASIC
_AUTH
}, data
={
85 'grant_type': 'password',
86 'scope': 'offline_access',
87 }, note
='Logging in', errnote
='Failed to log in')
88 except ExtractorError
as error
:
89 if isinstance(error
.cause
, HTTPError
) and error
.cause
.status
== 401:
90 raise ExtractorError('Invalid username and/or password', expected
=True)
93 CrunchyrollBaseIE
._REFRESH
_TOKEN
= login_response
['refresh_token']
94 self
.cache
.store(self
._NETRC
_MACHINE
, username
, CrunchyrollBaseIE
._REFRESH
_TOKEN
)
95 self
._set
_auth
_info
(login_response
)
97 def _update_auth(self
):
98 if CrunchyrollBaseIE
._AUTH
_HEADERS
and CrunchyrollBaseIE
._AUTH
_EXPIRY
> time_seconds():
101 auth_headers
= {'Authorization': self
._BASIC
_AUTH
}
102 if CrunchyrollBaseIE
._REFRESH
_TOKEN
:
104 'refresh_token': CrunchyrollBaseIE
._REFRESH
_TOKEN
,
105 'grant_type': 'refresh_token',
106 'scope': 'offline_access',
109 data
= {'grant_type': 'client_id'}
110 auth_headers
['ETP-Anonymous-ID'] = uuid
.uuid4()
112 auth_response
= self
._request
_token
(auth_headers
, data
)
113 except ExtractorError
as error
:
114 username
, password
= self
._get
_login
_info
()
115 if not username
or not isinstance(error
.cause
, HTTPError
) or error
.cause
.status
!= 400:
117 self
.to_screen('Refresh token has expired. Re-logging in')
118 CrunchyrollBaseIE
._REFRESH
_TOKEN
= None
119 self
.cache
.store(self
._NETRC
_MACHINE
, username
, None)
120 self
._perform
_login
(username
, password
)
123 self
._set
_auth
_info
(auth_response
)
125 def _locale_from_language(self
, language
):
126 config_locale
= self
._configuration
_arg
('metadata', ie_key
=CrunchyrollBetaIE
, casesense
=True)
127 return config_locale
[0] if config_locale
else self
._LOCALE
_LOOKUP
.get(language
)
129 def _call_base_api(self
, endpoint
, internal_id
, lang
, note
=None, query
={}):
132 if not endpoint
.startswith('/'):
133 endpoint
= f
'/{endpoint}'
136 locale
= self
._locale
_from
_language
(lang
)
138 query
['locale'] = locale
140 return self
._download
_json
(
141 f
'{self._BASE_URL}{endpoint}', internal_id
, note
or f
'Calling API: {endpoint}',
142 headers
=CrunchyrollBaseIE
._AUTH
_HEADERS
, query
=query
)
144 def _call_api(self
, path
, internal_id
, lang
, note
='api', query
={}):
145 if not path
.startswith(f
'/content/v2/{self._API_ENDPOINT}/'):
146 path
= f
'/content/v2/{self._API_ENDPOINT}/{path}'
149 result
= self
._call
_base
_api
(
150 path
, internal_id
, lang
, f
'Downloading {note} JSON ({self._API_ENDPOINT})', query
=query
)
151 except ExtractorError
as error
:
152 if isinstance(error
.cause
, HTTPError
) and error
.cause
.status
== 404:
157 raise ExtractorError(f
'Unexpected response when downloading {note} JSON')
160 def _extract_chapters(self
, internal_id
):
161 # if no skip events are available, a 403 xml error is returned
162 skip_events
= self
._download
_json
(
163 f
'https://static.crunchyroll.com/skip-events/production/{internal_id}.json',
164 internal_id
, note
='Downloading chapter info', fatal
=False, errnote
=False)
169 for event
in ('recap', 'intro', 'credits', 'preview'):
170 start
= traverse_obj(skip_events
, (event
, 'start', {float_or_none}
))
171 end
= traverse_obj(skip_events
, (event
, 'end', {float_or_none}
))
172 # some chapters have no start and/or ending time, they will just be ignored
173 if start
is None or end
is None:
175 chapters
.append({'title': event
.capitalize(), 'start_time': start
, 'end_time': end
})
179 def _extract_stream(self
, identifier
, display_id
=None):
181 display_id
= identifier
184 headers
= {**CrunchyrollBaseIE
._AUTH
_HEADERS
, 'User-Agent': self
._SWITCH
_USER
_AGENT
}
186 stream_response
= self
._download
_json
(
187 f
'https://cr-play-service.prd.crunchyrollsvc.com/v1/{identifier}/console/switch/play',
188 display_id
, note
='Downloading stream info', errnote
='Failed to download stream info', headers
=headers
)
189 except ExtractorError
as error
:
190 if self
.get_param('ignore_no_formats_error'):
191 self
.report_warning(error
.orig_msg
)
193 elif isinstance(error
.cause
, HTTPError
) and error
.cause
.status
== 420:
194 raise ExtractorError(
195 'You have reached the rate-limit for active streams; try again later', expected
=True)
198 available_formats
= {'': ('', '', stream_response
['url'])}
199 for hardsub_lang
, stream
in traverse_obj(stream_response
, ('hardSubs', {dict.items
}, lambda _
, v
: v
[1]['url'])):
200 available_formats
[hardsub_lang
] = (f
'hardsub-{hardsub_lang}', hardsub_lang
, stream
['url'])
202 requested_hardsubs
= [('' if val
== 'none' else val
) for val
in (self
._configuration
_arg
('hardsub') or ['none'])]
203 hardsub_langs
= [lang
for lang
in available_formats
if lang
]
204 if hardsub_langs
and 'all' not in requested_hardsubs
:
205 full_format_langs
= set(requested_hardsubs
)
206 self
.to_screen(f
'Available hardsub languages: {", ".join(hardsub_langs)}')
208 'To extract formats of a hardsub language, use '
209 '"--extractor-args crunchyrollbeta:hardsub=<language_code or all>". '
210 'See https://github.com/yt-dlp/yt-dlp#crunchyrollbeta-crunchyroll for more info',
213 full_format_langs
= set(map(str.lower
, available_formats
))
215 audio_locale
= traverse_obj(stream_response
, ('audioLocale', {str}
))
216 hardsub_preference
= qualities(requested_hardsubs
[::-1])
217 formats
, subtitles
= [], {}
218 for format_id
, hardsub_lang
, stream_url
in available_formats
.values():
219 if hardsub_lang
.lower() in full_format_langs
:
220 adaptive_formats
, dash_subs
= self
._extract
_mpd
_formats
_and
_subtitles
(
221 stream_url
, display_id
, mpd_id
=format_id
, headers
=CrunchyrollBaseIE
._AUTH
_HEADERS
,
222 fatal
=False, note
=f
'Downloading {f"{format_id} " if hardsub_lang else ""}MPD manifest')
223 self
._merge
_subtitles
(dash_subs
, target
=subtitles
)
225 continue # XXX: Update this if meta mpd formats work; will be tricky with token invalidation
226 for f
in adaptive_formats
:
227 if f
.get('acodec') != 'none':
228 f
['language'] = audio_locale
229 f
['quality'] = hardsub_preference(hardsub_lang
.lower())
230 formats
.extend(adaptive_formats
)
232 for locale
, subtitle
in traverse_obj(stream_response
, (('subtitles', 'captions'), {dict.items
}, ...)):
233 subtitles
.setdefault(locale
, []).append(traverse_obj(subtitle
, {'url': 'url', 'ext': 'format'}))
235 # Invalidate stream token to avoid rate-limit
236 error_msg
= 'Unable to invalidate stream token; you may experience rate-limiting'
237 if stream_token
:= stream_response
.get('token'):
238 self
._request
_webpage
(Request(
239 f
'https://cr-play-service.prd.crunchyrollsvc.com/v1/token/{identifier}/{stream_token}/inactive',
240 headers
=headers
, method
='PATCH'), display_id
, 'Invalidating stream token', error_msg
, fatal
=False)
242 self
.report_warning(error_msg
)
244 return formats
, subtitles
247 class CrunchyrollCmsBaseIE(CrunchyrollBaseIE
):
248 _API_ENDPOINT
= 'cms'
251 def _call_cms_api_signed(self
, path
, internal_id
, lang
, note
='api'):
252 if not CrunchyrollCmsBaseIE
._CMS
_EXPIRY
or CrunchyrollCmsBaseIE
._CMS
_EXPIRY
<= time_seconds():
253 response
= self
._call
_base
_api
('index/v2', None, lang
, 'Retrieving signed policy')['cms_web']
254 CrunchyrollCmsBaseIE
._CMS
_QUERY
= {
255 'Policy': response
['policy'],
256 'Signature': response
['signature'],
257 'Key-Pair-Id': response
['key_pair_id'],
259 CrunchyrollCmsBaseIE
._CMS
_BUCKET
= response
['bucket']
260 CrunchyrollCmsBaseIE
._CMS
_EXPIRY
= parse_iso8601(response
['expires']) - 10
262 if not path
.startswith('/cms/v2'):
263 path
= f
'/cms/v2{CrunchyrollCmsBaseIE._CMS_BUCKET}/{path}'
265 return self
._call
_base
_api
(
266 path
, internal_id
, lang
, f
'Downloading {note} JSON (signed cms)', query
=CrunchyrollCmsBaseIE
._CMS
_QUERY
)
269 class CrunchyrollBetaIE(CrunchyrollCmsBaseIE
):
270 IE_NAME
= 'crunchyroll'
271 _VALID_URL
= r
'''(?x)
272 https?://(?:beta\.|www\.)?crunchyroll\.com/
273 (?:(?P<lang>\w{2}(?:-\w{2})?)/)?
274 watch/(?!concert|musicvideo)(?P<id>\w+)'''
277 'url': 'https://www.crunchyroll.com/watch/GY2P1Q98Y/to-the-future',
281 'duration': 1380.241,
282 'timestamp': 1459632600,
283 'description': 'md5:a022fbec4fbb023d43631032c91ed64b',
284 'title': 'World Trigger Episode 73 – To the Future',
285 'upload_date': '20160402',
286 'series': 'World Trigger',
287 'series_id': 'GR757DMKY',
288 'season': 'World Trigger',
289 'season_id': 'GR9P39NJ6',
291 'episode': 'To the Future',
292 'episode_number': 73,
293 'thumbnail': r
're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
294 'chapters': 'count:2',
297 'dislike_count': int,
300 'skip_download': 'm3u8',
301 'extractor_args': {'crunchyrollbeta': {'hardsub': ['de-DE']}},
302 'format': 'bv[format_id~=hardsub]',
306 'url': 'https://www.crunchyroll.com/watch/GYE5WKQGR',
311 'timestamp': 1476788400,
312 'description': 'md5:74b67283ffddd75f6e224ca7dc031e76',
313 'title': 'SHELTER – Porter Robinson presents Shelter the Animation',
314 'upload_date': '20161018',
316 'series_id': 'GYGG09WWY',
318 'season_id': 'GR09MGK4R',
320 'episode': 'Porter Robinson presents Shelter the Animation',
322 'thumbnail': r
're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
325 'dislike_count': int,
327 'params': {'skip_download': True},
329 'url': 'https://www.crunchyroll.com/watch/GJWU2VKK3/cherry-blossom-meeting-and-a-coming-blizzard',
333 'duration': 1420.054,
334 'description': 'md5:2d1c67c0ec6ae514d9c30b0b99a625cd',
335 'title': 'The Ice Guy and His Cool Female Colleague Episode 1 – Cherry Blossom Meeting and a Coming Blizzard',
336 'series': 'The Ice Guy and His Cool Female Colleague',
337 'series_id': 'GW4HM75NP',
338 'season': 'The Ice Guy and His Cool Female Colleague',
339 'season_id': 'GY9PC21VE',
341 'episode': 'Cherry Blossom Meeting and a Coming Blizzard',
343 'chapters': 'count:2',
344 'thumbnail': r
're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
345 'timestamp': 1672839000,
346 'upload_date': '20230104',
349 'dislike_count': int,
351 'params': {'skip_download': 'm3u8'},
353 'url': 'https://www.crunchyroll.com/watch/GM8F313NQ',
357 'title': 'Garakowa -Restore the World-',
358 'description': 'md5:8d2f8b6b9dd77d87810882e7d2ee5608',
359 'duration': 3996.104,
361 'thumbnail': r
're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
363 'params': {'skip_download': 'm3u8'},
364 'skip': 'no longer exists',
366 'url': 'https://www.crunchyroll.com/watch/G62PEZ2E6',
369 'description': 'md5:8d2f8b6b9dd77d87810882e7d2ee5608',
372 'title': 'Garakowa -Restore the World-',
374 'playlist_mincount': 5,
376 'url': 'https://www.crunchyroll.com/de/watch/GY2P1Q98Y',
377 'only_matching': True,
379 'url': 'https://beta.crunchyroll.com/pt-br/watch/G8WUN8VKP/the-ruler-of-conspiracy',
380 'only_matching': True,
382 # We want to support lazy playlist filtering and movie listings cannot be inside a playlist
383 _RETURN_TYPE
= 'video'
385 def _real_extract(self
, url
):
386 lang
, internal_id
= self
._match
_valid
_url
(url
).group('lang', 'id')
388 # We need to use unsigned API call to allow ratings query string
389 response
= traverse_obj(self
._call
_api
(
390 f
'objects/{internal_id}', internal_id
, lang
, 'object info', {'ratings': 'true'}), ('data', 0, {dict}
))
392 raise ExtractorError(f
'No video with id {internal_id} could be found (possibly region locked?)', expected
=True)
394 object_type
= response
.get('type')
395 if object_type
== 'episode':
396 result
= self
._transform
_episode
_response
(response
)
398 elif object_type
== 'movie':
399 result
= self
._transform
_movie
_response
(response
)
401 elif object_type
== 'movie_listing':
402 first_movie_id
= traverse_obj(response
, ('movie_listing_metadata', 'first_movie_id'))
403 if not self
._yes
_playlist
(internal_id
, first_movie_id
):
404 return self
.url_result(f
'{self._BASE_URL}/{lang}watch/{first_movie_id}', CrunchyrollBetaIE
, first_movie_id
)
407 movies
= self
._call
_api
(f
'movie_listings/{internal_id}/movies', internal_id
, lang
, 'movie list')
408 for movie_response
in traverse_obj(movies
, ('data', ...)):
409 yield self
.url_result(
410 f
'{self._BASE_URL}/{lang}watch/{movie_response["id"]}',
411 CrunchyrollBetaIE
, **self
._transform
_movie
_response
(movie_response
))
413 return self
.playlist_result(entries(), **self
._transform
_movie
_response
(response
))
416 raise ExtractorError(f
'Unknown object type {object_type}')
418 if not self
._IS
_PREMIUM
and traverse_obj(response
, (f
'{object_type}_metadata', 'is_premium_only')):
419 message
= f
'This {object_type} is for premium members only'
420 if CrunchyrollBaseIE
._REFRESH
_TOKEN
:
421 self
.raise_no_formats(message
, expected
=True, video_id
=internal_id
)
423 self
.raise_login_required(message
, method
='password', metadata_available
=True)
425 result
['formats'], result
['subtitles'] = self
._extract
_stream
(internal_id
)
427 result
['chapters'] = self
._extract
_chapters
(internal_id
)
429 def calculate_count(item
):
430 return parse_count(''.join((item
['displayed'], item
.get('unit') or '')))
432 result
.update(traverse_obj(response
, ('rating', {
433 'like_count': ('up', {calculate_count}
),
434 'dislike_count': ('down', {calculate_count}
),
440 def _transform_episode_response(data
):
441 metadata
= traverse_obj(data
, (('episode_metadata', None), {dict}
), get_all
=False) or {}
444 'title': ' \u2013 '.join((
446 format_field(metadata
, 'season_title'),
447 format_field(metadata
, 'episode', ' Episode %s'))),
448 format_field(data
, 'title'))),
449 **traverse_obj(data
, {
450 'episode': ('title', {str}
),
451 'description': ('description', {str}
, {lambda x
: x
.replace(r
'\r\n', '\n')}),
452 'thumbnails': ('images', 'thumbnail', ..., ..., {
453 'url': ('source', {url_or_none}
),
454 'width': ('width', {int_or_none}
),
455 'height': ('height', {int_or_none}
),
458 **traverse_obj(metadata
, {
459 'duration': ('duration_ms', {float_or_none(scale
=1000)}),
460 'timestamp': ('upload_date', {parse_iso8601}
),
461 'series': ('series_title', {str}
),
462 'series_id': ('series_id', {str}
),
463 'season': ('season_title', {str}
),
464 'season_id': ('season_id', {str}
),
465 'season_number': ('season_number', ({int}
, {float_or_none}
)),
466 'episode_number': ('sequence_number', ({int}
, {float_or_none}
)),
467 'age_limit': ('maturity_ratings', -1, {parse_age_limit}
),
468 'language': ('audio_locale', {str}
),
473 def _transform_movie_response(data
):
474 metadata
= traverse_obj(data
, (('movie_metadata', 'movie_listing_metadata', None), {dict}
), get_all
=False) or {}
477 **traverse_obj(data
, {
478 'title': ('title', {str}
),
479 'description': ('description', {str}
, {lambda x
: x
.replace(r
'\r\n', '\n')}),
480 'thumbnails': ('images', 'thumbnail', ..., ..., {
481 'url': ('source', {url_or_none}
),
482 'width': ('width', {int_or_none}
),
483 'height': ('height', {int_or_none}
),
486 **traverse_obj(metadata
, {
487 'duration': ('duration_ms', {float_or_none(scale
=1000)}),
488 'age_limit': ('maturity_ratings', -1, {parse_age_limit}
),
493 class CrunchyrollBetaShowIE(CrunchyrollCmsBaseIE
):
494 IE_NAME
= 'crunchyroll:playlist'
495 _VALID_URL
= r
'''(?x)
496 https?://(?:beta\.|www\.)?crunchyroll\.com/
497 (?P<lang>(?:\w{2}(?:-\w{2})?/)?)
498 series/(?P<id>\w+)'''
500 'url': 'https://www.crunchyroll.com/series/GY19NQ2QR/Girl-Friend-BETA',
503 'title': 'Girl Friend BETA',
504 'description': 'md5:99c1b22ee30a74b536a8277ced8eb750',
505 # XXX: `thumbnail` does not get set from `thumbnails` in playlist
506 # 'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
509 'playlist_mincount': 10,
511 'url': 'https://beta.crunchyroll.com/it/series/GY19NQ2QR',
512 'only_matching': True,
515 def _real_extract(self
, url
):
516 lang
, internal_id
= self
._match
_valid
_url
(url
).group('lang', 'id')
519 seasons_response
= self
._call
_cms
_api
_signed
(f
'seasons?series_id={internal_id}', internal_id
, lang
, 'seasons')
520 for season
in traverse_obj(seasons_response
, ('items', ..., {dict}
)):
521 episodes_response
= self
._call
_cms
_api
_signed
(
522 f
'episodes?season_id={season["id"]}', season
['id'], lang
, 'episode list')
523 for episode_response
in traverse_obj(episodes_response
, ('items', ..., {dict}
)):
524 yield self
.url_result(
525 f
'{self._BASE_URL}/{lang}watch/{episode_response["id"]}',
526 CrunchyrollBetaIE
, **CrunchyrollBetaIE
._transform
_episode
_response
(episode_response
))
528 return self
.playlist_result(
529 entries(), internal_id
,
530 **traverse_obj(self
._call
_api
(f
'series/{internal_id}', internal_id
, lang
, 'series'), ('data', 0, {
531 'title': ('title', {str}
),
532 'description': ('description', {lambda x
: x
.replace(r
'\r\n', '\n')}),
533 'age_limit': ('maturity_ratings', -1, {parse_age_limit}
),
534 'thumbnails': ('images', ..., ..., ..., {
535 'url': ('source', {url_or_none}
),
536 'width': ('width', {int_or_none}
),
537 'height': ('height', {int_or_none}
),
542 class CrunchyrollMusicIE(CrunchyrollBaseIE
):
543 IE_NAME
= 'crunchyroll:music'
544 _VALID_URL
= r
'''(?x)
545 https?://(?:www\.)?crunchyroll\.com/
546 (?P<lang>(?:\w{2}(?:-\w{2})?/)?)
547 watch/(?P<type>concert|musicvideo)/(?P<id>\w+)'''
549 'url': 'https://www.crunchyroll.com/de/watch/musicvideo/MV5B02C79',
553 'display_id': 'egaono-hana',
554 'title': 'Egaono Hana',
555 'track': 'Egaono Hana',
556 'artists': ['Goose house'],
557 'thumbnail': r
're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
560 'params': {'skip_download': 'm3u8'},
562 'url': 'https://www.crunchyroll.com/watch/musicvideo/MV88BB7F2C',
566 'display_id': 'crossing-field',
567 'title': 'Crossing Field',
568 'track': 'Crossing Field',
570 'thumbnail': r
're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
573 'params': {'skip_download': 'm3u8'},
574 'skip': 'no longer exists',
576 'url': 'https://www.crunchyroll.com/watch/concert/MC2E2AC135',
580 'display_id': 'live-is-smile-always-364joker-at-yokohama-arena',
581 'title': 'LiVE is Smile Always-364+JOKER- at YOKOHAMA ARENA',
582 'track': 'LiVE is Smile Always-364+JOKER- at YOKOHAMA ARENA',
584 'thumbnail': r
're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
585 'description': 'md5:747444e7e6300907b7a43f0a0503072e',
588 'params': {'skip_download': 'm3u8'},
590 'url': 'https://www.crunchyroll.com/de/watch/musicvideo/MV5B02C79/egaono-hana',
591 'only_matching': True,
593 'url': 'https://www.crunchyroll.com/watch/concert/MC2E2AC135/live-is-smile-always-364joker-at-yokohama-arena',
594 'only_matching': True,
596 'url': 'https://www.crunchyroll.com/watch/musicvideo/MV88BB7F2C/crossing-field',
597 'only_matching': True,
599 _API_ENDPOINT
= 'music'
601 def _real_extract(self
, url
):
602 lang
, internal_id
, object_type
= self
._match
_valid
_url
(url
).group('lang', 'id', 'type')
604 'concert': ('concerts', 'concert info'),
605 'musicvideo': ('music_videos', 'music video info'),
607 response
= traverse_obj(self
._call
_api
(f
'{path}/{internal_id}', internal_id
, lang
, name
), ('data', 0, {dict}
))
609 raise ExtractorError(f
'No video with id {internal_id} could be found (possibly region locked?)', expected
=True)
611 result
= self
._transform
_music
_response
(response
)
613 if not self
._IS
_PREMIUM
and response
.get('isPremiumOnly'):
614 message
= f
'This {response.get("type") or "media"} is for premium members only'
615 if CrunchyrollBaseIE
._REFRESH
_TOKEN
:
616 self
.raise_no_formats(message
, expected
=True, video_id
=internal_id
)
618 self
.raise_login_required(message
, method
='password', metadata_available
=True)
620 result
['formats'], _
= self
._extract
_stream
(f
'music/{internal_id}', internal_id
)
625 def _transform_music_response(data
):
628 **traverse_obj(data
, {
629 'display_id': 'slug',
632 'artists': ('artist', 'name', all
),
633 'description': ('description', {str}
, {lambda x
: x
.replace(r
'\r\n', '\n') or None}),
634 'thumbnails': ('images', ..., ..., {
635 'url': ('source', {url_or_none}
),
636 'width': ('width', {int_or_none}
),
637 'height': ('height', {int_or_none}
),
639 'genres': ('genres', ..., 'displayValue'),
640 'age_limit': ('maturity_ratings', -1, {parse_age_limit}
),
645 class CrunchyrollArtistIE(CrunchyrollBaseIE
):
646 IE_NAME
= 'crunchyroll:artist'
647 _VALID_URL
= r
'''(?x)
648 https?://(?:www\.)?crunchyroll\.com/
649 (?P<lang>(?:\w{2}(?:-\w{2})?/)?)
650 artist/(?P<id>\w{10})'''
652 'url': 'https://www.crunchyroll.com/artist/MA179CB50D',
656 'genres': ['Anime', 'J-Pop', 'Rock'],
657 'description': 'md5:16d87de61a55c3f7d6c454b73285938e',
659 'playlist_mincount': 83,
661 'url': 'https://www.crunchyroll.com/artist/MA179CB50D/lisa',
662 'only_matching': True,
664 _API_ENDPOINT
= 'music'
666 def _real_extract(self
, url
):
667 lang
, internal_id
= self
._match
_valid
_url
(url
).group('lang', 'id')
668 response
= traverse_obj(self
._call
_api
(
669 f
'artists/{internal_id}', internal_id
, lang
, 'artist info'), ('data', 0))
672 for attribute
, path
in [('concerts', 'concert'), ('videos', 'musicvideo')]:
673 for internal_id
in traverse_obj(response
, (attribute
, ...)):
674 yield self
.url_result(f
'{self._BASE_URL}/watch/{path}/{internal_id}', CrunchyrollMusicIE
, internal_id
)
676 return self
.playlist_result(entries(), **self
._transform
_artist
_response
(response
))
679 def _transform_artist_response(data
):
682 **traverse_obj(data
, {
684 'description': ('description', {str}
, {lambda x
: x
.replace(r
'\r\n', '\n')}),
685 'thumbnails': ('images', ..., ..., {
686 'url': ('source', {url_or_none}
),
687 'width': ('width', {int_or_none}
),
688 'height': ('height', {int_or_none}
),
690 'genres': ('genres', ..., 'displayValue'),