2 from __future__ 
import unicode_literals
 
   6 import xml
.etree
.ElementTree 
as etree
 
   9 from hashlib 
import sha1
 
  10 from math 
import pow, sqrt
, floor
 
  11 from .common 
import InfoExtractor
 
  12 from .vrv 
import VRVIE
 
  13 from ..compat 
import ( 
  15     compat_etree_fromstring
, 
  16     compat_urllib_parse_urlencode
, 
  17     compat_urllib_request
, 
  39 class CrunchyrollBaseIE(InfoExtractor
): 
  40     _LOGIN_URL 
= 'https://www.crunchyroll.com/login' 
  41     _LOGIN_FORM 
= 'login_form' 
  42     _NETRC_MACHINE 
= 'crunchyroll' 
  44     def _call_rpc_api(self
, method
, video_id
, note
=None, data
=None): 
  46         data
['req'] = 'RpcApi' + method
 
  47         data 
= compat_urllib_parse_urlencode(data
).encode('utf-8') 
  48         return self
._download
_xml
( 
  49             'https://www.crunchyroll.com/xml/', 
  50             video_id
, note
, fatal
=False, data
=data
, headers
={ 
  51                 'Content-Type': 'application/x-www-form-urlencoded', 
  55         username
, password 
= self
._get
_login
_info
() 
  59         self
._download
_webpage
( 
  60             'https://www.crunchyroll.com/?a=formhandler', 
  61             None, 'Logging in', 'Wrong login info', 
  62             data
=urlencode_postdata({ 
  63                 'formname': 'RpcApiUser_Login', 
  64                 'next_url': 'https://www.crunchyroll.com/acct/membership', 
  70         login_page = self._download_webpage( 
  71             self._LOGIN_URL, None, 'Downloading login page') 
  73         def is_logged(webpage): 
  74             return '<title>Redirecting' in webpage 
  77         if is_logged(login_page): 
  80         login_form_str = self._search_regex( 
  81             r'(?P<form><form[^>]+?id=(["\'])%s\2[^>]*>)' % self._LOGIN_FORM, 
  82             login_page, 'login form', group='form') 
  84         post_url = extract_attributes(login_form_str).get('action') 
  86             post_url = self._LOGIN_URL 
  87         elif not post_url.startswith('http'): 
  88             post_url = compat_urlparse.urljoin(self._LOGIN_URL, post_url) 
  90         login_form = self._form_hidden_inputs(self._LOGIN_FORM, login_page) 
  93             'login_form[name]': username, 
  94             'login_form[password]': password, 
  97         response = self._download_webpage( 
  98             post_url, None, 'Logging in', 'Wrong login info', 
  99             data=urlencode_postdata(login_form), 
 100             headers={'Content-Type': 'application/x-www-form-urlencoded'}) 
 103         if is_logged(response): 
 106         error = self._html_search_regex( 
 107             '(?s)<ul[^>]+class=["\']messages["\'][^>]*>(.+?)</ul>', 
 108             response, 'error message', default=None) 
 110             raise ExtractorError('Unable to login: %s' % error, expected=True) 
 112         raise ExtractorError('Unable to log in') 
 115     def _real_initialize(self
): 
 118     def _download_webpage(self
, url_or_request
, *args
, **kwargs
): 
 119         request 
= (url_or_request 
if isinstance(url_or_request
, compat_urllib_request
.Request
) 
 120                    else sanitized_Request(url_or_request
)) 
 121         # Accept-Language must be set explicitly to accept any language to avoid issues 
 122         # similar to https://github.com/rg3/youtube-dl/issues/6797. 
 123         # Along with IP address Crunchyroll uses Accept-Language to guess whether georestriction 
 124         # should be imposed or not (from what I can see it just takes the first language 
 125         # ignoring the priority and requires it to correspond the IP). By the way this causes 
 126         # Crunchyroll to not work in georestriction cases in some browsers that don't place 
 127         # the locale lang first in header. However allowing any language seems to workaround the issue. 
 128         request
.add_header('Accept-Language', '*') 
 129         return super(CrunchyrollBaseIE
, self
)._download
_webpage
(request
, *args
, **kwargs
) 
 132     def _add_skip_wall(url
): 
 133         parsed_url 
= compat_urlparse
.urlparse(url
) 
 134         qs 
= compat_urlparse
.parse_qs(parsed_url
.query
) 
 135         # Always force skip_wall to bypass maturity wall, namely 18+ confirmation message: 
 136         # > This content may be inappropriate for some people. 
 137         # > Are you sure you want to continue? 
 138         # since it's not disabled by default in crunchyroll account's settings. 
 139         # See https://github.com/rg3/youtube-dl/issues/7202. 
 140         qs
['skip_wall'] = ['1'] 
 141         return compat_urlparse
.urlunparse( 
 142             parsed_url
._replace
(query
=compat_urllib_parse_urlencode(qs
, True))) 
 145 class CrunchyrollIE(CrunchyrollBaseIE
, VRVIE
): 
 146     IE_NAME 
= 'crunchyroll' 
 147     _VALID_URL 
= r
'https?://(?:(?P<prefix>www|m)\.)?(?P<url>crunchyroll\.(?:com|fr)/(?:media(?:-|/\?id=)|[^/]*/[^/?&]*?)(?P<video_id>[0-9]+))(?:[/?&]|$)' 
 149         'url': 'http://www.crunchyroll.com/wanna-be-the-strongest-in-the-world/episode-1-an-idol-wrestler-is-born-645513', 
 153             'title': 'Wanna be the Strongest in the World Episode 1 – An Idol-Wrestler is Born!', 
 154             'description': 'md5:2d17137920c64f2f49981a7797d275ef', 
 155             'thumbnail': r
're:^https?://.*\.jpg$', 
 156             'uploader': 'Yomiuri Telecasting Corporation (YTV)', 
 157             'upload_date': '20131013', 
 158             'url': 're:(?!.*&)', 
 162             'skip_download': True, 
 165         'url': 'http://www.crunchyroll.com/media-589804/culture-japan-1', 
 169             'title': 'Culture Japan Episode 1 – Rebuilding Japan after the 3.11', 
 170             'description': 'md5:2fbc01f90b87e8e9137296f37b461c12', 
 171             'thumbnail': r
're:^https?://.*\.jpg$', 
 172             'uploader': 'Danny Choo Network', 
 173             'upload_date': '20120213', 
 177             'skip_download': True, 
 179         'skip': 'Video gone', 
 181         'url': 'http://www.crunchyroll.com/rezero-starting-life-in-another-world-/episode-5-the-morning-of-our-promise-is-still-distant-702409', 
 185             'title': 'Re:ZERO -Starting Life in Another World- Episode 5 – The Morning of Our Promise Is Still Distant', 
 186             'description': 'md5:97664de1ab24bbf77a9c01918cb7dca9', 
 187             'thumbnail': r
're:^https?://.*\.jpg$', 
 188             'uploader': 'TV TOKYO', 
 189             'upload_date': '20160508', 
 193             'skip_download': True, 
 196         'url': 'http://www.crunchyroll.com/konosuba-gods-blessing-on-this-wonderful-world/episode-1-give-me-deliverance-from-this-judicial-injustice-727589', 
 200             'title': "KONOSUBA -God's blessing on this wonderful world! 2 Episode 1 – Give Me Deliverance From This Judicial Injustice!", 
 201             'description': 'md5:cbcf05e528124b0f3a0a419fc805ea7d', 
 202             'thumbnail': r
're:^https?://.*\.jpg$', 
 203             'uploader': 'Kadokawa Pictures Inc.', 
 204             'upload_date': '20170118', 
 205             'series': "KONOSUBA -God's blessing on this wonderful world!", 
 206             'season': "KONOSUBA -God's blessing on this wonderful world! 2", 
 208             'episode': 'Give Me Deliverance From This Judicial Injustice!', 
 213             'skip_download': True, 
 216         'url': 'http://www.crunchyroll.fr/girl-friend-beta/episode-11-goodbye-la-mode-661697', 
 217         'only_matching': True, 
 219         # geo-restricted (US), 18+ maturity wall, non-premium available 
 220         'url': 'http://www.crunchyroll.com/cosplay-complex-ova/episode-1-the-birth-of-the-cosplay-club-565617', 
 221         'only_matching': True, 
 223         # A description with double quotes 
 224         'url': 'http://www.crunchyroll.com/11eyes/episode-1-piros-jszaka-red-night-535080', 
 228             'title': '11eyes Episode 1 – Red Night ~ Piros éjszaka', 
 229             'description': 'Kakeru and Yuka are thrown into an alternate nightmarish world they call "Red Night".', 
 230             'uploader': 'Marvelous AQL Inc.', 
 231             'upload_date': '20091021', 
 234             # Just test metadata extraction 
 235             'skip_download': True, 
 238         # make sure we can extract an uploader name that's not a link 
 239         'url': 'http://www.crunchyroll.com/hakuoki-reimeiroku/episode-1-dawn-of-the-divine-warriors-606899', 
 243             'title': 'Hakuoki Reimeiroku Episode 1 – Dawn of the Divine Warriors', 
 244             'description': 'Ryunosuke was left to die, but Serizawa-san asked him a simple question "Do you want to live?"', 
 245             'uploader': 'Geneon Entertainment', 
 246             'upload_date': '20120717', 
 249             # just test metadata extraction 
 250             'skip_download': True, 
 253         # A video with a vastly different season name compared to the series name 
 254         'url': 'http://www.crunchyroll.com/nyarko-san-another-crawling-chaos/episode-1-test-590532', 
 258             'title': 'Haiyoru! Nyaruani (ONA) Episode 1 – Test', 
 259             'description': 'Mahiro and Nyaruko talk about official certification.', 
 260             'uploader': 'TV TOKYO', 
 261             'upload_date': '20120305', 
 262             'series': 'Nyarko-san: Another Crawling Chaos', 
 263             'season': 'Haiyoru! Nyaruani (ONA)', 
 266             # Just test metadata extraction 
 267             'skip_download': True, 
 270         'url': 'http://www.crunchyroll.com/media-723735', 
 271         'only_matching': True, 
 275         '360': ('60', '106'), 
 276         '480': ('61', '106'), 
 277         '720': ('62', '106'), 
 278         '1080': ('80', '108'), 
 281     def _decrypt_subtitles(self
, data
, iv
, id): 
 282         data 
= bytes_to_intlist(compat_b64decode(data
)) 
 283         iv 
= bytes_to_intlist(compat_b64decode(iv
)) 
 286         def obfuscate_key_aux(count
, modulo
, start
): 
 288             for _ 
in range(count
): 
 289                 output
.append(output
[-1] + output
[-2]) 
 290             # cut off start values 
 292             output 
= list(map(lambda x
: x 
% modulo 
+ 33, output
)) 
 295         def obfuscate_key(key
): 
 296             num1 
= int(floor(pow(2, 25) * sqrt(6.9))) 
 297             num2 
= (num1 ^ key
) << 5 
 299             num4 
= num3 ^ 
(num3 
>> 3) ^ num2
 
 300             prefix 
= intlist_to_bytes(obfuscate_key_aux(20, 97, (1, 2))) 
 301             shaHash 
= bytes_to_intlist(sha1(prefix 
+ str(num4
).encode('ascii')).digest()) 
 302             # Extend 160 Bit hash to 256 Bit 
 303             return shaHash 
+ [0] * 12 
 305         key 
= obfuscate_key(id) 
 307         decrypted_data 
= intlist_to_bytes(aes_cbc_decrypt(data
, key
, iv
)) 
 308         return zlib
.decompress(decrypted_data
) 
 310     def _convert_subtitles_to_srt(self
, sub_root
): 
 313         for i
, event 
in enumerate(sub_root
.findall('./events/event'), 1): 
 314             start 
= event
.attrib
['start'].replace('.', ',') 
 315             end 
= event
.attrib
['end'].replace('.', ',') 
 316             text 
= event
.attrib
['text'].replace('\\N', '\n') 
 317             output 
+= '%d\n%s --> %s\n%s\n\n' % (i
, start
, end
, text
) 
 320     def _convert_subtitles_to_ass(self
, sub_root
): 
 323         def ass_bool(strvalue
): 
 329         output 
= '[Script Info]\n' 
 330         output 
+= 'Title: %s\n' % sub_root
.attrib
['title'] 
 331         output 
+= 'ScriptType: v4.00+\n' 
 332         output 
+= 'WrapStyle: %s\n' % sub_root
.attrib
['wrap_style'] 
 333         output 
+= 'PlayResX: %s\n' % sub_root
.attrib
['play_res_x'] 
 334         output 
+= 'PlayResY: %s\n' % sub_root
.attrib
['play_res_y'] 
 337 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 
 339         for style 
in sub_root
.findall('./styles/style'): 
 340             output 
+= 'Style: ' + style
.attrib
['name'] 
 341             output 
+= ',' + style
.attrib
['font_name'] 
 342             output 
+= ',' + style
.attrib
['font_size'] 
 343             output 
+= ',' + style
.attrib
['primary_colour'] 
 344             output 
+= ',' + style
.attrib
['secondary_colour'] 
 345             output 
+= ',' + style
.attrib
['outline_colour'] 
 346             output 
+= ',' + style
.attrib
['back_colour'] 
 347             output 
+= ',' + ass_bool(style
.attrib
['bold']) 
 348             output 
+= ',' + ass_bool(style
.attrib
['italic']) 
 349             output 
+= ',' + ass_bool(style
.attrib
['underline']) 
 350             output 
+= ',' + ass_bool(style
.attrib
['strikeout']) 
 351             output 
+= ',' + style
.attrib
['scale_x'] 
 352             output 
+= ',' + style
.attrib
['scale_y'] 
 353             output 
+= ',' + style
.attrib
['spacing'] 
 354             output 
+= ',' + style
.attrib
['angle'] 
 355             output 
+= ',' + style
.attrib
['border_style'] 
 356             output 
+= ',' + style
.attrib
['outline'] 
 357             output 
+= ',' + style
.attrib
['shadow'] 
 358             output 
+= ',' + style
.attrib
['alignment'] 
 359             output 
+= ',' + style
.attrib
['margin_l'] 
 360             output 
+= ',' + style
.attrib
['margin_r'] 
 361             output 
+= ',' + style
.attrib
['margin_v'] 
 362             output 
+= ',' + style
.attrib
['encoding'] 
 367 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 
 369         for event 
in sub_root
.findall('./events/event'): 
 370             output 
+= 'Dialogue: 0' 
 371             output 
+= ',' + event
.attrib
['start'] 
 372             output 
+= ',' + event
.attrib
['end'] 
 373             output 
+= ',' + event
.attrib
['style'] 
 374             output 
+= ',' + event
.attrib
['name'] 
 375             output 
+= ',' + event
.attrib
['margin_l'] 
 376             output 
+= ',' + event
.attrib
['margin_r'] 
 377             output 
+= ',' + event
.attrib
['margin_v'] 
 378             output 
+= ',' + event
.attrib
['effect'] 
 379             output 
+= ',' + event
.attrib
['text'] 
 384     def _extract_subtitles(self
, subtitle
): 
 385         sub_root 
= compat_etree_fromstring(subtitle
) 
 388             'data': self
._convert
_subtitles
_to
_srt
(sub_root
), 
 391             'data': self
._convert
_subtitles
_to
_ass
(sub_root
), 
 394     def _get_subtitles(self
, video_id
, webpage
): 
 396         for sub_id
, sub_name 
in re
.findall(r
'\bssid=([0-9]+)"[^>]+?\btitle="([^"]+)', webpage
): 
 397             sub_doc 
= self
._call
_rpc
_api
( 
 398                 'Subtitle_GetXml', video_id
, 
 399                 'Downloading subtitles for ' + sub_name
, data
={ 
 400                     'subtitle_script_id': sub_id
, 
 402             if not isinstance(sub_doc
, etree
.Element
): 
 404             sid 
= sub_doc
.get('id') 
 405             iv 
= xpath_text(sub_doc
, 'iv', 'subtitle iv') 
 406             data 
= xpath_text(sub_doc
, 'data', 'subtitle data') 
 407             if not sid 
or not iv 
or not data
: 
 409             subtitle 
= self
._decrypt
_subtitles
(data
, iv
, sid
).decode('utf-8') 
 410             lang_code 
= self
._search
_regex
(r
'lang_code=["\']([^
"\']+)', subtitle, 'subtitle_lang_code', fatal=False) 
 413             subtitles[lang_code] = self._extract_subtitles(subtitle) 
 416     def _real_extract(self, url): 
 417         mobj = re.match(self._VALID_URL, url) 
 418         video_id = mobj.group('video_id') 
 420         if mobj.group('prefix') == 'm': 
 421             mobile_webpage = self._download_webpage(url, video_id, 'Downloading mobile webpage') 
 422             webpage_url = self._search_regex(r'<link rel="canonical
" href="([^
"]+)" />', mobile_webpage, 'webpage_url
') 
 424             webpage_url = 'http
://www
.' + mobj.group('url
') 
 426         webpage = self._download_webpage( 
 427             self._add_skip_wall(webpage_url), video_id, 
 428             headers=self.geo_verification_headers()) 
 429         note_m = self._html_search_regex( 
 430             r'<div 
class="showmedia-trailer-notice">(.+?
)</div
>', 
 431             webpage, 'trailer
-notice
', default='') 
 433             raise ExtractorError(note_m) 
 435         mobj = re.search(r'Page\
.messaging_box_controller\
.addItems\
(\
[(?P
<msg
>{.+?
})\
]\
)', webpage) 
 437             msg = json.loads(mobj.group('msg
')) 
 438             if msg.get('type') == 'error
': 
 439                 raise ExtractorError('crunchyroll returned error
: %s' % msg['message_body
'], expected=True) 
 441         if 'To view this
, please log 
in to verify you are 
18 or older
.' in webpage: 
 442             self.raise_login_required() 
 444         media = self._parse_json(self._search_regex( 
 445             r'vilos\
.config\
.media\s
*=\s
*({.+?
});', 
 446             webpage, 'vilos media
', default='{}'), video_id) 
 447         media_metadata = media.get('metadata
') or {} 
 449         language = self._search_regex( 
 450             r'(?
:vilos\
.config\
.player\
.language|LOCALE
)\s
*=\s
*(["\'])(?P<lang>(?:(?!\1).)+)\1', 
 451             webpage, 'language', default=None, group='lang') 
 453         video_title = self._html_search_regex( 
 454             r'(?s)<h1[^>]*>((?:(?!<h1).)*?<span[^>]+itemprop=["\']title
["\'][^>]*>(?:(?!<h1).)+?)</h1>', 
 455             webpage, 'video_title') 
 456         video_title = re.sub(r' {2,}', ' ', video_title) 
 457         video_description = (self._parse_json(self._html_search_regex( 
 458             r'<script[^>]*>\s*.+?\[media_id=%s\].+?({.+?"description
"\s*:.+?})\);' % video_id, 
 459             webpage, 'description', default='{}'), video_id) or media_metadata).get('description') 
 460         if video_description: 
 461             video_description = lowercase_escape(video_description.replace(r'\r\n', '\n')) 
 462         video_upload_date = self._html_search_regex( 
 463             [r'<div>Availability for free users:(.+?)</div>', r'<div>[^<>]+<span>\s*(.+?\d{4})\s*</span></div>'], 
 464             webpage, 'video_upload_date', fatal=False, flags=re.DOTALL) 
 465         if video_upload_date: 
 466             video_upload_date = unified_strdate(video_upload_date) 
 467         video_uploader = self._html_search_regex( 
 468             # try looking for both an uploader that's a link and one that's not 
 469             [r'<a[^>]+href="/publisher
/[^
"]+"[^
>]*>([^
<]+)</a
>', r'<div
>\s
*Publisher
:\s
*<span
>\s
*(.+?
)\s
*</span
>\s
*</div
>'], 
 470             webpage, 'video_uploader
', fatal=False) 
 473         for stream in media.get('streams
', []): 
 474             audio_lang = stream.get('audio_lang
') 
 475             hardsub_lang = stream.get('hardsub_lang
') 
 476             vrv_formats = self._extract_vrv_formats( 
 477                 stream.get('url
'), video_id, stream.get('format
'), 
 478                 audio_lang, hardsub_lang) 
 479             for f in vrv_formats: 
 482                 language_preference = 0 
 483                 if audio_lang == language: 
 484                     language_preference += 1 
 485                 if hardsub_lang == language: 
 486                     language_preference += 1 
 487                 if language_preference: 
 488                     f['language_preference
'] = language_preference 
 489             formats.extend(vrv_formats) 
 492             for a, fmt in re.findall(r'(<a
[^
>]+token
=["\']showmedia\.([0-9]{3,4})p["\'][^
>]+>)', webpage): 
 493                 attrs = extract_attributes(a) 
 494                 href = attrs.get('href
') 
 495                 if href and '/freetrial
' in href: 
 497                 available_fmts.append(fmt) 
 498             if not available_fmts: 
 499                 for p in (r'token
=["\']showmedia\.([0-9]{3,4})p"', r'showmedia\
.([0-9]{3,4})p
'): 
 500                     available_fmts = re.findall(p, webpage) 
 503             if not available_fmts: 
 504                 available_fmts = self._FORMAT_IDS.keys() 
 505             video_encode_ids = [] 
 507             for fmt in available_fmts: 
 508                 stream_quality, stream_format = self._FORMAT_IDS[fmt] 
 509                 video_format = fmt + 'p
' 
 511                 streamdata = self._call_rpc_api( 
 512                     'VideoPlayer_GetStandardConfig
', video_id, 
 513                     'Downloading media info 
for %s' % video_format, data={ 
 514                         'media_id
': video_id, 
 515                         'video_format
': stream_format, 
 516                         'video_quality
': stream_quality, 
 519                 if isinstance(streamdata, etree.Element): 
 520                     stream_info = streamdata.find('./{default}preload
/stream_info
') 
 521                     if stream_info is not None: 
 522                         stream_infos.append(stream_info) 
 523                 stream_info = self._call_rpc_api( 
 524                     'VideoEncode_GetStreamInfo
', video_id, 
 525                     'Downloading stream info 
for %s' % video_format, data={ 
 526                         'media_id
': video_id, 
 527                         'video_format
': stream_format, 
 528                         'video_encode_quality
': stream_quality, 
 530                 if isinstance(stream_info, etree.Element): 
 531                     stream_infos.append(stream_info) 
 532                 for stream_info in stream_infos: 
 533                     video_encode_id = xpath_text(stream_info, './video_encode_id
') 
 534                     if video_encode_id in video_encode_ids: 
 536                     video_encode_ids.append(video_encode_id) 
 538                     video_file = xpath_text(stream_info, './file') 
 541                     if video_file.startswith('http
'): 
 542                         formats.extend(self._extract_m3u8_formats( 
 543                             video_file, video_id, 'mp4
', entry_protocol='m3u8_native
', 
 544                             m3u8_id='hls
', fatal=False)) 
 547                     video_url = xpath_text(stream_info, './host
') 
 550                     metadata = stream_info.find('./metadata
') 
 552                         'format
': video_format, 
 553                         'height
': int_or_none(xpath_text(metadata, './height
')), 
 554                         'width
': int_or_none(xpath_text(metadata, './width
')), 
 557                     if '.fplive
.net
/' in video_url: 
 558                         video_url = re.sub(r'^rtmpe?
://', 'http
://', video_url.strip()) 
 559                         parsed_video_url = compat_urlparse.urlparse(video_url) 
 560                         direct_video_url = compat_urlparse.urlunparse(parsed_video_url._replace( 
 561                             netloc='v
.lvlt
.crcdn
.net
', 
 562                             path='%s/%s' % (remove_end(parsed_video_url.path, '/'), video_file.split(':')[-1]))) 
 563                         if self._is_valid_url(direct_video_url, video_id, video_format): 
 565                                 'format_id
': 'http
-' + video_format, 
 566                                 'url
': direct_video_url, 
 568                             formats.append(format_info) 
 572                         'format_id
': 'rtmp
-' + video_format, 
 574                         'play_path
': video_file, 
 577                     formats.append(format_info) 
 578         self._sort_formats(formats, ('preference
', 'language_preference
', 'height
', 'width
', 'tbr
', 'fps
')) 
 580         metadata = self._call_rpc_api( 
 581             'VideoPlayer_GetMediaMetadata
', video_id, 
 582             note='Downloading media info
', data={ 
 583                 'media_id
': video_id, 
 587         for subtitle in media.get('subtitles
', []): 
 588             subtitle_url = subtitle.get('url
') 
 591             subtitles.setdefault(subtitle.get('language
', 'enUS
'), []).append({ 
 593                 'ext
': subtitle.get('format
', 'ass
'), 
 596             subtitles = self.extract_subtitles(video_id, webpage) 
 598         # webpage provide more accurate data than series_title from XML 
 599         series = self._html_search_regex( 
 600             r'(?s
)<h\d
[^
>]+\bid
=["\']showmedia_about_episode_num[^>]+>(.+?)</h\d', 
 601             webpage, 'series', fatal=False) 
 603         season = episode = episode_number = duration = thumbnail = None 
 605         if isinstance(metadata, etree.Element): 
 606             season = xpath_text(metadata, 'series_title') 
 607             episode = xpath_text(metadata, 'episode_title') 
 608             episode_number = int_or_none(xpath_text(metadata, 'episode_number')) 
 609             duration = float_or_none(media_metadata.get('duration'), 1000) 
 610             thumbnail = xpath_text(metadata, 'episode_image_url') 
 613             episode = media_metadata.get('title') 
 614         if not episode_number: 
 615             episode_number = int_or_none(media_metadata.get('episode_number')) 
 617             thumbnail = media_metadata.get('thumbnail', {}).get('url') 
 619         season_number = int_or_none(self._search_regex( 
 620             r'(?s)<h\d[^>]+id=["\']showmedia_about_episode_num
[^
>]+>.+?
</h\d
>\s
*<h4
>\s
*Season (\d
+)', 
 621             webpage, 'season number
', default=None)) 
 625             'title
': video_title, 
 626             'description
': video_description, 
 627             'duration
': duration, 
 628             'thumbnail
': thumbnail, 
 629             'uploader
': video_uploader, 
 630             'upload_date
': video_upload_date, 
 633             'season_number
': season_number, 
 635             'episode_number
': episode_number, 
 636             'subtitles
': subtitles, 
 641 class CrunchyrollShowPlaylistIE(CrunchyrollBaseIE): 
 642     IE_NAME = 'crunchyroll
:playlist
' 
 643     _VALID_URL = r'https?
://(?
:(?P
<prefix
>www|m
)\
.)?
(?P
<url
>crunchyroll\
.com
/(?
!(?
:news|anime
-news|library|forum|launchcalendar|lineup|store|comics|freetrial|login|media
-\d
+))(?P
<id>[\w\
-]+))/?
(?
:\?|$
)' 
 646         'url
': 'http
://www
.crunchyroll
.com
/a
-bridge
-to
-the
-starry
-skies
-hoshizora
-e
-kakaru
-hashi
', 
 648             'id': 'a
-bridge
-to
-the
-starry
-skies
-hoshizora
-e
-kakaru
-hashi
', 
 649             'title
': 'A Bridge to the Starry Skies 
- Hoshizora e Kakaru Hashi
' 
 651         'playlist_count
': 13, 
 653         # geo-restricted (US), 18+ maturity wall, non-premium available 
 654         'url
': 'http
://www
.crunchyroll
.com
/cosplay
-complex-ova
', 
 656             'id': 'cosplay
-complex-ova
', 
 657             'title
': 'Cosplay Complex OVA
' 
 660         'skip
': 'Georestricted
', 
 662         # geo-restricted (US), 18+ maturity wall, non-premium will be available since 2015.11.14 
 663         'url
': 'http
://www
.crunchyroll
.com
/ladies
-versus
-butlers?skip_wall
=1', 
 664         'only_matching
': True, 
 667     def _real_extract(self, url): 
 668         show_id = self._match_id(url) 
 670         webpage = self._download_webpage( 
 671             self._add_skip_wall(url), show_id, 
 672             headers=self.geo_verification_headers()) 
 673         title = self._html_search_regex( 
 674             r'(?s
)<h1
[^
>]*>\s
*<span itemprop
="name">(.*?
)</span
>', 
 676         episode_paths = re.findall( 
 677             r'(?s
)<li 
id="showview_videos_media_(\d+)"[^
>]+>.*?
<a href
="([^"]+)"', 
 680             self.url_result('http://www.crunchyroll.com' + ep, 'Crunchyroll', ep_id) 
 681             for ep_id, ep in episode_paths