mirror of
https://github.com/yt-dlp/yt-dlp.git
synced 2026-10-16 13:38:07 -07:00
Compare commits
34
Commits
2026.08.19
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
897341241f | ||
|
|
817450d28d | ||
|
|
51bab8a011 | ||
|
|
779f599669 | ||
|
|
a29f2a2b92 | ||
|
|
caf63903a1 | ||
|
|
28731cbacb | ||
|
|
c2901ed367 | ||
|
|
f9eee6ba62 | ||
|
|
9dd5e68cd7 | ||
|
|
39b31b2755 | ||
|
|
f45f14bd79 | ||
|
|
8a49065e18 | ||
|
|
c7fb478d21 | ||
|
|
bbc809a116 | ||
|
|
9caaf45401 | ||
|
|
2f3929ba1a | ||
|
|
f1df65d5d7 | ||
|
|
3d062c86ea | ||
|
|
fcdbefb85f | ||
|
|
8377aa9555 | ||
|
|
28d35b7762 | ||
|
|
94eba4c156 | ||
|
|
1d1351f40f | ||
|
|
2089f8ad37 | ||
|
|
a2a8846b0d | ||
|
|
d1cb4709cc | ||
|
|
e164eebe19 | ||
|
|
66f49765d5 | ||
|
|
9fb5969797 | ||
|
|
88a9516584 | ||
|
|
81ecd58b13 | ||
|
|
5022b8c119 | ||
|
|
91f784d6fd |
@@ -22,3 +22,15 @@ paths:
|
||||
ignore:
|
||||
# https://github.com/rhysd/actionlint/issues/657
|
||||
- 'unexpected key "queue" for "concurrency" section.+'
|
||||
.github/workflows/release-master.yml:
|
||||
ignore:
|
||||
# https://github.com/rhysd/actionlint/issues/711
|
||||
- 'reusable workflow call .+ at "uses" is not following the format.+'
|
||||
.github/workflows/release-nightly.yml:
|
||||
ignore:
|
||||
# https://github.com/rhysd/actionlint/issues/711
|
||||
- 'reusable workflow call .+ at "uses" is not following the format.+'
|
||||
.github/workflows/release.yml:
|
||||
ignore:
|
||||
# https://github.com/rhysd/actionlint/issues/711
|
||||
- 'reusable workflow call .+ at "uses" is not following the format.+'
|
||||
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: matrix.qemu_platform
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4-56@sha256:30cc9a4d03765acac9be2ed0afc23af1ad018aed2c28ea4be8c2eb9afe03fbd1
|
||||
cache-image: false
|
||||
|
||||
@@ -36,12 +36,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write # May be needed to publish release
|
||||
id-token: write # Needed for trusted publishing
|
||||
uses: ./.github/workflows/release.yml
|
||||
uses: $/.github/workflows/release.yml
|
||||
with:
|
||||
prerelease: true
|
||||
source: ${{ (github.repository != 'yt-dlp/yt-dlp' && vars.MASTER_ARCHIVE_REPO) || 'master' }}
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write # May be needed to publish release
|
||||
id-token: write # Needed for trusted publishing
|
||||
uses: ./.github/workflows/release.yml
|
||||
uses: $/.github/workflows/release.yml
|
||||
with:
|
||||
prerelease: true
|
||||
source: ${{ (github.repository != 'yt-dlp/yt-dlp' && vars.NIGHTLY_ARCHIVE_REPO) || 'nightly' }}
|
||||
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
needs: [prepare]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build.yml
|
||||
uses: $/.github/workflows/build.yml
|
||||
with:
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
channel: ${{ needs.prepare.outputs.channel }}
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
advanced-security: false
|
||||
persona: pedantic
|
||||
|
||||
@@ -1859,7 +1859,7 @@ The following extractors use this feature:
|
||||
#### youtube
|
||||
* `lang`: Prefer translated metadata (`title`, `description` etc) of this language code (case-sensitive). By default, the video primary language metadata is preferred, with a fallback to `en` translated. See [youtube/_base.py](https://github.com/yt-dlp/yt-dlp/blob/415b4c9f955b1a0391204bd24a7132590e7b3bdb/yt_dlp/extractor/youtube/_base.py#L402-L409) for the list of supported content language codes
|
||||
* `skip`: One or more of `hls`, `dash` or `translated_subs` to skip extraction of the m3u8 manifests, dash manifests and [auto-translated subtitles](https://github.com/yt-dlp/yt-dlp/issues/4090#issuecomment-1158102032) respectively
|
||||
* `player_client`: Clients to extract video data from. The currently available clients are `web`, `web_safari`, `web_embedded`, `web_music`, `web_creator`, `mweb`, `ios`, `visionos`, `android`, `android_vr`, `tv`, `tv_downgraded`, and `tv_simply`. By default, `visionos,web` is used. If no JavaScript runtime/engine is available, then `web` is omitted. If logged-in cookies are passed to yt-dlp, then `web_embedded,tv_downgraded,web` is used for free accounts and `web_creator,tv_downgraded,web` is used for premium accounts. The `web_music` client is added for `music.youtube.com` URLs when logged-in cookies are used. The `web_embedded` client is added for age-restricted videos but only successfully works around the age-restriction sometimes (e.g. if the video is embeddable). The `tv_downgraded` and `web_embedded` clients may be added as a fallback if `android_vr` or `visionos` is unable to access a video. The `web_creator` client is added for age-restricted videos if account age-verification is required. Some clients, such as `web_creator` and `web_music`, require a `po_token` for their formats to be downloadable. Some clients, such as `web_creator`, will only work with authentication. Not all clients support authentication via cookies. You can use `default` for the default clients, or you can use `all` for all clients (not recommended). You can prefix a client with `-` to exclude it, e.g. `youtube:player_client=default,-web`
|
||||
* `player_client`: Clients to extract video data from. The currently available clients are `web`, `web_safari`, `web_embedded`, `web_music`, `web_creator`, `mweb`, `ios`, `visionos`, `android`, `android_vr`, `tv`, `tv_downgraded`, `tv_samsung`, and `tv_simply`. By default, `tv_samsung,visionos,web` is used. If no JavaScript runtime/engine is available, then `web` and `tv_samsung` are omitted. If logged-in cookies are passed to yt-dlp, then `web_embedded,tv_samsung,web` is used for free accounts and `web_creator,tv_samsung,web` is used for premium accounts. The `web_music` client is added for `music.youtube.com` URLs when logged-in cookies are used. The `web_embedded` client is added for age-restricted videos but only successfully works around the age-restriction sometimes (e.g. if the video is embeddable). The `web_embedded` client may be added as a fallback if `android_vr` or `visionos` is unable to access a video. The `web_creator` client is added for age-restricted videos if account age-verification is required. Some clients, such as `web_creator` and `web_music`, require a `po_token` for their formats to be downloadable. Some clients, such as `web_creator`, will only work with authentication. Not all clients support authentication via cookies. You can use `default` for the default clients, or you can use `all` for all clients (not recommended). You can prefix a client with `-` to exclude it, e.g. `youtube:player_client=default,-web`
|
||||
* `player_skip`: Skip some network requests that are generally needed for robust extraction. One or more of `configs` (skip client configs), `webpage` (skip initial webpage), `js` (skip js player), `initial_data` (skip initial data/next ep request). While these options can help reduce the number of requests needed or avoid some rate-limiting, they could cause issues such as missing formats or metadata. See [#860](https://github.com/yt-dlp/yt-dlp/pull/860) and [#12826](https://github.com/yt-dlp/yt-dlp/issues/12826) for more details
|
||||
* `webpage_skip`: Skip extraction of embedded webpage data. One or both of `player_response`, `initial_data`. These options are for testing purposes and don't skip any network requests. Neither is skipped by default; however, if a `player_js_version` value other than `actual` is used, then `webpage_skip=player_response` is implied
|
||||
* `webpage_client`: Client to use for the video webpage request. One of `web` (default) or `web_safari`
|
||||
@@ -1978,6 +1978,9 @@ The following extractors use this feature:
|
||||
* `client`: Client to extract video data from. The currently available clients are `android` and `web`. Only one client can be used. The `web` client is used by default, and it only works with account cookies or login credentials. The `android` client only works with previously cached OAuth tokens
|
||||
* `original_format_policy`: Policy for when to try extracting original formats. One of `always`, `never`, or `auto`. The default `auto` policy tries to avoid exceeding the web client's API rate-limit by only making an extra request when Vimeo publicizes the video's downloadability
|
||||
|
||||
#### adn
|
||||
* `profile_id`: The numeric ID of the premium account profile that will be used to download videos (default: `1`)
|
||||
|
||||
#### zan
|
||||
* `split_angles`: Split multi-angle streams into separate angle formats. Forces re-encoding of the video stream during download, and requires ffmpeg. Either `true` or `false` (default)
|
||||
|
||||
|
||||
@@ -240,6 +240,10 @@ mutagen==1.48.1 \
|
||||
--hash=sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7 \
|
||||
--hash=sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173
|
||||
# via yt-dlp
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and implementation_name != 'cpython' \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via cffi
|
||||
pycryptodomex==3.23.0 \
|
||||
--hash=sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51 \
|
||||
--hash=sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6 \
|
||||
|
||||
@@ -66,8 +66,7 @@ def convert_code_blocks(readme):
|
||||
|
||||
def move_sections(readme):
|
||||
MOVE_TAG_TEMPLATE = '<!-- MANPAGE: MOVE "%s" SECTION HERE -->'
|
||||
sections = re.findall(r'(?m)^%s$' % (
|
||||
re.escape(MOVE_TAG_TEMPLATE).replace(r'\%', '%') % '(.+)'), readme)
|
||||
sections = re.findall(r'(?m)^%s$' % (re.escape(MOVE_TAG_TEMPLATE) % '(.+)'), readme)
|
||||
|
||||
for section_name in sections:
|
||||
move_tag = MOVE_TAG_TEMPLATE % section_name
|
||||
|
||||
@@ -77,7 +77,7 @@ BUNDLE_TARGETS = {
|
||||
'default': Target(
|
||||
extras=['default'],
|
||||
# PyPy bundles cffi, which is a transitive dep of brotlicffi, which is only required for PyPy
|
||||
prune_packages=['cffi'],
|
||||
omit_packages=['cffi'],
|
||||
),
|
||||
'curl-cffi': Target(
|
||||
extras=['default', 'curl-cffi'],
|
||||
@@ -643,7 +643,7 @@ def update_requirements(
|
||||
extras=[extra_name],
|
||||
bare=True,
|
||||
# PyPy bundles cffi, which is a transitive dep of brotlicffi, which is only required for PyPy
|
||||
prune_packages=['cffi'] if extra_name == 'default' else [],
|
||||
omit_packages=['cffi'] if extra_name == 'default' else [],
|
||||
).splitlines()
|
||||
|
||||
# Write the finalized pyproject.toml
|
||||
|
||||
@@ -74,6 +74,7 @@ pin = [
|
||||
"charset-normalizer==3.5.0",
|
||||
"idna==3.18",
|
||||
"mutagen==1.48.1",
|
||||
"pycparser==3.0 ; implementation_name != 'PyPy' and implementation_name != 'cpython'",
|
||||
"pycryptodomex==3.23.0",
|
||||
"requests==2.34.2",
|
||||
"urllib3==2.7.0",
|
||||
|
||||
+1
-37
@@ -3,7 +3,6 @@ import hashlib
|
||||
import json
|
||||
import os.path
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import types
|
||||
|
||||
@@ -318,36 +317,6 @@ def expect_info_dict(self, got_dict, expected_dict):
|
||||
'Missing keys in test definition: {}'.format(', '.join(sorted(missing_keys))))
|
||||
|
||||
|
||||
def assertRegexpMatches(self, text, regexp, msg=None):
|
||||
if hasattr(self, 'assertRegexp'):
|
||||
return self.assertRegexp(text, regexp, msg)
|
||||
else:
|
||||
m = re.match(regexp, text)
|
||||
if not m:
|
||||
note = f'Regexp didn\'t match: {regexp!r} not found'
|
||||
if len(text) < 1000:
|
||||
note += f' in {text!r}'
|
||||
if msg is None:
|
||||
msg = note
|
||||
else:
|
||||
msg = note + ', ' + msg
|
||||
self.assertTrue(m, msg)
|
||||
|
||||
|
||||
def assertGreaterEqual(self, got, expected, msg=None):
|
||||
if not (got >= expected):
|
||||
if msg is None:
|
||||
msg = f'{got!r} not greater than or equal to {expected!r}'
|
||||
self.assertTrue(got >= expected, msg)
|
||||
|
||||
|
||||
def assertLessEqual(self, got, expected, msg=None):
|
||||
if not (got <= expected):
|
||||
if msg is None:
|
||||
msg = f'{got!r} not less than or equal to {expected!r}'
|
||||
self.assertTrue(got <= expected, msg)
|
||||
|
||||
|
||||
def assertEqual(self, got, expected, msg=None):
|
||||
if got != expected:
|
||||
if msg is None:
|
||||
@@ -366,12 +335,7 @@ def expect_warnings(ydl, warnings_re):
|
||||
|
||||
|
||||
def http_server_port(httpd):
|
||||
if os.name == 'java' and isinstance(httpd.socket, ssl.SSLSocket):
|
||||
# In Jython SSLSocket is not a subclass of socket.socket
|
||||
sock = httpd.socket.sock
|
||||
else:
|
||||
sock = httpd.socket
|
||||
return sock.getsockname()[1]
|
||||
return httpd.server_address[1]
|
||||
|
||||
|
||||
def verify_address_availability(address):
|
||||
|
||||
@@ -15,7 +15,7 @@ import contextlib
|
||||
import copy
|
||||
import json
|
||||
|
||||
from test.helper import FakeYDL, assertRegexpMatches, try_rm
|
||||
from test.helper import FakeYDL, try_rm
|
||||
from yt_dlp import YoutubeDL
|
||||
from yt_dlp.extractor.common import InfoExtractor
|
||||
from yt_dlp.postprocessor.common import PostProcessor
|
||||
@@ -860,10 +860,10 @@ class TestYoutubeDL(unittest.TestCase):
|
||||
def test_format_note(self):
|
||||
ydl = YoutubeDL()
|
||||
self.assertEqual(ydl._format_note({}), '')
|
||||
assertRegexpMatches(self, ydl._format_note({
|
||||
self.assertRegex(ydl._format_note({
|
||||
'vbr': 10,
|
||||
}), r'^\s*10k$')
|
||||
assertRegexpMatches(self, ydl._format_note({
|
||||
self.assertRegex(ydl._format_note({
|
||||
'fps': 30,
|
||||
}), r'^30fps$')
|
||||
|
||||
|
||||
+54
-1
@@ -7,10 +7,12 @@ import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
from yt_dlp.utils.jslib import devalue
|
||||
@@ -84,10 +86,61 @@ TEST_CASES_EQUALS = [{
|
||||
'name': 'Uint8Array',
|
||||
'unparsed': [['Uint8Array', 'AQID']],
|
||||
'parsed': [1, 2, 3],
|
||||
}, {
|
||||
'name': 'Uint8Array with ArrayBuffer reference',
|
||||
'unparsed': [['Uint8Array', 1], ['ArrayBuffer', 'AQID']],
|
||||
'parsed': [1, 2, 3],
|
||||
}, {
|
||||
'name': 'Uint8Array with byte offset and element length',
|
||||
'unparsed': [['Uint8Array', 1, 1, 2], ['ArrayBuffer', 'AQIDBAU=']],
|
||||
'parsed': [2, 3],
|
||||
}, {
|
||||
'name': 'Uint16Array with byte offset and element length',
|
||||
'unparsed': [
|
||||
['Uint16Array', 1, 2, 2],
|
||||
['ArrayBuffer', base64.b64encode(struct.pack('=4H', 1, 2, 3, 4)).decode()],
|
||||
],
|
||||
'parsed': [2, 3],
|
||||
}, {
|
||||
'name': 'DataView with byte offset and byte length',
|
||||
'unparsed': [['DataView', 1, 1, 2], ['ArrayBuffer', 'AQIDBAU=']],
|
||||
'parsed': b'\x02\x03',
|
||||
}, {
|
||||
'name': 'DataView with byte offset',
|
||||
'unparsed': [['DataView', 1, 2], ['ArrayBuffer', 'AQIDBAU=']],
|
||||
'parsed': b'\x03\x04\x05',
|
||||
}, {
|
||||
'name': 'Float16Array',
|
||||
'unparsed': [[
|
||||
'Float16Array',
|
||||
base64.b64encode(struct.pack('=2e', -2.0, 1.5)).decode(),
|
||||
]],
|
||||
'parsed': [-2.0, 1.5],
|
||||
}, {
|
||||
'name': 'Uint32Array',
|
||||
'unparsed': [[
|
||||
'Uint32Array',
|
||||
base64.b64encode(struct.pack('=2I', 12345, 12345678)).decode(),
|
||||
]],
|
||||
'parsed': [12345, 12345678],
|
||||
}, {
|
||||
'name': 'BigInt64Array',
|
||||
'unparsed': [[
|
||||
'BigInt64Array',
|
||||
base64.b64encode(struct.pack('=2q', -1, 2)).decode(),
|
||||
]],
|
||||
'parsed': [-1, 2],
|
||||
}, {
|
||||
'name': 'BigUint64Array',
|
||||
'unparsed': [[
|
||||
'BigUint64Array',
|
||||
base64.b64encode(struct.pack('=2Q', 1, 2)).decode(),
|
||||
]],
|
||||
'parsed': [1, 2],
|
||||
}, {
|
||||
'name': 'ArrayBuffer',
|
||||
'unparsed': [['ArrayBuffer', 'AQID']],
|
||||
'parsed': [1, 2, 3],
|
||||
'parsed': b'\x01\x02\x03',
|
||||
}, {
|
||||
'name': 'str (repetition)',
|
||||
'unparsed': [[1, 1], 'a string'],
|
||||
|
||||
@@ -13,8 +13,6 @@ import hashlib
|
||||
import json
|
||||
|
||||
from test.helper import (
|
||||
assertGreaterEqual,
|
||||
assertLessEqual,
|
||||
expect_info_dict,
|
||||
expect_warnings,
|
||||
get_params,
|
||||
@@ -201,8 +199,8 @@ def generator(test_case, tname):
|
||||
num_entries = len(res_dict.get('entries', []))
|
||||
if 'playlist_mincount' in test_case:
|
||||
mincount = test_case['playlist_mincount']
|
||||
assertGreaterEqual(
|
||||
self, num_entries, mincount,
|
||||
self.assertGreaterEqual(
|
||||
num_entries, mincount,
|
||||
f'Expected at least {mincount} entries in playlist {test_url}, but got only {num_entries}')
|
||||
if 'playlist_count' in test_case:
|
||||
count = test_case['playlist_count']
|
||||
@@ -212,8 +210,8 @@ def generator(test_case, tname):
|
||||
f'Expected exactly {count} entries in playlist {test_url}, but got {got}')
|
||||
if 'playlist_maxcount' in test_case:
|
||||
maxcount = test_case['playlist_maxcount']
|
||||
assertLessEqual(
|
||||
self, num_entries, maxcount,
|
||||
self.assertLessEqual(
|
||||
num_entries, maxcount,
|
||||
f'Expected at most {maxcount} entries in playlist {test_url}, but got more')
|
||||
if 'playlist_duration_sum' in test_case:
|
||||
got_duration = sum(e['duration'] for e in res_dict['entries'])
|
||||
@@ -241,8 +239,8 @@ def generator(test_case, tname):
|
||||
if params.get('test'):
|
||||
expected_minsize = max(expected_minsize, 10000)
|
||||
got_fsize = os.path.getsize(tc_filename)
|
||||
assertGreaterEqual(
|
||||
self, got_fsize, expected_minsize,
|
||||
self.assertGreaterEqual(
|
||||
got_fsize, expected_minsize,
|
||||
f'Expected {tc_filename} to be at least {format_bytes(expected_minsize)}, '
|
||||
f'but it\'s only {format_bytes(got_fsize)} ')
|
||||
if 'md5' in tc:
|
||||
|
||||
+12
-19
@@ -180,6 +180,11 @@ class HTTPTestRequestHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.send_header('Location', '/a/b/./../../headers')
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
elif self.path == '/a/b/redirect_relative_dotsegments':
|
||||
self.send_response(301)
|
||||
self.send_header('Location', '../../headers')
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
elif self.path == '/redirect_dotsegments_absolute':
|
||||
self.send_response(301)
|
||||
# redirect to /headers but with dot segments before - absolute url
|
||||
@@ -338,7 +343,7 @@ class TestHTTPRequestHandler(TestRequestHandlerBase):
|
||||
https_server_thread.start()
|
||||
|
||||
with handler(verify=False) as rh:
|
||||
with pytest.raises(SSLError, match=r'(?i)ssl(?:v3|/tls).alert.handshake.failure') as exc_info:
|
||||
with pytest.raises(SSLError, match=r'(?i)(?:sslv3|tls).alert.handshake.failure') as exc_info:
|
||||
validate_and_send(rh, Request(f'https://127.0.0.1:{https_port}/headers'))
|
||||
assert not issubclass(exc_info.type, CertificateVerifyError)
|
||||
|
||||
@@ -412,6 +417,7 @@ class TestHTTPRequestHandler(TestRequestHandlerBase):
|
||||
@pytest.mark.parametrize('path', [
|
||||
'/a/b/./../../headers',
|
||||
'/redirect_dotsegments',
|
||||
'/a/b/redirect_relative_dotsegments',
|
||||
# https://github.com/yt-dlp/yt-dlp/issues/9020
|
||||
'/redirect_dotsegments_absolute',
|
||||
])
|
||||
@@ -984,28 +990,15 @@ class TestUrllibRequestHandler(TestRequestHandlerBase):
|
||||
):
|
||||
validate_and_send(rh, Request(f'https://127.0.0.1:{self.https_port}/headers'))
|
||||
|
||||
@pytest.mark.parametrize('req,match,version_check', [
|
||||
@pytest.mark.parametrize('req,match', [
|
||||
# https://github.com/python/cpython/blob/987b712b4aeeece336eed24fcc87a950a756c3e2/Lib/http/client.py#L1256
|
||||
# bpo-39603: Check implemented in 3.7.9+, 3.8.5+
|
||||
(
|
||||
Request('http://127.0.0.1', method='GET\n'),
|
||||
'method can\'t contain control characters',
|
||||
lambda v: v < (3, 7, 9) or (3, 8, 0) <= v < (3, 8, 5),
|
||||
),
|
||||
(Request('http://127.0.0.1', method='GET\n'), 'method can\'t contain control characters'),
|
||||
# https://github.com/python/cpython/blob/987b712b4aeeece336eed24fcc87a950a756c3e2/Lib/http/client.py#L1265
|
||||
# bpo-38576: Check implemented in 3.7.8+, 3.8.3+
|
||||
(
|
||||
Request('http://127.0.0. 1', method='GET'),
|
||||
'URL can\'t contain control characters',
|
||||
lambda v: v < (3, 7, 8) or (3, 8, 0) <= v < (3, 8, 3),
|
||||
),
|
||||
(Request('http://127.0.0. 1', method='GET'), 'URL can\'t contain control characters'),
|
||||
# https://github.com/python/cpython/blob/987b712b4aeeece336eed24fcc87a950a756c3e2/Lib/http/client.py#L1288C31-L1288C50
|
||||
(Request('http://127.0.0.1', headers={'foo\n': 'bar'}), 'Invalid header name', None),
|
||||
(Request('http://127.0.0.1', headers={'foo\n': 'bar'}), 'Invalid header name'),
|
||||
])
|
||||
def test_httplib_validation_errors(self, handler, req, match, version_check):
|
||||
if version_check and version_check(sys.version_info):
|
||||
pytest.skip(f'Python {sys.version} version does not have the required validation for this test.')
|
||||
|
||||
def test_httplib_validation_errors(self, handler, req, match):
|
||||
with handler() as rh:
|
||||
with pytest.raises(RequestError, match=match) as exc_info:
|
||||
validate_and_send(rh, req)
|
||||
|
||||
@@ -63,13 +63,13 @@ class TestNetworkingUtils:
|
||||
'username': 'user',
|
||||
'password': '',
|
||||
}),
|
||||
('socks4://u%40ser:pa%20ss@127.0.0.1:1080', {
|
||||
('socks4://u%40ser+name:pa%20ss+word@127.0.0.1:1080', {
|
||||
'proxytype': ProxyType.SOCKS4,
|
||||
'addr': '127.0.0.1',
|
||||
'port': 1080,
|
||||
'rdns': False,
|
||||
'username': 'u@ser',
|
||||
'password': 'pa ss',
|
||||
'username': 'u@ser+name',
|
||||
'password': 'pa ss+word',
|
||||
}),
|
||||
('socks4a://:pa%20ss@127.0.0.1', {
|
||||
'proxytype': ProxyType.SOCKS4A,
|
||||
|
||||
@@ -546,6 +546,18 @@ class TestTraversalHelpers:
|
||||
{'url': 'https://example.com/subs/de4'},
|
||||
],
|
||||
}, 'non str types should be replaced by default id'
|
||||
assert traverse_obj([
|
||||
{'name': '', 'ext': '', 'url': 'https://example.com/subs/en'},
|
||||
], [..., {
|
||||
'id': 'name',
|
||||
'ext': 'ext',
|
||||
'url': 'url',
|
||||
}, all, {subs_list_to_dict(lang='en', ext='vtt')}]) == {
|
||||
'en': [{
|
||||
'ext': 'vtt',
|
||||
'url': 'https://example.com/subs/en',
|
||||
}],
|
||||
}, 'empty id and ext should be replaced by defaults'
|
||||
|
||||
def test_trim_str(self):
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
+39
-8
@@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import email.message
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
@@ -48,6 +49,7 @@ from yt_dlp.utils import (
|
||||
determine_ext,
|
||||
determine_file_encoding,
|
||||
dfxp2srt,
|
||||
disposition_filename,
|
||||
encode_base_n,
|
||||
encode_compat_str,
|
||||
expand_path,
|
||||
@@ -107,6 +109,7 @@ from yt_dlp.utils import (
|
||||
sanitize_filename,
|
||||
sanitize_path,
|
||||
sanitize_url,
|
||||
setproctitle,
|
||||
shell_quote,
|
||||
strftime_or_none,
|
||||
smuggle_url,
|
||||
@@ -125,6 +128,7 @@ from yt_dlp.utils import (
|
||||
url_basename,
|
||||
url_or_none,
|
||||
urlencode_postdata,
|
||||
urlhandle_detect_ext,
|
||||
urljoin,
|
||||
urshift,
|
||||
variadic,
|
||||
@@ -613,6 +617,14 @@ class TestUtil(unittest.TestCase):
|
||||
self.assertEqual(res_url, url)
|
||||
self.assertEqual(res_data, {'a': 'b', 'c': 'd'})
|
||||
|
||||
@unittest.mock.patch('ctypes.cdll.LoadLibrary')
|
||||
def test_setproctitle(self, load_library):
|
||||
for title in ('yt-dlp', 'café'):
|
||||
with self.subTest(title=title):
|
||||
setproctitle(title)
|
||||
buf = load_library.return_value.prctl.call_args.args[1]
|
||||
self.assertEqual(bytes(buf), title.encode() + b'\0')
|
||||
|
||||
def test_shell_quote(self):
|
||||
args = ['ffmpeg', '-i', 'ñ€ß\'.mp4']
|
||||
self.assertEqual(
|
||||
@@ -1347,15 +1359,8 @@ class TestUtil(unittest.TestCase):
|
||||
self.assertEqual(extract_attributes('<e _:funny-name1=1>'), {'_:funny-name1': '1'})
|
||||
self.assertEqual(extract_attributes('<e x="Fáilte 世界 \U0001f600">'), {'x': 'Fáilte 世界 \U0001f600'})
|
||||
self.assertEqual(extract_attributes('<e x="décomposé">'), {'x': 'décompose\u0301'})
|
||||
# "Narrow" Python builds don't support unicode code points outside BMP.
|
||||
try:
|
||||
chr(0x10000)
|
||||
supports_outside_bmp = True
|
||||
except ValueError:
|
||||
supports_outside_bmp = False
|
||||
if supports_outside_bmp:
|
||||
self.assertEqual(extract_attributes('<e x="Smile 😀!">'), {'x': 'Smile \U0001f600!'})
|
||||
# Malformed HTML should not break attributes extraction on older Python
|
||||
# Malformed HTML should not break attribute extraction
|
||||
self.assertEqual(extract_attributes('<mal"formed/>'), {})
|
||||
|
||||
def test_clean_html(self):
|
||||
@@ -2121,6 +2126,32 @@ Line 1
|
||||
self.assertEqual(get_compatible_ext(
|
||||
vcodecs=['av1'], acodecs=['mp4a'], vexts=['webm'], aexts=['m4a'], preferences=('webm', 'mkv')), 'mkv')
|
||||
|
||||
def test_disposition_filename(self):
|
||||
test_cases = (
|
||||
(None, None),
|
||||
('attachment', None),
|
||||
('attachment; filename="video.mp4"', 'video.mp4'),
|
||||
('attachment; FILENAME="video.mp4"', 'video.mp4'),
|
||||
('inline; filename=video.mp4', 'video.mp4'),
|
||||
('attachment; filename=" video.mp4 "', 'video.mp4'),
|
||||
("attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.mp4", 'résumé.mp4'),
|
||||
("attachment; filename=fallback.mp4; filename*=UTF-8''actual.webm", 'actual.webm'),
|
||||
("attachment; filename*=UTF-8''actual.webm; filename=fallback.mp4", 'actual.webm'),
|
||||
)
|
||||
|
||||
for content_disposition, expected in test_cases:
|
||||
headers = email.message.Message()
|
||||
if content_disposition is not None:
|
||||
headers['Content-Disposition'] = content_disposition
|
||||
with self.subTest(content_disposition=content_disposition):
|
||||
self.assertEqual(disposition_filename(headers), expected)
|
||||
|
||||
def test_urlhandle_detect_ext(self):
|
||||
headers = email.message.Message()
|
||||
headers['Content-Disposition'] = "attachment; filename=fallback.mp4; filename*=UTF-8''actual.webm"
|
||||
headers['Content-Type'] = 'video/mp4'
|
||||
self.assertEqual(urlhandle_detect_ext(unittest.mock.Mock(headers=headers)), 'webm')
|
||||
|
||||
def test_try_call(self):
|
||||
def total(*x, **kwargs):
|
||||
return sum(x) + sum(kwargs.values())
|
||||
|
||||
@@ -130,7 +130,11 @@ def ws_validate_and_send(rh, req):
|
||||
try:
|
||||
return rh.send(req)
|
||||
except TransportError as e:
|
||||
if i < (max_tries - 1) and 'connection closed during handshake' in str(e):
|
||||
if i < (max_tries - 1) and (
|
||||
'connection closed during handshake' in str(e)
|
||||
or (isinstance(e.cause, websockets.exceptions.InvalidMessage)
|
||||
and isinstance(e.cause.__cause__, EOFError))
|
||||
):
|
||||
# websockets server sometimes hangs on new connections
|
||||
continue
|
||||
raise
|
||||
@@ -186,7 +190,7 @@ class TestWebsSocketRequestHandlerConformance:
|
||||
|
||||
def test_ssl_error(self, handler):
|
||||
with handler(verify=False) as rh:
|
||||
with pytest.raises(SSLError, match=r'ssl(?:v3|/tls) alert handshake failure') as exc_info:
|
||||
with pytest.raises(SSLError, match=r'(?:sslv3|tls) alert handshake failure') as exc_info:
|
||||
ws_validate_and_send(rh, Request(self.bad_wss_host))
|
||||
assert not issubclass(exc_info.type, CertificateVerifyError)
|
||||
|
||||
|
||||
@@ -1447,6 +1447,7 @@ pin = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy' and implementation_name != 'cpython'" },
|
||||
{ name = "pycryptodomex" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
@@ -1534,6 +1535,7 @@ requires-dist = [
|
||||
{ name = "mutagen", marker = "extra == 'default'" },
|
||||
{ name = "mutagen", marker = "extra == 'pin'", specifier = "==1.48.1" },
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' and extra == 'pin-secretstorage'", specifier = "==3.0" },
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy' and implementation_name != 'cpython' and extra == 'pin'", specifier = "==3.0" },
|
||||
{ name = "pycparser", marker = "implementation_name == 'cpython' and extra == 'pin-curl-cffi'", specifier = "==3.0" },
|
||||
{ name = "pycryptodomex", marker = "extra == 'default'" },
|
||||
{ name = "pycryptodomex", marker = "extra == 'pin'", specifier = "==3.23.0" },
|
||||
|
||||
+1
-3
@@ -672,7 +672,6 @@ class YoutubeDL:
|
||||
except Exception as e:
|
||||
self.write_debug(f'Failed to enable VT mode: {e}')
|
||||
|
||||
# hehe "immutable" namespace
|
||||
self._out_files.console = next(filter(supports_terminal_sequences, (sys.stderr, sys.stdout)), None)
|
||||
|
||||
if self.params.get('no_color'):
|
||||
@@ -2398,8 +2397,7 @@ class YoutubeDL:
|
||||
selectors = []
|
||||
current_selector = None
|
||||
for type_, string_, start, _, _ in tokens:
|
||||
# ENCODING is only defined in Python 3.x
|
||||
if type_ == getattr(tokenize, 'ENCODING', None):
|
||||
if type_ == tokenize.ENCODING:
|
||||
continue
|
||||
elif type_ in [tokenize.NAME, tokenize.NUMBER]:
|
||||
current_selector = FormatSelector(SINGLE, string_, [])
|
||||
|
||||
+1
-1
@@ -293,7 +293,7 @@ def aes_decrypt_text(data, password, key_size_bytes):
|
||||
- Mode of operation is 'counter'
|
||||
|
||||
@param {str} data Base64 encoded string
|
||||
@param {str,unicode} password Password (will be encoded with utf-8)
|
||||
@param {str} password Password (will be encoded with UTF-8)
|
||||
@param {int} key_size_bytes Possible values: 16 for 128-Bit, 24 for 192-Bit or 32 for 256-Bit
|
||||
@returns {str} Decrypted data
|
||||
"""
|
||||
|
||||
@@ -8,9 +8,8 @@ passthrough_module(__name__, '._deprecated')
|
||||
del passthrough_module
|
||||
|
||||
|
||||
# HTMLParseError has been deprecated in Python 3.3 and removed in
|
||||
# Python 3.5. Introducing dummy exception for Python >3.5 for compatible
|
||||
# and uniform cross-version exception handling
|
||||
# HTMLParseError was deprecated in Python 3.3 and removed in Python 3.5.
|
||||
# Keep a replacement for API compatibility and uniform exception handling.
|
||||
class compat_HTMLParseError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ def write_piff_header(stream, params):
|
||||
sample_entry_payload += u16.pack(0x18) # depth
|
||||
sample_entry_payload += s16.pack(-1) # pre defined
|
||||
|
||||
codec_private_data = binascii.unhexlify(params['codec_private_data'].encode())
|
||||
codec_private_data = binascii.unhexlify(params['codec_private_data'])
|
||||
if fourcc in ('H264', 'AVC1'):
|
||||
sps, pps = codec_private_data.split(u32.pack(1))[1:]
|
||||
avcc_payload = u8.pack(1) # configuration version
|
||||
|
||||
@@ -1221,6 +1221,7 @@ from .nhk import (
|
||||
from .nhl import NHLIE
|
||||
from .nick import NickIE
|
||||
from .niconico import (
|
||||
NiconicoChannelIE,
|
||||
NiconicoHistoryIE,
|
||||
NiconicoIE,
|
||||
NiconicoLiveIE,
|
||||
|
||||
@@ -50,14 +50,13 @@ class ADNIE(ADNBaseIE):
|
||||
_VALID_URL = r'https?://(?:www\.)?animationdigitalnetwork\.com/(?:(?P<lang>de)/)?video/[^/?#]+/(?P<id>\d+)'
|
||||
_TESTS = [{
|
||||
'url': 'https://animationdigitalnetwork.com/video/558-fruits-basket/9841-episode-1-a-ce-soir',
|
||||
'md5': '1c9ef066ceb302c86f80c2b371615261',
|
||||
'md5': '3999b7b235ffb3591a385d1913cd3cd1',
|
||||
'info_dict': {
|
||||
'id': '9841',
|
||||
'ext': 'mp4',
|
||||
'title': 'Fruits Basket - Episode 1',
|
||||
'description': 'md5:14be2f72c3c96809b0ca424b0097d336',
|
||||
'series': 'Fruits Basket',
|
||||
'duration': 1437,
|
||||
'duration': 1436,
|
||||
'release_date': '20190405',
|
||||
'comment_count': int,
|
||||
'average_rating': float,
|
||||
@@ -174,6 +173,8 @@ Format: Marked,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text'''
|
||||
def _real_extract(self, url):
|
||||
lang, video_id = self._match_valid_url(url).group('lang', 'id')
|
||||
self._HEADERS['X-Target-Distribution'] = lang or 'fr'
|
||||
if 'Authorization' in self._HEADERS:
|
||||
self._HEADERS['X-Profile-ID'] = self._configuration_arg('profile_id', ['1'])[0]
|
||||
video_base_url = self._PLAYER_BASE_URL + f'video/{video_id}/'
|
||||
player = self._download_json(
|
||||
video_base_url + 'configuration', video_id,
|
||||
|
||||
@@ -28,7 +28,7 @@ class AppleBaseIE(InfoExtractor):
|
||||
|
||||
def _get_token(self, webpage, episode_id):
|
||||
if self._jwt_cache.get(self._BASE_URL) and not self._jwt_is_expired(self._jwt_cache[self._BASE_URL]):
|
||||
return self._jwt
|
||||
return self._jwt_cache[self._BASE_URL]
|
||||
|
||||
js_path = self._search_regex(
|
||||
r'<script [^>]*\bsrc="(/assets/index~[0-9a-f]+\.js)">', webpage, 'JS asset path')
|
||||
|
||||
@@ -25,7 +25,35 @@ from ..utils import (
|
||||
from ..utils.traversal import find_element, find_elements, traverse_obj
|
||||
|
||||
|
||||
class BandcampIE(InfoExtractor):
|
||||
class BandcampBaseIE(InfoExtractor):
|
||||
# Initially try without impersonation, retry with impersonation
|
||||
def _download_webpage(self, *args, **kwargs):
|
||||
impersonate = kwargs.pop('impersonate', None) or True
|
||||
kwargs.pop('require_impersonation', None)
|
||||
webpage = super()._download_webpage(*args, **kwargs)
|
||||
if webpage:
|
||||
if self._html_extract_title(webpage) == 'Client Challenge':
|
||||
self.write_debug('Got client challenge webpage response')
|
||||
else:
|
||||
return webpage
|
||||
|
||||
res = self._download_webpage_handle(*args, impersonate=impersonate, require_impersonation=True, **kwargs)
|
||||
if res is False:
|
||||
return False
|
||||
|
||||
webpage, urlh = res
|
||||
if self._html_extract_title(webpage) == 'Client Challenge':
|
||||
raise ExtractorError(f'Got client challenge webpage response with {urlh.extensions.get("impersonate")}')
|
||||
|
||||
return webpage
|
||||
|
||||
def _extract_data_attr(self, webpage, video_id, attr='tralbum', fatal=True):
|
||||
return self._parse_json(self._html_search_regex(
|
||||
rf'data-{attr}=(["\'])({{.+?}})\1', webpage,
|
||||
attr + ' data', group=2), video_id, fatal=fatal)
|
||||
|
||||
|
||||
class BandcampIE(BandcampBaseIE):
|
||||
_VALID_URL = r'https?://(?P<uploader>[^/]+)\.bandcamp\.com/track/(?P<id>[^/?#&]+)'
|
||||
_EMBED_REGEX = [r'<meta property="og:url"[^>]*?content="(?P<url>.*?bandcamp\.com.*?)"']
|
||||
_TESTS = [{
|
||||
@@ -149,14 +177,9 @@ class BandcampIE(InfoExtractor):
|
||||
'skip': 'embed detection is broken',
|
||||
}]
|
||||
|
||||
def _extract_data_attr(self, webpage, video_id, attr='tralbum', fatal=True):
|
||||
return self._parse_json(self._html_search_regex(
|
||||
rf'data-{attr}=(["\'])({{.+?}})\1', webpage,
|
||||
attr + ' data', group=2), video_id, fatal=fatal)
|
||||
|
||||
def _real_extract(self, url):
|
||||
title, uploader = self._match_valid_url(url).group('id', 'uploader')
|
||||
webpage = self._download_webpage(url, title, impersonate=True)
|
||||
webpage = self._download_webpage(url, title)
|
||||
tralbum = self._extract_data_attr(webpage, title)
|
||||
thumbnail = self._og_search_thumbnail(webpage)
|
||||
|
||||
@@ -202,7 +225,7 @@ class BandcampIE(InfoExtractor):
|
||||
track_id = str(tralbum['id'])
|
||||
|
||||
download_webpage = self._download_webpage(
|
||||
download_link, track_id, 'Downloading free downloads page', impersonate=True)
|
||||
download_link, track_id, 'Downloading free downloads page')
|
||||
|
||||
blob = self._extract_data_attr(download_webpage, track_id, 'blob')
|
||||
|
||||
@@ -284,7 +307,7 @@ class BandcampIE(InfoExtractor):
|
||||
}
|
||||
|
||||
|
||||
class BandcampAlbumIE(BandcampIE): # XXX: Do not subclass from concrete IE
|
||||
class BandcampAlbumIE(BandcampBaseIE):
|
||||
IE_NAME = 'Bandcamp:album'
|
||||
_VALID_URL = r'https?://(?:(?P<subdomain>[^.]+)\.)?bandcamp\.com/album/(?P<id>[^/?#&]+)'
|
||||
|
||||
@@ -389,7 +412,7 @@ class BandcampAlbumIE(BandcampIE): # XXX: Do not subclass from concrete IE
|
||||
def _real_extract(self, url):
|
||||
uploader_id, album_id = self._match_valid_url(url).groups()
|
||||
playlist_id = album_id or uploader_id
|
||||
webpage = self._download_webpage(url, playlist_id, impersonate=True)
|
||||
webpage = self._download_webpage(url, playlist_id)
|
||||
tralbum = self._extract_data_attr(webpage, playlist_id)
|
||||
track_info = tralbum.get('trackinfo')
|
||||
if not track_info:
|
||||
@@ -414,7 +437,7 @@ class BandcampAlbumIE(BandcampIE): # XXX: Do not subclass from concrete IE
|
||||
}
|
||||
|
||||
|
||||
class BandcampWeeklyIE(BandcampIE): # XXX: Do not subclass from concrete IE
|
||||
class BandcampWeeklyIE(BandcampBaseIE):
|
||||
IE_NAME = 'Bandcamp:weekly'
|
||||
_VALID_URL = r'https?://(?:www\.)?bandcamp\.com/radio/?\?(?:[^#]+&)?show=(?P<id>\d+)'
|
||||
_TESTS = [{
|
||||
@@ -478,7 +501,7 @@ class BandcampWeeklyIE(BandcampIE): # XXX: Do not subclass from concrete IE
|
||||
}
|
||||
|
||||
|
||||
class BandcampUserIE(InfoExtractor):
|
||||
class BandcampUserIE(BandcampBaseIE):
|
||||
IE_NAME = 'Bandcamp:user'
|
||||
_VALID_URL = r'https?://(?!www\.)(?P<id>[^.]+)\.bandcamp\.com(?:/music)?/?(?:[#?]|$)'
|
||||
|
||||
|
||||
@@ -432,29 +432,28 @@ class InfoExtractor:
|
||||
|
||||
chapter: Name or title of the chapter the video belongs to.
|
||||
chapter_number: Number of the chapter the video belongs to, as an integer.
|
||||
chapter_id: Id of the chapter the video belongs to, as a unicode string.
|
||||
chapter_id: Id of the chapter the video belongs to.
|
||||
|
||||
The following fields should only be used when the video is an episode of some
|
||||
series, programme or podcast:
|
||||
|
||||
series: Title of the series or programme the video episode belongs to.
|
||||
series_id: Id of the series or programme the video episode belongs to, as a unicode string.
|
||||
series_id: Id of the series or programme the video episode belongs to.
|
||||
season: Title of the season the video episode belongs to.
|
||||
season_number: Number of the season the video episode belongs to, as an integer.
|
||||
season_id: Id of the season the video episode belongs to, as a unicode string.
|
||||
season_id: Id of the season the video episode belongs to.
|
||||
episode: Title of the video episode. Unlike mandatory video title field,
|
||||
this field should denote the exact title of the video episode
|
||||
without any kind of decoration.
|
||||
episode_number: Number of the video episode within a season, as an integer.
|
||||
episode_id: Id of the video episode, as a unicode string.
|
||||
episode_id: Id of the video episode.
|
||||
|
||||
The following fields should only be used when the media is a track or a part of
|
||||
a music album:
|
||||
|
||||
track: Title of the track.
|
||||
track_number: Number of the track within an album or a disc, as an integer.
|
||||
track_id: Id of the track (useful in case of custom indexing, e.g. 6.iii),
|
||||
as a unicode string.
|
||||
track_id: Id of the track (useful for custom indexing, e.g. 6.iii).
|
||||
artists: List of artists of the track.
|
||||
composers: List of composers of the piece.
|
||||
genres: List of genres of the track.
|
||||
@@ -487,7 +486,7 @@ class InfoExtractor:
|
||||
creator: Use "creators" instead.
|
||||
The creator of the video.
|
||||
|
||||
Unless mentioned otherwise, the fields should be Unicode strings.
|
||||
Unless mentioned otherwise, the fields should be strings.
|
||||
|
||||
Unless mentioned otherwise, None is equivalent to absence of information.
|
||||
|
||||
@@ -1277,6 +1276,9 @@ class InfoExtractor:
|
||||
"""Returns a URL that points to a page that should be processed"""
|
||||
if ie is not None:
|
||||
kwargs['ie_key'] = ie if isinstance(ie, str) else ie.ie_key()
|
||||
# Silence fallback warning if this is an intentional generic result
|
||||
if kwargs.get('ie_key') == 'Generic':
|
||||
url = smuggle_url(url, {'to_generic': True})
|
||||
if video_id is not None:
|
||||
kwargs['id'] = video_id
|
||||
if video_title is not None:
|
||||
|
||||
+105
-116
@@ -1,14 +1,6 @@
|
||||
from .common import InfoExtractor
|
||||
from ..utils import (
|
||||
clean_html,
|
||||
join_nonempty,
|
||||
parse_duration,
|
||||
str_or_none,
|
||||
traverse_obj,
|
||||
unified_strdate,
|
||||
unified_timestamp,
|
||||
urlhandle_detect_ext,
|
||||
)
|
||||
from ..utils import url_or_none
|
||||
from ..utils.traversal import require, traverse_obj
|
||||
|
||||
|
||||
class GlobalPlayerBaseIE(InfoExtractor):
|
||||
@@ -16,29 +8,11 @@ class GlobalPlayerBaseIE(InfoExtractor):
|
||||
webpage = self._download_webpage(url, video_id)
|
||||
return self._search_nextjs_data(webpage, video_id)['props']['pageProps']
|
||||
|
||||
def _request_ext(self, url, video_id):
|
||||
return urlhandle_detect_ext(self._request_webpage( # Server rejects HEAD requests
|
||||
url, video_id, note='Determining source extension'))
|
||||
|
||||
def _extract_audio(self, episode, series):
|
||||
return {
|
||||
'vcodec': 'none',
|
||||
**traverse_obj(series, {
|
||||
'series': 'title',
|
||||
'series_id': 'id',
|
||||
'thumbnail': 'imageUrl',
|
||||
'uploader': 'itunesAuthor', # podcasts only
|
||||
}),
|
||||
**traverse_obj(episode, {
|
||||
'id': 'id',
|
||||
'description': ('description', {clean_html}),
|
||||
'duration': ('duration', {parse_duration}),
|
||||
'thumbnail': 'imageUrl',
|
||||
'url': 'streamUrl',
|
||||
'timestamp': (('pubDate', 'startDate'), {unified_timestamp}),
|
||||
'title': 'title',
|
||||
}, get_all=False),
|
||||
}
|
||||
@staticmethod
|
||||
def _get_playback_url(data):
|
||||
return traverse_obj(data, (
|
||||
'playback', lambda _, v: v['canUse'] == 'true',
|
||||
'url', {url_or_none}, any, {require('playback URL')}))
|
||||
|
||||
|
||||
class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
|
||||
@@ -48,11 +22,10 @@ class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
|
||||
'info_dict': {
|
||||
'id': '2mx1E',
|
||||
'ext': 'aac',
|
||||
'display_id': 'smoothchill-uk',
|
||||
'title': 're:^Smooth Chill.+$',
|
||||
'thumbnail': 'https://herald.musicradio.com/media/f296ade8-50c9-4f60-911f-924e96873620.png',
|
||||
'description': 'Music To Chill To',
|
||||
'live_status': 'is_live',
|
||||
'thumbnail': 'md5:d5040f26c7c4061014a44866129b900e',
|
||||
'description': 'md5:6e183929da9001778895f32ae85124bc',
|
||||
'title': 're:^Smooth Chill.+$',
|
||||
},
|
||||
}, {
|
||||
# national station
|
||||
@@ -60,11 +33,10 @@ class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
|
||||
'info_dict': {
|
||||
'id': '2mwx4',
|
||||
'ext': 'aac',
|
||||
'description': 'turn up the feel good!',
|
||||
'thumbnail': 'https://herald.musicradio.com/media/49b9e8cb-15bf-4bf2-8c28-a4850cc6b0f3.png',
|
||||
'live_status': 'is_live',
|
||||
'description': 'md5:492d07dfea8addadd15650ef40c10d02',
|
||||
'thumbnail': 'md5:6f13378a53ce55bcf57365a654e1b490',
|
||||
'title': 're:^Heart UK.+$',
|
||||
'display_id': 'heart-uk',
|
||||
},
|
||||
}, {
|
||||
# regional variation
|
||||
@@ -72,110 +44,131 @@ class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
|
||||
'info_dict': {
|
||||
'id': 'AMqg',
|
||||
'ext': 'aac',
|
||||
'thumbnail': 'https://herald.musicradio.com/media/49b9e8cb-15bf-4bf2-8c28-a4850cc6b0f3.png',
|
||||
'title': 're:^Heart London.+$',
|
||||
'live_status': 'is_live',
|
||||
'display_id': 'heart-london',
|
||||
'description': 'turn up the feel good!',
|
||||
'description': 'md5:492d07dfea8addadd15650ef40c10d02',
|
||||
'thumbnail': 'md5:6f13378a53ce55bcf57365a654e1b490',
|
||||
'title': 're:^Heart London.+$',
|
||||
},
|
||||
}]
|
||||
|
||||
def _real_extract(self, url):
|
||||
video_id = self._match_id(url)
|
||||
station = self._get_page_props(url, video_id)['station']
|
||||
stream_url = station['streamUrl']
|
||||
meta = self._get_page_props(url, video_id)['station']
|
||||
station_id = meta['id']
|
||||
|
||||
data = self._download_json(f'https://bff-web-guacamole.musicradio.com/playables/{station_id}', video_id)
|
||||
|
||||
return {
|
||||
'id': station['id'],
|
||||
'display_id': join_nonempty('brandSlug', 'slug', from_dict=station) or station.get('legacyStationPrefix'),
|
||||
'url': stream_url,
|
||||
'ext': self._request_ext(stream_url, video_id),
|
||||
'id': station_id,
|
||||
'url': self._get_playback_url(data),
|
||||
'ext': 'aac',
|
||||
'vcodec': 'none',
|
||||
'is_live': True,
|
||||
**traverse_obj(station, {
|
||||
'title': (('name', 'brandName'), {str_or_none}),
|
||||
'description': 'tagline',
|
||||
'thumbnail': 'brandLogo',
|
||||
}, get_all=False),
|
||||
**traverse_obj(meta, {
|
||||
'thumbnail': ('brandLogo', {url_or_none}),
|
||||
'description': ('tagline', {str}),
|
||||
'title': ('name', {str}),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class GlobalPlayerLivePlaylistIE(GlobalPlayerBaseIE):
|
||||
_VALID_URL = r'https?://www\.globalplayer\.com/playlists/(?P<id>\w+)'
|
||||
_TESTS = [{
|
||||
# "live playlist"
|
||||
# live playlist
|
||||
'url': 'https://www.globalplayer.com/playlists/8bLk/',
|
||||
'info_dict': {
|
||||
'id': '8bLk',
|
||||
'ext': 'aac',
|
||||
'live_status': 'is_live',
|
||||
'description': 'md5:e10f5e10b01a7f2c14ba815509fbb38d',
|
||||
'thumbnail': 'https://images.globalplayer.com/images/551379?width=450&signature=oMLPZIoi5_dBSHnTMREW0Xg76mA=',
|
||||
'thumbnail': 'md5:391a13cc087b42f626e9e65bbeaf0a11',
|
||||
'description': 'md5:f015f2f6c6f6a807669ebcc9a0ca147c',
|
||||
'title': 're:^Classic FM Hall of Fame.+$',
|
||||
},
|
||||
}]
|
||||
|
||||
def _real_extract(self, url):
|
||||
video_id = self._match_id(url)
|
||||
station = self._get_page_props(url, video_id)['playlistData']
|
||||
stream_url = station['streamUrl']
|
||||
meta = self._get_page_props(url, video_id)['playlistData']
|
||||
|
||||
return {
|
||||
'id': video_id,
|
||||
'url': stream_url,
|
||||
'ext': self._request_ext(stream_url, video_id),
|
||||
'url': meta['streamUrl'],
|
||||
'ext': 'aac',
|
||||
'vcodec': 'none',
|
||||
'id': video_id,
|
||||
'is_live': True,
|
||||
**traverse_obj(station, {
|
||||
'title': 'title',
|
||||
'description': 'description',
|
||||
'thumbnail': 'image',
|
||||
**traverse_obj(meta, {
|
||||
'thumbnail': ('image', {url_or_none}),
|
||||
'description': ('description', {str}),
|
||||
'title': ('title', {str}),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class GlobalPlayerAudioIE(GlobalPlayerBaseIE):
|
||||
_VALID_URL = r'https?://www\.globalplayer\.com/(?:(?P<podcast>podcasts)/|catchup/\w+/\w+/)(?P<id>\w+)/?(?:$|[?#])'
|
||||
_VALID_URL = r'https?://www\.globalplayer\.com/(?P<path>(?P<podcast>podcasts)/|catchup/\w+/\w+/)(?P<id>\w+)/?(?:$|[?#])'
|
||||
_TESTS = [{
|
||||
# podcast
|
||||
'url': 'https://www.globalplayer.com/podcasts/42KuaM/',
|
||||
'playlist_mincount': 5,
|
||||
'playlist_mincount': 2,
|
||||
'info_dict': {
|
||||
'id': '42KuaM',
|
||||
'title': 'Filthy Ritual',
|
||||
'thumbnail': 'md5:60286e7d12d795bd1bbc9efc6cee643e',
|
||||
'categories': ['Society & Culture', 'True Crime'],
|
||||
'uploader': 'Global',
|
||||
'description': 'md5:da5b918eac9ae319454a10a563afacf9',
|
||||
'description': 'md5:17b7b9e3c76b2f4d9e31ccc4f0b66e32',
|
||||
'title': 'Filthy Ritual',
|
||||
},
|
||||
}, {
|
||||
# radio catchup
|
||||
'url': 'https://www.globalplayer.com/catchup/lbc/uk/46vyD7z/',
|
||||
'playlist_mincount': 3,
|
||||
'playlist_mincount': 2,
|
||||
'info_dict': {
|
||||
'id': '46vyD7z',
|
||||
'description': 'Nick Ferrari At Breakfast is Leading Britain\'s Conversation.',
|
||||
'thumbnail': 'md5:664ad62a8fb920a2b8e264ed780eee3d',
|
||||
'description': 'md5:53b6fa5ef71a3cff6628551bcc416384',
|
||||
'title': 'Nick Ferrari',
|
||||
'thumbnail': 'md5:4df24d8a226f5b2508efbcc6ae874ebf',
|
||||
},
|
||||
}]
|
||||
|
||||
def _real_extract(self, url):
|
||||
video_id, podcast = self._match_valid_url(url).group('id', 'podcast')
|
||||
video_id, path, podcast = self._match_valid_url(url).group('id', 'path', 'podcast')
|
||||
props = self._get_page_props(url, video_id)
|
||||
series = props['podcastInfo'] if podcast else props['catchupInfo']
|
||||
if podcast:
|
||||
meta = props['podcastInfo']['metadata']
|
||||
blocks = props['podcastInfo']['blocks'][1]['items']
|
||||
else:
|
||||
catchup = props['catchupShow'] if 'catchupShow' in props else props['catchupInfo']
|
||||
meta = catchup['metadata']
|
||||
blocks = catchup['blocks'][1]['items']
|
||||
|
||||
def _entries():
|
||||
for block in blocks:
|
||||
entry_id = block['id']
|
||||
data = self._download_json(
|
||||
f'https://bff-web-guacamole.musicradio.com/playables/{entry_id}',
|
||||
video_id, f'Downloading metadata JSON for {entry_id}')
|
||||
|
||||
yield {
|
||||
'id': entry_id,
|
||||
'url': self._get_playback_url(data),
|
||||
'vcodec': 'none',
|
||||
'extractor': GlobalPlayerAudioEpisodeIE.IE_NAME,
|
||||
'extractor_key': GlobalPlayerAudioEpisodeIE.ie_key(),
|
||||
'webpage_url': f'https://www.globalplayer.com/{path}episodes/{entry_id}',
|
||||
**traverse_obj(block, {
|
||||
'thumbnail': ('image', 'url', {url_or_none}),
|
||||
'description': ('description', {str}),
|
||||
'title': ('title', {str}),
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
'_type': 'playlist',
|
||||
'id': video_id,
|
||||
'entries': [self._extract_audio(ep, series) for ep in traverse_obj(
|
||||
series, ('episodes', lambda _, v: v['id'] and v['streamUrl']))],
|
||||
'categories': traverse_obj(series, ('categories', ..., 'name')) or None,
|
||||
**traverse_obj(series, {
|
||||
'description': 'description',
|
||||
'thumbnail': 'imageUrl',
|
||||
'title': 'title',
|
||||
'uploader': 'itunesAuthor', # podcasts only
|
||||
'entries': _entries(),
|
||||
**traverse_obj(meta, {
|
||||
'thumbnail': ('image', 'url', {url_or_none}),
|
||||
'description': ('description', {str}),
|
||||
'title': ('title', {str}),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -184,44 +177,42 @@ class GlobalPlayerAudioEpisodeIE(GlobalPlayerBaseIE):
|
||||
_VALID_URL = r'https?://www\.globalplayer\.com/(?:(?P<podcast>podcasts)|catchup/\w+/\w+)/episodes/(?P<id>\w+)/?(?:$|[?#])'
|
||||
_TESTS = [{
|
||||
# podcast
|
||||
'url': 'https://www.globalplayer.com/podcasts/episodes/7DrfNnE/',
|
||||
'url': 'https://www.globalplayer.com/podcasts/episodes/7DrorSc/',
|
||||
'info_dict': {
|
||||
'id': '7DrfNnE',
|
||||
'id': '7DrorSc',
|
||||
'ext': 'mp3',
|
||||
'title': 'Filthy Ritual - Trailer',
|
||||
'description': 'md5:1f1562fd0f01b4773b590984f94223e0',
|
||||
'thumbnail': 'md5:60286e7d12d795bd1bbc9efc6cee643e',
|
||||
'duration': 225.0,
|
||||
'timestamp': 1681254900,
|
||||
'series': 'Filthy Ritual',
|
||||
'series_id': '42KuaM',
|
||||
'upload_date': '20230411',
|
||||
'uploader': 'Global',
|
||||
'description': 'md5:372e5aa2b531f9eba863dfc67d007c1c',
|
||||
'title': 'Filthy Ritual - Trailer',
|
||||
},
|
||||
}, {
|
||||
# radio catchup
|
||||
'url': 'https://www.globalplayer.com/catchup/lbc/uk/episodes/2zGq26Vcv1fCWhddC4JAwETXWe/',
|
||||
# radio catchup - test urls are removed after 7 days
|
||||
'url': 'https://www.globalplayer.com/catchup/lbc/uk/episodes/2zGmrV6DnvogKkNCXkwkQ8HQTA/',
|
||||
'info_dict': {
|
||||
'id': '2zGq26Vcv1fCWhddC4JAwETXWe',
|
||||
'id': '2zGmrV6DnvogKkNCXkwkQ8HQTA',
|
||||
'ext': 'm4a',
|
||||
'timestamp': 1682056800,
|
||||
'series': 'Nick Ferrari',
|
||||
'thumbnail': 'md5:4df24d8a226f5b2508efbcc6ae874ebf',
|
||||
'upload_date': '20230421',
|
||||
'series_id': '46vyD7z',
|
||||
'description': 'Nick Ferrari At Breakfast is Leading Britain\'s Conversation.',
|
||||
'thumbnail': 'md5:664ad62a8fb920a2b8e264ed780eee3d',
|
||||
'description': 'md5:53b6fa5ef71a3cff6628551bcc416384',
|
||||
'title': 'Nick Ferrari',
|
||||
'duration': 10800.0,
|
||||
},
|
||||
}]
|
||||
|
||||
def _real_extract(self, url):
|
||||
video_id, podcast = self._match_valid_url(url).group('id', 'podcast')
|
||||
props = self._get_page_props(url, video_id)
|
||||
episode = props['podcastEpisode'] if podcast else props['catchupEpisode']
|
||||
meta = props['podcastEpisode']['metadata'] if podcast else props['catchupEpisode']['metadata']
|
||||
data = self._download_json(f'https://bff-web-guacamole.musicradio.com/playables/{video_id}', video_id)
|
||||
|
||||
return self._extract_audio(
|
||||
episode, traverse_obj(episode, 'podcast', 'show', expected_type=dict) or {})
|
||||
return {
|
||||
'id': video_id,
|
||||
'url': self._get_playback_url(data),
|
||||
'vcodec': 'none',
|
||||
**traverse_obj(meta, {
|
||||
'thumbnail': ('image', 'url', {url_or_none}),
|
||||
'description': ('description', {str}),
|
||||
'title': ('title', {str}),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class GlobalPlayerVideoIE(GlobalPlayerBaseIE):
|
||||
@@ -231,9 +222,8 @@ class GlobalPlayerVideoIE(GlobalPlayerBaseIE):
|
||||
'info_dict': {
|
||||
'id': '2JsSZ7Gm2uP',
|
||||
'ext': 'mp4',
|
||||
'description': 'md5:6a9f063c67c42f218e42eee7d0298bfd',
|
||||
'thumbnail': 'md5:d4498af48e15aae4839ce77b97d39550',
|
||||
'upload_date': '20230420',
|
||||
'description': 'md5:6a9f063c67c42f218e42eee7d0298bfd',
|
||||
'title': 'Treble Malakai Bayoh sings a sublime Handel aria at Classic FM Live',
|
||||
},
|
||||
}]
|
||||
@@ -245,10 +235,9 @@ class GlobalPlayerVideoIE(GlobalPlayerBaseIE):
|
||||
return {
|
||||
'id': video_id,
|
||||
**traverse_obj(meta, {
|
||||
'url': 'url',
|
||||
'thumbnail': ('image', 'url'),
|
||||
'title': 'title',
|
||||
'upload_date': ('publish_date', {unified_strdate}),
|
||||
'description': 'description',
|
||||
'url': ('url', {url_or_none}),
|
||||
'thumbnail': ('image', 'url', {url_or_none}),
|
||||
'description': ('description', {str}),
|
||||
'title': ('title', {str}),
|
||||
}),
|
||||
}
|
||||
|
||||
+26
-23
@@ -116,41 +116,41 @@ class GoIE(AdobePassIE):
|
||||
'params': {'skip_download': 'm3u8'},
|
||||
'skip': 'This video requires AdobePass MSO credentials',
|
||||
}, {
|
||||
'url': 'https://www.freeform.com/episode/bda0eaf7-761a-4838-aa44-96f794000844/playlist/PL553044961',
|
||||
'url': 'https://www.freeform.com/episode/235128d8-2609-4df4-9874-0b0b687fe9f9/playlist/PL5539647334',
|
||||
'info_dict': {
|
||||
'id': 'VDKA39007340',
|
||||
'id': 'VDKA39623200',
|
||||
'ext': 'mp4',
|
||||
'title': 'Angel\'s Landing',
|
||||
'description': 'md5:91bf084e785c968fab16734df7313446',
|
||||
'title': 'New House / New Rules',
|
||||
'description': 'md5:6f38b4b1649dc9f3a9e7acf911a70056',
|
||||
'age_limit': 14,
|
||||
'duration': 2523,
|
||||
'duration': 2733,
|
||||
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||
'series': 'How I Escaped My Cult',
|
||||
'season': 'Season 1',
|
||||
'season_number': 1,
|
||||
'episode': 'Episode 2',
|
||||
'episode_number': 2,
|
||||
'timestamp': 1740038400.0,
|
||||
'upload_date': '20250220',
|
||||
'series': 'Project Runway',
|
||||
'season': 'Season 21',
|
||||
'season_number': 21,
|
||||
'episode': 'Episode 1',
|
||||
'episode_number': 1,
|
||||
'timestamp': 1754020800,
|
||||
'upload_date': '20250801',
|
||||
},
|
||||
'params': {'skip_download': 'm3u8'},
|
||||
}, {
|
||||
'url': 'https://www.nationalgeographic.com/tv/episode/ca694661-1186-41ae-8089-82f64d69b16d/playlist/PL554408064',
|
||||
'url': 'https://www.nationalgeographic.com/tv/episode/df0e5bd8-f9bb-4c92-9f94-74dec4dad8a7/playlist/PL553044961',
|
||||
'info_dict': {
|
||||
'id': 'VDKA39492078',
|
||||
'id': 'VDKA35475602',
|
||||
'ext': 'mp4',
|
||||
'title': 'Heart of the Emperors',
|
||||
'description': 'md5:4fc50a2878f030bb3a7eac9124dca677',
|
||||
'title': 'The Pol Shebang',
|
||||
'description': 'md5:ccea1210c5ebcdc5c1a435f01bdb8b82',
|
||||
'age_limit': 0,
|
||||
'duration': 2775,
|
||||
'duration': 1323,
|
||||
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||
'series': 'Secrets of the Penguins',
|
||||
'series': 'The Incredible Pol Farm',
|
||||
'season': 'Season 1',
|
||||
'season_number': 1,
|
||||
'episode': 'Episode 1',
|
||||
'episode_number': 1,
|
||||
'timestamp': 1745204400.0,
|
||||
'upload_date': '20250421',
|
||||
'episode': 'Episode 14',
|
||||
'episode_number': 14,
|
||||
'timestamp': 1704614400,
|
||||
'upload_date': '20240107',
|
||||
},
|
||||
'params': {'skip_download': 'm3u8'},
|
||||
}, {
|
||||
@@ -190,7 +190,10 @@ class GoIE(AdobePassIE):
|
||||
|
||||
site_info = self._SITE_INFO[site]
|
||||
brand = site_info['brand']
|
||||
video_data = self._extract_videos(brand, video_id)[0]
|
||||
videos = self._extract_videos(brand, video_id)
|
||||
if not videos:
|
||||
self.report_drm(video_id)
|
||||
video_data = videos[0]
|
||||
video_id = video_data['id']
|
||||
title = video_data['title']
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from .common import InfoExtractor
|
||||
from ..utils import (
|
||||
determine_ext,
|
||||
disposition_filename,
|
||||
extract_attributes,
|
||||
filter_dict,
|
||||
get_element_by_class,
|
||||
@@ -184,9 +185,7 @@ class GoogleDriveIE(InfoExtractor):
|
||||
def add_source_format(urlh):
|
||||
nonlocal title
|
||||
if not title:
|
||||
title = self._search_regex(
|
||||
r'\bfilename="([^"]+)"', urlh.headers.get('Content-Disposition'),
|
||||
'title', default=None)
|
||||
title = disposition_filename(urlh.headers) or None
|
||||
formats.append({
|
||||
# Use redirect URLs as download URLs in order to calculate
|
||||
# correct cookies in _calc_cookies.
|
||||
@@ -198,7 +197,7 @@ class GoogleDriveIE(InfoExtractor):
|
||||
'format_id': 'source',
|
||||
'quality': 1,
|
||||
})
|
||||
if urlh.headers.get('Content-Disposition'):
|
||||
if urlh.headers.get_content_disposition():
|
||||
add_source_format(urlh)
|
||||
else:
|
||||
confirmation_webpage = self._webpage_read_content(
|
||||
@@ -209,7 +208,7 @@ class GoogleDriveIE(InfoExtractor):
|
||||
get_element_html_by_id('download-form', confirmation_webpage) or '').get('action')
|
||||
if confirmed_source_url:
|
||||
urlh = request_source_file(confirmed_source_url, 'confirmed source', data=b'')
|
||||
if urlh and urlh.headers.get('Content-Disposition'):
|
||||
if urlh and urlh.headers.get_content_disposition():
|
||||
add_source_format(urlh)
|
||||
else:
|
||||
self.report_warning(
|
||||
|
||||
@@ -114,7 +114,7 @@ class ITVIE(InfoExtractor):
|
||||
# See: https://github.com/yt-dlp/yt-dlp/issues/986
|
||||
platform_tag_subs, featureset_subs = next(
|
||||
((platform_tag, featureset)
|
||||
for platform_tag, featuresets in reversed(list(variants.items())) for featureset in featuresets
|
||||
for platform_tag, featuresets in reversed(variants.items()) for featureset in featuresets
|
||||
if try_get(featureset, lambda x: x[2]) == 'outband-webvtt'),
|
||||
(None, None))
|
||||
|
||||
@@ -143,7 +143,7 @@ class ITVIE(InfoExtractor):
|
||||
# See: https://github.com/yt-dlp/yt-dlp/issues/986
|
||||
platform_tag_video, featureset_video = next(
|
||||
((platform_tag, featureset)
|
||||
for platform_tag, featuresets in reversed(list(variants.items())) for featureset in featuresets
|
||||
for platform_tag, featuresets in reversed(variants.items()) for featureset in featuresets
|
||||
if set(try_get(featureset, lambda x: x[:2]) or []) == {'aes', 'hls'}),
|
||||
(None, None))
|
||||
if not platform_tag_video or not featureset_video:
|
||||
|
||||
@@ -3,6 +3,7 @@ import functools
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
from .common import InfoExtractor, SearchInfoExtractor
|
||||
from ..networking.exceptions import HTTPError
|
||||
@@ -14,6 +15,7 @@ from ..utils import (
|
||||
extract_attributes,
|
||||
float_or_none,
|
||||
int_or_none,
|
||||
join_nonempty,
|
||||
parse_bitrate,
|
||||
parse_iso8601,
|
||||
parse_qs,
|
||||
@@ -31,8 +33,10 @@ from ..utils import (
|
||||
)
|
||||
from ..utils.traversal import (
|
||||
find_element,
|
||||
find_elements,
|
||||
require,
|
||||
traverse_obj,
|
||||
trim_str,
|
||||
)
|
||||
|
||||
|
||||
@@ -1080,3 +1084,98 @@ class NiconicoLiveIE(NiconicoBaseIE):
|
||||
'thumbnails': thumbnails,
|
||||
'formats': formats,
|
||||
}
|
||||
|
||||
|
||||
class NiconicoChannelIE(NiconicoBaseIE):
|
||||
IE_NAME = 'niconico:channel'
|
||||
|
||||
_PAGE_SIZE = 20
|
||||
_SEARCH_PAGE_SIZE = 32
|
||||
_VALID_URL = [
|
||||
r'https?://ch\.nicovideo\.jp/(?P<id>[\w-]+)/(?P<type>video)/?(?P<slug>continuation|member|pay|so\d{8})?(?:[/?#]|$)',
|
||||
r'https?://ch\.nicovideo\.jp/(?P<type>search)/(?P<id>[^/?#]+)',
|
||||
]
|
||||
_TESTS = [{
|
||||
'url': 'https://ch.nicovideo.jp/higurashianime/video',
|
||||
'info_dict': {
|
||||
'id': 'higurashianime',
|
||||
'title': '「ひぐらしのなく頃に」オフィシャルチャンネル',
|
||||
},
|
||||
'playlist_mincount': 54,
|
||||
}, {
|
||||
'url': 'https://ch.nicovideo.jp/amiami-ssr/video?page=2',
|
||||
'info_dict': {
|
||||
'id': 'amiami-ssr',
|
||||
'title': 'あみあみSSRチャンネル',
|
||||
},
|
||||
'playlist_count': 20,
|
||||
}, {
|
||||
'url': 'https://ch.nicovideo.jp/yukarisama/video/pay',
|
||||
'info_dict': {
|
||||
'id': 'yukarisama',
|
||||
'title': '縁結びのゆかり様',
|
||||
},
|
||||
'playlist_mincount': 16,
|
||||
}, {
|
||||
'url': 'https://ch.nicovideo.jp/mokou1/video/member',
|
||||
'info_dict': {
|
||||
'id': 'mokou1',
|
||||
'title': 'もこう。',
|
||||
},
|
||||
'playlist_mincount': 49,
|
||||
}, {
|
||||
'url': 'https://ch.nicovideo.jp/amiami-ch/video/continuation',
|
||||
'info_dict': {
|
||||
'id': 'amiami-ch',
|
||||
'title': 'あみあみチャンネル',
|
||||
},
|
||||
'playlist_mincount': 1,
|
||||
}, {
|
||||
'url': 'https://ch.nicovideo.jp/search/%E3%81%AF%E3%81%AA%E3%81%BE%E3%81%8D%E3%81%93%E3%82%82%E3%81%A1%E3%81%83?channel_id=ch2585696&type=video',
|
||||
'info_dict': {
|
||||
'id': 'secondshot',
|
||||
'title': 'セカンドショットちゃんねる - はなまきこもちぃ',
|
||||
},
|
||||
'playlist_mincount': 115,
|
||||
}, {
|
||||
'url': 'https://ch.nicovideo.jp/amiami-ch/video/so44060088',
|
||||
'only_matching': True,
|
||||
}]
|
||||
|
||||
def _fetch_page(self, url, playlist_id, page):
|
||||
page += 1
|
||||
webpage = self._download_webpage(
|
||||
url, playlist_id, f'Downloading page {page}', query={'page': page})
|
||||
for url in traverse_obj(webpage, (
|
||||
{find_elements(cls='watchLink', html=True)},
|
||||
..., {extract_attributes}, 'href', {url_or_none},
|
||||
)):
|
||||
yield self.url_result(url, NiconicoIE)
|
||||
|
||||
def _real_extract(self, url):
|
||||
mobj = self._match_valid_url(url)
|
||||
display_id = urllib.parse.unquote(mobj.group('id'))
|
||||
playlist_type = mobj.group('type')
|
||||
|
||||
if playlist_type == 'search':
|
||||
keyword = display_id
|
||||
page_size = self._SEARCH_PAGE_SIZE
|
||||
else:
|
||||
if (slug := mobj.group('slug')) and slug.startswith('so'):
|
||||
return self.url_result(
|
||||
f'{self._BASE_URL}/watch/{slug}', NiconicoIE)
|
||||
keyword = None
|
||||
page_size = self._PAGE_SIZE
|
||||
|
||||
webpage = self._download_webpage(url, display_id)
|
||||
channel_name = traverse_obj(webpage, (
|
||||
{find_element(cls='channel_name')}, {find_element(tag='a', html=True)},
|
||||
{extract_attributes}, 'href', {str}, {trim_str(start='/')}, filter))
|
||||
site_name = self._og_search_property('site_name', webpage, default=None)
|
||||
|
||||
fetch_page = functools.partial(self._fetch_page, url, display_id)
|
||||
page = traverse_obj(parse_qs(url), ('page', -1, {int_or_none}))
|
||||
entries = fetch_page(page - 1) if page else OnDemandPagedList(fetch_page, page_size)
|
||||
|
||||
return self.playlist_result(
|
||||
entries, channel_name, join_nonempty(site_name, keyword, delim=' - '))
|
||||
|
||||
@@ -2,10 +2,8 @@ import urllib.parse
|
||||
|
||||
from .common import InfoExtractor
|
||||
from ..compat import compat_etree_fromstring
|
||||
from ..networking import HEADRequest
|
||||
from ..utils import (
|
||||
ExtractorError,
|
||||
float_or_none,
|
||||
int_or_none,
|
||||
qualities,
|
||||
smuggle_url,
|
||||
@@ -21,7 +19,7 @@ from ..utils.traversal import find_element, traverse_obj
|
||||
class OdnoklassnikiIE(InfoExtractor):
|
||||
_VALID_URL = r'''(?x)
|
||||
https?://
|
||||
(?:(?:www|m|mobile)\.)?
|
||||
(?:www\.)?
|
||||
(?:odnoklassniki|ok)\.ru/
|
||||
(?:
|
||||
video(?P<embed>embed)?/|
|
||||
@@ -34,21 +32,21 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
_EMBED_REGEX = [r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//(?:odnoklassniki|ok)\.ru/videoembed/.+?)\1']
|
||||
_TESTS = [{
|
||||
'note': 'Coub embedded',
|
||||
'url': 'http://ok.ru/video/1484130554189',
|
||||
'url': 'https://ok.ru/video/1484130554189',
|
||||
'info_dict': {
|
||||
'id': '1keok9',
|
||||
'ext': 'mp4',
|
||||
'timestamp': 1545580896,
|
||||
'view_count': int,
|
||||
'thumbnail': r're:^https?://.*\.jpg$',
|
||||
'title': 'Народная забава',
|
||||
'uploader': 'Nevata',
|
||||
'upload_date': '20181223',
|
||||
'age_limit': 0,
|
||||
'uploader_id': 'nevata.s',
|
||||
'view_count': int,
|
||||
'like_count': int,
|
||||
'duration': 8.08,
|
||||
'repost_count': int,
|
||||
'age_limit': 0,
|
||||
'duration': 8.08,
|
||||
'thumbnail': r're:^https?://.*\.jpg$',
|
||||
'timestamp': 1545580896,
|
||||
'upload_date': '20181223',
|
||||
},
|
||||
}, {
|
||||
'note': 'vk.com embedded',
|
||||
@@ -66,28 +64,25 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
},
|
||||
'skip': 'vk extractor error',
|
||||
}, {
|
||||
# metadata in JSON, webm_dash with Firefox UA
|
||||
'url': 'http://ok.ru/video/20079905452',
|
||||
# metadataWebmUrl
|
||||
'url': 'https://ok.ru/video/20079905452',
|
||||
'md5': '8f477d8931c531374a3e36daec617b2c',
|
||||
'info_dict': {
|
||||
'id': '20079905452',
|
||||
'ext': 'webm',
|
||||
'title': 'Культура меняет нас (прекрасный ролик!))',
|
||||
'thumbnail': str,
|
||||
'duration': 100,
|
||||
'upload_date': '20141207',
|
||||
'uploader_id': '330537914540',
|
||||
'uploader': 'Виталий Добровольский',
|
||||
'like_count': int,
|
||||
'age_limit': 0,
|
||||
'duration': 100,
|
||||
'thumbnail': 'https://iv.okcdn.ru/videoPreview?id=50132290220&type=37&idx=13&tkn=pYXx7IXsAq2kUf1W0PpYHMMAnF8&fn=external_8',
|
||||
'upload_date': '20141207',
|
||||
},
|
||||
'params': {
|
||||
'format': 'bv[ext=webm]',
|
||||
'http_headers': {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0'},
|
||||
},
|
||||
}, {
|
||||
# metadataUrl
|
||||
'url': 'http://ok.ru/video/63567059965189-0?fromTime=5',
|
||||
'url': 'https://ok.ru/video/63567059965189-0?fromTime=5',
|
||||
'md5': '2bae2f58eefe1b3d26f3926c4a64d2f3',
|
||||
'info_dict': {
|
||||
'id': '63567059965189-0',
|
||||
@@ -103,6 +98,7 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
'start_time': 5,
|
||||
},
|
||||
'params': {'skip_download': 'm3u8'},
|
||||
'skip': 'Redirects to homepage',
|
||||
}, {
|
||||
# YouTube embed (metadataUrl, provider == USER_YOUTUBE)
|
||||
'url': 'https://ok.ru/video/3952212382174',
|
||||
@@ -132,9 +128,10 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
'playable_in_embed': True,
|
||||
'channel': 'BornToReact',
|
||||
},
|
||||
'skip': 'This video has been removed for violating YouTube\'s Community Guidelines',
|
||||
}, {
|
||||
# YouTube embed (metadata, provider == USER_YOUTUBE, no metadata.movie.title field)
|
||||
'url': 'http://ok.ru/video/62036049272859-0',
|
||||
'url': 'https://ok.ru/video/62036049272859-0',
|
||||
'info_dict': {
|
||||
'id': '62036049272859-0',
|
||||
'ext': 'mp4',
|
||||
@@ -149,16 +146,6 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
'skip_download': True,
|
||||
},
|
||||
'skip': 'Video has not been found',
|
||||
}, {
|
||||
'note': 'Only available in mobile webpage',
|
||||
'url': 'https://m.ok.ru/video/2361249957145',
|
||||
'info_dict': {
|
||||
'id': '2361249957145',
|
||||
'ext': 'mp4',
|
||||
'title': 'Быковское крещение',
|
||||
'duration': 3038.181,
|
||||
'thumbnail': r're:^https?://i\.mycdn\.me/videoPreview\?.+',
|
||||
},
|
||||
}, {
|
||||
'note': 'subtitles',
|
||||
'url': 'https://ok.ru/video/4249587550747',
|
||||
@@ -178,56 +165,47 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
'params': {
|
||||
'skip_download': True,
|
||||
},
|
||||
'skip': 'Video has not been found',
|
||||
}, {
|
||||
'url': 'http://ok.ru/web-api/video/moviePlayer/20079905452',
|
||||
'url': 'https://ok.ru/videoembed/9178551814752',
|
||||
'md5': 'cf97450124817f122136ba09819a60c3',
|
||||
'info_dict': {
|
||||
'id': '9178551814752',
|
||||
'ext': 'mp4',
|
||||
'title': 'В ПАРКЕ ЧАИР - РЕТРО ХИТЫ - СОВЕТСКИЕ ПЕСНИ',
|
||||
'like_count': int,
|
||||
'duration': 3260,
|
||||
'thumbnail': 'https://iv.okcdn.ru/videoPreview?id=8382455220832&type=32&idx=12&tkn=FpC95WqL8aCthA4GNKSHpoEMgg4&fn=external_8',
|
||||
},
|
||||
}, {
|
||||
'url': 'https://ok.ru/web-api/video/moviePlayer/20079905452',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'http://www.ok.ru/video/20648036891',
|
||||
'url': 'https://www.ok.ru/video/20648036891',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'http://www.ok.ru/videoembed/20648036891',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'http://m.ok.ru/video/20079905452',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'http://mobile.ok.ru/video/20079905452',
|
||||
'url': 'https://www.ok.ru/videoembed/20648036891',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'https://www.ok.ru/live/484531969818',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'https://m.ok.ru/dk?st.cmd=movieLayer&st.discId=863789452017&st.retLoc=friend&st.rtu=%2Fdk%3Fst.cmd%3DfriendMovies%26st.mode%3Down%26st.mrkId%3D%257B%2522uploadedMovieMarker%2522%253A%257B%2522marker%2522%253A%25221519410114503%2522%252C%2522hasMore%2522%253Atrue%257D%252C%2522sharedMovieMarker%2522%253A%257B%2522marker%2522%253Anull%252C%2522hasMore%2522%253Afalse%257D%257D%26st.friendId%3D561722190321%26st.frwd%3Don%26_prevCmd%3DfriendMovies%26tkn%3D7257&st.discType=MOVIE&st.mvId=863789452017&_prevCmd=friendMovies&tkn=3648#lst#',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
# Paid video
|
||||
'url': 'https://ok.ru/video/954886983203',
|
||||
'only_matching': True,
|
||||
}, {
|
||||
'url': 'https://ok.ru/videoembed/2932705602075',
|
||||
'info_dict': {
|
||||
'id': '2932705602075',
|
||||
'ext': 'mp4',
|
||||
'thumbnail': 'https://i.mycdn.me/videoPreview?id=1369902483995&type=37&idx=2&tkn=fqlnoQD_xwq5ovIlKfgNyU08qmM&fn=external_8',
|
||||
'title': 'Boosty для тебя!',
|
||||
'uploader_id': '597811038747',
|
||||
'like_count': 0,
|
||||
'duration': 35,
|
||||
},
|
||||
}]
|
||||
|
||||
_WEBPAGE_TESTS = [{
|
||||
'url': 'https://boosty.to/ikakprosto/posts/56cedaca-b56a-4dfd-b3ed-98c79cfa0167',
|
||||
'url': 'https://www.liveinternet.ru/users/5932706/post515277341/',
|
||||
'md5': 'cf97450124817f122136ba09819a60c3',
|
||||
'info_dict': {
|
||||
'id': '3950343629563',
|
||||
'id': '9178551814752',
|
||||
'ext': 'mp4',
|
||||
'thumbnail': 'https://i.mycdn.me/videoPreview?id=2776238394107&type=37&idx=11&tkn=F3ejkUFcpuI4DnMRxrDGcH5YcmM&fn=external_8',
|
||||
'title': 'Заяц Бусти.mp4',
|
||||
'uploader_id': '571368965883',
|
||||
'like_count': 0,
|
||||
'duration': 10444,
|
||||
'title': 'В ПАРКЕ ЧАИР - РЕТРО ХИТЫ - СОВЕТСКИЕ ПЕСНИ',
|
||||
'like_count': int,
|
||||
'duration': 3260,
|
||||
'thumbnail': 'https://iv.okcdn.ru/videoPreview?id=8382455220832&type=32&idx=12&tkn=FpC95WqL8aCthA4GNKSHpoEMgg4&fn=external_8',
|
||||
},
|
||||
'skip': 'Site no longer embeds',
|
||||
}]
|
||||
|
||||
def _clear_cookies(self, cdn_url):
|
||||
@@ -242,16 +220,6 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
yield smuggle_url(x, {'referrer': url})
|
||||
|
||||
def _real_extract(self, url):
|
||||
try:
|
||||
return self._extract_desktop(url)
|
||||
except ExtractorError as e:
|
||||
try:
|
||||
return self._extract_mobile(url)
|
||||
except ExtractorError:
|
||||
# error message of desktop webpage is in English
|
||||
raise e
|
||||
|
||||
def _extract_desktop(self, url):
|
||||
start_time = int_or_none(urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(url).query).get('fromTime', [None])[0])
|
||||
|
||||
@@ -261,7 +229,6 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
|
||||
webpage = self._download_webpage(
|
||||
f'https://ok.ru/{mode}/{video_id}', video_id,
|
||||
note='Downloading desktop webpage',
|
||||
headers={'Referer': smuggled['referrer']} if smuggled.get('referrer') else {})
|
||||
|
||||
error = traverse_obj(webpage, {find_element(cls='vp_video_stub_txt')})
|
||||
@@ -287,9 +254,7 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
flashvars = player['flashvars']
|
||||
|
||||
metadata = flashvars.get('metadata')
|
||||
if metadata:
|
||||
metadata = self._parse_json(metadata, video_id)
|
||||
else:
|
||||
if not metadata:
|
||||
data = {}
|
||||
st_location = flashvars.get('location')
|
||||
if st_location:
|
||||
@@ -302,7 +267,7 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
movie = metadata['movie']
|
||||
|
||||
# Some embedded videos may not contain title in movie dict (e.g.
|
||||
# http://ok.ru/video/62036049272859-0) thus we allow missing title
|
||||
# https://ok.ru/video/62036049272859-0) thus we allow missing title
|
||||
# here and it's going to be extracted later by an extractor that
|
||||
# will process the actual embed.
|
||||
provider = metadata.get('provider')
|
||||
@@ -416,36 +381,3 @@ class OdnoklassnikiIE(InfoExtractor):
|
||||
|
||||
info['formats'] = formats
|
||||
return info
|
||||
|
||||
def _extract_mobile(self, url):
|
||||
video_id = self._match_id(url)
|
||||
|
||||
webpage = self._download_webpage(
|
||||
f'https://m.ok.ru/video/{video_id}', video_id,
|
||||
note='Downloading mobile webpage')
|
||||
|
||||
error = self._search_regex(
|
||||
r'видео</a>\s*<div\s+class="empty">(.+?)</div>',
|
||||
webpage, 'error', default=None)
|
||||
if error:
|
||||
raise ExtractorError(error, expected=True)
|
||||
|
||||
json_data = self._search_regex(
|
||||
r'data-video="(.+?)"', webpage, 'json data')
|
||||
json_data = self._parse_json(unescapeHTML(json_data), video_id) or {}
|
||||
|
||||
redirect_url = self._request_webpage(HEADRequest(
|
||||
json_data['videoSrc']), video_id, 'Requesting download URL').url
|
||||
self._clear_cookies(redirect_url)
|
||||
|
||||
return {
|
||||
'id': video_id,
|
||||
'title': json_data.get('videoName'),
|
||||
'duration': float_or_none(json_data.get('videoDuration'), scale=1000),
|
||||
'thumbnail': json_data.get('videoPosterSrc'),
|
||||
'formats': [{
|
||||
'format_id': 'mobile',
|
||||
'url': redirect_url,
|
||||
'ext': 'mp4',
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ from ..utils.traversal import (
|
||||
)
|
||||
|
||||
|
||||
class ShowRoomLiveIE(InfoExtractor):
|
||||
class ShowRoomBaseIE(InfoExtractor):
|
||||
_API_BASE = 'https://www.showroom-live.com/api'
|
||||
|
||||
|
||||
class ShowRoomLiveIE(ShowRoomBaseIE):
|
||||
IE_NAME = 'showroom:live'
|
||||
IE_DESC = 'SHOWROOM'
|
||||
|
||||
@@ -29,19 +33,11 @@ class ShowRoomLiveIE(InfoExtractor):
|
||||
|
||||
def _real_extract(self, url):
|
||||
broadcaster_id = self._match_id(url)
|
||||
webpage = self._download_webpage(
|
||||
url, broadcaster_id, headers={'Accept-Language': 'ja'})
|
||||
nuxt_data = self._search_nuxt_json(webpage, broadcaster_id)['data']
|
||||
|
||||
cookies = self._get_cookies(url)
|
||||
sr_id = traverse_obj(cookies, ('sr_id', 'value', {str}, filter))
|
||||
if not sr_id:
|
||||
self.raise_login_required()
|
||||
|
||||
room_profile = traverse_obj(nuxt_data, (
|
||||
f'roomProfile-{broadcaster_id}-{sr_id}', {dict}))
|
||||
start_timestamp = traverse_obj(room_profile, ('current_live_started_at', {int_or_none}))
|
||||
is_live = traverse_obj(room_profile, ('is_onlive', {bool}))
|
||||
room_status = self._download_json(
|
||||
f'{self._API_BASE}/room/status', broadcaster_id,
|
||||
query={'room_url_key': broadcaster_id})
|
||||
start_timestamp = traverse_obj(room_status, ('started_at', {int_or_none}))
|
||||
is_live = traverse_obj(room_status, ('is_live', {bool}))
|
||||
|
||||
if not is_live:
|
||||
if start_timestamp:
|
||||
@@ -59,12 +55,15 @@ class ShowRoomLiveIE(InfoExtractor):
|
||||
}
|
||||
raise UserNotLive(video_id=broadcaster_id)
|
||||
|
||||
room_id = traverse_obj(room_profile, ('room_id', {str_or_none}))
|
||||
room_id = traverse_obj(room_status, ('room_id', {str_or_none}))
|
||||
room_profile = self._download_json(
|
||||
f'{self._API_BASE}/room/profile',
|
||||
broadcaster_id, query={'room_id': room_id})
|
||||
room_name = traverse_obj(room_profile, (
|
||||
('room_name', 'main_name'), {clean_html}, filter, any))
|
||||
|
||||
streaming_url_list = self._download_json(
|
||||
'https://www.showroom-live.com/api/live/streaming_url',
|
||||
f'{self._API_BASE}/live/streaming_url',
|
||||
broadcaster_id, query={'room_id': room_id})
|
||||
m3u8_url = traverse_obj(streaming_url_list, (
|
||||
'streaming_url_list', lambda _, v: v['type'] == 'hls_all',
|
||||
@@ -84,13 +83,13 @@ class ShowRoomLiveIE(InfoExtractor):
|
||||
'description': ('description', {clean_html}, filter),
|
||||
'genres': ('genre_name', {clean_html}, filter, all, filter),
|
||||
'tags': ('live_tags', ..., 'name', {clean_html}, filter, all, filter),
|
||||
'thumbnail': ('image_square', {url_or_none}),
|
||||
'thumbnail': (('image_square', 'image'), {url_or_none}, any),
|
||||
'view_count': ('view_num', {int_or_none}),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class ShowRoomVodIE(InfoExtractor):
|
||||
class ShowRoomVodIE(ShowRoomBaseIE):
|
||||
IE_NAME = 'showroom:vod'
|
||||
|
||||
_VALID_URL = r'https?://(?:www\.)?showroom-live\.com/episode/watch\?(?:[^#]+&)?id=(?P<id>\w+)'
|
||||
@@ -111,7 +110,7 @@ class ShowRoomVodIE(InfoExtractor):
|
||||
{extract_attributes}, 'data-episode', {json.loads}, {dict}))
|
||||
|
||||
streaming_url_list = self._download_json(
|
||||
'https://www.showroom-live.com/api/episode/streaming_url',
|
||||
f'{self._API_BASE}/episode/streaming_url',
|
||||
episode_id, query={'episode_id': episode_id})
|
||||
m3u8_url = traverse_obj(streaming_url_list, (
|
||||
'streaming_url_list', 'hls_all',
|
||||
|
||||
@@ -46,7 +46,7 @@ class TedTalkIE(TedBaseIE):
|
||||
webpage = self._download_webpage(url, display_id)
|
||||
talk_info = self._search_nextjs_data(webpage, display_id)['props']['pageProps']['videoData']
|
||||
video_id = talk_info['id']
|
||||
player_data = self._parse_json(talk_info.get('playerData'), video_id)
|
||||
player_data = talk_info.get('videoPlayerData') or {}
|
||||
|
||||
http_url = None
|
||||
formats, subtitles = [], {}
|
||||
@@ -193,8 +193,8 @@ class TedPlaylistIE(TedBaseIE):
|
||||
'url': 'https://www.ted.com/playlists/171/the_most_popular_talks_of_all',
|
||||
'info_dict': {
|
||||
'id': '171',
|
||||
'title': 'The most popular talks of all time',
|
||||
'description': 'md5:d2f22831dc86c7040e733a3cb3993d78',
|
||||
'title': 'The most popular TED Talks of all time',
|
||||
'description': 'md5:5346ef094754d2edd7e1a4cd3a166168',
|
||||
},
|
||||
'playlist_mincount': 25,
|
||||
}]
|
||||
|
||||
+24
-36
@@ -1,23 +1,17 @@
|
||||
import re
|
||||
|
||||
from .common import InfoExtractor
|
||||
from ..networking import Request
|
||||
from ..utils import (
|
||||
ExtractorError,
|
||||
int_or_none,
|
||||
js_to_json,
|
||||
strip_or_none,
|
||||
traverse_obj,
|
||||
url_or_none,
|
||||
urlencode_postdata,
|
||||
)
|
||||
from ..utils.traversal import require, traverse_obj
|
||||
|
||||
|
||||
class TubiTvIE(InfoExtractor):
|
||||
IE_NAME = 'tubitv'
|
||||
_VALID_URL = r'https?://(?:www\.)?tubitv\.com/(?:[a-z]{2}-[a-z]{2}/)?(?P<type>video|movies|tv-shows)/(?P<id>\d+)'
|
||||
_LOGIN_URL = 'http://tubitv.com/login'
|
||||
_NETRC_MACHINE = 'tubitv'
|
||||
_TESTS = [{
|
||||
'url': 'https://tubitv.com/movies/100004539/the-39-steps',
|
||||
'info_dict': {
|
||||
@@ -27,7 +21,7 @@ class TubiTvIE(InfoExtractor):
|
||||
'description': 'md5:bb2f2dd337f0dc58c06cb509943f54c8',
|
||||
'uploader_id': 'abc2558d54505d4f0f32be94f2e7108c',
|
||||
'release_year': 1935,
|
||||
'thumbnail': r're:^https?://.+\.(jpe?g|png)$',
|
||||
'thumbnail': r're:^https?://canvas-lb\.tubitv\.com/.+',
|
||||
'duration': 5187,
|
||||
},
|
||||
'params': {'skip_download': 'm3u8'},
|
||||
@@ -44,7 +38,7 @@ class TubiTvIE(InfoExtractor):
|
||||
'season_number': 1,
|
||||
'uploader_id': '2a9273e728c510d22aa5c57d0646810b',
|
||||
'release_year': 2011,
|
||||
'thumbnail': r're:^https?://.+\.(jpe?g|png)$',
|
||||
'thumbnail': r're:^https?://canvas-lb\.tubitv\.com/.+',
|
||||
'duration': 1376,
|
||||
},
|
||||
'params': {'skip_download': 'm3u8'},
|
||||
@@ -82,24 +76,11 @@ class TubiTvIE(InfoExtractor):
|
||||
_UNPLAYABLE_FORMATS = ('hlsv6_widevine', 'hlsv6_widevine_nonclearlead', 'hlsv6_playready_psshv0',
|
||||
'hlsv6_fairplay', 'dash_widevine', 'dash_widevine_nonclearlead')
|
||||
|
||||
def _perform_login(self, username, password):
|
||||
self.report_login()
|
||||
form_data = {
|
||||
'username': username,
|
||||
'password': password,
|
||||
}
|
||||
payload = urlencode_postdata(form_data)
|
||||
request = Request(self._LOGIN_URL, payload)
|
||||
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
login_page = self._download_webpage(
|
||||
request, None, False, 'Wrong login info')
|
||||
if not re.search(r'id="tubi-logout"', login_page):
|
||||
raise ExtractorError(
|
||||
'Login failed (invalid username/password)', expected=True)
|
||||
|
||||
def _real_extract(self, url):
|
||||
video_id, video_type = self._match_valid_url(url).group('id', 'type')
|
||||
webpage = self._download_webpage(f'https://tubitv.com/{video_type}/{video_id}/', video_id)
|
||||
webpage = self._download_webpage(
|
||||
f'https://tubitv.com/{video_type}/{video_id}/', video_id,
|
||||
headers=self.geo_verification_headers())
|
||||
video_data = self._search_json(
|
||||
r'window\.__data\s*=', webpage, 'data', video_id,
|
||||
transform_source=js_to_json)['video']['byId'][video_id]
|
||||
@@ -113,7 +94,11 @@ class TubiTvIE(InfoExtractor):
|
||||
if resource_type == 'dash':
|
||||
formats.extend(self._extract_mpd_formats(manifest_url, video_id, mpd_id=resource_type, fatal=False))
|
||||
elif resource_type in ('hlsv3', 'hlsv6'):
|
||||
formats.extend(self._extract_m3u8_formats(manifest_url, video_id, 'mp4', m3u8_id=resource_type, fatal=False))
|
||||
fmts = self._extract_m3u8_formats(manifest_url, video_id, 'mp4', m3u8_id=resource_type, fatal=False)
|
||||
for fmt in fmts:
|
||||
if 'Audio Description' in fmt.get('format_note', ''):
|
||||
fmt['language_preference'] = -10
|
||||
formats.extend(fmts)
|
||||
elif resource_type in self._UNPLAYABLE_FORMATS:
|
||||
drm_formats = True
|
||||
else:
|
||||
@@ -162,31 +147,34 @@ class TubiTvShowIE(InfoExtractor):
|
||||
'id': 'the-joy-of-painting-with-bob-ross',
|
||||
},
|
||||
}, {
|
||||
'url': 'https://tubitv.com/series/2311/the-saddle-club/season-1',
|
||||
'url': 'https://tubitv.com/series/300000435/the-saddle-club/season-1',
|
||||
'playlist_count': 26,
|
||||
'info_dict': {
|
||||
'id': 'the-saddle-club-season-1',
|
||||
},
|
||||
}, {
|
||||
'url': 'https://tubitv.com/series/2311/the-saddle-club/season-3',
|
||||
'playlist_count': 19,
|
||||
'url': 'https://tubitv.com/series/300000435/the-saddle-club/season-2',
|
||||
'playlist_count': 26,
|
||||
'info_dict': {
|
||||
'id': 'the-saddle-club-season-3',
|
||||
'id': 'the-saddle-club-season-2',
|
||||
},
|
||||
}, {
|
||||
'url': 'https://tubitv.com/series/2311/the-saddle-club/',
|
||||
'playlist_mincount': 71,
|
||||
'url': 'https://tubitv.com/series/300000435/the-saddle-club/',
|
||||
'playlist_mincount': 52,
|
||||
'info_dict': {
|
||||
'id': 'the-saddle-club',
|
||||
},
|
||||
}]
|
||||
|
||||
def _entries(self, show_url, playlist_id, selected_season):
|
||||
webpage = self._download_webpage(show_url, playlist_id)
|
||||
webpage = self._download_webpage(show_url, playlist_id, headers=self.geo_verification_headers())
|
||||
|
||||
data = self._search_json(
|
||||
r'window\.__REACT_QUERY_STATE__\s*=', webpage, 'data', playlist_id,
|
||||
transform_source=js_to_json)['queries'][0]['state']['data']
|
||||
react_query_state = self._search_json(
|
||||
r'window\.__REACT_QUERY_STATE__\s*=', webpage,
|
||||
'react query state', playlist_id, transform_source=js_to_json)
|
||||
data = traverse_obj(react_query_state, (
|
||||
'queries', lambda _, v: v['state']['data']['seasons'][0],
|
||||
'state', 'data', any, {require('season data')}))
|
||||
|
||||
# v['number'] is already a decimal string, but stringify to protect against API changes
|
||||
path = [lambda _, v: str(v['number']) == selected_season] if selected_season else [..., {dict}]
|
||||
|
||||
@@ -44,7 +44,7 @@ class TwitchBaseIE(InfoExtractor):
|
||||
'CollectionSideBar': '016e1e4ccee0eb4698eb3bf1a04dc1c077fb746c78c82bac9a8f0289658fbd1a',
|
||||
'FilterableVideoTower_Videos': '67004f7881e65c297936f32c75246470629557a393788fb5a69d6d9a25a8fd5f',
|
||||
'ClipsCards__User': '1cd671bfa12cec480499c087319f26d21925e9695d1f80225aae6a4354f23088',
|
||||
'ShareClipRenderStatus': '0a02bb974443b576f5579aab0fef1d4b7f44e58a8a256f0c5adfead0db70640f',
|
||||
'ShareClipRenderStatus': '2db6a3b20eabf510bd3cf465ae2408834b59eb6b8af89ca73ab1486cacecfb63',
|
||||
'ChannelCollectionsContent': '5247910a19b1cd2b760939bf4cba4dcbd3d13bdf8c266decd16956f6ef814077',
|
||||
'StreamMetadata': 'ad022ca32220d5523d03a23cbcb5beaa1e0999889c1f8f78f9f2520dafb5cae6',
|
||||
'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01',
|
||||
|
||||
@@ -125,6 +125,7 @@ INNERTUBE_CLIENTS = {
|
||||
'client': {
|
||||
'clientName': 'WEB_EMBEDDED_PLAYER',
|
||||
'clientVersion': '2.20260708.00.00',
|
||||
'userAgent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15,gzip(gfe)',
|
||||
},
|
||||
},
|
||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 56,
|
||||
@@ -363,6 +364,21 @@ INNERTUBE_CLIENTS = {
|
||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
|
||||
'SUPPORTS_COOKIES': True,
|
||||
},
|
||||
'tv_samsung': {
|
||||
'INNERTUBE_CONTEXT': {
|
||||
'client': {
|
||||
'clientName': 'TVHTML5',
|
||||
'clientVersion': '5.20260707',
|
||||
'deviceMake': 'Samsung',
|
||||
'deviceModel': 'UKS9800',
|
||||
'userAgent': 'Mozilla/5.0 (SMART-TV; Linux; Tizen 2.4.0) AppleWebKit/538.1 (KHTML, like Gecko) Version/2.4.0 TV Safari/538.1',
|
||||
'osName': 'Tizen',
|
||||
'osVersion': '2.4.0',
|
||||
},
|
||||
},
|
||||
'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
|
||||
'SUPPORTS_COOKIES': True,
|
||||
},
|
||||
'tv_simply': {
|
||||
'INNERTUBE_CONTEXT': {
|
||||
'client': {
|
||||
|
||||
@@ -140,11 +140,11 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
|
||||
_RETURN_TYPE = 'video' # XXX: How to handle multifeed?
|
||||
|
||||
_SUBTITLE_FORMATS = ('json3', 'srv1', 'srv2', 'srv3', 'ttml', 'srt', 'vtt')
|
||||
_DEFAULT_CLIENTS = ('visionos', 'web')
|
||||
_DEFAULT_CLIENTS = ('tv_samsung', 'visionos', 'web')
|
||||
_DEFAULT_JSLESS_CLIENTS = ('visionos',)
|
||||
_DEFAULT_AUTHED_CLIENTS = ('web_embedded', 'tv_downgraded', 'web')
|
||||
_DEFAULT_AUTHED_CLIENTS = ('web_embedded', 'tv_samsung', 'web')
|
||||
# Premium does not require POT (except for subtitles)
|
||||
_DEFAULT_PREMIUM_CLIENTS = ('web_creator', 'tv_downgraded', 'web')
|
||||
_DEFAULT_PREMIUM_CLIENTS = ('web_creator', 'tv_samsung', 'web')
|
||||
_WEBPAGE_CLIENTS = ('web', 'web_safari')
|
||||
_DEFAULT_WEBPAGE_CLIENT = 'web'
|
||||
|
||||
@@ -3151,7 +3151,6 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
|
||||
and any(p.is_available() for p in self._jsc_director.providers.values())
|
||||
):
|
||||
append_client('web_embedded')
|
||||
append_client('tv_downgraded')
|
||||
|
||||
# web_embedded can work around age-gate and age-verification for some embeddable videos
|
||||
if self._is_agegated(pr) and variant != 'web_embedded':
|
||||
@@ -3640,7 +3639,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
|
||||
f['source_preference'] = -1
|
||||
|
||||
# Deprioritize since its pre-merged m3u8 formats may have lower quality audio streams
|
||||
if client_name == 'web_safari' and proto == 'hls' and live_status != 'is_live':
|
||||
if client_name in ('web_safari', 'web_embedded') and proto == 'hls' and live_status != 'is_live':
|
||||
f['source_preference'] -= 1
|
||||
|
||||
if missing_pot:
|
||||
|
||||
@@ -76,7 +76,7 @@ class YoutubeIEContentProviderLogger(IEContentProviderLogger):
|
||||
if self.log_level <= self.LogLevel.ERROR:
|
||||
self.__ie._downloader.report_error(
|
||||
self._format_msg(message), is_error=False,
|
||||
tb=''.join(traceback.format_exception(None, cause, cause.__traceback__)) if cause else None)
|
||||
tb=''.join(traceback.format_exception(cause)) if cause else None)
|
||||
|
||||
|
||||
class PoTokenCache:
|
||||
|
||||
@@ -52,25 +52,27 @@ def ssl_load_windows_store_certs(ssl_context, storename):
|
||||
|
||||
def make_socks_proxy_opts(socks_proxy):
|
||||
url_components = urllib.parse.urlparse(socks_proxy)
|
||||
if url_components.scheme.lower() == 'socks5':
|
||||
scheme = url_components.scheme.lower()
|
||||
|
||||
if scheme == 'socks5':
|
||||
socks_type = ProxyType.SOCKS5
|
||||
rdns = False
|
||||
elif url_components.scheme.lower() == 'socks5h':
|
||||
elif scheme == 'socks5h':
|
||||
socks_type = ProxyType.SOCKS5
|
||||
rdns = True
|
||||
elif url_components.scheme.lower() == 'socks4':
|
||||
elif scheme == 'socks4':
|
||||
socks_type = ProxyType.SOCKS4
|
||||
rdns = False
|
||||
elif url_components.scheme.lower() == 'socks4a':
|
||||
elif scheme == 'socks4a':
|
||||
socks_type = ProxyType.SOCKS4A
|
||||
rdns = True
|
||||
else:
|
||||
raise ValueError(f'Unknown SOCKS proxy version: {url_components.scheme.lower()}')
|
||||
raise ValueError(f'Unknown SOCKS proxy version: {scheme}')
|
||||
|
||||
def unquote_if_non_empty(s):
|
||||
if not s:
|
||||
return s
|
||||
return urllib.parse.unquote_plus(s)
|
||||
return urllib.parse.unquote(s)
|
||||
return {
|
||||
'proxytype': socks_type,
|
||||
'addr': url_components.hostname,
|
||||
@@ -108,8 +110,6 @@ def make_ssl_context(
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.check_hostname = verify
|
||||
context.verify_mode = ssl.CERT_REQUIRED if verify else ssl.CERT_NONE
|
||||
# OpenSSL 1.1.1+ Python 3.8+ keylog file
|
||||
if hasattr(context, 'keylog_filename'):
|
||||
context.keylog_filename = os.environ.get('SSLKEYLOGFILE') or None
|
||||
|
||||
# Some servers may reject requests if ALPN extension is not sent. See:
|
||||
|
||||
@@ -123,7 +123,7 @@ class RequestsResponseAdapter(Response):
|
||||
# Work around issue with `.read(amt)` then `.read()`
|
||||
# See: https://github.com/urllib3/urllib3/issues/3636
|
||||
if amt is None:
|
||||
# compat: py3.9: Python 3.9 preallocates the whole read buffer, read in chunks
|
||||
# Read in chunks to avoid preallocating a large buffer
|
||||
read_chunk = functools.partial(self.fp.read, 1 << 20, decode_content=True)
|
||||
return b''.join(iter(read_chunk, b''))
|
||||
# Interact with urllib3 response directly.
|
||||
|
||||
@@ -161,17 +161,7 @@ class HTTPHandler(urllib.request.AbstractHTTPHandler):
|
||||
if decoded_response is not None:
|
||||
resp = urllib.request.addinfourl(io.BytesIO(decoded_response), old_resp.headers, old_resp.url, old_resp.code)
|
||||
resp.msg = old_resp.msg
|
||||
# Percent-encode redirect URL of Location HTTP header to satisfy RFC 3986 (see
|
||||
# https://github.com/ytdl-org/youtube-dl/issues/6457).
|
||||
if 300 <= resp.code < 400:
|
||||
location = resp.headers.get('Location')
|
||||
if location:
|
||||
# As of RFC 2616 default charset is iso-8859-1 that is respected by Python 3
|
||||
location = location.encode('iso-8859-1').decode()
|
||||
location_escaped = normalize_url(location)
|
||||
if location != location_escaped:
|
||||
del resp.headers['Location']
|
||||
resp.headers['Location'] = location_escaped
|
||||
|
||||
return resp
|
||||
|
||||
https_request = http_request
|
||||
@@ -296,13 +286,9 @@ class UrllibResponseAdapter(Response):
|
||||
"""
|
||||
|
||||
def __init__(self, res: http.client.HTTPResponse | urllib.response.addinfourl):
|
||||
# addinfourl: In Python 3.9+, .status was introduced and .getcode() was deprecated [1]
|
||||
# HTTPResponse: .getcode() was deprecated, .status always existed [2]
|
||||
# 1. https://docs.python.org/3/library/urllib.request.html#urllib.response.addinfourl.getcode
|
||||
# 2. https://docs.python.org/3.10/library/http.client.html#http.client.HTTPResponse.status
|
||||
super().__init__(
|
||||
fp=res, headers=res.headers, url=res.url,
|
||||
status=getattr(res, 'status', None) or res.getcode(), reason=getattr(res, 'reason', None))
|
||||
status=res.status, reason=getattr(res, 'reason', None))
|
||||
|
||||
def read(self, amt=None):
|
||||
if self.closed:
|
||||
@@ -317,7 +303,9 @@ class UrllibResponseAdapter(Response):
|
||||
# urllib's addinfourl does not close the underlying fp automatically when fully read
|
||||
if isinstance(underlying, io.BytesIO):
|
||||
# data URLs or in-memory responses (e.g. gzip/deflate/brotli decoded)
|
||||
if underlying.tell() >= len(underlying.getbuffer()):
|
||||
with underlying.getbuffer() as view:
|
||||
fully_read = underlying.tell() >= len(view)
|
||||
if fully_read:
|
||||
self.close()
|
||||
elif isinstance(underlying, io.BufferedReader) and amt is None:
|
||||
# file URLs.
|
||||
|
||||
@@ -164,6 +164,11 @@ class WebsocketsRH(WebSocketRequestHandler):
|
||||
user_agent_header=None,
|
||||
ssl=ssl_ctx if wsuri.secure else None,
|
||||
close_timeout=0, # not ideal, but prevents yt-dlp hanging
|
||||
# Workaround for websockets>=17.1 support:
|
||||
# connect() is intended to be used as a context manager and other usage has been deprecated.
|
||||
# The `legacy` flag was added in 17.1 for backwards compat and has not yet been deprecated.
|
||||
# Ref: https://github.com/python-websockets/websockets/commit/1f7f0e537233ac82e4ca51921678963c696c8c08
|
||||
**({'legacy': True} if websockets_version >= (17, 1) else {}),
|
||||
)
|
||||
return WebsocketsResponseAdapter(conn, url=request.url)
|
||||
|
||||
|
||||
+16
-5
@@ -227,6 +227,10 @@ def _make_label(origin, tag, version=None):
|
||||
return f'{origin}@{tag}'
|
||||
|
||||
|
||||
class _GitHubError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateInfo:
|
||||
"""
|
||||
@@ -319,7 +323,14 @@ class Updater:
|
||||
path = 'latest/download' if tag == 'latest' else f'download/{tag}'
|
||||
url = f'https://github.com/{self.requested_repo}/releases/{path}/{name}'
|
||||
self.ydl.write_debug(f'Downloading {name} from {url}')
|
||||
return self.ydl.urlopen(url).read()
|
||||
response = self.ydl.urlopen(url)
|
||||
# GitHub may send an error webpage response with HTTP status 200
|
||||
# See https://github.com/yt-dlp/yt-dlp/issues/17550
|
||||
prefix = response.read(512)
|
||||
if b'<!DOCTYPE html>' in prefix:
|
||||
raise _GitHubError('got error webpage instead of release asset')
|
||||
|
||||
return prefix + response.read()
|
||||
|
||||
def _call_api(self, tag):
|
||||
tag = f'tags/{tag}' if tag != 'latest' else tag
|
||||
@@ -358,7 +369,7 @@ class Updater:
|
||||
for tag in source_tags:
|
||||
try:
|
||||
return self._download_asset('_update_spec', tag=tag).decode()
|
||||
except network_exceptions as error:
|
||||
except (_GitHubError, *network_exceptions) as error:
|
||||
if isinstance(error, HTTPError) and error.status == 404:
|
||||
continue
|
||||
self._report_network_error(f'fetch update spec: {error}')
|
||||
@@ -462,7 +473,7 @@ class Updater:
|
||||
if not is_non_updateable():
|
||||
try:
|
||||
hashes = self._download_asset('SHA2-256SUMS', result_tag)
|
||||
except network_exceptions as error:
|
||||
except (_GitHubError, *network_exceptions) as error:
|
||||
if not isinstance(error, HTTPError) or error.status != 404:
|
||||
self._report_network_error(f'fetch checksums: {error}')
|
||||
return None
|
||||
@@ -525,7 +536,7 @@ class Updater:
|
||||
|
||||
try:
|
||||
newcontent = self._download_asset(update_info.binary_name, update_info.tag)
|
||||
except network_exceptions as e:
|
||||
except (_GitHubError, *network_exceptions) as e:
|
||||
if isinstance(e, HTTPError) and e.status == 404:
|
||||
return self._report_error(
|
||||
f'The requested tag {self.requested_repo}@{update_info.tag} does not exist', True)
|
||||
@@ -609,7 +620,7 @@ class Updater:
|
||||
self.ydl._download_retcode = 100
|
||||
|
||||
def _report_permission_error(self, file):
|
||||
self._report_error(f'Unable to write to {file}; try running as administrator', True)
|
||||
self._report_error(f'Insufficient permissions to write to {file}', True)
|
||||
|
||||
def _report_network_error(self, action, delim=';', tag=None):
|
||||
if not tag:
|
||||
|
||||
+34
-30
@@ -238,11 +238,9 @@ def find_xpath_attr(node, xpath, key, val=None):
|
||||
expr = xpath + (f'[@{key}]' if val is None else f"[@{key}='{val}']")
|
||||
return node.find(expr)
|
||||
|
||||
# On python2.6 the xml.etree.ElementTree.Element methods don't support
|
||||
# the namespace parameter
|
||||
|
||||
|
||||
def xpath_with_ns(path, ns_map):
|
||||
"""Expand namespace-prefixed names to Clark notation."""
|
||||
components = [c.split(':') for c in path.split('/')]
|
||||
replaced = []
|
||||
for c in components:
|
||||
@@ -876,7 +874,7 @@ class Popen(subprocess.Popen):
|
||||
|
||||
self.__text_mode = kwargs.get('encoding') or kwargs.get('errors') or text or kwargs.get('universal_newlines')
|
||||
if text is True:
|
||||
kwargs['universal_newlines'] = True # For 3.6 compatibility
|
||||
kwargs['text'] = True
|
||||
kwargs.setdefault('encoding', 'utf-8')
|
||||
kwargs.setdefault('errors', 'replace')
|
||||
|
||||
@@ -1012,7 +1010,7 @@ class ExtractorError(YoutubeDLError):
|
||||
def format_traceback(self):
|
||||
return join_nonempty(
|
||||
self.traceback and ''.join(traceback.format_tb(self.traceback)),
|
||||
self.cause and ''.join(traceback.format_exception(None, self.cause, self.cause.__traceback__)[1:]),
|
||||
self.cause and ''.join(traceback.format_exception(self.cause)[1:]),
|
||||
delim='\n') or None
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
@@ -1960,14 +1958,8 @@ def setproctitle(title):
|
||||
libc = ctypes.cdll.LoadLibrary('libc.so.6')
|
||||
except OSError:
|
||||
return
|
||||
except TypeError:
|
||||
# LoadLibrary in Windows Python 2.7.13 only expects
|
||||
# a bytestring, but since unicode_literals turns
|
||||
# every string into a unicode string, it fails.
|
||||
return
|
||||
title_bytes = title.encode()
|
||||
buf = ctypes.create_string_buffer(len(title_bytes))
|
||||
buf.value = title_bytes
|
||||
|
||||
buf = ctypes.create_string_buffer(title.encode())
|
||||
try:
|
||||
# PR_SET_NAME = 15 Ref: /usr/include/linux/prctl.h
|
||||
libc.prctl(15, buf, 0, 0, 0)
|
||||
@@ -2655,11 +2647,10 @@ def multipart_encode(data, boundary=None):
|
||||
Encode a dict to RFC 7578-compliant form-data
|
||||
|
||||
data:
|
||||
A dict where keys and values can be either Unicode or bytes-like
|
||||
objects.
|
||||
A dict where keys and values can be either str or bytes-like objects.
|
||||
boundary:
|
||||
If specified a Unicode object, it's used as the boundary. Otherwise
|
||||
a random boundary is generated.
|
||||
An ASCII string to use as the boundary. If omitted, a random boundary
|
||||
is generated.
|
||||
|
||||
Reference: https://tools.ietf.org/html/rfc7578
|
||||
"""
|
||||
@@ -3138,18 +3129,26 @@ def get_compatible_ext(*, vcodecs, acodecs, vexts, aexts, preferences=None):
|
||||
return 'mkv' if allow_mkv else preferences[-1]
|
||||
|
||||
|
||||
def urlhandle_detect_ext(url_handle, default=NO_DEFAULT):
|
||||
getheader = url_handle.headers.get
|
||||
def disposition_filename(headers):
|
||||
filename = None
|
||||
for name, value in headers.get_params(header='Content-Disposition') or ():
|
||||
if name.lower() != 'filename':
|
||||
continue
|
||||
filename = value
|
||||
if isinstance(value, tuple): # Prefer filename* over filename
|
||||
break
|
||||
|
||||
if cd := getheader('Content-Disposition'):
|
||||
if m := re.match(r'attachment;\s*filename="(?P<filename>[^"]+)"', cd):
|
||||
if ext := determine_ext(m.group('filename'), default_ext=None):
|
||||
return ext
|
||||
return email.utils.collapse_rfc2231_value(filename).strip() if filename is not None else None
|
||||
|
||||
|
||||
def urlhandle_detect_ext(url_handle, default=NO_DEFAULT):
|
||||
headers = url_handle.headers
|
||||
|
||||
return (
|
||||
determine_ext(getheader('x-amz-meta-name'), default_ext=None)
|
||||
or getheader('x-amz-meta-file-type')
|
||||
or mimetype2ext(getheader('Content-Type'), default=default))
|
||||
determine_ext(disposition_filename(headers), default_ext=None)
|
||||
or determine_ext(headers.get('x-amz-meta-name'), default_ext=None)
|
||||
or headers.get('x-amz-meta-file-type')
|
||||
or mimetype2ext(headers.get('Content-Type'), default=default))
|
||||
|
||||
|
||||
def encode_data_uri(data, mime_type):
|
||||
@@ -3391,10 +3390,15 @@ class download_range_func:
|
||||
|
||||
def __eq__(self, other):
|
||||
return (isinstance(other, download_range_func)
|
||||
and self.chapters == other.chapters and self.ranges == other.ranges)
|
||||
and self.chapters == other.chapters
|
||||
and self.ranges == other.ranges
|
||||
and self.from_info == other.from_info)
|
||||
|
||||
def __repr__(self):
|
||||
return f'{__name__}.{type(self).__name__}({self.chapters}, {self.ranges})'
|
||||
args = [repr(self.chapters), repr(self.ranges)]
|
||||
if self.from_info:
|
||||
args.append('from_info=True')
|
||||
return f'{__name__}.{type(self).__name__}({", ".join(args)})'
|
||||
|
||||
|
||||
def parse_dfxp_time_expr(time_expr):
|
||||
@@ -3422,7 +3426,7 @@ def ass_subtitles_timecode(seconds):
|
||||
def dfxp2srt(dfxp_data):
|
||||
"""
|
||||
@param dfxp_data A bytes-like object containing DFXP data
|
||||
@returns A unicode object containing converted SRT data
|
||||
@returns A string containing the converted SRT data
|
||||
"""
|
||||
LEGACY_NAMESPACES = (
|
||||
(b'http://www.w3.org/ns/ttml', [
|
||||
@@ -5090,7 +5094,7 @@ class function_with_repr:
|
||||
|
||||
|
||||
class Namespace(types.SimpleNamespace):
|
||||
"""Immutable namespace"""
|
||||
"""SimpleNamespace iterable over attribute values"""
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.__dict__.values())
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
import base64
|
||||
import datetime as dt
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
|
||||
from .._utils import parse_iso8601
|
||||
|
||||
@@ -22,13 +22,13 @@ _ARRAY_TYPE_LOOKUP = {
|
||||
'Uint8ClampedArray': 'B',
|
||||
'Int16Array': 'h',
|
||||
'Uint16Array': 'H',
|
||||
'Float16Array': 'e',
|
||||
'Int32Array': 'i',
|
||||
'Uint32Array': 'I',
|
||||
'Float32Array': 'f',
|
||||
'Float64Array': 'd',
|
||||
'BigInt64Array': 'l',
|
||||
'BigUint64Array': 'L',
|
||||
'ArrayBuffer': 'B',
|
||||
'BigInt64Array': 'q',
|
||||
'BigUint64Array': 'Q',
|
||||
}
|
||||
|
||||
|
||||
@@ -85,30 +85,31 @@ def parse_iter(parsed: typing.Any, /, *, revivers: dict[str, collections.abc.Cal
|
||||
|
||||
if isinstance(value, list):
|
||||
if value and isinstance(value[0], str):
|
||||
type_name = value[0]
|
||||
# TODO: implement zips `strict=True`
|
||||
if reviver := revivers.get(value[0]):
|
||||
if reviver := revivers.get(type_name):
|
||||
if value[1] == source:
|
||||
# XXX: avoid infinite loop
|
||||
yield IndexError(f'{value[0]!r} cannot point to itself (index: {source})')
|
||||
yield IndexError(f'{type_name!r} cannot point to itself (index: {source})')
|
||||
continue
|
||||
# inverse order: resolve index, revive value
|
||||
stack.append((target, index, (value[0], value[1], reviver)))
|
||||
stack.append((target, index, (type_name, value[1], reviver)))
|
||||
stack.append((target, index, value[1]))
|
||||
continue
|
||||
|
||||
elif value[0] == 'Date':
|
||||
elif type_name == 'Date':
|
||||
try:
|
||||
result = dt.datetime.fromtimestamp(parse_iso8601(value[1]), tz=dt.timezone.utc)
|
||||
except Exception:
|
||||
yield ValueError(f'invalid date: {value[1]!r}')
|
||||
result = None
|
||||
|
||||
elif value[0] == 'Set':
|
||||
elif type_name == 'Set':
|
||||
result = [None] * (len(value) - 1)
|
||||
for offset, new_source in enumerate(value[1:]):
|
||||
stack.append((result, offset, new_source))
|
||||
|
||||
elif value[0] == 'Map':
|
||||
elif type_name == 'Map':
|
||||
result = []
|
||||
for key, new_source in zip(*(iter(value[1:]),) * 2, strict=True):
|
||||
pair = [None, None]
|
||||
@@ -116,29 +117,101 @@ def parse_iter(parsed: typing.Any, /, *, revivers: dict[str, collections.abc.Cal
|
||||
stack.append((pair, 1, new_source))
|
||||
result.append(pair)
|
||||
|
||||
elif value[0] == 'RegExp':
|
||||
elif type_name == 'RegExp':
|
||||
# XXX: use jsinterp to translate regex flags
|
||||
# currently ignores `value[2]`
|
||||
result = re.compile(value[1])
|
||||
|
||||
elif value[0] == 'Object':
|
||||
elif type_name == 'Object':
|
||||
result = value[1]
|
||||
|
||||
elif value[0] == 'BigInt':
|
||||
elif type_name == 'BigInt':
|
||||
result = int(value[1])
|
||||
|
||||
elif value[0] == 'null':
|
||||
elif type_name == 'null':
|
||||
result = {}
|
||||
for key, new_source in zip(*(iter(value[1:]),) * 2, strict=True):
|
||||
stack.append((result, key, new_source))
|
||||
|
||||
elif value[0] in _ARRAY_TYPE_LOOKUP:
|
||||
typecode = _ARRAY_TYPE_LOOKUP[value[0]]
|
||||
elif type_name == 'ArrayBuffer':
|
||||
try:
|
||||
if len(value) < 2 or not isinstance(value[1], str):
|
||||
raise TypeError(f'Invalid ArrayBuffer encoding: {value[1:]!r}')
|
||||
result = base64.b64decode(value[1])
|
||||
except Exception as error:
|
||||
yield ValueError(f'Invalid ArrayBuffer at {source}: {error}')
|
||||
result = None
|
||||
|
||||
elif type_name in _ARRAY_TYPE_LOOKUP or type_name == 'DataView':
|
||||
try:
|
||||
if len(value) < 2:
|
||||
raise TypeError('Missing ArrayBuffer reference')
|
||||
if isinstance(value[1], str):
|
||||
data = base64.b64decode(value[1])
|
||||
result = array.array(typecode, data).tolist()
|
||||
else:
|
||||
buffer_index = value[1]
|
||||
if (
|
||||
not isinstance(buffer_index, int)
|
||||
or isinstance(buffer_index, bool)
|
||||
or not 0 <= buffer_index < len(parsed)
|
||||
):
|
||||
raise IndexError(f'Invalid ArrayBuffer index: {buffer_index!r}')
|
||||
|
||||
if buffer_index in resolved:
|
||||
data = resolved[buffer_index]
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError(f'Invalid ArrayBuffer reference: {buffer_index!r}')
|
||||
else:
|
||||
buffer = parsed[buffer_index]
|
||||
if not (
|
||||
isinstance(buffer, list)
|
||||
and len(buffer) >= 2
|
||||
and buffer[0] == 'ArrayBuffer'
|
||||
and isinstance(buffer[1], str)
|
||||
):
|
||||
raise TypeError(f'Invalid ArrayBuffer reference: {buffer_index!r}')
|
||||
data = resolved[buffer_index] = base64.b64decode(buffer[1])
|
||||
|
||||
offset = value[2] if len(value) > 2 else 0
|
||||
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
||||
raise ValueError(f'Invalid byte offset: {offset!r}')
|
||||
|
||||
length = value[3] if len(value) > 3 else None
|
||||
if length is not None and (
|
||||
not isinstance(length, int) or isinstance(length, bool) or length < 0
|
||||
):
|
||||
raise ValueError(f'Invalid length: {length!r}')
|
||||
|
||||
if type_name == 'DataView':
|
||||
end = len(data) if length is None else offset + length
|
||||
if offset > len(data) or end > len(data):
|
||||
raise ValueError('View exceeds ArrayBuffer length')
|
||||
result = data[offset:end]
|
||||
else:
|
||||
typecode = _ARRAY_TYPE_LOOKUP[type_name]
|
||||
itemsize = struct.calcsize(f'={typecode}')
|
||||
if offset % itemsize:
|
||||
raise ValueError(f'Byte offset {offset} is not aligned to {itemsize}-byte elements')
|
||||
if offset > len(data):
|
||||
raise ValueError('View exceeds ArrayBuffer length')
|
||||
|
||||
if length is None:
|
||||
view = data[offset:]
|
||||
if len(view) % itemsize:
|
||||
raise ValueError(f'Byte length is not a multiple of {itemsize}')
|
||||
else:
|
||||
end = offset + length * itemsize
|
||||
if end > len(data):
|
||||
raise ValueError('View exceeds ArrayBuffer length')
|
||||
view = data[offset:end]
|
||||
|
||||
result = [item[0] for item in struct.iter_unpack(f'={typecode}', view)]
|
||||
except Exception as error:
|
||||
yield ValueError(f'Invalid {type_name} at {source}: {error}')
|
||||
result = None
|
||||
|
||||
else:
|
||||
yield TypeError(f'invalid type at {source}: {value[0]!r}')
|
||||
yield TypeError(f'Invalid type at {source}: {type_name!r}')
|
||||
result = None
|
||||
else:
|
||||
result = len(value) * [None]
|
||||
|
||||
@@ -360,12 +360,12 @@ def subs_list_to_dict(subs: list[dict] | None = None, /, *, lang='und', ext=None
|
||||
if not url_or_none(sub.get('url')) and not sub.get('data'):
|
||||
continue
|
||||
sub_id = sub.pop('id', None)
|
||||
if not isinstance(sub_id, str):
|
||||
if not isinstance(sub_id, str) or not sub_id:
|
||||
if not lang:
|
||||
continue
|
||||
sub_id = lang
|
||||
sub_ext = sub.get('ext')
|
||||
if not isinstance(sub_ext, str):
|
||||
if not isinstance(sub_ext, str) or not sub_ext:
|
||||
if not ext:
|
||||
sub.pop('ext', None)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user