mirror of
https://github.com/yt-dlp/yt-dlp.git
synced 2026-08-15 08:39:09 +03:00
Compare commits
8
Commits
ad9a6f25f6
...
e8de28e23c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8de28e23c | ||
|
|
1472d10980 | ||
|
|
6694ef8299 | ||
|
|
fa383a9efa | ||
|
|
5aa335ecd9 | ||
|
|
26654a359d | ||
|
|
161dd9fd05 | ||
|
|
ac4c955ea9 |
@@ -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.+'
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))),
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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': 'バイオハザード RE:2', '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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user