14 from .common
import InfoExtractor
15 from ..aes
import aes_ecb_decrypt
16 from ..networking
import RequestHandler
, Response
17 from ..networking
.exceptions
import TransportError
31 class AbemaLicenseRH(RequestHandler
):
32 _SUPPORTED_URL_SCHEMES
= ('abematv-license',)
33 _SUPPORTED_PROXY_SCHEMES
= None
34 _SUPPORTED_FEATURES
= None
35 RH_NAME
= 'abematv_license'
37 _STRTABLE
= '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
38 _HKEY
= b
'3AF0298C219469522A313570E8583005A642E73EDD58E3EA2FB7339D3DF1597E'
40 def __init__(self
, *, ie
: 'AbemaTVIE', **kwargs
):
41 super().__init
__(**kwargs
)
44 def _send(self
, request
):
46 ticket
= urllib
.parse
.urlparse(url
).netloc
49 response_data
= self
._get
_videokey
_from
_ticket
(ticket
)
50 except ExtractorError
as e
:
51 raise TransportError(cause
=e
.cause
) from e
52 except (IndexError, KeyError, TypeError) as e
:
53 raise TransportError(cause
=repr(e
)) from e
56 io
.BytesIO(response_data
), url
,
57 headers
={'Content-Length': str(len(response_data
))})
59 def _get_videokey_from_ticket(self
, ticket
):
60 to_show
= self
.ie
.get_param('verbose', False)
61 media_token
= self
.ie
._get
_media
_token
(to_show
=to_show
)
63 license_response
= self
.ie
._download
_json
(
64 'https://license.abema.io/abematv-hls', None, note
='Requesting playback license' if to_show
else False,
65 query
={'t': media_token
},
71 'Content-Type': 'application/json',
74 res
= decode_base_n(license_response
['k'], table
=self
._STRTABLE
)
75 encvideokey
= bytes_to_intlist(struct
.pack('>QQ', res
>> 64, res
& 0xffffffffffffffff))
78 binascii
.unhexlify(self
._HKEY
),
79 (license_response
['cid'] + self
.ie
._DEVICE
_ID
).encode(),
80 digestmod
=hashlib
.sha256
)
81 enckey
= bytes_to_intlist(h
.digest())
83 return intlist_to_bytes(aes_ecb_decrypt(encvideokey
, enckey
))
86 class AbemaTVBaseIE(InfoExtractor
):
87 _NETRC_MACHINE
= 'abematv'
93 _SECRETKEY
= b
'v+Gjs=25Aw5erR!J8ZuvRrCx*rGswhB&qdHd_SYerEWdU&a?3DzN9BRbp5KwY4hEmcj5#fykMjJ=AuWz5GSMY-d@H7DMEh3M@9n2G552Us$$k9cD=3TxwWe86!x#Zyhe'
96 def _generate_aks(cls
, deviceid
):
97 deviceid
= deviceid
.encode()
98 # add 1 hour and then drop minute and secs
99 ts_1hour
= int((time_seconds() // 3600 + 1) * 3600)
100 time_struct
= time
.gmtime(ts_1hour
)
101 ts_1hour_str
= str(ts_1hour
).encode()
107 h
= hmac
.new(cls
._SECRETKEY
, digestmod
=hashlib
.sha256
)
113 for _
in range(count
):
116 def mix_twist(nonce
):
118 mix_once(base64
.urlsafe_b64encode(tmp
).rstrip(b
'=') + nonce
)
120 mix_once(cls
._SECRETKEY
)
121 mix_tmp(time_struct
.tm_mon
)
123 mix_tmp(time_struct
.tm_mday
% 5)
124 mix_twist(ts_1hour_str
)
125 mix_tmp(time_struct
.tm_hour
% 5)
127 return base64
.urlsafe_b64encode(tmp
).rstrip(b
'=').decode('utf-8')
129 def _get_device_token(self
):
131 return self
._USERTOKEN
133 self
._downloader
._request
_director
.add_handler(AbemaLicenseRH(ie
=self
, logger
=None))
135 username
, _
= self
._get
_login
_info
()
136 auth_cache
= username
and self
.cache
.load(self
._NETRC
_MACHINE
, username
, min_ver
='2024.01.19')
137 AbemaTVBaseIE
._USERTOKEN
= auth_cache
and auth_cache
.get('usertoken')
138 if AbemaTVBaseIE
._USERTOKEN
:
139 # try authentication with locally stored token
141 AbemaTVBaseIE
._DEVICE
_ID
= auth_cache
.get('device_id')
142 self
._get
_media
_token
(True)
144 except ExtractorError
as e
:
145 self
.report_warning(f
'Failed to login with cached user token; obtaining a fresh one ({e})')
147 AbemaTVBaseIE
._DEVICE
_ID
= str(uuid
.uuid4())
148 aks
= self
._generate
_aks
(self
._DEVICE
_ID
)
149 user_data
= self
._download
_json
(
150 'https://api.abema.io/v1/users', None, note
='Authorizing',
152 'deviceId': self
._DEVICE
_ID
,
153 'applicationKeySecret': aks
,
156 'Content-Type': 'application/json',
158 AbemaTVBaseIE
._USERTOKEN
= user_data
['token']
160 return self
._USERTOKEN
162 def _get_media_token(self
, invalidate
=False, to_show
=True):
163 if not invalidate
and self
._MEDIATOKEN
:
164 return self
._MEDIATOKEN
166 AbemaTVBaseIE
._MEDIATOKEN
= self
._download
_json
(
167 'https://api.abema.io/v1/media/token', None, note
='Fetching media token' if to_show
else False,
170 'osVersion': '6.0.1',
172 'osTimezone': 'Asia/Tokyo',
174 'appVersion': '3.27.1',
176 'Authorization': f
'bearer {self._get_device_token()}',
179 return self
._MEDIATOKEN
181 def _perform_login(self
, username
, password
):
182 self
._get
_device
_token
()
183 if self
.cache
.load(self
._NETRC
_MACHINE
, username
, min_ver
='2024.01.19') and self
._get
_media
_token
():
184 self
.write_debug('Skipping logging in')
187 if '@' in username
: # don't strictly check if it's email address or not
188 ep
, method
= 'user/email', 'email'
190 ep
, method
= 'oneTimePassword', 'userId'
192 login_response
= self
._download
_json
(
193 f
'https://api.abema.io/v1/auth/{ep}', None, note
='Logging in',
196 'password': password
,
197 }).encode(), headers
={
198 'Authorization': f
'bearer {self._get_device_token()}',
199 'Origin': 'https://abema.tv',
200 'Referer': 'https://abema.tv/',
201 'Content-Type': 'application/json',
204 AbemaTVBaseIE
._USERTOKEN
= login_response
['token']
205 self
._get
_media
_token
(True)
207 'device_id': AbemaTVBaseIE
._DEVICE
_ID
,
208 'usertoken': AbemaTVBaseIE
._USERTOKEN
,
210 self
.cache
.store(self
._NETRC
_MACHINE
, username
, auth_cache
)
212 def _call_api(self
, endpoint
, video_id
, query
=None, note
='Downloading JSON metadata'):
213 return self
._download
_json
(
214 f
'https://api.abema.io/{endpoint}', video_id
, query
=query
or {},
217 'Authorization': f
'bearer {self._get_device_token()}',
220 def _extract_breadcrumb_list(self
, webpage
, video_id
):
221 for jld
in re
.finditer(
222 r
'(?is)</span></li></ul><script[^>]+type=(["\']?
)application
/ld\
+json\
1[^
>]*>(?P
<json_ld
>.+?
)</script
>',
224 jsonld = self._parse_json(jld.group('json_ld
'), video_id, fatal=False)
225 if traverse_obj(jsonld, '@type') != 'BreadcrumbList
':
227 items = traverse_obj(jsonld, ('itemListElement
', ..., 'name
'))
233 class AbemaTVIE(AbemaTVBaseIE):
234 _VALID_URL = r'https?
://abema\
.tv
/(?P
<type>now
-on
-air|video
/episode|channels
/.+?
/slots
)/(?P
<id>[^?
/]+)'
236 'url
': 'https
://abema
.tv
/video
/episode
/194-25_s
2_p
1',
238 'id': '194-25_s
2_p
1',
239 'title
': '第
1話 「チーズケーキ」 「モーニング再び」
',
243 'episode
': '第
1話 「チーズケーキ」 「モーニング再び」
',
248 'url
': 'https
://abema
.tv
/channels
/anime
-live2
/slots
/E8tvAnMJ7a9a5d
',
250 'id': 'E8tvAnMJ7a9a5d
',
251 'title
': 'ゆるキャン△ SEASON2 全話一挙【無料ビデオ
72時間】
',
252 'series
': 'ゆるキャン△ SEASON2
',
253 'episode
': 'ゆるキャン△ SEASON2 全話一挙【無料ビデオ
72時間】
',
256 'description
': 'md5
:9c5a3172ae763278f9303922f0ea5b17
',
260 'url
': 'https
://abema
.tv
/video
/episode
/87-877_s
1282_p
31047',
262 'id': 'E8tvAnMJ7a9a5d
',
264 'description
': 'md5
:56d4fc1b4f7769ded5f923c55bb4695d
',
265 'thumbnail
': r're
:https
://hayabusa\
.io
/.+',
267 'episode
': '第
5話『光射す』
',
271 'url
': 'https
://abema
.tv
/now
-on
-air
/abema
-anime
',
275 # 'title
': '女子高生の無駄づかい 全話一挙【無料ビデオ
72時間】
',
276 'description
': 'md5
:55f2e61f46a17e9230802d7bcc913d5f
',
279 'skip
': 'Not supported until yt
-dlp implements native live downloader OR AbemaTV can start a local HTTP server
',
283 def _real_extract(self, url):
284 # starting download using infojson from this extractor is undefined behavior,
285 # and never be fixed in the future; you must trigger downloads by directly specifying URL.
286 # (unless there's a way to hook before downloading by extractor
)
287 video_id
, video_type
= self
._match
_valid
_url
(url
).group('id', 'type')
289 'Authorization': 'Bearer ' + self
._get
_device
_token
(),
291 video_type
= video_type
.split('/')[-1]
293 webpage
= self
._download
_webpage
(url
, video_id
)
294 canonical_url
= self
._search
_regex
(
295 r
'<link\s+rel="canonical"\s*href="(.+?)"', webpage
, 'canonical URL',
297 info
= self
._search
_json
_ld
(webpage
, video_id
, default
={})
299 title
= self
._search
_regex
(
300 r
'<span\s*class=".+?EpisodeTitleBlock__title">(.+?)</span>', webpage
, 'title', default
=None)
303 for jld
in re
.finditer(
304 r
'(?is)<span\s*class="com-m-Thumbnail__image">(?:</span>)?<script[^>]+type=(["\']?
)application
/ld\
+json\
1[^
>]*>(?P
<json_ld
>.+?
)</script
>',
306 jsonld = self._parse_json(jld.group('json_ld
'), video_id, fatal=False)
310 title = jsonld.get('caption
')
311 if not title and video_type == 'now
-on
-air
':
312 if not self._TIMETABLE:
313 # cache the timetable because it goes to 5MiB in size (!!)
314 self._TIMETABLE = self._download_json(
315 'https
://api
.abema
.io
/v1
/timetable
/dataSet?debug
=false
', video_id,
317 now = time_seconds(hours=9)
318 for slot in self._TIMETABLE.get('slots
', []):
319 if slot.get('channelId
') != video_id:
321 if slot['startAt
'] <= now and now < slot['endAt
']:
322 title = slot['title
']
325 # read breadcrumb on top of page
326 breadcrumb = self._extract_breadcrumb_list(webpage, video_id)
328 # breadcrumb list translates to: (e.g. 1st test for this IE)
329 # Home > Anime (genre) > Isekai Shokudo 2 (series name) > Episode 1 "Cheese cakes" "Morning again" (episode title)
331 info['series
'] = breadcrumb[-2]
332 info['episode
'] = breadcrumb[-1]
334 title = info['episode
']
336 description = self._html_search_regex(
337 (r'<p\s
+class="com-video-EpisodeDetailsBlock__content"><span\s
+class=".+?">(.+?
)</span
></p
><div
',
338 r'<span\s
+class=".+?SlotSummary.+?">(.+?
)</span
></div
><div
'),
339 webpage, 'description
', default=None, group=1)
341 og_desc = self._html_search_meta(
342 ('description
', 'og
:description
', 'twitter
:description
'), webpage)
344 description = re.sub(r'''(?sx)
346 アニメの動画を無料で見るならABEMA!| # anime
347 等、.+ # applies for most of categories
351 # canonical URL may contain season and episode number
352 mobj = re.search(r's(\d
+)_p(\d
+)$
', canonical_url)
354 seri = int_or_none(mobj.group(1), default=float('inf
'))
355 epis = int_or_none(mobj.group(2), default=float('inf
'))
356 info['season_number
'] = seri if seri < 100 else None
357 # some anime like Detective Conan (though not available in AbemaTV)
358 # has more than 1000 episodes (1026 as of 2021/11/15)
359 info['episode_number
'] = epis if epis < 2000 else None
361 is_live, m3u8_url = False, None
362 availability = 'public
'
363 if video_type == 'now
-on
-air
':
365 channel_url = 'https
://api
.abema
.io
/v1
/channels
'
366 if video_id == 'news
-global':
367 channel_url = update_url_query(channel_url, {'division
': '1'})
368 onair_channels = self._download_json(channel_url, video_id)
369 for ch in onair_channels['channels
']:
370 if video_id == ch['id']:
371 m3u8_url = ch['playback
']['hls
']
374 raise ExtractorError(f'Cannot find on
-air {video_id} channel
.', expected=True)
375 elif video_type == 'episode
':
376 api_response = self._download_json(
377 f'https
://api
.abema
.io
/v1
/video
/programs
/{video_id}
', video_id,
378 note='Checking playability
',
380 if not traverse_obj(api_response, ('label
', 'free
', {bool})):
381 # cannot acquire decryption key for these streams
382 self.report_warning('This
is a premium
-only stream
')
383 availability = 'premium_only
'
384 info.update(traverse_obj(api_response, {
385 'series
': ('series
', 'title
'),
386 'season
': ('season
', 'name
'),
387 'season_number
': ('season
', 'sequence
'),
388 'episode_number
': ('episode
', 'number
'),
391 title = traverse_obj(api_response, ('episode
', 'title
'))
393 description = traverse_obj(api_response, ('episode
', 'content
'))
395 m3u8_url = f'https
://vod
-abematv
.akamaized
.net
/program
/{video_id}
/playlist
.m3u8
'
396 elif video_type == 'slots
':
397 api_response = self._download_json(
398 f'https
://api
.abema
.io
/v1
/media
/slots
/{video_id}
', video_id,
399 note='Checking playability
',
401 if not traverse_obj(api_response, ('slot
', 'flags
', 'timeshiftFree
'), default=False):
402 self.report_warning('This
is a premium
-only stream
')
403 availability = 'premium_only
'
405 m3u8_url = f'https
://vod
-abematv
.akamaized
.net
/slot
/{video_id}
/playlist
.m3u8
'
407 raise ExtractorError('Unreachable
')
410 self.report_warning("This is a livestream; yt-dlp doesn't support downloading natively
, but FFmpeg cannot handle m3u8 manifests
from AbemaTV
")
411 self.report_warning('Please consider using Streamlink to download these streams (https://github.com/streamlink/streamlink)')
412 formats = self._extract_m3u8_formats(
413 m3u8_url, video_id, ext='mp4', live=is_live)
418 'description': description,
421 'availability': availability,
426 class AbemaTVTitleIE(AbemaTVBaseIE):
427 _VALID_URL = r'https?://abema\.tv/video/title/(?P<id>[^?/]+)'
431 'url': 'https://abema.tv/video/title/90-1597',
434 'title': 'シャッフルアイランド',
436 'playlist_mincount': 2,
438 'url': 'https://abema.tv/video/title/193-132',
441 'title': '真心が届く~僕とスターのオフィス・ラブ!?~',
443 'playlist_mincount': 16,
445 'url': 'https://abema.tv/video/title/25-102',
448 'title': 'ソードアート・オンライン アリシゼーション',
450 'playlist_mincount': 24,
453 def _fetch_page(self, playlist_id, series_version, page):
454 programs = self._call_api(
455 f'v1/video/series/{playlist_id}/programs', playlist_id,
456 note=f'Downloading page {page + 1}',
458 'seriesVersion': series_version,
459 'offset': str(page * self._PAGE_SIZE),
461 'limit': str(self._PAGE_SIZE),
464 self.url_result(f'https://abema.tv/video/episode/{x}')
465 for x in traverse_obj(programs, ('programs', ..., 'id')))
467 def _entries(self, playlist_id, series_version):
468 return OnDemandPagedList(
469 functools.partial(self._fetch_page, playlist_id, series_version),
472 def _real_extract(self, url):
473 playlist_id = self._match_id(url)
474 series_info = self._call_api(f'v1/video/series/{playlist_id}', playlist_id)
476 return self.playlist_result(
477 self._entries(playlist_id, series_info['version']), playlist_id=playlist_id,
478 playlist_title=series_info.get('title'),
479 playlist_description=series_info.get('content'))