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:
# SC1090 "Can't follow non-constant source": ignore when using `source` to activate venv
- '.+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)
--live-from-start Download livestreams from the start.
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
(default)
--wait-for-video MIN[-MAX] Wait for scheduled streams to become
@@ -1969,6 +1969,9 @@ The following extractors use this feature:
#### sonylivseries
* `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
* `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 .olympics import OlympicsReplayIE
from .omnyfm import (
OmnyfmIE,
OmnyfmPlaylistIE,
OmnyfmShowIE,
)
from .on24 import On24IE
from .ondemandkorea import (
OnDemandKoreaIE,
@@ -1335,8 +1340,11 @@ from .opencast import (
)
from .openrec import (
OpenRecCaptureIE,
OpenRecChannelIE,
OpenRecChannelSearchIE,
OpenRecIE,
OpenRecMovieIE,
OpenRecPlaylistIE,
)
from .orf import (
ORFIPTVIE,
+93 -28
View File
@@ -127,7 +127,7 @@ class BilibiliBaseIE(InfoExtractor):
'format_note': ('quality', {format_names.get}),
'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
@@ -166,8 +166,58 @@ class BilibiliBaseIE(InfoExtractor):
params['w_rid'] = hashlib.md5(f'{query}{self._get_wbi_key(video_id)}'.encode()).hexdigest()
return params
def _download_playinfo(self, bvid, cid, headers=None, query=None):
params = {'bvid': bvid, 'cid': cid, 'fnval': 4048, **(query or {})}
@staticmethod
@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:
params.pop('try_look', None)
if qn := params.get('qn'):
@@ -175,9 +225,24 @@ class BilibiliBaseIE(InfoExtractor):
else:
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,
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):
srt_data = ''
@@ -298,7 +363,7 @@ class BilibiliBaseIE(InfoExtractor):
'title': f'{metainfo.get("title")} - {next(iter(edges.values())).get("title")}',
'formats': self.extract_formats(play_info),
'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),
}
@@ -662,7 +727,10 @@ class BiliBiliIE(BilibiliBaseIE):
if not self._match_valid_url(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)
if not initial_state:
@@ -758,13 +826,12 @@ class BiliBiliIE(BilibiliBaseIE):
duration=traverse_obj(initial_state, ('videoData', 'duration', {int_or_none})),
__post_extractor=self.extract_comments(aid))
play_info = None
if self.is_logged_in:
play_info = traverse_obj(
self._search_json(r'window\.__playinfo__\s*=', webpage, 'play info', video_id, default=None),
('data', {dict}))
if not play_info:
play_info = self._download_playinfo(video_id, cid, headers=headers, query={'try_look': 1})
play_info = traverse_obj(
self._search_json(r'window\.__playinfo__\s*=', webpage, 'play info', video_id, default=None),
('data', {dict}))
if not self.is_logged_in or not play_info:
if dl_play_info := self._download_playinfo(video_id, cid, headers=headers, query={'try_look': 1}, fatal=False):
play_info = dl_play_info
formats = self.extract_formats(play_info)
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,
'__post_extractor': self.extract_comments(aid) if idx == 0 else None,
} 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 {
**metainfo,
'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),
'subtitles': self.extract_subtitles(video_id, cid),
'__post_extractor': self.extract_comments(aid),
@@ -1319,20 +1386,21 @@ class BilibiliSpaceVideoIE(BilibiliSpaceBaseIE):
'pn': page_idx + 1,
'ps': 30,
'tid': 0,
'web_location': 1550101,
'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(),
'dm_img_inter': '{"ds":[],"wh":[6093,6631,31],"of":[430,760,380]}',
'web_location': '333.1387',
'special_type': '',
'index': 0,
**self._dm_params,
}
try:
response = self._download_json(
'https://api.bilibili.com/x/space/wbi/arc/search', 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:
if isinstance(e.cause, HTTPError) and e.cause.status == 412:
raise ExtractorError(
@@ -2030,12 +2098,9 @@ class BiliBiliDynamicIE(InfoExtractor):
def _real_extract(self, url):
post_id = self._match_id(url)
# Without the newer chrome UA, the API will return an error (-352)
post_data = self._download_json(
'https://api.bilibili.com/x/polymer/web-dynamic/v1/detail', post_id,
query={'id': post_id}, headers={
'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',
})
query={'id': post_id})
video_url = traverse_obj(post_data, (
'data', 'item', (None, 'orig'), 'modules', 'module_dynamic',
(('major', ('archive', 'pgc')), ('additional', ('reserve', 'common'))),
+47 -14
View File
@@ -3,9 +3,11 @@ import hashlib
import itertools
import json
import re
import urllib.parse
from .common import InfoExtractor
from ..networking.exceptions import HTTPError
from ..networking.impersonate import ImpersonateTarget
from ..utils import (
ExtractorError,
bug_reports_message,
@@ -42,15 +44,27 @@ def _id_to_pk(shortcode):
class InstagramBaseIE(InfoExtractor):
_API_BASE_URL = 'https://i.instagram.com/api/v1'
_BASE_URL = 'https://www.instagram.com/'
_LOGIN_URL = 'https://www.instagram.com/accounts/login'
_APP_IDS = {
'ios': '124024574287414',
'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
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
def _app_id(self):
@@ -71,6 +85,10 @@ class InstagramBaseIE(InfoExtractor):
'Accept': '*/*',
}
@staticmethod
def _is_login_redirect(url):
return urllib.parse.urlparse(url).path.startswith('/accounts/login')
def _get_count(self, media, kind, *keys):
return traverse_obj(
media, (kind, 'count'), *((f'edge_media_{key}', 'count') for key in keys),
@@ -397,9 +415,11 @@ class InstagramIE(InstagramBaseIE):
def _real_initialize(self):
if self._is_logged_in:
self.write_debug('Found Instagram account cookies')
return
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(
r'<script\b[^>]*\bid="__eqmc"[^>]*>', webpage, 'eqmc JSON', None, default={})
self._lsd_token = (
@@ -411,15 +431,27 @@ class InstagramIE(InstagramBaseIE):
media_id = str(_id_to_pk(video_id))
if self._is_logged_in:
return self._extract_product(self._download_json(
f'{self._API_BASE_URL}/media/{media_id}/info/', video_id,
'Downloading video info', 'Video info extraction failed',
impersonate=self._is_web_app, headers=self._api_headers)['items'][0])
try:
return self._extract_product(self._download_json(
f'{self._API_BASE_URL}/media/{media_id}/info/', video_id,
'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(
f'{self._API_BASE_URL}/web/get_ruling_for_content/', video_id,
'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 {}
csrf_token = self._get_cookies('https://www.instagram.com').get('csrftoken')
@@ -447,7 +479,7 @@ class InstagramIE(InstagramBaseIE):
'server_timestamps': 'true',
'variables': json.dumps({'media_id': media_id}, separators=(',', ':')),
'doc_id': '27130156389949648',
}))
})) if self._can_impersonate else None
media = traverse_obj(response, ('data', 'xig_polaris_media', {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')
webpage, urlh = self._download_webpage_handle(
f'https://www.instagram.com/p/{video_id}', video_id)
if urlh.url.startswith(self._LOGIN_URL):
f'https://www.instagram.com/p/{video_id}', video_id, impersonate=self._can_impersonate)
if self._is_login_redirect(urlh.url):
self.raise_login_required(
'The webpage request was redirected to the login page. '
'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
raise ExtractorError('Input URL is missing a highlight ID', expected=True)
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)
if not user_info:
self.raise_login_required('This content is unreachable')
@@ -700,8 +733,8 @@ class InstagramStoryIE(InstagramBaseIE):
videos = traverse_obj(self._download_json(
f'{self._API_BASE_URL}/feed/reels_media/?reel_ids={story_info_url}',
display_id, errnote=False, fatal=False, impersonate=self._is_web_app,
headers=self._api_headers), 'reels')
display_id, errnote=False, fatal=False, headers=self._api_headers,
impersonate=self._can_impersonate and self._is_web_app), 'reels')
if not videos:
self.raise_login_required('You need to log in to access this content')
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 ..utils import (
ExtractorError,
get_first,
InAdvancePagedList,
clean_html,
extract_attributes,
filter_dict,
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,
try_get,
unified_strdate,
unified_timestamp,
)
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):
return self._parse_json(
self._search_regex(r'(?m)window\.pageStore\s*=\s*(\{.+?\});$', webpage, 'window.pageStore'), video_id)
start = r'window\.pageStore\s*='
def _expand_media(self, video_id, media):
for name, m3u8_url in (media or {}).items():
if not m3u8_url:
continue
yield from self._extract_m3u8_formats(
m3u8_url, video_id, ext='mp4', m3u8_id=name, headers=self._M3U8_HEADERS)
if store := self._search_regex(
rf'{start}\s*JSON\.parse\s*\(\s*decodeURIComponent'
r'\s*\(\s*(?P<q>["\'])(?P<json>.*?)(?P=q)\s*\)\s*\)',
webpage, 'encoded window pagestore', group='json', default=None,
):
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):
window_stores = self._extract_pagestore(webpage, video_id)
movie_stores = [
# extract all three important data (most of data are duplicated each other, but slightly different!)
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')
def _call_api(self, path, item_id):
return self._download_json(
f'{self._API_BASE}/{path}', item_id,
headers=self._api_headers, expected_status=401)
formats = list(self._expand_media(video_id, get_first(movie_stores, 'media')))
if not formats:
# 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
def _parse_openrec_metadata(self, page_store, video_id):
info = traverse_obj(page_store, ('v8', 'movie', {dict}))
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,
'title': get_first(movie_stores, 'title'),
'description': get_first(movie_stores, 'introduction'),
'thumbnail': get_first(movie_stores, 'thumbnailUrl'),
'formats': formats,
'uploader': get_first(movie_stores, ('channel', 'user', 'name')),
'uploader_id': get_first(movie_stores, ('channel', 'user', 'id')),
'timestamp': int_or_none(get_first(movie_stores, ['publishedAt', 'time']), scale=1000) or unified_timestamp(get_first(movie_stores, 'publishedAt')),
'is_live': is_live,
'http_headers': self._M3U8_HEADERS,
'availability': self._availability(
needs_premium=needs_premium,
needs_subscription=needs_subscription,
needs_auth=needs_auth,
) or 'public',
'http_headers': self._HEADERS,
'tags': traverse_obj(page_store, (
'movieStore', 'tags', ..., {clean_html}, filter, all, filter)),
**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):
IE_NAME = 'openrec'
_VALID_URL = r'https?://(?:www\.)?openrec\.tv/live/(?P<id>[^/?#]+)'
IE_NAME = 'mellowfan'
IE_DESC = 'mellow-fan'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?live/(?P<id>[^/?#]+)'
_TESTS = [{
'url': 'https://www.openrec.tv/live/2p8v31qe4zy',
'only_matching': True,
'url': 'https://www.openrec.tv/live/e2zwj0mp6ro',
'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',
'only_matching': True,
# SP
'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):
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):
IE_NAME = 'openrec:capture'
_VALID_URL = r'https?://(?:www\.)?openrec\.tv/capture/(?P<id>[^/?#]+)'
IE_NAME = 'mellowfan:capture'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?capture/(?P<id>[^/?#]+)'
_TESTS = [{
'url': 'https://www.openrec.tv/capture/l9nk2x4gn14',
'only_matching': True,
}, {
'url': 'https://www.openrec.tv/capture/mldjr82p7qk',
'url': 'https://www.mellow-fan.com/capture/l2q00vxl8q8',
'info_dict': {
'id': 'mldjr82p7qk',
'title': 'たいじの恥ずかしい英語力',
'uploader': 'たいちゃんねる',
'uploader_id': 'Yaritaiji',
'upload_date': '20210803',
'id': 'l2q00vxl8q8',
'ext': 'mp4',
'title': '????',
'channel': '布団ちゃん',
'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):
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)
movie_store = window_stores.get('movie')
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)
capture = page_store['capture']
m3u8_url = traverse_obj(capture, ('source', {url_or_none}))
return {
'id': video_id,
'title': capture_data.get('title'),
'thumbnail': capture_data.get('thumbnailUrl'),
'formats': formats,
'timestamp': unified_timestamp(traverse_obj(movie_store, 'createdAt', expected_type=str)),
'uploader': traverse_obj(movie_store, ('channel', 'name'), expected_type=str),
'uploader_id': traverse_obj(movie_store, ('channel', 'id'), expected_type=str),
'upload_date': unified_strdate(capture_data.get('createdAt')),
'http_headers': self._M3U8_HEADERS,
'formats': self._extract_m3u8_formats(
m3u8_url, video_id, 'mp4', headers=self._HEADERS),
'http_headers': self._HEADERS,
**traverse_obj(page_store, ('movie', 'channel', {
'channel': ('name', {clean_html}, filter),
'channel_id': ('id', {str}),
})),
**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):
IE_NAME = 'openrec:movie'
_VALID_URL = r'https?://(?:www\.)?openrec\.tv/movie/(?P<id>[^/?#]+)'
IE_NAME = 'mellowfan:movie'
_VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?movie/(?P<id>[^/?#]+)'
_TESTS = [{
'url': 'https://www.openrec.tv/movie/nqz5xl5km8v',
'url': 'https://www.mellow-fan.com/movie/e5rk9k4o6zv',
'info_dict': {
'id': 'nqz5xl5km8v',
'title': '限定コミュニティ(Discord)参加方法ご説明動画',
'description': 'md5:ebd563e5f5b060cda2f02bf26b14d87f',
'thumbnail': r're:https://.+',
'uploader': 'タイキとカズヒロ',
'uploader_id': 'taiki_to_kazuhiro',
'timestamp': 1638856800,
'id': 'e5rk9k4o6zv',
'ext': 'mp4',
'title': 'みゃこRaMuの企画会議#3 ~2人のやってみたいこと~',
'availability': 'public',
'categories': ['雑談'],
'channel': 'みゃことRaMuの何して遊ぶ?',
'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',
'only_matching': True,
# Members only
'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):
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 ..utils import (
ExtractorError,
clean_html,
filter_dict,
float_or_none,
int_or_none,
join_nonempty,
mimetype2ext,
parse_iso8601,
unsmuggle_url,
update_url_query,
url_or_none,
)
@@ -22,22 +23,28 @@ class StreaksBaseIE(InfoExtractor):
_GEO_BYPASS = False
_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:
response = self._download_json(
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 {}),
})
response = self._download_streaks_playback_json(project_id, media_id, headers=headers)
except ExtractorError as e:
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)
message = traverse_obj(error, ('message', {str}))
code = traverse_obj(error, ('code', {str}))
error_id = traverse_obj(error, ('id', {int}))
message = traverse_obj(error, ('message', {clean_html}, filter))
code = traverse_obj(error, ('code', {clean_html}, filter))
error_id = traverse_obj(error, ('id', {int_or_none}))
if code == 'REQUEST_FAILED':
if error_id == 124:
self.raise_geo_restricted(countries=self._GEO_COUNTRIES)
@@ -59,8 +66,12 @@ class StreaksBaseIE(InfoExtractor):
formats, subtitles = [], {}
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'):
drm_formats = True
continue
@@ -108,12 +119,12 @@ class StreaksBaseIE(InfoExtractor):
'subtitles': subtitles,
'uploader_id': project_id,
**traverse_obj(response, {
'title': ('name', {str}),
'description': ('description', {str}, filter),
'title': ('name', {clean_html}, filter),
'description': ('description', {clean_html}, filter),
'duration': ('duration', {float_or_none}),
'modified_timestamp': ('updated_at', {parse_iso8601}),
'tags': ('tags', ..., {str}),
'thumbnails': (('poster', 'thumbnail'), 'src', {'url': {url_or_none}}),
'tags': ('tags', ..., {clean_html}, filter, all, filter),
'thumbnail': (('thumbnail', 'poster'), 'src', {url_or_none}, any),
'timestamp': ('created_at', {parse_iso8601}),
}),
}
@@ -121,26 +132,27 @@ class StreaksBaseIE(InfoExtractor):
class StreaksIE(StreaksBaseIE):
_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-]+)',
]
_EMBED_REGEX = [rf'<iframe\s+[^>]*\bsrc\s*=\s*["\'](?P<url>{_VALID_URL[0]})']
_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': {
'id': 'dbdf2df35b4d483ebaeeaeb38c594647',
'id': 'ba2c253508914d9ea061a5f26bc58b20',
'ext': 'mp4',
'title': '3shunenCM_edit.mp4',
'display_id': 'dbdf2df35b4d483ebaeeaeb38c594647',
'duration': 47.533,
'title': 'tarun_suimin.mp4',
'duration': 265.344,
'live_status': 'not_live',
'modified_date': '20230726',
'modified_timestamp': 1690356180,
'timestamp': 1690355996,
'upload_date': '20230726',
'modified_date': '20230908',
'modified_timestamp': 1694146842,
'timestamp': 1694145352,
'upload_date': '20230908',
'uploader_id': 'tipness',
},
}, {
# https://www.ktv.jp/mycoffeetime/
'url': 'https://players.streaks.jp/ktv-web/0298e8964c164ab384c07ef6e08c444b/index.html?m=ref:mycoffeetime_250317',
'info_dict': {
'id': 'dccdc079e3fd41f88b0c8435e2d453ab',
@@ -157,22 +169,24 @@ class StreaksIE(StreaksBaseIE):
'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': {
'id': 'b5411938e1e5435dac71edf829dd4813',
'id': '714171c4c53c409bb41e1572997ebfcf',
'ext': 'mp4',
'title': 'KANTELE_SYUSEi_0630',
'display_id': 'b5411938e1e5435dac71edf829dd4813',
'title': '28105.mp4',
'duration': 49.984,
'live_status': 'not_live',
'modified_date': '20250122',
'modified_timestamp': 1737522999,
'modified_date': '20260702',
'modified_timestamp': 1783028559,
'thumbnail': r're:https?://.+\.jpg',
'timestamp': 1735205137,
'upload_date': '20241226',
'uploader_id': 'ktv-web',
'timestamp': 1783028407,
'upload_date': '20260702',
'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',
'info_dict': {
'id': 'c10f7345adb648cf804d7578ab93b2e3',
@@ -187,8 +201,10 @@ class StreaksIE(StreaksBaseIE):
'upload_date': '20240804',
'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',
'info_dict': {
'id': 'c4e83a7b48f4409a96adacec674b4e22',
@@ -202,12 +218,14 @@ class StreaksIE(StreaksBaseIE):
'upload_date': '20240117',
'uploader_id': 'tbs',
},
'skip': 'Invalid URL',
}, {
# DRM protected
'url': 'https://players.streaks.jp/sp-jbc/a12d7ee0f40c49d6a0a2bff520639677/index.html?m=5f89c62f37ee4a68be8e6e3b1396c7d8',
'only_matching': True,
}]
_WEBPAGE_TESTS = [{
# https://players.streaks.jp/play/719af2a1d2d544e89bcad3456eeae5d9/index.html?m=2d975178293140dc8074a7fc536a7604
'url': 'https://event.play.jp/playnext2023/',
'info_dict': {
'id': '2d975178293140dc8074a7fc536a7604',
@@ -222,6 +240,7 @@ class StreaksIE(StreaksBaseIE):
'modified_date': '20250213',
'live_status': 'not_live',
},
'params': {'nocheckcertificate': True},
}, {
'url': 'https://wowshop.jp/Page/special/cooking_goods/?bid=wowshop&srsltid=AfmBOor_phUNoPEE_UCPiGGSCMrJE5T2US397smvsbrSdLqUxwON0el4',
'playlist_mincount': 2,
@@ -232,13 +251,15 @@ class StreaksIE(StreaksBaseIE):
'age_limit': 0,
'thumbnail': 'https://wowshop.jp/Page/special/cooking_goods/images/ogp.jpg',
},
'skip': 'Invalid URL',
}]
def _real_extract(self, url):
url, smuggled_data = unsmuggle_url(url, {})
project_id, media_id = self._match_valid_url(url).group('project_id', 'id')
mobj = self._match_valid_url(url).groupdict()
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(
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(
'--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(
'--no-live-from-start',
action='store_false', dest='live_from_start',