aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--yt_dlp/extractor/loco.py76
1 files changed, 74 insertions, 2 deletions
diff --git a/yt_dlp/extractor/loco.py b/yt_dlp/extractor/loco.py
index a648f7e13..6c9a25567 100644
--- a/yt_dlp/extractor/loco.py
+++ b/yt_dlp/extractor/loco.py
@@ -1,5 +1,9 @@
+import json
+import random
+import time
+
from .common import InfoExtractor
-from ..utils import int_or_none, url_or_none
+from ..utils import int_or_none, jwt_decode_hs256, try_call, url_or_none
from ..utils.traversal import require, traverse_obj
@@ -55,13 +59,81 @@ class LocoIE(InfoExtractor):
'upload_date': '20250226',
'modified_date': '20250226',
},
+ }, {
+ # Requires video authorization
+ 'url': 'https://loco.com/stream/ac854641-ae0f-497c-a8ea-4195f6d8cc53',
+ 'md5': '0513edf85c1e65c9521f555f665387d5',
+ 'info_dict': {
+ 'id': 'ac854641-ae0f-497c-a8ea-4195f6d8cc53',
+ 'ext': 'mp4',
+ 'title': 'DUAS CONTAS DESAFIANTE, RUSH TOP 1 NO BRASIL!',
+ 'description': 'md5:aa77818edd6fe00dd4b6be75cba5f826',
+ 'uploader_id': '7Y9JNAZC3Q',
+ 'channel': 'ayellol',
+ 'channel_follower_count': int,
+ 'comment_count': int,
+ 'view_count': int,
+ 'concurrent_view_count': int,
+ 'like_count': int,
+ 'duration': 1229,
+ 'thumbnail': 'https://static.ivory.getloconow.com/default_thumb/f5aa678b-6d04-45d9-a89a-859af0a8028f.jpg',
+ 'tags': ['Gameplay', 'Carry'],
+ 'series': 'League of Legends',
+ 'timestamp': 1741182253,
+ 'upload_date': '20250305',
+ 'modified_timestamp': 1741182419,
+ 'modified_date': '20250305',
+ },
}]
+ # From _app.js
+ _CLIENT_ID = 'TlwKp1zmF6eKFpcisn3FyR18WkhcPkZtzwPVEEC3'
+ _CLIENT_SECRET = 'Kp7tYlUN7LXvtcSpwYvIitgYcLparbtsQSe5AdyyCdiEJBP53Vt9J8eB4AsLdChIpcO2BM19RA3HsGtqDJFjWmwoonvMSG3ZQmnS8x1YIM8yl82xMXZGbE3NKiqmgBVU'
+
+ def _is_jwt_expired(self, token):
+ return jwt_decode_hs256(token)['exp'] - time.time() < 300
+
+ def _get_access_token(self, video_id):
+ access_token = try_call(lambda: self._get_cookies('https://loco.com')['access_token'].value)
+ if access_token and not self._is_jwt_expired(access_token):
+ return access_token
+ access_token = traverse_obj(self._download_json(
+ 'https://api.getloconow.com/v3/user/device_profile/', video_id,
+ 'Downloading access token', fatal=False, data=json.dumps({
+ 'platform': 7,
+ 'client_id': self._CLIENT_ID,
+ 'client_secret': self._CLIENT_SECRET,
+ 'model': 'Mozilla',
+ 'os_name': 'Win32',
+ 'os_ver': '5.0 (Windows)',
+ 'app_ver': '5.0 (Windows)',
+ }).encode(), headers={
+ 'Content-Type': 'application/json;charset=utf-8',
+ 'DEVICE-ID': ''.join(random.choices('0123456789abcdef', k=32)) + 'live',
+ 'X-APP-LANG': 'en',
+ 'X-APP-LOCALE': 'en-US',
+ 'X-CLIENT-ID': self._CLIENT_ID,
+ 'X-CLIENT-SECRET': self._CLIENT_SECRET,
+ 'X-PLATFORM': '7',
+ }), 'access_token')
+ if access_token and not self._is_jwt_expired(access_token):
+ self._set_cookie('.loco.com', 'access_token', access_token)
+ return access_token
+
def _real_extract(self, url):
video_type, video_id = self._match_valid_url(url).group('type', 'id')
webpage = self._download_webpage(url, video_id)
stream = traverse_obj(self._search_nextjs_data(webpage, video_id), (
- 'props', 'pageProps', ('liveStreamData', 'stream'), {dict}, any, {require('stream info')}))
+ 'props', 'pageProps', ('liveStreamData', 'stream', 'liveStream'), {dict}, any, {require('stream info')}))
+
+ if access_token := self._get_access_token(video_id):
+ self._request_webpage(
+ 'https://drm.loco.com/v1/streams/playback/', video_id,
+ 'Downloading video authorization', fatal=False, headers={
+ 'authorization': access_token,
+ }, query={
+ 'stream_uid': stream['uid'],
+ })
return {
'formats': self._extract_m3u8_formats(stream['conf']['hls'], video_id),