Compare commits

...
34 Commits
Author SHA1 Message Date
ruben1PvP 897341241f [ie/youtube] Set tv_samsung as a default client (#17805)
Authored by: ruben1PvP
2026-10-10 23:15:52 +02:00
error-reporting 817450d28d [ie/youtube] Add tv_samsung client (#17723)
Authored by: error-reporting
2026-10-11 08:56:51 +13:00
InvalidUsernameException 51bab8a011 [ie/odnoklassniki] Fix extractor (#17739)
Closes #17585, Closes #17600
Authored by: InvalidUsernameException
2026-09-27 22:09:13 +00:00
dlp-botandbashonly 779f599669 [ci] Update 3 actions in 3 workflows (#17748)
* Bump docker/setup-qemu-action v4.2.0 => v4.4.0
* Bump github/codeql-action v4.37.7 => v4.38.2
* Bump zizmorcore/zizmor-action v0.6.2 => v0.6.4
* Use self-repository syntax in reusable workflow calls

Authored by: bashonly, dlp-bot

Co-authored-by: bashonly <bashonly@protonmail.com>
2026-09-27 21:12:35 +00:00
bashonly a29f2a2b92 [rh:websockets] Add legacy connect workaround for >=17.1 (#17747)
Authored by: bashonly
2026-09-27 20:17:39 +00:00
doe1080 caf63903a1 [networking] Fix + decoding in SOCKS proxy credentials (#17728)
Authored by: doe1080
2026-09-27 20:44:21 +02:00
doe1080 28731cbacb [utils] Improve Content-Disposition filename parsing (#17727)
Authored by: doe1080
2026-09-27 20:21:54 +02:00
doe1080 c2901ed367 [rh:urllib] Fix relative redirects with dot segments (#17712)
Authored by: doe1080
2026-09-27 19:55:49 +02:00
doe1080 f9eee6ba62 [rh:urllib] Eagerly release BytesIO responses (#17717)
Authored by: doe1080
2026-09-27 19:54:19 +02:00
doe1080 9dd5e68cd7 [test:websockets] Update server hang workaround (#17718)
Authored by: doe1080
2026-09-27 19:50:36 +02:00
doe1080 39b31b2755 [utils] setproctitle: Fix NUL termination (#17724)
Authored by: doe1080
2026-09-27 19:40:54 +02:00
Simon Sawicki f45f14bd79 [misc/requirements] Fix locking for pycparser (#17746)
Authored by: Grub4K
2026-09-27 19:27:44 +02:00
doe1080 8a49065e18 [devscripts] update_requirements: Preserve cffi dependencies on PyPy (#17716)
Authored by: doe1080
2026-09-27 18:34:03 +02:00
gamer191 c7fb478d21 [ie/youtube] Use Safari UA for web_embedded client (#17684)
Authored by: gamer191
2026-09-16 19:48:51 +12:00
doe1080 bbc809a116 [utils] devalue: Improve binary type parsing (#16934)
Authored by: doe1080
2026-08-30 15:58:45 +02:00
FraFraFra-LongD 9caaf45401 [ie] Do not warn on intentional generic results (#16683)
Authored by: FraFraFra-LongD
2026-08-30 15:53:37 +02:00
tcely 2f3929ba1a [ie/applepodcasts] Fix token caching (#17567)
Fix aaf7405ba3

Authored by: tcely
2026-08-29 23:22:47 +00:00
Atsushi2965 f1df65d5d7 [utils]download_range_func: Update __eq__ and __repr__ to include from_info (#16393)
Authored by: atsushi2965
2026-08-30 00:31:58 +02:00
doe1080 3d062c86ea [docs] Fix Namespace documentation (#17324)
Authored by: doe1080
2026-08-30 00:12:11 +02:00
doe1080 fcdbefb85f [utils] subs_list_to_dict: Fix empty value handling (#17311)
Authored by: doe1080
2026-08-29 23:55:22 +02:00
bashonly 8377aa9555 [update] Detect GitHub error page responses (#17555)
Closes #17550
Authored by: bashonly
2026-08-27 23:05:38 +00:00
bashonly 28d35b7762 [ie/twitch:clips] Fix extractor (#17554)
Closes #17553
Authored by: bashonly
2026-08-27 22:52:39 +00:00
Lillie 94eba4c156 [ie/globalplayer] Fix extractors (#17442)
Closes #17215, Closes #17429
Authored by: LillieH1000
2026-08-26 23:44:14 +00:00
bashonly 1d1351f40f [ie/bandcamp] Fix extractors (#17546)
Fix ec9425fcd0

Closes #17506
Authored by: bashonly
2026-08-26 23:27:37 +00:00
InvalidUsernameException 2089f8ad37 [ie/tubitv:series] Fix extractor (#17545)
Closes #17543
Authored by: InvalidUsernameException
2026-08-26 22:34:11 +00:00
wlerinanddoe1080 a2a8846b0d [ie/showroom] Fix extractor (#17488)
Closes #17331
Authored by: wlerin, doe1080

Co-authored-by: doe1080 <98906116+doe1080@users.noreply.github.com>
2026-08-26 22:22:17 +00:00
InvalidUsernameException d1cb4709cc [ie/tubitv] Improve extractor (#16428)
Closes #16422
Authored by: InvalidUsernameException
2026-08-26 20:40:14 +00:00
ache e164eebe19 [ie/adn] Add profile_id extractor-arg (#17522)
Authored by: arobase-che
2026-08-26 20:39:10 +00:00
norepro 66f49765d5 [ie/go] Improve error handling (#15882)
Closes #15774
Authored by: norepro
2026-08-25 16:30:27 +00:00
InvalidUsernameException 9fb5969797 [ie/ted] Fix extractor (#17510)
Closes #17507
Authored by: InvalidUsernameException
2026-08-25 01:45:23 +00:00
doe1080 88a9516584 [cleanup] Remove obsolete Python compatibility code (#17357)
Authored by: doe1080
2026-08-25 01:32:13 +00:00
doe1080 81ecd58b13 [ie/niconico:channel] Support channels (#17398)
Closes #9421
Authored by: doe1080
2026-08-20 22:26:03 +00:00
bashonly 5022b8c119 [update] Remove bad advice (#17492)
Authored by: bashonly
2026-08-20 22:20:28 +00:00
Mikel Olasagasti Uranga 91f784d6fd [test] Fix handshake error matching for OpenSSL 4.x (#17491)
Closes #17490
Authored by: mikelolasagasti
2026-08-20 22:04:50 +00:00
52 changed files with 733 additions and 528 deletions
+12
View File
@@ -22,3 +22,15 @@ paths:
ignore: ignore:
# https://github.com/rhysd/actionlint/issues/657 # https://github.com/rhysd/actionlint/issues/657
- 'unexpected key "queue" for "concurrency" section.+' - '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.+'
+1 -1
View File
@@ -265,7 +265,7 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
if: matrix.qemu_platform if: matrix.qemu_platform
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with: with:
image: tonistiigi/binfmt:qemu-v10.0.4-56@sha256:30cc9a4d03765acac9be2ed0afc23af1ad018aed2c28ea4be8c2eb9afe03fbd1 image: tonistiigi/binfmt:qemu-v10.0.4-56@sha256:30cc9a4d03765acac9be2ed0afc23af1ad018aed2c28ea4be8c2eb9afe03fbd1
cache-image: false cache-image: false
+2 -2
View File
@@ -36,12 +36,12 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: none build-mode: none
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
permissions: permissions:
contents: write # May be needed to publish release contents: write # May be needed to publish release
id-token: write # Needed for trusted publishing id-token: write # Needed for trusted publishing
uses: ./.github/workflows/release.yml uses: $/.github/workflows/release.yml
with: with:
prerelease: true prerelease: true
source: ${{ (github.repository != 'yt-dlp/yt-dlp' && vars.MASTER_ARCHIVE_REPO) || 'master' }} source: ${{ (github.repository != 'yt-dlp/yt-dlp' && vars.MASTER_ARCHIVE_REPO) || 'master' }}
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
permissions: permissions:
contents: write # May be needed to publish release contents: write # May be needed to publish release
id-token: write # Needed for trusted publishing id-token: write # Needed for trusted publishing
uses: ./.github/workflows/release.yml uses: $/.github/workflows/release.yml
with: with:
prerelease: true prerelease: true
source: ${{ (github.repository != 'yt-dlp/yt-dlp' && vars.NIGHTLY_ARCHIVE_REPO) || 'nightly' }} source: ${{ (github.repository != 'yt-dlp/yt-dlp' && vars.NIGHTLY_ARCHIVE_REPO) || 'nightly' }}
+1 -1
View File
@@ -155,7 +155,7 @@ jobs:
needs: [prepare] needs: [prepare]
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/build.yml uses: $/.github/workflows/build.yml
with: with:
version: ${{ needs.prepare.outputs.version }} version: ${{ needs.prepare.outputs.version }}
channel: ${{ needs.prepare.outputs.channel }} channel: ${{ needs.prepare.outputs.channel }}
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with: with:
advanced-security: false advanced-security: false
persona: pedantic persona: pedantic
+4 -1
View File
@@ -1859,7 +1859,7 @@ The following extractors use this feature:
#### youtube #### 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 * `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 * `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 * `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_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` * `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 * `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 * `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 #### 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) * `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)
+4
View File
@@ -240,6 +240,10 @@ mutagen==1.48.1 \
--hash=sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7 \ --hash=sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7 \
--hash=sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173 --hash=sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173
# via yt-dlp # 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 \ pycryptodomex==3.23.0 \
--hash=sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51 \ --hash=sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51 \
--hash=sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6 \ --hash=sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6 \
+1 -2
View File
@@ -66,8 +66,7 @@ def convert_code_blocks(readme):
def move_sections(readme): def move_sections(readme):
MOVE_TAG_TEMPLATE = '<!-- MANPAGE: MOVE "%s" SECTION HERE -->' MOVE_TAG_TEMPLATE = '<!-- MANPAGE: MOVE "%s" SECTION HERE -->'
sections = re.findall(r'(?m)^%s$' % ( sections = re.findall(r'(?m)^%s$' % (re.escape(MOVE_TAG_TEMPLATE) % '(.+)'), readme)
re.escape(MOVE_TAG_TEMPLATE).replace(r'\%', '%') % '(.+)'), readme)
for section_name in sections: for section_name in sections:
move_tag = MOVE_TAG_TEMPLATE % section_name move_tag = MOVE_TAG_TEMPLATE % section_name
+2 -2
View File
@@ -77,7 +77,7 @@ BUNDLE_TARGETS = {
'default': Target( 'default': Target(
extras=['default'], extras=['default'],
# PyPy bundles cffi, which is a transitive dep of brotlicffi, which is only required for PyPy # 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( 'curl-cffi': Target(
extras=['default', 'curl-cffi'], extras=['default', 'curl-cffi'],
@@ -643,7 +643,7 @@ def update_requirements(
extras=[extra_name], extras=[extra_name],
bare=True, bare=True,
# PyPy bundles cffi, which is a transitive dep of brotlicffi, which is only required for PyPy # 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() ).splitlines()
# Write the finalized pyproject.toml # Write the finalized pyproject.toml
+1
View File
@@ -74,6 +74,7 @@ pin = [
"charset-normalizer==3.5.0", "charset-normalizer==3.5.0",
"idna==3.18", "idna==3.18",
"mutagen==1.48.1", "mutagen==1.48.1",
"pycparser==3.0 ; implementation_name != 'PyPy' and implementation_name != 'cpython'",
"pycryptodomex==3.23.0", "pycryptodomex==3.23.0",
"requests==2.34.2", "requests==2.34.2",
"urllib3==2.7.0", "urllib3==2.7.0",
+1 -37
View File
@@ -3,7 +3,6 @@ import hashlib
import json import json
import os.path import os.path
import re import re
import ssl
import sys import sys
import types 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)))) '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): def assertEqual(self, got, expected, msg=None):
if got != expected: if got != expected:
if msg is None: if msg is None:
@@ -366,12 +335,7 @@ def expect_warnings(ydl, warnings_re):
def http_server_port(httpd): def http_server_port(httpd):
if os.name == 'java' and isinstance(httpd.socket, ssl.SSLSocket): return httpd.server_address[1]
# In Jython SSLSocket is not a subclass of socket.socket
sock = httpd.socket.sock
else:
sock = httpd.socket
return sock.getsockname()[1]
def verify_address_availability(address): def verify_address_availability(address):
+3 -3
View File
@@ -15,7 +15,7 @@ import contextlib
import copy import copy
import json 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 import YoutubeDL
from yt_dlp.extractor.common import InfoExtractor from yt_dlp.extractor.common import InfoExtractor
from yt_dlp.postprocessor.common import PostProcessor from yt_dlp.postprocessor.common import PostProcessor
@@ -860,10 +860,10 @@ class TestYoutubeDL(unittest.TestCase):
def test_format_note(self): def test_format_note(self):
ydl = YoutubeDL() ydl = YoutubeDL()
self.assertEqual(ydl._format_note({}), '') self.assertEqual(ydl._format_note({}), '')
assertRegexpMatches(self, ydl._format_note({ self.assertRegex(ydl._format_note({
'vbr': 10, 'vbr': 10,
}), r'^\s*10k$') }), r'^\s*10k$')
assertRegexpMatches(self, ydl._format_note({ self.assertRegex(ydl._format_note({
'fps': 30, 'fps': 30,
}), r'^30fps$') }), r'^30fps$')
+54 -1
View File
@@ -7,10 +7,12 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import base64
import datetime as dt import datetime as dt
import json import json
import math import math
import re import re
import struct
import unittest import unittest
from yt_dlp.utils.jslib import devalue from yt_dlp.utils.jslib import devalue
@@ -84,10 +86,61 @@ TEST_CASES_EQUALS = [{
'name': 'Uint8Array', 'name': 'Uint8Array',
'unparsed': [['Uint8Array', 'AQID']], 'unparsed': [['Uint8Array', 'AQID']],
'parsed': [1, 2, 3], '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', 'name': 'ArrayBuffer',
'unparsed': [['ArrayBuffer', 'AQID']], 'unparsed': [['ArrayBuffer', 'AQID']],
'parsed': [1, 2, 3], 'parsed': b'\x01\x02\x03',
}, { }, {
'name': 'str (repetition)', 'name': 'str (repetition)',
'unparsed': [[1, 1], 'a string'], 'unparsed': [[1, 1], 'a string'],
+6 -8
View File
@@ -13,8 +13,6 @@ import hashlib
import json import json
from test.helper import ( from test.helper import (
assertGreaterEqual,
assertLessEqual,
expect_info_dict, expect_info_dict,
expect_warnings, expect_warnings,
get_params, get_params,
@@ -201,8 +199,8 @@ def generator(test_case, tname):
num_entries = len(res_dict.get('entries', [])) num_entries = len(res_dict.get('entries', []))
if 'playlist_mincount' in test_case: if 'playlist_mincount' in test_case:
mincount = test_case['playlist_mincount'] mincount = test_case['playlist_mincount']
assertGreaterEqual( self.assertGreaterEqual(
self, num_entries, mincount, num_entries, mincount,
f'Expected at least {mincount} entries in playlist {test_url}, but got only {num_entries}') f'Expected at least {mincount} entries in playlist {test_url}, but got only {num_entries}')
if 'playlist_count' in test_case: if 'playlist_count' in test_case:
count = test_case['playlist_count'] 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}') f'Expected exactly {count} entries in playlist {test_url}, but got {got}')
if 'playlist_maxcount' in test_case: if 'playlist_maxcount' in test_case:
maxcount = test_case['playlist_maxcount'] maxcount = test_case['playlist_maxcount']
assertLessEqual( self.assertLessEqual(
self, num_entries, maxcount, num_entries, maxcount,
f'Expected at most {maxcount} entries in playlist {test_url}, but got more') f'Expected at most {maxcount} entries in playlist {test_url}, but got more')
if 'playlist_duration_sum' in test_case: if 'playlist_duration_sum' in test_case:
got_duration = sum(e['duration'] for e in res_dict['entries']) got_duration = sum(e['duration'] for e in res_dict['entries'])
@@ -241,8 +239,8 @@ def generator(test_case, tname):
if params.get('test'): if params.get('test'):
expected_minsize = max(expected_minsize, 10000) expected_minsize = max(expected_minsize, 10000)
got_fsize = os.path.getsize(tc_filename) got_fsize = os.path.getsize(tc_filename)
assertGreaterEqual( self.assertGreaterEqual(
self, got_fsize, expected_minsize, got_fsize, expected_minsize,
f'Expected {tc_filename} to be at least {format_bytes(expected_minsize)}, ' f'Expected {tc_filename} to be at least {format_bytes(expected_minsize)}, '
f'but it\'s only {format_bytes(got_fsize)} ') f'but it\'s only {format_bytes(got_fsize)} ')
if 'md5' in tc: if 'md5' in tc:
+12 -19
View File
@@ -180,6 +180,11 @@ class HTTPTestRequestHandler(http.server.BaseHTTPRequestHandler):
self.send_header('Location', '/a/b/./../../headers') self.send_header('Location', '/a/b/./../../headers')
self.send_header('Content-Length', '0') self.send_header('Content-Length', '0')
self.end_headers() 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': elif self.path == '/redirect_dotsegments_absolute':
self.send_response(301) self.send_response(301)
# redirect to /headers but with dot segments before - absolute url # redirect to /headers but with dot segments before - absolute url
@@ -338,7 +343,7 @@ class TestHTTPRequestHandler(TestRequestHandlerBase):
https_server_thread.start() https_server_thread.start()
with handler(verify=False) as rh: 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')) validate_and_send(rh, Request(f'https://127.0.0.1:{https_port}/headers'))
assert not issubclass(exc_info.type, CertificateVerifyError) assert not issubclass(exc_info.type, CertificateVerifyError)
@@ -412,6 +417,7 @@ class TestHTTPRequestHandler(TestRequestHandlerBase):
@pytest.mark.parametrize('path', [ @pytest.mark.parametrize('path', [
'/a/b/./../../headers', '/a/b/./../../headers',
'/redirect_dotsegments', '/redirect_dotsegments',
'/a/b/redirect_relative_dotsegments',
# https://github.com/yt-dlp/yt-dlp/issues/9020 # https://github.com/yt-dlp/yt-dlp/issues/9020
'/redirect_dotsegments_absolute', '/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')) 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 # 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'),
(
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),
),
# https://github.com/python/cpython/blob/987b712b4aeeece336eed24fcc87a950a756c3e2/Lib/http/client.py#L1265 # 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'),
(
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),
),
# https://github.com/python/cpython/blob/987b712b4aeeece336eed24fcc87a950a756c3e2/Lib/http/client.py#L1288C31-L1288C50 # 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): def test_httplib_validation_errors(self, handler, req, match):
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.')
with handler() as rh: with handler() as rh:
with pytest.raises(RequestError, match=match) as exc_info: with pytest.raises(RequestError, match=match) as exc_info:
validate_and_send(rh, req) validate_and_send(rh, req)
+3 -3
View File
@@ -63,13 +63,13 @@ class TestNetworkingUtils:
'username': 'user', 'username': 'user',
'password': '', '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, 'proxytype': ProxyType.SOCKS4,
'addr': '127.0.0.1', 'addr': '127.0.0.1',
'port': 1080, 'port': 1080,
'rdns': False, 'rdns': False,
'username': 'u@ser', 'username': 'u@ser+name',
'password': 'pa ss', 'password': 'pa ss+word',
}), }),
('socks4a://:pa%20ss@127.0.0.1', { ('socks4a://:pa%20ss@127.0.0.1', {
'proxytype': ProxyType.SOCKS4A, 'proxytype': ProxyType.SOCKS4A,
+12
View File
@@ -546,6 +546,18 @@ class TestTraversalHelpers:
{'url': 'https://example.com/subs/de4'}, {'url': 'https://example.com/subs/de4'},
], ],
}, 'non str types should be replaced by default id' }, '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): def test_trim_str(self):
with pytest.raises(TypeError): with pytest.raises(TypeError):
+39 -8
View File
@@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import contextlib import contextlib
import datetime as dt import datetime as dt
import email.message
import io import io
import itertools import itertools
import json import json
@@ -48,6 +49,7 @@ from yt_dlp.utils import (
determine_ext, determine_ext,
determine_file_encoding, determine_file_encoding,
dfxp2srt, dfxp2srt,
disposition_filename,
encode_base_n, encode_base_n,
encode_compat_str, encode_compat_str,
expand_path, expand_path,
@@ -107,6 +109,7 @@ from yt_dlp.utils import (
sanitize_filename, sanitize_filename,
sanitize_path, sanitize_path,
sanitize_url, sanitize_url,
setproctitle,
shell_quote, shell_quote,
strftime_or_none, strftime_or_none,
smuggle_url, smuggle_url,
@@ -125,6 +128,7 @@ from yt_dlp.utils import (
url_basename, url_basename,
url_or_none, url_or_none,
urlencode_postdata, urlencode_postdata,
urlhandle_detect_ext,
urljoin, urljoin,
urshift, urshift,
variadic, variadic,
@@ -613,6 +617,14 @@ class TestUtil(unittest.TestCase):
self.assertEqual(res_url, url) self.assertEqual(res_url, url)
self.assertEqual(res_data, {'a': 'b', 'c': 'd'}) 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): def test_shell_quote(self):
args = ['ffmpeg', '-i', 'ñ€ß\'.mp4'] args = ['ffmpeg', '-i', 'ñ€ß\'.mp4']
self.assertEqual( 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 _: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="Fáilte 世界 \U0001f600">'), {'x': 'Fáilte 世界 \U0001f600'})
self.assertEqual(extract_attributes('<e x="décompose&#769;">'), {'x': 'décompose\u0301'}) self.assertEqual(extract_attributes('<e x="décompose&#769;">'), {'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 &#128512;!">'), {'x': 'Smile \U0001f600!'}) self.assertEqual(extract_attributes('<e x="Smile &#128512;!">'), {'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/>'), {}) self.assertEqual(extract_attributes('<mal"formed/>'), {})
def test_clean_html(self): def test_clean_html(self):
@@ -2121,6 +2126,32 @@ Line 1
self.assertEqual(get_compatible_ext( self.assertEqual(get_compatible_ext(
vcodecs=['av1'], acodecs=['mp4a'], vexts=['webm'], aexts=['m4a'], preferences=('webm', 'mkv')), 'mkv') 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 test_try_call(self):
def total(*x, **kwargs): def total(*x, **kwargs):
return sum(x) + sum(kwargs.values()) return sum(x) + sum(kwargs.values())
+6 -2
View File
@@ -130,7 +130,11 @@ def ws_validate_and_send(rh, req):
try: try:
return rh.send(req) return rh.send(req)
except TransportError as e: 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 # websockets server sometimes hangs on new connections
continue continue
raise raise
@@ -186,7 +190,7 @@ class TestWebsSocketRequestHandlerConformance:
def test_ssl_error(self, handler): def test_ssl_error(self, handler):
with handler(verify=False) as rh: 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)) ws_validate_and_send(rh, Request(self.bad_wss_host))
assert not issubclass(exc_info.type, CertificateVerifyError) assert not issubclass(exc_info.type, CertificateVerifyError)
Generated
+2
View File
@@ -1447,6 +1447,7 @@ pin = [
{ name = "charset-normalizer" }, { name = "charset-normalizer" },
{ name = "idna" }, { name = "idna" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pycparser", marker = "implementation_name != 'PyPy' and implementation_name != 'cpython'" },
{ name = "pycryptodomex" }, { name = "pycryptodomex" },
{ name = "requests" }, { name = "requests" },
{ name = "urllib3" }, { name = "urllib3" },
@@ -1534,6 +1535,7 @@ requires-dist = [
{ name = "mutagen", marker = "extra == 'default'" }, { name = "mutagen", marker = "extra == 'default'" },
{ name = "mutagen", marker = "extra == 'pin'", specifier = "==1.48.1" }, { 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 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 = "pycparser", marker = "implementation_name == 'cpython' and extra == 'pin-curl-cffi'", specifier = "==3.0" },
{ name = "pycryptodomex", marker = "extra == 'default'" }, { name = "pycryptodomex", marker = "extra == 'default'" },
{ name = "pycryptodomex", marker = "extra == 'pin'", specifier = "==3.23.0" }, { name = "pycryptodomex", marker = "extra == 'pin'", specifier = "==3.23.0" },
+1 -3
View File
@@ -672,7 +672,6 @@ class YoutubeDL:
except Exception as e: except Exception as e:
self.write_debug(f'Failed to enable VT mode: {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) self._out_files.console = next(filter(supports_terminal_sequences, (sys.stderr, sys.stdout)), None)
if self.params.get('no_color'): if self.params.get('no_color'):
@@ -2398,8 +2397,7 @@ class YoutubeDL:
selectors = [] selectors = []
current_selector = None current_selector = None
for type_, string_, start, _, _ in tokens: for type_, string_, start, _, _ in tokens:
# ENCODING is only defined in Python 3.x if type_ == tokenize.ENCODING:
if type_ == getattr(tokenize, 'ENCODING', None):
continue continue
elif type_ in [tokenize.NAME, tokenize.NUMBER]: elif type_ in [tokenize.NAME, tokenize.NUMBER]:
current_selector = FormatSelector(SINGLE, string_, []) current_selector = FormatSelector(SINGLE, string_, [])
+1 -1
View File
@@ -293,7 +293,7 @@ def aes_decrypt_text(data, password, key_size_bytes):
- Mode of operation is 'counter' - Mode of operation is 'counter'
@param {str} data Base64 encoded string @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 @param {int} key_size_bytes Possible values: 16 for 128-Bit, 24 for 192-Bit or 32 for 256-Bit
@returns {str} Decrypted data @returns {str} Decrypted data
""" """
+2 -3
View File
@@ -8,9 +8,8 @@ passthrough_module(__name__, '._deprecated')
del passthrough_module del passthrough_module
# HTMLParseError has been deprecated in Python 3.3 and removed in # HTMLParseError was deprecated in Python 3.3 and removed in Python 3.5.
# Python 3.5. Introducing dummy exception for Python >3.5 for compatible # Keep a replacement for API compatibility and uniform exception handling.
# and uniform cross-version exception handling
class compat_HTMLParseError(ValueError): class compat_HTMLParseError(ValueError):
pass pass
+1 -1
View File
@@ -154,7 +154,7 @@ def write_piff_header(stream, params):
sample_entry_payload += u16.pack(0x18) # depth sample_entry_payload += u16.pack(0x18) # depth
sample_entry_payload += s16.pack(-1) # pre defined 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'): if fourcc in ('H264', 'AVC1'):
sps, pps = codec_private_data.split(u32.pack(1))[1:] sps, pps = codec_private_data.split(u32.pack(1))[1:]
avcc_payload = u8.pack(1) # configuration version avcc_payload = u8.pack(1) # configuration version
+1
View File
@@ -1221,6 +1221,7 @@ from .nhk import (
from .nhl import NHLIE from .nhl import NHLIE
from .nick import NickIE from .nick import NickIE
from .niconico import ( from .niconico import (
NiconicoChannelIE,
NiconicoHistoryIE, NiconicoHistoryIE,
NiconicoIE, NiconicoIE,
NiconicoLiveIE, NiconicoLiveIE,
+4 -3
View File
@@ -50,14 +50,13 @@ class ADNIE(ADNBaseIE):
_VALID_URL = r'https?://(?:www\.)?animationdigitalnetwork\.com/(?:(?P<lang>de)/)?video/[^/?#]+/(?P<id>\d+)' _VALID_URL = r'https?://(?:www\.)?animationdigitalnetwork\.com/(?:(?P<lang>de)/)?video/[^/?#]+/(?P<id>\d+)'
_TESTS = [{ _TESTS = [{
'url': 'https://animationdigitalnetwork.com/video/558-fruits-basket/9841-episode-1-a-ce-soir', 'url': 'https://animationdigitalnetwork.com/video/558-fruits-basket/9841-episode-1-a-ce-soir',
'md5': '1c9ef066ceb302c86f80c2b371615261', 'md5': '3999b7b235ffb3591a385d1913cd3cd1',
'info_dict': { 'info_dict': {
'id': '9841', 'id': '9841',
'ext': 'mp4', 'ext': 'mp4',
'title': 'Fruits Basket - Episode 1', 'title': 'Fruits Basket - Episode 1',
'description': 'md5:14be2f72c3c96809b0ca424b0097d336',
'series': 'Fruits Basket', 'series': 'Fruits Basket',
'duration': 1437, 'duration': 1436,
'release_date': '20190405', 'release_date': '20190405',
'comment_count': int, 'comment_count': int,
'average_rating': float, 'average_rating': float,
@@ -174,6 +173,8 @@ Format: Marked,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text'''
def _real_extract(self, url): def _real_extract(self, url):
lang, video_id = self._match_valid_url(url).group('lang', 'id') lang, video_id = self._match_valid_url(url).group('lang', 'id')
self._HEADERS['X-Target-Distribution'] = lang or 'fr' 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}/' video_base_url = self._PLAYER_BASE_URL + f'video/{video_id}/'
player = self._download_json( player = self._download_json(
video_base_url + 'configuration', video_id, video_base_url + 'configuration', video_id,
+1 -1
View File
@@ -28,7 +28,7 @@ class AppleBaseIE(InfoExtractor):
def _get_token(self, webpage, episode_id): 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]): 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( js_path = self._search_regex(
r'<script [^>]*\bsrc="(/assets/index~[0-9a-f]+\.js)">', webpage, 'JS asset path') r'<script [^>]*\bsrc="(/assets/index~[0-9a-f]+\.js)">', webpage, 'JS asset path')
+35 -12
View File
@@ -25,7 +25,35 @@ from ..utils import (
from ..utils.traversal import find_element, find_elements, traverse_obj 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>[^/?#&]+)' _VALID_URL = r'https?://(?P<uploader>[^/]+)\.bandcamp\.com/track/(?P<id>[^/?#&]+)'
_EMBED_REGEX = [r'<meta property="og:url"[^>]*?content="(?P<url>.*?bandcamp\.com.*?)"'] _EMBED_REGEX = [r'<meta property="og:url"[^>]*?content="(?P<url>.*?bandcamp\.com.*?)"']
_TESTS = [{ _TESTS = [{
@@ -149,14 +177,9 @@ class BandcampIE(InfoExtractor):
'skip': 'embed detection is broken', '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): def _real_extract(self, url):
title, uploader = self._match_valid_url(url).group('id', 'uploader') 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) tralbum = self._extract_data_attr(webpage, title)
thumbnail = self._og_search_thumbnail(webpage) thumbnail = self._og_search_thumbnail(webpage)
@@ -202,7 +225,7 @@ class BandcampIE(InfoExtractor):
track_id = str(tralbum['id']) track_id = str(tralbum['id'])
download_webpage = self._download_webpage( 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') 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' IE_NAME = 'Bandcamp:album'
_VALID_URL = r'https?://(?:(?P<subdomain>[^.]+)\.)?bandcamp\.com/album/(?P<id>[^/?#&]+)' _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): def _real_extract(self, url):
uploader_id, album_id = self._match_valid_url(url).groups() uploader_id, album_id = self._match_valid_url(url).groups()
playlist_id = album_id or uploader_id 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) tralbum = self._extract_data_attr(webpage, playlist_id)
track_info = tralbum.get('trackinfo') track_info = tralbum.get('trackinfo')
if not track_info: 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' IE_NAME = 'Bandcamp:weekly'
_VALID_URL = r'https?://(?:www\.)?bandcamp\.com/radio/?\?(?:[^#]+&)?show=(?P<id>\d+)' _VALID_URL = r'https?://(?:www\.)?bandcamp\.com/radio/?\?(?:[^#]+&)?show=(?P<id>\d+)'
_TESTS = [{ _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' IE_NAME = 'Bandcamp:user'
_VALID_URL = r'https?://(?!www\.)(?P<id>[^.]+)\.bandcamp\.com(?:/music)?/?(?:[#?]|$)' _VALID_URL = r'https?://(?!www\.)(?P<id>[^.]+)\.bandcamp\.com(?:/music)?/?(?:[#?]|$)'
+9 -7
View File
@@ -432,29 +432,28 @@ class InfoExtractor:
chapter: Name or title of the chapter the video belongs to. 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_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 The following fields should only be used when the video is an episode of some
series, programme or podcast: series, programme or podcast:
series: Title of the series or programme the video episode belongs to. 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: Title of the season the video episode belongs to.
season_number: Number of the season the video episode belongs to, as an integer. 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, episode: Title of the video episode. Unlike mandatory video title field,
this field should denote the exact title of the video episode this field should denote the exact title of the video episode
without any kind of decoration. without any kind of decoration.
episode_number: Number of the video episode within a season, as an integer. 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 The following fields should only be used when the media is a track or a part of
a music album: a music album:
track: Title of the track. track: Title of the track.
track_number: Number of the track within an album or a disc, as an integer. 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), track_id: Id of the track (useful for custom indexing, e.g. 6.iii).
as a unicode string.
artists: List of artists of the track. artists: List of artists of the track.
composers: List of composers of the piece. composers: List of composers of the piece.
genres: List of genres of the track. genres: List of genres of the track.
@@ -487,7 +486,7 @@ class InfoExtractor:
creator: Use "creators" instead. creator: Use "creators" instead.
The creator of the video. 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. 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""" """Returns a URL that points to a page that should be processed"""
if ie is not None: if ie is not None:
kwargs['ie_key'] = ie if isinstance(ie, str) else ie.ie_key() 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: if video_id is not None:
kwargs['id'] = video_id kwargs['id'] = video_id
if video_title is not None: if video_title is not None:
+105 -116
View File
@@ -1,14 +1,6 @@
from .common import InfoExtractor from .common import InfoExtractor
from ..utils import ( from ..utils import url_or_none
clean_html, from ..utils.traversal import require, traverse_obj
join_nonempty,
parse_duration,
str_or_none,
traverse_obj,
unified_strdate,
unified_timestamp,
urlhandle_detect_ext,
)
class GlobalPlayerBaseIE(InfoExtractor): class GlobalPlayerBaseIE(InfoExtractor):
@@ -16,29 +8,11 @@ class GlobalPlayerBaseIE(InfoExtractor):
webpage = self._download_webpage(url, video_id) webpage = self._download_webpage(url, video_id)
return self._search_nextjs_data(webpage, video_id)['props']['pageProps'] return self._search_nextjs_data(webpage, video_id)['props']['pageProps']
def _request_ext(self, url, video_id): @staticmethod
return urlhandle_detect_ext(self._request_webpage( # Server rejects HEAD requests def _get_playback_url(data):
url, video_id, note='Determining source extension')) return traverse_obj(data, (
'playback', lambda _, v: v['canUse'] == 'true',
def _extract_audio(self, episode, series): 'url', {url_or_none}, any, {require('playback URL')}))
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),
}
class GlobalPlayerLiveIE(GlobalPlayerBaseIE): class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
@@ -48,11 +22,10 @@ class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
'info_dict': { 'info_dict': {
'id': '2mx1E', 'id': '2mx1E',
'ext': 'aac', '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', 'live_status': 'is_live',
'thumbnail': 'md5:d5040f26c7c4061014a44866129b900e',
'description': 'md5:6e183929da9001778895f32ae85124bc',
'title': 're:^Smooth Chill.+$',
}, },
}, { }, {
# national station # national station
@@ -60,11 +33,10 @@ class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
'info_dict': { 'info_dict': {
'id': '2mwx4', 'id': '2mwx4',
'ext': 'aac', 'ext': 'aac',
'description': 'turn up the feel good!',
'thumbnail': 'https://herald.musicradio.com/media/49b9e8cb-15bf-4bf2-8c28-a4850cc6b0f3.png',
'live_status': 'is_live', 'live_status': 'is_live',
'description': 'md5:492d07dfea8addadd15650ef40c10d02',
'thumbnail': 'md5:6f13378a53ce55bcf57365a654e1b490',
'title': 're:^Heart UK.+$', 'title': 're:^Heart UK.+$',
'display_id': 'heart-uk',
}, },
}, { }, {
# regional variation # regional variation
@@ -72,110 +44,131 @@ class GlobalPlayerLiveIE(GlobalPlayerBaseIE):
'info_dict': { 'info_dict': {
'id': 'AMqg', 'id': 'AMqg',
'ext': 'aac', 'ext': 'aac',
'thumbnail': 'https://herald.musicradio.com/media/49b9e8cb-15bf-4bf2-8c28-a4850cc6b0f3.png',
'title': 're:^Heart London.+$',
'live_status': 'is_live', 'live_status': 'is_live',
'display_id': 'heart-london', 'description': 'md5:492d07dfea8addadd15650ef40c10d02',
'description': 'turn up the feel good!', 'thumbnail': 'md5:6f13378a53ce55bcf57365a654e1b490',
'title': 're:^Heart London.+$',
}, },
}] }]
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) video_id = self._match_id(url)
station = self._get_page_props(url, video_id)['station'] meta = self._get_page_props(url, video_id)['station']
stream_url = station['streamUrl'] station_id = meta['id']
data = self._download_json(f'https://bff-web-guacamole.musicradio.com/playables/{station_id}', video_id)
return { return {
'id': station['id'], 'id': station_id,
'display_id': join_nonempty('brandSlug', 'slug', from_dict=station) or station.get('legacyStationPrefix'), 'url': self._get_playback_url(data),
'url': stream_url, 'ext': 'aac',
'ext': self._request_ext(stream_url, video_id),
'vcodec': 'none', 'vcodec': 'none',
'is_live': True, 'is_live': True,
**traverse_obj(station, { **traverse_obj(meta, {
'title': (('name', 'brandName'), {str_or_none}), 'thumbnail': ('brandLogo', {url_or_none}),
'description': 'tagline', 'description': ('tagline', {str}),
'thumbnail': 'brandLogo', 'title': ('name', {str}),
}, get_all=False), }),
} }
class GlobalPlayerLivePlaylistIE(GlobalPlayerBaseIE): class GlobalPlayerLivePlaylistIE(GlobalPlayerBaseIE):
_VALID_URL = r'https?://www\.globalplayer\.com/playlists/(?P<id>\w+)' _VALID_URL = r'https?://www\.globalplayer\.com/playlists/(?P<id>\w+)'
_TESTS = [{ _TESTS = [{
# "live playlist" # live playlist
'url': 'https://www.globalplayer.com/playlists/8bLk/', 'url': 'https://www.globalplayer.com/playlists/8bLk/',
'info_dict': { 'info_dict': {
'id': '8bLk', 'id': '8bLk',
'ext': 'aac', 'ext': 'aac',
'live_status': 'is_live', 'live_status': 'is_live',
'description': 'md5:e10f5e10b01a7f2c14ba815509fbb38d', 'thumbnail': 'md5:391a13cc087b42f626e9e65bbeaf0a11',
'thumbnail': 'https://images.globalplayer.com/images/551379?width=450&signature=oMLPZIoi5_dBSHnTMREW0Xg76mA=', 'description': 'md5:f015f2f6c6f6a807669ebcc9a0ca147c',
'title': 're:^Classic FM Hall of Fame.+$', 'title': 're:^Classic FM Hall of Fame.+$',
}, },
}] }]
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) video_id = self._match_id(url)
station = self._get_page_props(url, video_id)['playlistData'] meta = self._get_page_props(url, video_id)['playlistData']
stream_url = station['streamUrl']
return { return {
'id': video_id, 'url': meta['streamUrl'],
'url': stream_url, 'ext': 'aac',
'ext': self._request_ext(stream_url, video_id),
'vcodec': 'none', 'vcodec': 'none',
'id': video_id,
'is_live': True, 'is_live': True,
**traverse_obj(station, { **traverse_obj(meta, {
'title': 'title', 'thumbnail': ('image', {url_or_none}),
'description': 'description', 'description': ('description', {str}),
'thumbnail': 'image', 'title': ('title', {str}),
}), }),
} }
class GlobalPlayerAudioIE(GlobalPlayerBaseIE): 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 = [{ _TESTS = [{
# podcast # podcast
'url': 'https://www.globalplayer.com/podcasts/42KuaM/', 'url': 'https://www.globalplayer.com/podcasts/42KuaM/',
'playlist_mincount': 5, 'playlist_mincount': 2,
'info_dict': { 'info_dict': {
'id': '42KuaM', 'id': '42KuaM',
'title': 'Filthy Ritual',
'thumbnail': 'md5:60286e7d12d795bd1bbc9efc6cee643e', 'thumbnail': 'md5:60286e7d12d795bd1bbc9efc6cee643e',
'categories': ['Society & Culture', 'True Crime'], 'description': 'md5:17b7b9e3c76b2f4d9e31ccc4f0b66e32',
'uploader': 'Global', 'title': 'Filthy Ritual',
'description': 'md5:da5b918eac9ae319454a10a563afacf9',
}, },
}, { }, {
# radio catchup # radio catchup
'url': 'https://www.globalplayer.com/catchup/lbc/uk/46vyD7z/', 'url': 'https://www.globalplayer.com/catchup/lbc/uk/46vyD7z/',
'playlist_mincount': 3, 'playlist_mincount': 2,
'info_dict': { 'info_dict': {
'id': '46vyD7z', 'id': '46vyD7z',
'description': 'Nick Ferrari At Breakfast is Leading Britain\'s Conversation.', 'thumbnail': 'md5:664ad62a8fb920a2b8e264ed780eee3d',
'description': 'md5:53b6fa5ef71a3cff6628551bcc416384',
'title': 'Nick Ferrari', 'title': 'Nick Ferrari',
'thumbnail': 'md5:4df24d8a226f5b2508efbcc6ae874ebf',
}, },
}] }]
def _real_extract(self, url): 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) 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 { return {
'_type': 'playlist', '_type': 'playlist',
'id': video_id, 'id': video_id,
'entries': [self._extract_audio(ep, series) for ep in traverse_obj( 'entries': _entries(),
series, ('episodes', lambda _, v: v['id'] and v['streamUrl']))], **traverse_obj(meta, {
'categories': traverse_obj(series, ('categories', ..., 'name')) or None, 'thumbnail': ('image', 'url', {url_or_none}),
**traverse_obj(series, { 'description': ('description', {str}),
'description': 'description', 'title': ('title', {str}),
'thumbnail': 'imageUrl',
'title': 'title',
'uploader': 'itunesAuthor', # podcasts only
}), }),
} }
@@ -184,44 +177,42 @@ class GlobalPlayerAudioEpisodeIE(GlobalPlayerBaseIE):
_VALID_URL = r'https?://www\.globalplayer\.com/(?:(?P<podcast>podcasts)|catchup/\w+/\w+)/episodes/(?P<id>\w+)/?(?:$|[?#])' _VALID_URL = r'https?://www\.globalplayer\.com/(?:(?P<podcast>podcasts)|catchup/\w+/\w+)/episodes/(?P<id>\w+)/?(?:$|[?#])'
_TESTS = [{ _TESTS = [{
# podcast # podcast
'url': 'https://www.globalplayer.com/podcasts/episodes/7DrfNnE/', 'url': 'https://www.globalplayer.com/podcasts/episodes/7DrorSc/',
'info_dict': { 'info_dict': {
'id': '7DrfNnE', 'id': '7DrorSc',
'ext': 'mp3', 'ext': 'mp3',
'title': 'Filthy Ritual - Trailer',
'description': 'md5:1f1562fd0f01b4773b590984f94223e0',
'thumbnail': 'md5:60286e7d12d795bd1bbc9efc6cee643e', 'thumbnail': 'md5:60286e7d12d795bd1bbc9efc6cee643e',
'duration': 225.0, 'description': 'md5:372e5aa2b531f9eba863dfc67d007c1c',
'timestamp': 1681254900, 'title': 'Filthy Ritual - Trailer',
'series': 'Filthy Ritual',
'series_id': '42KuaM',
'upload_date': '20230411',
'uploader': 'Global',
}, },
}, { }, {
# radio catchup # radio catchup - test urls are removed after 7 days
'url': 'https://www.globalplayer.com/catchup/lbc/uk/episodes/2zGq26Vcv1fCWhddC4JAwETXWe/', 'url': 'https://www.globalplayer.com/catchup/lbc/uk/episodes/2zGmrV6DnvogKkNCXkwkQ8HQTA/',
'info_dict': { 'info_dict': {
'id': '2zGq26Vcv1fCWhddC4JAwETXWe', 'id': '2zGmrV6DnvogKkNCXkwkQ8HQTA',
'ext': 'm4a', 'ext': 'm4a',
'timestamp': 1682056800, 'thumbnail': 'md5:664ad62a8fb920a2b8e264ed780eee3d',
'series': 'Nick Ferrari', 'description': 'md5:53b6fa5ef71a3cff6628551bcc416384',
'thumbnail': 'md5:4df24d8a226f5b2508efbcc6ae874ebf',
'upload_date': '20230421',
'series_id': '46vyD7z',
'description': 'Nick Ferrari At Breakfast is Leading Britain\'s Conversation.',
'title': 'Nick Ferrari', 'title': 'Nick Ferrari',
'duration': 10800.0,
}, },
}] }]
def _real_extract(self, url): def _real_extract(self, url):
video_id, podcast = self._match_valid_url(url).group('id', 'podcast') video_id, podcast = self._match_valid_url(url).group('id', 'podcast')
props = self._get_page_props(url, video_id) 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( return {
episode, traverse_obj(episode, 'podcast', 'show', expected_type=dict) or {}) '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): class GlobalPlayerVideoIE(GlobalPlayerBaseIE):
@@ -231,9 +222,8 @@ class GlobalPlayerVideoIE(GlobalPlayerBaseIE):
'info_dict': { 'info_dict': {
'id': '2JsSZ7Gm2uP', 'id': '2JsSZ7Gm2uP',
'ext': 'mp4', 'ext': 'mp4',
'description': 'md5:6a9f063c67c42f218e42eee7d0298bfd',
'thumbnail': 'md5:d4498af48e15aae4839ce77b97d39550', 'thumbnail': 'md5:d4498af48e15aae4839ce77b97d39550',
'upload_date': '20230420', 'description': 'md5:6a9f063c67c42f218e42eee7d0298bfd',
'title': 'Treble Malakai Bayoh sings a sublime Handel aria at Classic FM Live', 'title': 'Treble Malakai Bayoh sings a sublime Handel aria at Classic FM Live',
}, },
}] }]
@@ -245,10 +235,9 @@ class GlobalPlayerVideoIE(GlobalPlayerBaseIE):
return { return {
'id': video_id, 'id': video_id,
**traverse_obj(meta, { **traverse_obj(meta, {
'url': 'url', 'url': ('url', {url_or_none}),
'thumbnail': ('image', 'url'), 'thumbnail': ('image', 'url', {url_or_none}),
'title': 'title', 'description': ('description', {str}),
'upload_date': ('publish_date', {unified_strdate}), 'title': ('title', {str}),
'description': 'description',
}), }),
} }
+26 -23
View File
@@ -116,41 +116,41 @@ class GoIE(AdobePassIE):
'params': {'skip_download': 'm3u8'}, 'params': {'skip_download': 'm3u8'},
'skip': 'This video requires AdobePass MSO credentials', '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': { 'info_dict': {
'id': 'VDKA39007340', 'id': 'VDKA39623200',
'ext': 'mp4', 'ext': 'mp4',
'title': 'Angel\'s Landing', 'title': 'New House / New Rules',
'description': 'md5:91bf084e785c968fab16734df7313446', 'description': 'md5:6f38b4b1649dc9f3a9e7acf911a70056',
'age_limit': 14, 'age_limit': 14,
'duration': 2523, 'duration': 2733,
'thumbnail': r're:https?://.+/.+\.jpg', 'thumbnail': r're:https?://.+/.+\.jpg',
'series': 'How I Escaped My Cult', 'series': 'Project Runway',
'season': 'Season 1', 'season': 'Season 21',
'season_number': 1, 'season_number': 21,
'episode': 'Episode 2', 'episode': 'Episode 1',
'episode_number': 2, 'episode_number': 1,
'timestamp': 1740038400.0, 'timestamp': 1754020800,
'upload_date': '20250220', 'upload_date': '20250801',
}, },
'params': {'skip_download': 'm3u8'}, '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': { 'info_dict': {
'id': 'VDKA39492078', 'id': 'VDKA35475602',
'ext': 'mp4', 'ext': 'mp4',
'title': 'Heart of the Emperors', 'title': 'The Pol Shebang',
'description': 'md5:4fc50a2878f030bb3a7eac9124dca677', 'description': 'md5:ccea1210c5ebcdc5c1a435f01bdb8b82',
'age_limit': 0, 'age_limit': 0,
'duration': 2775, 'duration': 1323,
'thumbnail': r're:https?://.+/.+\.jpg', 'thumbnail': r're:https?://.+/.+\.jpg',
'series': 'Secrets of the Penguins', 'series': 'The Incredible Pol Farm',
'season': 'Season 1', 'season': 'Season 1',
'season_number': 1, 'season_number': 1,
'episode': 'Episode 1', 'episode': 'Episode 14',
'episode_number': 1, 'episode_number': 14,
'timestamp': 1745204400.0, 'timestamp': 1704614400,
'upload_date': '20250421', 'upload_date': '20240107',
}, },
'params': {'skip_download': 'm3u8'}, 'params': {'skip_download': 'm3u8'},
}, { }, {
@@ -190,7 +190,10 @@ class GoIE(AdobePassIE):
site_info = self._SITE_INFO[site] site_info = self._SITE_INFO[site]
brand = site_info['brand'] 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'] video_id = video_data['id']
title = video_data['title'] title = video_data['title']
+4 -5
View File
@@ -3,6 +3,7 @@ import re
from .common import InfoExtractor from .common import InfoExtractor
from ..utils import ( from ..utils import (
determine_ext, determine_ext,
disposition_filename,
extract_attributes, extract_attributes,
filter_dict, filter_dict,
get_element_by_class, get_element_by_class,
@@ -184,9 +185,7 @@ class GoogleDriveIE(InfoExtractor):
def add_source_format(urlh): def add_source_format(urlh):
nonlocal title nonlocal title
if not title: if not title:
title = self._search_regex( title = disposition_filename(urlh.headers) or None
r'\bfilename="([^"]+)"', urlh.headers.get('Content-Disposition'),
'title', default=None)
formats.append({ formats.append({
# Use redirect URLs as download URLs in order to calculate # Use redirect URLs as download URLs in order to calculate
# correct cookies in _calc_cookies. # correct cookies in _calc_cookies.
@@ -198,7 +197,7 @@ class GoogleDriveIE(InfoExtractor):
'format_id': 'source', 'format_id': 'source',
'quality': 1, 'quality': 1,
}) })
if urlh.headers.get('Content-Disposition'): if urlh.headers.get_content_disposition():
add_source_format(urlh) add_source_format(urlh)
else: else:
confirmation_webpage = self._webpage_read_content( 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') get_element_html_by_id('download-form', confirmation_webpage) or '').get('action')
if confirmed_source_url: if confirmed_source_url:
urlh = request_source_file(confirmed_source_url, 'confirmed source', data=b'') 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) add_source_format(urlh)
else: else:
self.report_warning( self.report_warning(
+2 -2
View File
@@ -114,7 +114,7 @@ class ITVIE(InfoExtractor):
# See: https://github.com/yt-dlp/yt-dlp/issues/986 # See: https://github.com/yt-dlp/yt-dlp/issues/986
platform_tag_subs, featureset_subs = next( platform_tag_subs, featureset_subs = next(
((platform_tag, featureset) ((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'), if try_get(featureset, lambda x: x[2]) == 'outband-webvtt'),
(None, None)) (None, None))
@@ -143,7 +143,7 @@ class ITVIE(InfoExtractor):
# See: https://github.com/yt-dlp/yt-dlp/issues/986 # See: https://github.com/yt-dlp/yt-dlp/issues/986
platform_tag_video, featureset_video = next( platform_tag_video, featureset_video = next(
((platform_tag, featureset) ((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'}), if set(try_get(featureset, lambda x: x[:2]) or []) == {'aes', 'hls'}),
(None, None)) (None, None))
if not platform_tag_video or not featureset_video: if not platform_tag_video or not featureset_video:
+99
View File
@@ -3,6 +3,7 @@ import functools
import itertools import itertools
import json import json
import re import re
import urllib.parse
from .common import InfoExtractor, SearchInfoExtractor from .common import InfoExtractor, SearchInfoExtractor
from ..networking.exceptions import HTTPError from ..networking.exceptions import HTTPError
@@ -14,6 +15,7 @@ from ..utils import (
extract_attributes, extract_attributes,
float_or_none, float_or_none,
int_or_none, int_or_none,
join_nonempty,
parse_bitrate, parse_bitrate,
parse_iso8601, parse_iso8601,
parse_qs, parse_qs,
@@ -31,8 +33,10 @@ from ..utils import (
) )
from ..utils.traversal import ( from ..utils.traversal import (
find_element, find_element,
find_elements,
require, require,
traverse_obj, traverse_obj,
trim_str,
) )
@@ -1080,3 +1084,98 @@ class NiconicoLiveIE(NiconicoBaseIE):
'thumbnails': thumbnails, 'thumbnails': thumbnails,
'formats': formats, '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=' - '))
+41 -109
View File
@@ -2,10 +2,8 @@ import urllib.parse
from .common import InfoExtractor from .common import InfoExtractor
from ..compat import compat_etree_fromstring from ..compat import compat_etree_fromstring
from ..networking import HEADRequest
from ..utils import ( from ..utils import (
ExtractorError, ExtractorError,
float_or_none,
int_or_none, int_or_none,
qualities, qualities,
smuggle_url, smuggle_url,
@@ -21,7 +19,7 @@ from ..utils.traversal import find_element, traverse_obj
class OdnoklassnikiIE(InfoExtractor): class OdnoklassnikiIE(InfoExtractor):
_VALID_URL = r'''(?x) _VALID_URL = r'''(?x)
https?:// https?://
(?:(?:www|m|mobile)\.)? (?:www\.)?
(?:odnoklassniki|ok)\.ru/ (?:odnoklassniki|ok)\.ru/
(?: (?:
video(?P<embed>embed)?/| video(?P<embed>embed)?/|
@@ -34,21 +32,21 @@ class OdnoklassnikiIE(InfoExtractor):
_EMBED_REGEX = [r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//(?:odnoklassniki|ok)\.ru/videoembed/.+?)\1'] _EMBED_REGEX = [r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//(?:odnoklassniki|ok)\.ru/videoembed/.+?)\1']
_TESTS = [{ _TESTS = [{
'note': 'Coub embedded', 'note': 'Coub embedded',
'url': 'http://ok.ru/video/1484130554189', 'url': 'https://ok.ru/video/1484130554189',
'info_dict': { 'info_dict': {
'id': '1keok9', 'id': '1keok9',
'ext': 'mp4', 'ext': 'mp4',
'timestamp': 1545580896,
'view_count': int,
'thumbnail': r're:^https?://.*\.jpg$',
'title': 'Народная забава', 'title': 'Народная забава',
'uploader': 'Nevata', 'uploader': 'Nevata',
'upload_date': '20181223',
'age_limit': 0,
'uploader_id': 'nevata.s', 'uploader_id': 'nevata.s',
'view_count': int,
'like_count': int, 'like_count': int,
'duration': 8.08,
'repost_count': int, 'repost_count': int,
'age_limit': 0,
'duration': 8.08,
'thumbnail': r're:^https?://.*\.jpg$',
'timestamp': 1545580896,
'upload_date': '20181223',
}, },
}, { }, {
'note': 'vk.com embedded', 'note': 'vk.com embedded',
@@ -66,28 +64,25 @@ class OdnoklassnikiIE(InfoExtractor):
}, },
'skip': 'vk extractor error', 'skip': 'vk extractor error',
}, { }, {
# metadata in JSON, webm_dash with Firefox UA # metadataWebmUrl
'url': 'http://ok.ru/video/20079905452', 'url': 'https://ok.ru/video/20079905452',
'md5': '8f477d8931c531374a3e36daec617b2c', 'md5': '8f477d8931c531374a3e36daec617b2c',
'info_dict': { 'info_dict': {
'id': '20079905452', 'id': '20079905452',
'ext': 'webm', 'ext': 'webm',
'title': 'Культура меняет нас (прекрасный ролик!))', 'title': 'Культура меняет нас (прекрасный ролик!))',
'thumbnail': str,
'duration': 100,
'upload_date': '20141207',
'uploader_id': '330537914540',
'uploader': 'Виталий Добровольский',
'like_count': int, 'like_count': int,
'age_limit': 0, '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': { 'params': {
'format': 'bv[ext=webm]', 'format': 'bv[ext=webm]',
'http_headers': {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0'},
}, },
}, { }, {
# metadataUrl # metadataUrl
'url': 'http://ok.ru/video/63567059965189-0?fromTime=5', 'url': 'https://ok.ru/video/63567059965189-0?fromTime=5',
'md5': '2bae2f58eefe1b3d26f3926c4a64d2f3', 'md5': '2bae2f58eefe1b3d26f3926c4a64d2f3',
'info_dict': { 'info_dict': {
'id': '63567059965189-0', 'id': '63567059965189-0',
@@ -103,6 +98,7 @@ class OdnoklassnikiIE(InfoExtractor):
'start_time': 5, 'start_time': 5,
}, },
'params': {'skip_download': 'm3u8'}, 'params': {'skip_download': 'm3u8'},
'skip': 'Redirects to homepage',
}, { }, {
# YouTube embed (metadataUrl, provider == USER_YOUTUBE) # YouTube embed (metadataUrl, provider == USER_YOUTUBE)
'url': 'https://ok.ru/video/3952212382174', 'url': 'https://ok.ru/video/3952212382174',
@@ -132,9 +128,10 @@ class OdnoklassnikiIE(InfoExtractor):
'playable_in_embed': True, 'playable_in_embed': True,
'channel': 'BornToReact', '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) # 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': { 'info_dict': {
'id': '62036049272859-0', 'id': '62036049272859-0',
'ext': 'mp4', 'ext': 'mp4',
@@ -149,16 +146,6 @@ class OdnoklassnikiIE(InfoExtractor):
'skip_download': True, 'skip_download': True,
}, },
'skip': 'Video has not been found', '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', 'note': 'subtitles',
'url': 'https://ok.ru/video/4249587550747', 'url': 'https://ok.ru/video/4249587550747',
@@ -178,56 +165,47 @@ class OdnoklassnikiIE(InfoExtractor):
'params': { 'params': {
'skip_download': True, '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, 'only_matching': True,
}, { }, {
'url': 'http://www.ok.ru/video/20648036891', 'url': 'https://www.ok.ru/video/20648036891',
'only_matching': True, 'only_matching': True,
}, { }, {
'url': 'http://www.ok.ru/videoembed/20648036891', 'url': 'https://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',
'only_matching': True, 'only_matching': True,
}, { }, {
'url': 'https://www.ok.ru/live/484531969818', 'url': 'https://www.ok.ru/live/484531969818',
'only_matching': True, '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 # Paid video
'url': 'https://ok.ru/video/954886983203', 'url': 'https://ok.ru/video/954886983203',
'only_matching': True, '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 = [{ _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': { 'info_dict': {
'id': '3950343629563', 'id': '9178551814752',
'ext': 'mp4', 'ext': 'mp4',
'thumbnail': 'https://i.mycdn.me/videoPreview?id=2776238394107&type=37&idx=11&tkn=F3ejkUFcpuI4DnMRxrDGcH5YcmM&fn=external_8', 'title': 'В ПАРКЕ ЧАИР - РЕТРО ХИТЫ - СОВЕТСКИЕ ПЕСНИ',
'title': 'Заяц Бусти.mp4', 'like_count': int,
'uploader_id': '571368965883', 'duration': 3260,
'like_count': 0, 'thumbnail': 'https://iv.okcdn.ru/videoPreview?id=8382455220832&type=32&idx=12&tkn=FpC95WqL8aCthA4GNKSHpoEMgg4&fn=external_8',
'duration': 10444,
}, },
'skip': 'Site no longer embeds',
}] }]
def _clear_cookies(self, cdn_url): def _clear_cookies(self, cdn_url):
@@ -242,16 +220,6 @@ class OdnoklassnikiIE(InfoExtractor):
yield smuggle_url(x, {'referrer': url}) yield smuggle_url(x, {'referrer': url})
def _real_extract(self, 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( start_time = int_or_none(urllib.parse.parse_qs(
urllib.parse.urlparse(url).query).get('fromTime', [None])[0]) urllib.parse.urlparse(url).query).get('fromTime', [None])[0])
@@ -261,7 +229,6 @@ class OdnoklassnikiIE(InfoExtractor):
webpage = self._download_webpage( webpage = self._download_webpage(
f'https://ok.ru/{mode}/{video_id}', video_id, f'https://ok.ru/{mode}/{video_id}', video_id,
note='Downloading desktop webpage',
headers={'Referer': smuggled['referrer']} if smuggled.get('referrer') else {}) headers={'Referer': smuggled['referrer']} if smuggled.get('referrer') else {})
error = traverse_obj(webpage, {find_element(cls='vp_video_stub_txt')}) error = traverse_obj(webpage, {find_element(cls='vp_video_stub_txt')})
@@ -287,9 +254,7 @@ class OdnoklassnikiIE(InfoExtractor):
flashvars = player['flashvars'] flashvars = player['flashvars']
metadata = flashvars.get('metadata') metadata = flashvars.get('metadata')
if metadata: if not metadata:
metadata = self._parse_json(metadata, video_id)
else:
data = {} data = {}
st_location = flashvars.get('location') st_location = flashvars.get('location')
if st_location: if st_location:
@@ -302,7 +267,7 @@ class OdnoklassnikiIE(InfoExtractor):
movie = metadata['movie'] movie = metadata['movie']
# Some embedded videos may not contain title in movie dict (e.g. # 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 # here and it's going to be extracted later by an extractor that
# will process the actual embed. # will process the actual embed.
provider = metadata.get('provider') provider = metadata.get('provider')
@@ -416,36 +381,3 @@ class OdnoklassnikiIE(InfoExtractor):
info['formats'] = formats info['formats'] = formats
return info 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',
}],
}
+18 -19
View File
@@ -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_NAME = 'showroom:live'
IE_DESC = 'SHOWROOM' IE_DESC = 'SHOWROOM'
@@ -29,19 +33,11 @@ class ShowRoomLiveIE(InfoExtractor):
def _real_extract(self, url): def _real_extract(self, url):
broadcaster_id = self._match_id(url) broadcaster_id = self._match_id(url)
webpage = self._download_webpage( room_status = self._download_json(
url, broadcaster_id, headers={'Accept-Language': 'ja'}) f'{self._API_BASE}/room/status', broadcaster_id,
nuxt_data = self._search_nuxt_json(webpage, broadcaster_id)['data'] query={'room_url_key': broadcaster_id})
start_timestamp = traverse_obj(room_status, ('started_at', {int_or_none}))
cookies = self._get_cookies(url) is_live = traverse_obj(room_status, ('is_live', {bool}))
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}))
if not is_live: if not is_live:
if start_timestamp: if start_timestamp:
@@ -59,12 +55,15 @@ class ShowRoomLiveIE(InfoExtractor):
} }
raise UserNotLive(video_id=broadcaster_id) 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 = traverse_obj(room_profile, (
('room_name', 'main_name'), {clean_html}, filter, any)) ('room_name', 'main_name'), {clean_html}, filter, any))
streaming_url_list = self._download_json( 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}) broadcaster_id, query={'room_id': room_id})
m3u8_url = traverse_obj(streaming_url_list, ( m3u8_url = traverse_obj(streaming_url_list, (
'streaming_url_list', lambda _, v: v['type'] == 'hls_all', 'streaming_url_list', lambda _, v: v['type'] == 'hls_all',
@@ -84,13 +83,13 @@ class ShowRoomLiveIE(InfoExtractor):
'description': ('description', {clean_html}, filter), 'description': ('description', {clean_html}, filter),
'genres': ('genre_name', {clean_html}, filter, all, filter), 'genres': ('genre_name', {clean_html}, filter, all, filter),
'tags': ('live_tags', ..., '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}), 'view_count': ('view_num', {int_or_none}),
}), }),
} }
class ShowRoomVodIE(InfoExtractor): class ShowRoomVodIE(ShowRoomBaseIE):
IE_NAME = 'showroom:vod' IE_NAME = 'showroom:vod'
_VALID_URL = r'https?://(?:www\.)?showroom-live\.com/episode/watch\?(?:[^#]+&)?id=(?P<id>\w+)' _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})) {extract_attributes}, 'data-episode', {json.loads}, {dict}))
streaming_url_list = self._download_json( 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}) episode_id, query={'episode_id': episode_id})
m3u8_url = traverse_obj(streaming_url_list, ( m3u8_url = traverse_obj(streaming_url_list, (
'streaming_url_list', 'hls_all', 'streaming_url_list', 'hls_all',
+3 -3
View File
@@ -46,7 +46,7 @@ class TedTalkIE(TedBaseIE):
webpage = self._download_webpage(url, display_id) webpage = self._download_webpage(url, display_id)
talk_info = self._search_nextjs_data(webpage, display_id)['props']['pageProps']['videoData'] talk_info = self._search_nextjs_data(webpage, display_id)['props']['pageProps']['videoData']
video_id = talk_info['id'] 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 http_url = None
formats, subtitles = [], {} formats, subtitles = [], {}
@@ -193,8 +193,8 @@ class TedPlaylistIE(TedBaseIE):
'url': 'https://www.ted.com/playlists/171/the_most_popular_talks_of_all', 'url': 'https://www.ted.com/playlists/171/the_most_popular_talks_of_all',
'info_dict': { 'info_dict': {
'id': '171', 'id': '171',
'title': 'The most popular talks of all time', 'title': 'The most popular TED Talks of all time',
'description': 'md5:d2f22831dc86c7040e733a3cb3993d78', 'description': 'md5:5346ef094754d2edd7e1a4cd3a166168',
}, },
'playlist_mincount': 25, 'playlist_mincount': 25,
}] }]
+24 -36
View File
@@ -1,23 +1,17 @@
import re
from .common import InfoExtractor from .common import InfoExtractor
from ..networking import Request
from ..utils import ( from ..utils import (
ExtractorError, ExtractorError,
int_or_none, int_or_none,
js_to_json, js_to_json,
strip_or_none, strip_or_none,
traverse_obj,
url_or_none, url_or_none,
urlencode_postdata,
) )
from ..utils.traversal import require, traverse_obj
class TubiTvIE(InfoExtractor): class TubiTvIE(InfoExtractor):
IE_NAME = 'tubitv' 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+)' _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 = [{ _TESTS = [{
'url': 'https://tubitv.com/movies/100004539/the-39-steps', 'url': 'https://tubitv.com/movies/100004539/the-39-steps',
'info_dict': { 'info_dict': {
@@ -27,7 +21,7 @@ class TubiTvIE(InfoExtractor):
'description': 'md5:bb2f2dd337f0dc58c06cb509943f54c8', 'description': 'md5:bb2f2dd337f0dc58c06cb509943f54c8',
'uploader_id': 'abc2558d54505d4f0f32be94f2e7108c', 'uploader_id': 'abc2558d54505d4f0f32be94f2e7108c',
'release_year': 1935, 'release_year': 1935,
'thumbnail': r're:^https?://.+\.(jpe?g|png)$', 'thumbnail': r're:^https?://canvas-lb\.tubitv\.com/.+',
'duration': 5187, 'duration': 5187,
}, },
'params': {'skip_download': 'm3u8'}, 'params': {'skip_download': 'm3u8'},
@@ -44,7 +38,7 @@ class TubiTvIE(InfoExtractor):
'season_number': 1, 'season_number': 1,
'uploader_id': '2a9273e728c510d22aa5c57d0646810b', 'uploader_id': '2a9273e728c510d22aa5c57d0646810b',
'release_year': 2011, 'release_year': 2011,
'thumbnail': r're:^https?://.+\.(jpe?g|png)$', 'thumbnail': r're:^https?://canvas-lb\.tubitv\.com/.+',
'duration': 1376, 'duration': 1376,
}, },
'params': {'skip_download': 'm3u8'}, 'params': {'skip_download': 'm3u8'},
@@ -82,24 +76,11 @@ class TubiTvIE(InfoExtractor):
_UNPLAYABLE_FORMATS = ('hlsv6_widevine', 'hlsv6_widevine_nonclearlead', 'hlsv6_playready_psshv0', _UNPLAYABLE_FORMATS = ('hlsv6_widevine', 'hlsv6_widevine_nonclearlead', 'hlsv6_playready_psshv0',
'hlsv6_fairplay', 'dash_widevine', 'dash_widevine_nonclearlead') '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): def _real_extract(self, url):
video_id, video_type = self._match_valid_url(url).group('id', 'type') 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( video_data = self._search_json(
r'window\.__data\s*=', webpage, 'data', video_id, r'window\.__data\s*=', webpage, 'data', video_id,
transform_source=js_to_json)['video']['byId'][video_id] transform_source=js_to_json)['video']['byId'][video_id]
@@ -113,7 +94,11 @@ class TubiTvIE(InfoExtractor):
if resource_type == 'dash': if resource_type == 'dash':
formats.extend(self._extract_mpd_formats(manifest_url, video_id, mpd_id=resource_type, fatal=False)) formats.extend(self._extract_mpd_formats(manifest_url, video_id, mpd_id=resource_type, fatal=False))
elif resource_type in ('hlsv3', 'hlsv6'): 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: elif resource_type in self._UNPLAYABLE_FORMATS:
drm_formats = True drm_formats = True
else: else:
@@ -162,31 +147,34 @@ class TubiTvShowIE(InfoExtractor):
'id': 'the-joy-of-painting-with-bob-ross', '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, 'playlist_count': 26,
'info_dict': { 'info_dict': {
'id': 'the-saddle-club-season-1', 'id': 'the-saddle-club-season-1',
}, },
}, { }, {
'url': 'https://tubitv.com/series/2311/the-saddle-club/season-3', 'url': 'https://tubitv.com/series/300000435/the-saddle-club/season-2',
'playlist_count': 19, 'playlist_count': 26,
'info_dict': { 'info_dict': {
'id': 'the-saddle-club-season-3', 'id': 'the-saddle-club-season-2',
}, },
}, { }, {
'url': 'https://tubitv.com/series/2311/the-saddle-club/', 'url': 'https://tubitv.com/series/300000435/the-saddle-club/',
'playlist_mincount': 71, 'playlist_mincount': 52,
'info_dict': { 'info_dict': {
'id': 'the-saddle-club', 'id': 'the-saddle-club',
}, },
}] }]
def _entries(self, show_url, playlist_id, selected_season): 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( react_query_state = self._search_json(
r'window\.__REACT_QUERY_STATE__\s*=', webpage, 'data', playlist_id, r'window\.__REACT_QUERY_STATE__\s*=', webpage,
transform_source=js_to_json)['queries'][0]['state']['data'] '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 # 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}] path = [lambda _, v: str(v['number']) == selected_season] if selected_season else [..., {dict}]
+1 -1
View File
@@ -44,7 +44,7 @@ class TwitchBaseIE(InfoExtractor):
'CollectionSideBar': '016e1e4ccee0eb4698eb3bf1a04dc1c077fb746c78c82bac9a8f0289658fbd1a', 'CollectionSideBar': '016e1e4ccee0eb4698eb3bf1a04dc1c077fb746c78c82bac9a8f0289658fbd1a',
'FilterableVideoTower_Videos': '67004f7881e65c297936f32c75246470629557a393788fb5a69d6d9a25a8fd5f', 'FilterableVideoTower_Videos': '67004f7881e65c297936f32c75246470629557a393788fb5a69d6d9a25a8fd5f',
'ClipsCards__User': '1cd671bfa12cec480499c087319f26d21925e9695d1f80225aae6a4354f23088', 'ClipsCards__User': '1cd671bfa12cec480499c087319f26d21925e9695d1f80225aae6a4354f23088',
'ShareClipRenderStatus': '0a02bb974443b576f5579aab0fef1d4b7f44e58a8a256f0c5adfead0db70640f', 'ShareClipRenderStatus': '2db6a3b20eabf510bd3cf465ae2408834b59eb6b8af89ca73ab1486cacecfb63',
'ChannelCollectionsContent': '5247910a19b1cd2b760939bf4cba4dcbd3d13bdf8c266decd16956f6ef814077', 'ChannelCollectionsContent': '5247910a19b1cd2b760939bf4cba4dcbd3d13bdf8c266decd16956f6ef814077',
'StreamMetadata': 'ad022ca32220d5523d03a23cbcb5beaa1e0999889c1f8f78f9f2520dafb5cae6', 'StreamMetadata': 'ad022ca32220d5523d03a23cbcb5beaa1e0999889c1f8f78f9f2520dafb5cae6',
'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01', 'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01',
+16
View File
@@ -125,6 +125,7 @@ INNERTUBE_CLIENTS = {
'client': { 'client': {
'clientName': 'WEB_EMBEDDED_PLAYER', 'clientName': 'WEB_EMBEDDED_PLAYER',
'clientVersion': '2.20260708.00.00', '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, 'INNERTUBE_CONTEXT_CLIENT_NAME': 56,
@@ -363,6 +364,21 @@ INNERTUBE_CLIENTS = {
'INNERTUBE_CONTEXT_CLIENT_NAME': 7, 'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
'SUPPORTS_COOKIES': True, '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': { 'tv_simply': {
'INNERTUBE_CONTEXT': { 'INNERTUBE_CONTEXT': {
'client': { 'client': {
+4 -5
View File
@@ -140,11 +140,11 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
_RETURN_TYPE = 'video' # XXX: How to handle multifeed? _RETURN_TYPE = 'video' # XXX: How to handle multifeed?
_SUBTITLE_FORMATS = ('json3', 'srv1', 'srv2', 'srv3', 'ttml', 'srt', 'vtt') _SUBTITLE_FORMATS = ('json3', 'srv1', 'srv2', 'srv3', 'ttml', 'srt', 'vtt')
_DEFAULT_CLIENTS = ('visionos', 'web') _DEFAULT_CLIENTS = ('tv_samsung', 'visionos', 'web')
_DEFAULT_JSLESS_CLIENTS = ('visionos',) _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) # 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') _WEBPAGE_CLIENTS = ('web', 'web_safari')
_DEFAULT_WEBPAGE_CLIENT = 'web' _DEFAULT_WEBPAGE_CLIENT = 'web'
@@ -3151,7 +3151,6 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
and any(p.is_available() for p in self._jsc_director.providers.values()) and any(p.is_available() for p in self._jsc_director.providers.values())
): ):
append_client('web_embedded') append_client('web_embedded')
append_client('tv_downgraded')
# web_embedded can work around age-gate and age-verification for some embeddable videos # web_embedded can work around age-gate and age-verification for some embeddable videos
if self._is_agegated(pr) and variant != 'web_embedded': if self._is_agegated(pr) and variant != 'web_embedded':
@@ -3640,7 +3639,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
f['source_preference'] = -1 f['source_preference'] = -1
# Deprioritize since its pre-merged m3u8 formats may have lower quality audio streams # 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 f['source_preference'] -= 1
if missing_pot: if missing_pot:
+1 -1
View File
@@ -76,7 +76,7 @@ class YoutubeIEContentProviderLogger(IEContentProviderLogger):
if self.log_level <= self.LogLevel.ERROR: if self.log_level <= self.LogLevel.ERROR:
self.__ie._downloader.report_error( self.__ie._downloader.report_error(
self._format_msg(message), is_error=False, 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: class PoTokenCache:
+8 -8
View File
@@ -52,25 +52,27 @@ def ssl_load_windows_store_certs(ssl_context, storename):
def make_socks_proxy_opts(socks_proxy): def make_socks_proxy_opts(socks_proxy):
url_components = urllib.parse.urlparse(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 socks_type = ProxyType.SOCKS5
rdns = False rdns = False
elif url_components.scheme.lower() == 'socks5h': elif scheme == 'socks5h':
socks_type = ProxyType.SOCKS5 socks_type = ProxyType.SOCKS5
rdns = True rdns = True
elif url_components.scheme.lower() == 'socks4': elif scheme == 'socks4':
socks_type = ProxyType.SOCKS4 socks_type = ProxyType.SOCKS4
rdns = False rdns = False
elif url_components.scheme.lower() == 'socks4a': elif scheme == 'socks4a':
socks_type = ProxyType.SOCKS4A socks_type = ProxyType.SOCKS4A
rdns = True rdns = True
else: 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): def unquote_if_non_empty(s):
if not s: if not s:
return s return s
return urllib.parse.unquote_plus(s) return urllib.parse.unquote(s)
return { return {
'proxytype': socks_type, 'proxytype': socks_type,
'addr': url_components.hostname, 'addr': url_components.hostname,
@@ -108,8 +110,6 @@ def make_ssl_context(
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = verify context.check_hostname = verify
context.verify_mode = ssl.CERT_REQUIRED if verify else ssl.CERT_NONE 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 context.keylog_filename = os.environ.get('SSLKEYLOGFILE') or None
# Some servers may reject requests if ALPN extension is not sent. See: # Some servers may reject requests if ALPN extension is not sent. See:
+1 -1
View File
@@ -123,7 +123,7 @@ class RequestsResponseAdapter(Response):
# Work around issue with `.read(amt)` then `.read()` # Work around issue with `.read(amt)` then `.read()`
# See: https://github.com/urllib3/urllib3/issues/3636 # See: https://github.com/urllib3/urllib3/issues/3636
if amt is None: 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) read_chunk = functools.partial(self.fp.read, 1 << 20, decode_content=True)
return b''.join(iter(read_chunk, b'')) return b''.join(iter(read_chunk, b''))
# Interact with urllib3 response directly. # Interact with urllib3 response directly.
+5 -17
View File
@@ -161,17 +161,7 @@ class HTTPHandler(urllib.request.AbstractHTTPHandler):
if decoded_response is not None: if decoded_response is not None:
resp = urllib.request.addinfourl(io.BytesIO(decoded_response), old_resp.headers, old_resp.url, old_resp.code) resp = urllib.request.addinfourl(io.BytesIO(decoded_response), old_resp.headers, old_resp.url, old_resp.code)
resp.msg = old_resp.msg 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 return resp
https_request = http_request https_request = http_request
@@ -296,13 +286,9 @@ class UrllibResponseAdapter(Response):
""" """
def __init__(self, res: http.client.HTTPResponse | urllib.response.addinfourl): 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__( super().__init__(
fp=res, headers=res.headers, url=res.url, 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): def read(self, amt=None):
if self.closed: if self.closed:
@@ -317,7 +303,9 @@ class UrllibResponseAdapter(Response):
# urllib's addinfourl does not close the underlying fp automatically when fully read # urllib's addinfourl does not close the underlying fp automatically when fully read
if isinstance(underlying, io.BytesIO): if isinstance(underlying, io.BytesIO):
# data URLs or in-memory responses (e.g. gzip/deflate/brotli decoded) # 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() self.close()
elif isinstance(underlying, io.BufferedReader) and amt is None: elif isinstance(underlying, io.BufferedReader) and amt is None:
# file URLs. # file URLs.
+5
View File
@@ -164,6 +164,11 @@ class WebsocketsRH(WebSocketRequestHandler):
user_agent_header=None, user_agent_header=None,
ssl=ssl_ctx if wsuri.secure else None, ssl=ssl_ctx if wsuri.secure else None,
close_timeout=0, # not ideal, but prevents yt-dlp hanging 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) return WebsocketsResponseAdapter(conn, url=request.url)
+16 -5
View File
@@ -227,6 +227,10 @@ def _make_label(origin, tag, version=None):
return f'{origin}@{tag}' return f'{origin}@{tag}'
class _GitHubError(Exception):
pass
@dataclass @dataclass
class UpdateInfo: class UpdateInfo:
""" """
@@ -319,7 +323,14 @@ class Updater:
path = 'latest/download' if tag == 'latest' else f'download/{tag}' path = 'latest/download' if tag == 'latest' else f'download/{tag}'
url = f'https://github.com/{self.requested_repo}/releases/{path}/{name}' url = f'https://github.com/{self.requested_repo}/releases/{path}/{name}'
self.ydl.write_debug(f'Downloading {name} from {url}') 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): def _call_api(self, tag):
tag = f'tags/{tag}' if tag != 'latest' else tag tag = f'tags/{tag}' if tag != 'latest' else tag
@@ -358,7 +369,7 @@ class Updater:
for tag in source_tags: for tag in source_tags:
try: try:
return self._download_asset('_update_spec', tag=tag).decode() 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: if isinstance(error, HTTPError) and error.status == 404:
continue continue
self._report_network_error(f'fetch update spec: {error}') self._report_network_error(f'fetch update spec: {error}')
@@ -462,7 +473,7 @@ class Updater:
if not is_non_updateable(): if not is_non_updateable():
try: try:
hashes = self._download_asset('SHA2-256SUMS', result_tag) 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: if not isinstance(error, HTTPError) or error.status != 404:
self._report_network_error(f'fetch checksums: {error}') self._report_network_error(f'fetch checksums: {error}')
return None return None
@@ -525,7 +536,7 @@ class Updater:
try: try:
newcontent = self._download_asset(update_info.binary_name, update_info.tag) 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: if isinstance(e, HTTPError) and e.status == 404:
return self._report_error( return self._report_error(
f'The requested tag {self.requested_repo}@{update_info.tag} does not exist', True) 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 self.ydl._download_retcode = 100
def _report_permission_error(self, file): 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): def _report_network_error(self, action, delim=';', tag=None):
if not tag: if not tag:
+34 -30
View File
@@ -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}']") expr = xpath + (f'[@{key}]' if val is None else f"[@{key}='{val}']")
return node.find(expr) 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): def xpath_with_ns(path, ns_map):
"""Expand namespace-prefixed names to Clark notation."""
components = [c.split(':') for c in path.split('/')] components = [c.split(':') for c in path.split('/')]
replaced = [] replaced = []
for c in components: 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') self.__text_mode = kwargs.get('encoding') or kwargs.get('errors') or text or kwargs.get('universal_newlines')
if text is True: if text is True:
kwargs['universal_newlines'] = True # For 3.6 compatibility kwargs['text'] = True
kwargs.setdefault('encoding', 'utf-8') kwargs.setdefault('encoding', 'utf-8')
kwargs.setdefault('errors', 'replace') kwargs.setdefault('errors', 'replace')
@@ -1012,7 +1010,7 @@ class ExtractorError(YoutubeDLError):
def format_traceback(self): def format_traceback(self):
return join_nonempty( return join_nonempty(
self.traceback and ''.join(traceback.format_tb(self.traceback)), 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 delim='\n') or None
def __setattr__(self, name, value): def __setattr__(self, name, value):
@@ -1960,14 +1958,8 @@ def setproctitle(title):
libc = ctypes.cdll.LoadLibrary('libc.so.6') libc = ctypes.cdll.LoadLibrary('libc.so.6')
except OSError: except OSError:
return return
except TypeError:
# LoadLibrary in Windows Python 2.7.13 only expects buf = ctypes.create_string_buffer(title.encode())
# 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
try: try:
# PR_SET_NAME = 15 Ref: /usr/include/linux/prctl.h # PR_SET_NAME = 15 Ref: /usr/include/linux/prctl.h
libc.prctl(15, buf, 0, 0, 0) 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 Encode a dict to RFC 7578-compliant form-data
data: data:
A dict where keys and values can be either Unicode or bytes-like A dict where keys and values can be either str or bytes-like objects.
objects.
boundary: boundary:
If specified a Unicode object, it's used as the boundary. Otherwise An ASCII string to use as the boundary. If omitted, a random boundary
a random boundary is generated. is generated.
Reference: https://tools.ietf.org/html/rfc7578 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] return 'mkv' if allow_mkv else preferences[-1]
def urlhandle_detect_ext(url_handle, default=NO_DEFAULT): def disposition_filename(headers):
getheader = url_handle.headers.get 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'): return email.utils.collapse_rfc2231_value(filename).strip() if filename is not None else None
if m := re.match(r'attachment;\s*filename="(?P<filename>[^"]+)"', cd):
if ext := determine_ext(m.group('filename'), default_ext=None):
return ext def urlhandle_detect_ext(url_handle, default=NO_DEFAULT):
headers = url_handle.headers
return ( return (
determine_ext(getheader('x-amz-meta-name'), default_ext=None) determine_ext(disposition_filename(headers), default_ext=None)
or getheader('x-amz-meta-file-type') or determine_ext(headers.get('x-amz-meta-name'), default_ext=None)
or mimetype2ext(getheader('Content-Type'), default=default)) or headers.get('x-amz-meta-file-type')
or mimetype2ext(headers.get('Content-Type'), default=default))
def encode_data_uri(data, mime_type): def encode_data_uri(data, mime_type):
@@ -3391,10 +3390,15 @@ class download_range_func:
def __eq__(self, other): def __eq__(self, other):
return (isinstance(other, download_range_func) 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): 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): def parse_dfxp_time_expr(time_expr):
@@ -3422,7 +3426,7 @@ def ass_subtitles_timecode(seconds):
def dfxp2srt(dfxp_data): def dfxp2srt(dfxp_data):
""" """
@param dfxp_data A bytes-like object containing 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 = ( LEGACY_NAMESPACES = (
(b'http://www.w3.org/ns/ttml', [ (b'http://www.w3.org/ns/ttml', [
@@ -5090,7 +5094,7 @@ class function_with_repr:
class Namespace(types.SimpleNamespace): class Namespace(types.SimpleNamespace):
"""Immutable namespace""" """SimpleNamespace iterable over attribute values"""
def __iter__(self): def __iter__(self):
return iter(self.__dict__.values()) return iter(self.__dict__.values())
+91 -18
View File
@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import array
import base64 import base64
import datetime as dt import datetime as dt
import math import math
import re import re
import struct
from .._utils import parse_iso8601 from .._utils import parse_iso8601
@@ -22,13 +22,13 @@ _ARRAY_TYPE_LOOKUP = {
'Uint8ClampedArray': 'B', 'Uint8ClampedArray': 'B',
'Int16Array': 'h', 'Int16Array': 'h',
'Uint16Array': 'H', 'Uint16Array': 'H',
'Float16Array': 'e',
'Int32Array': 'i', 'Int32Array': 'i',
'Uint32Array': 'I', 'Uint32Array': 'I',
'Float32Array': 'f', 'Float32Array': 'f',
'Float64Array': 'd', 'Float64Array': 'd',
'BigInt64Array': 'l', 'BigInt64Array': 'q',
'BigUint64Array': 'L', 'BigUint64Array': 'Q',
'ArrayBuffer': 'B',
} }
@@ -85,30 +85,31 @@ def parse_iter(parsed: typing.Any, /, *, revivers: dict[str, collections.abc.Cal
if isinstance(value, list): if isinstance(value, list):
if value and isinstance(value[0], str): if value and isinstance(value[0], str):
type_name = value[0]
# TODO: implement zips `strict=True` # TODO: implement zips `strict=True`
if reviver := revivers.get(value[0]): if reviver := revivers.get(type_name):
if value[1] == source: if value[1] == source:
# XXX: avoid infinite loop # 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 continue
# inverse order: resolve index, revive value # 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])) stack.append((target, index, value[1]))
continue continue
elif value[0] == 'Date': elif type_name == 'Date':
try: try:
result = dt.datetime.fromtimestamp(parse_iso8601(value[1]), tz=dt.timezone.utc) result = dt.datetime.fromtimestamp(parse_iso8601(value[1]), tz=dt.timezone.utc)
except Exception: except Exception:
yield ValueError(f'invalid date: {value[1]!r}') yield ValueError(f'invalid date: {value[1]!r}')
result = None result = None
elif value[0] == 'Set': elif type_name == 'Set':
result = [None] * (len(value) - 1) result = [None] * (len(value) - 1)
for offset, new_source in enumerate(value[1:]): for offset, new_source in enumerate(value[1:]):
stack.append((result, offset, new_source)) stack.append((result, offset, new_source))
elif value[0] == 'Map': elif type_name == 'Map':
result = [] result = []
for key, new_source in zip(*(iter(value[1:]),) * 2, strict=True): for key, new_source in zip(*(iter(value[1:]),) * 2, strict=True):
pair = [None, None] 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)) stack.append((pair, 1, new_source))
result.append(pair) result.append(pair)
elif value[0] == 'RegExp': elif type_name == 'RegExp':
# XXX: use jsinterp to translate regex flags # XXX: use jsinterp to translate regex flags
# currently ignores `value[2]` # currently ignores `value[2]`
result = re.compile(value[1]) result = re.compile(value[1])
elif value[0] == 'Object': elif type_name == 'Object':
result = value[1] result = value[1]
elif value[0] == 'BigInt': elif type_name == 'BigInt':
result = int(value[1]) result = int(value[1])
elif value[0] == 'null': elif type_name == 'null':
result = {} result = {}
for key, new_source in zip(*(iter(value[1:]),) * 2, strict=True): for key, new_source in zip(*(iter(value[1:]),) * 2, strict=True):
stack.append((result, key, new_source)) stack.append((result, key, new_source))
elif value[0] in _ARRAY_TYPE_LOOKUP: elif type_name == 'ArrayBuffer':
typecode = _ARRAY_TYPE_LOOKUP[value[0]] 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]) 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: else:
yield TypeError(f'invalid type at {source}: {value[0]!r}') yield TypeError(f'Invalid type at {source}: {type_name!r}')
result = None result = None
else: else:
result = len(value) * [None] result = len(value) * [None]
+2 -2
View File
@@ -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'): if not url_or_none(sub.get('url')) and not sub.get('data'):
continue continue
sub_id = sub.pop('id', None) 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: if not lang:
continue continue
sub_id = lang sub_id = lang
sub_ext = sub.get('ext') sub_ext = sub.get('ext')
if not isinstance(sub_ext, str): if not isinstance(sub_ext, str) or not sub_ext:
if not ext: if not ext:
sub.pop('ext', None) sub.pop('ext', None)
else: else: