Compare commits

..
8 Commits
Author SHA1 Message Date
grqzandGitHub e8de28e23c [ie/bilibili] Fix API extraction (#13730)
Closes #12887, Closes #16962
Authored by: grqz
2026-07-03 23:36:55 +00:00
bashonlyandGitHub 1472d10980 [ci] Add label handler workflow (#17128)
Authored by: bashonly
2026-07-03 23:27:24 +00:00
doe1080andGitHub 6694ef8299 [ie/openrec] Change _NETRC_MACHINE to mellowfan (#17130)
Fix 5aa335ecd9

Authored by: doe1080
2026-07-03 23:00:59 +00:00
doe1080andGitHub fa383a9efa [ie/omnyfm] Add extractors (#15942)
Closes #10201, Closes #11035
Authored by: doe1080
2026-07-03 22:36:01 +00:00
doe1080andGitHub 5aa335ecd9 [ie/openrec] Rework extractors (#16857)
Closes #12740, Closes #13698
Authored by: doe1080
2026-07-03 18:55:36 +00:00
doe1080andGitHub 26654a359d [ie/streaks] Fix extractor (#16413)
* Add `api_key` extractor-arg
* Fix SSAI detection
* Add hooks for custom Streaks playback API requests

Authored by: doe1080
2026-07-03 18:49:39 +00:00
bashonlyandGitHub 161dd9fd05 [ie/instagram] Avoid unnecessary API call (#17127)
Fix 8b8e3e3cb4

Authored by: bashonly
2026-07-03 18:28:53 +00:00
bashonlyandGitHub ac4c955ea9 [ie/instagram] Detect when cookies are invalidated (#17126)
Thanks to @0xvd and @gamer191 for their research/testing

Closes #17124
Authored by: bashonly
2026-07-03 18:16:37 +00:00
10 changed files with 1258 additions and 185 deletions
+4
View File
@@ -18,3 +18,7 @@ paths:
ignore: ignore:
# SC1090 "Can't follow non-constant source": ignore when using `source` to activate venv # SC1090 "Can't follow non-constant source": ignore when using `source` to activate venv
- '.+SC1090.+' - '.+SC1090.+'
.github/workflows/label-handler.yml:
ignore:
# https://github.com/rhysd/actionlint/issues/657
- 'unexpected key "queue" for "concurrency" section.+'
+92
View File
@@ -0,0 +1,92 @@
name: Label Handler
on:
issues:
types: [labeled]
pull_request_target:
types: [labeled] # zizmor: ignore[dangerous-triggers]
permissions: {}
concurrency:
group: label
cancel-in-progress: false
queue: max
env:
GH_TELEMETRY: "false"
LLM_MESSAGE: >-
This contribution has been determined to be in violation of yt-dlp's
[**NO AI / NO LLM POLICY**](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#no-ai--no-llm-policy).
Repeat or flagrant violations of this policy will result in a permanent ban from this repository.
BAD_MESSAGE: >-
This contribution has been determined to be in violation of yt-dlp's
[policy against supporting sites that are primarily used for
piracy](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy).
Please consult the [yt-dlp wiki
FAQ](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#why-is-there-a-rule-against-websites-primarily-used-for-piracy)
if you have questions.
jobs:
issue-llm:
name: Issue (ai-policy-violation)
if: github.event.issue.state == 'open' && contains(github.event.issue.labels.*.name, 'ai-policy-violation')
permissions:
issues: write # Needed to comment on, close, and lock issues
runs-on: ubuntu-slim
env:
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
steps:
- name: Comment, close and lock issue
run: |
gh issue unlock "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" || true
gh issue close "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" --reason "not planned" --comment "${LLM_MESSAGE}"
gh issue lock "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}"
issue-bad:
name: Issue (piracy/illegal)
if: github.event.issue.state == 'open' && contains(github.event.issue.labels.*.name, 'piracy/illegal')
permissions:
issues: write # Needed to comment on, close, and lock issues
runs-on: ubuntu-slim
env:
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
steps:
- name: Comment, close and lock issue
run: |
gh issue unlock "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" || true
gh issue close "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" --reason "not planned" --comment "${BAD_MESSAGE}"
gh issue lock "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}"
pr-llm:
name: PR (ai-policy-violation)
if: github.event.pull_request.state == 'open' && contains(github.event.pull_request.labels.*.name, 'ai-policy-violation')
permissions:
pull-requests: write # Needed to comment on, close, and lock PRs
runs-on: ubuntu-slim
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Comment, close and lock PR
run: |
gh pr unlock "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" || true
gh pr close "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --comment "${LLM_MESSAGE}"
gh pr lock "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}"
pr-bad:
name: PR (piracy/illegal)
if: github.event.pull_request.state == 'open' && contains(github.event.pull_request.labels.*.name, 'piracy/illegal')
permissions:
pull-requests: write # Needed to comment on, close, and lock PRs
runs-on: ubuntu-slim
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Comment, close and lock PR
run: |
gh pr unlock "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" || true
gh pr close "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --comment "${BAD_MESSAGE}"
gh pr lock "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}"
+4 -1
View File
@@ -403,7 +403,7 @@ Tip: Use `CTRL`+`F` (or `Command`+`F`) to search by keywords
(default) (default)
--live-from-start Download livestreams from the start. --live-from-start Download livestreams from the start.
Currently experimental and only supported Currently experimental and only supported
for YouTube, Twitch, and TVer for YouTube, Twitch, TVer, and mellow-fan
--no-live-from-start Download livestreams from the current time --no-live-from-start Download livestreams from the current time
(default) (default)
--wait-for-video MIN[-MAX] Wait for scheduled streams to become --wait-for-video MIN[-MAX] Wait for scheduled streams to become
@@ -1969,6 +1969,9 @@ The following extractors use this feature:
#### sonylivseries #### sonylivseries
* `sort_order`: Episode sort order for series extraction - one of `asc` (ascending, oldest first) or `desc` (descending, newest first). Default is `asc` * `sort_order`: Episode sort order for series extraction - one of `asc` (ascending, oldest first) or `desc` (descending, newest first). Default is `asc`
#### streaks
* `api_key`: API key for the `X-Streaks-Api-Key` header
#### tver #### tver
* `backend`: Backend API to use for extraction - one of `streaks` (default) or `brightcove` (deprecated) * `backend`: Backend API to use for extraction - one of `streaks` (default) or `brightcove` (deprecated)
+8
View File
@@ -1314,6 +1314,11 @@ from .oftv import (
) )
from .oktoberfesttv import OktoberfestTVIE from .oktoberfesttv import OktoberfestTVIE
from .olympics import OlympicsReplayIE from .olympics import OlympicsReplayIE
from .omnyfm import (
OmnyfmIE,
OmnyfmPlaylistIE,
OmnyfmShowIE,
)
from .on24 import On24IE from .on24 import On24IE
from .ondemandkorea import ( from .ondemandkorea import (
OnDemandKoreaIE, OnDemandKoreaIE,
@@ -1335,8 +1340,11 @@ from .opencast import (
) )
from .openrec import ( from .openrec import (
OpenRecCaptureIE, OpenRecCaptureIE,
OpenRecChannelIE,
OpenRecChannelSearchIE,
OpenRecIE, OpenRecIE,
OpenRecMovieIE, OpenRecMovieIE,
OpenRecPlaylistIE,
) )
from .orf import ( from .orf import (
ORFIPTVIE, ORFIPTVIE,
+93 -28
View File
@@ -127,7 +127,7 @@ class BilibiliBaseIE(InfoExtractor):
'format_note': ('quality', {format_names.get}), 'format_note': ('quality', {format_names.get}),
'duration': ('timelength', {float_or_none(scale=1000)}), 'duration': ('timelength', {float_or_none(scale=1000)}),
}), }),
**parse_resolution(format_names.get(play_info.get('quality'))), **parse_resolution(traverse_obj(play_info, ('quality', {format_names.get}))),
}) })
return formats return formats
@@ -166,8 +166,58 @@ class BilibiliBaseIE(InfoExtractor):
params['w_rid'] = hashlib.md5(f'{query}{self._get_wbi_key(video_id)}'.encode()).hexdigest() params['w_rid'] = hashlib.md5(f'{query}{self._get_wbi_key(video_id)}'.encode()).hexdigest()
return params return params
def _download_playinfo(self, bvid, cid, headers=None, query=None): @staticmethod
params = {'bvid': bvid, 'cid': cid, 'fnval': 4048, **(query or {})} @functools.cache
def __screen_dimensions():
dims, prefs = zip(
((1920, 1080), 18),
((1366, 768), 18),
((1536, 864), 17),
((1280, 720), 8),
((2560, 1440), 7),
((1440, 900), 5),
((1600, 900), 5),
strict=True)
return random.choices(dims, weights=prefs)[0]
@property
def _dm_params(self):
def get_wh(width=1920, height=1080):
res0, res1 = width, height
rnd = math.floor(114 * random.random())
return [2 * res0 + 2 * res1 + 3 * rnd, 4 * res0 - res1 + rnd, rnd]
def get_of(scroll_top=10, scroll_left=10):
res0, res1 = scroll_top, scroll_left
rnd = math.floor(514 * random.random())
return [3 * res0 + 2 * res1 + rnd, 4 * res0 - 4 * res1 + 2 * rnd, rnd]
# Source: https://s1.hdslb.com/bfs/seed/jinkela/short/user-fingerprint/bili-user-fingerprint.min.js
# function window.__biliUserFp__.queryUserLog
# .dm_img_list and .dm_img_inter.ds are more troublesome as they come from mousemove/click events.
# Leave them empty for now, since they should allow playing the video without any mousemove/click.
return {
'dm_img_list': '[]',
'dm_img_str': base64.b64encode(
''.join(random.choices(string.printable, k=random.randint(16, 64))).encode())[:-2].decode(),
'dm_cover_img_str': base64.b64encode(
''.join(random.choices(string.printable, k=random.randint(32, 128))).encode())[:-2].decode(),
# Bilibili expects dm_img_inter to be a compact JSON (without spaces)
'dm_img_inter': json.dumps({
'ds': [],
'wh': get_wh(*self.__screen_dimensions()),
'of': get_of(random.randint(0, 100), 0),
}, separators=(',', ':')),
}
def _download_playinfo(self, bvid, cid, headers=None, query=None, fatal=True):
params = {
'bvid': bvid,
'cid': cid,
'fnval': 4048,
**self._dm_params,
**(query or {}),
}
if self.is_logged_in: if self.is_logged_in:
params.pop('try_look', None) params.pop('try_look', None)
if qn := params.get('qn'): if qn := params.get('qn'):
@@ -175,9 +225,24 @@ class BilibiliBaseIE(InfoExtractor):
else: else:
note = f'Downloading video formats for cid {cid}' note = f'Downloading video formats for cid {cid}'
return self._download_json( playurl_raw = self._download_json(
'https://api.bilibili.com/x/player/wbi/playurl', bvid, 'https://api.bilibili.com/x/player/wbi/playurl', bvid,
query=self._sign_wbi(params, bvid), headers=headers, note=note)['data'] query=self._sign_wbi(params, bvid), headers=headers, note=note)
code = traverse_obj(playurl_raw, ('code', {lambda x: x * -1}))
if code == 0:
return playurl_raw['data']
else:
msg = join_nonempty(
'Unable to download video info', code,
traverse_obj(playurl_raw, ('message', {str})),
delim=': ')
expected = code in (401, 352)
if expected:
msg += ', please wait and try later'
if fatal:
raise ExtractorError(msg, expected=expected)
else:
self.report_warning(msg)
def json2srt(self, json_data): def json2srt(self, json_data):
srt_data = '' srt_data = ''
@@ -298,7 +363,7 @@ class BilibiliBaseIE(InfoExtractor):
'title': f'{metainfo.get("title")} - {next(iter(edges.values())).get("title")}', 'title': f'{metainfo.get("title")} - {next(iter(edges.values())).get("title")}',
'formats': self.extract_formats(play_info), 'formats': self.extract_formats(play_info),
'description': f'{json.dumps(edges, ensure_ascii=False)}\n{metainfo.get("description", "")}', 'description': f'{json.dumps(edges, ensure_ascii=False)}\n{metainfo.get("description", "")}',
'duration': float_or_none(play_info.get('timelength'), scale=1000), 'duration': traverse_obj(play_info, ('timelength', {float_or_none(scale=1000)})),
'subtitles': self.extract_subtitles(video_id, cid), 'subtitles': self.extract_subtitles(video_id, cid),
} }
@@ -662,7 +727,10 @@ class BiliBiliIE(BilibiliBaseIE):
if not self._match_valid_url(urlh.url): if not self._match_valid_url(urlh.url):
return self.url_result(urlh.url) return self.url_result(urlh.url)
headers['Referer'] = url headers.update({
'Referer': 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com',
})
initial_state = self._search_json(r'window\.__INITIAL_STATE__\s*=', webpage, 'initial state', video_id, default=None) initial_state = self._search_json(r'window\.__INITIAL_STATE__\s*=', webpage, 'initial state', video_id, default=None)
if not initial_state: if not initial_state:
@@ -758,13 +826,12 @@ class BiliBiliIE(BilibiliBaseIE):
duration=traverse_obj(initial_state, ('videoData', 'duration', {int_or_none})), duration=traverse_obj(initial_state, ('videoData', 'duration', {int_or_none})),
__post_extractor=self.extract_comments(aid)) __post_extractor=self.extract_comments(aid))
play_info = None play_info = traverse_obj(
if self.is_logged_in: self._search_json(r'window\.__playinfo__\s*=', webpage, 'play info', video_id, default=None),
play_info = traverse_obj( ('data', {dict}))
self._search_json(r'window\.__playinfo__\s*=', webpage, 'play info', video_id, default=None), if not self.is_logged_in or not play_info:
('data', {dict})) if dl_play_info := self._download_playinfo(video_id, cid, headers=headers, query={'try_look': 1}, fatal=False):
if not play_info: play_info = dl_play_info
play_info = self._download_playinfo(video_id, cid, headers=headers, query={'try_look': 1})
formats = self.extract_formats(play_info) formats = self.extract_formats(play_info)
if video_data.get('is_upower_exclusive'): if video_data.get('is_upower_exclusive'):
@@ -819,13 +886,13 @@ class BiliBiliIE(BilibiliBaseIE):
'subtitles': self.extract_subtitles(video_id, cid) if idx == 0 else None, 'subtitles': self.extract_subtitles(video_id, cid) if idx == 0 else None,
'__post_extractor': self.extract_comments(aid) if idx == 0 else None, '__post_extractor': self.extract_comments(aid) if idx == 0 else None,
} for idx, fragment in enumerate(formats[0]['fragments'])], } for idx, fragment in enumerate(formats[0]['fragments'])],
'duration': float_or_none(play_info.get('timelength'), scale=1000), 'duration': traverse_obj(play_info, ('timelength', {float_or_none(scale=1000)})),
} }
return { return {
**metainfo, **metainfo,
'formats': formats, 'formats': formats,
'duration': float_or_none(play_info.get('timelength'), scale=1000), 'duration': traverse_obj(play_info, ('timelength', {float_or_none(scale=1000)})),
'chapters': self._get_chapters(aid, cid), 'chapters': self._get_chapters(aid, cid),
'subtitles': self.extract_subtitles(video_id, cid), 'subtitles': self.extract_subtitles(video_id, cid),
'__post_extractor': self.extract_comments(aid), '__post_extractor': self.extract_comments(aid),
@@ -1319,20 +1386,21 @@ class BilibiliSpaceVideoIE(BilibiliSpaceBaseIE):
'pn': page_idx + 1, 'pn': page_idx + 1,
'ps': 30, 'ps': 30,
'tid': 0, 'tid': 0,
'web_location': 1550101, 'web_location': '333.1387',
'dm_img_list': '[]', 'special_type': '',
'dm_img_str': base64.b64encode( 'index': 0,
''.join(random.choices(string.printable, k=random.randint(16, 64))).encode())[:-2].decode(), **self._dm_params,
'dm_cover_img_str': base64.b64encode(
''.join(random.choices(string.printable, k=random.randint(32, 128))).encode())[:-2].decode(),
'dm_img_inter': '{"ds":[],"wh":[6093,6631,31],"of":[430,760,380]}',
} }
try: try:
response = self._download_json( response = self._download_json(
'https://api.bilibili.com/x/space/wbi/arc/search', playlist_id, 'https://api.bilibili.com/x/space/wbi/arc/search', playlist_id,
query=self._sign_wbi(query, playlist_id), query=self._sign_wbi(query, playlist_id),
note=f'Downloading space page {page_idx}', headers={'Referer': url}) note=f'Downloading space page {page_idx}', headers={
'Referer': url,
'Origin': 'https://space.bilibili.com',
'Accept-Language': 'en,zh-CN;q=0.9,zh;q=0.8',
})
except ExtractorError as e: except ExtractorError as e:
if isinstance(e.cause, HTTPError) and e.cause.status == 412: if isinstance(e.cause, HTTPError) and e.cause.status == 412:
raise ExtractorError( raise ExtractorError(
@@ -2030,12 +2098,9 @@ class BiliBiliDynamicIE(InfoExtractor):
def _real_extract(self, url): def _real_extract(self, url):
post_id = self._match_id(url) post_id = self._match_id(url)
# Without the newer chrome UA, the API will return an error (-352)
post_data = self._download_json( post_data = self._download_json(
'https://api.bilibili.com/x/polymer/web-dynamic/v1/detail', post_id, 'https://api.bilibili.com/x/polymer/web-dynamic/v1/detail', post_id,
query={'id': post_id}, headers={ query={'id': post_id})
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
})
video_url = traverse_obj(post_data, ( video_url = traverse_obj(post_data, (
'data', 'item', (None, 'orig'), 'modules', 'module_dynamic', 'data', 'item', (None, 'orig'), 'modules', 'module_dynamic',
(('major', ('archive', 'pgc')), ('additional', ('reserve', 'common'))), (('major', ('archive', 'pgc')), ('additional', ('reserve', 'common'))),
+47 -14
View File
@@ -3,9 +3,11 @@ import hashlib
import itertools import itertools
import json import json
import re import re
import urllib.parse
from .common import InfoExtractor from .common import InfoExtractor
from ..networking.exceptions import HTTPError from ..networking.exceptions import HTTPError
from ..networking.impersonate import ImpersonateTarget
from ..utils import ( from ..utils import (
ExtractorError, ExtractorError,
bug_reports_message, bug_reports_message,
@@ -42,15 +44,27 @@ def _id_to_pk(shortcode):
class InstagramBaseIE(InfoExtractor): class InstagramBaseIE(InfoExtractor):
_API_BASE_URL = 'https://i.instagram.com/api/v1' _API_BASE_URL = 'https://i.instagram.com/api/v1'
_BASE_URL = 'https://www.instagram.com/' _BASE_URL = 'https://www.instagram.com/'
_LOGIN_URL = 'https://www.instagram.com/accounts/login'
_APP_IDS = { _APP_IDS = {
'ios': '124024574287414', 'ios': '124024574287414',
'web': '936619743392459', # default 'web': '936619743392459', # default
} }
_AUTH_COOKIE_NAME = 'sessionid'
_COOKIE_DOMAINS = (
'i.instagram.com',
'.i.instagram.com',
'www.instagram.com',
'.www.instagram.com',
'instagram.com',
'.instagram.com',
)
@functools.cached_property
def _can_impersonate(self):
return self._downloader._impersonate_target_available(ImpersonateTarget())
@property @property
def _is_logged_in(self): def _is_logged_in(self):
return bool(self._get_cookies(self._BASE_URL).get('sessionid')) return bool(self._get_cookies(self._BASE_URL).get(self._AUTH_COOKIE_NAME))
@functools.cached_property @functools.cached_property
def _app_id(self): def _app_id(self):
@@ -71,6 +85,10 @@ class InstagramBaseIE(InfoExtractor):
'Accept': '*/*', 'Accept': '*/*',
} }
@staticmethod
def _is_login_redirect(url):
return urllib.parse.urlparse(url).path.startswith('/accounts/login')
def _get_count(self, media, kind, *keys): def _get_count(self, media, kind, *keys):
return traverse_obj( return traverse_obj(
media, (kind, 'count'), *((f'edge_media_{key}', 'count') for key in keys), media, (kind, 'count'), *((f'edge_media_{key}', 'count') for key in keys),
@@ -397,9 +415,11 @@ class InstagramIE(InstagramBaseIE):
def _real_initialize(self): def _real_initialize(self):
if self._is_logged_in: if self._is_logged_in:
self.write_debug('Found Instagram account cookies')
return return
if not self._lsd_token: if not self._lsd_token:
webpage = self._download_webpage(self._BASE_URL, None, 'Setting up session', impersonate=True) webpage = self._download_webpage(
self._BASE_URL, None, 'Setting up session', impersonate=self._can_impersonate)
eqmc = self._search_json( eqmc = self._search_json(
r'<script\b[^>]*\bid="__eqmc"[^>]*>', webpage, 'eqmc JSON', None, default={}) r'<script\b[^>]*\bid="__eqmc"[^>]*>', webpage, 'eqmc JSON', None, default={})
self._lsd_token = ( self._lsd_token = (
@@ -411,15 +431,27 @@ class InstagramIE(InstagramBaseIE):
media_id = str(_id_to_pk(video_id)) media_id = str(_id_to_pk(video_id))
if self._is_logged_in: if self._is_logged_in:
return self._extract_product(self._download_json( try:
f'{self._API_BASE_URL}/media/{media_id}/info/', video_id, return self._extract_product(self._download_json(
'Downloading video info', 'Video info extraction failed', f'{self._API_BASE_URL}/media/{media_id}/info/', video_id,
impersonate=self._is_web_app, headers=self._api_headers)['items'][0]) 'Downloading video info', 'Video info extraction failed',
impersonate=self._can_impersonate and self._is_web_app,
headers=self._api_headers)['items'][0])
except ExtractorError as e:
if not (isinstance(e.cause, HTTPError) and self._is_login_redirect(e.cause.response.url)):
raise
self.report_warning('The provided Instagram account cookies are no longer valid')
# XXX: With curl-cffi, the error response may not invalidate the cookie in our jar
for domain in self._COOKIE_DOMAINS:
self.cookiejar.clear(domain=domain, path='/', name=self._AUTH_COOKIE_NAME)
# Re-initialize to set lsd token for logged-out extraction
self._real_initialize()
api_check = self._download_json( api_check = self._download_json(
f'{self._API_BASE_URL}/web/get_ruling_for_content/', video_id, f'{self._API_BASE_URL}/web/get_ruling_for_content/', video_id,
'Checking post accessibility', errnote=False, fatal=False, 'Checking post accessibility', errnote=False, fatal=False,
impersonate=True, headers=self._api_headers, impersonate=self._can_impersonate, headers=self._api_headers,
query={'content_type': 'MEDIA', 'target_id': media_id}) or {} query={'content_type': 'MEDIA', 'target_id': media_id}) or {}
csrf_token = self._get_cookies('https://www.instagram.com').get('csrftoken') csrf_token = self._get_cookies('https://www.instagram.com').get('csrftoken')
@@ -447,7 +479,7 @@ class InstagramIE(InstagramBaseIE):
'server_timestamps': 'true', 'server_timestamps': 'true',
'variables': json.dumps({'media_id': media_id}, separators=(',', ':')), 'variables': json.dumps({'media_id': media_id}, separators=(',', ':')),
'doc_id': '27130156389949648', 'doc_id': '27130156389949648',
})) })) if self._can_impersonate else None
media = traverse_obj(response, ('data', 'xig_polaris_media', {dict})) media = traverse_obj(response, ('data', 'xig_polaris_media', {dict}))
product_info = traverse_obj(media, ('if_not_gated_logged_out', {dict})) product_info = traverse_obj(media, ('if_not_gated_logged_out', {dict}))
@@ -465,8 +497,8 @@ class InstagramIE(InstagramBaseIE):
'This content is only available for registered users who follow this account') 'This content is only available for registered users who follow this account')
webpage, urlh = self._download_webpage_handle( webpage, urlh = self._download_webpage_handle(
f'https://www.instagram.com/p/{video_id}', video_id) f'https://www.instagram.com/p/{video_id}', video_id, impersonate=self._can_impersonate)
if urlh.url.startswith(self._LOGIN_URL): if self._is_login_redirect(urlh.url):
self.raise_login_required( self.raise_login_required(
'The webpage request was redirected to the login page. ' 'The webpage request was redirected to the login page. '
'You have exceeded the rate-limit for accessing posts anonymously') 'You have exceeded the rate-limit for accessing posts anonymously')
@@ -685,7 +717,8 @@ class InstagramStoryIE(InstagramBaseIE):
if username == 'highlights' and not story_id: # story id is only mandatory for highlights if username == 'highlights' and not story_id: # story id is only mandatory for highlights
raise ExtractorError('Input URL is missing a highlight ID', expected=True) raise ExtractorError('Input URL is missing a highlight ID', expected=True)
display_id = story_id or username display_id = story_id or username
story_info = self._download_webpage(url, display_id, impersonate=self._is_web_app) story_info = self._download_webpage(
url, display_id, impersonate=self._can_impersonate and self._is_web_app)
user_info = self._search_json(r'"user":', story_info, 'user info', display_id, fatal=False) user_info = self._search_json(r'"user":', story_info, 'user info', display_id, fatal=False)
if not user_info: if not user_info:
self.raise_login_required('This content is unreachable') self.raise_login_required('This content is unreachable')
@@ -700,8 +733,8 @@ class InstagramStoryIE(InstagramBaseIE):
videos = traverse_obj(self._download_json( videos = traverse_obj(self._download_json(
f'{self._API_BASE_URL}/feed/reels_media/?reel_ids={story_info_url}', f'{self._API_BASE_URL}/feed/reels_media/?reel_ids={story_info_url}',
display_id, errnote=False, fatal=False, impersonate=self._is_web_app, display_id, errnote=False, fatal=False, headers=self._api_headers,
headers=self._api_headers), 'reels') impersonate=self._can_impersonate and self._is_web_app), 'reels')
if not videos: if not videos:
self.raise_login_required('You need to log in to access this content') self.raise_login_required('You need to log in to access this content')
user_info = traverse_obj(videos, (user_id, 'user', {dict})) or {} user_info = traverse_obj(videos, (user_id, 'user', {dict})) or {}
+272
View File
@@ -0,0 +1,272 @@
import functools
import itertools
from .common import InfoExtractor
from ..utils import (
OnDemandPagedList,
clean_html,
filter_dict,
float_or_none,
int_or_none,
parse_duration,
parse_iso8601,
parse_qs,
update_url,
url_or_none,
)
from ..utils.traversal import (
require,
traverse_obj,
trim_str,
)
class OmnyfmIE(InfoExtractor):
_VALID_URL = r'https?://omny\.fm/shows/(?P<uploader_id>[\w-]+)/(?P<id>(?!playlists(?:[/?#"\']|$))[\w-]+)(?:/embed)?(?=[?#"\']|$)'
_EMBED_REGEX = [rf'<iframe[^>]+\bsrc\s*=\s*(["\'])(?P<url>{_VALID_URL}[^"\']*)\1']
_TESTS = [{
'url': 'https://omny.fm/shows/sleep-hub/cannabinoids-and-sleep',
'md5': 'e45ec0ce43da757a0be6ca117ec01bdc',
'info_dict': {
'id': 'cannabinoids-and-sleep',
'ext': 'mp3',
'title': 'Cannabinoids and Sleep',
'categories': 'count:1',
'chapters': [
{'start_time': 0, 'title': 'Introduction'},
{'start_time': 138, 'title': 'Theme: Cannabinoids and Sleep'},
{'start_time': 1487, 'title': 'Clinical Tip'},
{'start_time': 1635, 'title': 'Pick of the Month'},
{'start_time': 1795, 'title': 'What\'s Coming Up?'},
],
'description': 'md5:c0fd2d29f3148382d344cfbd012fb00d',
'duration': 1840.274,
'episode': 'Episode 48',
'episode_number': 48,
'modified_date': r're:\d{8}',
'modified_timestamp': int,
'tags': 'count:6',
'thumbnail': r're:https?://www\.omnycontent\.com/.+',
'timestamp': 1574013600,
'upload_date': '20191117',
'uploader': 'Sleep Talk',
'uploader_id': 'sleep-hub',
},
}, {
'url': 'https://omny.fm/shows/the-origin-of-things/a-song-of-hope/embed',
'md5': 'd7600ef33e3f139ff1bb8946f3651b15',
'info_dict': {
'id': 'a-song-of-hope',
'ext': 'mp3',
'title': 'A song of hope',
'categories': 'count:3',
'description': 'md5:f8e710765c341a48cfda8dacd428f56d',
'duration': 478.955,
'episode': 'Episode 17',
'episode_number': 17,
'modified_date': r're:\d{8}',
'modified_timestamp': int,
'season': 'Season 3',
'season_number': 3,
'tags': 'count:27',
'thumbnail': r're:https?://www\.omnycontent\.com/.+',
'timestamp': 1679445000,
'upload_date': '20230322',
'uploader': 'The Origin Of Things',
'uploader_id': 'the-origin-of-things',
},
}]
_WEBPAGE_TESTS = [{
'url': 'https://www.asahi.com/special/podcasts/item/?itemid=311a5f48-ad71-4548-b1f2-af5e00747fbc',
'md5': '4c788bf03323734524a7c0f98d9956ed',
'info_dict': {
'id': 'sdgs-271',
'ext': 'mp3',
'title': '「どこかのだれかの人生のにおいがする」 SDGsを音声番組で身近に #271',
'categories': 'count:5',
'description': 'md5:fa086ecce764d81c51648a82d0fe4850',
'duration': 1870.524,
'episode': 'Episode 271',
'episode_number': 271,
'modified_date': r're:\d{8}',
'modified_timestamp': int,
'season': 'Season 1',
'season_number': 1,
'tags': 'count:4',
'thumbnail': r're:https?://www\.omnycontent\.com/.+',
'timestamp': 1670266800,
'upload_date': '20221205',
'uploader': '朝日新聞ポッドキャスト',
'uploader_id': 'asahi',
'webpage_url': 'https://omny.fm/shows/asahi/sdgs-271',
},
}]
def _real_extract(self, url):
uploader_id, audio_id = self._match_valid_url(url).group('uploader_id', 'id')
webpage = self._download_webpage(url, audio_id)
nextjs_data = self._search_nextjs_data(webpage, audio_id)
clip = traverse_obj(nextjs_data, ('props', 'pageProps', 'clip', {dict}))
return {
'id': audio_id,
'section_start': traverse_obj(url, ({parse_qs}, 't', -1, {parse_duration})),
'uploader_id': uploader_id,
'vcodec': 'none',
**traverse_obj(clip, {
'title': ('Title', {clean_html}, filter),
'chapters': ('Chapters', lambda _, v: parse_duration(v['Position']) is not None, {
'title': ('Name', {clean_html}, filter),
'start_time': ('Position', {parse_duration}),
}),
'description': ('Description', {clean_html}, filter),
'duration': ('DurationSeconds', {float_or_none}),
'episode_number': ('Episode', {int_or_none}),
'filesize': ('PublishedAudioSizeInBytes', {int_or_none}),
'modified_timestamp': ('ModifiedAtUtc', {parse_iso8601}),
'season_number': ('Season', {int_or_none}),
'tags': ('Tags', ..., {clean_html}, filter, all, filter),
'thumbnail': ('ImageUrl', {update_url(query=None)}),
'timestamp': ('PublishedUtc', {parse_iso8601}),
'url': ('AudioUrl', {url_or_none}, {require('audio URL')}),
'webpage_url': ('PublishedUrl', {url_or_none}),
}),
**traverse_obj(clip, ('Program', {
'categories': ('Categories', ..., {clean_html}, filter, all, filter),
'uploader': ('Name', {clean_html}, filter),
})),
}
class OmnyfmPlaylistBaseIE(InfoExtractor):
_API_BASE = 'https://api.omny.fm'
_BASE_URL = 'https://omny.fm/shows'
_PAGE_SIZE = 100
def _yield_clips(self, clips, uploader_id):
for audio_id in traverse_obj(clips, (
'Clips', ..., 'Slug', {str},
)):
yield self.url_result(
f'{self._BASE_URL}/{uploader_id}/{audio_id}', OmnyfmIE)
class OmnyfmPlaylistIE(OmnyfmPlaylistBaseIE):
_VALID_URL = r'https?://omny\.fm/shows/(?P<uploader_id>[\w-]+)/playlists(?:/(?P<id>[\w-]+))?(?:/embed)?/?(?=[?#"\']|$)'
_EMBED_REGEX = [fr'<iframe[^>]+\bsrc=(["\'])(?P<url>{_VALID_URL}[^"\']*)\1']
_TESTS = [{
'url': 'https://omny.fm/shows/sleep-hub/playlists/sleep-talk',
'info_dict': {
'id': 'sleep-talk',
'title': 'Sleep Talk - Talking all things sleep',
'description': 'md5:c1d7e5bf32100a432307d2d32c4ab74a',
'thumbnail': r're:https?://www\.omnycontent\.com/.+',
},
'playlist_mincount': 79,
}, {
'url': 'https://omny.fm/shows/bayfm-program03/playlists',
'info_dict': {
'id': 'bayfm-program03',
},
'playlist_count': 4,
}]
_WEBPAGE_TESTS = [{
'url': 'https://www.asahi.com/articles/ASP763WDKP4JDIFI002.html',
'info_dict': {
'id': 'podcast',
'title': 'ニュースの現場から',
'description': 'md5:ed1f78462ebed09258ca31b1da5ff640',
'thumbnail': r're:https?://www\.omnycontent\.com/.+',
'webpage_url': 'https://omny.fm/shows/asahi/playlists/podcast',
},
'playlist_mincount': 2517,
}]
def _entries(self, uploader_id, playlist_id):
clip_id = None
for page in itertools.count(1):
clips = self._download_json(
f'{self._API_BASE}/programs/{uploader_id}/playlists/{playlist_id}/clips',
playlist_id, f'Downloading page {page}', query=filter_dict({
'clipId': clip_id,
'direction': 'AfterExclusive',
'pageSize': self._PAGE_SIZE,
}))
yield from self._yield_clips(clips, uploader_id)
if not clips.get('NextClipsAvailable'):
break
clip_id = traverse_obj(clips, ('Clips', -1, 'Id', {str}))
if not clip_id:
break
def _real_extract(self, url):
uploader_id, playlist_id = self._match_valid_url(url).group('uploader_id', 'id')
webpage = self._download_webpage(url, playlist_id or uploader_id)
nextjs_data = self._search_nextjs_data(webpage, playlist_id or uploader_id)
page_props = traverse_obj(nextjs_data, ('props', 'pageProps', {dict}))
if not playlist_id:
entries = [self.url_result(
f'{self._BASE_URL}/{uploader_id}/playlists/{playlist_id}', OmnyfmPlaylistIE,
) for playlist_id in traverse_obj(page_props, (
'playlistsWithClips', ..., 'playlist', 'Slug', {str},
))]
return self.playlist_result(entries, uploader_id)
return self.playlist_result(
self._entries(uploader_id, playlist_id), playlist_id,
**traverse_obj(page_props, ('playlist', {
'title': ('Title', {clean_html}, filter),
'description': ('Description', {clean_html}, filter),
'thumbnail': ('ArtworkUrl', {update_url(query=None)}),
'webpage_url': ('EmbedUrl', {url_or_none}, {trim_str(end='/embed')}),
})))
class OmnyfmShowIE(OmnyfmPlaylistBaseIE):
_VALID_URL = r'https?://omny\.fm/shows/(?P<id>[\w-]+)/?(?:[?#]|$)'
_TESTS = [{
'url': 'https://omny.fm/shows/the-origin-of-things',
'info_dict': {
'id': 'the-origin-of-things',
'title': 'The Origin Of Things',
'description': 'md5:52b7fba08201d050639c78ea88cc782e',
'thumbnail': r're:https?://www\.omnycontent\.com/.+',
},
'playlist_mincount': 75,
}]
def _fetch_page(self, uploader_id, organization_id, program_id, page):
clips = self._download_json(
f'{self._API_BASE}/orgs/{organization_id}/programs/{program_id}/clips',
uploader_id, f'Downloading page {page + 1}', query={
'cursor': page,
'pageSize': self._PAGE_SIZE,
})
yield from self._yield_clips(clips, uploader_id)
def _real_extract(self, url):
uploader_id = self._match_id(url)
webpage = self._download_webpage(url, uploader_id)
nextjs_data = self._search_nextjs_data(webpage, uploader_id)
program = traverse_obj(nextjs_data, ('props', 'pageProps', 'program', {dict}))
organization_id = traverse_obj(program, (
'OrganizationId', {str}, {require('organization ID')}))
program_id = traverse_obj(program, ('Id', {str}, {require('program ID')}))
entries = OnDemandPagedList(
functools.partial(self._fetch_page, uploader_id, organization_id, program_id), self._PAGE_SIZE)
return self.playlist_result(
entries, uploader_id,
**traverse_obj(program, {
'title': ('Name', {clean_html}, filter),
'description': ('Description', {clean_html}, filter),
'thumbnail': ('ArtworkUrl', {update_url(query=None)}),
}))
+674 -99
View File
@@ -1,153 +1,728 @@
import collections
import datetime as dt
import functools
import itertools
import json
import math
import time
import urllib.parse
import xml.etree.ElementTree
from .common import InfoExtractor from .common import InfoExtractor
from ..utils import ( from ..utils import (
ExtractorError, ExtractorError,
get_first, InAdvancePagedList,
clean_html,
extract_attributes,
filter_dict,
int_or_none, int_or_none,
join_nonempty,
parse_iso8601,
parse_qs,
str_or_none,
update_url_query,
url_or_none,
urljoin,
)
from ..utils.traversal import (
find_element,
find_elements,
require,
traverse_obj, traverse_obj,
try_get,
unified_strdate,
unified_timestamp,
) )
class OpenRecBaseIE(InfoExtractor): class OpenRecBaseIE(InfoExtractor):
_M3U8_HEADERS = {'Referer': 'https://www.openrec.tv/'} _API_BASE = 'https://apiv5.mellow-fan.com/api/v5'
_BASE_URL = 'https://www.mellow-fan.com'
_HEADERS = {'Referer': f'{_BASE_URL}/'}
_NETRC_MACHINE = 'mellowfan'
_PUBLIC_API_BASE = 'https://public.mellow-fan.com/external/api/v5'
def _perform_login(self, username, password):
if self._get_cookies(self._BASE_URL).get('access-token'):
return
login = self._download_json(
f'{self._BASE_URL}/apiv5/email/login',
None, 'Logging in', headers={
'Content-Type': 'application/json',
}, data=json.dumps({
'email': username,
'password': password,
}).encode())
if traverse_obj(login, ('status', {int_or_none})) != 0:
err_msg = traverse_obj(login, ('message', {clean_html}, filter))
raise ExtractorError(err_msg or 'Failed to log in', expected=True)
def _real_initialize(self):
cookies = self._get_cookies(self._BASE_URL)
self._api_headers = traverse_obj(cookies, {
'access-token': ('access_token', 'value', {str}, filter),
'random': ('random', 'value', {str}, filter),
'token': ('token', 'value', {str}, filter),
'uuid': ('uuid', 'value', {str}, filter),
})
def _extract_pagestore(self, webpage, video_id): def _extract_pagestore(self, webpage, video_id):
return self._parse_json( start = r'window\.pageStore\s*='
self._search_regex(r'(?m)window\.pageStore\s*=\s*(\{.+?\});$', webpage, 'window.pageStore'), video_id)
def _expand_media(self, video_id, media): if store := self._search_regex(
for name, m3u8_url in (media or {}).items(): rf'{start}\s*JSON\.parse\s*\(\s*decodeURIComponent'
if not m3u8_url: r'\s*\(\s*(?P<q>["\'])(?P<json>.*?)(?P=q)\s*\)\s*\)',
continue webpage, 'encoded window pagestore', group='json', default=None,
yield from self._extract_m3u8_formats( ):
m3u8_url, video_id, ext='mp4', m3u8_id=name, headers=self._M3U8_HEADERS) return self._parse_json(store, video_id, transform_source=urllib.parse.unquote)
return self._search_json(start, webpage, 'window pagestore', video_id)
def _extract_movie(self, webpage, video_id, name, is_live): def _call_api(self, path, item_id):
window_stores = self._extract_pagestore(webpage, video_id) return self._download_json(
movie_stores = [ f'{self._API_BASE}/{path}', item_id,
# extract all three important data (most of data are duplicated each other, but slightly different!) headers=self._api_headers, expected_status=401)
traverse_obj(window_stores, ('v8', 'state', 'movie'), expected_type=dict),
traverse_obj(window_stores, ('v8', 'movie'), expected_type=dict),
traverse_obj(window_stores, 'movieStore', expected_type=dict),
]
if not any(movie_stores):
raise ExtractorError(f'Failed to extract {name} info')
formats = list(self._expand_media(video_id, get_first(movie_stores, 'media'))) def _parse_openrec_metadata(self, page_store, video_id):
if not formats: info = traverse_obj(page_store, ('v8', 'movie', {dict}))
# archived livestreams or subscriber-only videos
cookies = self._get_cookies('https://www.openrec.tv/')
detail = self._download_json(
f'https://apiv5.openrec.tv/api/v5/movies/{video_id}/detail', video_id,
headers={
'Origin': 'https://www.openrec.tv',
'Referer': 'https://www.openrec.tv/',
'access-token': try_get(cookies, lambda x: x.get('access_token').value),
'uuid': try_get(cookies, lambda x: x.get('uuid').value),
})
new_media = traverse_obj(detail, ('data', 'items', ..., 'media'), get_all=False)
formats = list(self._expand_media(video_id, new_media))
is_live = False
return { target_members = traverse_obj(info, (
'targetMembers', ..., 'type', {str}, filter, any))
needs_subscription = target_members == 'subscription'
needs_auth = target_members == 'ppv'
me = self._call_api('users/me', video_id)
needs_premium = traverse_obj(info, (
'publicType', {str}, filter)) == 'premium'
is_premium = traverse_obj(me, (
'data', 'items', ..., 'is_premium', {bool}, any)) or False
detail = self._call_api(f'movies/{video_id}/detail', video_id)
is_member = traverse_obj(detail, (
'data', 'items', ..., 'membership', 'is_active', {bool}, any)) or False
has_ppv = traverse_obj(detail, (
'data', 'items', ..., 'ppv_ticket_products', ..., {dict}, any)) or False
need = None
if needs_premium and not is_premium:
need = 'premium membership'
elif needs_subscription and not is_member:
need = 'channel subscription'
elif needs_auth and not has_ppv:
need = 'PPV purchase'
if need:
self.raise_login_required(
f'This content requires a {need}', metadata_available=True)
return info, detail, {
'id': video_id, 'id': video_id,
'title': get_first(movie_stores, 'title'), 'availability': self._availability(
'description': get_first(movie_stores, 'introduction'), needs_premium=needs_premium,
'thumbnail': get_first(movie_stores, 'thumbnailUrl'), needs_subscription=needs_subscription,
'formats': formats, needs_auth=needs_auth,
'uploader': get_first(movie_stores, ('channel', 'user', 'name')), ) or 'public',
'uploader_id': get_first(movie_stores, ('channel', 'user', 'id')), 'http_headers': self._HEADERS,
'timestamp': int_or_none(get_first(movie_stores, ['publishedAt', 'time']), scale=1000) or unified_timestamp(get_first(movie_stores, 'publishedAt')), 'tags': traverse_obj(page_store, (
'is_live': is_live, 'movieStore', 'tags', ..., {clean_html}, filter, all, filter)),
'http_headers': self._M3U8_HEADERS, **traverse_obj(info, {
'title': ('title', {clean_html}, filter),
'cast': ('casts', ..., 'name', {clean_html}, filter, all, filter),
'categories': ('game', 'title', {clean_html}, filter, all, filter),
'description': ('introduction', {clean_html}, filter),
'duration': ('playTime', 'value', {int_or_none(scale=1000)}),
'thumbnail': (('lThumbnailUrl', 'thumbnailUrl'), {url_or_none}, any),
'timestamp': ('startedAt', 'time', {int_or_none(scale=1000)}),
'view_count': ('totalViews', {int_or_none}),
}),
**traverse_obj(info, ('channel', 'user', {
'channel_follower_count': ('followers', {int_or_none}),
'channel_id': ('id', {str_or_none}),
'channel': ('name', {clean_html}, filter),
'channel_is_verified': ('isOfficial', {bool}),
})),
} }
class OpenRecIE(OpenRecBaseIE): class OpenRecIE(OpenRecBaseIE):
IE_NAME = 'openrec' IE_NAME = 'mellowfan'
_VALID_URL = r'https?://(?:www\.)?openrec\.tv/live/(?P<id>[^/?#]+)' IE_DESC = 'mellow-fan'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?live/(?P<id>[^/?#]+)'
_TESTS = [{ _TESTS = [{
'url': 'https://www.openrec.tv/live/2p8v31qe4zy', 'url': 'https://www.openrec.tv/live/e2zwj0mp6ro',
'only_matching': True, 'info_dict': {
'id': 'e2zwj0mp6ro',
'ext': 'mp4',
'title': '収束',
'availability': 'public',
'categories': ['雑談'],
'channel': 'おおえのたかゆき',
'channel_follower_count': int,
'channel_id': 'oekaki',
'channel_is_verified': True,
'comment_count': int,
'description': 'md5:62260f3060b40187282f3213bcd97abd',
'duration': 14257,
'live_status': 'was_live',
'tags': ['雑談'],
'thumbnail': r're:https?://.+',
'timestamp': 1685271819,
'upload_date': '20230528',
'view_count': int,
},
'skip': '404 Not Found',
}, { }, {
'url': 'https://www.openrec.tv/live/wez93eqvjzl', # SP
'only_matching': True, 'url': 'https://www.mellow-fan.com/live/2p8vv29438y',
'info_dict': {
'id': '2p8vv29438y',
'ext': 'mp4',
'title': 'それいけ加藤純一探検隊! 〜南の孤島で希少生物を探せスペシャル〜 (OPENRECプレミアム会員限定)',
'availability': 'premium_only',
'categories': ['雑談'],
'channel': '加藤 純一',
'channel_follower_count': int,
'channel_id': 'junichi_kato_channel',
'channel_is_verified': True,
'comment_count': int,
'description': 'md5:693bc0c838ff6080f9ca00d41dc5e840',
'duration': 24336,
'live_status': 'was_live',
'release_date': '20251008',
'release_timestamp': 1759892400,
'thumbnail': r're:https?://.+',
'timestamp': 1759891802,
'upload_date': '20251008',
'view_count': int,
},
}, {
# Members only
'url': 'https://www.mellow-fan.com/live/kdr7nldqgzj',
'info_dict': {
'id': 'kdr7nldqgzj',
'ext': 'mp4',
'title': '【ゲーム実況生配信】小森結梨のひきこもりゲーム部屋#20[ゲスト:梅澤めぐ]',
'availability': 'subscriber_only',
'categories': ['Human Fall Flat'],
'channel': 'セカンドショットGAME部',
'channel_follower_count': int,
'channel_id': 'secondshot_games',
'channel_is_verified': True,
'chapters': [
{'start_time': 0, 'title': 'バイオハザード RE2', 'end_time': 443},
{'start_time': 443, 'title': 'ぷにゃん', 'end_time': 3215},
{'start_time': 3215, 'title': 'Human Fall Flat', 'end_time': 7163},
],
'comment_count': int,
'description': 'md5:b77fac6fb0ad86048dc6377ec2c22646',
'duration': 7163,
'live_status': 'was_live',
'release_date': '20260220',
'release_timestamp': 1771581300,
'tags': ['声優'],
'thumbnail': r're:https?://.+',
'timestamp': 1771581322,
'upload_date': '20260220',
'view_count': int,
},
'skip': 'Subscribers only',
}, {
# PPV
'url': 'https://www.mellow-fan.com/live/e5rk93xn1zv',
'info_dict': {
'id': 'e5rk93xn1zv',
'ext': 'mp4',
'title': '三川華月生誕パーティー2026 【ゲスト:幸村恵理/北原沙弥香】',
'availability': 'needs_auth',
'categories': ['雑談'],
'channel': '三川華月の開店!はるちゃん食堂',
'channel_follower_count': int,
'channel_id': 'haruna_harusyoku',
'channel_is_verified': True,
'description': 'md5:912d0c2d60d343e28300fc18bd491d6c',
'duration': 6451,
'live_status': 'was_live',
'release_date': '20260222',
'release_timestamp': 1771758000,
'thumbnail': r're:https?://.+',
'timestamp': 1771758062,
'upload_date': '20260222',
'view_count': int,
},
'skip': 'Paid video',
}] }]
@staticmethod
def _json2xml(subs, started_at):
def filter_valid(items):
yield from traverse_obj(items, (
lambda _, v: clean_html(v['message']) and not v['stamp']))
total = collections.Counter(
traverse_obj(subs, (..., 'posted_at', {parse_iso8601})))
order = collections.defaultdict(int)
root = xml.etree.ElementTree.Element('packet')
for i, s in enumerate(filter_valid(subs), 1):
posted_at = traverse_obj(s, ('posted_at', {parse_iso8601}))
offset = order[posted_at] / total[posted_at] + 1
order[posted_at] += 1
vpos = int_or_none((posted_at - started_at + offset) * 100)
xml.etree.ElementTree.SubElement(
root, 'chat', filter_dict({
**traverse_obj(s, ('user', {
'premium': ('is_premium', {bool}, {lambda x: '1' if x else '0'}),
'user_id': ('id', {str_or_none}),
'name': ('nickname', {str}, filter),
})),
'no': str(i),
'vpos': str_or_none(vpos),
'date': str_or_none(posted_at),
}),
).text = traverse_obj(s, ('message', {clean_html}, filter))
xml.etree.ElementTree.indent(root, space=' ')
return xml.etree.ElementTree.tostring(
root, encoding='utf-8', xml_declaration=True).decode()
def _get_subtitles(self, duration, started_at, video_id):
ended_at = started_at + duration
timestamp = started_at
subs = []
for page in itertools.count(1):
created_at = dt.datetime.fromtimestamp(
timestamp, dt.timezone(dt.timedelta(hours=9))).strftime('%Y-%m-%dT%H:%M:%S%z')
chats = self._download_json(
f'{self._PUBLIC_API_BASE}/movies/{video_id}/chats',
video_id, f'Downloading chats page {page}', query={
'from_created_at': created_at,
'is_including_system_message': 'true',
})
if not chats:
break
subs.extend(chats)
last_posted_at = traverse_obj(chats, (-1, 'posted_at', {parse_iso8601}))
if ended_at < last_posted_at:
break
timestamp = last_posted_at + 1
time.sleep(0.1)
return {
'chats': [{
'data': json.dumps(subs, indent=2, ensure_ascii=False),
'ext': 'json',
}, {
'data': self._json2xml(subs, started_at),
'ext': 'xml',
}],
}
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) video_id = self._match_id(url)
webpage = self._download_webpage(f'https://www.openrec.tv/live/{video_id}', video_id) webpage = self._download_webpage(url, video_id, expected_status=404)
page_store = self._extract_pagestore(webpage, video_id)
if traverse_obj(page_store, ('movieStore', 'notFound', {bool})):
raise ExtractorError('This video in no longer available', expected=True)
return self._extract_movie(webpage, video_id, 'live', True) info, detail, metadata = self._parse_openrec_metadata(page_store, video_id)
live_status = {
'ARCHIVE': 'was_live',
'COMING_UP': 'is_upcoming',
'LIVE_STREAMING': 'is_live',
'UPLOADED': 'not_live',
}.get(info.get('onAirStatus'))
release_timestamp = traverse_obj(page_store, ('movieStore', 'willStartAt', {parse_iso8601}))
if live_status == 'is_upcoming':
if release_timestamp is not None:
start_time = dt.datetime.fromtimestamp(
release_timestamp, dt.timezone.utc,
).astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')
msg = f'This livestream is scheduled to start at {start_time}'
else:
msg = 'This livestream has not yet started'
self.raise_no_formats(msg, expected=True)
return {
'id': video_id,
'live_status': live_status,
'release_timestamp': release_timestamp,
}
duration = metadata['duration']
started_at = metadata['timestamp']
chapters = []
for chapter in traverse_obj(info, (
'chapters', lambda _, v: int_or_none(v['chapterAt']['time']),
)):
chapter_at = traverse_obj(chapter, ('chapterAt', 'time', {int_or_none(scale=1000)}))
chapters.append({
'start_time': chapter_at - started_at,
'title': traverse_obj(chapter, ('title', {clean_html}, filter)),
})
formats = []
is_dvr = live_status == 'is_live' and self.get_param('live_from_start')
media_keys = ('url_dvr', 'url_dvr_audio') if is_dvr else ('url', 'url_audio')
for m3u8_url in traverse_obj(detail, (
'data', 'items', ...,
('media', 'subs_trial_media'), media_keys, {url_or_none},
)):
fmts = self._extract_m3u8_formats(
m3u8_url, video_id, 'mp4', headers=self._HEADERS)
for fmt in fmts:
if is_dvr:
fmt.setdefault('downloader_options', {}).update({'ffmpeg_args': ['-live_start_index', '0']})
fmt['is_from_start'] = True
formats.extend(fmts)
return {
'chapters': chapters or None,
'comment_count': traverse_obj(page_store, (
'commentStore', 'commentCount', {int_or_none})),
'formats': formats,
'live_status': live_status,
'release_timestamp': release_timestamp,
'subtitles': self.extract_subtitles(duration, started_at, video_id),
**metadata,
}
class OpenRecCaptureIE(OpenRecBaseIE): class OpenRecCaptureIE(OpenRecBaseIE):
IE_NAME = 'openrec:capture' IE_NAME = 'mellowfan:capture'
_VALID_URL = r'https?://(?:www\.)?openrec\.tv/capture/(?P<id>[^/?#]+)'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?capture/(?P<id>[^/?#]+)'
_TESTS = [{ _TESTS = [{
'url': 'https://www.openrec.tv/capture/l9nk2x4gn14', 'url': 'https://www.mellow-fan.com/capture/l2q00vxl8q8',
'only_matching': True,
}, {
'url': 'https://www.openrec.tv/capture/mldjr82p7qk',
'info_dict': { 'info_dict': {
'id': 'mldjr82p7qk', 'id': 'l2q00vxl8q8',
'title': 'たいじの恥ずかしい英語力', 'ext': 'mp4',
'uploader': 'たいちゃんねる', 'title': '????',
'uploader_id': 'Yaritaiji', 'channel': '布団ちゃん',
'upload_date': '20210803', 'channel_id': 'indegnasen',
'duration': 89,
'thumbnail': r're:https?://.+',
'timestamp': 1637589871,
'upload_date': '20211122',
},
}, {
'url': 'https://www.mellow-fan.com/capture/9pdz9334vng',
'info_dict': {
'id': '9pdz9334vng',
'ext': 'mp4',
'title': 'オプレの現実',
'channel': 'ゆゆうた&みゃこの泥沼バラエティ',
'channel_id': 'doronuma-variety',
'duration': 64,
'thumbnail': r're:https?://.+',
'timestamp': 1677585253,
'upload_date': '20230228',
}, },
}] }]
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) video_id = self._match_id(url)
webpage = self._download_webpage(f'https://www.openrec.tv/capture/{video_id}', video_id) webpage = self._download_webpage(url, video_id)
page_store = self._extract_pagestore(webpage, video_id)
window_stores = self._extract_pagestore(webpage, video_id) capture = page_store['capture']
movie_store = window_stores.get('movie') m3u8_url = traverse_obj(capture, ('source', {url_or_none}))
capture_data = window_stores.get('capture')
if not capture_data:
raise ExtractorError('Cannot extract title')
formats = self._extract_m3u8_formats(
capture_data.get('source'), video_id, ext='mp4', headers=self._M3U8_HEADERS)
return { return {
'id': video_id, 'id': video_id,
'title': capture_data.get('title'), 'formats': self._extract_m3u8_formats(
'thumbnail': capture_data.get('thumbnailUrl'), m3u8_url, video_id, 'mp4', headers=self._HEADERS),
'formats': formats, 'http_headers': self._HEADERS,
'timestamp': unified_timestamp(traverse_obj(movie_store, 'createdAt', expected_type=str)), **traverse_obj(page_store, ('movie', 'channel', {
'uploader': traverse_obj(movie_store, ('channel', 'name'), expected_type=str), 'channel': ('name', {clean_html}, filter),
'uploader_id': traverse_obj(movie_store, ('channel', 'id'), expected_type=str), 'channel_id': ('id', {str}),
'upload_date': unified_strdate(capture_data.get('createdAt')), })),
'http_headers': self._M3U8_HEADERS, **traverse_obj(capture, {
'title': ('title', {clean_html}, filter),
'duration': ({lambda x: int_or_none(x['endTime']) - int_or_none(x['startTime'])}),
'thumbnail': ('thumbnailUrl', {url_or_none}),
'timestamp': ('publishedAt', {parse_iso8601}),
}),
} }
class OpenRecMovieIE(OpenRecBaseIE): class OpenRecMovieIE(OpenRecBaseIE):
IE_NAME = 'openrec:movie' IE_NAME = 'mellowfan:movie'
_VALID_URL = r'https?://(?:www\.)?openrec\.tv/movie/(?P<id>[^/?#]+)'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?movie/(?P<id>[^/?#]+)'
_TESTS = [{ _TESTS = [{
'url': 'https://www.openrec.tv/movie/nqz5xl5km8v', 'url': 'https://www.mellow-fan.com/movie/e5rk9k4o6zv',
'info_dict': { 'info_dict': {
'id': 'nqz5xl5km8v', 'id': 'e5rk9k4o6zv',
'title': '限定コミュニティ(Discord)参加方法ご説明動画', 'ext': 'mp4',
'description': 'md5:ebd563e5f5b060cda2f02bf26b14d87f', 'title': 'みゃこRaMuの企画会議#3 ~2人のやってみたいこと~',
'thumbnail': r're:https://.+', 'availability': 'public',
'uploader': 'タイキとカズヒロ', 'categories': ['雑談'],
'uploader_id': 'taiki_to_kazuhiro', 'channel': 'みゃことRaMuの何して遊ぶ?',
'timestamp': 1638856800, 'channel_follower_count': int,
'channel_id': 'myakoramu',
'channel_is_verified': True,
'description': 'md5:90924fd73356ebd574bec8d761d4fa62',
'duration': 771,
'tags': ['雑談'],
'thumbnail': r're:https?://.+',
'view_count': int,
}, },
}, { }, {
'url': 'https://www.openrec.tv/movie/2p8vvex548y?playlist_id=98brq96vvsgn2nd', # Members only
'only_matching': True, 'url': 'https://www.mellow-fan.com/movie/n9ze6q3eo84',
'info_dict': {
'id': 'n9ze6q3eo84',
'ext': 'mp4',
'title': '大西亜玖璃・高尾奏音のあぐのんる~むらぼ♪第123回傑作選vol.3【高画質・完全版】',
'availability': 'subscriber_only',
'categories': ['雑談'],
'channel': 'セカンドショットGAME部',
'channel_follower_count': int,
'channel_id': 'secondshot_games',
'channel_is_verified': True,
'description': 'md5:b7ab5ddd71ba5edc1141cb427af7a9c6',
'duration': 1800,
'tags': ['声優'],
'thumbnail': r're:https?://.+',
'view_count': int,
},
'skip': 'Subscribers only',
}, {
# PPV
'url': 'https://www.mellow-fan.com/movie/em8xvd4ljr2',
'info_dict': {
'id': 'em8xvd4ljr2',
'ext': 'mp4',
'title': '【PPV購入特典映像】KAWAII LAB. SESSION in OKINAWA',
'availability': 'needs_auth',
'categories': ['ミュージック'],
'channel': 'KAWAII LAB.チャンネル',
'channel_follower_count': int,
'channel_id': 'KAWAIILAB',
'channel_is_verified': True,
'description': 'md5:e9f67d8648d3cbe35df1dd689afc29a8',
'duration': 2002,
'tags': ['アイドル'],
'thumbnail': r're:https?://.+',
'view_count': int,
},
'skip': 'Paid video',
}] }]
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) video_id = self._match_id(url)
webpage = self._download_webpage(f'https://www.openrec.tv/movie/{video_id}', video_id) webpage = self._download_webpage(url, video_id)
page_store = self._extract_pagestore(webpage, video_id)
_, detail, metadata = self._parse_openrec_metadata(page_store, video_id)
return self._extract_movie(webpage, video_id, 'movie', False) formats = []
for m3u8_url in traverse_obj(detail, (
'data', 'items', ..., 'media',
('url', 'url_audio'), {url_or_none},
)):
formats.extend(self._extract_m3u8_formats(
m3u8_url, video_id, 'mp4', headers=self._HEADERS))
return {
'formats': formats,
**metadata,
}
class OpenRecPlaylistIE(OpenRecBaseIE):
IE_NAME = 'mellowfan:playlist'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?user/[^/?#]+/playlist/(?P<id>[^/?#]+)'
_TESTS = [{
# live
'url': 'https://www.mellow-fan.com/user/DbD_BPF/playlist/j59svruhtua2z8t',
'info_dict': {
'id': 'j59svruhtua2z8t',
'title': 'BPFのおすすめ',
},
'playlist_mincount': 10,
}, {
# capture
'url': 'https://www.mellow-fan.com/user/sagara_mayu/playlist/xngNMzv71yLjGdW',
'info_dict': {
'id': 'xngNMzv71yLjGdW',
'title': '相良茉優のFAN!FUN!FACTORY!キャプチャ',
},
'playlist_mincount': 4,
}, {
# movie
'url': 'https://www.mellow-fan.com/user/oreranohonoka/playlist/sficoshvi9dgkqh',
'info_dict': {
'id': 'sficoshvi9dgkqh',
'title': 'SP(スペシャル)映像:(短編映像)おにいたむプランだけが視聴できます',
},
'playlist_mincount': 111,
}]
def _entries(self, items):
for movie in traverse_obj(items, (
'playlist_movies', ..., 'movie', 'id', {str_or_none},
)):
is_live = traverse_obj(movie, ('movie', 'is_live', {bool}))
path, ie = ('live', OpenRecIE) if is_live else ('movie', OpenRecMovieIE)
movie_id = movie['movie']['id']
yield self.url_result(f'{self._BASE_URL}/{path}/{movie_id}', ie)
for capture in traverse_obj(items, (
'playlist_captures', ...,
'capture_relation', 'capture', 'id', {str_or_none},
)):
capture_id = capture['capture_relation']['capture']['id']
yield self.url_result(
f'{self._BASE_URL}/capture/{capture_id}', OpenRecCaptureIE)
def _real_extract(self, url):
playlist_id = self._match_id(url)
me = self._call_api(f'users/me/playlists/{playlist_id}', playlist_id)
if items := traverse_obj(me, (
'data', 'items', ..., {dict}, any,
)):
return self.playlist_result(
self._entries(items), playlist_id,
traverse_obj(items, ('title', {clean_html}, filter)))
webpage = self._download_webpage(url, playlist_id)
playlist_title = traverse_obj(webpage, (
{find_element(cls='sc-1ak77bz-10')}, {clean_html}, filter))
return self.playlist_from_matches(traverse_obj(webpage, (
{find_elements(cls='sc-vc0xhn-0')}, ...,
{find_element(cls='sc-1ddd11y-0', html=True)},
{extract_attributes}, 'href', {str},
)), playlist_id, playlist_title, getter=urljoin(f'{self._BASE_URL}/'))
class OpenRecChannelIE(OpenRecBaseIE):
IE_NAME = 'mellowfan:channel'
_PAGE_SIZE = 40
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?user/(?P<id>[^/?#]+)$'
_TESTS = [{
'url': 'https://www.mellow-fan.com/user/OPENRECPARK',
'info_dict': {
'id': 'OPENRECPARK',
'title': 'OPENREC PARK',
},
'playlist_mincount': 40,
}]
def _fetch_page(self, channel_id, page):
page += 1
search_movies = self._download_json(
f'{self._PUBLIC_API_BASE}/search-movies', channel_id,
f'Downloading page {page}', query={
'channel_ids': channel_id,
'include_live': 'true',
'include_upload': 'true',
'onair_status': '2',
'include_deleted': 'true',
'sort': 'published_at',
'page': str(page),
})
for movie in traverse_obj(search_movies, (
lambda _, v: str_or_none(v['movie_type']) and str_or_none(v['id']),
)):
path, ie = ('live', OpenRecIE) if movie['movie_type'] == '1' else ('movie', OpenRecMovieIE)
yield self.url_result(f'{self._BASE_URL}/{path}/{movie["id"]}', ie)
def _real_extract(self, url):
channel_id = self._match_id(url)
webpage = self._download_webpage(url, channel_id)
page_store = self._extract_pagestore(webpage, channel_id)
channel = traverse_obj(page_store, ('state', '_channel', {dict}))
movie_count = traverse_obj(channel, ('movieCount', {int_or_none}))
return self.playlist_result(InAdvancePagedList(
functools.partial(self._fetch_page, channel_id),
math.ceil(movie_count / self._PAGE_SIZE), self._PAGE_SIZE,
), channel_id, traverse_obj(channel, ('user', 'name', {clean_html}, filter)))
class OpenRecChannelSearchIE(OpenRecBaseIE):
IE_NAME = 'mellowfan:channel:search'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?user/(?P<id>[^/?#]+)/search(?:/(?P<type>capture|movie))?(?:[/?#]|$)'
_TESTS = [{
'url': 'https://www.mellow-fan.com/user/indegnasen/search?search_query=%E3%82%B9%E3%82%A4%E3%82%AB',
'info_dict': {
'id': 'indegnasen',
'title': 'indegnasen:スイカ',
},
'playlist_count': 2,
}, {
'url': 'https://www.mellow-fan.com/user/ofurekodesu/search/movie?search_query=%E3%82%A2%E3%83%AA%E3%82%AA%E5%85%AB%E5%B0%BE',
'info_dict': {
'id': 'ofurekodesu',
'title': 'ofurekodesu:アリオ八尾:movie',
},
'playlist_mincount': 31,
}, {
'url': 'https://www.mellow-fan.com/user/DbD_BPF/search/capture?search_query=%E3%81%82%E3%81%A3%E3%81%95%E3%82%8A%E3%81%97%E3%82%87%E3%81%93',
'info_dict': {
'id': 'DbD_BPF',
'title': 'DbD_BPF:あっさりしょこ:capture',
},
'playlist_mincount': 10,
}]
def _entries(self, channel_id, search_type, search_query):
api_url = f'{self._PUBLIC_API_BASE}/search-{search_type}s'
type_map = {
'capture': OpenRecCaptureIE,
'live': OpenRecIE,
'movie': OpenRecMovieIE,
}
for page in itertools.count(1):
search_items = self._download_json(
api_url, channel_id, f'Downloading page {page}', query={
'channel_ids': channel_id,
'page': page,
'search_query': search_query,
})
if not search_items:
break
for item in search_items:
item_type = 'live' if search_type == 'movie' and traverse_obj(item, ('is_live', {bool})) else search_type
item_id = traverse_obj(item, ((None, 'capture'), 'id', {str_or_none}, any))
yield self.url_result(
f'{self._BASE_URL}/{item_type}/{item_id}', type_map[item_type])
def _real_extract(self, url):
channel_id, search_type = self._match_valid_url(url).group('id', 'type')
search_query = traverse_obj(url, (
{parse_qs}, 'search_query', -1, {str}, filter,
{require('search query', expected=True)}))
if not search_type:
entries = []
for search_type in ('capture', 'movie'):
search_url = update_url_query(
f'{self._BASE_URL}/user/{channel_id}/search/{search_type}', {'search_query': search_query})
entries.append(self.url_result(search_url, OpenRecChannelSearchIE))
return self.playlist_result(
entries, channel_id, join_nonempty(channel_id, search_query, delim=':'))
return self.playlist_result(
self._entries(channel_id, search_type, search_query),
channel_id, join_nonempty(channel_id, search_query, search_type, delim=':'))
+63 -42
View File
@@ -5,12 +5,13 @@ from .common import InfoExtractor
from ..networking.exceptions import HTTPError from ..networking.exceptions import HTTPError
from ..utils import ( from ..utils import (
ExtractorError, ExtractorError,
clean_html,
filter_dict, filter_dict,
float_or_none, float_or_none,
int_or_none,
join_nonempty, join_nonempty,
mimetype2ext, mimetype2ext,
parse_iso8601, parse_iso8601,
unsmuggle_url,
update_url_query, update_url_query,
url_or_none, url_or_none,
) )
@@ -22,22 +23,28 @@ class StreaksBaseIE(InfoExtractor):
_GEO_BYPASS = False _GEO_BYPASS = False
_GEO_COUNTRIES = ['JP'] _GEO_COUNTRIES = ['JP']
def _extract_from_streaks_api(self, project_id, media_id, headers=None, query=None, ssai=False, live_from_start=False): def _streaks_playback_api_url(self, project_id, media_id):
return self._API_URL_TEMPLATE.format('playback', project_id, media_id, '')
def _download_streaks_playback_json(self, project_id, media_id, headers=None):
return self._download_json(
self._streaks_playback_api_url(project_id, media_id),
media_id, 'Downloading STREAKS playback API JSON', headers={
'Accept': 'application/json',
'Origin': 'https://players.streaks.jp',
**self.geo_verification_headers(),
**(headers or {}),
})
def _extract_from_streaks_api(self, project_id, media_id, headers=None, query=None, live_from_start=False):
try: try:
response = self._download_json( response = self._download_streaks_playback_json(project_id, media_id, headers=headers)
self._API_URL_TEMPLATE.format('playback', project_id, media_id, ''),
media_id, 'Downloading STREAKS playback API JSON', headers={
'Accept': 'application/json',
'Origin': 'https://players.streaks.jp',
**self.geo_verification_headers(),
**(headers or {}),
})
except ExtractorError as e: except ExtractorError as e:
if isinstance(e.cause, HTTPError) and e.cause.status in (403, 404): if isinstance(e.cause, HTTPError) and e.cause.status in (403, 404):
error = self._parse_json(e.cause.response.read().decode(), media_id, fatal=False) error = self._parse_json(e.cause.response.read().decode(), media_id, fatal=False)
message = traverse_obj(error, ('message', {str})) message = traverse_obj(error, ('message', {clean_html}, filter))
code = traverse_obj(error, ('code', {str})) code = traverse_obj(error, ('code', {clean_html}, filter))
error_id = traverse_obj(error, ('id', {int})) error_id = traverse_obj(error, ('id', {int_or_none}))
if code == 'REQUEST_FAILED': if code == 'REQUEST_FAILED':
if error_id == 124: if error_id == 124:
self.raise_geo_restricted(countries=self._GEO_COUNTRIES) self.raise_geo_restricted(countries=self._GEO_COUNTRIES)
@@ -59,8 +66,12 @@ class StreaksBaseIE(InfoExtractor):
formats, subtitles = [], {} formats, subtitles = [], {}
drm_formats = False drm_formats = False
sources = response['sources']
ssai = traverse_obj(sources, (..., 'ssai', {dict}, any))
for source in traverse_obj(response, ('sources', lambda _, v: v['src'])): for source in traverse_obj(sources, (
lambda _, v: url_or_none(v['src']),
)):
if source.get('key_systems'): if source.get('key_systems'):
drm_formats = True drm_formats = True
continue continue
@@ -108,12 +119,12 @@ class StreaksBaseIE(InfoExtractor):
'subtitles': subtitles, 'subtitles': subtitles,
'uploader_id': project_id, 'uploader_id': project_id,
**traverse_obj(response, { **traverse_obj(response, {
'title': ('name', {str}), 'title': ('name', {clean_html}, filter),
'description': ('description', {str}, filter), 'description': ('description', {clean_html}, filter),
'duration': ('duration', {float_or_none}), 'duration': ('duration', {float_or_none}),
'modified_timestamp': ('updated_at', {parse_iso8601}), 'modified_timestamp': ('updated_at', {parse_iso8601}),
'tags': ('tags', ..., {str}), 'tags': ('tags', ..., {clean_html}, filter, all, filter),
'thumbnails': (('poster', 'thumbnail'), 'src', {'url': {url_or_none}}), 'thumbnail': (('thumbnail', 'poster'), 'src', {url_or_none}, any),
'timestamp': ('created_at', {parse_iso8601}), 'timestamp': ('created_at', {parse_iso8601}),
}), }),
} }
@@ -121,26 +132,27 @@ class StreaksBaseIE(InfoExtractor):
class StreaksIE(StreaksBaseIE): class StreaksIE(StreaksBaseIE):
_VALID_URL = [ _VALID_URL = [
r'https?://players\.streaks\.jp/(?P<project_id>[\w-]+)/[\da-f]+/index\.html\?(?:[^#]+&)?m=(?P<id>(?:ref:)?[\w-]+)', r'https?://players\.streaks\.jp/(?P<project_id>[\w-]+)/(?P<api_key>[\da-f]+)/index\.html\?(?:[^#]+&)?m=(?P<id>(?:ref:)?[\w-]+)',
r'https?://playback\.api\.streaks\.jp/v1/projects/(?P<project_id>[\w-]+)/medias/(?P<id>(?:ref:)?[\w-]+)', r'https?://playback\.api\.streaks\.jp/v1/projects/(?P<project_id>[\w-]+)/medias/(?P<id>(?:ref:)?[\w-]+)',
] ]
_EMBED_REGEX = [rf'<iframe\s+[^>]*\bsrc\s*=\s*["\'](?P<url>{_VALID_URL[0]})'] _EMBED_REGEX = [rf'<iframe\s+[^>]*\bsrc\s*=\s*["\'](?P<url>{_VALID_URL[0]})']
_TESTS = [{ _TESTS = [{
'url': 'https://players.streaks.jp/tipness/08155cd19dc14c12bebefb69b92eafcc/index.html?m=dbdf2df35b4d483ebaeeaeb38c594647', # https://online.tipness.co.jp/contents/9a064492-a62b-5d03-4668-6b40d6325b1e
'url': 'https://players.streaks.jp/tipness/08155cd19dc14c12bebefb69b92eafcc/index.html?m=ba2c253508914d9ea061a5f26bc58b20',
'info_dict': { 'info_dict': {
'id': 'dbdf2df35b4d483ebaeeaeb38c594647', 'id': 'ba2c253508914d9ea061a5f26bc58b20',
'ext': 'mp4', 'ext': 'mp4',
'title': '3shunenCM_edit.mp4', 'title': 'tarun_suimin.mp4',
'display_id': 'dbdf2df35b4d483ebaeeaeb38c594647', 'duration': 265.344,
'duration': 47.533,
'live_status': 'not_live', 'live_status': 'not_live',
'modified_date': '20230726', 'modified_date': '20230908',
'modified_timestamp': 1690356180, 'modified_timestamp': 1694146842,
'timestamp': 1690355996, 'timestamp': 1694145352,
'upload_date': '20230726', 'upload_date': '20230908',
'uploader_id': 'tipness', 'uploader_id': 'tipness',
}, },
}, { }, {
# https://www.ktv.jp/mycoffeetime/
'url': 'https://players.streaks.jp/ktv-web/0298e8964c164ab384c07ef6e08c444b/index.html?m=ref:mycoffeetime_250317', 'url': 'https://players.streaks.jp/ktv-web/0298e8964c164ab384c07ef6e08c444b/index.html?m=ref:mycoffeetime_250317',
'info_dict': { 'info_dict': {
'id': 'dccdc079e3fd41f88b0c8435e2d453ab', 'id': 'dccdc079e3fd41f88b0c8435e2d453ab',
@@ -157,22 +169,24 @@ class StreaksIE(StreaksBaseIE):
'uploader_id': 'ktv-web', 'uploader_id': 'ktv-web',
}, },
}, { }, {
'url': 'https://playback.api.streaks.jp/v1/projects/ktv-web/medias/b5411938e1e5435dac71edf829dd4813', # https://www.ktv.jp/news/articles/?id=28105
'url': 'https://playback.api.streaks.jp/v1/projects/ktv-news/medias/714171c4c53c409bb41e1572997ebfcf',
'info_dict': { 'info_dict': {
'id': 'b5411938e1e5435dac71edf829dd4813', 'id': '714171c4c53c409bb41e1572997ebfcf',
'ext': 'mp4', 'ext': 'mp4',
'title': 'KANTELE_SYUSEi_0630', 'title': '28105.mp4',
'display_id': 'b5411938e1e5435dac71edf829dd4813', 'duration': 49.984,
'live_status': 'not_live', 'live_status': 'not_live',
'modified_date': '20250122', 'modified_date': '20260702',
'modified_timestamp': 1737522999, 'modified_timestamp': 1783028559,
'thumbnail': r're:https?://.+\.jpg', 'thumbnail': r're:https?://.+\.jpg',
'timestamp': 1735205137, 'timestamp': 1783028407,
'upload_date': '20241226', 'upload_date': '20260702',
'uploader_id': 'ktv-web', 'uploader_id': 'ktv-news',
}, },
'params': {'extractor_args': {'streaks': {'api_key': ['0ff2ccfb6381401582d6ee60e3cb66a1']}}},
}, { }, {
# TVer Olympics: website already down, but api remains accessible # https://tver.jp/olympic/paris2024/live/FBLMTEAM11------------SFNL000100--/
'url': 'https://playback.api.streaks.jp/v1/projects/tver-olympic/medias/ref:sp_240806_1748_dvr', 'url': 'https://playback.api.streaks.jp/v1/projects/tver-olympic/medias/ref:sp_240806_1748_dvr',
'info_dict': { 'info_dict': {
'id': 'c10f7345adb648cf804d7578ab93b2e3', 'id': 'c10f7345adb648cf804d7578ab93b2e3',
@@ -187,8 +201,10 @@ class StreaksIE(StreaksBaseIE):
'upload_date': '20240804', 'upload_date': '20240804',
'uploader_id': 'tver-olympic', 'uploader_id': 'tver-olympic',
}, },
'params': {'extractor_args': {'streaks': {'api_key': ['e09168c4383d4b18949067022558f071']}}},
'skip': 'Invalid URL',
}, { }, {
# TBS FREE: 24-hour stream # https://cu.tbs.co.jp/simul/simul-02
'url': 'https://playback.api.streaks.jp/v1/projects/tbs/medias/ref:simul-02', 'url': 'https://playback.api.streaks.jp/v1/projects/tbs/medias/ref:simul-02',
'info_dict': { 'info_dict': {
'id': 'c4e83a7b48f4409a96adacec674b4e22', 'id': 'c4e83a7b48f4409a96adacec674b4e22',
@@ -202,12 +218,14 @@ class StreaksIE(StreaksBaseIE):
'upload_date': '20240117', 'upload_date': '20240117',
'uploader_id': 'tbs', 'uploader_id': 'tbs',
}, },
'skip': 'Invalid URL',
}, { }, {
# DRM protected # DRM protected
'url': 'https://players.streaks.jp/sp-jbc/a12d7ee0f40c49d6a0a2bff520639677/index.html?m=5f89c62f37ee4a68be8e6e3b1396c7d8', 'url': 'https://players.streaks.jp/sp-jbc/a12d7ee0f40c49d6a0a2bff520639677/index.html?m=5f89c62f37ee4a68be8e6e3b1396c7d8',
'only_matching': True, 'only_matching': True,
}] }]
_WEBPAGE_TESTS = [{ _WEBPAGE_TESTS = [{
# https://players.streaks.jp/play/719af2a1d2d544e89bcad3456eeae5d9/index.html?m=2d975178293140dc8074a7fc536a7604
'url': 'https://event.play.jp/playnext2023/', 'url': 'https://event.play.jp/playnext2023/',
'info_dict': { 'info_dict': {
'id': '2d975178293140dc8074a7fc536a7604', 'id': '2d975178293140dc8074a7fc536a7604',
@@ -222,6 +240,7 @@ class StreaksIE(StreaksBaseIE):
'modified_date': '20250213', 'modified_date': '20250213',
'live_status': 'not_live', 'live_status': 'not_live',
}, },
'params': {'nocheckcertificate': True},
}, { }, {
'url': 'https://wowshop.jp/Page/special/cooking_goods/?bid=wowshop&srsltid=AfmBOor_phUNoPEE_UCPiGGSCMrJE5T2US397smvsbrSdLqUxwON0el4', 'url': 'https://wowshop.jp/Page/special/cooking_goods/?bid=wowshop&srsltid=AfmBOor_phUNoPEE_UCPiGGSCMrJE5T2US397smvsbrSdLqUxwON0el4',
'playlist_mincount': 2, 'playlist_mincount': 2,
@@ -232,13 +251,15 @@ class StreaksIE(StreaksBaseIE):
'age_limit': 0, 'age_limit': 0,
'thumbnail': 'https://wowshop.jp/Page/special/cooking_goods/images/ogp.jpg', 'thumbnail': 'https://wowshop.jp/Page/special/cooking_goods/images/ogp.jpg',
}, },
'skip': 'Invalid URL',
}] }]
def _real_extract(self, url): def _real_extract(self, url):
url, smuggled_data = unsmuggle_url(url, {}) mobj = self._match_valid_url(url).groupdict()
project_id, media_id = self._match_valid_url(url).group('project_id', 'id') project_id, media_id = mobj['project_id'], mobj['id']
api_key = mobj.get('api_key') or self._configuration_arg('api_key', [None])[0]
return self._extract_from_streaks_api( return self._extract_from_streaks_api(
project_id, media_id, headers=filter_dict({ project_id, media_id, headers=filter_dict({
'X-Streaks-Api-Key': smuggled_data.get('api_key'), 'X-Streaks-Api-Key': api_key,
})) }))
+1 -1
View File
@@ -511,7 +511,7 @@ def create_parser():
general.add_option( general.add_option(
'--live-from-start', '--live-from-start',
action='store_true', dest='live_from_start', action='store_true', dest='live_from_start',
help='Download livestreams from the start. Currently experimental and only supported for YouTube, Twitch, and TVer') help='Download livestreams from the start. Currently experimental and only supported for YouTube, Twitch, TVer, and mellow-fan')
general.add_option( general.add_option(
'--no-live-from-start', '--no-live-from-start',
action='store_false', dest='live_from_start', action='store_false', dest='live_from_start',