mirror of
https://github.com/yt-dlp/yt-dlp
synced 2025-12-17 14:45:42 +07:00
Compare commits
172 Commits
2025.03.31
...
2025.06.30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30fa54280b | ||
|
|
b018784498 | ||
|
|
11b9416e10 | ||
|
|
35fc33fbc5 | ||
|
|
b16722ede8 | ||
|
|
500761e41a | ||
|
|
2ba5391cd6 | ||
|
|
e9f157669e | ||
|
|
958153a226 | ||
|
|
1b88384634 | ||
|
|
7b81634fb1 | ||
|
|
7e2504f941 | ||
|
|
4bd9a7ade7 | ||
|
|
b5bd057fe8 | ||
|
|
5e292baad6 | ||
|
|
0a6b104489 | ||
|
|
06c1a8cdff | ||
|
|
99b85ac102 | ||
|
|
eff0759705 | ||
|
|
1838a1ce5d | ||
|
|
2600849bad | ||
|
|
3bd3029160 | ||
|
|
a4ce4327c9 | ||
|
|
c57412d1f9 | ||
|
|
5b559d0072 | ||
|
|
8f94b76cbf | ||
|
|
ff6f94041a | ||
|
|
73bf102116 | ||
|
|
1722c55400 | ||
|
|
e6bd4a3da2 | ||
|
|
51887484e4 | ||
|
|
ba090caeaa | ||
|
|
339614a173 | ||
|
|
aa863ddab9 | ||
|
|
db162b76f6 | ||
|
|
e3c605a61f | ||
|
|
97ddfefeb4 | ||
|
|
a8bf0011bd | ||
|
|
13e5516271 | ||
|
|
03dba2012d | ||
|
|
5d96527be8 | ||
|
|
1fd0e88b67 | ||
|
|
231349786e | ||
|
|
f37d599a69 | ||
|
|
9e38b273b7 | ||
|
|
4e7c1ea346 | ||
|
|
e1b6062f8c | ||
|
|
c723c4e5e7 | ||
|
|
148a1eb4c5 | ||
|
|
85c8a405e3 | ||
|
|
943083edcd | ||
|
|
3fe72e9eea | ||
|
|
d30a49742c | ||
|
|
6d265388c6 | ||
|
|
a9b3700698 | ||
|
|
201812100f | ||
|
|
cc749a8a3b | ||
|
|
f7bbf5a617 | ||
|
|
b5be29fa58 | ||
|
|
6121559e02 | ||
|
|
2e5bf002da | ||
|
|
6693d66033 | ||
|
|
b094747e93 | ||
|
|
98f8eec956 | ||
|
|
0daddc780d | ||
|
|
2d7949d564 | ||
|
|
ed108b3ea4 | ||
|
|
eee90acc47 | ||
|
|
711c5d5d09 | ||
|
|
89c1b349ad | ||
|
|
0ee1102268 | ||
|
|
7794374de8 | ||
|
|
538eb30567 | ||
|
|
f8051e3a61 | ||
|
|
52f9729c9a | ||
|
|
1a8a03ea8d | ||
|
|
e0d6c08229 | ||
|
|
53ea743a9c | ||
|
|
415b4c9f95 | ||
|
|
7977b329ed | ||
|
|
e491fd4d09 | ||
|
|
32ed5f107c | ||
|
|
167d7a9f0f | ||
|
|
83fabf3524 | ||
|
|
00b1bec552 | ||
|
|
c7e575e316 | ||
|
|
31e090cb78 | ||
|
|
545c1a5b6f | ||
|
|
f569be4602 | ||
|
|
2685654a37 | ||
|
|
abf58dcd6a | ||
|
|
20f288bdc2 | ||
|
|
f475e8b529 | ||
|
|
41c0a1fb89 | ||
|
|
a7d9a5eb79 | ||
|
|
586b557b12 | ||
|
|
317f4b8006 | ||
|
|
6839276496 | ||
|
|
cbcfe6378d | ||
|
|
7dbb47f84f | ||
|
|
464c84fedf | ||
|
|
7a7b85c901 | ||
|
|
d880e06080 | ||
|
|
ded11ebc9a | ||
|
|
ea8498ed53 | ||
|
|
b26bc32579 | ||
|
|
f123cc83b3 | ||
|
|
0feec6dc13 | ||
|
|
1d0f6539c4 | ||
|
|
17cf9088d0 | ||
|
|
9064d2482d | ||
|
|
8f303afb43 | ||
|
|
5328eda882 | ||
|
|
b77e5a553a | ||
|
|
505b400795 | ||
|
|
74fc2ae12c | ||
|
|
7be14109a6 | ||
|
|
61c9a938b3 | ||
|
|
fd8394bc50 | ||
|
|
22ac81a069 | ||
|
|
25cd7c1ecb | ||
|
|
28f04e8a5e | ||
|
|
a3e91df30a | ||
|
|
80736b9c90 | ||
|
|
1ae6bff564 | ||
|
|
b37ff4de5b | ||
|
|
3690e91265 | ||
|
|
8cb08028f5 | ||
|
|
1cf39ddf3d | ||
|
|
c2d6659d10 | ||
|
|
26feac3dd1 | ||
|
|
70599e53b7 | ||
|
|
8d127b18f8 | ||
|
|
7d05aa99c6 | ||
|
|
36da6360e1 | ||
|
|
e7e3b7a55c | ||
|
|
dce8234624 | ||
|
|
2381881fe5 | ||
|
|
741fd809bc | ||
|
|
34a061a295 | ||
|
|
9032f98136 | ||
|
|
de271a06fd | ||
|
|
d596824c2f | ||
|
|
88eb1e7a9a | ||
|
|
f5a37ea40e | ||
|
|
f07ee91c71 | ||
|
|
ed8ad1b4d6 | ||
|
|
839d643253 | ||
|
|
f5736bb35b | ||
|
|
9d26daa04a | ||
|
|
73a26f9ee6 | ||
|
|
4e69a626cc | ||
|
|
77aa15e98f | ||
|
|
cb271d445b | ||
|
|
ceab4d5ed6 | ||
|
|
ed6c6d7eef | ||
|
|
f484c51599 | ||
|
|
72ba487930 | ||
|
|
74e90dd9b8 | ||
|
|
1d45e30537 | ||
|
|
3c1c75ecb8 | ||
|
|
7faa18b83d | ||
|
|
a473e59233 | ||
|
|
45f01de00e | ||
|
|
db6d1f145a | ||
|
|
a3f2b54c25 | ||
|
|
91832111a1 | ||
|
|
425017531f | ||
|
|
58d0c83457 | ||
|
|
4ebf41309d | ||
|
|
e1847535e2 | ||
|
|
5361a7c6e2 |
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -192,7 +192,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ./repo
|
path: ./repo
|
||||||
- name: Virtualized Install, Prepare & Build
|
- name: Virtualized Install, Prepare & Build
|
||||||
uses: yt-dlp/run-on-arch-action@v2
|
uses: yt-dlp/run-on-arch-action@v3
|
||||||
with:
|
with:
|
||||||
# Ref: https://github.com/uraimo/run-on-arch-action/issues/55
|
# Ref: https://github.com/uraimo/run-on-arch-action/issues/55
|
||||||
env: |
|
env: |
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/yt-dlp-build-venv
|
~/yt-dlp-build-venv
|
||||||
key: cache-reqs-${{ github.job }}
|
key: cache-reqs-${{ github.job }}-${{ github.ref }}
|
||||||
|
|
||||||
- name: Install Requirements
|
- name: Install Requirements
|
||||||
run: |
|
run: |
|
||||||
@@ -331,19 +331,16 @@ jobs:
|
|||||||
if: steps.restore-cache.outputs.cache-hit == 'true'
|
if: steps.restore-cache.outputs.cache-hit == 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
cache_key: cache-reqs-${{ github.job }}
|
cache_key: cache-reqs-${{ github.job }}-${{ github.ref }}
|
||||||
repository: ${{ github.repository }}
|
|
||||||
branch: ${{ github.ref }}
|
|
||||||
run: |
|
run: |
|
||||||
gh extension install actions/gh-actions-cache
|
gh cache delete "${cache_key}"
|
||||||
gh actions-cache delete "${cache_key}" -R "${repository}" -B "${branch}" --confirm
|
|
||||||
|
|
||||||
- name: Cache requirements
|
- name: Cache requirements
|
||||||
uses: actions/cache/save@v4
|
uses: actions/cache/save@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/yt-dlp-build-venv
|
~/yt-dlp-build-venv
|
||||||
key: cache-reqs-${{ github.job }}
|
key: cache-reqs-${{ github.job }}-${{ github.ref }}
|
||||||
|
|
||||||
macos_legacy:
|
macos_legacy:
|
||||||
needs: process
|
needs: process
|
||||||
@@ -411,7 +408,7 @@ jobs:
|
|||||||
run: | # Custom pyinstaller built with https://github.com/yt-dlp/pyinstaller-builds
|
run: | # Custom pyinstaller built with https://github.com/yt-dlp/pyinstaller-builds
|
||||||
python devscripts/install_deps.py -o --include build
|
python devscripts/install_deps.py -o --include build
|
||||||
python devscripts/install_deps.py --include curl-cffi
|
python devscripts/install_deps.py --include curl-cffi
|
||||||
python -m pip install -U "https://yt-dlp.github.io/Pyinstaller-Builds/x86_64/pyinstaller-6.11.1-py3-none-any.whl"
|
python -m pip install -U "https://yt-dlp.github.io/Pyinstaller-Builds/x86_64/pyinstaller-6.13.0-py3-none-any.whl"
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
@@ -460,7 +457,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python devscripts/install_deps.py -o --include build
|
python devscripts/install_deps.py -o --include build
|
||||||
python devscripts/install_deps.py
|
python devscripts/install_deps.py
|
||||||
python -m pip install -U "https://yt-dlp.github.io/Pyinstaller-Builds/i686/pyinstaller-6.11.1-py3-none-any.whl"
|
python -m pip install -U "https://yt-dlp.github.io/Pyinstaller-Builds/i686/pyinstaller-6.13.0-py3-none-any.whl"
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
4
.github/workflows/core.yml
vendored
4
.github/workflows/core.yml
vendored
@@ -6,7 +6,7 @@ on:
|
|||||||
- devscripts/**
|
- devscripts/**
|
||||||
- test/**
|
- test/**
|
||||||
- yt_dlp/**.py
|
- yt_dlp/**.py
|
||||||
- '!yt_dlp/extractor/*.py'
|
- '!yt_dlp/extractor/**.py'
|
||||||
- yt_dlp/extractor/__init__.py
|
- yt_dlp/extractor/__init__.py
|
||||||
- yt_dlp/extractor/common.py
|
- yt_dlp/extractor/common.py
|
||||||
- yt_dlp/extractor/extractors.py
|
- yt_dlp/extractor/extractors.py
|
||||||
@@ -16,7 +16,7 @@ on:
|
|||||||
- devscripts/**
|
- devscripts/**
|
||||||
- test/**
|
- test/**
|
||||||
- yt_dlp/**.py
|
- yt_dlp/**.py
|
||||||
- '!yt_dlp/extractor/*.py'
|
- '!yt_dlp/extractor/**.py'
|
||||||
- yt_dlp/extractor/__init__.py
|
- yt_dlp/extractor/__init__.py
|
||||||
- yt_dlp/extractor/common.py
|
- yt_dlp/extractor/common.py
|
||||||
- yt_dlp/extractor/extractors.py
|
- yt_dlp/extractor/extractors.py
|
||||||
|
|||||||
2
.github/workflows/quick-test.yml
vendored
2
.github/workflows/quick-test.yml
vendored
@@ -38,3 +38,5 @@ jobs:
|
|||||||
run: ruff check --output-format github .
|
run: ruff check --output-format github .
|
||||||
- name: Run autopep8
|
- name: Run autopep8
|
||||||
run: autopep8 --diff .
|
run: autopep8 --diff .
|
||||||
|
- name: Check file mode
|
||||||
|
run: git ls-files --format="%(objectmode) %(path)" yt_dlp/ | ( ! grep -v "^100644" )
|
||||||
|
|||||||
41
.github/workflows/signature-tests.yml
vendored
Normal file
41
.github/workflows/signature-tests.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Signature Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/signature-tests.yml
|
||||||
|
- test/test_youtube_signature.py
|
||||||
|
- yt_dlp/jsinterp.py
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/signature-tests.yml
|
||||||
|
- test/test_youtube_signature.py
|
||||||
|
- yt_dlp/jsinterp.py
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: signature-tests-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
name: Signature Tests
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', pypy-3.10, pypy-3.11]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install test requirements
|
||||||
|
run: python3 ./devscripts/install_deps.py --only-optional --include test
|
||||||
|
- name: Run tests
|
||||||
|
timeout-minutes: 15
|
||||||
|
run: |
|
||||||
|
python3 -m yt_dlp -v || true # Print debug head
|
||||||
|
python3 ./devscripts/run_tests.py test/test_youtube_signature.py
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -105,6 +105,8 @@ README.txt
|
|||||||
*.zsh
|
*.zsh
|
||||||
*.spec
|
*.spec
|
||||||
test/testdata/sigs/player-*.js
|
test/testdata/sigs/player-*.js
|
||||||
|
test/testdata/thumbnails/empty.webp
|
||||||
|
test/testdata/thumbnails/foo\ %d\ bar/foo_%d.*
|
||||||
|
|
||||||
# Binary
|
# Binary
|
||||||
/youtube-dl
|
/youtube-dl
|
||||||
|
|||||||
24
CONTRIBUTORS
24
CONTRIBUTORS
@@ -760,3 +760,27 @@ vallovic
|
|||||||
arabcoders
|
arabcoders
|
||||||
mireq
|
mireq
|
||||||
mlabeeb03
|
mlabeeb03
|
||||||
|
1271
|
||||||
|
CasperMcFadden95
|
||||||
|
Kicer86
|
||||||
|
Kiritomo
|
||||||
|
leeblackc
|
||||||
|
meGAmeS1
|
||||||
|
NeonMan
|
||||||
|
pj47x
|
||||||
|
troex
|
||||||
|
WouterGordts
|
||||||
|
baierjan
|
||||||
|
GeoffreyFrogeye
|
||||||
|
Pawka
|
||||||
|
v3DJG6GL
|
||||||
|
yozel
|
||||||
|
brian6932
|
||||||
|
iednod55
|
||||||
|
maxbin123
|
||||||
|
nullpos
|
||||||
|
anlar
|
||||||
|
eason1478
|
||||||
|
ceandreasen
|
||||||
|
chauhantirth
|
||||||
|
helpimnotdrowning
|
||||||
|
|||||||
222
Changelog.md
222
Changelog.md
@@ -4,6 +4,228 @@ # Changelog
|
|||||||
# To create a release, dispatch the https://github.com/yt-dlp/yt-dlp/actions/workflows/release.yml workflow on master
|
# To create a release, dispatch the https://github.com/yt-dlp/yt-dlp/actions/workflows/release.yml workflow on master
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
### 2025.06.30
|
||||||
|
|
||||||
|
#### Core changes
|
||||||
|
- **jsinterp**: [Fix `extract_object`](https://github.com/yt-dlp/yt-dlp/commit/958153a226214c86879e36211ac191bf78289578) ([#13580](https://github.com/yt-dlp/yt-dlp/issues/13580)) by [seproDev](https://github.com/seproDev)
|
||||||
|
|
||||||
|
#### Extractor changes
|
||||||
|
- **bilibilispacevideo**: [Extract hidden-mode collections as playlists](https://github.com/yt-dlp/yt-dlp/commit/99b85ac102047446e6adf5b62bfc3c8d80b53778) ([#13533](https://github.com/yt-dlp/yt-dlp/issues/13533)) by [c-basalt](https://github.com/c-basalt)
|
||||||
|
- **hotstar**
|
||||||
|
- [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/b5bd057fe86550f3aa67f2fc8790d1c6a251c57b) ([#13530](https://github.com/yt-dlp/yt-dlp/issues/13530)) by [bashonly](https://github.com/bashonly), [chauhantirth](https://github.com/chauhantirth) (With fixes in [e9f1576](https://github.com/yt-dlp/yt-dlp/commit/e9f157669e24953a88d15ce22053649db7a8e81e) by [bashonly](https://github.com/bashonly))
|
||||||
|
- [Fix metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/0a6b1044899f452cd10b6c7a6b00fa985a9a8b97) ([#13560](https://github.com/yt-dlp/yt-dlp/issues/13560)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Raise for login required](https://github.com/yt-dlp/yt-dlp/commit/5e292baad62c749b6c340621ab2d0f904165ddfb) ([#10405](https://github.com/yt-dlp/yt-dlp/issues/10405)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- series: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/4bd9a7ade7e0508b9795b3e72a69eeb40788b62b) ([#13564](https://github.com/yt-dlp/yt-dlp/issues/13564)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **jiocinema**: [Remove extractors](https://github.com/yt-dlp/yt-dlp/commit/7e2504f941a11ea2b0dba00de3f0295cdc253e79) ([#13565](https://github.com/yt-dlp/yt-dlp/issues/13565)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **kick**: [Support subscriber-only content](https://github.com/yt-dlp/yt-dlp/commit/b16722ede83377f77ea8352dcd0a6ca8e83b8f0f) ([#13550](https://github.com/yt-dlp/yt-dlp/issues/13550)) by [helpimnotdrowning](https://github.com/helpimnotdrowning)
|
||||||
|
- **niconico**: live: [Fix extractor and downloader](https://github.com/yt-dlp/yt-dlp/commit/06c1a8cdffe14050206683253726875144192ef5) ([#13158](https://github.com/yt-dlp/yt-dlp/issues/13158)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **sauceplus**: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/35fc33fbc51c7f5392fb2300f65abf6cf107ef90) ([#13567](https://github.com/yt-dlp/yt-dlp/issues/13567)) by [bashonly](https://github.com/bashonly), [ceandreasen](https://github.com/ceandreasen)
|
||||||
|
- **sproutvideo**: [Support browser impersonation](https://github.com/yt-dlp/yt-dlp/commit/11b9416e10cff7513167d76d6c47774fcdd3e26a) ([#13589](https://github.com/yt-dlp/yt-dlp/issues/13589)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **youtube**: [Fix premium formats extraction](https://github.com/yt-dlp/yt-dlp/commit/2ba5391cd68ed4f2415c827d2cecbcbc75ace10b) ([#13586](https://github.com/yt-dlp/yt-dlp/issues/13586)) by [bashonly](https://github.com/bashonly)
|
||||||
|
|
||||||
|
#### Misc. changes
|
||||||
|
- **ci**: [Add signature tests](https://github.com/yt-dlp/yt-dlp/commit/1b883846347addeab12663fd74317fd544341a1c) ([#13582](https://github.com/yt-dlp/yt-dlp/issues/13582)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cleanup**: Miscellaneous: [b018784](https://github.com/yt-dlp/yt-dlp/commit/b0187844988e557c7e1e6bb1aabd4c1176768d86) by [bashonly](https://github.com/bashonly)
|
||||||
|
|
||||||
|
### 2025.06.25
|
||||||
|
|
||||||
|
#### Extractor changes
|
||||||
|
- [Add `_search_nuxt_json` helper](https://github.com/yt-dlp/yt-dlp/commit/51887484e46ab6015c041cb1ab626a55f25a03bd) ([#13386](https://github.com/yt-dlp/yt-dlp/issues/13386)) by [bashonly](https://github.com/bashonly), [Grub4K](https://github.com/Grub4K)
|
||||||
|
- **brightcove**: new: [Improve metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/e6bd4a3da295b760ab20b39c18ce8934d312c2bf) ([#13461](https://github.com/yt-dlp/yt-dlp/issues/13461)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **huya**: live: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/2600849badb0d08c55b58dcc77a13af6ba423da6) ([#13520](https://github.com/yt-dlp/yt-dlp/issues/13520)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **hypergryph**: [Improve metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/1722c55400ff30bb5aee5dd7a262f0b7e9ce2f0e) ([#13415](https://github.com/yt-dlp/yt-dlp/issues/13415)) by [doe1080](https://github.com/doe1080), [eason1478](https://github.com/eason1478)
|
||||||
|
- **lsm**: [Fix extractors](https://github.com/yt-dlp/yt-dlp/commit/c57412d1f9cf0124adc972a47858ac42b740c61d) ([#13126](https://github.com/yt-dlp/yt-dlp/issues/13126)) by [Caesim404](https://github.com/Caesim404)
|
||||||
|
- **mave**: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/1838a1ce5d4ade80770ba9162eaffc9a1607dc70) ([#13380](https://github.com/yt-dlp/yt-dlp/issues/13380)) by [anlar](https://github.com/anlar)
|
||||||
|
- **sportdeutschland**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/a4ce4327c9836691d3b6b00e44a90b6741601ed8) ([#13519](https://github.com/yt-dlp/yt-dlp/issues/13519)) by [DTrombett](https://github.com/DTrombett)
|
||||||
|
- **sproutvideo**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/5b559d0072b7164daf06bacdc41c6f11283452c8) ([#13544](https://github.com/yt-dlp/yt-dlp/issues/13544)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **tv8.it**: [Support slugless URLs](https://github.com/yt-dlp/yt-dlp/commit/3bd30291601c47fa4a257983473884103ecab0c7) ([#13478](https://github.com/yt-dlp/yt-dlp/issues/13478)) by [DTrombett](https://github.com/DTrombett)
|
||||||
|
- **youtube**
|
||||||
|
- [Check any `ios` m3u8 formats prior to download](https://github.com/yt-dlp/yt-dlp/commit/8f94b76cbf7bbd9dfd8762c63cdea04f90f1297f) ([#13524](https://github.com/yt-dlp/yt-dlp/issues/13524)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Improve player context payloads](https://github.com/yt-dlp/yt-dlp/commit/ff6f94041aeee19c5559e1c1cd693960a1c1dd14) ([#13539](https://github.com/yt-dlp/yt-dlp/issues/13539)) by [bashonly](https://github.com/bashonly)
|
||||||
|
|
||||||
|
#### Misc. changes
|
||||||
|
- **test**: `traversal`: [Fix morsel tests for Python 3.14](https://github.com/yt-dlp/yt-dlp/commit/73bf10211668e4a59ccafd790e06ee82d9fea9ea) ([#13471](https://github.com/yt-dlp/yt-dlp/issues/13471)) by [Grub4K](https://github.com/Grub4K)
|
||||||
|
|
||||||
|
### 2025.06.09
|
||||||
|
|
||||||
|
#### Extractor changes
|
||||||
|
- [Improve JSON LD thumbnails extraction](https://github.com/yt-dlp/yt-dlp/commit/85c8a405e3651dc041b758f4744d4fb3c4c55e01) ([#13368](https://github.com/yt-dlp/yt-dlp/issues/13368)) by [bashonly](https://github.com/bashonly), [doe1080](https://github.com/doe1080)
|
||||||
|
- **10play**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/6d265388c6e943419ac99e9151cf75a3265f980f) ([#13349](https://github.com/yt-dlp/yt-dlp/issues/13349)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **adobepass**
|
||||||
|
- [Add Fubo MSO](https://github.com/yt-dlp/yt-dlp/commit/eee90acc47d7f8de24afaa8b0271ccaefdf6e88c) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [maxbin123](https://github.com/maxbin123)
|
||||||
|
- [Always add newer user-agent when required](https://github.com/yt-dlp/yt-dlp/commit/0ee1102268cf31b07f8a8318a47424c66b2f7378) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Fix Philo MSO authentication](https://github.com/yt-dlp/yt-dlp/commit/943083edcd3df45aaa597a6967bc6c95b720f54c) ([#13335](https://github.com/yt-dlp/yt-dlp/issues/13335)) by [Sipherdrakon](https://github.com/Sipherdrakon)
|
||||||
|
- [Rework to require software statement](https://github.com/yt-dlp/yt-dlp/commit/711c5d5d098fee2992a1a624b1c4b30364b91426) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly), [maxbin123](https://github.com/maxbin123)
|
||||||
|
- [Validate login URL before sending credentials](https://github.com/yt-dlp/yt-dlp/commit/89c1b349ad81318d9d3bea76c01c891696e58d38) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **aenetworks**
|
||||||
|
- [Fix playlist extractors](https://github.com/yt-dlp/yt-dlp/commit/f37d599a697e82fe68b423865897d55bae34f373) ([#13408](https://github.com/yt-dlp/yt-dlp/issues/13408)) by [Sipherdrakon](https://github.com/Sipherdrakon)
|
||||||
|
- [Fix provider-locked content extraction](https://github.com/yt-dlp/yt-dlp/commit/6693d6603358ae6beca834dbd822a7917498b813) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [maxbin123](https://github.com/maxbin123)
|
||||||
|
- **bilibilibangumi**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/13e55162719528d42d2133e16b65ff59a667a6e4) ([#13416](https://github.com/yt-dlp/yt-dlp/issues/13416)) by [c-basalt](https://github.com/c-basalt)
|
||||||
|
- **brightcove**: new: [Adapt to new AdobePass requirement](https://github.com/yt-dlp/yt-dlp/commit/98f8eec956e3b16cb66a3d49cc71af3807db795e) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cu.ntv.co.jp**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/aa863ddab9b1d104678e9cf39bb76f5b14fca660) ([#13302](https://github.com/yt-dlp/yt-dlp/issues/13302)) by [doe1080](https://github.com/doe1080), [nullpos](https://github.com/nullpos)
|
||||||
|
- **go**: [Fix provider-locked content extraction](https://github.com/yt-dlp/yt-dlp/commit/2e5bf002dad16f5ce35aa2023d392c9e518fcd8f) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly), [maxbin123](https://github.com/maxbin123)
|
||||||
|
- **nbc**: [Rework and adapt extractors to new AdobePass flow](https://github.com/yt-dlp/yt-dlp/commit/2d7949d5642bc37d1e71bf00c9a55260e5505d58) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **nobelprize**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/97ddfefeb4faba6e61cd80996c16952b8eab16f3) ([#13205](https://github.com/yt-dlp/yt-dlp/issues/13205)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **odnoklassniki**: [Detect and raise when login is required](https://github.com/yt-dlp/yt-dlp/commit/148a1eb4c59e127965396c7a6e6acf1979de459e) ([#13361](https://github.com/yt-dlp/yt-dlp/issues/13361)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **patreon**: [Fix m3u8 formats extraction](https://github.com/yt-dlp/yt-dlp/commit/e0d6c0822930f6e63f574d46d946a58b73ecd10c) ([#13266](https://github.com/yt-dlp/yt-dlp/issues/13266)) by [bashonly](https://github.com/bashonly) (With fixes in [1a8a03e](https://github.com/yt-dlp/yt-dlp/commit/1a8a03ea8d827107319a18076ee3505090667c5a))
|
||||||
|
- **podchaser**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/538eb305673c26bff6a2b12f1c96375fe02ce41a) ([#13271](https://github.com/yt-dlp/yt-dlp/issues/13271)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **sr**: mediathek: [Improve metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/e3c605a61f4cc2de9059f37434fa108c3c20f58e) ([#13294](https://github.com/yt-dlp/yt-dlp/issues/13294)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **stacommu**: [Avoid partial stream formats](https://github.com/yt-dlp/yt-dlp/commit/5d96527be80dc1ed1702d9cd548ff86de570ad70) ([#13412](https://github.com/yt-dlp/yt-dlp/issues/13412)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **startrek**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/a8bf0011bde92b3f1324a98bfbd38932fd3ebe18) ([#13188](https://github.com/yt-dlp/yt-dlp/issues/13188)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **svt**: play: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/e1b6062f8c4a3fa33c65269d48d09ec78de765a2) ([#13329](https://github.com/yt-dlp/yt-dlp/issues/13329)) by [barsnick](https://github.com/barsnick), [bashonly](https://github.com/bashonly)
|
||||||
|
- **telecinco**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/03dba2012d9bd3f402fa8c2f122afba89bbd22a4) ([#13379](https://github.com/yt-dlp/yt-dlp/issues/13379)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **theplatform**: [Improve metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/ed108b3ea481c6a4b5215a9302ba92d74baa2425) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **toutiao**: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/f8051e3a61686c5db1de5f5746366ecfbc3ad20c) ([#13246](https://github.com/yt-dlp/yt-dlp/issues/13246)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **turner**: [Adapt extractors to new AdobePass flow](https://github.com/yt-dlp/yt-dlp/commit/0daddc780d3ac5bebc3a3ec5b884d9243cbc0745) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **twitcasting**: [Fix password-protected livestream support](https://github.com/yt-dlp/yt-dlp/commit/52f9729c9a92ad4656d746ff0b1acecb87b3e96d) ([#13097](https://github.com/yt-dlp/yt-dlp/issues/13097)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **twitter**: broadcast: [Support events URLs](https://github.com/yt-dlp/yt-dlp/commit/7794374de8afb20499b023107e2abfd4e6b93ee4) ([#13248](https://github.com/yt-dlp/yt-dlp/issues/13248)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **umg**: de: [Rework extractor](https://github.com/yt-dlp/yt-dlp/commit/4e7c1ea346b510280218b47e8653dbbca3a69870) ([#13373](https://github.com/yt-dlp/yt-dlp/issues/13373)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **vice**: [Mark extractors as broken](https://github.com/yt-dlp/yt-dlp/commit/6121559e027a04574690799c1776bc42bb51af31) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **vimeo**: [Extract subtitles from player subdomain](https://github.com/yt-dlp/yt-dlp/commit/c723c4e5e78263df178dbe69844a3d05f3ef9e35) ([#13350](https://github.com/yt-dlp/yt-dlp/issues/13350)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **watchespn**: [Fix provider-locked content extraction](https://github.com/yt-dlp/yt-dlp/commit/b094747e93cfb0a2c53007120e37d0d84d41f030) ([#13131](https://github.com/yt-dlp/yt-dlp/issues/13131)) by [maxbin123](https://github.com/maxbin123)
|
||||||
|
- **weverse**: [Support login with oauth refresh tokens](https://github.com/yt-dlp/yt-dlp/commit/3fe72e9eea38d9a58211cde42cfaa577ce020e2c) ([#13284](https://github.com/yt-dlp/yt-dlp/issues/13284)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **youtube**
|
||||||
|
- [Add `tv_simply` player client](https://github.com/yt-dlp/yt-dlp/commit/1fd0e88b67db53ad163393d6965f68e908fa70e3) ([#13389](https://github.com/yt-dlp/yt-dlp/issues/13389)) by [gamer191](https://github.com/gamer191)
|
||||||
|
- [Extract srt subtitles](https://github.com/yt-dlp/yt-dlp/commit/231349786e8c42089c2e079ec94c0ea866c37999) ([#13411](https://github.com/yt-dlp/yt-dlp/issues/13411)) by [gamer191](https://github.com/gamer191)
|
||||||
|
- [Fix `--mark-watched` support](https://github.com/yt-dlp/yt-dlp/commit/b5be29fa58ec98226e11621fd9c58585bcff6879) ([#13222](https://github.com/yt-dlp/yt-dlp/issues/13222)) by [brian6932](https://github.com/brian6932), [iednod55](https://github.com/iednod55)
|
||||||
|
- [Fix automatic captions for some client combinations](https://github.com/yt-dlp/yt-dlp/commit/53ea743a9c158f8ca2d75a09ca44ba68606042d8) ([#13268](https://github.com/yt-dlp/yt-dlp/issues/13268)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Improve signature extraction debug output](https://github.com/yt-dlp/yt-dlp/commit/d30a49742cfa22e61c47df4ac0e7334d648fb85d) ([#13327](https://github.com/yt-dlp/yt-dlp/issues/13327)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Rework nsig function name extraction](https://github.com/yt-dlp/yt-dlp/commit/9e38b273b7ac942e7e9fc05a651ed810ab7d30ba) ([#13403](https://github.com/yt-dlp/yt-dlp/issues/13403)) by [Grub4K](https://github.com/Grub4K)
|
||||||
|
- [nsig code improvements and cleanup](https://github.com/yt-dlp/yt-dlp/commit/f7bbf5a617f9ab54ef51eaef99be36e175b5e9c3) ([#13280](https://github.com/yt-dlp/yt-dlp/issues/13280)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **zdf**: [Fix language extraction and format sorting](https://github.com/yt-dlp/yt-dlp/commit/db162b76f6bdece50babe2e0cacfe56888c2e125) ([#13313](https://github.com/yt-dlp/yt-dlp/issues/13313)) by [InvalidUsernameException](https://github.com/InvalidUsernameException)
|
||||||
|
|
||||||
|
#### Misc. changes
|
||||||
|
- **build**
|
||||||
|
- [Exclude `pkg_resources` from being collected](https://github.com/yt-dlp/yt-dlp/commit/cc749a8a3b8b6e5c05318868c72a403f376a1b38) ([#13320](https://github.com/yt-dlp/yt-dlp/issues/13320)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Fix macOS requirements caching](https://github.com/yt-dlp/yt-dlp/commit/201812100f315c6727a4418698d5b4e8a79863d4) ([#13328](https://github.com/yt-dlp/yt-dlp/issues/13328)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cleanup**: Miscellaneous: [339614a](https://github.com/yt-dlp/yt-dlp/commit/339614a173c74b42d63e858c446a9cae262a13af) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **test**: postprocessors: [Remove binary thumbnail test data](https://github.com/yt-dlp/yt-dlp/commit/a9b370069838e84d44ac7ad095d657003665885a) ([#13341](https://github.com/yt-dlp/yt-dlp/issues/13341)) by [bashonly](https://github.com/bashonly)
|
||||||
|
|
||||||
|
### 2025.05.22
|
||||||
|
|
||||||
|
#### Core changes
|
||||||
|
- **cookies**: [Fix Linux desktop environment detection](https://github.com/yt-dlp/yt-dlp/commit/e491fd4d090db3af52a82863fb0553dd5e17fb85) ([#13197](https://github.com/yt-dlp/yt-dlp/issues/13197)) by [mbway](https://github.com/mbway)
|
||||||
|
- **jsinterp**: [Fix increment/decrement evaluation](https://github.com/yt-dlp/yt-dlp/commit/167d7a9f0ffd1b4fe600193441bdb7358db2740b) ([#13238](https://github.com/yt-dlp/yt-dlp/issues/13238)) by [bashonly](https://github.com/bashonly), [seproDev](https://github.com/seproDev)
|
||||||
|
|
||||||
|
#### Extractor changes
|
||||||
|
- **1tv**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/41c0a1fb89628696f8bb88e2b9f3a68f355b8c26) ([#13168](https://github.com/yt-dlp/yt-dlp/issues/13168)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **amcnetworks**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/464c84fedf78eef822a431361155f108b5df96d7) ([#13147](https://github.com/yt-dlp/yt-dlp/issues/13147)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **bitchute**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/1d0f6539c47e5d5c68c3c47cdb7075339e2885ac) ([#13081](https://github.com/yt-dlp/yt-dlp/issues/13081)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cartoonnetwork**: [Remove extractor](https://github.com/yt-dlp/yt-dlp/commit/7dbb47f84f0ee1266a3a01f58c9bc4c76d76794a) ([#13148](https://github.com/yt-dlp/yt-dlp/issues/13148)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **iprima**: [Fix login support](https://github.com/yt-dlp/yt-dlp/commit/a7d9a5eb79ceeecb851389f3f2c88597871ca3f2) ([#12937](https://github.com/yt-dlp/yt-dlp/issues/12937)) by [baierjan](https://github.com/baierjan)
|
||||||
|
- **jiosaavn**
|
||||||
|
- artist: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/586b557b124f954d3f625360ebe970989022ad97) ([#12803](https://github.com/yt-dlp/yt-dlp/issues/12803)) by [subrat-lima](https://github.com/subrat-lima)
|
||||||
|
- playlist, show: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/317f4b8006c2c0f0f64f095b1485163ad97c9053) ([#12803](https://github.com/yt-dlp/yt-dlp/issues/12803)) by [subrat-lima](https://github.com/subrat-lima)
|
||||||
|
- show: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/6839276496d8814cf16f58b637e45663467928e6) ([#12803](https://github.com/yt-dlp/yt-dlp/issues/12803)) by [subrat-lima](https://github.com/subrat-lima)
|
||||||
|
- **lrtradio**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/abf58dcd6a09e14eec4ea82ae12f79a0337cb383) ([#13200](https://github.com/yt-dlp/yt-dlp/issues/13200)) by [Pawka](https://github.com/Pawka)
|
||||||
|
- **nebula**: [Support `--mark-watched`](https://github.com/yt-dlp/yt-dlp/commit/20f288bdc2173c7cc58d709d25ca193c1f6001e7) ([#13120](https://github.com/yt-dlp/yt-dlp/issues/13120)) by [GeoffreyFrogeye](https://github.com/GeoffreyFrogeye)
|
||||||
|
- **niconico**
|
||||||
|
- [Fix error handling](https://github.com/yt-dlp/yt-dlp/commit/f569be4602c2a857087e495d5d7ed6060cd97abe) ([#13236](https://github.com/yt-dlp/yt-dlp/issues/13236)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- live: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/7a7b85c9014d96421e18aa7ea5f4c1bee5ceece0) ([#13045](https://github.com/yt-dlp/yt-dlp/issues/13045)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **nytimesarticle**: [Fix extraction](https://github.com/yt-dlp/yt-dlp/commit/b26bc32579c00ef579d75a835807ccc87d20ee0a) ([#13104](https://github.com/yt-dlp/yt-dlp/issues/13104)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **once**: [Remove extractor](https://github.com/yt-dlp/yt-dlp/commit/f475e8b529d18efdad603ffda02a56e707fe0e2c) ([#13164](https://github.com/yt-dlp/yt-dlp/issues/13164)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **picarto**: vod: [Support `/profile/` video URLs](https://github.com/yt-dlp/yt-dlp/commit/31e090cb787f3504ec25485adff9a2a51d056734) ([#13227](https://github.com/yt-dlp/yt-dlp/issues/13227)) by [subrat-lima](https://github.com/subrat-lima)
|
||||||
|
- **playsuisse**: [Improve metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/d880e060803ae8ed5a047e578cca01e1f0e630ce) ([#12466](https://github.com/yt-dlp/yt-dlp/issues/12466)) by [v3DJG6GL](https://github.com/v3DJG6GL)
|
||||||
|
- **sprout**: [Remove extractor](https://github.com/yt-dlp/yt-dlp/commit/cbcfe6378dde33a650e3852ab17ad4503b8e008d) ([#13149](https://github.com/yt-dlp/yt-dlp/issues/13149)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **svtpage**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/ea8498ed534642dd7e925961b97b934987142fd3) ([#12957](https://github.com/yt-dlp/yt-dlp/issues/12957)) by [diman8](https://github.com/diman8)
|
||||||
|
- **twitch**: [Support `--live-from-start`](https://github.com/yt-dlp/yt-dlp/commit/00b1bec55249cf2ad6271d36492c51b34b6459d1) ([#13202](https://github.com/yt-dlp/yt-dlp/issues/13202)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **vimeo**: event: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/545c1a5b6f2fe88722b41aef0e7485bf3be3f3f9) ([#13216](https://github.com/yt-dlp/yt-dlp/issues/13216)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **wat.tv**: [Improve error handling](https://github.com/yt-dlp/yt-dlp/commit/f123cc83b3aea45053f5fa1d9141048b01fc2774) ([#13111](https://github.com/yt-dlp/yt-dlp/issues/13111)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **weverse**: [Fix live extraction](https://github.com/yt-dlp/yt-dlp/commit/5328eda8820cc5f21dcf917684d23fbdca41831d) ([#13084](https://github.com/yt-dlp/yt-dlp/issues/13084)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **xinpianchang**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/83fabf352489d52843f67e6e9cc752db86d27e6e) ([#13245](https://github.com/yt-dlp/yt-dlp/issues/13245)) by [garret1317](https://github.com/garret1317)
|
||||||
|
- **youtube**
|
||||||
|
- [Add PO token support for subtitles](https://github.com/yt-dlp/yt-dlp/commit/32ed5f107c6c641958d1cd2752e130de4db55a13) ([#13234](https://github.com/yt-dlp/yt-dlp/issues/13234)) by [bashonly](https://github.com/bashonly), [coletdjnz](https://github.com/coletdjnz)
|
||||||
|
- [Add `web_embedded` client for age-restricted videos](https://github.com/yt-dlp/yt-dlp/commit/0feec6dc131f488428bf881519e7c69766fbb9ae) ([#13089](https://github.com/yt-dlp/yt-dlp/issues/13089)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Add a PO Token Provider Framework](https://github.com/yt-dlp/yt-dlp/commit/2685654a37141cca63eda3a92da0e2706e23ccfd) ([#12840](https://github.com/yt-dlp/yt-dlp/issues/12840)) by [coletdjnz](https://github.com/coletdjnz)
|
||||||
|
- [Extract `media_type` for all videos](https://github.com/yt-dlp/yt-dlp/commit/ded11ebc9afba6ba33923375103e9be2d7c804e7) ([#13136](https://github.com/yt-dlp/yt-dlp/issues/13136)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Fix `--live-from-start` support for premieres](https://github.com/yt-dlp/yt-dlp/commit/8f303afb43395be360cafd7ad4ce2b6e2eedfb8a) ([#13079](https://github.com/yt-dlp/yt-dlp/issues/13079)) by [arabcoders](https://github.com/arabcoders)
|
||||||
|
- [Fix geo-restriction error handling](https://github.com/yt-dlp/yt-dlp/commit/c7e575e31608c19c5b26c10a4229db89db5fc9a8) ([#13217](https://github.com/yt-dlp/yt-dlp/issues/13217)) by [yozel](https://github.com/yozel)
|
||||||
|
|
||||||
|
#### Misc. changes
|
||||||
|
- **build**
|
||||||
|
- [Bump PyInstaller to v6.13.0](https://github.com/yt-dlp/yt-dlp/commit/17cf9088d0d535e4a7feffbf02bd49cd9dae5ab9) ([#13082](https://github.com/yt-dlp/yt-dlp/issues/13082)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Bump run-on-arch-action to v3](https://github.com/yt-dlp/yt-dlp/commit/9064d2482d1fe722bbb4a49731fe0711c410d1c8) ([#13088](https://github.com/yt-dlp/yt-dlp/issues/13088)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cleanup**: Miscellaneous: [7977b32](https://github.com/yt-dlp/yt-dlp/commit/7977b329ed97b216e37bd402f4935f28c00eac9e) by [bashonly](https://github.com/bashonly)
|
||||||
|
|
||||||
|
### 2025.04.30
|
||||||
|
|
||||||
|
#### Important changes
|
||||||
|
- **New option `--preset-alias`/`-t` has been added**
|
||||||
|
This provides convenient predefined aliases for common use cases. Available presets include `mp4`, `mp3`, `mkv`, `aac`, and `sleep`. See [the README](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#preset-aliases) for more details.
|
||||||
|
|
||||||
|
#### Core changes
|
||||||
|
- [Add `--preset-alias` option](https://github.com/yt-dlp/yt-dlp/commit/88eb1e7a9a2720ac89d653c0d0e40292388823bb) ([#12839](https://github.com/yt-dlp/yt-dlp/issues/12839)) by [Grub4K](https://github.com/Grub4K), [seproDev](https://github.com/seproDev)
|
||||||
|
- **utils**
|
||||||
|
- `_yield_json_ld`: [Make function less fatal](https://github.com/yt-dlp/yt-dlp/commit/45f01de00e1bc076b7f676a669736326178647b1) ([#12855](https://github.com/yt-dlp/yt-dlp/issues/12855)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- `url_or_none`: [Support WebSocket URLs](https://github.com/yt-dlp/yt-dlp/commit/a473e592337edb8ca40cde52c1fcaee261c54df9) ([#12848](https://github.com/yt-dlp/yt-dlp/issues/12848)) by [doe1080](https://github.com/doe1080)
|
||||||
|
|
||||||
|
#### Extractor changes
|
||||||
|
- **abematv**: [Fix thumbnail extraction](https://github.com/yt-dlp/yt-dlp/commit/f5736bb35bde62348caebf7b188668655e316deb) ([#12859](https://github.com/yt-dlp/yt-dlp/issues/12859)) by [Kiritomo](https://github.com/Kiritomo)
|
||||||
|
- **atresplayer**: [Rework extractor](https://github.com/yt-dlp/yt-dlp/commit/839d64325356310e6de6cd9cad28fb546619ca63) ([#11424](https://github.com/yt-dlp/yt-dlp/issues/11424)) by [meGAmeS1](https://github.com/meGAmeS1), [seproDev](https://github.com/seproDev)
|
||||||
|
- **bpb**: [Fix formats extraction](https://github.com/yt-dlp/yt-dlp/commit/80736b9c90818adee933a155079b8535bc06819f) ([#13015](https://github.com/yt-dlp/yt-dlp/issues/13015)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cda**: [Fix formats extraction](https://github.com/yt-dlp/yt-dlp/commit/9032f981362ea0be90626fab51ec37934feded6d) ([#12975](https://github.com/yt-dlp/yt-dlp/issues/12975)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **cdafolder**: [Extend `_VALID_URL`](https://github.com/yt-dlp/yt-dlp/commit/cb271d445bc2d866c9a3404b1d8f59bcb77447df) ([#12919](https://github.com/yt-dlp/yt-dlp/issues/12919)) by [fireattack](https://github.com/fireattack), [Kicer86](https://github.com/Kicer86)
|
||||||
|
- **crowdbunker**: [Make format extraction non-fatal](https://github.com/yt-dlp/yt-dlp/commit/4ebf41309d04a6e196944f1c0f5f0154cff0055a) ([#12836](https://github.com/yt-dlp/yt-dlp/issues/12836)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **dacast**: [Support tokenized URLs](https://github.com/yt-dlp/yt-dlp/commit/e7e3b7a55c456da4a5a812b4fefce4dce8e6a616) ([#12979](https://github.com/yt-dlp/yt-dlp/issues/12979)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **dzen.ru**: [Rework extractors](https://github.com/yt-dlp/yt-dlp/commit/a3f2b54c2535d862de6efa9cfaa6ca9a2b2f7dd6) ([#12852](https://github.com/yt-dlp/yt-dlp/issues/12852)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **generic**: [Fix MPD extraction for `file://` URLs](https://github.com/yt-dlp/yt-dlp/commit/34a061a295d156934417c67ee98070b94943006b) ([#12978](https://github.com/yt-dlp/yt-dlp/issues/12978)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **getcourseru**: [Fix extractors](https://github.com/yt-dlp/yt-dlp/commit/741fd809bc4d301c19b53877692ae510334a6750) ([#12943](https://github.com/yt-dlp/yt-dlp/issues/12943)) by [troex](https://github.com/troex)
|
||||||
|
- **ivoox**: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/7faa18b83dcfc74a1a1e2034e6b0369c495ca645) ([#12768](https://github.com/yt-dlp/yt-dlp/issues/12768)) by [NeonMan](https://github.com/NeonMan), [seproDev](https://github.com/seproDev)
|
||||||
|
- **kika**: [Add playlist extractor](https://github.com/yt-dlp/yt-dlp/commit/3c1c75ecb8ab352f422b59af46fff2be992e4115) ([#12832](https://github.com/yt-dlp/yt-dlp/issues/12832)) by [1100101](https://github.com/1100101)
|
||||||
|
- **linkedin**
|
||||||
|
- [Support feed URLs](https://github.com/yt-dlp/yt-dlp/commit/73a26f9ee68610e33c0b4407b77355f2ab7afd0e) ([#12927](https://github.com/yt-dlp/yt-dlp/issues/12927)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- events: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/b37ff4de5baf4e4e70c6a0ec34e136a279ad20af) ([#12926](https://github.com/yt-dlp/yt-dlp/issues/12926)) by [bashonly](https://github.com/bashonly), [seproDev](https://github.com/seproDev)
|
||||||
|
- **loco**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/f5a37ea40e20865b976ffeeff13eeae60292eb23) ([#12934](https://github.com/yt-dlp/yt-dlp/issues/12934)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **lrtradio**: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/74e90dd9b8f9c1a5c48a2515126654f4d398d687) ([#12801](https://github.com/yt-dlp/yt-dlp/issues/12801)) by [subrat-lima](https://github.com/subrat-lima)
|
||||||
|
- **manyvids**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/77aa15e98f34c4ad425aabf39dd1ee37b48f772c) ([#10907](https://github.com/yt-dlp/yt-dlp/issues/10907)) by [pj47x](https://github.com/pj47x)
|
||||||
|
- **mixcloud**: [Refactor extractor](https://github.com/yt-dlp/yt-dlp/commit/db6d1f145ad583e0220637726029f8f2fa6200a0) ([#12830](https://github.com/yt-dlp/yt-dlp/issues/12830)) by [seproDev](https://github.com/seproDev), [WouterGordts](https://github.com/WouterGordts)
|
||||||
|
- **mlbtv**: [Fix device ID caching](https://github.com/yt-dlp/yt-dlp/commit/36da6360e130197df927ee93409519ce3f4075f5) ([#12980](https://github.com/yt-dlp/yt-dlp/issues/12980)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **niconico**
|
||||||
|
- [Fix login support](https://github.com/yt-dlp/yt-dlp/commit/25cd7c1ecbb6cbf21dd3a6e59608e4af94715ecc) ([#13008](https://github.com/yt-dlp/yt-dlp/issues/13008)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- [Remove DMC formats support](https://github.com/yt-dlp/yt-dlp/commit/7d05aa99c65352feae1cd9a3ff8784b64bfe382a) ([#12916](https://github.com/yt-dlp/yt-dlp/issues/12916)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- live: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/1d45e30537bf83e069184a440703e4c43b2e0198) ([#12809](https://github.com/yt-dlp/yt-dlp/issues/12809)) by [Snack-X](https://github.com/Snack-X)
|
||||||
|
- **panopto**: [Fix formats extraction](https://github.com/yt-dlp/yt-dlp/commit/9d26daa04ad5108257bc5e30f7f040c7f1fe7a5a) ([#12925](https://github.com/yt-dlp/yt-dlp/issues/12925)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **parti**: [Add extractors](https://github.com/yt-dlp/yt-dlp/commit/425017531fbc3369becb5a44013e26f26efabf45) ([#12769](https://github.com/yt-dlp/yt-dlp/issues/12769)) by [benfaerber](https://github.com/benfaerber)
|
||||||
|
- **raiplay**: [Fix DRM detection](https://github.com/yt-dlp/yt-dlp/commit/dce82346245e35a46fda836ca2089805d2347935) ([#12971](https://github.com/yt-dlp/yt-dlp/issues/12971)) by [DTrombett](https://github.com/DTrombett)
|
||||||
|
- **reddit**: [Support `--ignore-no-formats-error`](https://github.com/yt-dlp/yt-dlp/commit/28f04e8a5e383ff531db646190b4be45554610d6) ([#12993](https://github.com/yt-dlp/yt-dlp/issues/12993)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **royalive**: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/e1847535e28788414a25546a45bebcada2f34558) ([#12817](https://github.com/yt-dlp/yt-dlp/issues/12817)) by [CasperMcFadden95](https://github.com/CasperMcFadden95)
|
||||||
|
- **rtve**: [Rework extractors](https://github.com/yt-dlp/yt-dlp/commit/f07ee91c71920ab1187a7ea756720e81aa406a9d) ([#10388](https://github.com/yt-dlp/yt-dlp/issues/10388)) by [meGAmeS1](https://github.com/meGAmeS1), [seproDev](https://github.com/seproDev)
|
||||||
|
- **rumble**: [Improve format extraction](https://github.com/yt-dlp/yt-dlp/commit/58d0c83457b93b3c9a81eb6bc5a4c65f25e949df) ([#12838](https://github.com/yt-dlp/yt-dlp/issues/12838)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **tokfmpodcast**: [Fix formats extraction](https://github.com/yt-dlp/yt-dlp/commit/91832111a12d87499294a0f430829b8c2254c339) ([#12842](https://github.com/yt-dlp/yt-dlp/issues/12842)) by [selfisekai](https://github.com/selfisekai)
|
||||||
|
- **tv2dk**: [Fix extractor](https://github.com/yt-dlp/yt-dlp/commit/a3e91df30a45943f40759d2c1e0b6c2ca4b2a263) ([#12945](https://github.com/yt-dlp/yt-dlp/issues/12945)) by [bashonly](https://github.com/bashonly), [seproDev](https://github.com/seproDev)
|
||||||
|
- **tvp**: vod: [Improve `_VALID_URL`](https://github.com/yt-dlp/yt-dlp/commit/4e69a626cce51428bc1d66dc606a56d9498b03a5) ([#12923](https://github.com/yt-dlp/yt-dlp/issues/12923)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **tvw**: tvchannels: [Add extractor](https://github.com/yt-dlp/yt-dlp/commit/ed8ad1b4d6b9d7a1426ff5192ff924f3371e4721) ([#12721](https://github.com/yt-dlp/yt-dlp/issues/12721)) by [fries1234](https://github.com/fries1234)
|
||||||
|
- **twitcasting**: [Fix livestream extraction](https://github.com/yt-dlp/yt-dlp/commit/de271a06fd6d20d4f55597ff7f90e4d913de0a52) ([#12977](https://github.com/yt-dlp/yt-dlp/issues/12977)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **twitch**: clips: [Fix uploader metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/1ae6bff564a65af41e94f1a4727892471ecdd05a) ([#13022](https://github.com/yt-dlp/yt-dlp/issues/13022)) by [1271](https://github.com/1271)
|
||||||
|
- **twitter**
|
||||||
|
- [Fix extraction when logged-in](https://github.com/yt-dlp/yt-dlp/commit/1cf39ddf3d10b6512daa7dd139e5f6c0dc548bbc) ([#13024](https://github.com/yt-dlp/yt-dlp/issues/13024)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- spaces: [Improve metadata extraction](https://github.com/yt-dlp/yt-dlp/commit/70599e53b736bb75922b737e6e0d4f76e419bb20) ([#12911](https://github.com/yt-dlp/yt-dlp/issues/12911)) by [doe1080](https://github.com/doe1080)
|
||||||
|
- **vimeo**: [Extract from mobile API](https://github.com/yt-dlp/yt-dlp/commit/22ac81a0692019ac833cf282e4ef99718e9ef3fa) ([#13034](https://github.com/yt-dlp/yt-dlp/issues/13034)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- **vk**
|
||||||
|
- [Fix chapters extraction](https://github.com/yt-dlp/yt-dlp/commit/5361a7c6e2933c919716e0cb1e3116c28c40419f) ([#12821](https://github.com/yt-dlp/yt-dlp/issues/12821)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- [Fix uploader extraction](https://github.com/yt-dlp/yt-dlp/commit/2381881fe58a723853350a6ab750a5efc9f10c85) ([#12985](https://github.com/yt-dlp/yt-dlp/issues/12985)) by [seproDev](https://github.com/seproDev)
|
||||||
|
- **youtube**
|
||||||
|
- [Add context to video request rate limit error](https://github.com/yt-dlp/yt-dlp/commit/26feac3dd142536ad08ad1ed731378cb88e63602) ([#12958](https://github.com/yt-dlp/yt-dlp/issues/12958)) by [coletdjnz](https://github.com/coletdjnz)
|
||||||
|
- [Add extractor arg to skip "initial_data" request](https://github.com/yt-dlp/yt-dlp/commit/ed6c6d7eefbc78fa72e4e60ad6edaa3ee2acc715) ([#12865](https://github.com/yt-dlp/yt-dlp/issues/12865)) by [leeblackc](https://github.com/leeblackc)
|
||||||
|
- [Add warning on video captcha challenge](https://github.com/yt-dlp/yt-dlp/commit/f484c51599a6cd01eb078ea7dc9bbba942967774) ([#12939](https://github.com/yt-dlp/yt-dlp/issues/12939)) by [coletdjnz](https://github.com/coletdjnz)
|
||||||
|
- [Cache signature timestamps](https://github.com/yt-dlp/yt-dlp/commit/61c9a938b390b8334ee3a879fe2d93f714e30138) ([#13047](https://github.com/yt-dlp/yt-dlp/issues/13047)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Detect and warn when account cookies are rotated](https://github.com/yt-dlp/yt-dlp/commit/8cb08028f5be2acb9835ce1670b196b9b077052f) ([#13014](https://github.com/yt-dlp/yt-dlp/issues/13014)) by [coletdjnz](https://github.com/coletdjnz)
|
||||||
|
- [Detect player JS variants for any locale](https://github.com/yt-dlp/yt-dlp/commit/c2d6659d1069f8cff97e1fd61d1c59e949e1e63d) ([#13003](https://github.com/yt-dlp/yt-dlp/issues/13003)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Do not strictly deprioritize `missing_pot` formats](https://github.com/yt-dlp/yt-dlp/commit/74fc2ae12c24eb6b4e02c6360c89bd05f3c8f740) ([#13061](https://github.com/yt-dlp/yt-dlp/issues/13061)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- [Improve warning for SABR-only/SSAP player responses](https://github.com/yt-dlp/yt-dlp/commit/fd8394bc50301ac5e930aa65aa71ab1b8372b8ab) ([#13049](https://github.com/yt-dlp/yt-dlp/issues/13049)) by [bashonly](https://github.com/bashonly)
|
||||||
|
- tab: [Extract continuation from empty page](https://github.com/yt-dlp/yt-dlp/commit/72ba4879304c2082fecbb472e6cc05ee2d154a3b) ([#12938](https://github.com/yt-dlp/yt-dlp/issues/12938)) by [coletdjnz](https://github.com/coletdjnz)
|
||||||
|
- **zdf**: [Fix extractors](https://github.com/yt-dlp/yt-dlp/commit/7be14109a6bd493a2e881da4f9e30adaf3e7e5d5) ([#12779](https://github.com/yt-dlp/yt-dlp/issues/12779)) by [bashonly](https://github.com/bashonly), [InvalidUsernameException](https://github.com/InvalidUsernameException)
|
||||||
|
|
||||||
|
#### Downloader changes
|
||||||
|
- **niconicodmc**: [Remove downloader](https://github.com/yt-dlp/yt-dlp/commit/8d127b18f81131453eaba05d3bb810d9b73adb75) ([#12916](https://github.com/yt-dlp/yt-dlp/issues/12916)) by [doe1080](https://github.com/doe1080)
|
||||||
|
|
||||||
|
#### Networking changes
|
||||||
|
- [Add PATCH request shortcut](https://github.com/yt-dlp/yt-dlp/commit/ceab4d5ed63a1f135a1816fe967c9d9a1ec7e6e8) ([#12884](https://github.com/yt-dlp/yt-dlp/issues/12884)) by [doe1080](https://github.com/doe1080)
|
||||||
|
|
||||||
|
#### Misc. changes
|
||||||
|
- **ci**: [Add file mode test to code check](https://github.com/yt-dlp/yt-dlp/commit/3690e91265d1d0bbeffaf6a9b8cc9baded1367bd) ([#13036](https://github.com/yt-dlp/yt-dlp/issues/13036)) by [Grub4K](https://github.com/Grub4K)
|
||||||
|
- **cleanup**: Miscellaneous: [505b400](https://github.com/yt-dlp/yt-dlp/commit/505b400795af557bdcfd9d4fa7e9133b26ef431c) by [bashonly](https://github.com/bashonly), [seproDev](https://github.com/seproDev)
|
||||||
|
|
||||||
### 2025.03.31
|
### 2025.03.31
|
||||||
|
|
||||||
#### Core changes
|
#### Core changes
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -18,10 +18,11 @@ pypi-files: AUTHORS Changelog.md LICENSE README.md README.txt supportedsites \
|
|||||||
tar pypi-files lazy-extractors install uninstall
|
tar pypi-files lazy-extractors install uninstall
|
||||||
|
|
||||||
clean-test:
|
clean-test:
|
||||||
rm -rf test/testdata/sigs/player-*.js tmp/ *.annotations.xml *.aria2 *.description *.dump *.frag \
|
rm -rf tmp/ *.annotations.xml *.aria2 *.description *.dump *.frag \
|
||||||
*.frag.aria2 *.frag.urls *.info.json *.live_chat.json *.meta *.part* *.tmp *.temp *.unknown_video *.ytdl \
|
*.frag.aria2 *.frag.urls *.info.json *.live_chat.json *.meta *.part* *.tmp *.temp *.unknown_video *.ytdl \
|
||||||
*.3gp *.ape *.ass *.avi *.desktop *.f4v *.flac *.flv *.gif *.jpeg *.jpg *.lrc *.m4a *.m4v *.mhtml *.mkv *.mov *.mp3 *.mp4 \
|
*.3gp *.ape *.ass *.avi *.desktop *.f4v *.flac *.flv *.gif *.jpeg *.jpg *.lrc *.m4a *.m4v *.mhtml *.mkv *.mov *.mp3 *.mp4 \
|
||||||
*.mpg *.mpga *.oga *.ogg *.opus *.png *.sbv *.srt *.ssa *.swf *.tt *.ttml *.url *.vtt *.wav *.webloc *.webm *.webp
|
*.mpg *.mpga *.oga *.ogg *.opus *.png *.sbv *.srt *.ssa *.swf *.tt *.ttml *.url *.vtt *.wav *.webloc *.webm *.webp \
|
||||||
|
test/testdata/sigs/player-*.js test/testdata/thumbnails/empty.webp "test/testdata/thumbnails/foo %d bar/foo_%d."*
|
||||||
clean-dist:
|
clean-dist:
|
||||||
rm -rf yt-dlp.1.temp.md yt-dlp.1 README.txt MANIFEST build/ dist/ .coverage cover/ yt-dlp.tar.gz completions/ \
|
rm -rf yt-dlp.1.temp.md yt-dlp.1 README.txt MANIFEST build/ dist/ .coverage cover/ yt-dlp.tar.gz completions/ \
|
||||||
yt_dlp/extractor/lazy_extractors.py *.spec CONTRIBUTING.md.tmp yt-dlp yt-dlp.exe yt_dlp.egg-info/ AUTHORS
|
yt_dlp/extractor/lazy_extractors.py *.spec CONTRIBUTING.md.tmp yt-dlp yt-dlp.exe yt_dlp.egg-info/ AUTHORS
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -44,6 +44,7 @@
|
|||||||
* [Post-processing Options](#post-processing-options)
|
* [Post-processing Options](#post-processing-options)
|
||||||
* [SponsorBlock Options](#sponsorblock-options)
|
* [SponsorBlock Options](#sponsorblock-options)
|
||||||
* [Extractor Options](#extractor-options)
|
* [Extractor Options](#extractor-options)
|
||||||
|
* [Preset Aliases](#preset-aliases)
|
||||||
* [CONFIGURATION](#configuration)
|
* [CONFIGURATION](#configuration)
|
||||||
* [Configuration file encoding](#configuration-file-encoding)
|
* [Configuration file encoding](#configuration-file-encoding)
|
||||||
* [Authentication with netrc](#authentication-with-netrc)
|
* [Authentication with netrc](#authentication-with-netrc)
|
||||||
@@ -348,8 +349,8 @@ ## General Options:
|
|||||||
--no-flat-playlist Fully extract the videos of a playlist
|
--no-flat-playlist Fully extract the videos of a playlist
|
||||||
(default)
|
(default)
|
||||||
--live-from-start Download livestreams from the start.
|
--live-from-start Download livestreams from the start.
|
||||||
Currently only supported for YouTube
|
Currently experimental and only supported
|
||||||
(Experimental)
|
for YouTube and Twitch
|
||||||
--no-live-from-start Download livestreams from the current time
|
--no-live-from-start Download livestreams from the current time
|
||||||
(default)
|
(default)
|
||||||
--wait-for-video MIN[-MAX] Wait for scheduled streams to become
|
--wait-for-video MIN[-MAX] Wait for scheduled streams to become
|
||||||
@@ -375,17 +376,23 @@ ## General Options:
|
|||||||
an alias starts with a dash "-", it is
|
an alias starts with a dash "-", it is
|
||||||
prefixed with "--". Arguments are parsed
|
prefixed with "--". Arguments are parsed
|
||||||
according to the Python string formatting
|
according to the Python string formatting
|
||||||
mini-language. E.g. --alias get-audio,-X
|
mini-language. E.g. --alias get-audio,-X "-S
|
||||||
"-S=aext:{0},abr -x --audio-format {0}"
|
aext:{0},abr -x --audio-format {0}" creates
|
||||||
creates options "--get-audio" and "-X" that
|
options "--get-audio" and "-X" that takes an
|
||||||
takes an argument (ARG0) and expands to
|
argument (ARG0) and expands to "-S
|
||||||
"-S=aext:ARG0,abr -x --audio-format ARG0".
|
aext:ARG0,abr -x --audio-format ARG0". All
|
||||||
All defined aliases are listed in the --help
|
defined aliases are listed in the --help
|
||||||
output. Alias options can trigger more
|
output. Alias options can trigger more
|
||||||
aliases; so be careful to avoid defining
|
aliases; so be careful to avoid defining
|
||||||
recursive options. As a safety measure, each
|
recursive options. As a safety measure, each
|
||||||
alias may be triggered a maximum of 100
|
alias may be triggered a maximum of 100
|
||||||
times. This option can be used multiple times
|
times. This option can be used multiple times
|
||||||
|
-t, --preset-alias PRESET Applies a predefined set of options. e.g.
|
||||||
|
--preset-alias mp3. The following presets
|
||||||
|
are available: mp3, aac, mp4, mkv, sleep.
|
||||||
|
See the "Preset Aliases" section at the end
|
||||||
|
for more info. This option can be used
|
||||||
|
multiple times
|
||||||
|
|
||||||
## Network Options:
|
## Network Options:
|
||||||
--proxy URL Use the specified HTTP/HTTPS/SOCKS proxy. To
|
--proxy URL Use the specified HTTP/HTTPS/SOCKS proxy. To
|
||||||
@@ -1098,6 +1105,27 @@ ## Extractor Options:
|
|||||||
can use this option multiple times to give
|
can use this option multiple times to give
|
||||||
arguments for different extractors
|
arguments for different extractors
|
||||||
|
|
||||||
|
## Preset Aliases:
|
||||||
|
Predefined aliases for convenience and ease of use. Note that future
|
||||||
|
versions of yt-dlp may add or adjust presets, but the existing preset
|
||||||
|
names will not be changed or removed
|
||||||
|
|
||||||
|
-t mp3 -f 'ba[acodec^=mp3]/ba/b' -x --audio-format
|
||||||
|
mp3
|
||||||
|
|
||||||
|
-t aac -f
|
||||||
|
'ba[acodec^=aac]/ba[acodec^=mp4a.40.]/ba/b'
|
||||||
|
-x --audio-format aac
|
||||||
|
|
||||||
|
-t mp4 --merge-output-format mp4 --remux-video mp4
|
||||||
|
-S vcodec:h264,lang,quality,res,fps,hdr:12,a
|
||||||
|
codec:aac
|
||||||
|
|
||||||
|
-t mkv --merge-output-format mkv --remux-video mkv
|
||||||
|
|
||||||
|
-t sleep --sleep-subtitles 5 --sleep-requests 0.75
|
||||||
|
--sleep-interval 10 --max-sleep-interval 20
|
||||||
|
|
||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
|
|
||||||
You can configure yt-dlp by placing any supported command line option in a configuration file. The configuration is loaded from the following locations:
|
You can configure yt-dlp by placing any supported command line option in a configuration file. The configuration is loaded from the following locations:
|
||||||
@@ -1767,11 +1795,12 @@ # EXTRACTOR ARGUMENTS
|
|||||||
The following extractors use this feature:
|
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.py](https://github.com/yt-dlp/yt-dlp/blob/c26f9b991a0681fd3ea548d535919cec1fbbd430/yt_dlp/extractor/youtube.py#L381-L390) for 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`, `android`, `android_vr`, `tv` and `tv_embedded`. By default, `tv,ios,web` is used, or `tv,web` is used when authenticating with cookies. The `web_music` client is added for `music.youtube.com` URLs when logged-in cookies are used. The `tv_embedded` and `web_creator` clients are added for age-restricted videos if account age-verification is required. Some clients, such as `web` 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,-ios`
|
* `player_client`: Clients to extract video data from. The currently available clients are `web`, `web_safari`, `web_embedded`, `web_music`, `web_creator`, `mweb`, `ios`, `android`, `android_vr`, `tv`, `tv_simply` and `tv_embedded`. By default, `tv,ios,web` is used, or `tv,web` is used when authenticating with cookies. 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 works if the video is embeddable. The `tv_embedded` and `web_creator` clients are added for age-restricted videos if account age-verification is required. Some clients, such as `web` 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,-ios`
|
||||||
* `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). While these options can help reduce the number of requests needed or avoid some rate-limiting, they could cause some issues. See [#860](https://github.com/yt-dlp/yt-dlp/pull/860) 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
|
||||||
* `player_params`: YouTube player parameters to use for player requests. Will overwrite any default ones set by yt-dlp.
|
* `player_params`: YouTube player parameters to use for player requests. Will overwrite any default ones set by yt-dlp.
|
||||||
|
* `player_js_variant`: The player javascript variant to use for signature and nsig deciphering. The known variants are: `main`, `tce`, `tv`, `tv_es6`, `phone`, `tablet`. Only `main` is recommended as a possible workaround; the others are for debugging purposes. The default is to use what is prescribed by the site, and can be selected with `actual`
|
||||||
* `comment_sort`: `top` or `new` (default) - choose comment sorting mode (on YouTube's side)
|
* `comment_sort`: `top` or `new` (default) - choose comment sorting mode (on YouTube's side)
|
||||||
* `max_comments`: Limit the amount of comments to gather. Comma-separated list of integers representing `max-comments,max-parents,max-replies,max-replies-per-thread`. Default is `all,all,all,all`
|
* `max_comments`: Limit the amount of comments to gather. Comma-separated list of integers representing `max-comments,max-parents,max-replies,max-replies-per-thread`. Default is `all,all,all,all`
|
||||||
* E.g. `all,all,1000,10` will get a maximum of 1000 replies total, with up to 10 replies per thread. `1000,all,100` will get a maximum of 1000 comments, with a maximum of 100 replies total
|
* E.g. `all,all,1000,10` will get a maximum of 1000 replies total, with up to 10 replies per thread. `1000,all,100` will get a maximum of 1000 comments, with a maximum of 100 replies total
|
||||||
@@ -1781,8 +1810,12 @@ #### youtube
|
|||||||
* `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
|
* `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
|
||||||
* `data_sync_id`: Overrides the account Data Sync ID used in Innertube API requests. This may be needed if you are using an account with `youtube:player_skip=webpage,configs` or `youtubetab:skip=webpage`
|
* `data_sync_id`: Overrides the account Data Sync ID used in Innertube API requests. This may be needed if you are using an account with `youtube:player_skip=webpage,configs` or `youtubetab:skip=webpage`
|
||||||
* `visitor_data`: Overrides the Visitor Data used in Innertube API requests. This should be used with `player_skip=webpage,configs` and without cookies. Note: this may have adverse effects if used improperly. If a session from a browser is wanted, you should pass cookies instead (which contain the Visitor ID)
|
* `visitor_data`: Overrides the Visitor Data used in Innertube API requests. This should be used with `player_skip=webpage,configs` and without cookies. Note: this may have adverse effects if used improperly. If a session from a browser is wanted, you should pass cookies instead (which contain the Visitor ID)
|
||||||
* `po_token`: Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs) or `player` (Innertube player request)
|
* `po_token`: Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be any of `gvs` (Google Video Server URLs), `player` (Innertube player request) or `subs` (Subtitles)
|
||||||
* `player_js_variant`: The player javascript variant to use for signature and nsig deciphering. The known variants are: `main`, `tce`, `tv`, `tv_es6`, `phone`, `tablet`. Only `main` is recommended as a possible workaround; the others are for debugging purposes. The default is to use what is prescribed by the site, and can be selected with `actual`
|
* `pot_trace`: Enable debug logging for PO Token fetching. Either `true` or `false` (default)
|
||||||
|
* `fetch_pot`: Policy to use for fetching a PO Token from providers. One of `always` (always try fetch a PO Token regardless if the client requires one for the given context), `never` (never fetch a PO Token), or `auto` (default; only fetch a PO Token if the client requires one for the given context)
|
||||||
|
|
||||||
|
#### youtubepot-webpo
|
||||||
|
* `bind_to_visitor_id`: Whether to use the Visitor ID instead of Visitor Data for caching WebPO tokens. Either `true` (default) or `false`
|
||||||
|
|
||||||
#### youtubetab (YouTube playlists, channels, feeds, etc.)
|
#### youtubetab (YouTube playlists, channels, feeds, etc.)
|
||||||
* `skip`: One or more of `webpage` (skip initial webpage download), `authcheck` (allow the download of playlists requiring authentication when no initial webpage is downloaded. This may cause unwanted behavior, see [#1122](https://github.com/yt-dlp/yt-dlp/pull/1122) for more details)
|
* `skip`: One or more of `webpage` (skip initial webpage download), `authcheck` (allow the download of playlists requiring authentication when no initial webpage is downloaded. This may cause unwanted behavior, see [#1122](https://github.com/yt-dlp/yt-dlp/pull/1122) for more details)
|
||||||
@@ -1799,9 +1832,6 @@ #### generic
|
|||||||
#### vikichannel
|
#### vikichannel
|
||||||
* `video_types`: Types of videos to download - one or more of `episodes`, `movies`, `clips`, `trailers`
|
* `video_types`: Types of videos to download - one or more of `episodes`, `movies`, `clips`, `trailers`
|
||||||
|
|
||||||
#### niconico
|
|
||||||
* `segment_duration`: Segment duration in milliseconds for HLS-DMC formats. Use it at your own risk since this feature **may result in your account termination.**
|
|
||||||
|
|
||||||
#### youtubewebarchive
|
#### youtubewebarchive
|
||||||
* `check_all`: Try to check more at the cost of more requests. One or more of `thumbnails`, `captures`
|
* `check_all`: Try to check more at the cost of more requests. One or more of `thumbnails`, `captures`
|
||||||
|
|
||||||
@@ -2153,7 +2183,7 @@ ### New features
|
|||||||
|
|
||||||
* **[Format Sorting](#sorting-formats)**: The default format sorting options have been changed so that higher resolution and better codecs will be now preferred instead of simply using larger bitrate. Furthermore, you can now specify the sort order using `-S`. This allows for much easier format selection than what is possible by simply using `--format` ([examples](#format-selection-examples))
|
* **[Format Sorting](#sorting-formats)**: The default format sorting options have been changed so that higher resolution and better codecs will be now preferred instead of simply using larger bitrate. Furthermore, you can now specify the sort order using `-S`. This allows for much easier format selection than what is possible by simply using `--format` ([examples](#format-selection-examples))
|
||||||
|
|
||||||
* **Merged with animelover1984/youtube-dl**: You get most of the features and improvements from [animelover1984/youtube-dl](https://github.com/animelover1984/youtube-dl) including `--write-comments`, `BiliBiliSearch`, `BilibiliChannel`, Embedding thumbnail in mp4/ogg/opus, playlist infojson etc. Note that NicoNico livestreams are not available. See [#31](https://github.com/yt-dlp/yt-dlp/pull/31) for details.
|
* **Merged with animelover1984/youtube-dl**: You get most of the features and improvements from [animelover1984/youtube-dl](https://github.com/animelover1984/youtube-dl) including `--write-comments`, `BiliBiliSearch`, `BilibiliChannel`, Embedding thumbnail in mp4/ogg/opus, playlist infojson etc. See [#31](https://github.com/yt-dlp/yt-dlp/pull/31) for details.
|
||||||
|
|
||||||
* **YouTube improvements**:
|
* **YouTube improvements**:
|
||||||
* Supports Clips, Stories (`ytstories:<channel UCID>`), Search (including filters)**\***, YouTube Music Search, Channel-specific search, Search prefixes (`ytsearch:`, `ytsearchdate:`)**\***, Mixes, and Feeds (`:ytfav`, `:ytwatchlater`, `:ytsubs`, `:ythistory`, `:ytrec`, `:ytnotif`)
|
* Supports Clips, Stories (`ytstories:<channel UCID>`), Search (including filters)**\***, YouTube Music Search, Channel-specific search, Search prefixes (`ytsearch:`, `ytsearchdate:`)**\***, Mixes, and Feeds (`:ytfav`, `:ytwatchlater`, `:ytsubs`, `:ythistory`, `:ytrec`, `:ytnotif`)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
source ~/.local/share/pipx/venvs/pyinstaller/bin/activate
|
source ~/.local/share/pipx/venvs/pyinstaller/bin/activate
|
||||||
|
python -m devscripts.install_deps -o --include build
|
||||||
python -m devscripts.install_deps --include secretstorage --include curl-cffi
|
python -m devscripts.install_deps --include secretstorage --include curl-cffi
|
||||||
python -m devscripts.make_lazy_extractors
|
python -m devscripts.make_lazy_extractors
|
||||||
python devscripts/update-version.py -c "${channel}" -r "${origin}" "${version}"
|
python devscripts/update-version.py -c "${channel}" -r "${origin}" "${version}"
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ def main():
|
|||||||
f'--name={name}',
|
f'--name={name}',
|
||||||
'--icon=devscripts/logo.ico',
|
'--icon=devscripts/logo.ico',
|
||||||
'--upx-exclude=vcruntime140.dll',
|
'--upx-exclude=vcruntime140.dll',
|
||||||
|
# Ref: https://github.com/yt-dlp/yt-dlp/issues/13311
|
||||||
|
# https://github.com/pyinstaller/pyinstaller/issues/9149
|
||||||
|
'--exclude-module=pkg_resources',
|
||||||
'--noconfirm',
|
'--noconfirm',
|
||||||
'--additional-hooks-dir=yt_dlp/__pyinstaller',
|
'--additional-hooks-dir=yt_dlp/__pyinstaller',
|
||||||
*opts,
|
*opts,
|
||||||
|
|||||||
@@ -245,5 +245,22 @@
|
|||||||
"when": "76ac023ff02f06e8c003d104f02a03deeddebdcd",
|
"when": "76ac023ff02f06e8c003d104f02a03deeddebdcd",
|
||||||
"short": "[ie/youtube:tab] Improve shorts title extraction (#11997)",
|
"short": "[ie/youtube:tab] Improve shorts title extraction (#11997)",
|
||||||
"authors": ["bashonly", "d3d9"]
|
"authors": ["bashonly", "d3d9"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "add",
|
||||||
|
"when": "88eb1e7a9a2720ac89d653c0d0e40292388823bb",
|
||||||
|
"short": "[priority] **New option `--preset-alias`/`-t` has been added**\nThis provides convenient predefined aliases for common use cases. Available presets include `mp4`, `mp3`, `mkv`, `aac`, and `sleep`. See [the README](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#preset-aliases) for more details."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "remove",
|
||||||
|
"when": "d596824c2f8428362c072518856065070616e348"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "remove",
|
||||||
|
"when": "7b81634fb1d15999757e7a9883daa6ef09ea785b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "remove",
|
||||||
|
"when": "500761e41acb96953a5064e951d41d190c287e46"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ build = [
|
|||||||
"build",
|
"build",
|
||||||
"hatchling",
|
"hatchling",
|
||||||
"pip",
|
"pip",
|
||||||
"setuptools>=71.0.2", # 71.0.0 broke pyinstaller
|
"setuptools>=71.0.2,<81", # See https://github.com/pyinstaller/pyinstaller/issues/9149
|
||||||
"wheel",
|
"wheel",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -82,7 +82,7 @@ test = [
|
|||||||
"pytest-rerunfailures~=14.0",
|
"pytest-rerunfailures~=14.0",
|
||||||
]
|
]
|
||||||
pyinstaller = [
|
pyinstaller = [
|
||||||
"pyinstaller>=6.11.1", # Windows temp cleanup fixed in 6.11.1
|
"pyinstaller>=6.13.0", # Windows temp cleanup fixed in 6.13.0
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ # Supported sites
|
|||||||
Not all sites listed here are guaranteed to work; websites are constantly changing and sometimes this breaks yt-dlp's support for them.
|
Not all sites listed here are guaranteed to work; websites are constantly changing and sometimes this breaks yt-dlp's support for them.
|
||||||
The only reliable way to check if a site is supported is to try it.
|
The only reliable way to check if a site is supported is to try it.
|
||||||
|
|
||||||
|
- **10play**: [*10play*](## "netrc machine")
|
||||||
|
- **10play:season**
|
||||||
- **17live**
|
- **17live**
|
||||||
- **17live:clip**
|
- **17live:clip**
|
||||||
- **17live:vod**
|
- **17live:vod**
|
||||||
@@ -246,7 +248,6 @@ # Supported sites
|
|||||||
- **Canalplus**: mycanal.fr and piwiplus.fr
|
- **Canalplus**: mycanal.fr and piwiplus.fr
|
||||||
- **Canalsurmas**
|
- **Canalsurmas**
|
||||||
- **CaracolTvPlay**: [*caracoltv-play*](## "netrc machine")
|
- **CaracolTvPlay**: [*caracoltv-play*](## "netrc machine")
|
||||||
- **CartoonNetwork**
|
|
||||||
- **cbc.ca**
|
- **cbc.ca**
|
||||||
- **cbc.ca:player**
|
- **cbc.ca:player**
|
||||||
- **cbc.ca:player:playlist**
|
- **cbc.ca:player:playlist**
|
||||||
@@ -296,7 +297,7 @@ # Supported sites
|
|||||||
- **CNNIndonesia**
|
- **CNNIndonesia**
|
||||||
- **ComedyCentral**
|
- **ComedyCentral**
|
||||||
- **ComedyCentralTV**
|
- **ComedyCentralTV**
|
||||||
- **ConanClassic**
|
- **ConanClassic**: (**Currently broken**)
|
||||||
- **CondeNast**: Condé Nast media group: Allure, Architectural Digest, Ars Technica, Bon Appétit, Brides, Condé Nast, Condé Nast Traveler, Details, Epicurious, GQ, Glamour, Golf Digest, SELF, Teen Vogue, The New Yorker, Vanity Fair, Vogue, W Magazine, WIRED
|
- **CondeNast**: Condé Nast media group: Allure, Architectural Digest, Ars Technica, Bon Appétit, Brides, Condé Nast, Condé Nast Traveler, Details, Epicurious, GQ, Glamour, Golf Digest, SELF, Teen Vogue, The New Yorker, Vanity Fair, Vogue, W Magazine, WIRED
|
||||||
- **CONtv**
|
- **CONtv**
|
||||||
- **CookingChannel**
|
- **CookingChannel**
|
||||||
@@ -318,7 +319,7 @@ # Supported sites
|
|||||||
- **CtsNews**: 華視新聞
|
- **CtsNews**: 華視新聞
|
||||||
- **CTV**
|
- **CTV**
|
||||||
- **CTVNews**
|
- **CTVNews**
|
||||||
- **cu.ntv.co.jp**: Nippon Television Network
|
- **cu.ntv.co.jp**: 日テレ無料TADA!
|
||||||
- **CultureUnplugged**
|
- **CultureUnplugged**
|
||||||
- **curiositystream**: [*curiositystream*](## "netrc machine")
|
- **curiositystream**: [*curiositystream*](## "netrc machine")
|
||||||
- **curiositystream:collections**: [*curiositystream*](## "netrc machine")
|
- **curiositystream:collections**: [*curiositystream*](## "netrc machine")
|
||||||
@@ -394,6 +395,8 @@ # Supported sites
|
|||||||
- **dvtv**: http://video.aktualne.cz/
|
- **dvtv**: http://video.aktualne.cz/
|
||||||
- **dw**: (**Currently broken**)
|
- **dw**: (**Currently broken**)
|
||||||
- **dw:article**: (**Currently broken**)
|
- **dw:article**: (**Currently broken**)
|
||||||
|
- **dzen.ru**: Дзен (dzen) formerly Яндекс.Дзен (Yandex Zen)
|
||||||
|
- **dzen.ru:channel**
|
||||||
- **EaglePlatform**
|
- **EaglePlatform**
|
||||||
- **EbaumsWorld**
|
- **EbaumsWorld**
|
||||||
- **Ebay**
|
- **Ebay**
|
||||||
@@ -572,9 +575,7 @@ # Supported sites
|
|||||||
- **HollywoodReporterPlaylist**
|
- **HollywoodReporterPlaylist**
|
||||||
- **Holodex**
|
- **Holodex**
|
||||||
- **HotNewHipHop**: (**Currently broken**)
|
- **HotNewHipHop**: (**Currently broken**)
|
||||||
- **hotstar**
|
- **hotstar**: JioHotstar
|
||||||
- **hotstar:playlist**
|
|
||||||
- **hotstar:season**
|
|
||||||
- **hotstar:series**
|
- **hotstar:series**
|
||||||
- **hrfernsehen**
|
- **hrfernsehen**
|
||||||
- **HRTi**: [*hrti*](## "netrc machine")
|
- **HRTi**: [*hrti*](## "netrc machine")
|
||||||
@@ -587,7 +588,7 @@ # Supported sites
|
|||||||
- **Hungama**
|
- **Hungama**
|
||||||
- **HungamaAlbumPlaylist**
|
- **HungamaAlbumPlaylist**
|
||||||
- **HungamaSong**
|
- **HungamaSong**
|
||||||
- **huya:live**: huya.com
|
- **huya:live**: 虎牙直播
|
||||||
- **huya:video**: 虎牙视频
|
- **huya:video**: 虎牙视频
|
||||||
- **Hypem**
|
- **Hypem**
|
||||||
- **Hytale**
|
- **Hytale**
|
||||||
@@ -634,6 +635,7 @@ # Supported sites
|
|||||||
- **ivi**: ivi.ru
|
- **ivi**: ivi.ru
|
||||||
- **ivi:compilation**: ivi.ru compilations
|
- **ivi:compilation**: ivi.ru compilations
|
||||||
- **ivideon**: Ivideon TV
|
- **ivideon**: Ivideon TV
|
||||||
|
- **Ivoox**
|
||||||
- **IVXPlayer**
|
- **IVXPlayer**
|
||||||
- **iwara**: [*iwara*](## "netrc machine")
|
- **iwara**: [*iwara*](## "netrc machine")
|
||||||
- **iwara:playlist**: [*iwara*](## "netrc machine")
|
- **iwara:playlist**: [*iwara*](## "netrc machine")
|
||||||
@@ -643,10 +645,11 @@ # Supported sites
|
|||||||
- **Jamendo**
|
- **Jamendo**
|
||||||
- **JamendoAlbum**
|
- **JamendoAlbum**
|
||||||
- **JeuxVideo**: (**Currently broken**)
|
- **JeuxVideo**: (**Currently broken**)
|
||||||
- **jiocinema**: [*jiocinema*](## "netrc machine")
|
|
||||||
- **jiocinema:series**: [*jiocinema*](## "netrc machine")
|
|
||||||
- **jiosaavn:album**
|
- **jiosaavn:album**
|
||||||
|
- **jiosaavn:artist**
|
||||||
- **jiosaavn:playlist**
|
- **jiosaavn:playlist**
|
||||||
|
- **jiosaavn:show**
|
||||||
|
- **jiosaavn:show:playlist**
|
||||||
- **jiosaavn:song**
|
- **jiosaavn:song**
|
||||||
- **Joj**
|
- **Joj**
|
||||||
- **JoqrAg**: 超!A&G+ 文化放送 (f.k.a. AGQR) Nippon Cultural Broadcasting, Inc. (JOQR)
|
- **JoqrAg**: 超!A&G+ 文化放送 (f.k.a. AGQR) Nippon Cultural Broadcasting, Inc. (JOQR)
|
||||||
@@ -671,6 +674,7 @@ # Supported sites
|
|||||||
- **Kicker**
|
- **Kicker**
|
||||||
- **KickStarter**
|
- **KickStarter**
|
||||||
- **Kika**: KiKA.de
|
- **Kika**: KiKA.de
|
||||||
|
- **KikaPlaylist**
|
||||||
- **kinja:embed**
|
- **kinja:embed**
|
||||||
- **KinoPoisk**
|
- **KinoPoisk**
|
||||||
- **Kommunetv**
|
- **Kommunetv**
|
||||||
@@ -723,6 +727,7 @@ # Supported sites
|
|||||||
- **limelight:channel**
|
- **limelight:channel**
|
||||||
- **limelight:channel_list**
|
- **limelight:channel_list**
|
||||||
- **LinkedIn**: [*linkedin*](## "netrc machine")
|
- **LinkedIn**: [*linkedin*](## "netrc machine")
|
||||||
|
- **linkedin:events**: [*linkedin*](## "netrc machine")
|
||||||
- **linkedin:learning**: [*linkedin*](## "netrc machine")
|
- **linkedin:learning**: [*linkedin*](## "netrc machine")
|
||||||
- **linkedin:learning:course**: [*linkedin*](## "netrc machine")
|
- **linkedin:learning:course**: [*linkedin*](## "netrc machine")
|
||||||
- **Liputan6**
|
- **Liputan6**
|
||||||
@@ -738,6 +743,7 @@ # Supported sites
|
|||||||
- **loom**
|
- **loom**
|
||||||
- **loom:folder**
|
- **loom:folder**
|
||||||
- **LoveHomePorn**
|
- **LoveHomePorn**
|
||||||
|
- **LRTRadio**
|
||||||
- **LRTStream**
|
- **LRTStream**
|
||||||
- **LRTVOD**
|
- **LRTVOD**
|
||||||
- **LSMLREmbed**
|
- **LSMLREmbed**
|
||||||
@@ -759,13 +765,14 @@ # Supported sites
|
|||||||
- **ManotoTV**: Manoto TV (Episode)
|
- **ManotoTV**: Manoto TV (Episode)
|
||||||
- **ManotoTVLive**: Manoto TV (Live)
|
- **ManotoTVLive**: Manoto TV (Live)
|
||||||
- **ManotoTVShow**: Manoto TV (Show)
|
- **ManotoTVShow**: Manoto TV (Show)
|
||||||
- **ManyVids**: (**Currently broken**)
|
- **ManyVids**
|
||||||
- **MaoriTV**
|
- **MaoriTV**
|
||||||
- **Markiza**: (**Currently broken**)
|
- **Markiza**: (**Currently broken**)
|
||||||
- **MarkizaPage**: (**Currently broken**)
|
- **MarkizaPage**: (**Currently broken**)
|
||||||
- **massengeschmack.tv**
|
- **massengeschmack.tv**
|
||||||
- **Masters**
|
- **Masters**
|
||||||
- **MatchTV**
|
- **MatchTV**
|
||||||
|
- **Mave**
|
||||||
- **MBN**: mbn.co.kr (매일방송)
|
- **MBN**: mbn.co.kr (매일방송)
|
||||||
- **MDR**: MDR.DE
|
- **MDR**: MDR.DE
|
||||||
- **MedalTV**
|
- **MedalTV**
|
||||||
@@ -822,7 +829,7 @@ # Supported sites
|
|||||||
- **Mojevideo**: mojevideo.sk
|
- **Mojevideo**: mojevideo.sk
|
||||||
- **Mojvideo**
|
- **Mojvideo**
|
||||||
- **Monstercat**
|
- **Monstercat**
|
||||||
- **MonsterSirenHypergryphMusic**
|
- **monstersiren**: 塞壬唱片
|
||||||
- **Motherless**
|
- **Motherless**
|
||||||
- **MotherlessGallery**
|
- **MotherlessGallery**
|
||||||
- **MotherlessGroup**
|
- **MotherlessGroup**
|
||||||
@@ -874,19 +881,19 @@ # Supported sites
|
|||||||
- **Naver**
|
- **Naver**
|
||||||
- **Naver:live**
|
- **Naver:live**
|
||||||
- **navernow**
|
- **navernow**
|
||||||
- **nba**
|
- **nba**: (**Currently broken**)
|
||||||
- **nba:channel**
|
- **nba:channel**: (**Currently broken**)
|
||||||
- **nba:embed**
|
- **nba:embed**: (**Currently broken**)
|
||||||
- **nba:watch**
|
- **nba:watch**: (**Currently broken**)
|
||||||
- **nba:watch:collection**
|
- **nba:watch:collection**: (**Currently broken**)
|
||||||
- **nba:watch:embed**
|
- **nba:watch:embed**: (**Currently broken**)
|
||||||
- **NBC**
|
- **NBC**
|
||||||
- **NBCNews**
|
- **NBCNews**
|
||||||
- **nbcolympics**
|
- **nbcolympics**
|
||||||
- **nbcolympics:stream**
|
- **nbcolympics:stream**: (**Currently broken**)
|
||||||
- **NBCSports**
|
- **NBCSports**: (**Currently broken**)
|
||||||
- **NBCSportsStream**
|
- **NBCSportsStream**: (**Currently broken**)
|
||||||
- **NBCSportsVPlayer**
|
- **NBCSportsVPlayer**: (**Currently broken**)
|
||||||
- **NBCStations**
|
- **NBCStations**
|
||||||
- **ndr**: NDR.de - Norddeutscher Rundfunk
|
- **ndr**: NDR.de - Norddeutscher Rundfunk
|
||||||
- **ndr:embed**
|
- **ndr:embed**
|
||||||
@@ -946,7 +953,7 @@ # Supported sites
|
|||||||
- **nickelodeonru**
|
- **nickelodeonru**
|
||||||
- **niconico**: [*niconico*](## "netrc machine") ニコニコ動画
|
- **niconico**: [*niconico*](## "netrc machine") ニコニコ動画
|
||||||
- **niconico:history**: NicoNico user history or likes. Requires cookies.
|
- **niconico:history**: NicoNico user history or likes. Requires cookies.
|
||||||
- **niconico:live**: ニコニコ生放送
|
- **niconico:live**: [*niconico*](## "netrc machine") ニコニコ生放送
|
||||||
- **niconico:playlist**
|
- **niconico:playlist**
|
||||||
- **niconico:series**
|
- **niconico:series**
|
||||||
- **niconico:tag**: NicoNico video tag URLs
|
- **niconico:tag**: NicoNico video tag URLs
|
||||||
@@ -962,7 +969,7 @@ # Supported sites
|
|||||||
- **Nitter**
|
- **Nitter**
|
||||||
- **njoy**: N-JOY
|
- **njoy**: N-JOY
|
||||||
- **njoy:embed**
|
- **njoy:embed**
|
||||||
- **NobelPrize**: (**Currently broken**)
|
- **NobelPrize**
|
||||||
- **NoicePodcast**
|
- **NoicePodcast**
|
||||||
- **NonkTube**
|
- **NonkTube**
|
||||||
- **NoodleMagazine**
|
- **NoodleMagazine**
|
||||||
@@ -1053,6 +1060,8 @@ # Supported sites
|
|||||||
- **Parler**: Posts on parler.com
|
- **Parler**: Posts on parler.com
|
||||||
- **parliamentlive.tv**: UK parliament videos
|
- **parliamentlive.tv**: UK parliament videos
|
||||||
- **Parlview**: (**Currently broken**)
|
- **Parlview**: (**Currently broken**)
|
||||||
|
- **parti:livestream**
|
||||||
|
- **parti:video**
|
||||||
- **patreon**
|
- **patreon**
|
||||||
- **patreon:campaign**
|
- **patreon:campaign**
|
||||||
- **pbs**: Public Broadcasting Service (PBS) and member stations: PBS: Public Broadcasting Service, APT - Alabama Public Television (WBIQ), GPB/Georgia Public Broadcasting (WGTV), Mississippi Public Broadcasting (WMPN), Nashville Public Television (WNPT), WFSU-TV (WFSU), WSRE (WSRE), WTCI (WTCI), WPBA/Channel 30 (WPBA), Alaska Public Media (KAKM), Arizona PBS (KAET), KNME-TV/Channel 5 (KNME), Vegas PBS (KLVX), AETN/ARKANSAS ETV NETWORK (KETS), KET (WKLE), WKNO/Channel 10 (WKNO), LPB/LOUISIANA PUBLIC BROADCASTING (WLPB), OETA (KETA), Ozarks Public Television (KOZK), WSIU Public Broadcasting (WSIU), KEET TV (KEET), KIXE/Channel 9 (KIXE), KPBS San Diego (KPBS), KQED (KQED), KVIE Public Television (KVIE), PBS SoCal/KOCE (KOCE), ValleyPBS (KVPT), CONNECTICUT PUBLIC TELEVISION (WEDH), KNPB Channel 5 (KNPB), SOPTV (KSYS), Rocky Mountain PBS (KRMA), KENW-TV3 (KENW), KUED Channel 7 (KUED), Wyoming PBS (KCWC), Colorado Public Television / KBDI 12 (KBDI), KBYU-TV (KBYU), Thirteen/WNET New York (WNET), WGBH/Channel 2 (WGBH), WGBY (WGBY), NJTV Public Media NJ (WNJT), WLIW21 (WLIW), mpt/Maryland Public Television (WMPB), WETA Television and Radio (WETA), WHYY (WHYY), PBS 39 (WLVT), WVPT - Your Source for PBS and More! (WVPT), Howard University Television (WHUT), WEDU PBS (WEDU), WGCU Public Media (WGCU), WPBT2 (WPBT), WUCF TV (WUCF), WUFT/Channel 5 (WUFT), WXEL/Channel 42 (WXEL), WLRN/Channel 17 (WLRN), WUSF Public Broadcasting (WUSF), ETV (WRLK), UNC-TV (WUNC), PBS Hawaii - Oceanic Cable Channel 10 (KHET), Idaho Public Television (KAID), KSPS (KSPS), OPB (KOPB), KWSU/Channel 10 & KTNW/Channel 31 (KWSU), WILL-TV (WILL), Network Knowledge - WSEC/Springfield (WSEC), WTTW11 (WTTW), Iowa Public Television/IPTV (KDIN), Nine Network (KETC), PBS39 Fort Wayne (WFWA), WFYI Indianapolis (WFYI), Milwaukee Public Television (WMVS), WNIN (WNIN), WNIT Public Television (WNIT), WPT (WPNE), WVUT/Channel 22 (WVUT), WEIU/Channel 51 (WEIU), WQPT-TV (WQPT), WYCC PBS Chicago (WYCC), WIPB-TV (WIPB), WTIU (WTIU), CET (WCET), ThinkTVNetwork (WPTD), WBGU-TV (WBGU), WGVU TV (WGVU), NET1 (KUON), Pioneer Public Television (KWCM), SDPB Television (KUSD), TPT (KTCA), KSMQ (KSMQ), KPTS/Channel 8 (KPTS), KTWU/Channel 11 (KTWU), East Tennessee PBS (WSJK), WCTE-TV (WCTE), WLJT, Channel 11 (WLJT), WOSU TV (WOSU), WOUB/WOUC (WOUB), WVPB (WVPB), WKYU-PBS (WKYU), KERA 13 (KERA), MPBN (WCBB), Mountain Lake PBS (WCFE), NHPTV (WENH), Vermont PBS (WETK), witf (WITF), WQED Multimedia (WQED), WMHT Educational Telecommunications (WMHT), Q-TV (WDCQ), WTVS Detroit Public TV (WTVS), CMU Public Television (WCMU), WKAR-TV (WKAR), WNMU-TV Public TV 13 (WNMU), WDSE - WRPT (WDSE), WGTE TV (WGTE), Lakeland Public Television (KAWE), KMOS-TV - Channels 6.1, 6.2 and 6.3 (KMOS), MontanaPBS (KUSM), KRWG/Channel 22 (KRWG), KACV (KACV), KCOS/Channel 13 (KCOS), WCNY/Channel 24 (WCNY), WNED (WNED), WPBS (WPBS), WSKG Public TV (WSKG), WXXI (WXXI), WPSU (WPSU), WVIA Public Media Studios (WVIA), WTVI (WTVI), Western Reserve PBS (WNEO), WVIZ/PBS ideastream (WVIZ), KCTS 9 (KCTS), Basin PBS (KPBT), KUHT / Channel 8 (KUHT), KLRN (KLRN), KLRU (KLRU), WTJX Channel 12 (WTJX), WCVE PBS (WCVE), KBTC Public Television (KBTC)
|
- **pbs**: Public Broadcasting Service (PBS) and member stations: PBS: Public Broadcasting Service, APT - Alabama Public Television (WBIQ), GPB/Georgia Public Broadcasting (WGTV), Mississippi Public Broadcasting (WMPN), Nashville Public Television (WNPT), WFSU-TV (WFSU), WSRE (WSRE), WTCI (WTCI), WPBA/Channel 30 (WPBA), Alaska Public Media (KAKM), Arizona PBS (KAET), KNME-TV/Channel 5 (KNME), Vegas PBS (KLVX), AETN/ARKANSAS ETV NETWORK (KETS), KET (WKLE), WKNO/Channel 10 (WKNO), LPB/LOUISIANA PUBLIC BROADCASTING (WLPB), OETA (KETA), Ozarks Public Television (KOZK), WSIU Public Broadcasting (WSIU), KEET TV (KEET), KIXE/Channel 9 (KIXE), KPBS San Diego (KPBS), KQED (KQED), KVIE Public Television (KVIE), PBS SoCal/KOCE (KOCE), ValleyPBS (KVPT), CONNECTICUT PUBLIC TELEVISION (WEDH), KNPB Channel 5 (KNPB), SOPTV (KSYS), Rocky Mountain PBS (KRMA), KENW-TV3 (KENW), KUED Channel 7 (KUED), Wyoming PBS (KCWC), Colorado Public Television / KBDI 12 (KBDI), KBYU-TV (KBYU), Thirteen/WNET New York (WNET), WGBH/Channel 2 (WGBH), WGBY (WGBY), NJTV Public Media NJ (WNJT), WLIW21 (WLIW), mpt/Maryland Public Television (WMPB), WETA Television and Radio (WETA), WHYY (WHYY), PBS 39 (WLVT), WVPT - Your Source for PBS and More! (WVPT), Howard University Television (WHUT), WEDU PBS (WEDU), WGCU Public Media (WGCU), WPBT2 (WPBT), WUCF TV (WUCF), WUFT/Channel 5 (WUFT), WXEL/Channel 42 (WXEL), WLRN/Channel 17 (WLRN), WUSF Public Broadcasting (WUSF), ETV (WRLK), UNC-TV (WUNC), PBS Hawaii - Oceanic Cable Channel 10 (KHET), Idaho Public Television (KAID), KSPS (KSPS), OPB (KOPB), KWSU/Channel 10 & KTNW/Channel 31 (KWSU), WILL-TV (WILL), Network Knowledge - WSEC/Springfield (WSEC), WTTW11 (WTTW), Iowa Public Television/IPTV (KDIN), Nine Network (KETC), PBS39 Fort Wayne (WFWA), WFYI Indianapolis (WFYI), Milwaukee Public Television (WMVS), WNIN (WNIN), WNIT Public Television (WNIT), WPT (WPNE), WVUT/Channel 22 (WVUT), WEIU/Channel 51 (WEIU), WQPT-TV (WQPT), WYCC PBS Chicago (WYCC), WIPB-TV (WIPB), WTIU (WTIU), CET (WCET), ThinkTVNetwork (WPTD), WBGU-TV (WBGU), WGVU TV (WGVU), NET1 (KUON), Pioneer Public Television (KWCM), SDPB Television (KUSD), TPT (KTCA), KSMQ (KSMQ), KPTS/Channel 8 (KPTS), KTWU/Channel 11 (KTWU), East Tennessee PBS (WSJK), WCTE-TV (WCTE), WLJT, Channel 11 (WLJT), WOSU TV (WOSU), WOUB/WOUC (WOUB), WVPB (WVPB), WKYU-PBS (WKYU), KERA 13 (KERA), MPBN (WCBB), Mountain Lake PBS (WCFE), NHPTV (WENH), Vermont PBS (WETK), witf (WITF), WQED Multimedia (WQED), WMHT Educational Telecommunications (WMHT), Q-TV (WDCQ), WTVS Detroit Public TV (WTVS), CMU Public Television (WCMU), WKAR-TV (WKAR), WNMU-TV Public TV 13 (WNMU), WDSE - WRPT (WDSE), WGTE TV (WGTE), Lakeland Public Television (KAWE), KMOS-TV - Channels 6.1, 6.2 and 6.3 (KMOS), MontanaPBS (KUSM), KRWG/Channel 22 (KRWG), KACV (KACV), KCOS/Channel 13 (KCOS), WCNY/Channel 24 (WCNY), WNED (WNED), WPBS (WPBS), WSKG Public TV (WSKG), WXXI (WXXI), WPSU (WPSU), WVIA Public Media Studios (WVIA), WTVI (WTVI), Western Reserve PBS (WNEO), WVIZ/PBS ideastream (WVIZ), KCTS 9 (KCTS), Basin PBS (KPBT), KUHT / Channel 8 (KUHT), KLRN (KLRN), KLRU (KLRU), WTJX Channel 12 (WTJX), WCVE PBS (WCVE), KBTC Public Television (KBTC)
|
||||||
@@ -1073,8 +1082,8 @@ # Supported sites
|
|||||||
- **Photobucket**
|
- **Photobucket**
|
||||||
- **PiaLive**
|
- **PiaLive**
|
||||||
- **Piapro**: [*piapro*](## "netrc machine")
|
- **Piapro**: [*piapro*](## "netrc machine")
|
||||||
- **Picarto**
|
- **picarto**
|
||||||
- **PicartoVod**
|
- **picarto:vod**
|
||||||
- **Piksel**
|
- **Piksel**
|
||||||
- **Pinkbike**
|
- **Pinkbike**
|
||||||
- **Pinterest**
|
- **Pinterest**
|
||||||
@@ -1227,6 +1236,7 @@ # Supported sites
|
|||||||
- **RoosterTeeth**: [*roosterteeth*](## "netrc machine")
|
- **RoosterTeeth**: [*roosterteeth*](## "netrc machine")
|
||||||
- **RoosterTeethSeries**: [*roosterteeth*](## "netrc machine")
|
- **RoosterTeethSeries**: [*roosterteeth*](## "netrc machine")
|
||||||
- **RottenTomatoes**
|
- **RottenTomatoes**
|
||||||
|
- **RoyaLive**
|
||||||
- **Rozhlas**
|
- **Rozhlas**
|
||||||
- **RozhlasVltava**
|
- **RozhlasVltava**
|
||||||
- **RTBF**: [*rtbf*](## "netrc machine") (**Currently broken**)
|
- **RTBF**: [*rtbf*](## "netrc machine") (**Currently broken**)
|
||||||
@@ -1247,9 +1257,8 @@ # Supported sites
|
|||||||
- **RTVCKaltura**
|
- **RTVCKaltura**
|
||||||
- **RTVCPlay**
|
- **RTVCPlay**
|
||||||
- **RTVCPlayEmbed**
|
- **RTVCPlayEmbed**
|
||||||
- **rtve.es:alacarta**: RTVE a la carta
|
- **rtve.es:alacarta**: RTVE a la carta and Play
|
||||||
- **rtve.es:audio**: RTVE audio
|
- **rtve.es:audio**: RTVE audio
|
||||||
- **rtve.es:infantil**: RTVE infantil
|
|
||||||
- **rtve.es:live**: RTVE.es live streams
|
- **rtve.es:live**: RTVE.es live streams
|
||||||
- **rtve.es:television**
|
- **rtve.es:television**
|
||||||
- **rtvslo.si**
|
- **rtvslo.si**
|
||||||
@@ -1286,6 +1295,7 @@ # Supported sites
|
|||||||
- **SampleFocus**
|
- **SampleFocus**
|
||||||
- **Sangiin**: 参議院インターネット審議中継 (archive)
|
- **Sangiin**: 参議院インターネット審議中継 (archive)
|
||||||
- **Sapo**: SAPO Vídeos
|
- **Sapo**: SAPO Vídeos
|
||||||
|
- **SaucePlus**: Sauce+
|
||||||
- **SBS**: sbs.com.au
|
- **SBS**: sbs.com.au
|
||||||
- **sbs.co.kr**
|
- **sbs.co.kr**
|
||||||
- **sbs.co.kr:allvod_program**
|
- **sbs.co.kr:allvod_program**
|
||||||
@@ -1382,16 +1392,15 @@ # Supported sites
|
|||||||
- **Spreaker**
|
- **Spreaker**
|
||||||
- **SpreakerShow**
|
- **SpreakerShow**
|
||||||
- **SpringboardPlatform**
|
- **SpringboardPlatform**
|
||||||
- **Sprout**
|
|
||||||
- **SproutVideo**
|
- **SproutVideo**
|
||||||
- **sr:mediathek**: Saarländischer Rundfunk (**Currently broken**)
|
- **sr:mediathek**: Saarländischer Rundfunk
|
||||||
- **SRGSSR**
|
- **SRGSSR**
|
||||||
- **SRGSSRPlay**: srf.ch, rts.ch, rsi.ch, rtr.ch and swissinfo.ch play sites
|
- **SRGSSRPlay**: srf.ch, rts.ch, rsi.ch, rtr.ch and swissinfo.ch play sites
|
||||||
- **StacommuLive**: [*stacommu*](## "netrc machine")
|
- **StacommuLive**: [*stacommu*](## "netrc machine")
|
||||||
- **StacommuVOD**: [*stacommu*](## "netrc machine")
|
- **StacommuVOD**: [*stacommu*](## "netrc machine")
|
||||||
- **StagePlusVODConcert**: [*stageplus*](## "netrc machine")
|
- **StagePlusVODConcert**: [*stageplus*](## "netrc machine")
|
||||||
- **stanfordoc**: Stanford Open ClassRoom
|
- **stanfordoc**: Stanford Open ClassRoom
|
||||||
- **StarTrek**: (**Currently broken**)
|
- **startrek**: STAR TREK
|
||||||
- **startv**
|
- **startv**
|
||||||
- **Steam**
|
- **Steam**
|
||||||
- **SteamCommunityBroadcast**
|
- **SteamCommunityBroadcast**
|
||||||
@@ -1414,12 +1423,11 @@ # Supported sites
|
|||||||
- **SunPorno**
|
- **SunPorno**
|
||||||
- **sverigesradio:episode**
|
- **sverigesradio:episode**
|
||||||
- **sverigesradio:publication**
|
- **sverigesradio:publication**
|
||||||
- **SVT**
|
- **svt:page**
|
||||||
- **SVTPage**
|
- **svt:play**: SVT Play and Öppet arkiv
|
||||||
- **SVTPlay**: SVT Play and Öppet arkiv
|
- **svt:play:series**
|
||||||
- **SVTSeries**
|
|
||||||
- **SwearnetEpisode**
|
- **SwearnetEpisode**
|
||||||
- **Syfy**: (**Currently broken**)
|
- **Syfy**
|
||||||
- **SYVDK**
|
- **SYVDK**
|
||||||
- **SztvHu**
|
- **SztvHu**
|
||||||
- **t-online.de**: (**Currently broken**)
|
- **t-online.de**: (**Currently broken**)
|
||||||
@@ -1463,8 +1471,6 @@ # Supported sites
|
|||||||
- **Telewebion**: (**Currently broken**)
|
- **Telewebion**: (**Currently broken**)
|
||||||
- **Tempo**
|
- **Tempo**
|
||||||
- **TennisTV**: [*tennistv*](## "netrc machine")
|
- **TennisTV**: [*tennistv*](## "netrc machine")
|
||||||
- **TenPlay**: [*10play*](## "netrc machine")
|
|
||||||
- **TenPlaySeason**
|
|
||||||
- **TF1**
|
- **TF1**
|
||||||
- **TFO**
|
- **TFO**
|
||||||
- **theatercomplextown:ppv**: [*theatercomplextown*](## "netrc machine")
|
- **theatercomplextown:ppv**: [*theatercomplextown*](## "netrc machine")
|
||||||
@@ -1502,6 +1508,7 @@ # Supported sites
|
|||||||
- **tokfm:podcast**
|
- **tokfm:podcast**
|
||||||
- **ToonGoggles**
|
- **ToonGoggles**
|
||||||
- **tou.tv**: [*toutv*](## "netrc machine")
|
- **tou.tv**: [*toutv*](## "netrc machine")
|
||||||
|
- **toutiao**: 今日头条
|
||||||
- **Toypics**: Toypics video (**Currently broken**)
|
- **Toypics**: Toypics video (**Currently broken**)
|
||||||
- **ToypicsUser**: Toypics user profile (**Currently broken**)
|
- **ToypicsUser**: Toypics user profile (**Currently broken**)
|
||||||
- **TrailerAddict**: (**Currently broken**)
|
- **TrailerAddict**: (**Currently broken**)
|
||||||
@@ -1562,7 +1569,8 @@ # Supported sites
|
|||||||
- **tvp:vod:series**
|
- **tvp:vod:series**
|
||||||
- **TVPlayer**
|
- **TVPlayer**
|
||||||
- **TVPlayHome**
|
- **TVPlayHome**
|
||||||
- **Tvw**
|
- **tvw**
|
||||||
|
- **tvw:tvchannels**
|
||||||
- **Tweakers**
|
- **Tweakers**
|
||||||
- **TwitCasting**
|
- **TwitCasting**
|
||||||
- **TwitCastingLive**
|
- **TwitCastingLive**
|
||||||
@@ -1590,7 +1598,7 @@ # Supported sites
|
|||||||
- **UKTVPlay**
|
- **UKTVPlay**
|
||||||
- **UlizaPlayer**
|
- **UlizaPlayer**
|
||||||
- **UlizaPortal**: ulizaportal.jp
|
- **UlizaPortal**: ulizaportal.jp
|
||||||
- **umg:de**: Universal Music Deutschland (**Currently broken**)
|
- **umg:de**: Universal Music Deutschland
|
||||||
- **Unistra**
|
- **Unistra**
|
||||||
- **Unity**: (**Currently broken**)
|
- **Unity**: (**Currently broken**)
|
||||||
- **uol.com.br**
|
- **uol.com.br**
|
||||||
@@ -1613,9 +1621,9 @@ # Supported sites
|
|||||||
- **VGTV**: VGTV, BTTV, FTV, Aftenposten and Aftonbladet
|
- **VGTV**: VGTV, BTTV, FTV, Aftenposten and Aftonbladet
|
||||||
- **vh1.com**
|
- **vh1.com**
|
||||||
- **vhx:embed**: [*vimeo*](## "netrc machine")
|
- **vhx:embed**: [*vimeo*](## "netrc machine")
|
||||||
- **vice**
|
- **vice**: (**Currently broken**)
|
||||||
- **vice:article**
|
- **vice:article**: (**Currently broken**)
|
||||||
- **vice:show**
|
- **vice:show**: (**Currently broken**)
|
||||||
- **Viddler**
|
- **Viddler**
|
||||||
- **Videa**
|
- **Videa**
|
||||||
- **video.arnes.si**: Arnes Video
|
- **video.arnes.si**: Arnes Video
|
||||||
@@ -1647,6 +1655,7 @@ # Supported sites
|
|||||||
- **vimeo**: [*vimeo*](## "netrc machine")
|
- **vimeo**: [*vimeo*](## "netrc machine")
|
||||||
- **vimeo:album**: [*vimeo*](## "netrc machine")
|
- **vimeo:album**: [*vimeo*](## "netrc machine")
|
||||||
- **vimeo:channel**: [*vimeo*](## "netrc machine")
|
- **vimeo:channel**: [*vimeo*](## "netrc machine")
|
||||||
|
- **vimeo:event**: [*vimeo*](## "netrc machine")
|
||||||
- **vimeo:group**: [*vimeo*](## "netrc machine")
|
- **vimeo:group**: [*vimeo*](## "netrc machine")
|
||||||
- **vimeo:likes**: [*vimeo*](## "netrc machine") Vimeo user likes
|
- **vimeo:likes**: [*vimeo*](## "netrc machine") Vimeo user likes
|
||||||
- **vimeo:ondemand**: [*vimeo*](## "netrc machine")
|
- **vimeo:ondemand**: [*vimeo*](## "netrc machine")
|
||||||
@@ -1821,14 +1830,12 @@ # Supported sites
|
|||||||
- **ZattooLive**: [*zattoo*](## "netrc machine")
|
- **ZattooLive**: [*zattoo*](## "netrc machine")
|
||||||
- **ZattooMovies**: [*zattoo*](## "netrc machine")
|
- **ZattooMovies**: [*zattoo*](## "netrc machine")
|
||||||
- **ZattooRecordings**: [*zattoo*](## "netrc machine")
|
- **ZattooRecordings**: [*zattoo*](## "netrc machine")
|
||||||
- **ZDF**
|
- **zdf**
|
||||||
- **ZDFChannel**
|
- **zdf:channel**
|
||||||
- **Zee5**: [*zee5*](## "netrc machine")
|
- **Zee5**: [*zee5*](## "netrc machine")
|
||||||
- **zee5:series**
|
- **zee5:series**
|
||||||
- **ZeeNews**: (**Currently broken**)
|
- **ZeeNews**: (**Currently broken**)
|
||||||
- **ZenPorn**
|
- **ZenPorn**
|
||||||
- **ZenYandex**
|
|
||||||
- **ZenYandexChannel**
|
|
||||||
- **ZetlandDKArticle**
|
- **ZetlandDKArticle**
|
||||||
- **Zhihu**
|
- **Zhihu**
|
||||||
- **zingmp3**: zingmp3.vn
|
- **zingmp3**: zingmp3.vn
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ def _iter_differences(got, expected, field):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if op == 'startswith':
|
if op == 'startswith':
|
||||||
if not val.startswith(got):
|
if not got.startswith(val):
|
||||||
yield field, f'should start with {val!r}, got {got!r}'
|
yield field, f'should start with {val!r}, got {got!r}'
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,20 @@ def test_search_json_ld_realworld(self):
|
|||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
# test thumbnail_url key without URL scheme
|
||||||
|
r'''
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoObject",
|
||||||
|
"thumbnail_url": "//www.nobelprize.org/images/12693-landscape-medium-gallery.jpg"
|
||||||
|
}</script>''',
|
||||||
|
{
|
||||||
|
'thumbnails': [{'url': 'https://www.nobelprize.org/images/12693-landscape-medium-gallery.jpg'}],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
for html, expected_dict, search_json_ld_kwargs in _TESTS:
|
for html, expected_dict, search_json_ld_kwargs in _TESTS:
|
||||||
expect_dict(
|
expect_dict(
|
||||||
@@ -1933,6 +1947,137 @@ def test_search_nextjs_data(self):
|
|||||||
with self.assertWarns(DeprecationWarning):
|
with self.assertWarns(DeprecationWarning):
|
||||||
self.assertEqual(self.ie._search_nextjs_data('', None, default='{}'), {})
|
self.assertEqual(self.ie._search_nextjs_data('', None, default='{}'), {})
|
||||||
|
|
||||||
|
def test_search_nuxt_json(self):
|
||||||
|
HTML_TMPL = '<script data-ssr="true" id="__NUXT_DATA__" type="application/json">[{}]</script>'
|
||||||
|
VALID_DATA = '''
|
||||||
|
["ShallowReactive",1],
|
||||||
|
{"data":2,"state":21,"once":25,"_errors":28,"_server_errors":30},
|
||||||
|
["ShallowReactive",3],
|
||||||
|
{"$abcdef123456":4},
|
||||||
|
{"podcast":5,"activeEpisodeData":7},
|
||||||
|
{"podcast":6,"seasons":14},
|
||||||
|
{"title":10,"id":11},
|
||||||
|
["Reactive",8],
|
||||||
|
{"episode":9,"creators":18,"empty_list":20},
|
||||||
|
{"title":12,"id":13,"refs":34,"empty_refs":35},
|
||||||
|
"Series Title",
|
||||||
|
"podcast-id-01",
|
||||||
|
"Episode Title",
|
||||||
|
"episode-id-99",
|
||||||
|
[15,16,17],
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
[19],
|
||||||
|
"Podcast Creator",
|
||||||
|
[],
|
||||||
|
{"$ssite-config":22},
|
||||||
|
{"env":23,"name":24,"map":26,"numbers":14},
|
||||||
|
"production",
|
||||||
|
"podcast-website",
|
||||||
|
["Set"],
|
||||||
|
["Reactive",27],
|
||||||
|
["Map"],
|
||||||
|
["ShallowReactive",29],
|
||||||
|
{},
|
||||||
|
["NuxtError",31],
|
||||||
|
{"status":32,"message":33},
|
||||||
|
503,
|
||||||
|
"Service Unavailable",
|
||||||
|
[36,37],
|
||||||
|
[38,39],
|
||||||
|
["Ref",40],
|
||||||
|
["ShallowRef",41],
|
||||||
|
["EmptyRef",42],
|
||||||
|
["EmptyShallowRef",43],
|
||||||
|
"ref",
|
||||||
|
"shallow_ref",
|
||||||
|
"{\\"ref\\":1}",
|
||||||
|
"{\\"shallow_ref\\":2}"
|
||||||
|
'''
|
||||||
|
PAYLOAD = {
|
||||||
|
'data': {
|
||||||
|
'$abcdef123456': {
|
||||||
|
'podcast': {
|
||||||
|
'podcast': {
|
||||||
|
'title': 'Series Title',
|
||||||
|
'id': 'podcast-id-01',
|
||||||
|
},
|
||||||
|
'seasons': [1, 2, 3],
|
||||||
|
},
|
||||||
|
'activeEpisodeData': {
|
||||||
|
'episode': {
|
||||||
|
'title': 'Episode Title',
|
||||||
|
'id': 'episode-id-99',
|
||||||
|
'refs': ['ref', 'shallow_ref'],
|
||||||
|
'empty_refs': [{'ref': 1}, {'shallow_ref': 2}],
|
||||||
|
},
|
||||||
|
'creators': ['Podcast Creator'],
|
||||||
|
'empty_list': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'state': {
|
||||||
|
'$ssite-config': {
|
||||||
|
'env': 'production',
|
||||||
|
'name': 'podcast-website',
|
||||||
|
'map': [],
|
||||||
|
'numbers': [1, 2, 3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'once': [],
|
||||||
|
'_errors': {},
|
||||||
|
'_server_errors': {
|
||||||
|
'status': 503,
|
||||||
|
'message': 'Service Unavailable',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
PARTIALLY_INVALID = [(
|
||||||
|
'''
|
||||||
|
{"data":1},
|
||||||
|
{"invalid_raw_list":2},
|
||||||
|
[15,16,17]
|
||||||
|
''',
|
||||||
|
{'data': {'invalid_raw_list': [None, None, None]}},
|
||||||
|
), (
|
||||||
|
'''
|
||||||
|
{"data":1},
|
||||||
|
["EmptyRef",2],
|
||||||
|
"not valid JSON"
|
||||||
|
''',
|
||||||
|
{'data': None},
|
||||||
|
), (
|
||||||
|
'''
|
||||||
|
{"data":1},
|
||||||
|
["EmptyShallowRef",2],
|
||||||
|
"not valid JSON"
|
||||||
|
''',
|
||||||
|
{'data': None},
|
||||||
|
)]
|
||||||
|
INVALID = [
|
||||||
|
'''
|
||||||
|
[]
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
["unsupported",1],
|
||||||
|
{"data":2},
|
||||||
|
{}
|
||||||
|
''',
|
||||||
|
]
|
||||||
|
DEFAULT = object()
|
||||||
|
|
||||||
|
self.assertEqual(self.ie._search_nuxt_json(HTML_TMPL.format(VALID_DATA), None), PAYLOAD)
|
||||||
|
self.assertEqual(self.ie._search_nuxt_json('', None, fatal=False), {})
|
||||||
|
self.assertIs(self.ie._search_nuxt_json('', None, default=DEFAULT), DEFAULT)
|
||||||
|
|
||||||
|
for data, expected in PARTIALLY_INVALID:
|
||||||
|
self.assertEqual(
|
||||||
|
self.ie._search_nuxt_json(HTML_TMPL.format(data), None, fatal=False), expected)
|
||||||
|
|
||||||
|
for data in INVALID:
|
||||||
|
self.assertIs(
|
||||||
|
self.ie._search_nuxt_json(HTML_TMPL.format(data), None, default=DEFAULT), DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1435,6 +1435,27 @@ def test_load_plugins_compat(self):
|
|||||||
FakeYDL().close()
|
FakeYDL().close()
|
||||||
assert all_plugins_loaded.value
|
assert all_plugins_loaded.value
|
||||||
|
|
||||||
|
def test_close_hooks(self):
|
||||||
|
# Should call all registered close hooks on close
|
||||||
|
close_hook_called = False
|
||||||
|
close_hook_two_called = False
|
||||||
|
|
||||||
|
def close_hook():
|
||||||
|
nonlocal close_hook_called
|
||||||
|
close_hook_called = True
|
||||||
|
|
||||||
|
def close_hook_two():
|
||||||
|
nonlocal close_hook_two_called
|
||||||
|
close_hook_two_called = True
|
||||||
|
|
||||||
|
ydl = FakeYDL()
|
||||||
|
ydl.add_close_hook(close_hook)
|
||||||
|
ydl.add_close_hook(close_hook_two)
|
||||||
|
|
||||||
|
ydl.close()
|
||||||
|
self.assertTrue(close_hook_called, 'Close hook was not called')
|
||||||
|
self.assertTrue(close_hook_two_called, 'Close hook two was not called')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ def test_get_desktop_environment(self):
|
|||||||
({'DESKTOP_SESSION': 'kde'}, _LinuxDesktopEnvironment.KDE3),
|
({'DESKTOP_SESSION': 'kde'}, _LinuxDesktopEnvironment.KDE3),
|
||||||
({'DESKTOP_SESSION': 'xfce'}, _LinuxDesktopEnvironment.XFCE),
|
({'DESKTOP_SESSION': 'xfce'}, _LinuxDesktopEnvironment.XFCE),
|
||||||
|
|
||||||
|
({'XDG_CURRENT_DESKTOP': 'my_custom_de', 'DESKTOP_SESSION': 'gnome'}, _LinuxDesktopEnvironment.GNOME),
|
||||||
|
({'XDG_CURRENT_DESKTOP': 'my_custom_de', 'DESKTOP_SESSION': 'mate'}, _LinuxDesktopEnvironment.GNOME),
|
||||||
|
({'XDG_CURRENT_DESKTOP': 'my_custom_de', 'DESKTOP_SESSION': 'kde4'}, _LinuxDesktopEnvironment.KDE4),
|
||||||
|
({'XDG_CURRENT_DESKTOP': 'my_custom_de', 'DESKTOP_SESSION': 'kde'}, _LinuxDesktopEnvironment.KDE3),
|
||||||
|
({'XDG_CURRENT_DESKTOP': 'my_custom_de', 'DESKTOP_SESSION': 'xfce'}, _LinuxDesktopEnvironment.XFCE),
|
||||||
|
|
||||||
|
({'XDG_CURRENT_DESKTOP': 'my_custom_de', 'DESKTOP_SESSION': 'my_custom_de', 'GNOME_DESKTOP_SESSION_ID': 1}, _LinuxDesktopEnvironment.GNOME),
|
||||||
|
|
||||||
({'GNOME_DESKTOP_SESSION_ID': 1}, _LinuxDesktopEnvironment.GNOME),
|
({'GNOME_DESKTOP_SESSION_ID': 1}, _LinuxDesktopEnvironment.GNOME),
|
||||||
({'KDE_FULL_SESSION': 1}, _LinuxDesktopEnvironment.KDE3),
|
({'KDE_FULL_SESSION': 1}, _LinuxDesktopEnvironment.KDE3),
|
||||||
({'KDE_FULL_SESSION': 1, 'DESKTOP_SESSION': 'kde4'}, _LinuxDesktopEnvironment.KDE4),
|
({'KDE_FULL_SESSION': 1, 'DESKTOP_SESSION': 'kde4'}, _LinuxDesktopEnvironment.KDE4),
|
||||||
|
|||||||
235
test/test_devalue.py
Normal file
235
test/test_devalue.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Allow direct execution
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from yt_dlp.utils.jslib import devalue
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CASES_EQUALS = [{
|
||||||
|
'name': 'int',
|
||||||
|
'unparsed': [-42],
|
||||||
|
'parsed': -42,
|
||||||
|
}, {
|
||||||
|
'name': 'str',
|
||||||
|
'unparsed': ['woo!!!'],
|
||||||
|
'parsed': 'woo!!!',
|
||||||
|
}, {
|
||||||
|
'name': 'Number',
|
||||||
|
'unparsed': [['Object', 42]],
|
||||||
|
'parsed': 42,
|
||||||
|
}, {
|
||||||
|
'name': 'String',
|
||||||
|
'unparsed': [['Object', 'yar']],
|
||||||
|
'parsed': 'yar',
|
||||||
|
}, {
|
||||||
|
'name': 'Infinity',
|
||||||
|
'unparsed': -4,
|
||||||
|
'parsed': math.inf,
|
||||||
|
}, {
|
||||||
|
'name': 'negative Infinity',
|
||||||
|
'unparsed': -5,
|
||||||
|
'parsed': -math.inf,
|
||||||
|
}, {
|
||||||
|
'name': 'negative zero',
|
||||||
|
'unparsed': -6,
|
||||||
|
'parsed': -0.0,
|
||||||
|
}, {
|
||||||
|
'name': 'RegExp',
|
||||||
|
'unparsed': [['RegExp', 'regexp', 'gim']], # XXX: flags are ignored
|
||||||
|
'parsed': re.compile('regexp'),
|
||||||
|
}, {
|
||||||
|
'name': 'Date',
|
||||||
|
'unparsed': [['Date', '2001-09-09T01:46:40.000Z']],
|
||||||
|
'parsed': dt.datetime.fromtimestamp(1e9, tz=dt.timezone.utc),
|
||||||
|
}, {
|
||||||
|
'name': 'Array',
|
||||||
|
'unparsed': [[1, 2, 3], 'a', 'b', 'c'],
|
||||||
|
'parsed': ['a', 'b', 'c'],
|
||||||
|
}, {
|
||||||
|
'name': 'Array (empty)',
|
||||||
|
'unparsed': [[]],
|
||||||
|
'parsed': [],
|
||||||
|
}, {
|
||||||
|
'name': 'Array (sparse)',
|
||||||
|
'unparsed': [[-2, 1, -2], 'b'],
|
||||||
|
'parsed': [None, 'b', None],
|
||||||
|
}, {
|
||||||
|
'name': 'Object',
|
||||||
|
'unparsed': [{'foo': 1, 'x-y': 2}, 'bar', 'z'],
|
||||||
|
'parsed': {'foo': 'bar', 'x-y': 'z'},
|
||||||
|
}, {
|
||||||
|
'name': 'Set',
|
||||||
|
'unparsed': [['Set', 1, 2, 3], 1, 2, 3],
|
||||||
|
'parsed': [1, 2, 3],
|
||||||
|
}, {
|
||||||
|
'name': 'Map',
|
||||||
|
'unparsed': [['Map', 1, 2], 'a', 'b'],
|
||||||
|
'parsed': [['a', 'b']],
|
||||||
|
}, {
|
||||||
|
'name': 'BigInt',
|
||||||
|
'unparsed': [['BigInt', '1']],
|
||||||
|
'parsed': 1,
|
||||||
|
}, {
|
||||||
|
'name': 'Uint8Array',
|
||||||
|
'unparsed': [['Uint8Array', 'AQID']],
|
||||||
|
'parsed': [1, 2, 3],
|
||||||
|
}, {
|
||||||
|
'name': 'ArrayBuffer',
|
||||||
|
'unparsed': [['ArrayBuffer', 'AQID']],
|
||||||
|
'parsed': [1, 2, 3],
|
||||||
|
}, {
|
||||||
|
'name': 'str (repetition)',
|
||||||
|
'unparsed': [[1, 1], 'a string'],
|
||||||
|
'parsed': ['a string', 'a string'],
|
||||||
|
}, {
|
||||||
|
'name': 'None (repetition)',
|
||||||
|
'unparsed': [[1, 1], None],
|
||||||
|
'parsed': [None, None],
|
||||||
|
}, {
|
||||||
|
'name': 'dict (repetition)',
|
||||||
|
'unparsed': [[1, 1], {}],
|
||||||
|
'parsed': [{}, {}],
|
||||||
|
}, {
|
||||||
|
'name': 'Object without prototype',
|
||||||
|
'unparsed': [['null']],
|
||||||
|
'parsed': {},
|
||||||
|
}, {
|
||||||
|
'name': 'cross-realm POJO',
|
||||||
|
'unparsed': [{}],
|
||||||
|
'parsed': {},
|
||||||
|
}]
|
||||||
|
|
||||||
|
TEST_CASES_IS = [{
|
||||||
|
'name': 'bool',
|
||||||
|
'unparsed': [True],
|
||||||
|
'parsed': True,
|
||||||
|
}, {
|
||||||
|
'name': 'Boolean',
|
||||||
|
'unparsed': [['Object', False]],
|
||||||
|
'parsed': False,
|
||||||
|
}, {
|
||||||
|
'name': 'undefined',
|
||||||
|
'unparsed': -1,
|
||||||
|
'parsed': None,
|
||||||
|
}, {
|
||||||
|
'name': 'null',
|
||||||
|
'unparsed': [None],
|
||||||
|
'parsed': None,
|
||||||
|
}, {
|
||||||
|
'name': 'NaN',
|
||||||
|
'unparsed': -3,
|
||||||
|
'parsed': math.nan,
|
||||||
|
}]
|
||||||
|
|
||||||
|
TEST_CASES_INVALID = [{
|
||||||
|
'name': 'empty string',
|
||||||
|
'unparsed': '',
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'expected int or list as input',
|
||||||
|
}, {
|
||||||
|
'name': 'hole',
|
||||||
|
'unparsed': -2,
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'invalid integer input',
|
||||||
|
}, {
|
||||||
|
'name': 'string',
|
||||||
|
'unparsed': 'hello',
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'expected int or list as input',
|
||||||
|
}, {
|
||||||
|
'name': 'number',
|
||||||
|
'unparsed': 42,
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'invalid integer input',
|
||||||
|
}, {
|
||||||
|
'name': 'boolean',
|
||||||
|
'unparsed': True,
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'expected int or list as input',
|
||||||
|
}, {
|
||||||
|
'name': 'null',
|
||||||
|
'unparsed': None,
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'expected int or list as input',
|
||||||
|
}, {
|
||||||
|
'name': 'object',
|
||||||
|
'unparsed': {},
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'expected int or list as input',
|
||||||
|
}, {
|
||||||
|
'name': 'empty array',
|
||||||
|
'unparsed': [],
|
||||||
|
'error': ValueError,
|
||||||
|
'pattern': r'expected a non-empty list as input',
|
||||||
|
}, {
|
||||||
|
'name': 'Python negative indexing',
|
||||||
|
'unparsed': [[1, 2, 3, 4, 5, 6, 7, -7], 1, 2, 3, 4, 5, 6, 7],
|
||||||
|
'error': IndexError,
|
||||||
|
'pattern': r'invalid index: -7',
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevalue(unittest.TestCase):
|
||||||
|
def test_devalue_parse_equals(self):
|
||||||
|
for tc in TEST_CASES_EQUALS:
|
||||||
|
self.assertEqual(devalue.parse(tc['unparsed']), tc['parsed'], tc['name'])
|
||||||
|
|
||||||
|
def test_devalue_parse_is(self):
|
||||||
|
for tc in TEST_CASES_IS:
|
||||||
|
self.assertIs(devalue.parse(tc['unparsed']), tc['parsed'], tc['name'])
|
||||||
|
|
||||||
|
def test_devalue_parse_invalid(self):
|
||||||
|
for tc in TEST_CASES_INVALID:
|
||||||
|
with self.assertRaisesRegex(tc['error'], tc['pattern'], msg=tc['name']):
|
||||||
|
devalue.parse(tc['unparsed'])
|
||||||
|
|
||||||
|
def test_devalue_parse_cyclical(self):
|
||||||
|
name = 'Map (cyclical)'
|
||||||
|
result = devalue.parse([['Map', 1, 0], 'self'])
|
||||||
|
self.assertEqual(result[0][0], 'self', name)
|
||||||
|
self.assertIs(result, result[0][1], name)
|
||||||
|
|
||||||
|
name = 'Set (cyclical)'
|
||||||
|
result = devalue.parse([['Set', 0, 1], 42])
|
||||||
|
self.assertEqual(result[1], 42, name)
|
||||||
|
self.assertIs(result, result[0], name)
|
||||||
|
|
||||||
|
result = devalue.parse([[0]])
|
||||||
|
self.assertIs(result, result[0], 'Array (cyclical)')
|
||||||
|
|
||||||
|
name = 'Object (cyclical)'
|
||||||
|
result = devalue.parse([{'self': 0}])
|
||||||
|
self.assertIs(result, result['self'], name)
|
||||||
|
|
||||||
|
name = 'Object with null prototype (cyclical)'
|
||||||
|
result = devalue.parse([['null', 'self', 0]])
|
||||||
|
self.assertIs(result, result['self'], name)
|
||||||
|
|
||||||
|
name = 'Objects (cyclical)'
|
||||||
|
result = devalue.parse([[1, 2], {'second': 2}, {'first': 1}])
|
||||||
|
self.assertIs(result[0], result[1]['first'], name)
|
||||||
|
self.assertIs(result[1], result[0]['second'], name)
|
||||||
|
|
||||||
|
def test_devalue_parse_revivers(self):
|
||||||
|
self.assertEqual(
|
||||||
|
devalue.parse([['indirect', 1], {'a': 2}, 'b'], revivers={'indirect': lambda x: x}),
|
||||||
|
{'a': 'b'}, 'revivers (indirect)')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
devalue.parse([['parse', 1], '{"a":0}'], revivers={'parse': lambda x: json.loads(x)}),
|
||||||
|
{'a': 0}, 'revivers (parse)')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -478,6 +478,18 @@ def test_extract_function_with_global_stack(self):
|
|||||||
func = jsi.extract_function('c', {'e': 10}, {'f': 100, 'g': 1000})
|
func = jsi.extract_function('c', {'e': 10}, {'f': 100, 'g': 1000})
|
||||||
self.assertEqual(func([1]), 1111)
|
self.assertEqual(func([1]), 1111)
|
||||||
|
|
||||||
|
def test_extract_object(self):
|
||||||
|
jsi = JSInterpreter('var a={};a.xy={};var xy;var zxy={};xy={z:function(){return "abc"}};')
|
||||||
|
self.assertTrue('z' in jsi.extract_object('xy', None))
|
||||||
|
|
||||||
|
def test_increment_decrement(self):
|
||||||
|
self._test('function f() { var x = 1; return ++x; }', 2)
|
||||||
|
self._test('function f() { var x = 1; return x++; }', 1)
|
||||||
|
self._test('function f() { var x = 1; x--; return x }', 0)
|
||||||
|
self._test('function f() { var y; var x = 1; x++, --x, x--, x--, y="z", "abc", x++; return --x }', -1)
|
||||||
|
self._test('function f() { var a = "test--"; return a; }', 'test--')
|
||||||
|
self._test('function f() { var b = 1; var a = "b--"; return a; }', 'b--')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
from yt_dlp.dependencies import brotli, curl_cffi, requests, urllib3
|
from yt_dlp.dependencies import brotli, curl_cffi, requests, urllib3
|
||||||
from yt_dlp.networking import (
|
from yt_dlp.networking import (
|
||||||
HEADRequest,
|
HEADRequest,
|
||||||
|
PATCHRequest,
|
||||||
PUTRequest,
|
PUTRequest,
|
||||||
Request,
|
Request,
|
||||||
RequestDirector,
|
RequestDirector,
|
||||||
@@ -1856,6 +1857,7 @@ def test_method(self):
|
|||||||
|
|
||||||
def test_request_helpers(self):
|
def test_request_helpers(self):
|
||||||
assert HEADRequest('http://example.com').method == 'HEAD'
|
assert HEADRequest('http://example.com').method == 'HEAD'
|
||||||
|
assert PATCHRequest('http://example.com').method == 'PATCH'
|
||||||
assert PUTRequest('http://example.com').method == 'PUT'
|
assert PUTRequest('http://example.com').method == 'PUT'
|
||||||
|
|
||||||
def test_headers(self):
|
def test_headers(self):
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
add_accept_encoding_header,
|
add_accept_encoding_header,
|
||||||
get_redirect_method,
|
get_redirect_method,
|
||||||
make_socks_proxy_opts,
|
make_socks_proxy_opts,
|
||||||
select_proxy,
|
|
||||||
ssl_load_certs,
|
ssl_load_certs,
|
||||||
)
|
)
|
||||||
from yt_dlp.networking.exceptions import (
|
from yt_dlp.networking.exceptions import (
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
IncompleteRead,
|
IncompleteRead,
|
||||||
)
|
)
|
||||||
from yt_dlp.socks import ProxyType
|
from yt_dlp.socks import ProxyType
|
||||||
from yt_dlp.utils.networking import HTTPHeaderDict
|
from yt_dlp.utils.networking import HTTPHeaderDict, select_proxy
|
||||||
|
|
||||||
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
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 subprocess
|
||||||
|
|
||||||
from yt_dlp import YoutubeDL
|
from yt_dlp import YoutubeDL
|
||||||
from yt_dlp.utils import shell_quote
|
from yt_dlp.utils import shell_quote
|
||||||
from yt_dlp.postprocessor import (
|
from yt_dlp.postprocessor import (
|
||||||
@@ -47,7 +49,18 @@ def test_escaping(self):
|
|||||||
print('Skipping: ffmpeg not found')
|
print('Skipping: ffmpeg not found')
|
||||||
return
|
return
|
||||||
|
|
||||||
file = 'test/testdata/thumbnails/foo %d bar/foo_%d.{}'
|
test_data_dir = 'test/testdata/thumbnails'
|
||||||
|
generated_file = f'{test_data_dir}/empty.webp'
|
||||||
|
|
||||||
|
subprocess.check_call([
|
||||||
|
pp.executable, '-y', '-f', 'lavfi', '-i', 'color=c=black:s=320x320',
|
||||||
|
'-c:v', 'libwebp', '-pix_fmt', 'yuv420p', '-vframes', '1', generated_file,
|
||||||
|
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
file = test_data_dir + '/foo %d bar/foo_%d.{}'
|
||||||
|
initial_file = file.format('webp')
|
||||||
|
os.replace(generated_file, initial_file)
|
||||||
|
|
||||||
tests = (('webp', 'png'), ('png', 'jpg'))
|
tests = (('webp', 'png'), ('png', 'jpg'))
|
||||||
|
|
||||||
for inp, out in tests:
|
for inp, out in tests:
|
||||||
@@ -55,11 +68,13 @@ def test_escaping(self):
|
|||||||
if os.path.exists(out_file):
|
if os.path.exists(out_file):
|
||||||
os.remove(out_file)
|
os.remove(out_file)
|
||||||
pp.convert_thumbnail(file.format(inp), out)
|
pp.convert_thumbnail(file.format(inp), out)
|
||||||
assert os.path.exists(out_file)
|
self.assertTrue(os.path.exists(out_file))
|
||||||
|
|
||||||
for _, out in tests:
|
for _, out in tests:
|
||||||
os.remove(file.format(out))
|
os.remove(file.format(out))
|
||||||
|
|
||||||
|
os.remove(initial_file)
|
||||||
|
|
||||||
|
|
||||||
class TestExec(unittest.TestCase):
|
class TestExec(unittest.TestCase):
|
||||||
def test_parse_cmd(self):
|
def test_parse_cmd(self):
|
||||||
@@ -610,3 +625,7 @@ def test_quote_for_concat_QuotesAtEnd(self):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
r"'special '\'' characters '\'' galore'\'\'\'",
|
r"'special '\'' characters '\'' galore'\'\'\'",
|
||||||
self._pp._quote_for_ffmpeg("special ' characters ' galore'''"))
|
self._pp._quote_for_ffmpeg("special ' characters ' galore'''"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|||||||
71
test/test_pot/conftest.py
Normal file
71
test/test_pot/conftest.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import collections
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from yt_dlp import YoutubeDL
|
||||||
|
from yt_dlp.cookies import YoutubeDLCookieJar
|
||||||
|
from yt_dlp.extractor.common import InfoExtractor
|
||||||
|
from yt_dlp.extractor.youtube.pot._provider import IEContentProviderLogger
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import PoTokenRequest, PoTokenContext
|
||||||
|
from yt_dlp.utils.networking import HTTPHeaderDict
|
||||||
|
|
||||||
|
|
||||||
|
class MockLogger(IEContentProviderLogger):
|
||||||
|
|
||||||
|
log_level = IEContentProviderLogger.LogLevel.TRACE
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.messages = collections.defaultdict(list)
|
||||||
|
|
||||||
|
def trace(self, message: str):
|
||||||
|
self.messages['trace'].append(message)
|
||||||
|
|
||||||
|
def debug(self, message: str):
|
||||||
|
self.messages['debug'].append(message)
|
||||||
|
|
||||||
|
def info(self, message: str):
|
||||||
|
self.messages['info'].append(message)
|
||||||
|
|
||||||
|
def warning(self, message: str, *, once=False):
|
||||||
|
self.messages['warning'].append(message)
|
||||||
|
|
||||||
|
def error(self, message: str):
|
||||||
|
self.messages['error'].append(message)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ie() -> InfoExtractor:
|
||||||
|
ydl = YoutubeDL()
|
||||||
|
return ydl.get_info_extractor('Youtube')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logger() -> MockLogger:
|
||||||
|
return MockLogger()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def pot_request() -> PoTokenRequest:
|
||||||
|
return PoTokenRequest(
|
||||||
|
context=PoTokenContext.GVS,
|
||||||
|
innertube_context={'client': {'clientName': 'WEB'}},
|
||||||
|
innertube_host='youtube.com',
|
||||||
|
session_index=None,
|
||||||
|
player_url=None,
|
||||||
|
is_authenticated=False,
|
||||||
|
video_webpage=None,
|
||||||
|
|
||||||
|
visitor_data='example-visitor-data',
|
||||||
|
data_sync_id='example-data-sync-id',
|
||||||
|
video_id='example-video-id',
|
||||||
|
|
||||||
|
request_cookiejar=YoutubeDLCookieJar(),
|
||||||
|
request_proxy=None,
|
||||||
|
request_headers=HTTPHeaderDict(),
|
||||||
|
request_timeout=None,
|
||||||
|
request_source_address=None,
|
||||||
|
request_verify_tls=True,
|
||||||
|
|
||||||
|
bypass_cache=False,
|
||||||
|
)
|
||||||
117
test/test_pot/test_pot_builtin_memorycache.py
Normal file
117
test/test_pot/test_pot_builtin_memorycache.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
import pytest
|
||||||
|
from yt_dlp.extractor.youtube.pot._provider import IEContentProvider, BuiltinIEContentProvider
|
||||||
|
from yt_dlp.utils import bug_reports_message
|
||||||
|
from yt_dlp.extractor.youtube.pot._builtin.memory_cache import MemoryLRUPCP, memorylru_preference, initialize_global_cache
|
||||||
|
from yt_dlp.version import __version__
|
||||||
|
from yt_dlp.extractor.youtube.pot._registry import _pot_cache_providers, _pot_memory_cache
|
||||||
|
|
||||||
|
|
||||||
|
class TestMemoryLRUPCS:
|
||||||
|
|
||||||
|
def test_base_type(self):
|
||||||
|
assert issubclass(MemoryLRUPCP, IEContentProvider)
|
||||||
|
assert issubclass(MemoryLRUPCP, BuiltinIEContentProvider)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pcp(self, ie, logger) -> MemoryLRUPCP:
|
||||||
|
return MemoryLRUPCP(ie, logger, {}, initialize_cache=lambda max_size: (OrderedDict(), threading.Lock(), max_size))
|
||||||
|
|
||||||
|
def test_is_registered(self):
|
||||||
|
assert _pot_cache_providers.value.get('MemoryLRU') == MemoryLRUPCP
|
||||||
|
|
||||||
|
def test_initialization(self, pcp):
|
||||||
|
assert pcp.PROVIDER_NAME == 'memory'
|
||||||
|
assert pcp.PROVIDER_VERSION == __version__
|
||||||
|
assert pcp.BUG_REPORT_MESSAGE == bug_reports_message(before='')
|
||||||
|
assert pcp.is_available()
|
||||||
|
|
||||||
|
def test_store_and_get(self, pcp):
|
||||||
|
pcp.store('key1', 'value1', int(time.time()) + 60)
|
||||||
|
assert pcp.get('key1') == 'value1'
|
||||||
|
assert len(pcp.cache) == 1
|
||||||
|
|
||||||
|
def test_store_ignore_expired(self, pcp):
|
||||||
|
pcp.store('key1', 'value1', int(time.time()) - 1)
|
||||||
|
assert len(pcp.cache) == 0
|
||||||
|
assert pcp.get('key1') is None
|
||||||
|
assert len(pcp.cache) == 0
|
||||||
|
|
||||||
|
def test_store_override_existing_key(self, ie, logger):
|
||||||
|
MAX_SIZE = 2
|
||||||
|
pcp = MemoryLRUPCP(ie, logger, {}, initialize_cache=lambda max_size: (OrderedDict(), threading.Lock(), MAX_SIZE))
|
||||||
|
pcp.store('key1', 'value1', int(time.time()) + 60)
|
||||||
|
pcp.store('key2', 'value2', int(time.time()) + 60)
|
||||||
|
assert len(pcp.cache) == 2
|
||||||
|
pcp.store('key1', 'value2', int(time.time()) + 60)
|
||||||
|
# Ensure that the override key gets added to the end of the cache instead of in the same position
|
||||||
|
pcp.store('key3', 'value3', int(time.time()) + 60)
|
||||||
|
assert pcp.get('key1') == 'value2'
|
||||||
|
|
||||||
|
def test_store_ignore_expired_existing_key(self, pcp):
|
||||||
|
pcp.store('key1', 'value2', int(time.time()) + 60)
|
||||||
|
pcp.store('key1', 'value1', int(time.time()) - 1)
|
||||||
|
assert len(pcp.cache) == 1
|
||||||
|
assert pcp.get('key1') == 'value2'
|
||||||
|
assert len(pcp.cache) == 1
|
||||||
|
|
||||||
|
def test_get_key_expired(self, pcp):
|
||||||
|
pcp.store('key1', 'value1', int(time.time()) + 60)
|
||||||
|
assert pcp.get('key1') == 'value1'
|
||||||
|
assert len(pcp.cache) == 1
|
||||||
|
pcp.cache['key1'] = ('value1', int(time.time()) - 1)
|
||||||
|
assert pcp.get('key1') is None
|
||||||
|
assert len(pcp.cache) == 0
|
||||||
|
|
||||||
|
def test_lru_eviction(self, ie, logger):
|
||||||
|
MAX_SIZE = 2
|
||||||
|
provider = MemoryLRUPCP(ie, logger, {}, initialize_cache=lambda max_size: (OrderedDict(), threading.Lock(), MAX_SIZE))
|
||||||
|
provider.store('key1', 'value1', int(time.time()) + 5)
|
||||||
|
provider.store('key2', 'value2', int(time.time()) + 5)
|
||||||
|
assert len(provider.cache) == 2
|
||||||
|
|
||||||
|
assert provider.get('key1') == 'value1'
|
||||||
|
|
||||||
|
provider.store('key3', 'value3', int(time.time()) + 5)
|
||||||
|
assert len(provider.cache) == 2
|
||||||
|
|
||||||
|
assert provider.get('key2') is None
|
||||||
|
|
||||||
|
provider.store('key4', 'value4', int(time.time()) + 5)
|
||||||
|
assert len(provider.cache) == 2
|
||||||
|
|
||||||
|
assert provider.get('key1') is None
|
||||||
|
assert provider.get('key3') == 'value3'
|
||||||
|
assert provider.get('key4') == 'value4'
|
||||||
|
|
||||||
|
def test_delete(self, pcp):
|
||||||
|
pcp.store('key1', 'value1', int(time.time()) + 5)
|
||||||
|
assert len(pcp.cache) == 1
|
||||||
|
assert pcp.get('key1') == 'value1'
|
||||||
|
pcp.delete('key1')
|
||||||
|
assert len(pcp.cache) == 0
|
||||||
|
assert pcp.get('key1') is None
|
||||||
|
|
||||||
|
def test_use_global_cache_default(self, ie, logger):
|
||||||
|
pcp = MemoryLRUPCP(ie, logger, {})
|
||||||
|
assert pcp.max_size == _pot_memory_cache.value['max_size'] == 25
|
||||||
|
assert pcp.cache is _pot_memory_cache.value['cache']
|
||||||
|
assert pcp.lock is _pot_memory_cache.value['lock']
|
||||||
|
|
||||||
|
pcp2 = MemoryLRUPCP(ie, logger, {})
|
||||||
|
assert pcp.max_size == pcp2.max_size == _pot_memory_cache.value['max_size'] == 25
|
||||||
|
assert pcp.cache is pcp2.cache is _pot_memory_cache.value['cache']
|
||||||
|
assert pcp.lock is pcp2.lock is _pot_memory_cache.value['lock']
|
||||||
|
|
||||||
|
def test_fail_max_size_change_global(self, ie, logger):
|
||||||
|
pcp = MemoryLRUPCP(ie, logger, {})
|
||||||
|
assert pcp.max_size == _pot_memory_cache.value['max_size'] == 25
|
||||||
|
with pytest.raises(ValueError, match='Cannot change max_size of initialized global memory cache'):
|
||||||
|
initialize_global_cache(50)
|
||||||
|
|
||||||
|
assert pcp.max_size == _pot_memory_cache.value['max_size'] == 25
|
||||||
|
|
||||||
|
def test_memory_lru_preference(self, pcp, ie, pot_request):
|
||||||
|
assert memorylru_preference(pcp, pot_request) == 10000
|
||||||
47
test/test_pot/test_pot_builtin_utils.py
Normal file
47
test/test_pot/test_pot_builtin_utils.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import pytest
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
PoTokenContext,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding, ContentBindingType
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetWebPoContentBinding:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('client_name, context, is_authenticated, expected', [
|
||||||
|
*[(client, context, is_authenticated, expected) for client in [
|
||||||
|
'WEB', 'MWEB', 'TVHTML5', 'WEB_EMBEDDED_PLAYER', 'WEB_CREATOR', 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', 'TVHTML5_SIMPLY']
|
||||||
|
for context, is_authenticated, expected in [
|
||||||
|
(PoTokenContext.GVS, False, ('example-visitor-data', ContentBindingType.VISITOR_DATA)),
|
||||||
|
(PoTokenContext.PLAYER, False, ('example-video-id', ContentBindingType.VIDEO_ID)),
|
||||||
|
(PoTokenContext.SUBS, False, ('example-video-id', ContentBindingType.VIDEO_ID)),
|
||||||
|
(PoTokenContext.GVS, True, ('example-data-sync-id', ContentBindingType.DATASYNC_ID)),
|
||||||
|
]],
|
||||||
|
('WEB_REMIX', PoTokenContext.GVS, False, ('example-visitor-data', ContentBindingType.VISITOR_DATA)),
|
||||||
|
('WEB_REMIX', PoTokenContext.PLAYER, False, ('example-visitor-data', ContentBindingType.VISITOR_DATA)),
|
||||||
|
('ANDROID', PoTokenContext.GVS, False, (None, None)),
|
||||||
|
('IOS', PoTokenContext.GVS, False, (None, None)),
|
||||||
|
])
|
||||||
|
def test_get_webpo_content_binding(self, pot_request, client_name, context, is_authenticated, expected):
|
||||||
|
pot_request.innertube_context['client']['clientName'] = client_name
|
||||||
|
pot_request.context = context
|
||||||
|
pot_request.is_authenticated = is_authenticated
|
||||||
|
assert get_webpo_content_binding(pot_request) == expected
|
||||||
|
|
||||||
|
def test_extract_visitor_id(self, pot_request):
|
||||||
|
pot_request.visitor_data = 'CgsxMjNhYmNYWVpfLSiA4s%2DqBg%3D%3D'
|
||||||
|
assert get_webpo_content_binding(pot_request, bind_to_visitor_id=True) == ('123abcXYZ_-', ContentBindingType.VISITOR_ID)
|
||||||
|
|
||||||
|
def test_invalid_visitor_id(self, pot_request):
|
||||||
|
# visitor id not alphanumeric (i.e. protobuf extraction failed)
|
||||||
|
pot_request.visitor_data = 'CggxMjM0NTY3OCiA4s-qBg%3D%3D'
|
||||||
|
assert get_webpo_content_binding(pot_request, bind_to_visitor_id=True) == (pot_request.visitor_data, ContentBindingType.VISITOR_DATA)
|
||||||
|
|
||||||
|
def test_no_visitor_id(self, pot_request):
|
||||||
|
pot_request.visitor_data = 'KIDiz6oG'
|
||||||
|
assert get_webpo_content_binding(pot_request, bind_to_visitor_id=True) == (pot_request.visitor_data, ContentBindingType.VISITOR_DATA)
|
||||||
|
|
||||||
|
def test_invalid_base64(self, pot_request):
|
||||||
|
pot_request.visitor_data = 'invalid-base64'
|
||||||
|
assert get_webpo_content_binding(pot_request, bind_to_visitor_id=True) == (pot_request.visitor_data, ContentBindingType.VISITOR_DATA)
|
||||||
92
test/test_pot/test_pot_builtin_webpospec.py
Normal file
92
test/test_pot/test_pot_builtin_webpospec.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot._provider import IEContentProvider, BuiltinIEContentProvider
|
||||||
|
from yt_dlp.extractor.youtube.pot.cache import CacheProviderWritePolicy
|
||||||
|
from yt_dlp.utils import bug_reports_message
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
PoTokenRequest,
|
||||||
|
PoTokenContext,
|
||||||
|
|
||||||
|
)
|
||||||
|
from yt_dlp.version import __version__
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot._builtin.webpo_cachespec import WebPoPCSP
|
||||||
|
from yt_dlp.extractor.youtube.pot._registry import _pot_pcs_providers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def pot_request(pot_request) -> PoTokenRequest:
|
||||||
|
pot_request.visitor_data = 'CgsxMjNhYmNYWVpfLSiA4s%2DqBg%3D%3D' # visitor_id=123abcXYZ_-
|
||||||
|
return pot_request
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebPoPCSP:
|
||||||
|
def test_base_type(self):
|
||||||
|
assert issubclass(WebPoPCSP, IEContentProvider)
|
||||||
|
assert issubclass(WebPoPCSP, BuiltinIEContentProvider)
|
||||||
|
|
||||||
|
def test_init(self, ie, logger):
|
||||||
|
pcs = WebPoPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
assert pcs.PROVIDER_NAME == 'webpo'
|
||||||
|
assert pcs.PROVIDER_VERSION == __version__
|
||||||
|
assert pcs.BUG_REPORT_MESSAGE == bug_reports_message(before='')
|
||||||
|
assert pcs.is_available()
|
||||||
|
|
||||||
|
def test_is_registered(self):
|
||||||
|
assert _pot_pcs_providers.value.get('WebPo') == WebPoPCSP
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('client_name, context, is_authenticated', [
|
||||||
|
('ANDROID', PoTokenContext.GVS, False),
|
||||||
|
('IOS', PoTokenContext.GVS, False),
|
||||||
|
('IOS', PoTokenContext.PLAYER, False),
|
||||||
|
])
|
||||||
|
def test_not_supports(self, ie, logger, pot_request, client_name, context, is_authenticated):
|
||||||
|
pcs = WebPoPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
pot_request.innertube_context['client']['clientName'] = client_name
|
||||||
|
pot_request.context = context
|
||||||
|
pot_request.is_authenticated = is_authenticated
|
||||||
|
assert pcs.generate_cache_spec(pot_request) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('client_name, context, is_authenticated, remote_host, source_address, request_proxy, expected', [
|
||||||
|
*[(client, context, is_authenticated, remote_host, source_address, request_proxy, expected) for client in [
|
||||||
|
'WEB', 'MWEB', 'TVHTML5', 'WEB_EMBEDDED_PLAYER', 'WEB_CREATOR', 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', 'TVHTML5_SIMPLY']
|
||||||
|
for context, is_authenticated, remote_host, source_address, request_proxy, expected in [
|
||||||
|
(PoTokenContext.GVS, False, 'example-remote-host', 'example-source-address', 'example-request-proxy', {'t': 'webpo', 'ip': 'example-remote-host', 'sa': 'example-source-address', 'px': 'example-request-proxy', 'cb': '123abcXYZ_-', 'cbt': 'visitor_id'}),
|
||||||
|
(PoTokenContext.PLAYER, False, 'example-remote-host', 'example-source-address', 'example-request-proxy', {'t': 'webpo', 'ip': 'example-remote-host', 'sa': 'example-source-address', 'px': 'example-request-proxy', 'cb': '123abcXYZ_-', 'cbt': 'video_id'}),
|
||||||
|
(PoTokenContext.GVS, True, 'example-remote-host', 'example-source-address', 'example-request-proxy', {'t': 'webpo', 'ip': 'example-remote-host', 'sa': 'example-source-address', 'px': 'example-request-proxy', 'cb': 'example-data-sync-id', 'cbt': 'datasync_id'}),
|
||||||
|
]],
|
||||||
|
('WEB_REMIX', PoTokenContext.PLAYER, False, 'example-remote-host', 'example-source-address', 'example-request-proxy', {'t': 'webpo', 'ip': 'example-remote-host', 'sa': 'example-source-address', 'px': 'example-request-proxy', 'cb': '123abcXYZ_-', 'cbt': 'visitor_id'}),
|
||||||
|
('WEB', PoTokenContext.GVS, False, None, None, None, {'t': 'webpo', 'cb': '123abcXYZ_-', 'cbt': 'visitor_id', 'ip': None, 'sa': None, 'px': None}),
|
||||||
|
('TVHTML5', PoTokenContext.PLAYER, False, None, None, 'http://example.com', {'t': 'webpo', 'cb': '123abcXYZ_-', 'cbt': 'video_id', 'ip': None, 'sa': None, 'px': 'http://example.com'}),
|
||||||
|
|
||||||
|
])
|
||||||
|
def test_generate_key_bindings(self, ie, logger, pot_request, client_name, context, is_authenticated, remote_host, source_address, request_proxy, expected):
|
||||||
|
pcs = WebPoPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
pot_request.innertube_context['client']['clientName'] = client_name
|
||||||
|
pot_request.context = context
|
||||||
|
pot_request.is_authenticated = is_authenticated
|
||||||
|
pot_request.innertube_context['client']['remoteHost'] = remote_host
|
||||||
|
pot_request.request_source_address = source_address
|
||||||
|
pot_request.request_proxy = request_proxy
|
||||||
|
pot_request.video_id = '123abcXYZ_-' # same as visitor id to test type
|
||||||
|
|
||||||
|
assert pcs.generate_cache_spec(pot_request).key_bindings == expected
|
||||||
|
|
||||||
|
def test_no_bind_visitor_id(self, ie, logger, pot_request):
|
||||||
|
# Should not bind to visitor id if setting is set to False
|
||||||
|
pcs = WebPoPCSP(ie=ie, logger=logger, settings={'bind_to_visitor_id': ['false']})
|
||||||
|
pot_request.innertube_context['client']['clientName'] = 'WEB'
|
||||||
|
pot_request.context = PoTokenContext.GVS
|
||||||
|
pot_request.is_authenticated = False
|
||||||
|
assert pcs.generate_cache_spec(pot_request).key_bindings == {'t': 'webpo', 'ip': None, 'sa': None, 'px': None, 'cb': 'CgsxMjNhYmNYWVpfLSiA4s%2DqBg%3D%3D', 'cbt': 'visitor_data'}
|
||||||
|
|
||||||
|
def test_default_ttl(self, ie, logger, pot_request):
|
||||||
|
pcs = WebPoPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
assert pcs.generate_cache_spec(pot_request).default_ttl == 6 * 60 * 60 # should default to 6 hours
|
||||||
|
|
||||||
|
def test_write_policy(self, ie, logger, pot_request):
|
||||||
|
pcs = WebPoPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
pot_request.context = PoTokenContext.GVS
|
||||||
|
assert pcs.generate_cache_spec(pot_request).write_policy == CacheProviderWritePolicy.WRITE_ALL
|
||||||
|
pot_request.context = PoTokenContext.PLAYER
|
||||||
|
assert pcs.generate_cache_spec(pot_request).write_policy == CacheProviderWritePolicy.WRITE_FIRST
|
||||||
1529
test/test_pot/test_pot_director.py
Normal file
1529
test/test_pot/test_pot_director.py
Normal file
File diff suppressed because it is too large
Load Diff
629
test/test_pot/test_pot_framework.py
Normal file
629
test/test_pot/test_pot_framework.py
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot._provider import IEContentProvider
|
||||||
|
from yt_dlp.cookies import YoutubeDLCookieJar
|
||||||
|
from yt_dlp.utils.networking import HTTPHeaderDict
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
PoTokenRequest,
|
||||||
|
PoTokenContext,
|
||||||
|
ExternalRequestFeature,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot.cache import (
|
||||||
|
PoTokenCacheProvider,
|
||||||
|
PoTokenCacheSpec,
|
||||||
|
PoTokenCacheSpecProvider,
|
||||||
|
CacheProviderWritePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
import yt_dlp.extractor.youtube.pot.cache as cache
|
||||||
|
|
||||||
|
from yt_dlp.networking import Request
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
PoTokenResponse,
|
||||||
|
PoTokenProvider,
|
||||||
|
PoTokenProviderRejectedRequest,
|
||||||
|
provider_bug_report_message,
|
||||||
|
register_provider,
|
||||||
|
register_preference,
|
||||||
|
)
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot._registry import _pot_providers, _ptp_preferences, _pot_pcs_providers, _pot_cache_providers, _pot_cache_provider_preferences
|
||||||
|
|
||||||
|
|
||||||
|
class ExamplePTP(PoTokenProvider):
|
||||||
|
PROVIDER_NAME = 'example'
|
||||||
|
PROVIDER_VERSION = '0.0.1'
|
||||||
|
BUG_REPORT_LOCATION = 'https://example.com/issues'
|
||||||
|
|
||||||
|
_SUPPORTED_CLIENTS = ('WEB',)
|
||||||
|
_SUPPORTED_CONTEXTS = (PoTokenContext.GVS, )
|
||||||
|
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = (
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_HTTP,
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_SOCKS5H,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _real_request_pot(self, request: PoTokenRequest) -> PoTokenResponse:
|
||||||
|
return PoTokenResponse('example-token', expires_at=123)
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleCacheProviderPCP(PoTokenCacheProvider):
|
||||||
|
|
||||||
|
PROVIDER_NAME = 'example'
|
||||||
|
PROVIDER_VERSION = '0.0.1'
|
||||||
|
BUG_REPORT_LOCATION = 'https://example.com/issues'
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, key: str):
|
||||||
|
return 'example-cache'
|
||||||
|
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleCacheSpecProviderPCSP(PoTokenCacheSpecProvider):
|
||||||
|
|
||||||
|
PROVIDER_NAME = 'example'
|
||||||
|
PROVIDER_VERSION = '0.0.1'
|
||||||
|
BUG_REPORT_LOCATION = 'https://example.com/issues'
|
||||||
|
|
||||||
|
def generate_cache_spec(self, request: PoTokenRequest):
|
||||||
|
return PoTokenCacheSpec(
|
||||||
|
key_bindings={'field': 'example-key'},
|
||||||
|
default_ttl=60,
|
||||||
|
write_policy=CacheProviderWritePolicy.WRITE_FIRST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoTokenProvider:
|
||||||
|
|
||||||
|
def test_base_type(self):
|
||||||
|
assert issubclass(PoTokenProvider, IEContentProvider)
|
||||||
|
|
||||||
|
def test_create_provider_missing_fetch_method(self, ie, logger):
|
||||||
|
class MissingMethodsPTP(PoTokenProvider):
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_create_provider_missing_available_method(self, ie, logger):
|
||||||
|
class MissingMethodsPTP(PoTokenProvider):
|
||||||
|
def _real_request_pot(self, request: PoTokenRequest) -> PoTokenResponse:
|
||||||
|
raise PoTokenProviderRejectedRequest('Not implemented')
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_barebones_provider(self, ie, logger):
|
||||||
|
class BarebonesProviderPTP(PoTokenProvider):
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _real_request_pot(self, request: PoTokenRequest) -> PoTokenResponse:
|
||||||
|
raise PoTokenProviderRejectedRequest('Not implemented')
|
||||||
|
|
||||||
|
provider = BarebonesProviderPTP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.PROVIDER_NAME == 'BarebonesProvider'
|
||||||
|
assert provider.PROVIDER_KEY == 'BarebonesProvider'
|
||||||
|
assert provider.PROVIDER_VERSION == '0.0.0'
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at (developer has not provided a bug report location) .'
|
||||||
|
|
||||||
|
def test_example_provider_success(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.PROVIDER_NAME == 'example'
|
||||||
|
assert provider.PROVIDER_KEY == 'Example'
|
||||||
|
assert provider.PROVIDER_VERSION == '0.0.1'
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at https://example.com/issues .'
|
||||||
|
assert provider.is_available()
|
||||||
|
|
||||||
|
response = provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
assert response.po_token == 'example-token'
|
||||||
|
assert response.expires_at == 123
|
||||||
|
|
||||||
|
def test_provider_unsupported_context(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
pot_request.context = PoTokenContext.PLAYER
|
||||||
|
|
||||||
|
with pytest.raises(PoTokenProviderRejectedRequest):
|
||||||
|
provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_unsupported_client(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
pot_request.innertube_context['client']['clientName'] = 'ANDROID'
|
||||||
|
|
||||||
|
with pytest.raises(PoTokenProviderRejectedRequest):
|
||||||
|
provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_unsupported_proxy_scheme(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
pot_request.request_proxy = 'socks4://example.com'
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
PoTokenProviderRejectedRequest,
|
||||||
|
match='External requests by "example" provider do not support proxy scheme "socks4". Supported proxy '
|
||||||
|
'schemes: http, socks5h',
|
||||||
|
):
|
||||||
|
provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
pot_request.request_proxy = 'http://example.com'
|
||||||
|
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_ignore_external_request_features(self, ie, logger, pot_request):
|
||||||
|
class InternalPTP(ExamplePTP):
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = None
|
||||||
|
|
||||||
|
provider = InternalPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
pot_request.request_proxy = 'socks5://example.com'
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
pot_request.request_source_address = '0.0.0.0'
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_unsupported_external_request_source_address(self, ie, logger, pot_request):
|
||||||
|
class InternalPTP(ExamplePTP):
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = tuple()
|
||||||
|
|
||||||
|
provider = InternalPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
pot_request.request_source_address = None
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
pot_request.request_source_address = '0.0.0.0'
|
||||||
|
with pytest.raises(
|
||||||
|
PoTokenProviderRejectedRequest,
|
||||||
|
match='External requests by "example" provider do not support setting source address',
|
||||||
|
):
|
||||||
|
provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_supported_external_request_source_address(self, ie, logger, pot_request):
|
||||||
|
class InternalPTP(ExamplePTP):
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = (
|
||||||
|
ExternalRequestFeature.SOURCE_ADDRESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = InternalPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
pot_request.request_source_address = None
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
pot_request.request_source_address = '0.0.0.0'
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_unsupported_external_request_tls_verification(self, ie, logger, pot_request):
|
||||||
|
class InternalPTP(ExamplePTP):
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = tuple()
|
||||||
|
|
||||||
|
provider = InternalPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
pot_request.request_verify_tls = True
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
pot_request.request_verify_tls = False
|
||||||
|
with pytest.raises(
|
||||||
|
PoTokenProviderRejectedRequest,
|
||||||
|
match='External requests by "example" provider do not support ignoring TLS certificate failures',
|
||||||
|
):
|
||||||
|
provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_supported_external_request_tls_verification(self, ie, logger, pot_request):
|
||||||
|
class InternalPTP(ExamplePTP):
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = (
|
||||||
|
ExternalRequestFeature.DISABLE_TLS_VERIFICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = InternalPTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
pot_request.request_verify_tls = True
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
pot_request.request_verify_tls = False
|
||||||
|
assert provider.request_pot(pot_request)
|
||||||
|
|
||||||
|
def test_provider_request_webpage(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
cookiejar = YoutubeDLCookieJar()
|
||||||
|
pot_request.request_headers = HTTPHeaderDict({'User-Agent': 'example-user-agent'})
|
||||||
|
pot_request.request_proxy = 'socks5://example-proxy.com'
|
||||||
|
pot_request.request_cookiejar = cookiejar
|
||||||
|
|
||||||
|
def mock_urlopen(request):
|
||||||
|
return request
|
||||||
|
|
||||||
|
ie._downloader.urlopen = mock_urlopen
|
||||||
|
|
||||||
|
sent_request = provider._request_webpage(Request(
|
||||||
|
'https://example.com',
|
||||||
|
), pot_request=pot_request)
|
||||||
|
|
||||||
|
assert sent_request.url == 'https://example.com'
|
||||||
|
assert sent_request.headers['User-Agent'] == 'example-user-agent'
|
||||||
|
assert sent_request.proxies == {'all': 'socks5://example-proxy.com'}
|
||||||
|
assert sent_request.extensions['cookiejar'] is cookiejar
|
||||||
|
assert 'Requesting webpage' in logger.messages['info']
|
||||||
|
|
||||||
|
def test_provider_request_webpage_override(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
cookiejar_request = YoutubeDLCookieJar()
|
||||||
|
pot_request.request_headers = HTTPHeaderDict({'User-Agent': 'example-user-agent'})
|
||||||
|
pot_request.request_proxy = 'socks5://example-proxy.com'
|
||||||
|
pot_request.request_cookiejar = cookiejar_request
|
||||||
|
|
||||||
|
def mock_urlopen(request):
|
||||||
|
return request
|
||||||
|
|
||||||
|
ie._downloader.urlopen = mock_urlopen
|
||||||
|
|
||||||
|
sent_request = provider._request_webpage(Request(
|
||||||
|
'https://example.com',
|
||||||
|
headers={'User-Agent': 'override-user-agent-override'},
|
||||||
|
proxies={'http': 'http://example-proxy-override.com'},
|
||||||
|
extensions={'cookiejar': YoutubeDLCookieJar()},
|
||||||
|
), pot_request=pot_request, note='Custom requesting webpage')
|
||||||
|
|
||||||
|
assert sent_request.url == 'https://example.com'
|
||||||
|
assert sent_request.headers['User-Agent'] == 'override-user-agent-override'
|
||||||
|
assert sent_request.proxies == {'http': 'http://example-proxy-override.com'}
|
||||||
|
assert sent_request.extensions['cookiejar'] is not cookiejar_request
|
||||||
|
assert 'Custom requesting webpage' in logger.messages['info']
|
||||||
|
|
||||||
|
def test_provider_request_webpage_no_log(self, ie, logger, pot_request):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def mock_urlopen(request):
|
||||||
|
return request
|
||||||
|
|
||||||
|
ie._downloader.urlopen = mock_urlopen
|
||||||
|
|
||||||
|
sent_request = provider._request_webpage(Request(
|
||||||
|
'https://example.com',
|
||||||
|
), note=False)
|
||||||
|
|
||||||
|
assert sent_request.url == 'https://example.com'
|
||||||
|
assert 'info' not in logger.messages
|
||||||
|
|
||||||
|
def test_provider_request_webpage_no_pot_request(self, ie, logger):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def mock_urlopen(request):
|
||||||
|
return request
|
||||||
|
|
||||||
|
ie._downloader.urlopen = mock_urlopen
|
||||||
|
|
||||||
|
sent_request = provider._request_webpage(Request(
|
||||||
|
'https://example.com',
|
||||||
|
), pot_request=None)
|
||||||
|
|
||||||
|
assert sent_request.url == 'https://example.com'
|
||||||
|
|
||||||
|
def test_get_config_arg(self, ie, logger):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={'abc': ['123D'], 'xyz': ['456a', '789B']})
|
||||||
|
|
||||||
|
assert provider._configuration_arg('abc') == ['123d']
|
||||||
|
assert provider._configuration_arg('abc', default=['default']) == ['123d']
|
||||||
|
assert provider._configuration_arg('ABC', default=['default']) == ['default']
|
||||||
|
assert provider._configuration_arg('abc', casesense=True) == ['123D']
|
||||||
|
assert provider._configuration_arg('xyz', casesense=False) == ['456a', '789b']
|
||||||
|
|
||||||
|
def test_require_class_end_with_suffix(self, ie, logger):
|
||||||
|
class InvalidSuffix(PoTokenProvider):
|
||||||
|
PROVIDER_NAME = 'invalid-suffix'
|
||||||
|
|
||||||
|
def _real_request_pot(self, request: PoTokenRequest) -> PoTokenResponse:
|
||||||
|
raise PoTokenProviderRejectedRequest('Not implemented')
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
provider = InvalidSuffix(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
provider.PROVIDER_KEY # noqa: B018
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoTokenCacheProvider:
|
||||||
|
|
||||||
|
def test_base_type(self):
|
||||||
|
assert issubclass(PoTokenCacheProvider, IEContentProvider)
|
||||||
|
|
||||||
|
def test_create_provider_missing_get_method(self, ie, logger):
|
||||||
|
class MissingMethodsPCP(PoTokenCacheProvider):
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPCP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_create_provider_missing_store_method(self, ie, logger):
|
||||||
|
class MissingMethodsPCP(PoTokenCacheProvider):
|
||||||
|
def get(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPCP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_create_provider_missing_delete_method(self, ie, logger):
|
||||||
|
class MissingMethodsPCP(PoTokenCacheProvider):
|
||||||
|
def get(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPCP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_create_provider_missing_is_available_method(self, ie, logger):
|
||||||
|
class MissingMethodsPCP(PoTokenCacheProvider):
|
||||||
|
def get(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPCP(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_barebones_provider(self, ie, logger):
|
||||||
|
class BarebonesProviderPCP(PoTokenCacheProvider):
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, key: str):
|
||||||
|
return 'example-cache'
|
||||||
|
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
provider = BarebonesProviderPCP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.PROVIDER_NAME == 'BarebonesProvider'
|
||||||
|
assert provider.PROVIDER_KEY == 'BarebonesProvider'
|
||||||
|
assert provider.PROVIDER_VERSION == '0.0.0'
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at (developer has not provided a bug report location) .'
|
||||||
|
|
||||||
|
def test_create_provider_example(self, ie, logger):
|
||||||
|
provider = ExampleCacheProviderPCP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.PROVIDER_NAME == 'example'
|
||||||
|
assert provider.PROVIDER_KEY == 'ExampleCacheProvider'
|
||||||
|
assert provider.PROVIDER_VERSION == '0.0.1'
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at https://example.com/issues .'
|
||||||
|
assert provider.is_available()
|
||||||
|
|
||||||
|
def test_get_config_arg(self, ie, logger):
|
||||||
|
provider = ExampleCacheProviderPCP(ie=ie, logger=logger, settings={'abc': ['123D'], 'xyz': ['456a', '789B']})
|
||||||
|
assert provider._configuration_arg('abc') == ['123d']
|
||||||
|
assert provider._configuration_arg('abc', default=['default']) == ['123d']
|
||||||
|
assert provider._configuration_arg('ABC', default=['default']) == ['default']
|
||||||
|
assert provider._configuration_arg('abc', casesense=True) == ['123D']
|
||||||
|
assert provider._configuration_arg('xyz', casesense=False) == ['456a', '789b']
|
||||||
|
|
||||||
|
def test_require_class_end_with_suffix(self, ie, logger):
|
||||||
|
class InvalidSuffix(PoTokenCacheProvider):
|
||||||
|
def get(self, key: str):
|
||||||
|
return 'example-cache'
|
||||||
|
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
provider = InvalidSuffix(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
provider.PROVIDER_KEY # noqa: B018
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoTokenCacheSpecProvider:
|
||||||
|
|
||||||
|
def test_base_type(self):
|
||||||
|
assert issubclass(PoTokenCacheSpecProvider, IEContentProvider)
|
||||||
|
|
||||||
|
def test_create_provider_missing_supports_method(self, ie, logger):
|
||||||
|
class MissingMethodsPCS(PoTokenCacheSpecProvider):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MissingMethodsPCS(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
def test_create_provider_barebones(self, ie, pot_request, logger):
|
||||||
|
class BarebonesProviderPCSP(PoTokenCacheSpecProvider):
|
||||||
|
def generate_cache_spec(self, request: PoTokenRequest):
|
||||||
|
return PoTokenCacheSpec(
|
||||||
|
default_ttl=100,
|
||||||
|
key_bindings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = BarebonesProviderPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.PROVIDER_NAME == 'BarebonesProvider'
|
||||||
|
assert provider.PROVIDER_KEY == 'BarebonesProvider'
|
||||||
|
assert provider.PROVIDER_VERSION == '0.0.0'
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at (developer has not provided a bug report location) .'
|
||||||
|
assert provider.is_available()
|
||||||
|
assert provider.generate_cache_spec(request=pot_request).default_ttl == 100
|
||||||
|
assert provider.generate_cache_spec(request=pot_request).key_bindings == {}
|
||||||
|
assert provider.generate_cache_spec(request=pot_request).write_policy == CacheProviderWritePolicy.WRITE_ALL
|
||||||
|
|
||||||
|
def test_create_provider_example(self, ie, pot_request, logger):
|
||||||
|
provider = ExampleCacheSpecProviderPCSP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.PROVIDER_NAME == 'example'
|
||||||
|
assert provider.PROVIDER_KEY == 'ExampleCacheSpecProvider'
|
||||||
|
assert provider.PROVIDER_VERSION == '0.0.1'
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at https://example.com/issues .'
|
||||||
|
assert provider.is_available()
|
||||||
|
assert provider.generate_cache_spec(pot_request)
|
||||||
|
assert provider.generate_cache_spec(pot_request).key_bindings == {'field': 'example-key'}
|
||||||
|
assert provider.generate_cache_spec(pot_request).default_ttl == 60
|
||||||
|
assert provider.generate_cache_spec(pot_request).write_policy == CacheProviderWritePolicy.WRITE_FIRST
|
||||||
|
|
||||||
|
def test_get_config_arg(self, ie, logger):
|
||||||
|
provider = ExampleCacheSpecProviderPCSP(ie=ie, logger=logger, settings={'abc': ['123D'], 'xyz': ['456a', '789B']})
|
||||||
|
|
||||||
|
assert provider._configuration_arg('abc') == ['123d']
|
||||||
|
assert provider._configuration_arg('abc', default=['default']) == ['123d']
|
||||||
|
assert provider._configuration_arg('ABC', default=['default']) == ['default']
|
||||||
|
assert provider._configuration_arg('abc', casesense=True) == ['123D']
|
||||||
|
assert provider._configuration_arg('xyz', casesense=False) == ['456a', '789b']
|
||||||
|
|
||||||
|
def test_require_class_end_with_suffix(self, ie, logger):
|
||||||
|
class InvalidSuffix(PoTokenCacheSpecProvider):
|
||||||
|
def generate_cache_spec(self, request: PoTokenRequest):
|
||||||
|
return None
|
||||||
|
|
||||||
|
provider = InvalidSuffix(ie=ie, logger=logger, settings={})
|
||||||
|
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
provider.PROVIDER_KEY # noqa: B018
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoTokenRequest:
|
||||||
|
def test_copy_request(self, pot_request):
|
||||||
|
copied_request = pot_request.copy()
|
||||||
|
|
||||||
|
assert copied_request is not pot_request
|
||||||
|
assert copied_request.context == pot_request.context
|
||||||
|
assert copied_request.innertube_context == pot_request.innertube_context
|
||||||
|
assert copied_request.innertube_context is not pot_request.innertube_context
|
||||||
|
copied_request.innertube_context['client']['clientName'] = 'ANDROID'
|
||||||
|
assert pot_request.innertube_context['client']['clientName'] != 'ANDROID'
|
||||||
|
assert copied_request.innertube_host == pot_request.innertube_host
|
||||||
|
assert copied_request.session_index == pot_request.session_index
|
||||||
|
assert copied_request.player_url == pot_request.player_url
|
||||||
|
assert copied_request.is_authenticated == pot_request.is_authenticated
|
||||||
|
assert copied_request.visitor_data == pot_request.visitor_data
|
||||||
|
assert copied_request.data_sync_id == pot_request.data_sync_id
|
||||||
|
assert copied_request.video_id == pot_request.video_id
|
||||||
|
assert copied_request.request_cookiejar is pot_request.request_cookiejar
|
||||||
|
assert copied_request.request_proxy == pot_request.request_proxy
|
||||||
|
assert copied_request.request_headers == pot_request.request_headers
|
||||||
|
assert copied_request.request_headers is not pot_request.request_headers
|
||||||
|
assert copied_request.request_timeout == pot_request.request_timeout
|
||||||
|
assert copied_request.request_source_address == pot_request.request_source_address
|
||||||
|
assert copied_request.request_verify_tls == pot_request.request_verify_tls
|
||||||
|
assert copied_request.bypass_cache == pot_request.bypass_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_bug_report_message(ie, logger):
|
||||||
|
provider = ExamplePTP(ie=ie, logger=logger, settings={})
|
||||||
|
assert provider.BUG_REPORT_MESSAGE == 'please report this issue to the provider developer at https://example.com/issues .'
|
||||||
|
|
||||||
|
message = provider_bug_report_message(provider)
|
||||||
|
assert message == '; please report this issue to the provider developer at https://example.com/issues .'
|
||||||
|
|
||||||
|
message_before = provider_bug_report_message(provider, before='custom message!')
|
||||||
|
assert message_before == 'custom message! Please report this issue to the provider developer at https://example.com/issues .'
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_provider(ie):
|
||||||
|
|
||||||
|
@register_provider
|
||||||
|
class UnavailableProviderPTP(PoTokenProvider):
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _real_request_pot(self, request: PoTokenRequest) -> PoTokenResponse:
|
||||||
|
raise PoTokenProviderRejectedRequest('Not implemented')
|
||||||
|
|
||||||
|
assert _pot_providers.value.get('UnavailableProvider') == UnavailableProviderPTP
|
||||||
|
_pot_providers.value.pop('UnavailableProvider')
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_pot_preference(ie):
|
||||||
|
before = len(_ptp_preferences.value)
|
||||||
|
|
||||||
|
@register_preference(ExamplePTP)
|
||||||
|
def unavailable_preference(provider: PoTokenProvider, request: PoTokenRequest):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assert len(_ptp_preferences.value) == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_cache_provider(ie):
|
||||||
|
|
||||||
|
@cache.register_provider
|
||||||
|
class UnavailableCacheProviderPCP(PoTokenCacheProvider):
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, key: str):
|
||||||
|
return 'example-cache'
|
||||||
|
|
||||||
|
def store(self, key: str, value: str, expires_at: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert _pot_cache_providers.value.get('UnavailableCacheProvider') == UnavailableCacheProviderPCP
|
||||||
|
_pot_cache_providers.value.pop('UnavailableCacheProvider')
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_cache_provider_spec(ie):
|
||||||
|
|
||||||
|
@cache.register_spec
|
||||||
|
class UnavailableCacheProviderPCSP(PoTokenCacheSpecProvider):
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_cache_spec(self, request: PoTokenRequest):
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert _pot_pcs_providers.value.get('UnavailableCacheProvider') == UnavailableCacheProviderPCSP
|
||||||
|
_pot_pcs_providers.value.pop('UnavailableCacheProvider')
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_cache_provider_preference(ie):
|
||||||
|
before = len(_pot_cache_provider_preferences.value)
|
||||||
|
|
||||||
|
@cache.register_preference(ExampleCacheProviderPCP)
|
||||||
|
def unavailable_preference(provider: PoTokenCacheProvider, request: PoTokenRequest):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assert len(_pot_cache_provider_preferences.value) == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_logger_log_level(logger):
|
||||||
|
assert logger.LogLevel('INFO') == logger.LogLevel.INFO
|
||||||
|
assert logger.LogLevel('debuG') == logger.LogLevel.DEBUG
|
||||||
|
assert logger.LogLevel(10) == logger.LogLevel.DEBUG
|
||||||
|
assert logger.LogLevel('UNKNOWN') == logger.LogLevel.INFO
|
||||||
@@ -416,18 +416,8 @@ def test_traversal_unbranching(self):
|
|||||||
'`any` should allow further branching'
|
'`any` should allow further branching'
|
||||||
|
|
||||||
def test_traversal_morsel(self):
|
def test_traversal_morsel(self):
|
||||||
values = {
|
|
||||||
'expires': 'a',
|
|
||||||
'path': 'b',
|
|
||||||
'comment': 'c',
|
|
||||||
'domain': 'd',
|
|
||||||
'max-age': 'e',
|
|
||||||
'secure': 'f',
|
|
||||||
'httponly': 'g',
|
|
||||||
'version': 'h',
|
|
||||||
'samesite': 'i',
|
|
||||||
}
|
|
||||||
morsel = http.cookies.Morsel()
|
morsel = http.cookies.Morsel()
|
||||||
|
values = dict(zip(morsel, 'abcdefghijklmnop'))
|
||||||
morsel.set('item_key', 'item_value', 'coded_value')
|
morsel.set('item_key', 'item_value', 'coded_value')
|
||||||
morsel.update(values)
|
morsel.update(values)
|
||||||
values['key'] = 'item_key'
|
values['key'] = 'item_key'
|
||||||
|
|||||||
@@ -659,6 +659,8 @@ def test_url_or_none(self):
|
|||||||
self.assertEqual(url_or_none('mms://foo.de'), 'mms://foo.de')
|
self.assertEqual(url_or_none('mms://foo.de'), 'mms://foo.de')
|
||||||
self.assertEqual(url_or_none('rtspu://foo.de'), 'rtspu://foo.de')
|
self.assertEqual(url_or_none('rtspu://foo.de'), 'rtspu://foo.de')
|
||||||
self.assertEqual(url_or_none('ftps://foo.de'), 'ftps://foo.de')
|
self.assertEqual(url_or_none('ftps://foo.de'), 'ftps://foo.de')
|
||||||
|
self.assertEqual(url_or_none('ws://foo.de'), 'ws://foo.de')
|
||||||
|
self.assertEqual(url_or_none('wss://foo.de'), 'wss://foo.de')
|
||||||
|
|
||||||
def test_parse_age_limit(self):
|
def test_parse_age_limit(self):
|
||||||
self.assertEqual(parse_age_limit(None), None)
|
self.assertEqual(parse_age_limit(None), None)
|
||||||
|
|||||||
@@ -133,6 +133,11 @@
|
|||||||
'2aq0aqSyOoJXtK73m-uME_jv7-pT15gOFC02RFkGMqWpzEICs69VdbwQ0LDp1v7j8xx92efCJlYFYb1sUkkBSPOlPmXgIARw8JQ0qOAOAA',
|
'2aq0aqSyOoJXtK73m-uME_jv7-pT15gOFC02RFkGMqWpzEICs69VdbwQ0LDp1v7j8xx92efCJlYFYb1sUkkBSPOlPmXgIARw8JQ0qOAOAA',
|
||||||
'IAOAOq0QJ8wRAAgXmPlOPSBkkUs1bYFYlJCfe29xx8j7v1pDL0QwbdV96sCIEzpWqMGkFR20CFOg51Tp-7vj_E2u-m37KtXJoOySqa0',
|
'IAOAOq0QJ8wRAAgXmPlOPSBkkUs1bYFYlJCfe29xx8j7v1pDL0QwbdV96sCIEzpWqMGkFR20CFOg51Tp-7vj_E2u-m37KtXJoOySqa0',
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
'https://www.youtube.com/s/player/e12fbea4/player_ias.vflset/en_US/base.js',
|
||||||
|
'gN7a-hudCuAuPH6fByOk1_GNXN0yNMHShjZXS2VOgsEItAJz0tipeavEOmNdYN-wUtcEqD3bCXjc0iyKfAyZxCBGgIARwsSdQfJ2CJtt',
|
||||||
|
'JC2JfQdSswRAIgGBCxZyAfKyi0cjXCb3DqEctUw-NYdNmOEvaepit0zJAtIEsgOV2SXZjhSHMNy0NXNG_1kOyBf6HPuAuCduh-a',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
_NSIG_TESTS = [
|
_NSIG_TESTS = [
|
||||||
@@ -316,6 +321,18 @@
|
|||||||
'https://www.youtube.com/s/player/8a8ac953/tv-player-es6.vflset/tv-player-es6.js',
|
'https://www.youtube.com/s/player/8a8ac953/tv-player-es6.vflset/tv-player-es6.js',
|
||||||
'MiBYeXx_vRREbiCCmh', 'RtZYMVvmkE0JE',
|
'MiBYeXx_vRREbiCCmh', 'RtZYMVvmkE0JE',
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
'https://www.youtube.com/s/player/59b252b9/player_ias.vflset/en_US/base.js',
|
||||||
|
'D3XWVpYgwhLLKNK4AGX', 'aZrQ1qWJ5yv5h',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'https://www.youtube.com/s/player/fc2a56a5/player_ias.vflset/en_US/base.js',
|
||||||
|
'qTKWg_Il804jd2kAC', 'OtUAm2W6gyzJjB9u',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'https://www.youtube.com/s/player/fc2a56a5/tv-player-ias.vflset/tv-player-ias.js',
|
||||||
|
'qTKWg_Il804jd2kAC', 'OtUAm2W6gyzJjB9u',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
test/testdata/thumbnails/foo %d bar/foo_%d.webp
vendored
BIN
test/testdata/thumbnails/foo %d bar/foo_%d.webp
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
0
test/testdata/thumbnails/foo %d bar/placeholder
vendored
Normal file
0
test/testdata/thumbnails/foo %d bar/placeholder
vendored
Normal file
@@ -490,7 +490,7 @@ class YoutubeDL:
|
|||||||
The template is mapped on a dictionary with keys 'progress' and 'info'
|
The template is mapped on a dictionary with keys 'progress' and 'info'
|
||||||
retry_sleep_functions: Dictionary of functions that takes the number of attempts
|
retry_sleep_functions: Dictionary of functions that takes the number of attempts
|
||||||
as argument and returns the time to sleep in seconds.
|
as argument and returns the time to sleep in seconds.
|
||||||
Allowed keys are 'http', 'fragment', 'file_access'
|
Allowed keys are 'http', 'fragment', 'file_access', 'extractor'
|
||||||
download_ranges: A callback function that gets called for every video with
|
download_ranges: A callback function that gets called for every video with
|
||||||
the signature (info_dict, ydl) -> Iterable[Section].
|
the signature (info_dict, ydl) -> Iterable[Section].
|
||||||
Only the returned sections will be downloaded.
|
Only the returned sections will be downloaded.
|
||||||
@@ -640,6 +640,7 @@ def __init__(self, params=None, auto_init=True):
|
|||||||
self._printed_messages = set()
|
self._printed_messages = set()
|
||||||
self._first_webpage_request = True
|
self._first_webpage_request = True
|
||||||
self._post_hooks = []
|
self._post_hooks = []
|
||||||
|
self._close_hooks = []
|
||||||
self._progress_hooks = []
|
self._progress_hooks = []
|
||||||
self._postprocessor_hooks = []
|
self._postprocessor_hooks = []
|
||||||
self._download_retcode = 0
|
self._download_retcode = 0
|
||||||
@@ -908,6 +909,11 @@ def add_post_hook(self, ph):
|
|||||||
"""Add the post hook"""
|
"""Add the post hook"""
|
||||||
self._post_hooks.append(ph)
|
self._post_hooks.append(ph)
|
||||||
|
|
||||||
|
def add_close_hook(self, ch):
|
||||||
|
"""Add a close hook, called when YoutubeDL.close() is called"""
|
||||||
|
assert callable(ch), 'Close hook must be callable'
|
||||||
|
self._close_hooks.append(ch)
|
||||||
|
|
||||||
def add_progress_hook(self, ph):
|
def add_progress_hook(self, ph):
|
||||||
"""Add the download progress hook"""
|
"""Add the download progress hook"""
|
||||||
self._progress_hooks.append(ph)
|
self._progress_hooks.append(ph)
|
||||||
@@ -1016,6 +1022,9 @@ def close(self):
|
|||||||
self._request_director.close()
|
self._request_director.close()
|
||||||
del self._request_director
|
del self._request_director
|
||||||
|
|
||||||
|
for close_hook in self._close_hooks:
|
||||||
|
close_hook()
|
||||||
|
|
||||||
def trouble(self, message=None, tb=None, is_error=True):
|
def trouble(self, message=None, tb=None, is_error=True):
|
||||||
"""Determine action to take when a download problem appears.
|
"""Determine action to take when a download problem appears.
|
||||||
|
|
||||||
@@ -2210,6 +2219,7 @@ def _check_formats(self, formats):
|
|||||||
self.report_warning(f'Unable to delete temporary file "{temp_file.name}"')
|
self.report_warning(f'Unable to delete temporary file "{temp_file.name}"')
|
||||||
f['__working'] = success
|
f['__working'] = success
|
||||||
if success:
|
if success:
|
||||||
|
f.pop('__needs_testing', None)
|
||||||
yield f
|
yield f
|
||||||
else:
|
else:
|
||||||
self.to_screen('[info] Unable to download format {}. Skipping...'.format(f['format_id']))
|
self.to_screen('[info] Unable to download format {}. Skipping...'.format(f['format_id']))
|
||||||
@@ -3954,6 +3964,7 @@ def simplified_codec(f, field):
|
|||||||
self._format_out('UNSUPPORTED', self.Styles.BAD_FORMAT) if f.get('ext') in ('f4f', 'f4m') else None,
|
self._format_out('UNSUPPORTED', self.Styles.BAD_FORMAT) if f.get('ext') in ('f4f', 'f4m') else None,
|
||||||
(self._format_out('Maybe DRM', self.Styles.WARNING) if f.get('has_drm') == 'maybe'
|
(self._format_out('Maybe DRM', self.Styles.WARNING) if f.get('has_drm') == 'maybe'
|
||||||
else self._format_out('DRM', self.Styles.BAD_FORMAT) if f.get('has_drm') else None),
|
else self._format_out('DRM', self.Styles.BAD_FORMAT) if f.get('has_drm') else None),
|
||||||
|
self._format_out('Untested', self.Styles.WARNING) if f.get('__needs_testing') else None,
|
||||||
format_field(f, 'format_note'),
|
format_field(f, 'format_note'),
|
||||||
format_field(f, 'container', ignore=(None, f.get('ext'))),
|
format_field(f, 'container', ignore=(None, f.get('ext'))),
|
||||||
delim=', '), delim=' '),
|
delim=', '), delim=' '),
|
||||||
|
|||||||
@@ -764,11 +764,11 @@ def _get_linux_desktop_environment(env, logger):
|
|||||||
GetDesktopEnvironment
|
GetDesktopEnvironment
|
||||||
"""
|
"""
|
||||||
xdg_current_desktop = env.get('XDG_CURRENT_DESKTOP', None)
|
xdg_current_desktop = env.get('XDG_CURRENT_DESKTOP', None)
|
||||||
desktop_session = env.get('DESKTOP_SESSION', None)
|
desktop_session = env.get('DESKTOP_SESSION', '')
|
||||||
if xdg_current_desktop is not None:
|
if xdg_current_desktop is not None:
|
||||||
for part in map(str.strip, xdg_current_desktop.split(':')):
|
for part in map(str.strip, xdg_current_desktop.split(':')):
|
||||||
if part == 'Unity':
|
if part == 'Unity':
|
||||||
if desktop_session is not None and 'gnome-fallback' in desktop_session:
|
if 'gnome-fallback' in desktop_session:
|
||||||
return _LinuxDesktopEnvironment.GNOME
|
return _LinuxDesktopEnvironment.GNOME
|
||||||
else:
|
else:
|
||||||
return _LinuxDesktopEnvironment.UNITY
|
return _LinuxDesktopEnvironment.UNITY
|
||||||
@@ -797,9 +797,8 @@ def _get_linux_desktop_environment(env, logger):
|
|||||||
return _LinuxDesktopEnvironment.UKUI
|
return _LinuxDesktopEnvironment.UKUI
|
||||||
elif part == 'LXQt':
|
elif part == 'LXQt':
|
||||||
return _LinuxDesktopEnvironment.LXQT
|
return _LinuxDesktopEnvironment.LXQT
|
||||||
logger.info(f'XDG_CURRENT_DESKTOP is set to an unknown value: "{xdg_current_desktop}"')
|
logger.debug(f'XDG_CURRENT_DESKTOP is set to an unknown value: "{xdg_current_desktop}"')
|
||||||
|
|
||||||
elif desktop_session is not None:
|
|
||||||
if desktop_session == 'deepin':
|
if desktop_session == 'deepin':
|
||||||
return _LinuxDesktopEnvironment.DEEPIN
|
return _LinuxDesktopEnvironment.DEEPIN
|
||||||
elif desktop_session in ('mate', 'gnome'):
|
elif desktop_session in ('mate', 'gnome'):
|
||||||
@@ -816,9 +815,8 @@ def _get_linux_desktop_environment(env, logger):
|
|||||||
elif desktop_session == 'ukui':
|
elif desktop_session == 'ukui':
|
||||||
return _LinuxDesktopEnvironment.UKUI
|
return _LinuxDesktopEnvironment.UKUI
|
||||||
else:
|
else:
|
||||||
logger.info(f'DESKTOP_SESSION is set to an unknown value: "{desktop_session}"')
|
logger.debug(f'DESKTOP_SESSION is set to an unknown value: "{desktop_session}"')
|
||||||
|
|
||||||
else:
|
|
||||||
if 'GNOME_DESKTOP_SESSION_ID' in env:
|
if 'GNOME_DESKTOP_SESSION_ID' in env:
|
||||||
return _LinuxDesktopEnvironment.GNOME
|
return _LinuxDesktopEnvironment.GNOME
|
||||||
elif 'KDE_FULL_SESSION' in env:
|
elif 'KDE_FULL_SESSION' in env:
|
||||||
@@ -826,6 +824,7 @@ def _get_linux_desktop_environment(env, logger):
|
|||||||
return _LinuxDesktopEnvironment.KDE4
|
return _LinuxDesktopEnvironment.KDE4
|
||||||
else:
|
else:
|
||||||
return _LinuxDesktopEnvironment.KDE3
|
return _LinuxDesktopEnvironment.KDE3
|
||||||
|
|
||||||
return _LinuxDesktopEnvironment.OTHER
|
return _LinuxDesktopEnvironment.OTHER
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def get_suitable_downloader(info_dict, params={}, default=NO_DEFAULT, protocol=N
|
|||||||
from .http import HttpFD
|
from .http import HttpFD
|
||||||
from .ism import IsmFD
|
from .ism import IsmFD
|
||||||
from .mhtml import MhtmlFD
|
from .mhtml import MhtmlFD
|
||||||
from .niconico import NiconicoDmcFD, NiconicoLiveFD
|
from .niconico import NiconicoLiveFD
|
||||||
from .rtmp import RtmpFD
|
from .rtmp import RtmpFD
|
||||||
from .rtsp import RtspFD
|
from .rtsp import RtspFD
|
||||||
from .websocket import WebSocketFragmentFD
|
from .websocket import WebSocketFragmentFD
|
||||||
@@ -50,7 +50,6 @@ def get_suitable_downloader(info_dict, params={}, default=NO_DEFAULT, protocol=N
|
|||||||
'http_dash_segments_generator': DashSegmentsFD,
|
'http_dash_segments_generator': DashSegmentsFD,
|
||||||
'ism': IsmFD,
|
'ism': IsmFD,
|
||||||
'mhtml': MhtmlFD,
|
'mhtml': MhtmlFD,
|
||||||
'niconico_dmc': NiconicoDmcFD,
|
|
||||||
'niconico_live': NiconicoLiveFD,
|
'niconico_live': NiconicoLiveFD,
|
||||||
'fc2_live': FC2LiveFD,
|
'fc2_live': FC2LiveFD,
|
||||||
'websocket_frag': WebSocketFragmentFD,
|
'websocket_frag': WebSocketFragmentFD,
|
||||||
@@ -67,7 +66,6 @@ def shorten_protocol_name(proto, simplify=False):
|
|||||||
'rtmp_ffmpeg': 'rtmpF',
|
'rtmp_ffmpeg': 'rtmpF',
|
||||||
'http_dash_segments': 'dash',
|
'http_dash_segments': 'dash',
|
||||||
'http_dash_segments_generator': 'dashG',
|
'http_dash_segments_generator': 'dashG',
|
||||||
'niconico_dmc': 'dmc',
|
|
||||||
'websocket_frag': 'WSfrag',
|
'websocket_frag': 'WSfrag',
|
||||||
}
|
}
|
||||||
if simplify:
|
if simplify:
|
||||||
|
|||||||
@@ -2,97 +2,49 @@
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from . import get_suitable_downloader
|
|
||||||
from .common import FileDownloader
|
from .common import FileDownloader
|
||||||
from .external import FFmpegFD
|
from .external import FFmpegFD
|
||||||
from ..networking import Request
|
from ..networking import Request
|
||||||
from ..utils import DownloadError, str_or_none, try_get
|
from ..networking.websocket import WebSocketResponse
|
||||||
|
from ..utils import DownloadError, str_or_none, truncate_string
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
class NiconicoDmcFD(FileDownloader):
|
|
||||||
""" Downloading niconico douga from DMC with heartbeat """
|
|
||||||
|
|
||||||
def real_download(self, filename, info_dict):
|
|
||||||
from ..extractor.niconico import NiconicoIE
|
|
||||||
|
|
||||||
self.to_screen(f'[{self.FD_NAME}] Downloading from DMC')
|
|
||||||
ie = NiconicoIE(self.ydl)
|
|
||||||
info_dict, heartbeat_info_dict = ie._get_heartbeat_info(info_dict)
|
|
||||||
|
|
||||||
fd = get_suitable_downloader(info_dict, params=self.params)(self.ydl, self.params)
|
|
||||||
|
|
||||||
success = download_complete = False
|
|
||||||
timer = [None]
|
|
||||||
heartbeat_lock = threading.Lock()
|
|
||||||
heartbeat_url = heartbeat_info_dict['url']
|
|
||||||
heartbeat_data = heartbeat_info_dict['data'].encode()
|
|
||||||
heartbeat_interval = heartbeat_info_dict.get('interval', 30)
|
|
||||||
|
|
||||||
request = Request(heartbeat_url, heartbeat_data)
|
|
||||||
|
|
||||||
def heartbeat():
|
|
||||||
try:
|
|
||||||
self.ydl.urlopen(request).read()
|
|
||||||
except Exception:
|
|
||||||
self.to_screen(f'[{self.FD_NAME}] Heartbeat failed')
|
|
||||||
|
|
||||||
with heartbeat_lock:
|
|
||||||
if not download_complete:
|
|
||||||
timer[0] = threading.Timer(heartbeat_interval, heartbeat)
|
|
||||||
timer[0].start()
|
|
||||||
|
|
||||||
heartbeat_info_dict['ping']()
|
|
||||||
self.to_screen('[%s] Heartbeat with %d second interval ...' % (self.FD_NAME, heartbeat_interval))
|
|
||||||
try:
|
|
||||||
heartbeat()
|
|
||||||
if type(fd).__name__ == 'HlsFD':
|
|
||||||
info_dict.update(ie._extract_m3u8_formats(info_dict['url'], info_dict['id'])[0])
|
|
||||||
success = fd.real_download(filename, info_dict)
|
|
||||||
finally:
|
|
||||||
if heartbeat_lock:
|
|
||||||
with heartbeat_lock:
|
|
||||||
timer[0].cancel()
|
|
||||||
download_complete = True
|
|
||||||
return success
|
|
||||||
|
|
||||||
|
|
||||||
class NiconicoLiveFD(FileDownloader):
|
class NiconicoLiveFD(FileDownloader):
|
||||||
""" Downloads niconico live without being stopped """
|
""" Downloads niconico live without being stopped """
|
||||||
|
|
||||||
def real_download(self, filename, info_dict):
|
def real_download(self, filename, info_dict):
|
||||||
video_id = info_dict['video_id']
|
video_id = info_dict['id']
|
||||||
ws_url = info_dict['url']
|
opts = info_dict['downloader_options']
|
||||||
ws_extractor = info_dict['ws']
|
quality, ws_extractor, ws_url = opts['max_quality'], opts['ws'], opts['ws_url']
|
||||||
ws_origin_host = info_dict['origin']
|
|
||||||
live_quality = info_dict.get('live_quality', 'high')
|
|
||||||
live_latency = info_dict.get('live_latency', 'high')
|
|
||||||
dl = FFmpegFD(self.ydl, self.params or {})
|
dl = FFmpegFD(self.ydl, self.params or {})
|
||||||
|
|
||||||
new_info_dict = info_dict.copy()
|
new_info_dict = info_dict.copy()
|
||||||
new_info_dict.update({
|
new_info_dict['protocol'] = 'm3u8'
|
||||||
'protocol': 'm3u8',
|
|
||||||
})
|
|
||||||
|
|
||||||
def communicate_ws(reconnect):
|
def communicate_ws(reconnect):
|
||||||
if reconnect:
|
# Support --load-info-json as if it is a reconnect attempt
|
||||||
ws = self.ydl.urlopen(Request(ws_url, headers={'Origin': f'https://{ws_origin_host}'}))
|
if reconnect or not isinstance(ws_extractor, WebSocketResponse):
|
||||||
|
ws = self.ydl.urlopen(Request(
|
||||||
|
ws_url, headers={'Origin': 'https://live.nicovideo.jp'}))
|
||||||
if self.ydl.params.get('verbose', False):
|
if self.ydl.params.get('verbose', False):
|
||||||
self.to_screen('[debug] Sending startWatching request')
|
self.write_debug('Sending startWatching request')
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'type': 'startWatching',
|
|
||||||
'data': {
|
'data': {
|
||||||
'stream': {
|
|
||||||
'quality': live_quality,
|
|
||||||
'protocol': 'hls+fmp4',
|
|
||||||
'latency': live_latency,
|
|
||||||
'chasePlay': False,
|
|
||||||
},
|
|
||||||
'room': {
|
|
||||||
'protocol': 'webSocket',
|
|
||||||
'commentable': True,
|
|
||||||
},
|
|
||||||
'reconnect': True,
|
'reconnect': True,
|
||||||
|
'room': {
|
||||||
|
'commentable': True,
|
||||||
|
'protocol': 'webSocket',
|
||||||
},
|
},
|
||||||
|
'stream': {
|
||||||
|
'accessRightMethod': 'single_cookie',
|
||||||
|
'chasePlay': False,
|
||||||
|
'latency': 'high',
|
||||||
|
'protocol': 'hls',
|
||||||
|
'quality': quality,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'type': 'startWatching',
|
||||||
}))
|
}))
|
||||||
else:
|
else:
|
||||||
ws = ws_extractor
|
ws = ws_extractor
|
||||||
@@ -105,7 +57,6 @@ def communicate_ws(reconnect):
|
|||||||
if not data or not isinstance(data, dict):
|
if not data or not isinstance(data, dict):
|
||||||
continue
|
continue
|
||||||
if data.get('type') == 'ping':
|
if data.get('type') == 'ping':
|
||||||
# pong back
|
|
||||||
ws.send(r'{"type":"pong"}')
|
ws.send(r'{"type":"pong"}')
|
||||||
ws.send(r'{"type":"keepSeat"}')
|
ws.send(r'{"type":"keepSeat"}')
|
||||||
elif data.get('type') == 'disconnect':
|
elif data.get('type') == 'disconnect':
|
||||||
@@ -113,12 +64,10 @@ def communicate_ws(reconnect):
|
|||||||
return True
|
return True
|
||||||
elif data.get('type') == 'error':
|
elif data.get('type') == 'error':
|
||||||
self.write_debug(data)
|
self.write_debug(data)
|
||||||
message = try_get(data, lambda x: x['body']['code'], str) or recv
|
message = traverse_obj(data, ('body', 'code', {str_or_none}), default=recv)
|
||||||
return DownloadError(message)
|
return DownloadError(message)
|
||||||
elif self.ydl.params.get('verbose', False):
|
elif self.ydl.params.get('verbose', False):
|
||||||
if len(recv) > 100:
|
self.write_debug(f'Server response: {truncate_string(recv, 100)}')
|
||||||
recv = recv[:100] + '...'
|
|
||||||
self.to_screen(f'[debug] Server said: {recv}')
|
|
||||||
|
|
||||||
def ws_main():
|
def ws_main():
|
||||||
reconnect = False
|
reconnect = False
|
||||||
@@ -128,7 +77,8 @@ def ws_main():
|
|||||||
if ret is True:
|
if ret is True:
|
||||||
return
|
return
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
self.to_screen('[{}] {}: Connection error occured, reconnecting after 10 seconds: {}'.format('niconico:live', video_id, str_or_none(e)))
|
self.to_screen(
|
||||||
|
f'[niconico:live] {video_id}: Connection error occured, reconnecting after 10 seconds: {e}')
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
continue
|
continue
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -300,7 +300,6 @@
|
|||||||
BrainPOPIlIE,
|
BrainPOPIlIE,
|
||||||
BrainPOPJrIE,
|
BrainPOPJrIE,
|
||||||
)
|
)
|
||||||
from .bravotv import BravoTVIE
|
|
||||||
from .breitbart import BreitBartIE
|
from .breitbart import BreitBartIE
|
||||||
from .brightcove import (
|
from .brightcove import (
|
||||||
BrightcoveLegacyIE,
|
BrightcoveLegacyIE,
|
||||||
@@ -338,7 +337,6 @@
|
|||||||
from .canalplus import CanalplusIE
|
from .canalplus import CanalplusIE
|
||||||
from .canalsurmas import CanalsurmasIE
|
from .canalsurmas import CanalsurmasIE
|
||||||
from .caracoltv import CaracolTvPlayIE
|
from .caracoltv import CaracolTvPlayIE
|
||||||
from .cartoonnetwork import CartoonNetworkIE
|
|
||||||
from .cbc import (
|
from .cbc import (
|
||||||
CBCIE,
|
CBCIE,
|
||||||
CBCGemIE,
|
CBCGemIE,
|
||||||
@@ -807,9 +805,7 @@
|
|||||||
from .hotnewhiphop import HotNewHipHopIE
|
from .hotnewhiphop import HotNewHipHopIE
|
||||||
from .hotstar import (
|
from .hotstar import (
|
||||||
HotStarIE,
|
HotStarIE,
|
||||||
HotStarPlaylistIE,
|
|
||||||
HotStarPrefixIE,
|
HotStarPrefixIE,
|
||||||
HotStarSeasonIE,
|
|
||||||
HotStarSeriesIE,
|
HotStarSeriesIE,
|
||||||
)
|
)
|
||||||
from .hrefli import HrefLiRedirectIE
|
from .hrefli import HrefLiRedirectIE
|
||||||
@@ -903,6 +899,7 @@
|
|||||||
IviIE,
|
IviIE,
|
||||||
)
|
)
|
||||||
from .ivideon import IvideonIE
|
from .ivideon import IvideonIE
|
||||||
|
from .ivoox import IvooxIE
|
||||||
from .iwara import (
|
from .iwara import (
|
||||||
IwaraIE,
|
IwaraIE,
|
||||||
IwaraPlaylistIE,
|
IwaraPlaylistIE,
|
||||||
@@ -922,13 +919,12 @@
|
|||||||
ShugiinItvVodIE,
|
ShugiinItvVodIE,
|
||||||
)
|
)
|
||||||
from .jeuxvideo import JeuxVideoIE
|
from .jeuxvideo import JeuxVideoIE
|
||||||
from .jiocinema import (
|
|
||||||
JioCinemaIE,
|
|
||||||
JioCinemaSeriesIE,
|
|
||||||
)
|
|
||||||
from .jiosaavn import (
|
from .jiosaavn import (
|
||||||
JioSaavnAlbumIE,
|
JioSaavnAlbumIE,
|
||||||
|
JioSaavnArtistIE,
|
||||||
JioSaavnPlaylistIE,
|
JioSaavnPlaylistIE,
|
||||||
|
JioSaavnShowIE,
|
||||||
|
JioSaavnShowPlaylistIE,
|
||||||
JioSaavnSongIE,
|
JioSaavnSongIE,
|
||||||
)
|
)
|
||||||
from .joj import JojIE
|
from .joj import JojIE
|
||||||
@@ -960,7 +956,10 @@
|
|||||||
)
|
)
|
||||||
from .kicker import KickerIE
|
from .kicker import KickerIE
|
||||||
from .kickstarter import KickStarterIE
|
from .kickstarter import KickStarterIE
|
||||||
from .kika import KikaIE
|
from .kika import (
|
||||||
|
KikaIE,
|
||||||
|
KikaPlaylistIE,
|
||||||
|
)
|
||||||
from .kinja import KinjaEmbedIE
|
from .kinja import KinjaEmbedIE
|
||||||
from .kinopoisk import KinoPoiskIE
|
from .kinopoisk import KinoPoiskIE
|
||||||
from .kommunetv import KommunetvIE
|
from .kommunetv import KommunetvIE
|
||||||
@@ -1038,6 +1037,7 @@
|
|||||||
LimelightMediaIE,
|
LimelightMediaIE,
|
||||||
)
|
)
|
||||||
from .linkedin import (
|
from .linkedin import (
|
||||||
|
LinkedInEventsIE,
|
||||||
LinkedInIE,
|
LinkedInIE,
|
||||||
LinkedInLearningCourseIE,
|
LinkedInLearningCourseIE,
|
||||||
LinkedInLearningIE,
|
LinkedInLearningIE,
|
||||||
@@ -1061,6 +1061,7 @@
|
|||||||
from .lovehomeporn import LoveHomePornIE
|
from .lovehomeporn import LoveHomePornIE
|
||||||
from .lrt import (
|
from .lrt import (
|
||||||
LRTVODIE,
|
LRTVODIE,
|
||||||
|
LRTRadioIE,
|
||||||
LRTStreamIE,
|
LRTStreamIE,
|
||||||
)
|
)
|
||||||
from .lsm import (
|
from .lsm import (
|
||||||
@@ -1100,6 +1101,7 @@
|
|||||||
from .massengeschmacktv import MassengeschmackTVIE
|
from .massengeschmacktv import MassengeschmackTVIE
|
||||||
from .masters import MastersIE
|
from .masters import MastersIE
|
||||||
from .matchtv import MatchTVIE
|
from .matchtv import MatchTVIE
|
||||||
|
from .mave import MaveIE
|
||||||
from .mbn import MBNIE
|
from .mbn import MBNIE
|
||||||
from .mdr import MDRIE
|
from .mdr import MDRIE
|
||||||
from .medaltv import MedalTVIE
|
from .medaltv import MedalTVIE
|
||||||
@@ -1254,6 +1256,7 @@
|
|||||||
)
|
)
|
||||||
from .nbc import (
|
from .nbc import (
|
||||||
NBCIE,
|
NBCIE,
|
||||||
|
BravoTVIE,
|
||||||
NBCNewsIE,
|
NBCNewsIE,
|
||||||
NBCOlympicsIE,
|
NBCOlympicsIE,
|
||||||
NBCOlympicsStreamIE,
|
NBCOlympicsStreamIE,
|
||||||
@@ -1261,6 +1264,7 @@
|
|||||||
NBCSportsStreamIE,
|
NBCSportsStreamIE,
|
||||||
NBCSportsVPlayerIE,
|
NBCSportsVPlayerIE,
|
||||||
NBCStationsIE,
|
NBCStationsIE,
|
||||||
|
SyfyIE,
|
||||||
)
|
)
|
||||||
from .ndr import (
|
from .ndr import (
|
||||||
NDRIE,
|
NDRIE,
|
||||||
@@ -1493,6 +1497,10 @@
|
|||||||
)
|
)
|
||||||
from .parler import ParlerIE
|
from .parler import ParlerIE
|
||||||
from .parlview import ParlviewIE
|
from .parlview import ParlviewIE
|
||||||
|
from .parti import (
|
||||||
|
PartiLivestreamIE,
|
||||||
|
PartiVideoIE,
|
||||||
|
)
|
||||||
from .patreon import (
|
from .patreon import (
|
||||||
PatreonCampaignIE,
|
PatreonCampaignIE,
|
||||||
PatreonIE,
|
PatreonIE,
|
||||||
@@ -1739,6 +1747,7 @@
|
|||||||
RoosterTeethSeriesIE,
|
RoosterTeethSeriesIE,
|
||||||
)
|
)
|
||||||
from .rottentomatoes import RottenTomatoesIE
|
from .rottentomatoes import RottenTomatoesIE
|
||||||
|
from .roya import RoyaLiveIE
|
||||||
from .rozhlas import (
|
from .rozhlas import (
|
||||||
MujRozhlasIE,
|
MujRozhlasIE,
|
||||||
RozhlasIE,
|
RozhlasIE,
|
||||||
@@ -1773,7 +1782,6 @@
|
|||||||
from .rtve import (
|
from .rtve import (
|
||||||
RTVEALaCartaIE,
|
RTVEALaCartaIE,
|
||||||
RTVEAudioIE,
|
RTVEAudioIE,
|
||||||
RTVEInfantilIE,
|
|
||||||
RTVELiveIE,
|
RTVELiveIE,
|
||||||
RTVETelevisionIE,
|
RTVETelevisionIE,
|
||||||
)
|
)
|
||||||
@@ -1816,6 +1824,7 @@
|
|||||||
from .saitosan import SaitosanIE
|
from .saitosan import SaitosanIE
|
||||||
from .samplefocus import SampleFocusIE
|
from .samplefocus import SampleFocusIE
|
||||||
from .sapo import SapoIE
|
from .sapo import SapoIE
|
||||||
|
from .sauceplus import SaucePlusIE
|
||||||
from .sbs import SBSIE
|
from .sbs import SBSIE
|
||||||
from .sbscokr import (
|
from .sbscokr import (
|
||||||
SBSCoKrAllvodProgramIE,
|
SBSCoKrAllvodProgramIE,
|
||||||
@@ -1954,7 +1963,6 @@
|
|||||||
SpreakerShowIE,
|
SpreakerShowIE,
|
||||||
)
|
)
|
||||||
from .springboardplatform import SpringboardPlatformIE
|
from .springboardplatform import SpringboardPlatformIE
|
||||||
from .sprout import SproutIE
|
|
||||||
from .sproutvideo import (
|
from .sproutvideo import (
|
||||||
SproutVideoIE,
|
SproutVideoIE,
|
||||||
VidsIoIE,
|
VidsIoIE,
|
||||||
@@ -2005,13 +2013,11 @@
|
|||||||
SverigesRadioPublicationIE,
|
SverigesRadioPublicationIE,
|
||||||
)
|
)
|
||||||
from .svt import (
|
from .svt import (
|
||||||
SVTIE,
|
|
||||||
SVTPageIE,
|
SVTPageIE,
|
||||||
SVTPlayIE,
|
SVTPlayIE,
|
||||||
SVTSeriesIE,
|
SVTSeriesIE,
|
||||||
)
|
)
|
||||||
from .swearnet import SwearnetEpisodeIE
|
from .swearnet import SwearnetEpisodeIE
|
||||||
from .syfy import SyfyIE
|
|
||||||
from .syvdk import SYVDKIE
|
from .syvdk import SYVDKIE
|
||||||
from .sztvhu import SztvHuIE
|
from .sztvhu import SztvHuIE
|
||||||
from .tagesschau import TagesschauIE
|
from .tagesschau import TagesschauIE
|
||||||
@@ -2136,6 +2142,7 @@
|
|||||||
from .toggo import ToggoIE
|
from .toggo import ToggoIE
|
||||||
from .tonline import TOnlineIE
|
from .tonline import TOnlineIE
|
||||||
from .toongoggles import ToonGogglesIE
|
from .toongoggles import ToonGogglesIE
|
||||||
|
from .toutiao import ToutiaoIE
|
||||||
from .toutv import TouTvIE
|
from .toutv import TouTvIE
|
||||||
from .toypics import (
|
from .toypics import (
|
||||||
ToypicsIE,
|
ToypicsIE,
|
||||||
@@ -2227,7 +2234,10 @@
|
|||||||
TVPlayIE,
|
TVPlayIE,
|
||||||
)
|
)
|
||||||
from .tvplayer import TVPlayerIE
|
from .tvplayer import TVPlayerIE
|
||||||
from .tvw import TvwIE
|
from .tvw import (
|
||||||
|
TvwIE,
|
||||||
|
TvwTvChannelsIE,
|
||||||
|
)
|
||||||
from .tweakers import TweakersIE
|
from .tweakers import TweakersIE
|
||||||
from .twentymin import TwentyMinutenIE
|
from .twentymin import TwentyMinutenIE
|
||||||
from .twentythreevideo import TwentyThreeVideoIE
|
from .twentythreevideo import TwentyThreeVideoIE
|
||||||
@@ -2355,6 +2365,7 @@
|
|||||||
VHXEmbedIE,
|
VHXEmbedIE,
|
||||||
VimeoAlbumIE,
|
VimeoAlbumIE,
|
||||||
VimeoChannelIE,
|
VimeoChannelIE,
|
||||||
|
VimeoEventIE,
|
||||||
VimeoGroupsIE,
|
VimeoGroupsIE,
|
||||||
VimeoIE,
|
VimeoIE,
|
||||||
VimeoLikesIE,
|
VimeoLikesIE,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
int_or_none,
|
int_or_none,
|
||||||
time_seconds,
|
time_seconds,
|
||||||
traverse_obj,
|
traverse_obj,
|
||||||
|
update_url,
|
||||||
update_url_query,
|
update_url_query,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -417,6 +418,10 @@ def _real_extract(self, url):
|
|||||||
'is_live': is_live,
|
'is_live': is_live,
|
||||||
'availability': availability,
|
'availability': availability,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if thumbnail := update_url(self._og_search_thumbnail(webpage, default=''), query=None):
|
||||||
|
info['thumbnails'] = [{'url': thumbnail}]
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
import xml.etree.ElementTree as etree
|
import xml.etree.ElementTree as etree
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
from ..utils import (
|
from ..utils import (
|
||||||
NO_DEFAULT,
|
NO_DEFAULT,
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
|
parse_qs,
|
||||||
unescapeHTML,
|
unescapeHTML,
|
||||||
unified_timestamp,
|
unified_timestamp,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
@@ -45,6 +47,8 @@
|
|||||||
'name': 'Comcast XFINITY',
|
'name': 'Comcast XFINITY',
|
||||||
'username_field': 'user',
|
'username_field': 'user',
|
||||||
'password_field': 'passwd',
|
'password_field': 'passwd',
|
||||||
|
'login_hostname': 'login.xfinity.com',
|
||||||
|
'needs_newer_ua': True,
|
||||||
},
|
},
|
||||||
'TWC': {
|
'TWC': {
|
||||||
'name': 'Time Warner Cable | Spectrum',
|
'name': 'Time Warner Cable | Spectrum',
|
||||||
@@ -74,6 +78,12 @@
|
|||||||
'name': 'Verizon FiOS',
|
'name': 'Verizon FiOS',
|
||||||
'username_field': 'IDToken1',
|
'username_field': 'IDToken1',
|
||||||
'password_field': 'IDToken2',
|
'password_field': 'IDToken2',
|
||||||
|
'login_hostname': 'ssoauth.verizon.com',
|
||||||
|
},
|
||||||
|
'Fubo': {
|
||||||
|
'name': 'Fubo',
|
||||||
|
'username_field': 'username',
|
||||||
|
'password_field': 'password',
|
||||||
},
|
},
|
||||||
'Cablevision': {
|
'Cablevision': {
|
||||||
'name': 'Optimum/Cablevision',
|
'name': 'Optimum/Cablevision',
|
||||||
@@ -1338,6 +1348,7 @@
|
|||||||
'name': 'Sling TV',
|
'name': 'Sling TV',
|
||||||
'username_field': 'username',
|
'username_field': 'username',
|
||||||
'password_field': 'password',
|
'password_field': 'password',
|
||||||
|
'login_hostname': 'identity.sling.com',
|
||||||
},
|
},
|
||||||
'Suddenlink': {
|
'Suddenlink': {
|
||||||
'name': 'Suddenlink',
|
'name': 'Suddenlink',
|
||||||
@@ -1355,7 +1366,6 @@
|
|||||||
class AdobePassIE(InfoExtractor): # XXX: Conventionally, base classes should end with BaseIE/InfoExtractor
|
class AdobePassIE(InfoExtractor): # XXX: Conventionally, base classes should end with BaseIE/InfoExtractor
|
||||||
_SERVICE_PROVIDER_TEMPLATE = 'https://sp.auth.adobe.com/adobe-services/%s'
|
_SERVICE_PROVIDER_TEMPLATE = 'https://sp.auth.adobe.com/adobe-services/%s'
|
||||||
_USER_AGENT = 'Mozilla/5.0 (X11; Linux i686; rv:47.0) Gecko/20100101 Firefox/47.0'
|
_USER_AGENT = 'Mozilla/5.0 (X11; Linux i686; rv:47.0) Gecko/20100101 Firefox/47.0'
|
||||||
_MODERN_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; rv:131.0) Gecko/20100101 Firefox/131.0'
|
|
||||||
_MVPD_CACHE = 'ap-mvpd'
|
_MVPD_CACHE = 'ap-mvpd'
|
||||||
|
|
||||||
_DOWNLOADING_LOGIN_PAGE = 'Downloading Provider Login Page'
|
_DOWNLOADING_LOGIN_PAGE = 'Downloading Provider Login Page'
|
||||||
@@ -1367,6 +1377,14 @@ def _download_webpage_handle(self, *args, **kwargs):
|
|||||||
return super()._download_webpage_handle(
|
return super()._download_webpage_handle(
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_mso_headers(mso_info):
|
||||||
|
# yt-dlp's default user-agent is usually too old for some MSO's like Comcast_SSO
|
||||||
|
# See: https://github.com/yt-dlp/yt-dlp/issues/10848
|
||||||
|
return {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:131.0) Gecko/20100101 Firefox/131.0',
|
||||||
|
} if mso_info.get('needs_newer_ua') else {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_mvpd_resource(provider_id, title, guid, rating):
|
def _get_mvpd_resource(provider_id, title, guid, rating):
|
||||||
channel = etree.Element('channel')
|
channel = etree.Element('channel')
|
||||||
@@ -1382,7 +1400,13 @@ def _get_mvpd_resource(provider_id, title, guid, rating):
|
|||||||
resource_rating.text = rating
|
resource_rating.text = rating
|
||||||
return '<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">' + etree.tostring(channel).decode() + '</rss>'
|
return '<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">' + etree.tostring(channel).decode() + '</rss>'
|
||||||
|
|
||||||
def _extract_mvpd_auth(self, url, video_id, requestor_id, resource):
|
def _extract_mvpd_auth(self, url, video_id, requestor_id, resource, software_statement):
|
||||||
|
mso_id = self.get_param('ap_mso')
|
||||||
|
if mso_id:
|
||||||
|
mso_info = MSO_INFO[mso_id]
|
||||||
|
else:
|
||||||
|
mso_info = {}
|
||||||
|
|
||||||
def xml_text(xml_str, tag):
|
def xml_text(xml_str, tag):
|
||||||
return self._search_regex(
|
return self._search_regex(
|
||||||
f'<{tag}>(.+?)</{tag}>', xml_str, tag)
|
f'<{tag}>(.+?)</{tag}>', xml_str, tag)
|
||||||
@@ -1391,15 +1415,27 @@ def is_expired(token, date_ele):
|
|||||||
token_expires = unified_timestamp(re.sub(r'[_ ]GMT', '', xml_text(token, date_ele)))
|
token_expires = unified_timestamp(re.sub(r'[_ ]GMT', '', xml_text(token, date_ele)))
|
||||||
return token_expires and token_expires <= int(time.time())
|
return token_expires and token_expires <= int(time.time())
|
||||||
|
|
||||||
def post_form(form_page_res, note, data={}):
|
def post_form(form_page_res, note, data={}, validate_url=False):
|
||||||
form_page, urlh = form_page_res
|
form_page, urlh = form_page_res
|
||||||
post_url = self._html_search_regex(r'<form[^>]+action=(["\'])(?P<url>.+?)\1', form_page, 'post url', group='url')
|
post_url = self._html_search_regex(r'<form[^>]+action=(["\'])(?P<url>.+?)\1', form_page, 'post url', group='url')
|
||||||
if not re.match(r'https?://', post_url):
|
if not re.match(r'https?://', post_url):
|
||||||
post_url = urllib.parse.urljoin(urlh.url, post_url)
|
post_url = urllib.parse.urljoin(urlh.url, post_url)
|
||||||
|
if validate_url:
|
||||||
|
# This request is submitting credentials so we should validate it when possible
|
||||||
|
url_parsed = urllib.parse.urlparse(post_url)
|
||||||
|
expected_hostname = mso_info.get('login_hostname')
|
||||||
|
if expected_hostname and expected_hostname != url_parsed.hostname:
|
||||||
|
raise ExtractorError(
|
||||||
|
f'Unexpected login URL hostname; expected "{expected_hostname}" but got '
|
||||||
|
f'"{url_parsed.hostname}". Aborting before submitting credentials')
|
||||||
|
if url_parsed.scheme != 'https':
|
||||||
|
self.write_debug('Upgrading login URL scheme to https')
|
||||||
|
post_url = urllib.parse.urlunparse(url_parsed._replace(scheme='https'))
|
||||||
form_data = self._hidden_inputs(form_page)
|
form_data = self._hidden_inputs(form_page)
|
||||||
form_data.update(data)
|
form_data.update(data)
|
||||||
return self._download_webpage_handle(
|
return self._download_webpage_handle(
|
||||||
post_url, video_id, note, data=urlencode_postdata(form_data), headers={
|
post_url, video_id, note, data=urlencode_postdata(form_data), headers={
|
||||||
|
**self._get_mso_headers(mso_info),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1432,19 +1468,58 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
}
|
}
|
||||||
|
|
||||||
guid = xml_text(resource, 'guid') if '<' in resource else resource
|
guid = xml_text(resource, 'guid') if '<' in resource else resource
|
||||||
count = 0
|
for _ in range(2):
|
||||||
while count < 2:
|
|
||||||
requestor_info = self.cache.load(self._MVPD_CACHE, requestor_id) or {}
|
requestor_info = self.cache.load(self._MVPD_CACHE, requestor_id) or {}
|
||||||
authn_token = requestor_info.get('authn_token')
|
authn_token = requestor_info.get('authn_token')
|
||||||
if authn_token and is_expired(authn_token, 'simpleTokenExpires'):
|
if authn_token and is_expired(authn_token, 'simpleTokenExpires'):
|
||||||
authn_token = None
|
authn_token = None
|
||||||
if not authn_token:
|
if not authn_token:
|
||||||
mso_id = self.get_param('ap_mso')
|
if not mso_id:
|
||||||
if mso_id:
|
raise_mvpd_required()
|
||||||
username, password = self._get_login_info('ap_username', 'ap_password', mso_id)
|
username, password = self._get_login_info('ap_username', 'ap_password', mso_id)
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise_mvpd_required()
|
raise_mvpd_required()
|
||||||
mso_info = MSO_INFO[mso_id]
|
|
||||||
|
device_info, urlh = self._download_json_handle(
|
||||||
|
'https://sp.auth.adobe.com/indiv/devices',
|
||||||
|
video_id, 'Registering device with Adobe',
|
||||||
|
data=json.dumps({'fingerprint': uuid.uuid4().hex}).encode(),
|
||||||
|
headers={'Content-Type': 'application/json; charset=UTF-8'})
|
||||||
|
|
||||||
|
device_id = device_info['deviceId']
|
||||||
|
mvpd_headers['pass_sfp'] = urlh.get_header('pass_sfp')
|
||||||
|
mvpd_headers['Ap_21'] = device_id
|
||||||
|
|
||||||
|
registration = self._download_json(
|
||||||
|
'https://sp.auth.adobe.com/o/client/register',
|
||||||
|
video_id, 'Registering client with Adobe',
|
||||||
|
data=json.dumps({'software_statement': software_statement}).encode(),
|
||||||
|
headers={'Content-Type': 'application/json; charset=UTF-8'})
|
||||||
|
|
||||||
|
access_token = self._download_json(
|
||||||
|
'https://sp.auth.adobe.com/o/client/token', video_id,
|
||||||
|
'Obtaining access token', data=urlencode_postdata({
|
||||||
|
'grant_type': 'client_credentials',
|
||||||
|
'client_id': registration['client_id'],
|
||||||
|
'client_secret': registration['client_secret'],
|
||||||
|
}),
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
})['access_token']
|
||||||
|
mvpd_headers['Authorization'] = f'Bearer {access_token}'
|
||||||
|
|
||||||
|
reg_code = self._download_json(
|
||||||
|
f'https://sp.auth.adobe.com/reggie/v1/{requestor_id}/regcode',
|
||||||
|
video_id, 'Obtaining registration code',
|
||||||
|
data=urlencode_postdata({
|
||||||
|
'requestor': requestor_id,
|
||||||
|
'deviceId': device_id,
|
||||||
|
'format': 'json',
|
||||||
|
}),
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Authorization': f'Bearer {access_token}',
|
||||||
|
})['code']
|
||||||
|
|
||||||
provider_redirect_page_res = self._download_webpage_handle(
|
provider_redirect_page_res = self._download_webpage_handle(
|
||||||
self._SERVICE_PROVIDER_TEMPLATE % 'authenticate/saml', video_id,
|
self._SERVICE_PROVIDER_TEMPLATE % 'authenticate/saml', video_id,
|
||||||
@@ -1455,17 +1530,10 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
'no_iframe': 'false',
|
'no_iframe': 'false',
|
||||||
'domain_name': 'adobe.com',
|
'domain_name': 'adobe.com',
|
||||||
'redirect_url': url,
|
'redirect_url': url,
|
||||||
}, headers={
|
'reg_code': reg_code,
|
||||||
# yt-dlp's default user-agent is usually too old for Comcast_SSO
|
}, headers=self._get_mso_headers(mso_info))
|
||||||
# See: https://github.com/yt-dlp/yt-dlp/issues/10848
|
|
||||||
'User-Agent': self._MODERN_USER_AGENT,
|
|
||||||
} if mso_id == 'Comcast_SSO' else None)
|
|
||||||
elif not self._cookies_passed:
|
|
||||||
raise_mvpd_required()
|
|
||||||
|
|
||||||
if not mso_id:
|
if mso_id == 'Comcast_SSO':
|
||||||
pass
|
|
||||||
elif mso_id == 'Comcast_SSO':
|
|
||||||
# Comcast page flow varies by video site and whether you
|
# Comcast page flow varies by video site and whether you
|
||||||
# are on Comcast's network.
|
# are on Comcast's network.
|
||||||
provider_redirect_page, urlh = provider_redirect_page_res
|
provider_redirect_page, urlh = provider_redirect_page_res
|
||||||
@@ -1489,8 +1557,8 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
oauth_redirect_url = extract_redirect_url(
|
oauth_redirect_url = extract_redirect_url(
|
||||||
provider_redirect_page, fatal=True)
|
provider_redirect_page, fatal=True)
|
||||||
provider_login_page_res = self._download_webpage_handle(
|
provider_login_page_res = self._download_webpage_handle(
|
||||||
oauth_redirect_url, video_id,
|
oauth_redirect_url, video_id, self._DOWNLOADING_LOGIN_PAGE,
|
||||||
self._DOWNLOADING_LOGIN_PAGE)
|
headers=self._get_mso_headers(mso_info))
|
||||||
else:
|
else:
|
||||||
provider_login_page_res = post_form(
|
provider_login_page_res = post_form(
|
||||||
provider_redirect_page_res,
|
provider_redirect_page_res,
|
||||||
@@ -1500,24 +1568,35 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
provider_login_page_res, 'Logging in', {
|
provider_login_page_res, 'Logging in', {
|
||||||
mso_info['username_field']: username,
|
mso_info['username_field']: username,
|
||||||
mso_info['password_field']: password,
|
mso_info['password_field']: password,
|
||||||
})
|
}, validate_url=True)
|
||||||
mvpd_confirm_page, urlh = mvpd_confirm_page_res
|
mvpd_confirm_page, urlh = mvpd_confirm_page_res
|
||||||
if '<button class="submit" value="Resume">Resume</button>' in mvpd_confirm_page:
|
if '<button class="submit" value="Resume">Resume</button>' in mvpd_confirm_page:
|
||||||
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
||||||
elif mso_id == 'Philo':
|
elif mso_id == 'Philo':
|
||||||
# Philo has very unique authentication method
|
# Philo has very unique authentication method
|
||||||
self._download_webpage(
|
self._request_webpage(
|
||||||
'https://idp.philo.com/auth/init/login_code', video_id, 'Requesting auth code', data=urlencode_postdata({
|
'https://idp.philo.com/auth/init/login_code', video_id,
|
||||||
|
'Requesting Philo auth code', data=json.dumps({
|
||||||
'ident': username,
|
'ident': username,
|
||||||
'device': 'web',
|
'device': 'web',
|
||||||
'send_confirm_link': False,
|
'send_confirm_link': False,
|
||||||
'send_token': True,
|
'send_token': True,
|
||||||
}))
|
'device_ident': f'web-{uuid.uuid4().hex}',
|
||||||
|
'include_login_link': True,
|
||||||
|
}).encode(), headers={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
philo_code = getpass.getpass('Type auth code you have received [Return]: ')
|
philo_code = getpass.getpass('Type auth code you have received [Return]: ')
|
||||||
self._download_webpage(
|
self._request_webpage(
|
||||||
'https://idp.philo.com/auth/update/login_code', video_id, 'Submitting token', data=urlencode_postdata({
|
'https://idp.philo.com/auth/update/login_code', video_id,
|
||||||
'token': philo_code,
|
'Submitting token', data=json.dumps({'token': philo_code}).encode(),
|
||||||
}))
|
headers={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
mvpd_confirm_page_res = self._download_webpage_handle('https://idp.philo.com/idp/submit', video_id, 'Confirming Philo Login')
|
mvpd_confirm_page_res = self._download_webpage_handle('https://idp.philo.com/idp/submit', video_id, 'Confirming Philo Login')
|
||||||
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
||||||
elif mso_id == 'Verizon':
|
elif mso_id == 'Verizon':
|
||||||
@@ -1539,7 +1618,7 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
provider_redirect_page_res, 'Logging in', {
|
provider_redirect_page_res, 'Logging in', {
|
||||||
mso_info['username_field']: username,
|
mso_info['username_field']: username,
|
||||||
mso_info['password_field']: password,
|
mso_info['password_field']: password,
|
||||||
})
|
}, validate_url=True)
|
||||||
saml_login_page, urlh = saml_login_page_res
|
saml_login_page, urlh = saml_login_page_res
|
||||||
if 'Please try again.' in saml_login_page:
|
if 'Please try again.' in saml_login_page:
|
||||||
raise ExtractorError(
|
raise ExtractorError(
|
||||||
@@ -1560,7 +1639,7 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
[saml_login_page, saml_redirect_url], 'Logging in', {
|
[saml_login_page, saml_redirect_url], 'Logging in', {
|
||||||
mso_info['username_field']: username,
|
mso_info['username_field']: username,
|
||||||
mso_info['password_field']: password,
|
mso_info['password_field']: password,
|
||||||
})
|
}, validate_url=True)
|
||||||
if 'Please try again.' in saml_login_page:
|
if 'Please try again.' in saml_login_page:
|
||||||
raise ExtractorError(
|
raise ExtractorError(
|
||||||
'Failed to login, incorrect User ID or Password.')
|
'Failed to login, incorrect User ID or Password.')
|
||||||
@@ -1631,7 +1710,7 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
provider_login_page_res, 'Logging in', {
|
provider_login_page_res, 'Logging in', {
|
||||||
mso_info['username_field']: username,
|
mso_info['username_field']: username,
|
||||||
mso_info['password_field']: password,
|
mso_info['password_field']: password,
|
||||||
})
|
}, validate_url=True)
|
||||||
|
|
||||||
provider_refresh_redirect_url = extract_redirect_url(
|
provider_refresh_redirect_url = extract_redirect_url(
|
||||||
provider_association_redirect, url=urlh.url)
|
provider_association_redirect, url=urlh.url)
|
||||||
@@ -1682,7 +1761,7 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
provider_login_page_res, 'Logging in', {
|
provider_login_page_res, 'Logging in', {
|
||||||
mso_info['username_field']: username,
|
mso_info['username_field']: username,
|
||||||
mso_info['password_field']: password,
|
mso_info['password_field']: password,
|
||||||
})
|
}, validate_url=True)
|
||||||
|
|
||||||
provider_refresh_redirect_url = extract_redirect_url(
|
provider_refresh_redirect_url = extract_redirect_url(
|
||||||
provider_association_redirect, url=urlh.url)
|
provider_association_redirect, url=urlh.url)
|
||||||
@@ -1699,6 +1778,27 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
query=hidden_data)
|
query=hidden_data)
|
||||||
|
|
||||||
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
||||||
|
elif mso_id == 'Fubo':
|
||||||
|
_, urlh = provider_redirect_page_res
|
||||||
|
|
||||||
|
fubo_response = self._download_json(
|
||||||
|
'https://api.fubo.tv/partners/tve/connect', video_id,
|
||||||
|
'Authenticating with Fubo', 'Unable to authenticate with Fubo',
|
||||||
|
query=parse_qs(urlh.url), data=json.dumps({
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
}).encode(), headers={
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
self._request_webpage(
|
||||||
|
'https://sp.auth.adobe.com/adobe-services/oauth2', video_id,
|
||||||
|
'Authenticating with Adobe', 'Failed to authenticate with Adobe',
|
||||||
|
query={
|
||||||
|
'code': fubo_response['code'],
|
||||||
|
'state': fubo_response['state'],
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
# Some providers (e.g. DIRECTV NOW) have another meta refresh
|
# Some providers (e.g. DIRECTV NOW) have another meta refresh
|
||||||
# based redirect that should be followed.
|
# based redirect that should be followed.
|
||||||
@@ -1717,7 +1817,8 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
}
|
}
|
||||||
if mso_id in ('Cablevision', 'AlticeOne'):
|
if mso_id in ('Cablevision', 'AlticeOne'):
|
||||||
form_data['_eventId_proceed'] = ''
|
form_data['_eventId_proceed'] = ''
|
||||||
mvpd_confirm_page_res = post_form(provider_login_page_res, 'Logging in', form_data)
|
mvpd_confirm_page_res = post_form(
|
||||||
|
provider_login_page_res, 'Logging in', form_data, validate_url=True)
|
||||||
if mso_id != 'Rogers':
|
if mso_id != 'Rogers':
|
||||||
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
post_form(mvpd_confirm_page_res, 'Confirming Login')
|
||||||
|
|
||||||
@@ -1727,6 +1828,7 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
'Retrieving Session', data=urlencode_postdata({
|
'Retrieving Session', data=urlencode_postdata({
|
||||||
'_method': 'GET',
|
'_method': 'GET',
|
||||||
'requestor_id': requestor_id,
|
'requestor_id': requestor_id,
|
||||||
|
'reg_code': reg_code,
|
||||||
}), headers=mvpd_headers)
|
}), headers=mvpd_headers)
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
if not mso_id and isinstance(e.cause, HTTPError) and e.cause.status == 401:
|
if not mso_id and isinstance(e.cause, HTTPError) and e.cause.status == 401:
|
||||||
@@ -1734,7 +1836,6 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
raise
|
raise
|
||||||
if '<pendingLogout' in session:
|
if '<pendingLogout' in session:
|
||||||
self.cache.store(self._MVPD_CACHE, requestor_id, {})
|
self.cache.store(self._MVPD_CACHE, requestor_id, {})
|
||||||
count += 1
|
|
||||||
continue
|
continue
|
||||||
authn_token = unescapeHTML(xml_text(session, 'authnToken'))
|
authn_token = unescapeHTML(xml_text(session, 'authnToken'))
|
||||||
requestor_info['authn_token'] = authn_token
|
requestor_info['authn_token'] = authn_token
|
||||||
@@ -1755,7 +1856,6 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
}), headers=mvpd_headers)
|
}), headers=mvpd_headers)
|
||||||
if '<pendingLogout' in authorize:
|
if '<pendingLogout' in authorize:
|
||||||
self.cache.store(self._MVPD_CACHE, requestor_id, {})
|
self.cache.store(self._MVPD_CACHE, requestor_id, {})
|
||||||
count += 1
|
|
||||||
continue
|
continue
|
||||||
if '<error' in authorize:
|
if '<error' in authorize:
|
||||||
raise ExtractorError(xml_text(authorize, 'details'), expected=True)
|
raise ExtractorError(xml_text(authorize, 'details'), expected=True)
|
||||||
@@ -1778,6 +1878,5 @@ def extract_redirect_url(html, url=None, fatal=False):
|
|||||||
}), headers=mvpd_headers)
|
}), headers=mvpd_headers)
|
||||||
if '<pendingLogout' in short_authorize:
|
if '<pendingLogout' in short_authorize:
|
||||||
self.cache.store(self._MVPD_CACHE, requestor_id, {})
|
self.cache.store(self._MVPD_CACHE, requestor_id, {})
|
||||||
count += 1
|
|
||||||
continue
|
continue
|
||||||
return short_authorize
|
return short_authorize
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ class AdultSwimIE(TurnerBaseIE):
|
|||||||
'skip': '404 Not Found',
|
'skip': '404 Not Found',
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
_SOFTWARE_STATEMENT = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwNjg5ZmU2My00OTc5LTQxZmQtYWYxNC1hYjVlNmJjNWVkZWIiLCJuYmYiOjE1MzcxOTA2NzQsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTM3MTkwNjc0fQ.Xl3AEduM0s1TxDQ6-XssdKIiLm261hhsEv1C1yo_nitIajZThSI9rXILqtIzO0aujoHhdzUnu_dUCq9ffiSBzEG632tTa1la-5tegHtce80cMhewBN4n2t8n9O5tiaPx8MPY8ALdm5wS7QzWE6DO_LTJKgE8Bl7Yv-CWJT4q4SywtNiQWLVOuhBRnDyfsRezxRwptw8qTn9dv5ZzUrVJaby5fDZ_nOncMKvegOgaKd5KEuCAGQ-mg-PSuValMjGuf6FwDguGaK7IyI5Y2oOrzXmD4Dj7q4WBg8w9QoZhtLeAU56mcsGILolku2R5FHlVLO9xhjResyt-pfmegOkpSw'
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
show_path, episode_path = self._match_valid_url(url).groups()
|
show_path, episode_path = self._match_valid_url(url).groups()
|
||||||
display_id = episode_path or show_path
|
display_id = episode_path or show_path
|
||||||
@@ -152,7 +154,7 @@ def _real_extract(self, url):
|
|||||||
# CDN_TOKEN_APP_ID from:
|
# CDN_TOKEN_APP_ID from:
|
||||||
# https://d2gg02c3xr550i.cloudfront.net/assets/asvp.e9c8bef24322d060ef87.bundle.js
|
# https://d2gg02c3xr550i.cloudfront.net/assets/asvp.e9c8bef24322d060ef87.bundle.js
|
||||||
'appId': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6ImFzLXR2ZS1kZXNrdG9wLXB0enQ2bSIsInByb2R1Y3QiOiJ0dmUiLCJuZXR3b3JrIjoiYXMiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJpYXQiOjE1MzI3MDIyNzl9.BzSCk-WYOZ2GMCIaeVb8zWnzhlgnXuJTCu0jGp_VaZE',
|
'appId': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6ImFzLXR2ZS1kZXNrdG9wLXB0enQ2bSIsInByb2R1Y3QiOiJ0dmUiLCJuZXR3b3JrIjoiYXMiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJpYXQiOjE1MzI3MDIyNzl9.BzSCk-WYOZ2GMCIaeVb8zWnzhlgnXuJTCu0jGp_VaZE',
|
||||||
}, {
|
}, self._SOFTWARE_STATEMENT, {
|
||||||
'url': url,
|
'url': url,
|
||||||
'site_name': 'AdultSwim',
|
'site_name': 'AdultSwim',
|
||||||
'auth_required': auth,
|
'auth_required': auth,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from .theplatform import ThePlatformIE
|
from .theplatform import ThePlatformIE
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
@@ -6,7 +8,6 @@
|
|||||||
remove_start,
|
remove_start,
|
||||||
traverse_obj,
|
traverse_obj,
|
||||||
update_url_query,
|
update_url_query,
|
||||||
urlencode_postdata,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,13 +21,13 @@ class AENetworksBaseIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
|||||||
_THEPLATFORM_KEY = '43jXaGRQud'
|
_THEPLATFORM_KEY = '43jXaGRQud'
|
||||||
_THEPLATFORM_SECRET = 'S10BPXHMlb'
|
_THEPLATFORM_SECRET = 'S10BPXHMlb'
|
||||||
_DOMAIN_MAP = {
|
_DOMAIN_MAP = {
|
||||||
'history.com': ('HISTORY', 'history'),
|
'history.com': ('HISTORY', 'history', 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI1MzZlMTQ3ZS0zMzFhLTQxY2YtYTMwNC01MDA2NzNlOGYwYjYiLCJuYmYiOjE1Mzg2NjMzMDksImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTM4NjYzMzA5fQ.n24-FVHLGXJe2D4atIQZ700aiXKIajKh5PWFoHJ40Az4itjtwwSFHnvufnoal3T8lYkwNLxce7H-IEGxIykRkZEdwq09pMKMT-ft9ASzE4vQ8fAWbf5ZgDME86x4Jq_YaxkRc9Ne0eShGhl8fgTJHvk07sfWcol61HJ7kU7K8FzzcHR0ucFQgA5VNd8RyjoGWY7c6VxnXR214LOpXsywmit04-vGJC102b_WA2EQfqI93UzG6M6l0EeV4n0_ijP3s8_i8WMJZ_uwnTafCIY6G_731i01dKXDLSFzG1vYglAwDa8DTcdrAAuIFFDF6QNGItCCmwbhjufjmoeVb7R1Gg'),
|
||||||
'aetv.com': ('AETV', 'aetv'),
|
'aetv.com': ('AETV', 'aetv', 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI5Y2IwNjg2Yy03ODUxLTRiZDUtODcyMC00MjNlZTg1YTQ1NzMiLCJuYmYiOjE1Mzg2NjMyOTAsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTM4NjYzMjkwfQ.T5Elf0X4TndO4NEgqBas1gDxNHGPVk_daO2Ha5FBzVO6xi3zM7eavdAKfYMCN7gpWYJx03iADaVPtczO_t_aGZczDjpwJHgTUzDgvcLZAVsVDqtDIAMy3S846rPgT6UDbVoxurA7B2VTPm9phjrSXhejvd0LBO8MQL4AZ3sy2VmiPJ2noT1ily5PuHCYlkrT1fheO064duR__Cd9DQ5VTMnKjzY3Cx345CEwKDkUk5gwgxhXM-aY0eblehrq8VD81_aRM_O3tvh7nbTydHOnUpV-k_iKVi49gqz7Sf8zb6Zh5z2Uftn3vYCfE5NQuesitoRMnsH17nW7o_D59hkRgg'),
|
||||||
'mylifetime.com': ('LIFETIME', 'lifetime'),
|
'mylifetime.com': ('LIFETIME', 'lifetime', 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmODg0MDM1ZC1mZGRmLTRmYjgtYmRkMC05MzRhZDdiYTAwYTciLCJuYmYiOjE1NDkzOTI2NDQsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTQ5MzkyNjQ0fQ.vkTIaCpheKdKQd__2-3ec4qkcpbAhyCTvwe5iTl922ItSQfVhpEJG4wseVSNmBTrpBi0hvLedcw6Hj1_UuzBMVuVcCqLprU-pI8recEwL0u7G-eVkylsxe1OTUm1o3V6OykXQ9KlA-QQLL1neUhdhR1n5B1LZ4cmtBmiEpfgf4rFwXD1ScFylIcaWKLBqHoRBNUmxyTmoXXvn_A-GGSj9eCizFzY8W5uBwUcsoiw2Cr1skx7PbB2RSP1I5DsoIJKG-8XV1KS7MWl-fNLjE-hVAsI9znqfEEFcPBiv3LhCP4Nf4OIs7xAselMn0M0c8igRUZhURWX_hdygUAxkbKFtQ'),
|
||||||
'lifetimemovieclub.com': ('LIFETIMEMOVIECLUB', 'lmc'),
|
'fyi.tv': ('FYI', 'fyi', 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxOGZiOWM3Ny1mYmMzLTQxYTktYmE1Yi1lMzM0ZmUzNzU4NjEiLCJuYmYiOjE1ODc1ODAzNzcsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTg3NTgwMzc3fQ.AYDuipKswmIfLBfOjHRsfc5fMV5NmJUmiJnkpiep4VEw9QiXkygFj4bN06Si5tFc5Mee5TDrGzDpV6iuKbVpLT5kuqXhAn-Wozf5zKPsg_IpdEKO7gsiCq4calt72ct44KTqtKD_hVcoxQU24_HaJsRgXzu3B-6Ff6UrmsXkyvYifYVC9v2DSkdCuA02_IrlllzVT2kRuefUXgL4vQRtTFf77uYa0RKSTG7uVkiQ_AU41eXevKlO2qgtc14Hk5cZ7-ZNrDyMCXYA5ngdIHP7Gs9PWaFXT36PFHI_rC4EfxUABPzjQFxjpP75aX5qn8SH__HbM9q3hoPWgaEaf76qIQ'),
|
||||||
'fyi.tv': ('FYI', 'fyi'),
|
'lifetimemovieclub.com': ('LIFETIMEMOVIECLUB', 'lmc', None),
|
||||||
'historyvault.com': (None, 'historyvault'),
|
'historyvault.com': (None, 'historyvault', None),
|
||||||
'biography.com': (None, 'biography'),
|
'biography.com': (None, 'biography', None),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_aen_smil(self, smil_url, video_id, auth=None):
|
def _extract_aen_smil(self, smil_url, video_id, auth=None):
|
||||||
@@ -71,7 +72,7 @@ def _extract_aen_smil(self, smil_url, video_id, auth=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _extract_aetn_info(self, domain, filter_key, filter_value, url):
|
def _extract_aetn_info(self, domain, filter_key, filter_value, url):
|
||||||
requestor_id, brand = self._DOMAIN_MAP[domain]
|
requestor_id, brand, software_statement = self._DOMAIN_MAP[domain]
|
||||||
result = self._download_json(
|
result = self._download_json(
|
||||||
f'https://feeds.video.aetnd.com/api/v2/{brand}/videos',
|
f'https://feeds.video.aetnd.com/api/v2/{brand}/videos',
|
||||||
filter_value, query={f'filter[{filter_key}]': filter_value})
|
filter_value, query={f'filter[{filter_key}]': filter_value})
|
||||||
@@ -95,7 +96,7 @@ def _extract_aetn_info(self, domain, filter_key, filter_value, url):
|
|||||||
theplatform_metadata.get('AETN$PPL_pplProgramId') or theplatform_metadata.get('AETN$PPL_pplProgramId_OLD'),
|
theplatform_metadata.get('AETN$PPL_pplProgramId') or theplatform_metadata.get('AETN$PPL_pplProgramId_OLD'),
|
||||||
traverse_obj(theplatform_metadata, ('ratings', 0, 'rating')))
|
traverse_obj(theplatform_metadata, ('ratings', 0, 'rating')))
|
||||||
auth = self._extract_mvpd_auth(
|
auth = self._extract_mvpd_auth(
|
||||||
url, video_id, requestor_id, resource)
|
url, video_id, requestor_id, resource, software_statement)
|
||||||
info.update(self._extract_aen_smil(media_url, video_id, auth))
|
info.update(self._extract_aen_smil(media_url, video_id, auth))
|
||||||
info.update({
|
info.update({
|
||||||
'title': title,
|
'title': title,
|
||||||
@@ -132,10 +133,11 @@ class AENetworksIE(AENetworksBaseIE):
|
|||||||
'tags': 'count:14',
|
'tags': 'count:14',
|
||||||
'categories': ['Mountain Men'],
|
'categories': ['Mountain Men'],
|
||||||
'episode_number': 1,
|
'episode_number': 1,
|
||||||
'episode': 'Episode 1',
|
'episode': 'Winter Is Coming',
|
||||||
'season': 'Season 1',
|
'season': 'Season 1',
|
||||||
'season_number': 1,
|
'season_number': 1,
|
||||||
'series': 'Mountain Men',
|
'series': 'Mountain Men',
|
||||||
|
'age_limit': 0,
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {
|
||||||
# m3u8 download
|
# m3u8 download
|
||||||
@@ -157,18 +159,18 @@ class AENetworksIE(AENetworksBaseIE):
|
|||||||
'thumbnail': r're:^https?://.*\.jpe?g$',
|
'thumbnail': r're:^https?://.*\.jpe?g$',
|
||||||
'chapters': 'count:4',
|
'chapters': 'count:4',
|
||||||
'tags': 'count:23',
|
'tags': 'count:23',
|
||||||
'episode': 'Episode 1',
|
'episode': 'Inlawful Entry',
|
||||||
'episode_number': 1,
|
'episode_number': 1,
|
||||||
'season': 'Season 9',
|
'season': 'Season 9',
|
||||||
'season_number': 9,
|
'season_number': 9,
|
||||||
'series': 'Duck Dynasty',
|
'series': 'Duck Dynasty',
|
||||||
|
'age_limit': 0,
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {
|
||||||
# m3u8 download
|
# m3u8 download
|
||||||
'skip_download': True,
|
'skip_download': True,
|
||||||
},
|
},
|
||||||
'add_ie': ['ThePlatform'],
|
'add_ie': ['ThePlatform'],
|
||||||
'skip': 'This video is only available for users of participating TV providers.',
|
|
||||||
}, {
|
}, {
|
||||||
'url': 'http://www.fyi.tv/shows/tiny-house-nation/season-1/episode-8',
|
'url': 'http://www.fyi.tv/shows/tiny-house-nation/season-1/episode-8',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -203,18 +205,19 @@ def _real_extract(self, url):
|
|||||||
class AENetworksListBaseIE(AENetworksBaseIE):
|
class AENetworksListBaseIE(AENetworksBaseIE):
|
||||||
def _call_api(self, resource, slug, brand, fields):
|
def _call_api(self, resource, slug, brand, fields):
|
||||||
return self._download_json(
|
return self._download_json(
|
||||||
'https://yoga.appsvcs.aetnd.com/graphql',
|
'https://yoga.appsvcs.aetnd.com/graphql', slug,
|
||||||
slug, query={'brand': brand}, data=urlencode_postdata({
|
query={'brand': brand}, headers={'Content-Type': 'application/json'},
|
||||||
|
data=json.dumps({
|
||||||
'query': '''{
|
'query': '''{
|
||||||
%s(slug: "%s") {
|
%s(slug: "%s") {
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
}''' % (resource, slug, fields), # noqa: UP031
|
}''' % (resource, slug, fields), # noqa: UP031
|
||||||
}))['data'][resource]
|
}).encode())['data'][resource]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
domain, slug = self._match_valid_url(url).groups()
|
domain, slug = self._match_valid_url(url).groups()
|
||||||
_, brand = self._DOMAIN_MAP[domain]
|
_, brand, _ = self._DOMAIN_MAP[domain]
|
||||||
playlist = self._call_api(self._RESOURCE, slug, brand, self._FIELDS)
|
playlist = self._call_api(self._RESOURCE, slug, brand, self._FIELDS)
|
||||||
base_url = f'http://watch.{domain}'
|
base_url = f'http://watch.{domain}'
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class TokFMPodcastIE(InfoExtractor):
|
|||||||
'url': 'https://audycje.tokfm.pl/podcast/91275,-Systemowy-rasizm-Czy-zamieszki-w-USA-po-morderstwie-w-Minneapolis-doprowadza-do-zmian-w-sluzbach-panstwowych',
|
'url': 'https://audycje.tokfm.pl/podcast/91275,-Systemowy-rasizm-Czy-zamieszki-w-USA-po-morderstwie-w-Minneapolis-doprowadza-do-zmian-w-sluzbach-panstwowych',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '91275',
|
'id': '91275',
|
||||||
'ext': 'aac',
|
'ext': 'mp3',
|
||||||
'title': 'md5:a9b15488009065556900169fb8061cce',
|
'title': 'md5:a9b15488009065556900169fb8061cce',
|
||||||
'episode': 'md5:a9b15488009065556900169fb8061cce',
|
'episode': 'md5:a9b15488009065556900169fb8061cce',
|
||||||
'series': 'Analizy',
|
'series': 'Analizy',
|
||||||
@@ -164,23 +164,20 @@ def _real_extract(self, url):
|
|||||||
raise ExtractorError('No such podcast', expected=True)
|
raise ExtractorError('No such podcast', expected=True)
|
||||||
metadata = metadata[0]
|
metadata = metadata[0]
|
||||||
|
|
||||||
formats = []
|
mp3_url = self._download_json(
|
||||||
for ext in ('aac', 'mp3'):
|
'https://api.podcast.radioagora.pl/api4/getSongUrl',
|
||||||
url_data = self._download_json(
|
media_id, 'Downloading podcast mp3 URL', query={
|
||||||
f'https://api.podcast.radioagora.pl/api4/getSongUrl?podcast_id={media_id}&device_id={uuid.uuid4()}&ppre=false&audio={ext}',
|
'podcast_id': media_id,
|
||||||
media_id, f'Downloading podcast {ext} URL')
|
'device_id': str(uuid.uuid4()),
|
||||||
# prevents inserting the mp3 (default) multiple times
|
'ppre': 'false',
|
||||||
if 'link_ssl' in url_data and f'.{ext}' in url_data['link_ssl']:
|
'audio': 'mp3',
|
||||||
formats.append({
|
})['link_ssl']
|
||||||
'url': url_data['link_ssl'],
|
|
||||||
'ext': ext,
|
|
||||||
'vcodec': 'none',
|
|
||||||
'acodec': ext,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': media_id,
|
'id': media_id,
|
||||||
'formats': formats,
|
'url': mp3_url,
|
||||||
|
'vcodec': 'none',
|
||||||
|
'ext': 'mp3',
|
||||||
'title': metadata.get('podcast_name'),
|
'title': metadata.get('podcast_name'),
|
||||||
'series': metadata.get('series_name'),
|
'series': metadata.get('series_name'),
|
||||||
'episode': metadata.get('podcast_name'),
|
'episode': metadata.get('podcast_name'),
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
import re
|
from .brightcove import BrightcoveNewIE
|
||||||
|
from .common import InfoExtractor
|
||||||
from .theplatform import ThePlatformIE
|
from ..utils.traversal import traverse_obj
|
||||||
from ..utils import (
|
|
||||||
int_or_none,
|
|
||||||
parse_age_limit,
|
|
||||||
try_get,
|
|
||||||
update_url_query,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AMCNetworksIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
class AMCNetworksIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:www\.)?(?P<site>amc|bbcamerica|ifc|(?:we|sundance)tv)\.com/(?P<id>(?:movies|shows(?:/[^/]+)+)/[^/?#&]+)'
|
_VALID_URL = r'https?://(?:www\.)?(?:amc|bbcamerica|ifc|(?:we|sundance)tv)\.com/(?P<id>(?:movies|shows(?:/[^/?#]+)+)/[^/?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.bbcamerica.com/shows/the-graham-norton-show/videos/tina-feys-adorable-airline-themed-family-dinner--51631',
|
'url': 'https://www.amc.com/shows/dark-winds/videos/dark-winds-a-look-at-season-3--1072027',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '4Lq1dzOnZGt0',
|
'id': '6369261343112',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': "The Graham Norton Show - Season 28 - Tina Fey's Adorable Airline-Themed Family Dinner",
|
'title': 'Dark Winds: A Look at Season 3',
|
||||||
'description': "It turns out child stewardesses are very generous with the wine! All-new episodes of 'The Graham Norton Show' premiere Fridays at 11/10c on BBC America.",
|
'uploader_id': '6240731308001',
|
||||||
'upload_date': '20201120',
|
'duration': 176.427,
|
||||||
'timestamp': 1605904350,
|
'thumbnail': r're:https://[^/]+\.boltdns\.net/.+/image\.jpg',
|
||||||
'uploader': 'AMCN',
|
'tags': [],
|
||||||
|
'timestamp': 1740414792,
|
||||||
|
'upload_date': '20250224',
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {'skip_download': 'm3u8'},
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
|
||||||
'skip': '404 Not Found',
|
|
||||||
}, {
|
}, {
|
||||||
'url': 'http://www.bbcamerica.com/shows/the-hunt/full-episodes/season-1/episode-01-the-hardest-challenge',
|
'url': 'http://www.bbcamerica.com/shows/the-hunt/full-episodes/season-1/episode-01-the-hardest-challenge',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -52,96 +44,18 @@ class AMCNetworksIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
|||||||
'url': 'https://www.sundancetv.com/shows/riviera/full-episodes/season-1/episode-01-episode-1',
|
'url': 'https://www.sundancetv.com/shows/riviera/full-episodes/season-1/episode-01-episode-1',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
_REQUESTOR_ID_MAP = {
|
|
||||||
'amc': 'AMC',
|
|
||||||
'bbcamerica': 'BBCA',
|
|
||||||
'ifc': 'IFC',
|
|
||||||
'sundancetv': 'SUNDANCE',
|
|
||||||
'wetv': 'WETV',
|
|
||||||
}
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
site, display_id = self._match_valid_url(url).groups()
|
display_id = self._match_id(url)
|
||||||
requestor_id = self._REQUESTOR_ID_MAP[site]
|
webpage = self._download_webpage(url, display_id)
|
||||||
page_data = self._download_json(
|
initial_data = self._search_json(
|
||||||
f'https://content-delivery-gw.svc.ds.amcn.com/api/v2/content/amcn/{requestor_id.lower()}/url/{display_id}',
|
r'window\.initialData\s*=\s*JSON\.parse\(String\.raw`', webpage, 'initial data', display_id)
|
||||||
display_id)['data']
|
video_id = traverse_obj(initial_data, ('initialData', 'properties', 'videoId', {str}))
|
||||||
properties = page_data.get('properties') or {}
|
if not video_id: # All locked videos are now DRM-protected
|
||||||
query = {
|
self.report_drm(display_id)
|
||||||
'mbr': 'true',
|
account_id = initial_data['config']['brightcove']['accountId']
|
||||||
'manifest': 'm3u',
|
player_id = initial_data['config']['brightcove']['playerId']
|
||||||
}
|
|
||||||
|
|
||||||
video_player_count = 0
|
return self.url_result(
|
||||||
try:
|
f'https://players.brightcove.net/{account_id}/{player_id}_default/index.html?videoId={video_id}',
|
||||||
for v in page_data['children']:
|
BrightcoveNewIE, video_id)
|
||||||
if v.get('type') == 'video-player':
|
|
||||||
release_pid = v['properties']['currentVideo']['meta']['releasePid']
|
|
||||||
tp_path = 'M_UwQC/' + release_pid
|
|
||||||
media_url = 'https://link.theplatform.com/s/' + tp_path
|
|
||||||
video_player_count += 1
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if video_player_count > 1:
|
|
||||||
self.report_warning(
|
|
||||||
f'The JSON data has {video_player_count} video players. Only one will be extracted')
|
|
||||||
|
|
||||||
# Fall back to videoPid if releasePid not found.
|
|
||||||
# TODO: Fall back to videoPid if releasePid manifest uses DRM.
|
|
||||||
if not video_player_count:
|
|
||||||
tp_path = 'M_UwQC/media/' + properties['videoPid']
|
|
||||||
media_url = 'https://link.theplatform.com/s/' + tp_path
|
|
||||||
|
|
||||||
theplatform_metadata = self._download_theplatform_metadata(tp_path, display_id)
|
|
||||||
info = self._parse_theplatform_metadata(theplatform_metadata)
|
|
||||||
video_id = theplatform_metadata['pid']
|
|
||||||
title = theplatform_metadata['title']
|
|
||||||
rating = try_get(
|
|
||||||
theplatform_metadata, lambda x: x['ratings'][0]['rating'])
|
|
||||||
video_category = properties.get('videoCategory')
|
|
||||||
if video_category and video_category.endswith('-Auth'):
|
|
||||||
resource = self._get_mvpd_resource(
|
|
||||||
requestor_id, title, video_id, rating)
|
|
||||||
query['auth'] = self._extract_mvpd_auth(
|
|
||||||
url, video_id, requestor_id, resource)
|
|
||||||
media_url = update_url_query(media_url, query)
|
|
||||||
formats, subtitles = self._extract_theplatform_smil(
|
|
||||||
media_url, video_id)
|
|
||||||
|
|
||||||
thumbnails = []
|
|
||||||
thumbnail_urls = [properties.get('imageDesktop')]
|
|
||||||
if 'thumbnail' in info:
|
|
||||||
thumbnail_urls.append(info.pop('thumbnail'))
|
|
||||||
for thumbnail_url in thumbnail_urls:
|
|
||||||
if not thumbnail_url:
|
|
||||||
continue
|
|
||||||
mobj = re.search(r'(\d+)x(\d+)', thumbnail_url)
|
|
||||||
thumbnails.append({
|
|
||||||
'url': thumbnail_url,
|
|
||||||
'width': int(mobj.group(1)) if mobj else None,
|
|
||||||
'height': int(mobj.group(2)) if mobj else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
info.update({
|
|
||||||
'age_limit': parse_age_limit(rating),
|
|
||||||
'formats': formats,
|
|
||||||
'id': video_id,
|
|
||||||
'subtitles': subtitles,
|
|
||||||
'thumbnails': thumbnails,
|
|
||||||
})
|
|
||||||
ns_keys = theplatform_metadata.get('$xmlns', {}).keys()
|
|
||||||
if ns_keys:
|
|
||||||
ns = next(iter(ns_keys))
|
|
||||||
episode = theplatform_metadata.get(ns + '$episodeTitle') or None
|
|
||||||
episode_number = int_or_none(
|
|
||||||
theplatform_metadata.get(ns + '$episode'))
|
|
||||||
season_number = int_or_none(
|
|
||||||
theplatform_metadata.get(ns + '$season'))
|
|
||||||
series = theplatform_metadata.get(ns + '$show') or None
|
|
||||||
info.update({
|
|
||||||
'episode': episode,
|
|
||||||
'episode_number': episode_number,
|
|
||||||
'season_number': season_number,
|
|
||||||
'series': series,
|
|
||||||
})
|
|
||||||
return info
|
|
||||||
|
|||||||
@@ -1,64 +1,105 @@
|
|||||||
|
import urllib.parse
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..networking.exceptions import HTTPError
|
from ..networking.exceptions import HTTPError
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
|
parse_age_limit,
|
||||||
|
url_or_none,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class AtresPlayerIE(InfoExtractor):
|
class AtresPlayerIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:www\.)?atresplayer\.com/[^/]+/[^/]+/[^/]+/[^/]+/(?P<display_id>.+?)_(?P<id>[0-9a-f]{24})'
|
_VALID_URL = r'https?://(?:www\.)?atresplayer\.com/(?:[^/?#]+/){4}(?P<display_id>.+?)_(?P<id>[0-9a-f]{24})'
|
||||||
_NETRC_MACHINE = 'atresplayer'
|
_NETRC_MACHINE = 'atresplayer'
|
||||||
_TESTS = [
|
_TESTS = [{
|
||||||
{
|
'url': 'https://www.atresplayer.com/lasexta/programas/el-objetivo/clips/mbappe-describe-como-entrenador-a-carlo-ancelotti-sabe-cuando-tiene-que-ser-padre-jefe-amigo-entrenador_67f2dfb2fb6ab0e4c7203849/',
|
||||||
'url': 'https://www.atresplayer.com/antena3/series/pequenas-coincidencias/temporada-1/capitulo-7-asuntos-pendientes_5d4aa2c57ed1a88fc715a615/',
|
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '5d4aa2c57ed1a88fc715a615',
|
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Capítulo 7: Asuntos pendientes',
|
'id': '67f2dfb2fb6ab0e4c7203849',
|
||||||
'description': 'md5:7634cdcb4d50d5381bedf93efb537fbc',
|
'display_id': 'md5:c203f8d4e425ed115ba56a1c6e4b3e6c',
|
||||||
'duration': 3413,
|
'title': 'Mbappé describe como entrenador a Carlo Ancelotti: "Sabe cuándo tiene que ser padre, jefe, amigo, entrenador..."',
|
||||||
|
'channel': 'laSexta',
|
||||||
|
'duration': 31,
|
||||||
|
'thumbnail': 'https://imagenes.atresplayer.com/atp/clipping/cmsimages02/2025/04/06/B02DBE1E-D59B-4683-8404-1A9595D15269/1920x1080.jpg',
|
||||||
|
'tags': ['Entrevista informativa', 'Actualidad', 'Debate informativo', 'Política', 'Economía', 'Sociedad', 'Cara a cara', 'Análisis', 'Más periodismo'],
|
||||||
|
'series': 'El Objetivo',
|
||||||
|
'season': 'Temporada 12',
|
||||||
|
'timestamp': 1743970079,
|
||||||
|
'upload_date': '20250406',
|
||||||
},
|
},
|
||||||
'skip': 'This video is only available for registered users',
|
}, {
|
||||||
|
'url': 'https://www.atresplayer.com/antena3/programas/el-hormiguero/clips/revive-la-entrevista-completa-a-miguel-bose-en-el-hormiguero_67f836baa4a5b0e4147ca59a/',
|
||||||
|
'info_dict': {
|
||||||
|
'ext': 'mp4',
|
||||||
|
'id': '67f836baa4a5b0e4147ca59a',
|
||||||
|
'display_id': 'revive-la-entrevista-completa-a-miguel-bose-en-el-hormiguero',
|
||||||
|
'title': 'Revive la entrevista completa a Miguel Bosé en El Hormiguero',
|
||||||
|
'description': 'md5:c6d2b591408d45a7bc2986dfb938eb72',
|
||||||
|
'channel': 'Antena 3',
|
||||||
|
'duration': 2556,
|
||||||
|
'thumbnail': 'https://imagenes.atresplayer.com/atp/clipping/cmsimages02/2025/04/10/9076395F-F1FD-48BE-9F18-540DBA10EBAD/1920x1080.jpg',
|
||||||
|
'tags': ['Entrevista', 'Variedades', 'Humor', 'Entretenimiento', 'Te sigo', 'Buen rollo', 'Cara a cara'],
|
||||||
|
'series': 'El Hormiguero ',
|
||||||
|
'season': 'Temporada 14',
|
||||||
|
'timestamp': 1744320111,
|
||||||
|
'upload_date': '20250410',
|
||||||
},
|
},
|
||||||
{
|
}, {
|
||||||
|
'url': 'https://www.atresplayer.com/flooxer/series/biara-proyecto-lazarus/temporada-1/capitulo-3-supervivientes_67a6038b64ceca00070f4f69/',
|
||||||
|
'info_dict': {
|
||||||
|
'ext': 'mp4',
|
||||||
|
'id': '67a6038b64ceca00070f4f69',
|
||||||
|
'display_id': 'capitulo-3-supervivientes',
|
||||||
|
'title': 'Capítulo 3: Supervivientes',
|
||||||
|
'description': 'md5:65b231f20302f776c2b0dd24594599a1',
|
||||||
|
'channel': 'Flooxer',
|
||||||
|
'duration': 1196,
|
||||||
|
'thumbnail': 'https://imagenes.atresplayer.com/atp/clipping/cmsimages01/2025/02/14/17CF90D3-FE67-40C5-A941-7825B3E13992/1920x1080.jpg',
|
||||||
|
'tags': ['Juvenil', 'Terror', 'Piel de gallina', 'Te sigo', 'Un break', 'Del tirón'],
|
||||||
|
'series': 'BIARA: Proyecto Lázarus',
|
||||||
|
'season': 'Temporada 1',
|
||||||
|
'season_number': 1,
|
||||||
|
'episode': 'Episode 3',
|
||||||
|
'episode_number': 3,
|
||||||
|
'timestamp': 1743095191,
|
||||||
|
'upload_date': '20250327',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
'url': 'https://www.atresplayer.com/lasexta/programas/el-club-de-la-comedia/temporada-4/capitulo-10-especial-solidario-nochebuena_5ad08edf986b2855ed47adc4/',
|
'url': 'https://www.atresplayer.com/lasexta/programas/el-club-de-la-comedia/temporada-4/capitulo-10-especial-solidario-nochebuena_5ad08edf986b2855ed47adc4/',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
'url': 'https://www.atresplayer.com/antena3/series/el-secreto-de-puente-viejo/el-chico-de-los-tres-lunares/capitulo-977-29-12-14_5ad51046986b2886722ccdea/',
|
'url': 'https://www.atresplayer.com/antena3/series/el-secreto-de-puente-viejo/el-chico-de-los-tres-lunares/capitulo-977-29-12-14_5ad51046986b2886722ccdea/',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
},
|
}]
|
||||||
]
|
|
||||||
_API_BASE = 'https://api.atresplayer.com/'
|
_API_BASE = 'https://api.atresplayer.com/'
|
||||||
|
|
||||||
def _perform_login(self, username, password):
|
def _perform_login(self, username, password):
|
||||||
self._request_webpage(
|
|
||||||
self._API_BASE + 'login', None, 'Downloading login page')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_url = self._download_json(
|
self._download_webpage(
|
||||||
'https://account.atresmedia.com/api/login', None,
|
'https://account.atresplayer.com/auth/v1/login', None,
|
||||||
'Logging in', headers={
|
'Logging in', 'Failed to log in', data=urlencode_postdata({
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
}, data=urlencode_postdata({
|
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
}))['targetUrl']
|
}))
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
if isinstance(e.cause, HTTPError) and e.cause.status == 400:
|
if isinstance(e.cause, HTTPError) and e.cause.status == 400:
|
||||||
raise ExtractorError('Invalid username and/or password', expected=True)
|
raise ExtractorError('Invalid username and/or password', expected=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self._request_webpage(target_url, None, 'Following Target URL')
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id, video_id = self._match_valid_url(url).groups()
|
display_id, video_id = self._match_valid_url(url).groups()
|
||||||
|
|
||||||
|
metadata_url = self._download_json(
|
||||||
|
self._API_BASE + 'client/v1/url', video_id, 'Downloading API endpoint data',
|
||||||
|
query={'href': urllib.parse.urlparse(url).path})['href']
|
||||||
|
metadata = self._download_json(metadata_url, video_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
episode = self._download_json(
|
video_data = self._download_json(metadata['urlVideo'], video_id, 'Downloading video data')
|
||||||
self._API_BASE + 'client/v1/player/episode/' + video_id, video_id)
|
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
if isinstance(e.cause, HTTPError) and e.cause.status == 403:
|
if isinstance(e.cause, HTTPError) and e.cause.status == 403:
|
||||||
error = self._parse_json(e.cause.response.read(), None)
|
error = self._parse_json(e.cause.response.read(), None)
|
||||||
@@ -67,37 +108,45 @@ def _real_extract(self, url):
|
|||||||
raise ExtractorError(error['error_description'], expected=True)
|
raise ExtractorError(error['error_description'], expected=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
title = episode['titulo']
|
|
||||||
|
|
||||||
formats = []
|
formats = []
|
||||||
subtitles = {}
|
subtitles = {}
|
||||||
for source in episode.get('sources', []):
|
for source in traverse_obj(video_data, ('sources', lambda _, v: url_or_none(v['src']))):
|
||||||
src = source.get('src')
|
src_url = source['src']
|
||||||
if not src:
|
|
||||||
continue
|
|
||||||
src_type = source.get('type')
|
src_type = source.get('type')
|
||||||
if src_type == 'application/vnd.apple.mpegurl':
|
if src_type in ('application/vnd.apple.mpegurl', 'application/hls+legacy', 'application/hls+hevc'):
|
||||||
formats, subtitles = self._extract_m3u8_formats(
|
fmts, subs = self._extract_m3u8_formats_and_subtitles(
|
||||||
src, video_id, 'mp4', 'm3u8_native',
|
src_url, video_id, 'mp4', m3u8_id='hls', fatal=False)
|
||||||
m3u8_id='hls', fatal=False)
|
elif src_type in ('application/dash+xml', 'application/dash+hevc'):
|
||||||
elif src_type == 'application/dash+xml':
|
fmts, subs = self._extract_mpd_formats_and_subtitles(
|
||||||
formats, subtitles = self._extract_mpd_formats(
|
src_url, video_id, mpd_id='dash', fatal=False)
|
||||||
src, video_id, mpd_id='dash', fatal=False)
|
else:
|
||||||
|
continue
|
||||||
heartbeat = episode.get('heartbeat') or {}
|
formats.extend(fmts)
|
||||||
omniture = episode.get('omniture') or {}
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
get_meta = lambda x: heartbeat.get(x) or omniture.get(x)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'display_id': display_id,
|
'display_id': display_id,
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': title,
|
|
||||||
'description': episode.get('descripcion'),
|
|
||||||
'thumbnail': episode.get('imgPoster'),
|
|
||||||
'duration': int_or_none(episode.get('duration')),
|
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'channel': get_meta('channel'),
|
|
||||||
'season': get_meta('season'),
|
|
||||||
'episode_number': int_or_none(get_meta('episodeNumber')),
|
|
||||||
'subtitles': subtitles,
|
'subtitles': subtitles,
|
||||||
|
**traverse_obj(video_data, {
|
||||||
|
'title': ('titulo', {str}),
|
||||||
|
'description': ('descripcion', {str}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'thumbnail': ('imgPoster', {url_or_none}, {lambda v: f'{v}1920x1080.jpg'}),
|
||||||
|
'age_limit': ('ageRating', {parse_age_limit}),
|
||||||
|
}),
|
||||||
|
**traverse_obj(metadata, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'description': ('description', {str}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'tags': ('tags', ..., 'title', {str}),
|
||||||
|
'age_limit': ('ageRating', {parse_age_limit}),
|
||||||
|
'series': ('format', 'title', {str}),
|
||||||
|
'season': ('currentSeason', 'title', {str}),
|
||||||
|
'season_number': ('currentSeason', 'seasonNumber', {int_or_none}),
|
||||||
|
'episode_number': ('numberOfEpisode', {int_or_none}),
|
||||||
|
'timestamp': ('publicationDate', {int_or_none(scale=1000)}),
|
||||||
|
'channel': ('channel', 'title', {str}),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -816,6 +816,26 @@ class BiliBiliBangumiIE(BilibiliBaseIE):
|
|||||||
'upload_date': '20111104',
|
'upload_date': '20111104',
|
||||||
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
|
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
'note': 'new playurlSSRData scheme',
|
||||||
|
'url': 'https://www.bilibili.com/bangumi/play/ep678060',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '678060',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'series': '去你家吃饭好吗',
|
||||||
|
'series_id': '6198',
|
||||||
|
'season': '第二季',
|
||||||
|
'season_id': '42542',
|
||||||
|
'season_number': 2,
|
||||||
|
'episode': '吴老二:你家大公鸡养不熟,能煮熟吗…',
|
||||||
|
'episode_id': '678060',
|
||||||
|
'episode_number': 61,
|
||||||
|
'title': '一只小九九丫 吴老二:你家大公鸡养不熟,能煮熟吗…',
|
||||||
|
'duration': 266.123,
|
||||||
|
'timestamp': 1663315904,
|
||||||
|
'upload_date': '20220916',
|
||||||
|
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.bilibili.com/bangumi/play/ep267851',
|
'url': 'https://www.bilibili.com/bangumi/play/ep267851',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -879,13 +899,27 @@ def _real_extract(self, url):
|
|||||||
'Extracting episode', query={'fnval': 12240, 'ep_id': episode_id},
|
'Extracting episode', query={'fnval': 12240, 'ep_id': episode_id},
|
||||||
headers=headers))
|
headers=headers))
|
||||||
|
|
||||||
|
geo_blocked = traverse_obj(play_info, (
|
||||||
|
'raw', 'data', 'plugins', lambda _, v: v['name'] == 'AreaLimitPanel', 'config', 'is_block', {bool}, any))
|
||||||
premium_only = play_info.get('code') == -10403
|
premium_only = play_info.get('code') == -10403
|
||||||
play_info = traverse_obj(play_info, ('result', 'video_info', {dict})) or {}
|
|
||||||
|
|
||||||
formats = self.extract_formats(play_info)
|
video_info = traverse_obj(play_info, (('result', ('raw', 'data')), 'video_info', {dict}, any)) or {}
|
||||||
if not formats and (premium_only or '成为大会员抢先看' in webpage or '开通大会员观看' in webpage):
|
formats = self.extract_formats(video_info)
|
||||||
|
|
||||||
|
if not formats:
|
||||||
|
if geo_blocked:
|
||||||
|
self.raise_geo_restricted()
|
||||||
|
elif premium_only or '成为大会员抢先看' in webpage or '开通大会员观看' in webpage:
|
||||||
self.raise_login_required('This video is for premium members only')
|
self.raise_login_required('This video is for premium members only')
|
||||||
|
|
||||||
|
if traverse_obj(play_info, ((
|
||||||
|
('result', 'play_check', 'play_detail'), # 'PLAY_PREVIEW' vs 'PLAY_WHOLE'
|
||||||
|
('raw', 'data', 'play_video_type'), # 'preview' vs 'whole'
|
||||||
|
), any, {lambda x: x in ('PLAY_PREVIEW', 'preview')})):
|
||||||
|
self.report_warning(
|
||||||
|
'Only preview format is available, '
|
||||||
|
f'you have to become a premium member to access full video. {self._login_hint()}')
|
||||||
|
|
||||||
bangumi_info = self._download_json(
|
bangumi_info = self._download_json(
|
||||||
'https://api.bilibili.com/pgc/view/web/season', episode_id, 'Get episode details',
|
'https://api.bilibili.com/pgc/view/web/season', episode_id, 'Get episode details',
|
||||||
query={'ep_id': episode_id}, headers=headers)['result']
|
query={'ep_id': episode_id}, headers=headers)['result']
|
||||||
@@ -922,7 +956,7 @@ def _real_extract(self, url):
|
|||||||
'season': str_or_none(season_title),
|
'season': str_or_none(season_title),
|
||||||
'season_id': str_or_none(season_id),
|
'season_id': str_or_none(season_id),
|
||||||
'season_number': season_number,
|
'season_number': season_number,
|
||||||
'duration': float_or_none(play_info.get('timelength'), scale=1000),
|
'duration': float_or_none(video_info.get('timelength'), scale=1000),
|
||||||
'subtitles': self.extract_subtitles(episode_id, episode_info.get('cid'), aid=aid),
|
'subtitles': self.extract_subtitles(episode_id, episode_info.get('cid'), aid=aid),
|
||||||
'__post_extractor': self.extract_comments(aid),
|
'__post_extractor': self.extract_comments(aid),
|
||||||
'http_headers': {'Referer': url},
|
'http_headers': {'Referer': url},
|
||||||
@@ -1192,6 +1226,26 @@ class BilibiliSpaceVideoIE(BilibiliSpaceBaseIE):
|
|||||||
'id': '313580179',
|
'id': '313580179',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 92,
|
'playlist_mincount': 92,
|
||||||
|
}, {
|
||||||
|
# Hidden-mode collection
|
||||||
|
'url': 'https://space.bilibili.com/3669403/video',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '3669403',
|
||||||
|
},
|
||||||
|
'playlist': [{
|
||||||
|
'info_dict': {
|
||||||
|
'_type': 'playlist',
|
||||||
|
'id': '3669403_3958082',
|
||||||
|
'title': '合集·直播回放',
|
||||||
|
'description': '',
|
||||||
|
'uploader': '月路Yuel',
|
||||||
|
'uploader_id': '3669403',
|
||||||
|
'timestamp': int,
|
||||||
|
'upload_date': str,
|
||||||
|
'thumbnail': str,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
'params': {'playlist_items': '7'},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
@@ -1248,7 +1302,13 @@ def get_metadata(page_data):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_entries(page_data):
|
def get_entries(page_data):
|
||||||
for entry in traverse_obj(page_data, ('list', 'vlist')) or []:
|
for entry in traverse_obj(page_data, ('list', 'vlist', ..., {dict})):
|
||||||
|
if traverse_obj(entry, ('meta', 'attribute')) == 156:
|
||||||
|
# hidden-mode collection doesn't show its videos in uploads; extract as playlist instead
|
||||||
|
yield self.url_result(
|
||||||
|
f'https://space.bilibili.com/{entry["mid"]}/lists/{entry["meta"]["id"]}?type=season',
|
||||||
|
BilibiliCollectionListIE, f'{entry["mid"]}_{entry["meta"]["id"]}')
|
||||||
|
else:
|
||||||
yield self.url_result(f'https://www.bilibili.com/video/{entry["bvid"]}', BiliBiliIE, entry['bvid'])
|
yield self.url_result(f'https://www.bilibili.com/video/{entry["bvid"]}', BiliBiliIE, entry['bvid'])
|
||||||
|
|
||||||
metadata, paged_list = self._extract_playlist(fetch_page, get_metadata, get_entries)
|
metadata, paged_list = self._extract_playlist(fetch_page, get_metadata, get_entries)
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
import functools
|
import functools
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..networking import HEADRequest
|
from ..networking import HEADRequest
|
||||||
|
from ..networking.exceptions import HTTPError
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
OnDemandPagedList,
|
OnDemandPagedList,
|
||||||
clean_html,
|
clean_html,
|
||||||
extract_attributes,
|
determine_ext,
|
||||||
|
format_field,
|
||||||
get_element_by_class,
|
get_element_by_class,
|
||||||
get_element_by_id,
|
|
||||||
get_element_html_by_class,
|
|
||||||
get_elements_html_by_class,
|
get_elements_html_by_class,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
orderedSet,
|
orderedSet,
|
||||||
parse_count,
|
parse_count,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
traverse_obj,
|
parse_iso8601,
|
||||||
unified_strdate,
|
url_or_none,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
urljoin,
|
urljoin,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class BitChuteIE(InfoExtractor):
|
class BitChuteIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:(?:www|old)\.)?bitchute\.com/(?:video|embed|torrent/[^/]+)/(?P<id>[^/?#&]+)'
|
_VALID_URL = r'https?://(?:(?:www|old)\.)?bitchute\.com/(?:video|embed|torrent/[^/?#]+)/(?P<id>[^/?#&]+)'
|
||||||
_EMBED_REGEX = [rf'<(?:script|iframe)[^>]+\bsrc=(["\'])(?P<url>{_VALID_URL})']
|
_EMBED_REGEX = [rf'<(?:script|iframe)[^>]+\bsrc=(["\'])(?P<url>{_VALID_URL})']
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.bitchute.com/video/UGlrF9o9b-Q/',
|
'url': 'https://www.bitchute.com/video/UGlrF9o9b-Q/',
|
||||||
@@ -34,12 +36,17 @@ class BitChuteIE(InfoExtractor):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'This is the first video on #BitChute !',
|
'title': 'This is the first video on #BitChute !',
|
||||||
'description': 'md5:a0337e7b1fe39e32336974af8173a034',
|
'description': 'md5:a0337e7b1fe39e32336974af8173a034',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:https?://.+/.+\.jpg$',
|
||||||
'uploader': 'BitChute',
|
'uploader': 'BitChute',
|
||||||
'upload_date': '20170103',
|
'upload_date': '20170103',
|
||||||
'uploader_url': 'https://www.bitchute.com/profile/I5NgtHZn9vPj/',
|
'uploader_url': 'https://www.bitchute.com/profile/I5NgtHZn9vPj/',
|
||||||
'channel': 'BitChute',
|
'channel': 'BitChute',
|
||||||
'channel_url': 'https://www.bitchute.com/channel/bitchute/',
|
'channel_url': 'https://www.bitchute.com/channel/bitchute/',
|
||||||
|
'uploader_id': 'I5NgtHZn9vPj',
|
||||||
|
'channel_id': '1VBwRfyNcKdX',
|
||||||
|
'view_count': int,
|
||||||
|
'duration': 16.0,
|
||||||
|
'timestamp': 1483425443,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
# test case: video with different channel and uploader
|
# test case: video with different channel and uploader
|
||||||
@@ -49,13 +56,18 @@ class BitChuteIE(InfoExtractor):
|
|||||||
'id': 'Yti_j9A-UZ4',
|
'id': 'Yti_j9A-UZ4',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Israel at War | Full Measure',
|
'title': 'Israel at War | Full Measure',
|
||||||
'description': 'md5:38cf7bc6f42da1a877835539111c69ef',
|
'description': 'md5:e60198b89971966d6030d22b3268f08f',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:https?://.+/.+\.jpg$',
|
||||||
'uploader': 'sharylattkisson',
|
'uploader': 'sharylattkisson',
|
||||||
'upload_date': '20231106',
|
'upload_date': '20231106',
|
||||||
'uploader_url': 'https://www.bitchute.com/profile/9K0kUWA9zmd9/',
|
'uploader_url': 'https://www.bitchute.com/profile/9K0kUWA9zmd9/',
|
||||||
'channel': 'Full Measure with Sharyl Attkisson',
|
'channel': 'Full Measure with Sharyl Attkisson',
|
||||||
'channel_url': 'https://www.bitchute.com/channel/sharylattkisson/',
|
'channel_url': 'https://www.bitchute.com/channel/sharylattkisson/',
|
||||||
|
'uploader_id': '9K0kUWA9zmd9',
|
||||||
|
'channel_id': 'NpdxoCRv3ZLb',
|
||||||
|
'view_count': int,
|
||||||
|
'duration': 554.0,
|
||||||
|
'timestamp': 1699296106,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
# video not downloadable in browser, but we can recover it
|
# video not downloadable in browser, but we can recover it
|
||||||
@@ -66,25 +78,21 @@ class BitChuteIE(InfoExtractor):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'filesize': 71537926,
|
'filesize': 71537926,
|
||||||
'title': 'STYXHEXENHAMMER666 - Election Fraud, Clinton 2020, EU Armies, and Gun Control',
|
'title': 'STYXHEXENHAMMER666 - Election Fraud, Clinton 2020, EU Armies, and Gun Control',
|
||||||
'description': 'md5:228ee93bd840a24938f536aeac9cf749',
|
'description': 'md5:2029c7c212ccd4b040f52bb2d036ef4e',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:https?://.+/.+\.jpg$',
|
||||||
'uploader': 'BitChute',
|
'uploader': 'BitChute',
|
||||||
'upload_date': '20181113',
|
'upload_date': '20181113',
|
||||||
'uploader_url': 'https://www.bitchute.com/profile/I5NgtHZn9vPj/',
|
'uploader_url': 'https://www.bitchute.com/profile/I5NgtHZn9vPj/',
|
||||||
'channel': 'BitChute',
|
'channel': 'BitChute',
|
||||||
'channel_url': 'https://www.bitchute.com/channel/bitchute/',
|
'channel_url': 'https://www.bitchute.com/channel/bitchute/',
|
||||||
|
'uploader_id': 'I5NgtHZn9vPj',
|
||||||
|
'channel_id': '1VBwRfyNcKdX',
|
||||||
|
'view_count': int,
|
||||||
|
'duration': 1701.0,
|
||||||
|
'tags': ['bitchute'],
|
||||||
|
'timestamp': 1542130287,
|
||||||
},
|
},
|
||||||
'params': {'check_formats': None},
|
'params': {'check_formats': None},
|
||||||
}, {
|
|
||||||
# restricted video
|
|
||||||
'url': 'https://www.bitchute.com/video/WEnQU7XGcTdl/',
|
|
||||||
'info_dict': {
|
|
||||||
'id': 'WEnQU7XGcTdl',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'Impartial Truth - Ein Letzter Appell an die Vernunft',
|
|
||||||
},
|
|
||||||
'params': {'skip_download': True},
|
|
||||||
'skip': 'Georestricted in DE',
|
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.bitchute.com/embed/lbb5G1hjPhw/',
|
'url': 'https://www.bitchute.com/embed/lbb5G1hjPhw/',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -96,11 +104,8 @@ class BitChuteIE(InfoExtractor):
|
|||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
_GEO_BYPASS = False
|
_GEO_BYPASS = False
|
||||||
|
_UPLOADER_URL_TMPL = 'https://www.bitchute.com/profile/%s/'
|
||||||
_HEADERS = {
|
_CHANNEL_URL_TMPL = 'https://www.bitchute.com/channel/%s/'
|
||||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.57 Safari/537.36',
|
|
||||||
'Referer': 'https://www.bitchute.com/',
|
|
||||||
}
|
|
||||||
|
|
||||||
def _check_format(self, video_url, video_id):
|
def _check_format(self, video_url, video_id):
|
||||||
urls = orderedSet(
|
urls = orderedSet(
|
||||||
@@ -112,7 +117,7 @@ def _check_format(self, video_url, video_id):
|
|||||||
for url in urls:
|
for url in urls:
|
||||||
try:
|
try:
|
||||||
response = self._request_webpage(
|
response = self._request_webpage(
|
||||||
HEADRequest(url), video_id=video_id, note=f'Checking {url}', headers=self._HEADERS)
|
HEADRequest(url), video_id=video_id, note=f'Checking {url}')
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
self.to_screen(f'{video_id}: URL is invalid, skipping: {e.cause}')
|
self.to_screen(f'{video_id}: URL is invalid, skipping: {e.cause}')
|
||||||
continue
|
continue
|
||||||
@@ -121,54 +126,79 @@ def _check_format(self, video_url, video_id):
|
|||||||
'filesize': int_or_none(response.headers.get('Content-Length')),
|
'filesize': int_or_none(response.headers.get('Content-Length')),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _raise_if_restricted(self, webpage):
|
def _call_api(self, endpoint, data, display_id, fatal=True):
|
||||||
page_title = clean_html(get_element_by_class('page-title', webpage)) or ''
|
note = endpoint.rpartition('/')[2]
|
||||||
if re.fullmatch(r'(?:Channel|Video) Restricted', page_title):
|
try:
|
||||||
reason = clean_html(get_element_by_id('page-detail', webpage)) or page_title
|
return self._download_json(
|
||||||
self.raise_geo_restricted(reason)
|
f'https://api.bitchute.com/api/beta/{endpoint}', display_id,
|
||||||
|
f'Downloading {note} API JSON', f'Unable to download {note} API JSON',
|
||||||
@staticmethod
|
data=json.dumps(data).encode(),
|
||||||
def _make_url(html):
|
headers={
|
||||||
path = extract_attributes(get_element_html_by_class('spa', html) or '').get('href')
|
'Accept': 'application/json',
|
||||||
return urljoin('https://www.bitchute.com', path)
|
'Content-Type': 'application/json',
|
||||||
|
})
|
||||||
|
except ExtractorError as e:
|
||||||
|
if isinstance(e.cause, HTTPError) and e.cause.status == 403:
|
||||||
|
errors = '. '.join(traverse_obj(e.cause.response.read().decode(), (
|
||||||
|
{json.loads}, 'errors', lambda _, v: v['context'] == 'reason', 'message', {str})))
|
||||||
|
if errors and 'location' in errors:
|
||||||
|
# Can always be fatal since the video/media call will reach this code first
|
||||||
|
self.raise_geo_restricted(errors)
|
||||||
|
if fatal:
|
||||||
|
raise
|
||||||
|
self.report_warning(e.msg)
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
webpage = self._download_webpage(
|
data = {'video_id': video_id}
|
||||||
f'https://old.bitchute.com/video/{video_id}', video_id, headers=self._HEADERS)
|
media_url = self._call_api('video/media', data, video_id)['media_url']
|
||||||
|
|
||||||
self._raise_if_restricted(webpage)
|
|
||||||
publish_date = clean_html(get_element_by_class('video-publish-date', webpage))
|
|
||||||
entries = self._parse_html5_media_entries(url, webpage, video_id)
|
|
||||||
|
|
||||||
formats = []
|
formats = []
|
||||||
for format_ in traverse_obj(entries, (0, 'formats', ...)):
|
if determine_ext(media_url) == 'm3u8':
|
||||||
|
formats.extend(
|
||||||
|
self._extract_m3u8_formats(media_url, video_id, 'mp4', m3u8_id='hls', live=True))
|
||||||
|
else:
|
||||||
if self.get_param('check_formats') is not False:
|
if self.get_param('check_formats') is not False:
|
||||||
format_.update(self._check_format(format_.pop('url'), video_id) or {})
|
if fmt := self._check_format(media_url, video_id):
|
||||||
if 'url' not in format_:
|
formats.append(fmt)
|
||||||
continue
|
else:
|
||||||
formats.append(format_)
|
formats.append({'url': media_url})
|
||||||
|
|
||||||
if not formats:
|
if not formats:
|
||||||
self.raise_no_formats(
|
self.raise_no_formats(
|
||||||
'Video is unavailable. Please make sure this video is playable in the browser '
|
'Video is unavailable. Please make sure this video is playable in the browser '
|
||||||
'before reporting this issue.', expected=True, video_id=video_id)
|
'before reporting this issue.', expected=True, video_id=video_id)
|
||||||
|
|
||||||
details = get_element_by_class('details', webpage) or ''
|
video = self._call_api('video', data, video_id, fatal=False)
|
||||||
uploader_html = get_element_html_by_class('creator', details) or ''
|
channel = None
|
||||||
channel_html = get_element_html_by_class('name', details) or ''
|
if channel_id := traverse_obj(video, ('channel', 'channel_id', {str})):
|
||||||
|
channel = self._call_api('channel', {'channel_id': channel_id}, video_id, fatal=False)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
**traverse_obj(video, {
|
||||||
|
'title': ('video_name', {str}),
|
||||||
|
'description': ('description', {str}),
|
||||||
|
'thumbnail': ('thumbnail_url', {url_or_none}),
|
||||||
|
'channel': ('channel', 'channel_name', {str}),
|
||||||
|
'channel_id': ('channel', 'channel_id', {str}),
|
||||||
|
'channel_url': ('channel', 'channel_url', {urljoin('https://www.bitchute.com/')}),
|
||||||
|
'uploader_id': ('profile_id', {str}),
|
||||||
|
'uploader_url': ('profile_id', {format_field(template=self._UPLOADER_URL_TMPL)}, filter),
|
||||||
|
'timestamp': ('date_published', {parse_iso8601}),
|
||||||
|
'duration': ('duration', {parse_duration}),
|
||||||
|
'tags': ('hashtags', ..., {str}, filter, all, filter),
|
||||||
|
'view_count': ('view_count', {int_or_none}),
|
||||||
|
'is_live': ('state_id', {lambda x: x == 'live'}),
|
||||||
|
}),
|
||||||
|
**traverse_obj(channel, {
|
||||||
|
'channel': ('channel_name', {str}),
|
||||||
|
'channel_id': ('channel_id', {str}),
|
||||||
|
'channel_url': ('url_slug', {format_field(template=self._CHANNEL_URL_TMPL)}, filter),
|
||||||
|
'uploader': ('profile_name', {str}),
|
||||||
|
'uploader_id': ('profile_id', {str}),
|
||||||
|
'uploader_url': ('profile_id', {format_field(template=self._UPLOADER_URL_TMPL)}, filter),
|
||||||
|
}),
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': self._html_extract_title(webpage) or self._og_search_title(webpage),
|
|
||||||
'description': self._og_search_description(webpage, default=None),
|
|
||||||
'thumbnail': self._og_search_thumbnail(webpage),
|
|
||||||
'uploader': clean_html(uploader_html),
|
|
||||||
'uploader_url': self._make_url(uploader_html),
|
|
||||||
'channel': clean_html(channel_html),
|
|
||||||
'channel_url': self._make_url(channel_html),
|
|
||||||
'upload_date': unified_strdate(self._search_regex(
|
|
||||||
r'at \d+:\d+ UTC on (.+?)\.', publish_date, 'upload date', fatal=False)),
|
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +220,7 @@ class BitChuteChannelIE(InfoExtractor):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'This is the first video on #BitChute !',
|
'title': 'This is the first video on #BitChute !',
|
||||||
'description': 'md5:a0337e7b1fe39e32336974af8173a034',
|
'description': 'md5:a0337e7b1fe39e32336974af8173a034',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:https?://.+/.+\.jpg$',
|
||||||
'uploader': 'BitChute',
|
'uploader': 'BitChute',
|
||||||
'upload_date': '20170103',
|
'upload_date': '20170103',
|
||||||
'uploader_url': 'https://www.bitchute.com/profile/I5NgtHZn9vPj/',
|
'uploader_url': 'https://www.bitchute.com/profile/I5NgtHZn9vPj/',
|
||||||
@@ -198,6 +228,9 @@ class BitChuteChannelIE(InfoExtractor):
|
|||||||
'channel_url': 'https://www.bitchute.com/channel/bitchute/',
|
'channel_url': 'https://www.bitchute.com/channel/bitchute/',
|
||||||
'duration': 16,
|
'duration': 16,
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
|
'uploader_id': 'I5NgtHZn9vPj',
|
||||||
|
'channel_id': '1VBwRfyNcKdX',
|
||||||
|
'timestamp': 1483425443,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -213,6 +246,7 @@ class BitChuteChannelIE(InfoExtractor):
|
|||||||
'title': 'Bruce MacDonald and "The Light of Darkness"',
|
'title': 'Bruce MacDonald and "The Light of Darkness"',
|
||||||
'description': 'md5:747724ef404eebdfc04277714f81863e',
|
'description': 'md5:747724ef404eebdfc04277714f81863e',
|
||||||
},
|
},
|
||||||
|
'skip': '404 Not Found',
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://old.bitchute.com/playlist/wV9Imujxasw9/',
|
'url': 'https://old.bitchute.com/playlist/wV9Imujxasw9/',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
join_nonempty,
|
join_nonempty,
|
||||||
js_to_json,
|
js_to_json,
|
||||||
mimetype2ext,
|
mimetype2ext,
|
||||||
|
parse_resolution,
|
||||||
unified_strdate,
|
unified_strdate,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
urljoin,
|
urljoin,
|
||||||
@@ -110,24 +111,23 @@ def _parse_vue_attributes(self, name, string, video_id):
|
|||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
|
|
||||||
@staticmethod
|
def _process_source(self, source):
|
||||||
def _process_source(source):
|
|
||||||
url = url_or_none(source['src'])
|
url = url_or_none(source['src'])
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
source_type = source.get('type', '')
|
source_type = source.get('type', '')
|
||||||
extension = mimetype2ext(source_type)
|
extension = mimetype2ext(source_type)
|
||||||
is_video = source_type.startswith('video')
|
note = self._search_regex(r'[_-]([a-z]+)\.[\da-z]+(?:$|\?)', url, 'note', default=None)
|
||||||
note = url.rpartition('.')[0].rpartition('_')[2] if is_video else None
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'url': url,
|
'url': url,
|
||||||
'ext': extension,
|
'ext': extension,
|
||||||
'vcodec': None if is_video else 'none',
|
'vcodec': None if source_type.startswith('video') else 'none',
|
||||||
'quality': 10 if note == 'high' else 0,
|
'quality': 10 if note == 'high' else 0,
|
||||||
'format_note': note,
|
'format_note': note,
|
||||||
'format_id': join_nonempty(extension, note),
|
'format_id': join_nonempty(extension, note),
|
||||||
|
**parse_resolution(source.get('label')),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
from .adobepass import AdobePassIE
|
|
||||||
from ..networking import HEADRequest
|
|
||||||
from ..utils import (
|
|
||||||
extract_attributes,
|
|
||||||
float_or_none,
|
|
||||||
get_element_html_by_class,
|
|
||||||
int_or_none,
|
|
||||||
merge_dicts,
|
|
||||||
parse_age_limit,
|
|
||||||
remove_end,
|
|
||||||
str_or_none,
|
|
||||||
traverse_obj,
|
|
||||||
unescapeHTML,
|
|
||||||
unified_timestamp,
|
|
||||||
update_url_query,
|
|
||||||
url_or_none,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BravoTVIE(AdobePassIE):
|
|
||||||
_VALID_URL = r'https?://(?:www\.)?(?P<site>bravotv|oxygen)\.com/(?:[^/]+/)+(?P<id>[^/?#]+)'
|
|
||||||
_TESTS = [{
|
|
||||||
'url': 'https://www.bravotv.com/top-chef/season-16/episode-15/videos/the-top-chef-season-16-winner-is',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3923059',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'The Top Chef Season 16 Winner Is...',
|
|
||||||
'description': 'Find out who takes the title of Top Chef!',
|
|
||||||
'upload_date': '20190314',
|
|
||||||
'timestamp': 1552591860,
|
|
||||||
'season_number': 16,
|
|
||||||
'episode_number': 15,
|
|
||||||
'series': 'Top Chef',
|
|
||||||
'episode': 'The Top Chef Season 16 Winner Is...',
|
|
||||||
'duration': 190.357,
|
|
||||||
'season': 'Season 16',
|
|
||||||
'thumbnail': r're:^https://.+\.jpg',
|
|
||||||
},
|
|
||||||
'params': {'skip_download': 'm3u8'},
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.bravotv.com/top-chef/season-20/episode-1/london-calling',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '9000234570',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'London Calling',
|
|
||||||
'description': 'md5:5af95a8cbac1856bd10e7562f86bb759',
|
|
||||||
'upload_date': '20230310',
|
|
||||||
'timestamp': 1678410000,
|
|
||||||
'season_number': 20,
|
|
||||||
'episode_number': 1,
|
|
||||||
'series': 'Top Chef',
|
|
||||||
'episode': 'London Calling',
|
|
||||||
'duration': 3266.03,
|
|
||||||
'season': 'Season 20',
|
|
||||||
'chapters': 'count:7',
|
|
||||||
'thumbnail': r're:^https://.+\.jpg',
|
|
||||||
'age_limit': 14,
|
|
||||||
},
|
|
||||||
'params': {'skip_download': 'm3u8'},
|
|
||||||
'skip': 'This video requires AdobePass MSO credentials',
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.oxygen.com/in-ice-cold-blood/season-1/closing-night',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3692045',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'Closing Night',
|
|
||||||
'description': 'md5:3170065c5c2f19548d72a4cbc254af63',
|
|
||||||
'upload_date': '20180401',
|
|
||||||
'timestamp': 1522623600,
|
|
||||||
'season_number': 1,
|
|
||||||
'episode_number': 1,
|
|
||||||
'series': 'In Ice Cold Blood',
|
|
||||||
'episode': 'Closing Night',
|
|
||||||
'duration': 2629.051,
|
|
||||||
'season': 'Season 1',
|
|
||||||
'chapters': 'count:6',
|
|
||||||
'thumbnail': r're:^https://.+\.jpg',
|
|
||||||
'age_limit': 14,
|
|
||||||
},
|
|
||||||
'params': {'skip_download': 'm3u8'},
|
|
||||||
'skip': 'This video requires AdobePass MSO credentials',
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.oxygen.com/in-ice-cold-blood/season-2/episode-16/videos/handling-the-horwitz-house-after-the-murder-season-2',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3974019',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': '\'Handling The Horwitz House After The Murder (Season 2, Episode 16)',
|
|
||||||
'description': 'md5:f9d638dd6946a1c1c0533a9c6100eae5',
|
|
||||||
'upload_date': '20190617',
|
|
||||||
'timestamp': 1560790800,
|
|
||||||
'season_number': 2,
|
|
||||||
'episode_number': 16,
|
|
||||||
'series': 'In Ice Cold Blood',
|
|
||||||
'episode': '\'Handling The Horwitz House After The Murder (Season 2, Episode 16)',
|
|
||||||
'duration': 68.235,
|
|
||||||
'season': 'Season 2',
|
|
||||||
'thumbnail': r're:^https://.+\.jpg',
|
|
||||||
'age_limit': 14,
|
|
||||||
},
|
|
||||||
'params': {'skip_download': 'm3u8'},
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.bravotv.com/below-deck/season-3/ep-14-reunion-part-1',
|
|
||||||
'only_matching': True,
|
|
||||||
}]
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
site, display_id = self._match_valid_url(url).group('site', 'id')
|
|
||||||
webpage = self._download_webpage(url, display_id)
|
|
||||||
settings = self._search_json(
|
|
||||||
r'<script[^>]+data-drupal-selector="drupal-settings-json"[^>]*>', webpage, 'settings', display_id)
|
|
||||||
tve = extract_attributes(get_element_html_by_class('tve-video-deck-app', webpage) or '')
|
|
||||||
query = {
|
|
||||||
'manifest': 'm3u',
|
|
||||||
'formats': 'm3u,mpeg4',
|
|
||||||
}
|
|
||||||
|
|
||||||
if tve:
|
|
||||||
account_pid = tve.get('data-mpx-media-account-pid') or 'HNK2IC'
|
|
||||||
account_id = tve['data-mpx-media-account-id']
|
|
||||||
metadata = self._parse_json(
|
|
||||||
tve.get('data-normalized-video', ''), display_id, fatal=False, transform_source=unescapeHTML)
|
|
||||||
video_id = tve.get('data-guid') or metadata['guid']
|
|
||||||
if tve.get('data-entitlement') == 'auth':
|
|
||||||
auth = traverse_obj(settings, ('tve_adobe_auth', {dict})) or {}
|
|
||||||
site = remove_end(site, 'tv')
|
|
||||||
release_pid = tve['data-release-pid']
|
|
||||||
resource = self._get_mvpd_resource(
|
|
||||||
tve.get('data-adobe-pass-resource-id') or auth.get('adobePassResourceId') or site,
|
|
||||||
tve['data-title'], release_pid, tve.get('data-rating'))
|
|
||||||
query.update({
|
|
||||||
'switch': 'HLSServiceSecure',
|
|
||||||
'auth': self._extract_mvpd_auth(
|
|
||||||
url, release_pid, auth.get('adobePassRequestorId') or site, resource),
|
|
||||||
})
|
|
||||||
|
|
||||||
else:
|
|
||||||
ls_playlist = traverse_obj(settings, ('ls_playlist', ..., {dict}), get_all=False) or {}
|
|
||||||
account_pid = ls_playlist.get('mpxMediaAccountPid') or 'PHSl-B'
|
|
||||||
account_id = ls_playlist['mpxMediaAccountId']
|
|
||||||
video_id = ls_playlist['defaultGuid']
|
|
||||||
metadata = traverse_obj(
|
|
||||||
ls_playlist, ('videos', lambda _, v: v['guid'] == video_id, {dict}), get_all=False)
|
|
||||||
|
|
||||||
tp_url = f'https://link.theplatform.com/s/{account_pid}/media/guid/{account_id}/{video_id}'
|
|
||||||
tp_metadata = self._download_json(
|
|
||||||
update_url_query(tp_url, {'format': 'preview'}), video_id, fatal=False)
|
|
||||||
|
|
||||||
chapters = traverse_obj(tp_metadata, ('chapters', ..., {
|
|
||||||
'start_time': ('startTime', {float_or_none(scale=1000)}),
|
|
||||||
'end_time': ('endTime', {float_or_none(scale=1000)}),
|
|
||||||
}))
|
|
||||||
# prune pointless single chapters that span the entire duration from short videos
|
|
||||||
if len(chapters) == 1 and not traverse_obj(chapters, (0, 'end_time')):
|
|
||||||
chapters = None
|
|
||||||
|
|
||||||
m3u8_url = self._request_webpage(HEADRequest(
|
|
||||||
update_url_query(f'{tp_url}/stream.m3u8', query)), video_id, 'Checking m3u8 URL').url
|
|
||||||
if 'mpeg_cenc' in m3u8_url:
|
|
||||||
self.report_drm(video_id)
|
|
||||||
formats, subtitles = self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, 'mp4', m3u8_id='hls')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'id': video_id,
|
|
||||||
'formats': formats,
|
|
||||||
'subtitles': subtitles,
|
|
||||||
'chapters': chapters,
|
|
||||||
**merge_dicts(traverse_obj(tp_metadata, {
|
|
||||||
'title': 'title',
|
|
||||||
'description': 'description',
|
|
||||||
'duration': ('duration', {float_or_none(scale=1000)}),
|
|
||||||
'timestamp': ('pubDate', {float_or_none(scale=1000)}),
|
|
||||||
'season_number': (('pl1$seasonNumber', 'nbcu$seasonNumber'), {int_or_none}),
|
|
||||||
'episode_number': (('pl1$episodeNumber', 'nbcu$episodeNumber'), {int_or_none}),
|
|
||||||
'series': (('pl1$show', 'nbcu$show'), (None, ...), {str}),
|
|
||||||
'episode': (('title', 'pl1$episodeNumber', 'nbcu$episodeNumber'), {str_or_none}),
|
|
||||||
'age_limit': ('ratings', ..., 'rating', {parse_age_limit}),
|
|
||||||
}, get_all=False), traverse_obj(metadata, {
|
|
||||||
'title': 'title',
|
|
||||||
'description': 'description',
|
|
||||||
'duration': ('durationInSeconds', {int_or_none}),
|
|
||||||
'timestamp': ('airDate', {unified_timestamp}),
|
|
||||||
'thumbnail': ('thumbnailUrl', {url_or_none}),
|
|
||||||
'season_number': ('seasonNumber', {int_or_none}),
|
|
||||||
'episode_number': ('episodeNumber', {int_or_none}),
|
|
||||||
'episode': 'episodeTitle',
|
|
||||||
'series': 'show',
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
@@ -495,8 +495,6 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
class BrightcoveNewBaseIE(AdobePassIE):
|
class BrightcoveNewBaseIE(AdobePassIE):
|
||||||
def _parse_brightcove_metadata(self, json_data, video_id, headers={}):
|
def _parse_brightcove_metadata(self, json_data, video_id, headers={}):
|
||||||
title = json_data['name'].strip()
|
|
||||||
|
|
||||||
formats, subtitles = [], {}
|
formats, subtitles = [], {}
|
||||||
sources = json_data.get('sources') or []
|
sources = json_data.get('sources') or []
|
||||||
for source in sources:
|
for source in sources:
|
||||||
@@ -600,16 +598,18 @@ def build_format_id(kind):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': title,
|
|
||||||
'description': clean_html(json_data.get('description')),
|
|
||||||
'thumbnails': thumbnails,
|
'thumbnails': thumbnails,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'timestamp': parse_iso8601(json_data.get('published_at')),
|
|
||||||
'uploader_id': json_data.get('account_id'),
|
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'subtitles': subtitles,
|
'subtitles': subtitles,
|
||||||
'tags': json_data.get('tags', []),
|
|
||||||
'is_live': is_live,
|
'is_live': is_live,
|
||||||
|
**traverse_obj(json_data, {
|
||||||
|
'title': ('name', {clean_html}),
|
||||||
|
'description': ('description', {clean_html}),
|
||||||
|
'tags': ('tags', ..., {str}, filter, all, filter),
|
||||||
|
'timestamp': ('published_at', {parse_iso8601}),
|
||||||
|
'uploader_id': ('account_id', {str}),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -645,10 +645,7 @@ class BrightcoveNewIE(BrightcoveNewBaseIE):
|
|||||||
'uploader_id': '4036320279001',
|
'uploader_id': '4036320279001',
|
||||||
'formats': 'mincount:39',
|
'formats': 'mincount:39',
|
||||||
},
|
},
|
||||||
'params': {
|
'skip': '404 Not Found',
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
# playlist stream
|
# playlist stream
|
||||||
'url': 'https://players.brightcove.net/1752604059001/S13cJdUBz_default/index.html?playlistId=5718313430001',
|
'url': 'https://players.brightcove.net/1752604059001/S13cJdUBz_default/index.html?playlistId=5718313430001',
|
||||||
@@ -709,7 +706,6 @@ class BrightcoveNewIE(BrightcoveNewBaseIE):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'TGD_01-032_5',
|
'title': 'TGD_01-032_5',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:^https?://.*\.jpg$',
|
||||||
'tags': [],
|
|
||||||
'timestamp': 1646078943,
|
'timestamp': 1646078943,
|
||||||
'uploader_id': '1569565978001',
|
'uploader_id': '1569565978001',
|
||||||
'upload_date': '20220228',
|
'upload_date': '20220228',
|
||||||
@@ -721,7 +717,6 @@ class BrightcoveNewIE(BrightcoveNewBaseIE):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'TGD 01-087 (Airs 05.25.22)_Segment 5',
|
'title': 'TGD 01-087 (Airs 05.25.22)_Segment 5',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:^https?://.*\.jpg$',
|
||||||
'tags': [],
|
|
||||||
'timestamp': 1651604591,
|
'timestamp': 1651604591,
|
||||||
'uploader_id': '1569565978001',
|
'uploader_id': '1569565978001',
|
||||||
'upload_date': '20220503',
|
'upload_date': '20220503',
|
||||||
@@ -923,10 +918,18 @@ def extract_policy_key():
|
|||||||
errors = json_data.get('errors')
|
errors = json_data.get('errors')
|
||||||
if errors and errors[0].get('error_subcode') == 'TVE_AUTH':
|
if errors and errors[0].get('error_subcode') == 'TVE_AUTH':
|
||||||
custom_fields = json_data['custom_fields']
|
custom_fields = json_data['custom_fields']
|
||||||
|
missing_fields = ', '.join(
|
||||||
|
key for key in ('source_url', 'software_statement') if not smuggled_data.get(key))
|
||||||
|
if missing_fields:
|
||||||
|
raise ExtractorError(
|
||||||
|
f'Missing fields in smuggled data: {missing_fields}. '
|
||||||
|
f'This video can be only extracted from the webpage where it is embedded. '
|
||||||
|
f'Pass the URL of the embedding webpage instead of the Brightcove URL', expected=True)
|
||||||
tve_token = self._extract_mvpd_auth(
|
tve_token = self._extract_mvpd_auth(
|
||||||
smuggled_data['source_url'], video_id,
|
smuggled_data['source_url'], video_id,
|
||||||
custom_fields['bcadobepassrequestorid'],
|
custom_fields['bcadobepassrequestorid'],
|
||||||
custom_fields['bcadobepassresourceid'])
|
custom_fields['bcadobepassresourceid'],
|
||||||
|
smuggled_data['software_statement'])
|
||||||
json_data = self._download_json(
|
json_data = self._download_json(
|
||||||
api_url, video_id, headers={
|
api_url, video_id, headers={
|
||||||
'Accept': f'application/json;pk={policy_key}',
|
'Accept': f'application/json;pk={policy_key}',
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
from .turner import TurnerBaseIE
|
|
||||||
from ..utils import int_or_none
|
|
||||||
|
|
||||||
|
|
||||||
class CartoonNetworkIE(TurnerBaseIE):
|
|
||||||
_VALID_URL = r'https?://(?:www\.)?cartoonnetwork\.com/video/(?:[^/]+/)+(?P<id>[^/?#]+)-(?:clip|episode)\.html'
|
|
||||||
_TEST = {
|
|
||||||
'url': 'https://www.cartoonnetwork.com/video/ben-10/how-to-draw-upgrade-episode.html',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '6e3375097f63874ebccec7ef677c1c3845fa850e',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'How to Draw Upgrade',
|
|
||||||
'description': 'md5:2061d83776db7e8be4879684eefe8c0f',
|
|
||||||
},
|
|
||||||
'params': {
|
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
display_id = self._match_id(url)
|
|
||||||
webpage = self._download_webpage(url, display_id)
|
|
||||||
|
|
||||||
def find_field(global_re, name, content_re=None, value_re='[^"]+', fatal=False):
|
|
||||||
metadata_re = ''
|
|
||||||
if content_re:
|
|
||||||
metadata_re = r'|video_metadata\.content_' + content_re
|
|
||||||
return self._search_regex(
|
|
||||||
rf'(?:_cnglobal\.currentVideo\.{global_re}{metadata_re})\s*=\s*"({value_re})";',
|
|
||||||
webpage, name, fatal=fatal)
|
|
||||||
|
|
||||||
media_id = find_field('mediaId', 'media id', 'id', '[0-9a-f]{40}', True)
|
|
||||||
title = find_field('episodeTitle', 'title', '(?:episodeName|name)', fatal=True)
|
|
||||||
|
|
||||||
info = self._extract_ngtv_info(
|
|
||||||
media_id, {'networkId': 'cartoonnetwork'}, {
|
|
||||||
'url': url,
|
|
||||||
'site_name': 'CartoonNetwork',
|
|
||||||
'auth_required': find_field('authType', 'auth type') != 'unauth',
|
|
||||||
})
|
|
||||||
|
|
||||||
series = find_field(
|
|
||||||
'propertyName', 'series', 'showName') or self._html_search_meta('partOfSeries', webpage)
|
|
||||||
info.update({
|
|
||||||
'id': media_id,
|
|
||||||
'display_id': display_id,
|
|
||||||
'title': title,
|
|
||||||
'description': self._html_search_meta('description', webpage),
|
|
||||||
'series': series,
|
|
||||||
'episode': title,
|
|
||||||
})
|
|
||||||
|
|
||||||
for field in ('season', 'episode'):
|
|
||||||
field_name = field + 'Number'
|
|
||||||
info[field + '_number'] = int_or_none(find_field(
|
|
||||||
field_name, field + ' number', value_re=r'\d+') or self._html_search_meta(field_name, webpage))
|
|
||||||
|
|
||||||
return info
|
|
||||||
@@ -13,16 +13,17 @@
|
|||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
OnDemandPagedList,
|
OnDemandPagedList,
|
||||||
|
determine_ext,
|
||||||
float_or_none,
|
float_or_none,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
merge_dicts,
|
merge_dicts,
|
||||||
multipart_encode,
|
multipart_encode,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
traverse_obj,
|
|
||||||
try_call,
|
try_call,
|
||||||
try_get,
|
url_or_none,
|
||||||
urljoin,
|
urljoin,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class CDAIE(InfoExtractor):
|
class CDAIE(InfoExtractor):
|
||||||
@@ -290,15 +291,16 @@ def extract_format(page, version):
|
|||||||
if not video or 'file' not in video:
|
if not video or 'file' not in video:
|
||||||
self.report_warning(f'Unable to extract {version} version information')
|
self.report_warning(f'Unable to extract {version} version information')
|
||||||
return
|
return
|
||||||
|
video_quality = video.get('quality')
|
||||||
|
qualities = video.get('qualities', {})
|
||||||
|
video_quality = next((k for k, v in qualities.items() if v == video_quality), video_quality)
|
||||||
|
if video.get('file'):
|
||||||
if video['file'].startswith('uggc'):
|
if video['file'].startswith('uggc'):
|
||||||
video['file'] = codecs.decode(video['file'], 'rot_13')
|
video['file'] = codecs.decode(video['file'], 'rot_13')
|
||||||
if video['file'].endswith('adc.mp4'):
|
if video['file'].endswith('adc.mp4'):
|
||||||
video['file'] = video['file'].replace('adc.mp4', '.mp4')
|
video['file'] = video['file'].replace('adc.mp4', '.mp4')
|
||||||
elif not video['file'].startswith('http'):
|
elif not video['file'].startswith('http'):
|
||||||
video['file'] = decrypt_file(video['file'])
|
video['file'] = decrypt_file(video['file'])
|
||||||
video_quality = video.get('quality')
|
|
||||||
qualities = video.get('qualities', {})
|
|
||||||
video_quality = next((k for k, v in qualities.items() if v == video_quality), video_quality)
|
|
||||||
info_dict['formats'].append({
|
info_dict['formats'].append({
|
||||||
'url': video['file'],
|
'url': video['file'],
|
||||||
'format_id': video_quality,
|
'format_id': video_quality,
|
||||||
@@ -310,14 +312,26 @@ def extract_format(page, version):
|
|||||||
data = {'jsonrpc': '2.0', 'method': 'videoGetLink', 'id': 2,
|
data = {'jsonrpc': '2.0', 'method': 'videoGetLink', 'id': 2,
|
||||||
'params': [video_id, cda_quality, video.get('ts'), video.get('hash2'), {}]}
|
'params': [video_id, cda_quality, video.get('ts'), video.get('hash2'), {}]}
|
||||||
data = json.dumps(data).encode()
|
data = json.dumps(data).encode()
|
||||||
video_url = self._download_json(
|
response = self._download_json(
|
||||||
f'https://www.cda.pl/video/{video_id}', video_id, headers={
|
f'https://www.cda.pl/video/{video_id}', video_id, headers={
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
}, data=data, note=f'Fetching {quality} url',
|
}, data=data, note=f'Fetching {quality} url',
|
||||||
errnote=f'Failed to fetch {quality} url', fatal=False)
|
errnote=f'Failed to fetch {quality} url', fatal=False)
|
||||||
if try_get(video_url, lambda x: x['result']['status']) == 'ok':
|
if (
|
||||||
video_url = try_get(video_url, lambda x: x['result']['resp'])
|
traverse_obj(response, ('result', 'status')) != 'ok'
|
||||||
|
or not traverse_obj(response, ('result', 'resp', {url_or_none}))
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
video_url = response['result']['resp']
|
||||||
|
ext = determine_ext(video_url)
|
||||||
|
if ext == 'mpd':
|
||||||
|
info_dict['formats'].extend(self._extract_mpd_formats(
|
||||||
|
video_url, video_id, mpd_id='dash', fatal=False))
|
||||||
|
elif ext == 'm3u8':
|
||||||
|
info_dict['formats'].extend(self._extract_m3u8_formats(
|
||||||
|
video_url, video_id, 'mp4', m3u8_id='hls', fatal=False))
|
||||||
|
else:
|
||||||
info_dict['formats'].append({
|
info_dict['formats'].append({
|
||||||
'url': video_url,
|
'url': video_url,
|
||||||
'format_id': quality,
|
'format_id': quality,
|
||||||
@@ -353,7 +367,7 @@ def extract_format(page, version):
|
|||||||
|
|
||||||
class CDAFolderIE(InfoExtractor):
|
class CDAFolderIE(InfoExtractor):
|
||||||
_MAX_PAGE_SIZE = 36
|
_MAX_PAGE_SIZE = 36
|
||||||
_VALID_URL = r'https?://(?:www\.)?cda\.pl/(?P<channel>\w+)/folder/(?P<id>\d+)'
|
_VALID_URL = r'https?://(?:www\.)?cda\.pl/(?P<channel>[\w-]+)/folder/(?P<id>\d+)'
|
||||||
_TESTS = [
|
_TESTS = [
|
||||||
{
|
{
|
||||||
'url': 'https://www.cda.pl/domino264/folder/31188385',
|
'url': 'https://www.cda.pl/domino264/folder/31188385',
|
||||||
@@ -378,6 +392,9 @@ class CDAFolderIE(InfoExtractor):
|
|||||||
'title': 'TESTY KOSMETYKÓW',
|
'title': 'TESTY KOSMETYKÓW',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 139,
|
'playlist_mincount': 139,
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.cda.pl/FILMY-SERIALE-ANIME-KRESKOWKI-BAJKI/folder/18493422',
|
||||||
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
|
|
||||||
class CloudyCDNIE(InfoExtractor):
|
class CloudyCDNIE(InfoExtractor):
|
||||||
_VALID_URL = r'(?:https?:)?//embed\.cloudycdn\.services/(?P<site_id>[^/?#]+)/media/(?P<id>[\w-]+)'
|
_VALID_URL = r'(?:https?:)?//embed\.(?P<domain>cloudycdn\.services|backscreen\.com)/(?P<site_id>[^/?#]+)/media/(?P<id>[\w-]+)'
|
||||||
_EMBED_REGEX = [rf'<iframe[^>]+\bsrc=[\'"](?P<url>{_VALID_URL})']
|
_EMBED_REGEX = [rf'<iframe[^>]+\bsrc=[\'"](?P<url>{_VALID_URL})']
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://embed.cloudycdn.services/ltv/media/46k_d23-6000-105?',
|
'url': 'https://embed.cloudycdn.services/ltv/media/46k_d23-6000-105?',
|
||||||
@@ -23,7 +23,7 @@ class CloudyCDNIE(InfoExtractor):
|
|||||||
'duration': 1442,
|
'duration': 1442,
|
||||||
'upload_date': '20231121',
|
'upload_date': '20231121',
|
||||||
'title': 'D23-6000-105_cetstud',
|
'title': 'D23-6000-105_cetstud',
|
||||||
'thumbnail': 'https://store.cloudycdn.services/tmsp00060/assets/media/660858/placeholder1700589200.jpg',
|
'thumbnail': 'https://store.bstrm.net/tmsp00060/assets/media/660858/placeholder1700589200.jpg',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://embed.cloudycdn.services/izm/media/26e_lv-8-5-1',
|
'url': 'https://embed.cloudycdn.services/izm/media/26e_lv-8-5-1',
|
||||||
@@ -33,7 +33,7 @@ class CloudyCDNIE(InfoExtractor):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'LV-8-5-1',
|
'title': 'LV-8-5-1',
|
||||||
'timestamp': 1669767167,
|
'timestamp': 1669767167,
|
||||||
'thumbnail': 'https://store.cloudycdn.services/tmsp00120/assets/media/488306/placeholder1679423604.jpg',
|
'thumbnail': 'https://store.bstrm.net/tmsp00120/assets/media/488306/placeholder1679423604.jpg',
|
||||||
'duration': 1205,
|
'duration': 1205,
|
||||||
'upload_date': '20221130',
|
'upload_date': '20221130',
|
||||||
},
|
},
|
||||||
@@ -48,9 +48,21 @@ class CloudyCDNIE(InfoExtractor):
|
|||||||
'duration': 1673,
|
'duration': 1673,
|
||||||
'title': 'D24-6000-074-cetstud',
|
'title': 'D24-6000-074-cetstud',
|
||||||
'timestamp': 1718902233,
|
'timestamp': 1718902233,
|
||||||
'thumbnail': 'https://store.cloudycdn.services/tmsp00060/assets/media/788392/placeholder1718903938.jpg',
|
'thumbnail': 'https://store.bstrm.net/tmsp00060/assets/media/788392/placeholder1718903938.jpg',
|
||||||
},
|
},
|
||||||
'params': {'format': 'bv'},
|
'params': {'format': 'bv'},
|
||||||
|
}, {
|
||||||
|
'url': 'https://embed.backscreen.com/ltv/media/32j_z25-0600-127?',
|
||||||
|
'md5': '9b6fa09ac1a4de53d4f42b94affc3b42',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '32j_z25-0600-127',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'Z25-0600-127-DZ',
|
||||||
|
'duration': 1906,
|
||||||
|
'thumbnail': 'https://store.bstrm.net/tmsp00060/assets/media/977427/placeholder1746633646.jpg',
|
||||||
|
'timestamp': 1746632402,
|
||||||
|
'upload_date': '20250507',
|
||||||
|
},
|
||||||
}]
|
}]
|
||||||
_WEBPAGE_TESTS = [{
|
_WEBPAGE_TESTS = [{
|
||||||
'url': 'https://www.tavaklase.lv/video/es-esmu-mina-um-2/',
|
'url': 'https://www.tavaklase.lv/video/es-esmu-mina-um-2/',
|
||||||
@@ -60,17 +72,17 @@ class CloudyCDNIE(InfoExtractor):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'upload_date': '20230223',
|
'upload_date': '20230223',
|
||||||
'duration': 629,
|
'duration': 629,
|
||||||
'thumbnail': 'https://store.cloudycdn.services/tmsp00120/assets/media/518407/placeholder1678748124.jpg',
|
'thumbnail': 'https://store.bstrm.net/tmsp00120/assets/media/518407/placeholder1678748124.jpg',
|
||||||
'timestamp': 1677181513,
|
'timestamp': 1677181513,
|
||||||
'title': 'LIB-2',
|
'title': 'LIB-2',
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
site_id, video_id = self._match_valid_url(url).group('site_id', 'id')
|
domain, site_id, video_id = self._match_valid_url(url).group('domain', 'site_id', 'id')
|
||||||
|
|
||||||
data = self._download_json(
|
data = self._download_json(
|
||||||
f'https://player.cloudycdn.services/player/{site_id}/media/{video_id}/',
|
f'https://player.{domain}/player/{site_id}/media/{video_id}/',
|
||||||
video_id, data=urlencode_postdata({
|
video_id, data=urlencode_postdata({
|
||||||
'version': '6.4.0',
|
'version': '6.4.0',
|
||||||
'referer': url,
|
'referer': url,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
xpath_with_ns,
|
xpath_with_ns,
|
||||||
)
|
)
|
||||||
from ..utils._utils import _request_dump_filename
|
from ..utils._utils import _request_dump_filename
|
||||||
|
from ..utils.jslib import devalue
|
||||||
|
|
||||||
|
|
||||||
class InfoExtractor:
|
class InfoExtractor:
|
||||||
@@ -262,6 +263,9 @@ class InfoExtractor:
|
|||||||
* http_chunk_size Chunk size for HTTP downloads
|
* http_chunk_size Chunk size for HTTP downloads
|
||||||
* ffmpeg_args Extra arguments for ffmpeg downloader (input)
|
* ffmpeg_args Extra arguments for ffmpeg downloader (input)
|
||||||
* ffmpeg_args_out Extra arguments for ffmpeg downloader (output)
|
* ffmpeg_args_out Extra arguments for ffmpeg downloader (output)
|
||||||
|
* ws (NiconicoLiveFD only) WebSocketResponse
|
||||||
|
* ws_url (NiconicoLiveFD only) Websockets URL
|
||||||
|
* max_quality (NiconicoLiveFD only) Max stream quality string
|
||||||
* is_dash_periods Whether the format is a result of merging
|
* is_dash_periods Whether the format is a result of merging
|
||||||
multiple DASH periods.
|
multiple DASH periods.
|
||||||
RTMP formats can also have the additional fields: page_url,
|
RTMP formats can also have the additional fields: page_url,
|
||||||
@@ -1570,6 +1574,8 @@ def _yield_json_ld(self, html, video_id, *, fatal=True, default=NO_DEFAULT):
|
|||||||
"""Yield all json ld objects in the html"""
|
"""Yield all json ld objects in the html"""
|
||||||
if default is not NO_DEFAULT:
|
if default is not NO_DEFAULT:
|
||||||
fatal = False
|
fatal = False
|
||||||
|
if not fatal and not isinstance(html, str):
|
||||||
|
return
|
||||||
for mobj in re.finditer(JSON_LD_RE, html):
|
for mobj in re.finditer(JSON_LD_RE, html):
|
||||||
json_ld_item = self._parse_json(
|
json_ld_item = self._parse_json(
|
||||||
mobj.group('json_ld'), video_id, fatal=fatal,
|
mobj.group('json_ld'), video_id, fatal=fatal,
|
||||||
@@ -1673,9 +1679,9 @@ def extract_video_object(e):
|
|||||||
'ext': mimetype2ext(e.get('encodingFormat')),
|
'ext': mimetype2ext(e.get('encodingFormat')),
|
||||||
'title': unescapeHTML(e.get('name')),
|
'title': unescapeHTML(e.get('name')),
|
||||||
'description': unescapeHTML(e.get('description')),
|
'description': unescapeHTML(e.get('description')),
|
||||||
'thumbnails': [{'url': unescapeHTML(url)}
|
'thumbnails': traverse_obj(e, (('thumbnailUrl', 'thumbnailURL', 'thumbnail_url'), (None, ...), {
|
||||||
for url in variadic(traverse_obj(e, 'thumbnailUrl', 'thumbnailURL'))
|
'url': ({str}, {unescapeHTML}, {self._proto_relative_url}, {url_or_none}),
|
||||||
if url_or_none(url)],
|
})),
|
||||||
'duration': parse_duration(e.get('duration')),
|
'duration': parse_duration(e.get('duration')),
|
||||||
'timestamp': unified_timestamp(e.get('uploadDate')),
|
'timestamp': unified_timestamp(e.get('uploadDate')),
|
||||||
# author can be an instance of 'Organization' or 'Person' types.
|
# author can be an instance of 'Organization' or 'Person' types.
|
||||||
@@ -1793,6 +1799,63 @@ def _search_nuxt_data(self, webpage, video_id, context_name='__NUXT__', *, fatal
|
|||||||
ret = self._parse_json(js, video_id, transform_source=functools.partial(js_to_json, vars=args), fatal=fatal)
|
ret = self._parse_json(js, video_id, transform_source=functools.partial(js_to_json, vars=args), fatal=fatal)
|
||||||
return traverse_obj(ret, traverse) or {}
|
return traverse_obj(ret, traverse) or {}
|
||||||
|
|
||||||
|
def _resolve_nuxt_array(self, array, video_id, *, fatal=True, default=NO_DEFAULT):
|
||||||
|
"""Resolves Nuxt rich JSON payload arrays"""
|
||||||
|
# Ref: https://github.com/nuxt/nuxt/commit/9e503be0f2a24f4df72a3ccab2db4d3e63511f57
|
||||||
|
# https://github.com/nuxt/nuxt/pull/19205
|
||||||
|
if default is not NO_DEFAULT:
|
||||||
|
fatal = False
|
||||||
|
|
||||||
|
if not isinstance(array, list) or not array:
|
||||||
|
error_msg = 'Unable to resolve Nuxt JSON data: invalid input'
|
||||||
|
if fatal:
|
||||||
|
raise ExtractorError(error_msg, video_id=video_id)
|
||||||
|
elif default is NO_DEFAULT:
|
||||||
|
self.report_warning(error_msg, video_id=video_id)
|
||||||
|
return {} if default is NO_DEFAULT else default
|
||||||
|
|
||||||
|
def indirect_reviver(data):
|
||||||
|
return data
|
||||||
|
|
||||||
|
def json_reviver(data):
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
gen = devalue.parse_iter(array, revivers={
|
||||||
|
'NuxtError': indirect_reviver,
|
||||||
|
'EmptyShallowRef': json_reviver,
|
||||||
|
'EmptyRef': json_reviver,
|
||||||
|
'ShallowRef': indirect_reviver,
|
||||||
|
'ShallowReactive': indirect_reviver,
|
||||||
|
'Ref': indirect_reviver,
|
||||||
|
'Reactive': indirect_reviver,
|
||||||
|
})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
error_msg = f'Error resolving Nuxt JSON: {gen.send(None)}'
|
||||||
|
if fatal:
|
||||||
|
raise ExtractorError(error_msg, video_id=video_id)
|
||||||
|
elif default is NO_DEFAULT:
|
||||||
|
self.report_warning(error_msg, video_id=video_id, only_once=True)
|
||||||
|
else:
|
||||||
|
self.write_debug(f'{video_id}: {error_msg}', only_once=True)
|
||||||
|
except StopIteration as error:
|
||||||
|
return error.value or ({} if default is NO_DEFAULT else default)
|
||||||
|
|
||||||
|
def _search_nuxt_json(self, webpage, video_id, *, fatal=True, default=NO_DEFAULT):
|
||||||
|
"""Parses metadata from Nuxt rich JSON payloads embedded in HTML"""
|
||||||
|
passed_default = default is not NO_DEFAULT
|
||||||
|
|
||||||
|
array = self._search_json(
|
||||||
|
r'<script\b[^>]+\bid="__NUXT_DATA__"[^>]*>', webpage,
|
||||||
|
'Nuxt JSON data', video_id, contains_pattern=r'\[(?s:.+)\]',
|
||||||
|
fatal=fatal, default=NO_DEFAULT if not passed_default else None)
|
||||||
|
|
||||||
|
if not array:
|
||||||
|
return default if passed_default else {}
|
||||||
|
|
||||||
|
return self._resolve_nuxt_array(array, video_id, fatal=fatal, default=default)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _hidden_inputs(html):
|
def _hidden_inputs(html):
|
||||||
html = re.sub(r'<!--(?:(?!<!--).)*-->', '', html)
|
html = re.sub(r'<!--(?:(?!<!--).)*-->', '', html)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
int_or_none,
|
int_or_none,
|
||||||
try_get,
|
try_get,
|
||||||
unified_strdate,
|
unified_strdate,
|
||||||
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class CrowdBunkerIE(InfoExtractor):
|
class CrowdBunkerIE(InfoExtractor):
|
||||||
@@ -44,16 +46,15 @@ def _real_extract(self, url):
|
|||||||
'url': sub_url,
|
'url': sub_url,
|
||||||
})
|
})
|
||||||
|
|
||||||
mpd_url = try_get(video_json, lambda x: x['dashManifest']['url'])
|
if mpd_url := traverse_obj(video_json, ('dashManifest', 'url', {url_or_none})):
|
||||||
if mpd_url:
|
fmts, subs = self._extract_mpd_formats_and_subtitles(mpd_url, video_id, mpd_id='dash', fatal=False)
|
||||||
fmts, subs = self._extract_mpd_formats_and_subtitles(mpd_url, video_id)
|
|
||||||
formats.extend(fmts)
|
formats.extend(fmts)
|
||||||
subtitles = self._merge_subtitles(subtitles, subs)
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
m3u8_url = try_get(video_json, lambda x: x['hlsManifest']['url'])
|
|
||||||
if m3u8_url:
|
if m3u8_url := traverse_obj(video_json, ('hlsManifest', 'url', {url_or_none})):
|
||||||
fmts, subs = self._extract_m3u8_formats_and_subtitles(mpd_url, video_id)
|
fmts, subs = self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, m3u8_id='hls', fatal=False)
|
||||||
formats.extend(fmts)
|
formats.extend(fmts)
|
||||||
subtitles = self._merge_subtitles(subtitles, subs)
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
|
|
||||||
thumbnails = [{
|
thumbnails = [{
|
||||||
'url': image['url'],
|
'url': image['url'],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
ExtractorError,
|
ExtractorError,
|
||||||
classproperty,
|
classproperty,
|
||||||
float_or_none,
|
float_or_none,
|
||||||
|
parse_qs,
|
||||||
traverse_obj,
|
traverse_obj,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
)
|
)
|
||||||
@@ -91,11 +92,15 @@ def _usp_signing_secret(self):
|
|||||||
# Rotates every so often, but hardcode a fallback in case of JS change/breakage before rotation
|
# Rotates every so often, but hardcode a fallback in case of JS change/breakage before rotation
|
||||||
return self._search_regex(
|
return self._search_regex(
|
||||||
r'\bUSP_SIGNING_SECRET\s*=\s*(["\'])(?P<secret>(?:(?!\1).)+)', player_js,
|
r'\bUSP_SIGNING_SECRET\s*=\s*(["\'])(?P<secret>(?:(?!\1).)+)', player_js,
|
||||||
'usp signing secret', group='secret', fatal=False) or 'odnInCGqhvtyRTtIiddxtuRtawYYICZP'
|
'usp signing secret', group='secret', fatal=False) or 'hGDtqMKYVeFdofrAfFmBcrsakaZELajI'
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
user_id, video_id = self._match_valid_url(url).group('user_id', 'id')
|
user_id, video_id = self._match_valid_url(url).group('user_id', 'id')
|
||||||
query = {'contentId': f'{user_id}-vod-{video_id}', 'provider': 'universe'}
|
query = {
|
||||||
|
'contentId': f'{user_id}-vod-{video_id}',
|
||||||
|
'provider': 'universe',
|
||||||
|
**traverse_obj(url, ({parse_qs}, 'uss_token', {'signedKey': -1})),
|
||||||
|
}
|
||||||
info = self._download_json(self._API_INFO_URL, video_id, query=query, fatal=False)
|
info = self._download_json(self._API_INFO_URL, video_id, query=query, fatal=False)
|
||||||
access = self._download_json(
|
access = self._download_json(
|
||||||
'https://playback.dacast.com/content/access', video_id,
|
'https://playback.dacast.com/content/access', video_id,
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ def _real_extract(self, url):
|
|||||||
'is_live': True,
|
'is_live': True,
|
||||||
**traverse_obj(room, {
|
**traverse_obj(room, {
|
||||||
'display_id': ('url', {str}, {lambda i: i[1:]}),
|
'display_id': ('url', {str}, {lambda i: i[1:]}),
|
||||||
'title': ('room_name', {unescapeHTML}),
|
'title': ('room_name', {str}, {unescapeHTML}),
|
||||||
'description': ('show_details', {str}),
|
'description': ('show_details', {str}),
|
||||||
'uploader': ('nickname', {str}),
|
'uploader': ('nickname', {str}),
|
||||||
'thumbnail': ('room_src', {url_or_none}),
|
'thumbnail': ('room_src', {url_or_none}),
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
from .zdf import ZDFBaseIE
|
from .zdf import ZDFBaseIE
|
||||||
|
from ..utils import (
|
||||||
|
int_or_none,
|
||||||
|
merge_dicts,
|
||||||
|
parse_iso8601,
|
||||||
|
)
|
||||||
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class DreiSatIE(ZDFBaseIE):
|
class DreiSatIE(ZDFBaseIE):
|
||||||
IE_NAME = '3sat'
|
IE_NAME = '3sat'
|
||||||
_VALID_URL = r'https?://(?:www\.)?3sat\.de/(?:[^/]+/)*(?P<id>[^/?#&]+)\.html'
|
_VALID_URL = r'https?://(?:www\.)?3sat\.de/(?:[^/?#]+/)*(?P<id>[^/?#&]+)\.html'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.3sat.de/dokumentation/reise/traumziele-suedostasiens-die-philippinen-und-vietnam-102.html',
|
'url': 'https://www.3sat.de/dokumentation/reise/traumziele-suedostasiens-die-philippinen-und-vietnam-102.html',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -12,40 +18,59 @@ class DreiSatIE(ZDFBaseIE):
|
|||||||
'title': 'Traumziele Südostasiens (1/2): Die Philippinen und Vietnam',
|
'title': 'Traumziele Südostasiens (1/2): Die Philippinen und Vietnam',
|
||||||
'description': 'md5:26329ce5197775b596773b939354079d',
|
'description': 'md5:26329ce5197775b596773b939354079d',
|
||||||
'duration': 2625.0,
|
'duration': 2625.0,
|
||||||
'thumbnail': 'https://www.3sat.de/assets/traumziele-suedostasiens-die-philippinen-und-vietnam-100~2400x1350?cb=1699870351148',
|
'thumbnail': 'https://www.3sat.de/assets/traumziele-suedostasiens-die-philippinen-und-vietnam-100~original?cb=1699870351148',
|
||||||
'episode': 'Traumziele Südostasiens (1/2): Die Philippinen und Vietnam',
|
'episode': 'Traumziele Südostasiens (1/2): Die Philippinen und Vietnam',
|
||||||
'episode_id': 'POS_cc7ff51c-98cf-4d12-b99d-f7a551de1c95',
|
'episode_id': 'POS_cc7ff51c-98cf-4d12-b99d-f7a551de1c95',
|
||||||
'timestamp': 1738593000,
|
'timestamp': 1747920900,
|
||||||
'upload_date': '20250203',
|
'upload_date': '20250522',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
# Same as https://www.zdf.de/dokumentation/ab-18/10-wochen-sommer-102.html
|
'url': 'https://www.3sat.de/film/ab-18/ab-18---mein-fremdes-ich-100.html',
|
||||||
'url': 'https://www.3sat.de/film/ab-18/10-wochen-sommer-108.html',
|
'md5': 'f92638413a11d759bdae95c9d8ec165c',
|
||||||
'md5': '0aff3e7bc72c8813f5e0fae333316a1d',
|
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '141007_ab18_10wochensommer_film',
|
'id': '221128_mein_fremdes_ich2_ab18',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Ab 18! - 10 Wochen Sommer',
|
'title': 'Ab 18! - Mein fremdes Ich',
|
||||||
'description': 'md5:8253f41dc99ce2c3ff892dac2d65fe26',
|
'description': 'md5:cae0c0b27b7426d62ca0dda181738bf0',
|
||||||
'duration': 2660,
|
'duration': 2625.0,
|
||||||
'timestamp': 1608604200,
|
'thumbnail': 'https://www.3sat.de/assets/ab-18---mein-fremdes-ich-106~original?cb=1666081865812',
|
||||||
'upload_date': '20201222',
|
'episode': 'Ab 18! - Mein fremdes Ich',
|
||||||
|
'episode_id': 'POS_6225d1ca-a0d5-45e3-870b-e783ee6c8a3f',
|
||||||
|
'timestamp': 1695081600,
|
||||||
|
'upload_date': '20230919',
|
||||||
},
|
},
|
||||||
'skip': '410 Gone',
|
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.3sat.de/gesellschaft/schweizweit/waidmannsheil-100.html',
|
'url': 'https://www.3sat.de/gesellschaft/37-grad-leben/aus-dem-leben-gerissen-102.html',
|
||||||
|
'md5': 'a903eaf8d1fd635bd3317cd2ad87ec84',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '140913_sendung_schweizweit',
|
'id': '250323_0903_sendung_sgl',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Waidmannsheil',
|
'title': 'Plötzlich ohne dich',
|
||||||
'description': 'md5:cce00ca1d70e21425e72c86a98a56817',
|
'description': 'md5:380cc10659289dd91510ad8fa717c66b',
|
||||||
'timestamp': 1410623100,
|
'duration': 1620.0,
|
||||||
'upload_date': '20140913',
|
'thumbnail': 'https://www.3sat.de/assets/37-grad-leben-106~original?cb=1645537156810',
|
||||||
|
'episode': 'Plötzlich ohne dich',
|
||||||
|
'episode_id': 'POS_faa7a93c-c0f2-4d51-823f-ce2ac3ee191b',
|
||||||
|
'timestamp': 1743162540,
|
||||||
|
'upload_date': '20250328',
|
||||||
},
|
},
|
||||||
'params': {
|
}, {
|
||||||
'skip_download': True,
|
# Video with chapters
|
||||||
|
'url': 'https://www.3sat.de/kultur/buchmesse/dein-buch-das-beste-von-der-leipziger-buchmesse-2025-teil-1-100.html',
|
||||||
|
'md5': '6b95790ce52e75f0d050adcdd2711ee6',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '250330_dein_buch1_bum',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'dein buch - Das Beste von der Leipziger Buchmesse 2025 - Teil 1',
|
||||||
|
'description': 'md5:bae51bfc22f15563ce3acbf97d2e8844',
|
||||||
|
'duration': 5399.0,
|
||||||
|
'thumbnail': 'https://www.3sat.de/assets/buchmesse-kerkeling-100~original?cb=1747256996338',
|
||||||
|
'chapters': 'count:24',
|
||||||
|
'episode': 'dein buch - Das Beste von der Leipziger Buchmesse 2025 - Teil 1',
|
||||||
|
'episode_id': 'POS_1ef236cc-b390-401e-acd0-4fb4b04315fb',
|
||||||
|
'timestamp': 1743327000,
|
||||||
|
'upload_date': '20250330',
|
||||||
},
|
},
|
||||||
'skip': '404 Not Found',
|
|
||||||
}, {
|
}, {
|
||||||
# Same as https://www.zdf.de/filme/filme-sonstige/der-hauptmann-112.html
|
# Same as https://www.zdf.de/filme/filme-sonstige/der-hauptmann-112.html
|
||||||
'url': 'https://www.3sat.de/film/spielfilm/der-hauptmann-100.html',
|
'url': 'https://www.3sat.de/film/spielfilm/der-hauptmann-100.html',
|
||||||
@@ -58,11 +83,42 @@ class DreiSatIE(ZDFBaseIE):
|
|||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
|
webpage = self._download_webpage(url, video_id)
|
||||||
|
player = self._search_json(
|
||||||
|
r'data-zdfplayer-jsb=(["\'])', webpage, 'player JSON', video_id)
|
||||||
|
player_url = player['content']
|
||||||
|
api_token = f'Bearer {player["apiToken"]}'
|
||||||
|
|
||||||
webpage = self._download_webpage(url, video_id, fatal=False)
|
content = self._call_api(player_url, video_id, 'video metadata', api_token)
|
||||||
if webpage:
|
|
||||||
player = self._extract_player(webpage, url, fatal=False)
|
|
||||||
if player:
|
|
||||||
return self._extract_regular(url, player, video_id)
|
|
||||||
|
|
||||||
return self._extract_mobile(video_id)
|
video_target = content['mainVideoContent']['http://zdf.de/rels/target']
|
||||||
|
ptmd_path = traverse_obj(video_target, (
|
||||||
|
(('streams', 'default'), None),
|
||||||
|
('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
|
||||||
|
{str}, any, {require('ptmd path')}))
|
||||||
|
ptmd_url = self._expand_ptmd_template(player_url, ptmd_path)
|
||||||
|
aspect_ratio = self._parse_aspect_ratio(video_target.get('aspectRatio'))
|
||||||
|
info = self._extract_ptmd(ptmd_url, video_id, api_token, aspect_ratio)
|
||||||
|
|
||||||
|
return merge_dicts(info, {
|
||||||
|
**traverse_obj(content, {
|
||||||
|
'title': (('title', 'teaserHeadline'), {str}, any),
|
||||||
|
'episode': (('title', 'teaserHeadline'), {str}, any),
|
||||||
|
'description': (('leadParagraph', 'teasertext'), {str}, any),
|
||||||
|
'timestamp': ('editorialDate', {parse_iso8601}),
|
||||||
|
}),
|
||||||
|
**traverse_obj(video_target, {
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'chapters': ('streamAnchorTag', {self._extract_chapters}),
|
||||||
|
}),
|
||||||
|
'thumbnails': self._extract_thumbnails(traverse_obj(content, ('teaserImageRef', 'layouts', {dict}))),
|
||||||
|
**traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
|
||||||
|
'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
|
||||||
|
'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
|
||||||
|
'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
|
||||||
|
'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
|
||||||
|
'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
|
||||||
|
'episode_number': ('episodeNumber', {int_or_none}),
|
||||||
|
'episode_id': ('contentId', {str}),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
from .adobepass import AdobePassIE
|
from .adobepass import AdobePassIE
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from .once import OnceIE
|
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
determine_ext,
|
determine_ext,
|
||||||
dict_get,
|
dict_get,
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ESPNIE(OnceIE):
|
class ESPNIE(InfoExtractor):
|
||||||
_VALID_URL = r'''(?x)
|
_VALID_URL = r'''(?x)
|
||||||
https?://
|
https?://
|
||||||
(?:
|
(?:
|
||||||
@@ -131,9 +130,7 @@ def extract_source(source_url, source_id=None):
|
|||||||
return
|
return
|
||||||
format_urls.add(source_url)
|
format_urls.add(source_url)
|
||||||
ext = determine_ext(source_url)
|
ext = determine_ext(source_url)
|
||||||
if OnceIE.suitable(source_url):
|
if ext == 'smil':
|
||||||
formats.extend(self._extract_once_formats(source_url))
|
|
||||||
elif ext == 'smil':
|
|
||||||
formats.extend(self._extract_smil_formats(
|
formats.extend(self._extract_smil_formats(
|
||||||
source_url, video_id, fatal=False))
|
source_url, video_id, fatal=False))
|
||||||
elif ext == 'f4m':
|
elif ext == 'f4m':
|
||||||
@@ -332,6 +329,7 @@ class WatchESPNIE(AdobePassIE):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
_API_KEY = 'ZXNwbiZicm93c2VyJjEuMC4w.ptUt7QxsteaRruuPmGZFaJByOoqKvDP2a5YkInHrc7c'
|
_API_KEY = 'ZXNwbiZicm93c2VyJjEuMC4w.ptUt7QxsteaRruuPmGZFaJByOoqKvDP2a5YkInHrc7c'
|
||||||
|
_SOFTWARE_STATEMENT = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIyZGJmZWM4My03OWE1LTQyNzEtYTVmZC04NTZjYTMxMjRjNjMiLCJuYmYiOjE1NDAyMTI3NjEsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTQwMjEyNzYxfQ.yaK3r4AI2uLVvsyN1GLzqzgzRlxMPtasSaiYYBV0wIstqih5tvjTmeoLmi8Xy9Kp_U7Md-bOffwiyK3srHkpUkhhwXLH2x6RPjmS1tPmhaG7-3LBcHTf2ySPvXhVf7cN4ngldawK4tdtLtsw6rF_JoZE2yaC6XbS2F51nXSFEDDnOQWIHEQRG3aYAj-38P2CLGf7g-Yfhbp5cKXeksHHQ90u3eOO4WH0EAjc9oO47h33U8KMEXxJbvjV5J8Va2G2fQSgLDZ013NBI3kQnE313qgqQh2feQILkyCENpB7g-TVBreAjOaH1fU471htSoGGYepcAXv-UDtpgitDiLy7CQ'
|
||||||
|
|
||||||
def _call_bamgrid_api(self, path, video_id, payload=None, headers={}):
|
def _call_bamgrid_api(self, path, video_id, payload=None, headers={}):
|
||||||
if 'Authorization' not in headers:
|
if 'Authorization' not in headers:
|
||||||
@@ -408,8 +406,8 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
# TV Provider required
|
# TV Provider required
|
||||||
else:
|
else:
|
||||||
resource = self._get_mvpd_resource('ESPN', video_data['name'], video_id, None)
|
resource = self._get_mvpd_resource('espn1', video_data['name'], video_id, None)
|
||||||
auth = self._extract_mvpd_auth(url, video_id, 'ESPN', resource).encode()
|
auth = self._extract_mvpd_auth(url, video_id, 'ESPN', resource, self._SOFTWARE_STATEMENT).encode()
|
||||||
|
|
||||||
asset = self._download_json(
|
asset = self._download_json(
|
||||||
f'https://watch.auth.api.espn.com/video/auth/media/{video_id}/asset?apikey=uiqlbgzdwuru14v627vdusswb',
|
f'https://watch.auth.api.espn.com/video/auth/media/{video_id}/asset?apikey=uiqlbgzdwuru14v627vdusswb',
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
|
determine_ext,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
qualities,
|
join_nonempty,
|
||||||
|
mimetype2ext,
|
||||||
|
parse_qs,
|
||||||
unified_strdate,
|
unified_strdate,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class FirstTVIE(InfoExtractor):
|
class FirstTVIE(InfoExtractor):
|
||||||
@@ -15,40 +19,51 @@ class FirstTVIE(InfoExtractor):
|
|||||||
_VALID_URL = r'https?://(?:www\.)?(?:sport)?1tv\.ru/(?:[^/?#]+/)+(?P<id>[^/?#]+)'
|
_VALID_URL = r'https?://(?:www\.)?(?:sport)?1tv\.ru/(?:[^/?#]+/)+(?P<id>[^/?#]+)'
|
||||||
|
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
# single format
|
# single format; has item.id
|
||||||
'url': 'http://www.1tv.ru/shows/naedine-so-vsemi/vypuski/gost-lyudmila-senchina-naedine-so-vsemi-vypusk-ot-12-02-2015',
|
'url': 'https://www.1tv.ru/shows/naedine-so-vsemi/vypuski/gost-lyudmila-senchina-naedine-so-vsemi-vypusk-ot-12-02-2015',
|
||||||
'md5': 'a1b6b60d530ebcf8daacf4565762bbaf',
|
'md5': '8011ae8e88ff4150107ab9c5a8f5b659',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '40049',
|
'id': '40049',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Гость Людмила Сенчина. Наедине со всеми. Выпуск от 12.02.2015',
|
'title': 'Гость Людмила Сенчина. Наедине со всеми. Выпуск от 12.02.2015',
|
||||||
'thumbnail': r're:^https?://.*\.(?:jpg|JPG)$',
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
'upload_date': '20150212',
|
'upload_date': '20150212',
|
||||||
'duration': 2694,
|
'duration': 2694,
|
||||||
},
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
# multiple formats
|
# multiple formats; has item.id
|
||||||
'url': 'http://www.1tv.ru/shows/dobroe-utro/pro-zdorove/vesennyaya-allergiya-dobroe-utro-fragment-vypuska-ot-07042016',
|
'url': 'https://www.1tv.ru/shows/dobroe-utro/pro-zdorove/vesennyaya-allergiya-dobroe-utro-fragment-vypuska-ot-07042016',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '364746',
|
'id': '364746',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Весенняя аллергия. Доброе утро. Фрагмент выпуска от 07.04.2016',
|
'title': 'Весенняя аллергия. Доброе утро. Фрагмент выпуска от 07.04.2016',
|
||||||
'thumbnail': r're:^https?://.*\.(?:jpg|JPG)$',
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
'upload_date': '20160407',
|
'upload_date': '20160407',
|
||||||
'duration': 179,
|
'duration': 179,
|
||||||
'formats': 'mincount:3',
|
'formats': 'mincount:3',
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {'skip_download': 'm3u8'},
|
||||||
'skip_download': True,
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
'url': 'http://www.1tv.ru/news/issue/2016-12-01/14:00',
|
'url': 'https://www.1tv.ru/news/issue/2016-12-01/14:00',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '14:00',
|
'id': '14:00',
|
||||||
'title': 'Выпуск новостей в 14:00 1 декабря 2016 года. Новости. Первый канал',
|
'title': 'Выпуск программы «Время» в 20:00 1 декабря 2016 года. Новости. Первый канал',
|
||||||
'description': 'md5:2e921b948f8c1ff93901da78ebdb1dfd',
|
'thumbnail': 'https://static.1tv.ru/uploads/photo/image/8/big/338448_big_8fc7eb236f.jpg',
|
||||||
},
|
},
|
||||||
'playlist_count': 13,
|
'playlist_count': 13,
|
||||||
|
}, {
|
||||||
|
# has timestamp; has item.uid but not item.id
|
||||||
|
'url': 'https://www.1tv.ru/shows/segodnya-vecherom/vypuski/avtory-odnogo-hita-segodnya-vecherom-vypusk-ot-03-05-2025',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '270411',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'Авторы одного хита. Сегодня вечером. Выпуск от 03.05.2025',
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
|
'timestamp': 1746286020,
|
||||||
|
'upload_date': '20250503',
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
'url': 'http://www.1tv.ru/shows/tochvtoch-supersezon/vystupleniya/evgeniy-dyatlov-vladimir-vysockiy-koni-priveredlivye-toch-v-toch-supersezon-fragment-vypuska-ot-06-11-2016',
|
'url': 'http://www.1tv.ru/shows/tochvtoch-supersezon/vystupleniya/evgeniy-dyatlov-vladimir-vysockiy-koni-priveredlivye-toch-v-toch-supersezon-fragment-vypuska-ot-06-11-2016',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -57,96 +72,60 @@ class FirstTVIE(InfoExtractor):
|
|||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
def _entries(self, items):
|
||||||
|
for item in items:
|
||||||
|
video_id = str(item.get('id') or item['uid'])
|
||||||
|
|
||||||
|
formats, subtitles = [], {}
|
||||||
|
for f in traverse_obj(item, ('sources', lambda _, v: url_or_none(v['src']))):
|
||||||
|
src = f['src']
|
||||||
|
ext = mimetype2ext(f.get('type'), default=determine_ext(src))
|
||||||
|
if ext == 'm3u8':
|
||||||
|
fmts, subs = self._extract_m3u8_formats_and_subtitles(
|
||||||
|
src, video_id, 'mp4', m3u8_id='hls', fatal=False)
|
||||||
|
elif ext == 'mpd':
|
||||||
|
fmts, subs = self._extract_mpd_formats_and_subtitles(
|
||||||
|
src, video_id, mpd_id='dash', fatal=False)
|
||||||
|
else:
|
||||||
|
tbr = self._search_regex(fr'_(\d{{3,}})\.{ext}', src, 'tbr', default=None)
|
||||||
|
formats.append({
|
||||||
|
'url': src,
|
||||||
|
'ext': ext,
|
||||||
|
'format_id': join_nonempty('http', ext, tbr),
|
||||||
|
'tbr': int_or_none(tbr),
|
||||||
|
# quality metadata of http formats may be incorrect
|
||||||
|
'quality': -10,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
formats.extend(fmts)
|
||||||
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
**traverse_obj(item, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'thumbnail': ('poster', {url_or_none}),
|
||||||
|
'timestamp': ('dvr_begin_at', {int_or_none}),
|
||||||
|
'upload_date': ('date_air', {unified_strdate}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
}),
|
||||||
|
'id': video_id,
|
||||||
|
'formats': formats,
|
||||||
|
'subtitles': subtitles,
|
||||||
|
}
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id = self._match_id(url)
|
display_id = self._match_id(url)
|
||||||
|
|
||||||
webpage = self._download_webpage(url, display_id)
|
webpage = self._download_webpage(url, display_id)
|
||||||
playlist_url = urllib.parse.urljoin(url, self._search_regex(
|
playlist_url = urllib.parse.urljoin(url, self._html_search_regex(
|
||||||
r'data-playlist-url=(["\'])(?P<url>(?:(?!\1).)+)\1',
|
r'data-playlist-url=(["\'])(?P<url>(?:(?!\1).)+)\1',
|
||||||
webpage, 'playlist url', group='url'))
|
webpage, 'playlist url', group='url'))
|
||||||
|
|
||||||
parsed_url = urllib.parse.urlparse(playlist_url)
|
item_ids = traverse_obj(parse_qs(playlist_url), 'video_id', 'videos_ids[]', 'news_ids[]')
|
||||||
qs = urllib.parse.parse_qs(parsed_url.query)
|
items = traverse_obj(
|
||||||
item_ids = qs.get('videos_ids[]') or qs.get('news_ids[]')
|
self._download_json(playlist_url, display_id),
|
||||||
|
lambda _, v: v['uid'] and (str(v['uid']) in item_ids if item_ids else True))
|
||||||
|
|
||||||
items = self._download_json(playlist_url, display_id)
|
return self.playlist_result(
|
||||||
|
self._entries(items), display_id, self._og_search_title(webpage, default=None),
|
||||||
if item_ids:
|
thumbnail=self._og_search_thumbnail(webpage, default=None))
|
||||||
items = [
|
|
||||||
item for item in items
|
|
||||||
if item.get('uid') and str(item['uid']) in item_ids]
|
|
||||||
else:
|
|
||||||
items = [items[0]]
|
|
||||||
|
|
||||||
entries = []
|
|
||||||
QUALITIES = ('ld', 'sd', 'hd')
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
title = item['title']
|
|
||||||
quality = qualities(QUALITIES)
|
|
||||||
formats = []
|
|
||||||
path = None
|
|
||||||
for f in item.get('mbr', []):
|
|
||||||
src = url_or_none(f.get('src'))
|
|
||||||
if not src:
|
|
||||||
continue
|
|
||||||
tbr = int_or_none(self._search_regex(
|
|
||||||
r'_(\d{3,})\.mp4', src, 'tbr', default=None))
|
|
||||||
if not path:
|
|
||||||
path = self._search_regex(
|
|
||||||
r'//[^/]+/(.+?)_\d+\.mp4', src,
|
|
||||||
'm3u8 path', default=None)
|
|
||||||
formats.append({
|
|
||||||
'url': src,
|
|
||||||
'format_id': f.get('name'),
|
|
||||||
'tbr': tbr,
|
|
||||||
'source_preference': quality(f.get('name')),
|
|
||||||
# quality metadata of http formats may be incorrect
|
|
||||||
'preference': -10,
|
|
||||||
})
|
|
||||||
# m3u8 URL format is reverse engineered from [1] (search for
|
|
||||||
# master.m3u8). dashEdges (that is currently balancer-vod.1tv.ru)
|
|
||||||
# is taken from [2].
|
|
||||||
# 1. http://static.1tv.ru/player/eump1tv-current/eump-1tv.all.min.js?rnd=9097422834:formatted
|
|
||||||
# 2. http://static.1tv.ru/player/eump1tv-config/config-main.js?rnd=9097422834
|
|
||||||
if not path and len(formats) == 1:
|
|
||||||
path = self._search_regex(
|
|
||||||
r'//[^/]+/(.+?$)', formats[0]['url'],
|
|
||||||
'm3u8 path', default=None)
|
|
||||||
if path:
|
|
||||||
if len(formats) == 1:
|
|
||||||
m3u8_path = ','
|
|
||||||
else:
|
|
||||||
tbrs = [str(t) for t in sorted(f['tbr'] for f in formats)]
|
|
||||||
m3u8_path = '_,{},{}'.format(','.join(tbrs), '.mp4')
|
|
||||||
formats.extend(self._extract_m3u8_formats(
|
|
||||||
f'http://balancer-vod.1tv.ru/{path}{m3u8_path}.urlset/master.m3u8',
|
|
||||||
display_id, 'mp4',
|
|
||||||
entry_protocol='m3u8_native', m3u8_id='hls', fatal=False))
|
|
||||||
|
|
||||||
thumbnail = item.get('poster') or self._og_search_thumbnail(webpage)
|
|
||||||
duration = int_or_none(item.get('duration') or self._html_search_meta(
|
|
||||||
'video:duration', webpage, 'video duration', fatal=False))
|
|
||||||
upload_date = unified_strdate(self._html_search_meta(
|
|
||||||
'ya:ovs:upload_date', webpage, 'upload date', default=None))
|
|
||||||
|
|
||||||
entries.append({
|
|
||||||
'id': str(item.get('id') or item['uid']),
|
|
||||||
'thumbnail': thumbnail,
|
|
||||||
'title': title,
|
|
||||||
'upload_date': upload_date,
|
|
||||||
'duration': int_or_none(duration),
|
|
||||||
'formats': formats,
|
|
||||||
})
|
|
||||||
|
|
||||||
title = self._html_search_regex(
|
|
||||||
(r'<div class="tv_translation">\s*<h1><a href="[^"]+">([^<]*)</a>',
|
|
||||||
r"'title'\s*:\s*'([^']+)'"),
|
|
||||||
webpage, 'title', default=None) or self._og_search_title(
|
|
||||||
webpage, default=None)
|
|
||||||
description = self._html_search_regex(
|
|
||||||
r'<div class="descr">\s*<div> </div>\s*<p>([^<]*)</p></div>',
|
|
||||||
webpage, 'description', default=None) or self._html_search_meta(
|
|
||||||
'description', webpage, 'description', default=None)
|
|
||||||
|
|
||||||
return self.playlist_result(entries, display_id, title, description)
|
|
||||||
|
|||||||
@@ -17,8 +17,140 @@
|
|||||||
from ..utils.traversal import traverse_obj
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class FloatplaneIE(InfoExtractor):
|
class FloatplaneBaseIE(InfoExtractor):
|
||||||
|
def _real_extract(self, url):
|
||||||
|
post_id = self._match_id(url)
|
||||||
|
|
||||||
|
post_data = self._download_json(
|
||||||
|
f'{self._BASE_URL}/api/v3/content/post', post_id, query={'id': post_id},
|
||||||
|
note='Downloading post data', errnote='Unable to download post data',
|
||||||
|
impersonate=self._IMPERSONATE_TARGET)
|
||||||
|
|
||||||
|
if not any(traverse_obj(post_data, ('metadata', ('hasVideo', 'hasAudio')))):
|
||||||
|
raise ExtractorError('Post does not contain a video or audio track', expected=True)
|
||||||
|
|
||||||
|
uploader_url = format_field(
|
||||||
|
post_data, [('creator', 'urlname')], f'{self._BASE_URL}/channel/%s/home') or None
|
||||||
|
|
||||||
|
common_info = {
|
||||||
|
'uploader_url': uploader_url,
|
||||||
|
'channel_url': urljoin(f'{uploader_url}/', traverse_obj(post_data, ('channel', 'urlname'))),
|
||||||
|
'availability': self._availability(needs_subscription=True),
|
||||||
|
**traverse_obj(post_data, {
|
||||||
|
'uploader': ('creator', 'title', {str}),
|
||||||
|
'uploader_id': ('creator', 'id', {str}),
|
||||||
|
'channel': ('channel', 'title', {str}),
|
||||||
|
'channel_id': ('channel', 'id', {str}),
|
||||||
|
'release_timestamp': ('releaseDate', {parse_iso8601}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for media in traverse_obj(post_data, (('videoAttachments', 'audioAttachments'), ...)):
|
||||||
|
media_id = media['id']
|
||||||
|
media_typ = media.get('type') or 'video'
|
||||||
|
|
||||||
|
metadata = self._download_json(
|
||||||
|
f'{self._BASE_URL}/api/v3/content/{media_typ}', media_id, query={'id': media_id},
|
||||||
|
note=f'Downloading {media_typ} metadata', impersonate=self._IMPERSONATE_TARGET)
|
||||||
|
|
||||||
|
stream = self._download_json(
|
||||||
|
f'{self._BASE_URL}/api/v2/cdn/delivery', media_id, query={
|
||||||
|
'type': 'vod' if media_typ == 'video' else 'aod',
|
||||||
|
'guid': metadata['guid'],
|
||||||
|
}, note=f'Downloading {media_typ} stream data',
|
||||||
|
impersonate=self._IMPERSONATE_TARGET)
|
||||||
|
|
||||||
|
path_template = traverse_obj(stream, ('resource', 'uri', {str}))
|
||||||
|
|
||||||
|
def format_path(params):
|
||||||
|
path = path_template
|
||||||
|
for i, val in (params or {}).items():
|
||||||
|
path = path.replace(f'{{qualityLevelParams.{i}}}', val)
|
||||||
|
return path
|
||||||
|
|
||||||
|
formats = []
|
||||||
|
for quality in traverse_obj(stream, ('resource', 'data', 'qualityLevels', ...)):
|
||||||
|
url = urljoin(stream['cdn'], format_path(traverse_obj(
|
||||||
|
stream, ('resource', 'data', 'qualityLevelParams', quality['name'], {dict}))))
|
||||||
|
format_id = traverse_obj(quality, ('name', {str}))
|
||||||
|
hls_aes = {}
|
||||||
|
m3u8_data = None
|
||||||
|
|
||||||
|
# If we need impersonation for the API, then we need it for HLS keys too: extract in advance
|
||||||
|
if self._IMPERSONATE_TARGET is not None:
|
||||||
|
m3u8_data = self._download_webpage(
|
||||||
|
url, media_id, fatal=False, impersonate=self._IMPERSONATE_TARGET, headers=self._HEADERS,
|
||||||
|
note=join_nonempty('Downloading', format_id, 'm3u8 information', delim=' '),
|
||||||
|
errnote=join_nonempty('Failed to download', format_id, 'm3u8 information', delim=' '))
|
||||||
|
if not m3u8_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key_url = self._search_regex(
|
||||||
|
r'#EXT-X-KEY:METHOD=AES-128,URI="(https?://[^"]+)"',
|
||||||
|
m3u8_data, 'HLS AES key URI', default=None)
|
||||||
|
if key_url:
|
||||||
|
urlh = self._request_webpage(
|
||||||
|
key_url, media_id, fatal=False, impersonate=self._IMPERSONATE_TARGET, headers=self._HEADERS,
|
||||||
|
note=join_nonempty('Downloading', format_id, 'HLS AES key', delim=' '),
|
||||||
|
errnote=join_nonempty('Failed to download', format_id, 'HLS AES key', delim=' '))
|
||||||
|
if urlh:
|
||||||
|
hls_aes['key'] = urlh.read().hex()
|
||||||
|
|
||||||
|
formats.append({
|
||||||
|
**traverse_obj(quality, {
|
||||||
|
'format_note': ('label', {str}),
|
||||||
|
'width': ('width', {int}),
|
||||||
|
'height': ('height', {int}),
|
||||||
|
}),
|
||||||
|
**parse_codecs(quality.get('codecs')),
|
||||||
|
'url': url,
|
||||||
|
'ext': determine_ext(url.partition('/chunk.m3u8')[0], 'mp4'),
|
||||||
|
'format_id': format_id,
|
||||||
|
'hls_media_playlist_data': m3u8_data,
|
||||||
|
'hls_aes': hls_aes or None,
|
||||||
|
})
|
||||||
|
items.append({
|
||||||
|
**common_info,
|
||||||
|
'id': media_id,
|
||||||
|
**traverse_obj(metadata, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'thumbnail': ('thumbnail', 'path', {url_or_none}),
|
||||||
|
}),
|
||||||
|
'formats': formats,
|
||||||
|
})
|
||||||
|
|
||||||
|
post_info = {
|
||||||
|
**common_info,
|
||||||
|
'id': post_id,
|
||||||
|
'display_id': post_id,
|
||||||
|
**traverse_obj(post_data, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'description': ('text', {clean_html}),
|
||||||
|
'like_count': ('likes', {int_or_none}),
|
||||||
|
'dislike_count': ('dislikes', {int_or_none}),
|
||||||
|
'comment_count': ('comments', {int_or_none}),
|
||||||
|
'thumbnail': ('thumbnail', 'path', {url_or_none}),
|
||||||
|
}),
|
||||||
|
'http_headers': self._HEADERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) > 1:
|
||||||
|
return self.playlist_result(items, **post_info)
|
||||||
|
|
||||||
|
post_info.update(items[0])
|
||||||
|
return post_info
|
||||||
|
|
||||||
|
|
||||||
|
class FloatplaneIE(FloatplaneBaseIE):
|
||||||
_VALID_URL = r'https?://(?:(?:www|beta)\.)?floatplane\.com/post/(?P<id>\w+)'
|
_VALID_URL = r'https?://(?:(?:www|beta)\.)?floatplane\.com/post/(?P<id>\w+)'
|
||||||
|
_BASE_URL = 'https://www.floatplane.com'
|
||||||
|
_IMPERSONATE_TARGET = None
|
||||||
|
_HEADERS = {
|
||||||
|
'Origin': _BASE_URL,
|
||||||
|
'Referer': f'{_BASE_URL}/',
|
||||||
|
}
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.floatplane.com/post/2Yf3UedF7C',
|
'url': 'https://www.floatplane.com/post/2Yf3UedF7C',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -170,105 +302,9 @@ class FloatplaneIE(InfoExtractor):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_initialize(self):
|
def _real_initialize(self):
|
||||||
if not self._get_cookies('https://www.floatplane.com').get('sails.sid'):
|
if not self._get_cookies(self._BASE_URL).get('sails.sid'):
|
||||||
self.raise_login_required()
|
self.raise_login_required()
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
post_id = self._match_id(url)
|
|
||||||
|
|
||||||
post_data = self._download_json(
|
|
||||||
'https://www.floatplane.com/api/v3/content/post', post_id, query={'id': post_id},
|
|
||||||
note='Downloading post data', errnote='Unable to download post data')
|
|
||||||
|
|
||||||
if not any(traverse_obj(post_data, ('metadata', ('hasVideo', 'hasAudio')))):
|
|
||||||
raise ExtractorError('Post does not contain a video or audio track', expected=True)
|
|
||||||
|
|
||||||
uploader_url = format_field(
|
|
||||||
post_data, [('creator', 'urlname')], 'https://www.floatplane.com/channel/%s/home') or None
|
|
||||||
|
|
||||||
common_info = {
|
|
||||||
'uploader_url': uploader_url,
|
|
||||||
'channel_url': urljoin(f'{uploader_url}/', traverse_obj(post_data, ('channel', 'urlname'))),
|
|
||||||
'availability': self._availability(needs_subscription=True),
|
|
||||||
**traverse_obj(post_data, {
|
|
||||||
'uploader': ('creator', 'title', {str}),
|
|
||||||
'uploader_id': ('creator', 'id', {str}),
|
|
||||||
'channel': ('channel', 'title', {str}),
|
|
||||||
'channel_id': ('channel', 'id', {str}),
|
|
||||||
'release_timestamp': ('releaseDate', {parse_iso8601}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for media in traverse_obj(post_data, (('videoAttachments', 'audioAttachments'), ...)):
|
|
||||||
media_id = media['id']
|
|
||||||
media_typ = media.get('type') or 'video'
|
|
||||||
|
|
||||||
metadata = self._download_json(
|
|
||||||
f'https://www.floatplane.com/api/v3/content/{media_typ}', media_id, query={'id': media_id},
|
|
||||||
note=f'Downloading {media_typ} metadata')
|
|
||||||
|
|
||||||
stream = self._download_json(
|
|
||||||
'https://www.floatplane.com/api/v2/cdn/delivery', media_id, query={
|
|
||||||
'type': 'vod' if media_typ == 'video' else 'aod',
|
|
||||||
'guid': metadata['guid'],
|
|
||||||
}, note=f'Downloading {media_typ} stream data')
|
|
||||||
|
|
||||||
path_template = traverse_obj(stream, ('resource', 'uri', {str}))
|
|
||||||
|
|
||||||
def format_path(params):
|
|
||||||
path = path_template
|
|
||||||
for i, val in (params or {}).items():
|
|
||||||
path = path.replace(f'{{qualityLevelParams.{i}}}', val)
|
|
||||||
return path
|
|
||||||
|
|
||||||
formats = []
|
|
||||||
for quality in traverse_obj(stream, ('resource', 'data', 'qualityLevels', ...)):
|
|
||||||
url = urljoin(stream['cdn'], format_path(traverse_obj(
|
|
||||||
stream, ('resource', 'data', 'qualityLevelParams', quality['name'], {dict}))))
|
|
||||||
formats.append({
|
|
||||||
**traverse_obj(quality, {
|
|
||||||
'format_id': ('name', {str}),
|
|
||||||
'format_note': ('label', {str}),
|
|
||||||
'width': ('width', {int}),
|
|
||||||
'height': ('height', {int}),
|
|
||||||
}),
|
|
||||||
**parse_codecs(quality.get('codecs')),
|
|
||||||
'url': url,
|
|
||||||
'ext': determine_ext(url.partition('/chunk.m3u8')[0], 'mp4'),
|
|
||||||
})
|
|
||||||
|
|
||||||
items.append({
|
|
||||||
**common_info,
|
|
||||||
'id': media_id,
|
|
||||||
**traverse_obj(metadata, {
|
|
||||||
'title': ('title', {str}),
|
|
||||||
'duration': ('duration', {int_or_none}),
|
|
||||||
'thumbnail': ('thumbnail', 'path', {url_or_none}),
|
|
||||||
}),
|
|
||||||
'formats': formats,
|
|
||||||
})
|
|
||||||
|
|
||||||
post_info = {
|
|
||||||
**common_info,
|
|
||||||
'id': post_id,
|
|
||||||
'display_id': post_id,
|
|
||||||
**traverse_obj(post_data, {
|
|
||||||
'title': ('title', {str}),
|
|
||||||
'description': ('text', {clean_html}),
|
|
||||||
'like_count': ('likes', {int_or_none}),
|
|
||||||
'dislike_count': ('dislikes', {int_or_none}),
|
|
||||||
'comment_count': ('comments', {int_or_none}),
|
|
||||||
'thumbnail': ('thumbnail', 'path', {url_or_none}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) > 1:
|
|
||||||
return self.playlist_result(items, **post_info)
|
|
||||||
|
|
||||||
post_info.update(items[0])
|
|
||||||
return post_info
|
|
||||||
|
|
||||||
|
|
||||||
class FloatplaneChannelIE(InfoExtractor):
|
class FloatplaneChannelIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:(?:www|beta)\.)?floatplane\.com/channel/(?P<id>[\w-]+)/home(?:/(?P<channel>[\w-]+))?'
|
_VALID_URL = r'https?://(?:(?:www|beta)\.)?floatplane\.com/channel/(?P<id>[\w-]+)/home(?:/(?P<channel>[\w-]+))?'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from .once import OnceIE
|
from .common import InfoExtractor
|
||||||
|
|
||||||
|
|
||||||
class GameSpotIE(OnceIE):
|
class GameSpotIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:www\.)?gamespot\.com/(?:video|article|review)s/(?:[^/]+/\d+-|embed/)(?P<id>\d+)'
|
_VALID_URL = r'https?://(?:www\.)?gamespot\.com/(?:video|article|review)s/(?:[^/]+/\d+-|embed/)(?P<id>\d+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'http://www.gamespot.com/videos/arma-3-community-guide-sitrep-i/2300-6410818/',
|
'url': 'http://www.gamespot.com/videos/arma-3-community-guide-sitrep-i/2300-6410818/',
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
MEDIA_EXTENSIONS,
|
MEDIA_EXTENSIONS,
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
UnsupportedError,
|
UnsupportedError,
|
||||||
base_url,
|
|
||||||
determine_ext,
|
determine_ext,
|
||||||
determine_protocol,
|
determine_protocol,
|
||||||
dict_get,
|
dict_get,
|
||||||
@@ -38,6 +37,7 @@
|
|||||||
unescapeHTML,
|
unescapeHTML,
|
||||||
unified_timestamp,
|
unified_timestamp,
|
||||||
unsmuggle_url,
|
unsmuggle_url,
|
||||||
|
update_url,
|
||||||
update_url_query,
|
update_url_query,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
urlhandle_detect_ext,
|
urlhandle_detect_ext,
|
||||||
@@ -2538,12 +2538,13 @@ def _real_extract(self, url):
|
|||||||
return self.playlist_result(
|
return self.playlist_result(
|
||||||
self._parse_xspf(
|
self._parse_xspf(
|
||||||
doc, video_id, xspf_url=url,
|
doc, video_id, xspf_url=url,
|
||||||
xspf_base_url=full_response.url),
|
xspf_base_url=new_url),
|
||||||
video_id)
|
video_id)
|
||||||
elif re.match(r'(?i)^(?:{[^}]+})?MPD$', doc.tag):
|
elif re.match(r'(?i)^(?:{[^}]+})?MPD$', doc.tag):
|
||||||
info_dict['formats'], info_dict['subtitles'] = self._parse_mpd_formats_and_subtitles(
|
info_dict['formats'], info_dict['subtitles'] = self._parse_mpd_formats_and_subtitles(
|
||||||
doc,
|
doc,
|
||||||
mpd_base_url=base_url(full_response.url),
|
# Do not use yt_dlp.utils.base_url here since it will raise on file:// URLs
|
||||||
|
mpd_base_url=update_url(new_url, query=None, fragment=None).rpartition('/')[0],
|
||||||
mpd_url=url)
|
mpd_url=url)
|
||||||
info_dict['live_status'] = 'is_live' if doc.get('type') == 'dynamic' else None
|
info_dict['live_status'] = 'is_live' if doc.get('type') == 'dynamic' else None
|
||||||
self._extra_manifest_info(info_dict, url)
|
self._extra_manifest_info(info_dict, url)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
|
|
||||||
class GetCourseRuPlayerIE(InfoExtractor):
|
class GetCourseRuPlayerIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://player02\.getcourse\.ru/sign-player/?\?(?:[^#]+&)?json=[^#&]+'
|
_VALID_URL = r'https?://(?:player02\.getcourse\.ru|cf-api-2\.vhcdn\.com)/sign-player/?\?(?:[^#]+&)?json=[^#&]+'
|
||||||
_EMBED_REGEX = [rf'<iframe[^>]+\bsrc=[\'"](?P<url>{_VALID_URL}[^\'"]*)']
|
_EMBED_REGEX = [rf'<iframe[^>]+\bsrc=[\'"](?P<url>{_VALID_URL}[^\'"]*)']
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'http://player02.getcourse.ru/sign-player/?json=eyJ2aWRlb19oYXNoIjoiMTkwYmRmOTNmMWIyOTczNTMwOTg1M2E3YTE5ZTI0YjMiLCJ1c2VyX2lkIjozNTk1MjUxODMsInN1Yl9sb2dpbl91c2VyX2lkIjpudWxsLCJsZXNzb25faWQiOm51bGwsImlwIjoiNDYuMTQyLjE4Mi4yNDciLCJnY19ob3N0IjoiYWNhZGVteW1lbC5vbmxpbmUiLCJ0aW1lIjoxNzA1NDQ5NjQyLCJwYXlsb2FkIjoidV8zNTk1MjUxODMiLCJ1aV9sYW5ndWFnZSI6InJ1IiwiaXNfaGF2ZV9jdXN0b21fc3R5bGUiOnRydWV9&s=354ad2c993d95d5ac629e3133d6cefea&vh-static-feature=zigzag',
|
'url': 'http://player02.getcourse.ru/sign-player/?json=eyJ2aWRlb19oYXNoIjoiMTkwYmRmOTNmMWIyOTczNTMwOTg1M2E3YTE5ZTI0YjMiLCJ1c2VyX2lkIjozNTk1MjUxODMsInN1Yl9sb2dpbl91c2VyX2lkIjpudWxsLCJsZXNzb25faWQiOm51bGwsImlwIjoiNDYuMTQyLjE4Mi4yNDciLCJnY19ob3N0IjoiYWNhZGVteW1lbC5vbmxpbmUiLCJ0aW1lIjoxNzA1NDQ5NjQyLCJwYXlsb2FkIjoidV8zNTk1MjUxODMiLCJ1aV9sYW5ndWFnZSI6InJ1IiwiaXNfaGF2ZV9jdXN0b21fc3R5bGUiOnRydWV9&s=354ad2c993d95d5ac629e3133d6cefea&vh-static-feature=zigzag',
|
||||||
@@ -20,6 +20,16 @@ class GetCourseRuPlayerIE(InfoExtractor):
|
|||||||
'duration': 1693,
|
'duration': 1693,
|
||||||
},
|
},
|
||||||
'skip': 'JWT expired',
|
'skip': 'JWT expired',
|
||||||
|
}, {
|
||||||
|
'url': 'https://cf-api-2.vhcdn.com/sign-player/?json=example',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '435735291',
|
||||||
|
'title': '8afd7c489952108e00f019590f3711f3',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'thumbnail': 'https://preview-htz.vhcdn.com/preview/8afd7c489952108e00f019590f3711f3/preview.jpg?version=1682170973&host=vh-72',
|
||||||
|
'duration': 777,
|
||||||
|
},
|
||||||
|
'skip': 'JWT expired',
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
@@ -168,7 +178,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
playlist_id = self._search_regex(
|
playlist_id = self._search_regex(
|
||||||
r'window\.(?:lessonId|gcsObjectId)\s*=\s*(\d+)', webpage, 'playlist id', default=display_id)
|
r'window\.(?:lessonId|gcsObjectId)\s*=\s*(\d+)', webpage, 'playlist id', default=display_id)
|
||||||
title = self._og_search_title(webpage) or self._html_extract_title(webpage)
|
title = self._og_search_title(webpage, default=None) or self._html_extract_title(webpage)
|
||||||
|
|
||||||
return self.playlist_from_matches(
|
return self.playlist_from_matches(
|
||||||
re.findall(GetCourseRuPlayerIE._EMBED_REGEX[0], webpage),
|
re.findall(GetCourseRuPlayerIE._EMBED_REGEX[0], webpage),
|
||||||
|
|||||||
@@ -7,161 +7,157 @@
|
|||||||
int_or_none,
|
int_or_none,
|
||||||
join_nonempty,
|
join_nonempty,
|
||||||
parse_age_limit,
|
parse_age_limit,
|
||||||
remove_end,
|
|
||||||
remove_start,
|
|
||||||
traverse_obj,
|
|
||||||
try_get,
|
|
||||||
unified_timestamp,
|
unified_timestamp,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class GoIE(AdobePassIE):
|
class GoIE(AdobePassIE):
|
||||||
_SITE_INFO = {
|
_SITE_INFO = {
|
||||||
'abc': {
|
'abc': {
|
||||||
'brand': '001',
|
'brand': '001',
|
||||||
'requestor_id': 'ABC',
|
'requestor_id': 'dtci',
|
||||||
|
'provider_id': 'ABC',
|
||||||
|
'software_statement': 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4OTcwMjlkYS0yYjM1LTQyOWUtYWQ0NS02ZjZiZjVkZTdhOTUiLCJuYmYiOjE2MjAxNzM5NjksImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNjIwMTczOTY5fQ.SC69DVJWSL8sIe-vVUrP6xS_kzHKqwz9PdKYexs_y-f7Vin6mM-7S-W1TE_-K55O0pyf-TL4xYgvm6LIye8CckG-nZfVwNPV4huduov0jmIcxCQFeUwkHULG2IaA44wfBVUBdaHgkhPweZ2amjycO_IXtez-gBXOLbE3B7Gx9j_5ISCFtyVUblThKfoGyQv6KT6t8Vpmc4ZSKCCQp74KWFFypydb9ucego1taW_nQD06Cdf4yByLd6NaTBceMcIKbug9b9gxFm3XBgJ5q3z7KGo1Kr6XalAV5j4m-fQ91wczlTilX8FM4AljMupyRM9mA_aEADILQ4hS79q4SM0w6w',
|
||||||
},
|
},
|
||||||
'freeform': {
|
'freeform': {
|
||||||
'brand': '002',
|
'brand': '002',
|
||||||
'requestor_id': 'ABCFamily',
|
'requestor_id': 'ABCFamily',
|
||||||
},
|
'provider_id': 'ABCFamily',
|
||||||
'watchdisneychannel': {
|
'software_statement': 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhZWM2MGYyNC0xYzRjLTQ1NzQtYjc0Zi03ZmM4N2E5YWMzMzgiLCJuYmYiOjE1ODc2NjU5MjMsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTg3NjY1OTIzfQ.flCn3dhvmvPnWmV0JV8Fm0YFyj07yPez9-n1GFEwVIm_S2wQVWbWyJhqsAyLZVFrhOMZYTqmPS3OHxGwTwXkEYn6PD7o_vIVG3oqi-Xn1m5jRt_Gazw5qEtpat6VE7bvKGSD3ZhcidOrsCk8NcYyq75u61NHDvSl81pcedJjVRVUpsqrEwmo0aVbA0C8PX3ri0mEbGvkMKvHn8E60xp-PSE-VK8SDT0plwPu_TwUszkZ6-_I8_2xcv_WBqcXFkAVg7Q-iNJXgQvmNsrpcrYuLvi6hEH4ZLtoDcXU6MhwTQAJTiHSo8x9aHX1_qFP09CzlNOFQbC2ZEJdP9SvA53SLQ',
|
||||||
'brand': '004',
|
|
||||||
'resource_id': 'Disney',
|
|
||||||
},
|
|
||||||
'watchdisneyjunior': {
|
|
||||||
'brand': '008',
|
|
||||||
'resource_id': 'DisneyJunior',
|
|
||||||
},
|
|
||||||
'watchdisneyxd': {
|
|
||||||
'brand': '009',
|
|
||||||
'resource_id': 'DisneyXD',
|
|
||||||
},
|
},
|
||||||
'disneynow': {
|
'disneynow': {
|
||||||
'brand': '011',
|
'brand': '011', # also: '004', '008', '009'
|
||||||
|
'requestor_id': 'DisneyChannels',
|
||||||
|
'provider_id': 'DisneyChannels',
|
||||||
|
'software_statement': 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI1MzAzNTRiOS04NDNiLTRkNjAtYTQ3ZS0yNzk1MzlkOTIyNTciLCJuYmYiOjE1NTg5ODc0NDksImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTU4OTg3NDQ5fQ.Jud6YS6-J2h0h6po0oMheDym0qRTJQGj4kzacrz4DFuEwhcBkkykW6pF5pKuAUJy9HCZ40oDAHe2KcTlDJjCZF5tDaUEfdihakZ9cC_rG7MU-QoRne8qaB_dPDKwGuk-ZyWD8eV3zwTJmbGo8hDxYTEU81YNCxwhyc_BPDr5TYiubbmpP3_pTnXmSpuL58isJ2peSKWlX9BacuXtBY25c_QnPFKk-_EETm7IHkTpDazde1QfHWGu4s4yJpKGk8RVVujVG6h6ELlL-ZeYLilBm7iS7h1TYG1u7fJhyZRL7isaom6NvAzsvN3ngss1fLwt8decP8wzdFHrbYTdTjW8qw',
|
||||||
'resource_id': 'Disney',
|
'resource_id': 'Disney',
|
||||||
},
|
},
|
||||||
'fxnow.fxnetworks': {
|
'fxnetworks': {
|
||||||
'brand': '025',
|
'brand': '025', # also: '020'
|
||||||
'requestor_id': 'dtci',
|
'requestor_id': 'dtci',
|
||||||
|
'provider_id': 'fx', # also 'fxx', 'fxm'
|
||||||
|
'software_statement': 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIzYWRhYWZiNC02OTAxLTRlYzktOTdmNy1lYWZkZTJkODJkN2EiLCJuYmYiOjE1NjIwMjQwNzYsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTYyMDI0MDc2fQ.dhKMpZK50AObbZYrMiYPSfWtzXHUaeMP3jrIY4Cgfvh0GaEgk0Mns_zp78jypFeZgRtPVleQMQDNq2YEloRLcAGqP1aa6WVDglnK77ZWUm4IKai14Rwf3A6YBhSRoO2_lMmUGkuTf6gZY-kMIPqBYKqzTQiQl4HbniPFodIzFRiuI9QJVrkoyTGrJL4oqiX08PoFI3Z-TOti1Heu3EbFC-GveQHhlinYrzU7rbiAqLEz7FImtfBDsnXX1Y3uJDLYM3Bq4Oh0nrzTv1Fd62wNsCNErHHIbELidh1zZF0ujvt7ReuZUwAitm0UhEJ7OxNOUbEQWtae6pVNscvdvTFMpg',
|
||||||
|
},
|
||||||
|
'nationalgeographic': {
|
||||||
|
'brand': '026', # also '023'
|
||||||
|
'requestor_id': 'dtci',
|
||||||
|
'provider_id': 'ngc', # also 'ngw'
|
||||||
|
'software_statement': 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMzE4YTM1Ni05Mjc4LTQ4NjEtYTFmNi1jMTIzMzg1ZWMzYzMiLCJuYmYiOjE1NjIwMjM4MjgsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTYyMDIzODI4fQ.Le-2OzF9-jrhJ7ZfWtLWk5iSHGVZoxeU1w0_fO--Heli0OwRZsRq2slSmx-oZTzxuWmAgDEiBkWSDcDK6sM25DrCLsdsJa3MBuZ-slBRtH8aq3HpNoqqLkU-vg6gRUEKMtwBUtwCu_9aKUCayYtndWv4b1DjVQeSrteOW5NNudWVYleAe0kxeNJQHo5If9SCzDudKVJktFUjhNks4QPOC_uONPkRRlL9D0fNvtOY-LRFckfcHhf5z9l1iZjeukV0YhdKnuw1wyiaWrQXBUDiBfbkCRd2DM-KnelqPxfiXCaTjGKDURRBO3pz33ebge3IFXSiU5vl4qHQ8xvunzGpFw',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_VALID_URL = r'''(?x)
|
_URL_PATH_RE = r'(?:video|episode|movies-and-specials)/(?P<id>[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12})'
|
||||||
https?://
|
_VALID_URL = [
|
||||||
(?P<sub_domain>
|
fr'https?://(?:www\.)?(?P<site>abc)\.com/{_URL_PATH_RE}',
|
||||||
(?:{}\.)?go|fxnow\.fxnetworks|
|
fr'https?://(?:www\.)?(?P<site>freeform)\.com/{_URL_PATH_RE}',
|
||||||
(?:www\.)?(?:abc|freeform|disneynow)
|
fr'https?://(?:www\.)?(?P<site>disneynow)\.com/{_URL_PATH_RE}',
|
||||||
)\.com/
|
fr'https?://fxnow\.(?P<site>fxnetworks)\.com/{_URL_PATH_RE}',
|
||||||
(?:
|
fr'https?://(?:www\.)?(?P<site>nationalgeographic)\.com/tv/{_URL_PATH_RE}',
|
||||||
(?:[^/]+/)*(?P<id>[Vv][Dd][Kk][Aa]\w+)|
|
]
|
||||||
(?:[^/]+/)*(?P<display_id>[^/?\#]+)
|
|
||||||
)
|
|
||||||
'''.format(r'\.|'.join(list(_SITE_INFO.keys())))
|
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'http://abc.go.com/shows/designated-survivor/video/most-recent/VDKA3807643',
|
'url': 'https://abc.com/episode/4192c0e6-26e5-47a8-817b-ce8272b9e440/playlist/PL551127435',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'VDKA3807643',
|
'id': 'VDKA10805898',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'The Traitor in the White House',
|
'title': 'Switch the Flip',
|
||||||
'description': 'md5:05b009d2d145a1e85d25111bd37222e8',
|
'description': 'To help get Brian’s life in order, Stewie and Brian swap bodies using a machine that Stewie invents.',
|
||||||
},
|
|
||||||
'params': {
|
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
|
||||||
'skip': 'This content is no longer available.',
|
|
||||||
}, {
|
|
||||||
'url': 'https://disneynow.com/shows/big-hero-6-the-series',
|
|
||||||
'info_dict': {
|
|
||||||
'title': 'Doraemon',
|
|
||||||
'id': 'SH55574025',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 51,
|
|
||||||
}, {
|
|
||||||
'url': 'http://freeform.go.com/shows/shadowhunters/episodes/season-2/1-this-guilty-blood',
|
|
||||||
'info_dict': {
|
|
||||||
'id': 'VDKA3609139',
|
|
||||||
'title': 'This Guilty Blood',
|
|
||||||
'description': 'md5:f18e79ad1c613798d95fdabfe96cd292',
|
|
||||||
'age_limit': 14,
|
'age_limit': 14,
|
||||||
|
'duration': 1297,
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
|
'series': 'Family Guy',
|
||||||
|
'season': 'Season 16',
|
||||||
|
'season_number': 16,
|
||||||
|
'episode': 'Episode 17',
|
||||||
|
'episode_number': 17,
|
||||||
|
'timestamp': 1746082800.0,
|
||||||
|
'upload_date': '20250501',
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
'skip': 'This video requires AdobePass MSO credentials',
|
||||||
|
}, {
|
||||||
|
'url': 'https://disneynow.com/episode/21029660-ba06-4406-adb0-a9a78f6e265e/playlist/PL553044961',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'VDKA39546942',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'Zero Friends Again',
|
||||||
|
'description': 'Relationships fray under the pressures of a difficult journey.',
|
||||||
|
'age_limit': 0,
|
||||||
|
'duration': 1721,
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
|
'series': 'Star Wars: Skeleton Crew',
|
||||||
|
'season': 'Season 1',
|
||||||
|
'season_number': 1,
|
||||||
|
'episode': 'Episode 6',
|
||||||
|
'episode_number': 6,
|
||||||
|
'timestamp': 1746946800.0,
|
||||||
|
'upload_date': '20250511',
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
'skip': 'This video requires AdobePass MSO credentials',
|
||||||
|
}, {
|
||||||
|
'url': 'https://fxnow.fxnetworks.com/episode/09f4fa6f-c293-469e-aebe-32c9ca5842a7/playlist/PL554408064',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'VDKA38112033',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'The Return of Jerry',
|
||||||
|
'description': 'The vampires’ long-lost fifth roommate returns. Written by Paul Simms; directed by Kyle Newacheck.',
|
||||||
|
'age_limit': 17,
|
||||||
|
'duration': 1493,
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
|
'series': 'What We Do in the Shadows',
|
||||||
|
'season': 'Season 6',
|
||||||
|
'season_number': 6,
|
||||||
'episode': 'Episode 1',
|
'episode': 'Episode 1',
|
||||||
'upload_date': '20170102',
|
|
||||||
'season': 'Season 2',
|
|
||||||
'thumbnail': 'http://cdn1.edgedatg.com/aws/v2/abcf/Shadowhunters/video/201/ae5f75608d86bf88aa4f9f4aa76ab1b7/579x325-Q100_ae5f75608d86bf88aa4f9f4aa76ab1b7.jpg',
|
|
||||||
'duration': 2544,
|
|
||||||
'season_number': 2,
|
|
||||||
'series': 'Shadowhunters',
|
|
||||||
'episode_number': 1,
|
'episode_number': 1,
|
||||||
'timestamp': 1483387200,
|
'timestamp': 1729573200.0,
|
||||||
'ext': 'mp4',
|
'upload_date': '20241022',
|
||||||
},
|
|
||||||
'params': {
|
|
||||||
'geo_bypass_ip_block': '3.244.239.0/24',
|
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
'skip': 'This video requires AdobePass MSO credentials',
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://abc.com/shows/the-rookie/episode-guide/season-04/12-the-knock',
|
'url': 'https://www.freeform.com/episode/bda0eaf7-761a-4838-aa44-96f794000844/playlist/PL553044961',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'VDKA26050359',
|
'id': 'VDKA39007340',
|
||||||
'title': 'The Knock',
|
|
||||||
'description': 'md5:0c2947e3ada4c31f28296db7db14aa64',
|
|
||||||
'age_limit': 14,
|
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'thumbnail': 'http://cdn1.edgedatg.com/aws/v2/abc/TheRookie/video/412/daf830d06e83b11eaf5c0a299d993ae3/1556x876-Q75_daf830d06e83b11eaf5c0a299d993ae3.jpg',
|
'title': 'Angel\'s Landing',
|
||||||
'episode': 'Episode 12',
|
'description': 'md5:91bf084e785c968fab16734df7313446',
|
||||||
'season_number': 4,
|
'age_limit': 14,
|
||||||
'season': 'Season 4',
|
'duration': 2523,
|
||||||
'timestamp': 1642975200,
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
'episode_number': 12,
|
'series': 'How I Escaped My Cult',
|
||||||
'upload_date': '20220123',
|
'season': 'Season 1',
|
||||||
'series': 'The Rookie',
|
'season_number': 1,
|
||||||
'duration': 2572,
|
'episode': 'Episode 2',
|
||||||
},
|
'episode_number': 2,
|
||||||
'params': {
|
'timestamp': 1740038400.0,
|
||||||
'geo_bypass_ip_block': '3.244.239.0/24',
|
'upload_date': '20250220',
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://fxnow.fxnetworks.com/shows/better-things/video/vdka12782841',
|
'url': 'https://www.nationalgeographic.com/tv/episode/ca694661-1186-41ae-8089-82f64d69b16d/playlist/PL554408064',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'VDKA12782841',
|
'id': 'VDKA39492078',
|
||||||
'title': 'First Look: Better Things - Season 2',
|
|
||||||
'description': 'md5:fa73584a95761c605d9d54904e35b407',
|
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'age_limit': 14,
|
'title': 'Heart of the Emperors',
|
||||||
'upload_date': '20170825',
|
'description': 'md5:4fc50a2878f030bb3a7eac9124dca677',
|
||||||
'duration': 161,
|
'age_limit': 0,
|
||||||
'series': 'Better Things',
|
'duration': 2775,
|
||||||
'thumbnail': 'http://cdn1.edgedatg.com/aws/v2/fx/BetterThings/video/12782841/b6b05e58264121cc2c98811318e6d507/1556x876-Q75_b6b05e58264121cc2c98811318e6d507.jpg',
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
'timestamp': 1503661074,
|
'series': 'Secrets of the Penguins',
|
||||||
},
|
'season': 'Season 1',
|
||||||
'params': {
|
'season_number': 1,
|
||||||
'geo_bypass_ip_block': '3.244.239.0/24',
|
'episode': 'Episode 1',
|
||||||
# m3u8 download
|
'episode_number': 1,
|
||||||
'skip_download': True,
|
'timestamp': 1745204400.0,
|
||||||
|
'upload_date': '20250421',
|
||||||
},
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
'url': 'http://abc.go.com/shows/the-catch/episode-guide/season-01/10-the-wedding',
|
'url': 'https://www.freeform.com/movies-and-specials/c38281fc-9f8f-47c7-8220-22394f9df2e1',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}, {
|
}, {
|
||||||
'url': 'http://abc.go.com/shows/world-news-tonight/episode-guide/2017-02/17-021717-intense-stand-off-between-man-with-rifle-and-police-in-oakland',
|
'url': 'https://abc.com/video/219a454a-172c-41bf-878a-d169e6bc0bdc/playlist/PL5523098420',
|
||||||
'only_matching': True,
|
|
||||||
}, {
|
|
||||||
# brand 004
|
|
||||||
'url': 'http://disneynow.go.com/shows/big-hero-6-the-series/season-01/episode-10-mr-sparkles-loses-his-sparkle/vdka4637915',
|
|
||||||
'only_matching': True,
|
|
||||||
}, {
|
|
||||||
# brand 008
|
|
||||||
'url': 'http://disneynow.go.com/shows/minnies-bow-toons/video/happy-campers/vdka4872013',
|
|
||||||
'only_matching': True,
|
|
||||||
}, {
|
|
||||||
'url': 'https://disneynow.com/shows/minnies-bow-toons/video/happy-campers/vdka4872013',
|
|
||||||
'only_matching': True,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.freeform.com/shows/cruel-summer/episode-guide/season-01/01-happy-birthday-jeanette-turner',
|
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@@ -171,58 +167,29 @@ def _extract_videos(self, brand, video_id='-1', show_id='-1'):
|
|||||||
f'http://api.contents.watchabc.go.com/vp2/ws/contents/3000/videos/{brand}/001/-1/{show_id}/-1/{video_id}/-1/-1.json',
|
f'http://api.contents.watchabc.go.com/vp2/ws/contents/3000/videos/{brand}/001/-1/{show_id}/-1/{video_id}/-1/-1.json',
|
||||||
display_id)['video']
|
display_id)['video']
|
||||||
|
|
||||||
|
def _extract_global_var(self, name, webpage, video_id):
|
||||||
|
return self._search_json(
|
||||||
|
fr'window\[["\']{re.escape(name)}["\']\]\s*=',
|
||||||
|
webpage, f'{name.strip("_")} JSON', video_id)
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
mobj = self._match_valid_url(url)
|
site, display_id = self._match_valid_url(url).group('site', 'id')
|
||||||
sub_domain = remove_start(remove_end(mobj.group('sub_domain') or '', '.go'), 'www.')
|
webpage = self._download_webpage(url, display_id)
|
||||||
video_id, display_id = mobj.group('id', 'display_id')
|
config = self._extract_global_var('__CONFIG__', webpage, display_id)
|
||||||
site_info = self._SITE_INFO.get(sub_domain, {})
|
data = self._extract_global_var(config['globalVar'], webpage, display_id)
|
||||||
brand = site_info.get('brand')
|
video_id = traverse_obj(data, (
|
||||||
if not video_id or not site_info:
|
'page', 'content', 'video', 'layout', (('video', 'id'), 'videoid'), {str}, any))
|
||||||
webpage = self._download_webpage(url, display_id or video_id)
|
|
||||||
data = self._parse_json(
|
|
||||||
self._search_regex(
|
|
||||||
r'["\']__abc_com__["\']\s*\]\s*=\s*({.+?})\s*;', webpage,
|
|
||||||
'data', default='{}'),
|
|
||||||
display_id or video_id, fatal=False)
|
|
||||||
# https://abc.com/shows/modern-family/episode-guide/season-01/101-pilot
|
|
||||||
layout = try_get(data, lambda x: x['page']['content']['video']['layout'], dict)
|
|
||||||
video_id = None
|
|
||||||
if layout:
|
|
||||||
video_id = try_get(
|
|
||||||
layout,
|
|
||||||
(lambda x: x['videoid'], lambda x: x['video']['id']),
|
|
||||||
str)
|
|
||||||
if not video_id:
|
if not video_id:
|
||||||
video_id = self._search_regex(
|
video_id = self._search_regex([
|
||||||
(
|
# data-track-video_id="VDKA39492078"
|
||||||
# There may be inner quotes, e.g. data-video-id="'VDKA3609139'"
|
# data-track-video_id_code="vdka39492078"
|
||||||
# from http://freeform.go.com/shows/shadowhunters/episodes/season-2/1-this-guilty-blood
|
# data-video-id="'VDKA3609139'"
|
||||||
r'data-video-id=["\']*(VDKA\w+)',
|
r'data-(?:track-)?video[_-]id(?:_code)?=["\']*((?:vdka|VDKA)\d+)',
|
||||||
# page.analytics.videoIdCode
|
# page.analytics.videoIdCode
|
||||||
r'\bvideoIdCode["\']\s*:\s*["\']((?:vdka|VDKA)\w+)',
|
r'\bvideoIdCode["\']\s*:\s*["\']((?:vdka|VDKA)\d+)'], webpage, 'video ID')
|
||||||
# https://abc.com/shows/the-rookie/episode-guide/season-02/03-the-bet
|
|
||||||
r'\b(?:video)?id["\']\s*:\s*["\'](VDKA\w+)',
|
site_info = self._SITE_INFO[site]
|
||||||
), webpage, 'video id', default=video_id)
|
brand = site_info['brand']
|
||||||
if not site_info:
|
|
||||||
brand = self._search_regex(
|
|
||||||
(r'data-brand=\s*["\']\s*(\d+)',
|
|
||||||
r'data-page-brand=\s*["\']\s*(\d+)'), webpage, 'brand',
|
|
||||||
default='004')
|
|
||||||
site_info = next(
|
|
||||||
si for _, si in self._SITE_INFO.items()
|
|
||||||
if si.get('brand') == brand)
|
|
||||||
if not video_id:
|
|
||||||
# show extraction works for Disney, DisneyJunior and DisneyXD
|
|
||||||
# ABC and Freeform has different layout
|
|
||||||
show_id = self._search_regex(r'data-show-id=["\']*(SH\d+)', webpage, 'show id')
|
|
||||||
videos = self._extract_videos(brand, show_id=show_id)
|
|
||||||
show_title = self._search_regex(r'data-show-title="([^"]+)"', webpage, 'show title', fatal=False)
|
|
||||||
entries = []
|
|
||||||
for video in videos:
|
|
||||||
entries.append(self.url_result(
|
|
||||||
video['url'], 'Go', video.get('id'), video.get('title')))
|
|
||||||
entries.reverse()
|
|
||||||
return self.playlist_result(entries, show_id, show_title)
|
|
||||||
video_data = self._extract_videos(brand, video_id)[0]
|
video_data = self._extract_videos(brand, video_id)[0]
|
||||||
video_id = video_data['id']
|
video_id = video_data['id']
|
||||||
title = video_data['title']
|
title = video_data['title']
|
||||||
@@ -238,26 +205,31 @@ def _real_extract(self, url):
|
|||||||
if ext == 'm3u8':
|
if ext == 'm3u8':
|
||||||
video_type = video_data.get('type')
|
video_type = video_data.get('type')
|
||||||
data = {
|
data = {
|
||||||
'video_id': video_data['id'],
|
'video_id': video_id,
|
||||||
'video_type': video_type,
|
'video_type': video_type,
|
||||||
'brand': brand,
|
'brand': brand,
|
||||||
'device': '001',
|
'device': '001',
|
||||||
|
'app_name': 'webplayer-abc',
|
||||||
}
|
}
|
||||||
if video_data.get('accesslevel') == '1':
|
if video_data.get('accesslevel') == '1':
|
||||||
requestor_id = site_info.get('requestor_id', 'DisneyChannels')
|
provider_id = site_info['provider_id']
|
||||||
|
software_statement = traverse_obj(data, ('app', 'config', (
|
||||||
|
('features', 'auth', 'softwareStatement'),
|
||||||
|
('tvAuth', 'SOFTWARE_STATEMENTS', 'PRODUCTION'),
|
||||||
|
), {str}, any)) or site_info['software_statement']
|
||||||
resource = site_info.get('resource_id') or self._get_mvpd_resource(
|
resource = site_info.get('resource_id') or self._get_mvpd_resource(
|
||||||
requestor_id, title, video_id, None)
|
provider_id, title, video_id, None)
|
||||||
auth = self._extract_mvpd_auth(
|
auth = self._extract_mvpd_auth(
|
||||||
url, video_id, requestor_id, resource)
|
url, video_id, site_info['requestor_id'], resource, software_statement)
|
||||||
data.update({
|
data.update({
|
||||||
'token': auth,
|
'token': auth,
|
||||||
'token_type': 'ap',
|
'token_type': 'ap',
|
||||||
'adobe_requestor_id': requestor_id,
|
'adobe_requestor_id': provider_id,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
self._initialize_geo_bypass({'countries': ['US']})
|
self._initialize_geo_bypass({'countries': ['US']})
|
||||||
entitlement = self._download_json(
|
entitlement = self._download_json(
|
||||||
'https://api.entitlement.watchabc.go.com/vp2/ws-secure/entitlement/2020/authorize.json',
|
'https://prod.gatekeeper.us-abc.symphony.edgedatg.go.com/vp2/ws-secure/entitlement/2020/playmanifest_secure.json',
|
||||||
video_id, data=urlencode_postdata(data))
|
video_id, data=urlencode_postdata(data))
|
||||||
errors = entitlement.get('errors', {}).get('errors', [])
|
errors = entitlement.get('errors', {}).get('errors', [])
|
||||||
if errors:
|
if errors:
|
||||||
@@ -267,7 +239,7 @@ def _real_extract(self, url):
|
|||||||
error['message'], countries=['US'])
|
error['message'], countries=['US'])
|
||||||
error_message = ', '.join([error['message'] for error in errors])
|
error_message = ', '.join([error['message'] for error in errors])
|
||||||
raise ExtractorError(f'{self.IE_NAME} said: {error_message}', expected=True)
|
raise ExtractorError(f'{self.IE_NAME} said: {error_message}', expected=True)
|
||||||
asset_url += '?' + entitlement['uplynkData']['sessionKey']
|
asset_url += '?' + entitlement['entitlement']['uplynkData']['sessionKey']
|
||||||
fmts, subs = self._extract_m3u8_formats_and_subtitles(
|
fmts, subs = self._extract_m3u8_formats_and_subtitles(
|
||||||
asset_url, video_id, 'mp4', m3u8_id=format_id or 'hls', fatal=False)
|
asset_url, video_id, 'mp4', m3u8_id=format_id or 'hls', fatal=False)
|
||||||
formats.extend(fmts)
|
formats.extend(fmts)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import functools
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
@@ -9,18 +10,20 @@
|
|||||||
from ..networking.exceptions import HTTPError
|
from ..networking.exceptions import HTTPError
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
|
OnDemandPagedList,
|
||||||
determine_ext,
|
determine_ext,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
join_nonempty,
|
join_nonempty,
|
||||||
str_or_none,
|
str_or_none,
|
||||||
traverse_obj,
|
|
||||||
url_or_none,
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class HotStarBaseIE(InfoExtractor):
|
class HotStarBaseIE(InfoExtractor):
|
||||||
_BASE_URL = 'https://www.hotstar.com'
|
_BASE_URL = 'https://www.hotstar.com'
|
||||||
_API_URL = 'https://api.hotstar.com'
|
_API_URL = 'https://api.hotstar.com'
|
||||||
|
_API_URL_V2 = 'https://apix.hotstar.com/v2'
|
||||||
_AKAMAI_ENCRYPTION_KEY = b'\x05\xfc\x1a\x01\xca\xc9\x4b\xc4\x12\xfc\x53\x12\x07\x75\xf9\xee'
|
_AKAMAI_ENCRYPTION_KEY = b'\x05\xfc\x1a\x01\xca\xc9\x4b\xc4\x12\xfc\x53\x12\x07\x75\xf9\xee'
|
||||||
|
|
||||||
def _call_api_v1(self, path, *args, **kwargs):
|
def _call_api_v1(self, path, *args, **kwargs):
|
||||||
@@ -29,57 +32,86 @@ def _call_api_v1(self, path, *args, **kwargs):
|
|||||||
headers={'x-country-code': 'IN', 'x-platform-code': 'PCTV'})
|
headers={'x-country-code': 'IN', 'x-platform-code': 'PCTV'})
|
||||||
|
|
||||||
def _call_api_impl(self, path, video_id, query, st=None, cookies=None):
|
def _call_api_impl(self, path, video_id, query, st=None, cookies=None):
|
||||||
|
if not cookies or not cookies.get('userUP'):
|
||||||
|
self.raise_login_required()
|
||||||
|
|
||||||
st = int_or_none(st) or int(time.time())
|
st = int_or_none(st) or int(time.time())
|
||||||
exp = st + 6000
|
exp = st + 6000
|
||||||
auth = f'st={st}~exp={exp}~acl=/*'
|
auth = f'st={st}~exp={exp}~acl=/*'
|
||||||
auth += '~hmac=' + hmac.new(self._AKAMAI_ENCRYPTION_KEY, auth.encode(), hashlib.sha256).hexdigest()
|
auth += '~hmac=' + hmac.new(self._AKAMAI_ENCRYPTION_KEY, auth.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
if cookies and cookies.get('userUP'):
|
|
||||||
token = cookies.get('userUP').value
|
|
||||||
else:
|
|
||||||
token = self._download_json(
|
|
||||||
f'{self._API_URL}/um/v3/users',
|
|
||||||
video_id, note='Downloading token',
|
|
||||||
data=json.dumps({'device_ids': [{'id': str(uuid.uuid4()), 'type': 'device_id'}]}).encode(),
|
|
||||||
headers={
|
|
||||||
'hotstarauth': auth,
|
|
||||||
'x-hs-platform': 'PCTV', # or 'web'
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
})['user_identity']
|
|
||||||
|
|
||||||
response = self._download_json(
|
response = self._download_json(
|
||||||
f'{self._API_URL}/{path}', video_id, query=query,
|
f'{self._API_URL_V2}/{path}', video_id, query=query,
|
||||||
headers={
|
headers={
|
||||||
|
'user-agent': 'Disney+;in.startv.hotstar.dplus.tv/23.08.14.4.2915 (Android/13)',
|
||||||
'hotstarauth': auth,
|
'hotstarauth': auth,
|
||||||
'x-hs-appversion': '6.72.2',
|
'x-hs-usertoken': cookies['userUP'].value,
|
||||||
'x-hs-platform': 'web',
|
'x-hs-device-id': traverse_obj(cookies, ('deviceId', 'value')) or str(uuid.uuid4()),
|
||||||
'x-hs-usertoken': token,
|
'x-hs-client': 'platform:androidtv;app_id:in.startv.hotstar.dplus.tv;app_version:23.08.14.4;os:Android;os_version:13;schema_version:0.0.970',
|
||||||
|
'x-hs-platform': 'androidtv',
|
||||||
|
'content-type': 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
if response['message'] != "Playback URL's fetched successfully":
|
if not traverse_obj(response, ('success', {dict})):
|
||||||
raise ExtractorError(
|
raise ExtractorError('API call was unsuccessful')
|
||||||
response['message'], expected=True)
|
return response['success']
|
||||||
return response['data']
|
|
||||||
|
|
||||||
def _call_api_v2(self, path, video_id, st=None, cookies=None):
|
def _call_api_v2(self, path, video_id, content_type, cookies=None, st=None):
|
||||||
return self._call_api_impl(
|
return self._call_api_impl(f'{path}', video_id, query={
|
||||||
f'{path}/content/{video_id}', video_id, st=st, cookies=cookies, query={
|
'content_id': video_id,
|
||||||
'desired-config': 'audio_channel:stereo|container:fmp4|dynamic_range:hdr|encryption:plain|ladder:tv|package:dash|resolution:fhd|subs-tag:HotstarVIP|video_codec:h265',
|
'filters': f'content_type={content_type}',
|
||||||
'device-id': cookies.get('device_id').value if cookies.get('device_id') else str(uuid.uuid4()),
|
'client_capabilities': json.dumps({
|
||||||
'os-name': 'Windows',
|
'package': ['dash', 'hls'],
|
||||||
'os-version': '10',
|
'container': ['fmp4br', 'fmp4'],
|
||||||
|
'ads': ['non_ssai', 'ssai'],
|
||||||
|
'audio_channel': ['atmos', 'dolby51', 'stereo'],
|
||||||
|
'encryption': ['plain', 'widevine'], # wv only so we can raise appropriate error
|
||||||
|
'video_codec': ['h265', 'h264'],
|
||||||
|
'ladder': ['tv', 'full'],
|
||||||
|
'resolution': ['4k', 'hd'],
|
||||||
|
'true_resolution': ['4k', 'hd'],
|
||||||
|
'dynamic_range': ['hdr', 'sdr'],
|
||||||
|
}, separators=(',', ':')),
|
||||||
|
'drm_parameters': json.dumps({
|
||||||
|
'widevine_security_level': ['SW_SECURE_DECODE', 'SW_SECURE_CRYPTO'],
|
||||||
|
'hdcp_version': ['HDCP_V2_2', 'HDCP_V2_1', 'HDCP_V2', 'HDCP_V1'],
|
||||||
|
}, separators=(',', ':')),
|
||||||
|
}, st=st, cookies=cookies)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_metadata_v1(video_data):
|
||||||
|
return traverse_obj(video_data, {
|
||||||
|
'id': ('contentId', {str}),
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'description': ('description', {str}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'timestamp': (('broadcastDate', 'startDate'), {int_or_none}, any),
|
||||||
|
'release_year': ('year', {int_or_none}),
|
||||||
|
'channel': ('channelName', {str}),
|
||||||
|
'channel_id': ('channelId', {int}, {str_or_none}),
|
||||||
|
'series': ('showName', {str}),
|
||||||
|
'season': ('seasonName', {str}),
|
||||||
|
'season_number': ('seasonNo', {int_or_none}),
|
||||||
|
'season_id': ('seasonId', {int}, {str_or_none}),
|
||||||
|
'episode': ('title', {str}),
|
||||||
|
'episode_number': ('episodeNo', {int_or_none}),
|
||||||
})
|
})
|
||||||
|
|
||||||
def _playlist_entries(self, path, item_id, root=None, **kwargs):
|
def _fetch_page(self, path, item_id, name, query, root, page):
|
||||||
results = self._call_api_v1(path, item_id, **kwargs)['body']['results']
|
results = self._call_api_v1(
|
||||||
for video in traverse_obj(results, (('assets', None), 'items', ...)):
|
path, item_id, note=f'Downloading {name} page {page + 1} JSON', query={
|
||||||
if video.get('contentId'):
|
**query,
|
||||||
|
'tao': page * self._PAGE_SIZE,
|
||||||
|
'tas': self._PAGE_SIZE,
|
||||||
|
})['body']['results']
|
||||||
|
|
||||||
|
for video in traverse_obj(results, (('assets', None), 'items', lambda _, v: v['contentId'])):
|
||||||
yield self.url_result(
|
yield self.url_result(
|
||||||
HotStarIE._video_url(video['contentId'], root=root), HotStarIE, video['contentId'])
|
HotStarIE._video_url(video['contentId'], root=root), HotStarIE, **self._parse_metadata_v1(video))
|
||||||
|
|
||||||
|
|
||||||
class HotStarIE(HotStarBaseIE):
|
class HotStarIE(HotStarBaseIE):
|
||||||
IE_NAME = 'hotstar'
|
IE_NAME = 'hotstar'
|
||||||
|
IE_DESC = 'JioHotstar'
|
||||||
_VALID_URL = r'''(?x)
|
_VALID_URL = r'''(?x)
|
||||||
https?://(?:www\.)?hotstar\.com(?:/in)?/(?!in/)
|
https?://(?:www\.)?hotstar\.com(?:/in)?/(?!in/)
|
||||||
(?:
|
(?:
|
||||||
@@ -114,15 +146,16 @@ class HotStarIE(HotStarBaseIE):
|
|||||||
'upload_date': '20190501',
|
'upload_date': '20190501',
|
||||||
'duration': 1219,
|
'duration': 1219,
|
||||||
'channel': 'StarPlus',
|
'channel': 'StarPlus',
|
||||||
'channel_id': '3',
|
'channel_id': '821',
|
||||||
'series': 'Ek Bhram - Sarvagun Sampanna',
|
'series': 'Ek Bhram - Sarvagun Sampanna',
|
||||||
'season': 'Chapter 1',
|
'season': 'Chapter 1',
|
||||||
'season_number': 1,
|
'season_number': 1,
|
||||||
'season_id': '6771',
|
'season_id': '1260004607',
|
||||||
'episode': 'Janhvi Targets Suman',
|
'episode': 'Janhvi Targets Suman',
|
||||||
'episode_number': 8,
|
'episode_number': 8,
|
||||||
},
|
},
|
||||||
}, {
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}, { # Metadata call gets HTTP Error 504 with tas=10000
|
||||||
'url': 'https://www.hotstar.com/in/shows/anupama/1260022017/anupama-anuj-share-a-moment/1000282843',
|
'url': 'https://www.hotstar.com/in/shows/anupama/1260022017/anupama-anuj-share-a-moment/1000282843',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '1000282843',
|
'id': '1000282843',
|
||||||
@@ -134,14 +167,14 @@ class HotStarIE(HotStarBaseIE):
|
|||||||
'channel': 'StarPlus',
|
'channel': 'StarPlus',
|
||||||
'series': 'Anupama',
|
'series': 'Anupama',
|
||||||
'season_number': 1,
|
'season_number': 1,
|
||||||
'season_id': '7399',
|
'season_id': '1260022018',
|
||||||
'upload_date': '20230307',
|
'upload_date': '20230307',
|
||||||
'episode': 'Anupama, Anuj Share a Moment',
|
'episode': 'Anupama, Anuj Share a Moment',
|
||||||
'episode_number': 853,
|
'episode_number': 853,
|
||||||
'duration': 1272,
|
'duration': 1266,
|
||||||
'channel_id': '3',
|
'channel_id': '821',
|
||||||
},
|
},
|
||||||
'skip': 'HTTP Error 504: Gateway Time-out', # XXX: Investigate 504 errors on some episodes
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.hotstar.com/in/shows/kana-kaanum-kaalangal/1260097087/back-to-school/1260097320',
|
'url': 'https://www.hotstar.com/in/shows/kana-kaanum-kaalangal/1260097087/back-to-school/1260097320',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -154,14 +187,15 @@ class HotStarIE(HotStarBaseIE):
|
|||||||
'channel': 'Hotstar Specials',
|
'channel': 'Hotstar Specials',
|
||||||
'series': 'Kana Kaanum Kaalangal',
|
'series': 'Kana Kaanum Kaalangal',
|
||||||
'season_number': 1,
|
'season_number': 1,
|
||||||
'season_id': '9441',
|
'season_id': '1260097089',
|
||||||
'upload_date': '20220421',
|
'upload_date': '20220421',
|
||||||
'episode': 'Back To School',
|
'episode': 'Back To School',
|
||||||
'episode_number': 1,
|
'episode_number': 1,
|
||||||
'duration': 1810,
|
'duration': 1810,
|
||||||
'channel_id': '54',
|
'channel_id': '1260003991',
|
||||||
},
|
},
|
||||||
}, {
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}, { # Metadata call gets HTTP Error 504 with tas=10000
|
||||||
'url': 'https://www.hotstar.com/in/clips/e3-sairat-kahani-pyaar-ki/1000262286',
|
'url': 'https://www.hotstar.com/in/clips/e3-sairat-kahani-pyaar-ki/1000262286',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '1000262286',
|
'id': '1000262286',
|
||||||
@@ -173,6 +207,7 @@ class HotStarIE(HotStarBaseIE):
|
|||||||
'timestamp': 1622943900,
|
'timestamp': 1622943900,
|
||||||
'duration': 5395,
|
'duration': 5395,
|
||||||
},
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.hotstar.com/in/movies/premam/1000091195',
|
'url': 'https://www.hotstar.com/in/movies/premam/1000091195',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -180,12 +215,13 @@ class HotStarIE(HotStarBaseIE):
|
|||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Premam',
|
'title': 'Premam',
|
||||||
'release_year': 2015,
|
'release_year': 2015,
|
||||||
'description': 'md5:d833c654e4187b5e34757eafb5b72d7f',
|
'description': 'md5:096cd8aaae8dab56524823dc19dfa9f7',
|
||||||
'timestamp': 1462149000,
|
'timestamp': 1462149000,
|
||||||
'upload_date': '20160502',
|
'upload_date': '20160502',
|
||||||
'episode': 'Premam',
|
'episode': 'Premam',
|
||||||
'duration': 8994,
|
'duration': 8994,
|
||||||
},
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.hotstar.com/movies/radha-gopalam/1000057157',
|
'url': 'https://www.hotstar.com/movies/radha-gopalam/1000057157',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -208,6 +244,13 @@ class HotStarIE(HotStarBaseIE):
|
|||||||
None: 'content',
|
None: 'content',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_CONTENT_TYPE = {
|
||||||
|
'movie': 'MOVIE',
|
||||||
|
'episode': 'EPISODE',
|
||||||
|
'match': 'SPORT',
|
||||||
|
'content': 'CLIPS',
|
||||||
|
}
|
||||||
|
|
||||||
_IGNORE_MAP = {
|
_IGNORE_MAP = {
|
||||||
'res': 'resolution',
|
'res': 'resolution',
|
||||||
'vcodec': 'video_codec',
|
'vcodec': 'video_codec',
|
||||||
@@ -229,38 +272,48 @@ def _video_url(cls, video_id, video_type=None, *, slug='ignore_me', root=None):
|
|||||||
|
|
||||||
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')
|
||||||
video_type = self._TYPE.get(video_type, video_type)
|
video_type = self._TYPE[video_type]
|
||||||
cookies = self._get_cookies(url) # Cookies before any request
|
cookies = self._get_cookies(url) # Cookies before any request
|
||||||
|
|
||||||
video_data = traverse_obj(
|
video_data = traverse_obj(
|
||||||
self._call_api_v1(
|
self._call_api_v1(f'{video_type}/detail', video_id, fatal=False, query={
|
||||||
f'{video_type}/detail', video_id, fatal=False, query={'tas': 10000, 'contentId': video_id}),
|
'tas': 5, # See https://github.com/yt-dlp/yt-dlp/issues/7946
|
||||||
('body', 'results', 'item', {dict})) or {}
|
'contentId': video_id,
|
||||||
if not self.get_param('allow_unplayable_formats') and video_data.get('drmProtected'):
|
}), ('body', 'results', 'item', {dict})) or {}
|
||||||
|
|
||||||
|
if video_data.get('drmProtected'):
|
||||||
self.report_drm(video_id)
|
self.report_drm(video_id)
|
||||||
|
|
||||||
# See https://github.com/yt-dlp/yt-dlp/issues/396
|
|
||||||
st = self._download_webpage_handle(f'{self._BASE_URL}/in', video_id)[1].headers.get('x-origin-date')
|
|
||||||
|
|
||||||
geo_restricted = False
|
geo_restricted = False
|
||||||
formats, subs = [], {}
|
formats, subs, has_drm = [], {}, False
|
||||||
headers = {'Referer': f'{self._BASE_URL}/in'}
|
headers = {'Referer': f'{self._BASE_URL}/in'}
|
||||||
|
content_type = traverse_obj(video_data, ('contentType', {str})) or self._CONTENT_TYPE[video_type]
|
||||||
|
|
||||||
# change to v2 in the future
|
# See https://github.com/yt-dlp/yt-dlp/issues/396
|
||||||
playback_sets = self._call_api_v2('play/v1/playback', video_id, st=st, cookies=cookies)['playBackSets']
|
st = self._request_webpage(
|
||||||
for playback_set in playback_sets:
|
f'{self._BASE_URL}/in', video_id, 'Fetching server time').get_header('x-origin-date')
|
||||||
if not isinstance(playback_set, dict):
|
watch = self._call_api_v2('pages/watch', video_id, content_type, cookies=cookies, st=st)
|
||||||
continue
|
player_config = traverse_obj(watch, (
|
||||||
tags = str_or_none(playback_set.get('tagsCombination')) or ''
|
'page', 'spaces', 'player', 'widget_wrappers', lambda _, v: v['template'] == 'PlayerWidget',
|
||||||
|
'widget', 'data', 'player_config', {dict}, any, {require('player config')}))
|
||||||
|
|
||||||
|
for playback_set in traverse_obj(player_config, (
|
||||||
|
('media_asset', 'media_asset_v2'),
|
||||||
|
('primary', 'fallback'),
|
||||||
|
all, lambda _, v: url_or_none(v['content_url']),
|
||||||
|
)):
|
||||||
|
tags = str_or_none(playback_set.get('playback_tags')) or ''
|
||||||
if any(f'{prefix}:{ignore}' in tags
|
if any(f'{prefix}:{ignore}' in tags
|
||||||
for key, prefix in self._IGNORE_MAP.items()
|
for key, prefix in self._IGNORE_MAP.items()
|
||||||
for ignore in self._configuration_arg(key)):
|
for ignore in self._configuration_arg(key)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
format_url = url_or_none(playback_set.get('playbackUrl'))
|
tag_dict = dict((*t.split(':', 1), None)[:2] for t in tags.split(';'))
|
||||||
if not format_url:
|
if tag_dict.get('encryption') not in ('plain', None):
|
||||||
|
has_drm = True
|
||||||
continue
|
continue
|
||||||
format_url = re.sub(r'(?<=//staragvod)(\d)', r'web\1', format_url)
|
|
||||||
|
format_url = re.sub(r'(?<=//staragvod)(\d)', r'web\1', playback_set['content_url'])
|
||||||
ext = determine_ext(format_url)
|
ext = determine_ext(format_url)
|
||||||
|
|
||||||
current_formats, current_subs = [], {}
|
current_formats, current_subs = [], {}
|
||||||
@@ -280,14 +333,12 @@ def _real_extract(self, url):
|
|||||||
'height': int_or_none(playback_set.get('height')),
|
'height': int_or_none(playback_set.get('height')),
|
||||||
}]
|
}]
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
if isinstance(e.cause, HTTPError) and e.cause.status == 403:
|
if isinstance(e.cause, HTTPError) and e.cause.status in (403, 474):
|
||||||
geo_restricted = True
|
geo_restricted = True
|
||||||
|
else:
|
||||||
|
self.write_debug(e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tag_dict = dict((*t.split(':', 1), None)[:2] for t in tags.split(';'))
|
|
||||||
if tag_dict.get('encryption') not in ('plain', None):
|
|
||||||
for f in current_formats:
|
|
||||||
f['has_drm'] = True
|
|
||||||
for f in current_formats:
|
for f in current_formats:
|
||||||
for k, v in self._TAG_FIELDS.items():
|
for k, v in self._TAG_FIELDS.items():
|
||||||
if not f.get(k):
|
if not f.get(k):
|
||||||
@@ -299,6 +350,11 @@ def _real_extract(self, url):
|
|||||||
'stereo': 2,
|
'stereo': 2,
|
||||||
'dolby51': 6,
|
'dolby51': 6,
|
||||||
}.get(tag_dict.get('audio_channel'))
|
}.get(tag_dict.get('audio_channel'))
|
||||||
|
if (
|
||||||
|
'Audio_Description' in f['format_id']
|
||||||
|
or 'Audio Description' in (f.get('format_note') or '')
|
||||||
|
):
|
||||||
|
f['source_preference'] = -99 + (f.get('source_preference') or -1)
|
||||||
f['format_note'] = join_nonempty(
|
f['format_note'] = join_nonempty(
|
||||||
tag_dict.get('ladder'),
|
tag_dict.get('ladder'),
|
||||||
tag_dict.get('audio_channel') if f.get('acodec') != 'none' else None,
|
tag_dict.get('audio_channel') if f.get('acodec') != 'none' else None,
|
||||||
@@ -310,27 +366,17 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
if not formats and geo_restricted:
|
if not formats and geo_restricted:
|
||||||
self.raise_geo_restricted(countries=['IN'], metadata_available=True)
|
self.raise_geo_restricted(countries=['IN'], metadata_available=True)
|
||||||
|
elif not formats and has_drm:
|
||||||
|
self.report_drm(video_id)
|
||||||
self._remove_duplicate_formats(formats)
|
self._remove_duplicate_formats(formats)
|
||||||
for f in formats:
|
for f in formats:
|
||||||
f.setdefault('http_headers', {}).update(headers)
|
f.setdefault('http_headers', {}).update(headers)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
**self._parse_metadata_v1(video_data),
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': video_data.get('title'),
|
|
||||||
'description': video_data.get('description'),
|
|
||||||
'duration': int_or_none(video_data.get('duration')),
|
|
||||||
'timestamp': int_or_none(traverse_obj(video_data, 'broadcastDate', 'startDate')),
|
|
||||||
'release_year': int_or_none(video_data.get('year')),
|
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'subtitles': subs,
|
'subtitles': subs,
|
||||||
'channel': video_data.get('channelName'),
|
|
||||||
'channel_id': str_or_none(video_data.get('channelId')),
|
|
||||||
'series': video_data.get('showName'),
|
|
||||||
'season': video_data.get('seasonName'),
|
|
||||||
'season_number': int_or_none(video_data.get('seasonNo')),
|
|
||||||
'season_id': str_or_none(video_data.get('seasonId')),
|
|
||||||
'episode': video_data.get('title'),
|
|
||||||
'episode_number': int_or_none(video_data.get('episodeNo')),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -371,64 +417,6 @@ def _real_extract(self, url):
|
|||||||
return self.url_result(HotStarIE._video_url(video_id, video_type), HotStarIE, video_id)
|
return self.url_result(HotStarIE._video_url(video_id, video_type), HotStarIE, video_id)
|
||||||
|
|
||||||
|
|
||||||
class HotStarPlaylistIE(HotStarBaseIE):
|
|
||||||
IE_NAME = 'hotstar:playlist'
|
|
||||||
_VALID_URL = r'https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)(?:/[^/]+){2}/list/[^/]+/t-(?P<id>\w+)'
|
|
||||||
_TESTS = [{
|
|
||||||
'url': 'https://www.hotstar.com/tv/savdhaan-india/s-26/list/popular-clips/t-3_2_26',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3_2_26',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 20,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.hotstar.com/shows/savdhaan-india/s-26/list/popular-clips/t-3_2_26',
|
|
||||||
'only_matching': True,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.hotstar.com/tv/savdhaan-india/s-26/list/extras/t-2480',
|
|
||||||
'only_matching': True,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.hotstar.com/in/tv/karthika-deepam/15457/list/popular-clips/t-3_2_1272',
|
|
||||||
'only_matching': True,
|
|
||||||
}]
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
id_ = self._match_id(url)
|
|
||||||
return self.playlist_result(
|
|
||||||
self._playlist_entries('tray/find', id_, query={'tas': 10000, 'uqId': id_}), id_)
|
|
||||||
|
|
||||||
|
|
||||||
class HotStarSeasonIE(HotStarBaseIE):
|
|
||||||
IE_NAME = 'hotstar:season'
|
|
||||||
_VALID_URL = r'(?P<url>https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)/[^/]+/\w+)/seasons/[^/]+/ss-(?P<id>\w+)'
|
|
||||||
_TESTS = [{
|
|
||||||
'url': 'https://www.hotstar.com/tv/radhakrishn/1260000646/seasons/season-2/ss-8028',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '8028',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 35,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.hotstar.com/in/tv/ishqbaaz/9567/seasons/season-2/ss-4357',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '4357',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 30,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.hotstar.com/in/tv/bigg-boss/14714/seasons/season-4/ss-8208/',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '8208',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 19,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.hotstar.com/in/shows/bigg-boss/14714/seasons/season-4/ss-8208/',
|
|
||||||
'only_matching': True,
|
|
||||||
}]
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
url, season_id = self._match_valid_url(url).groups()
|
|
||||||
return self.playlist_result(self._playlist_entries(
|
|
||||||
'season/asset', season_id, url, query={'tao': 0, 'tas': 0, 'size': 10000, 'id': season_id}), season_id)
|
|
||||||
|
|
||||||
|
|
||||||
class HotStarSeriesIE(HotStarBaseIE):
|
class HotStarSeriesIE(HotStarBaseIE):
|
||||||
IE_NAME = 'hotstar:series'
|
IE_NAME = 'hotstar:series'
|
||||||
_VALID_URL = r'(?P<url>https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)/[^/]+/(?P<id>\d+))/?(?:[#?]|$)'
|
_VALID_URL = r'(?P<url>https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)/[^/]+/(?P<id>\d+))/?(?:[#?]|$)'
|
||||||
@@ -443,25 +431,29 @@ class HotStarSeriesIE(HotStarBaseIE):
|
|||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '1260050431',
|
'id': '1260050431',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 43,
|
'playlist_mincount': 42,
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.hotstar.com/in/tv/mahabharat/435/',
|
'url': 'https://www.hotstar.com/in/tv/mahabharat/435/',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '435',
|
'id': '435',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 267,
|
'playlist_mincount': 267,
|
||||||
}, {
|
}, { # HTTP Error 504 with tas=10000 (possibly because total size is over 1000 items?)
|
||||||
'url': 'https://www.hotstar.com/in/shows/anupama/1260022017/',
|
'url': 'https://www.hotstar.com/in/shows/anupama/1260022017/',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '1260022017',
|
'id': '1260022017',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 940,
|
'playlist_mincount': 1601,
|
||||||
}]
|
}]
|
||||||
|
_PAGE_SIZE = 100
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
url, series_id = self._match_valid_url(url).groups()
|
url, series_id = self._match_valid_url(url).group('url', 'id')
|
||||||
id_ = self._call_api_v1(
|
eid = self._call_api_v1(
|
||||||
'show/detail', series_id, query={'contentId': series_id})['body']['results']['item']['id']
|
'show/detail', series_id, query={'contentId': series_id})['body']['results']['item']['id']
|
||||||
|
|
||||||
return self.playlist_result(self._playlist_entries(
|
entries = OnDemandPagedList(functools.partial(
|
||||||
'tray/g/1/items', series_id, url, query={'tao': 0, 'tas': 10000, 'etid': 0, 'eid': id_}), series_id)
|
self._fetch_page, 'tray/g/1/items', series_id,
|
||||||
|
'series', {'etid': 0, 'eid': eid}, url), self._PAGE_SIZE)
|
||||||
|
|
||||||
|
return self.playlist_result(entries, series_id)
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
|
clean_html,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
str_or_none,
|
str_or_none,
|
||||||
try_get,
|
try_get,
|
||||||
unescapeHTML,
|
unescapeHTML,
|
||||||
unified_strdate,
|
update_url,
|
||||||
update_url_query,
|
update_url_query,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
)
|
)
|
||||||
@@ -22,8 +23,8 @@
|
|||||||
class HuyaLiveIE(InfoExtractor):
|
class HuyaLiveIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:www\.|m\.)?huya\.com/(?!(?:video/play/))(?P<id>[^/#?&]+)(?:\D|$)'
|
_VALID_URL = r'https?://(?:www\.|m\.)?huya\.com/(?!(?:video/play/))(?P<id>[^/#?&]+)(?:\D|$)'
|
||||||
IE_NAME = 'huya:live'
|
IE_NAME = 'huya:live'
|
||||||
IE_DESC = 'huya.com'
|
IE_DESC = '虎牙直播'
|
||||||
TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.huya.com/572329',
|
'url': 'https://www.huya.com/572329',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '572329',
|
'id': '572329',
|
||||||
@@ -149,63 +150,94 @@ class HuyaVideoIE(InfoExtractor):
|
|||||||
'id': '1002412640',
|
'id': '1002412640',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': '8月3日',
|
'title': '8月3日',
|
||||||
'thumbnail': r're:https?://.*\.jpg',
|
'categories': ['主机游戏'],
|
||||||
'duration': 14,
|
'duration': 14.0,
|
||||||
'uploader': '虎牙-ATS欧卡车队青木',
|
'uploader': '虎牙-ATS欧卡车队青木',
|
||||||
'uploader_id': '1564376151',
|
'uploader_id': '1564376151',
|
||||||
'upload_date': '20240803',
|
'upload_date': '20240803',
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
'comment_count': int,
|
'comment_count': int,
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
|
'timestamp': 1722675433,
|
||||||
},
|
},
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
'url': 'https://www.huya.com/video/play/556054543.html',
|
'url': 'https://www.huya.com/video/play/556054543.html',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '556054543',
|
'id': '556054543',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': '我不挑事 也不怕事',
|
'title': '我不挑事 也不怕事',
|
||||||
'thumbnail': r're:https?://.*\.jpg',
|
'categories': ['英雄联盟'],
|
||||||
'duration': 1864,
|
'description': 'md5:58184869687d18ce62dc7b4b2ad21201',
|
||||||
|
'duration': 1864.0,
|
||||||
'uploader': '卡尔',
|
'uploader': '卡尔',
|
||||||
'uploader_id': '367138632',
|
'uploader_id': '367138632',
|
||||||
'upload_date': '20210811',
|
'upload_date': '20210811',
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
'comment_count': int,
|
'comment_count': int,
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
|
'tags': 'count:4',
|
||||||
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
|
'timestamp': 1628675950,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
# Only m3u8 available
|
||||||
|
'url': 'https://www.huya.com/video/play/1063345618.html',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '1063345618',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': '峡谷第一中!黑铁上钻石顶级教学对抗elo',
|
||||||
|
'categories': ['英雄联盟'],
|
||||||
|
'comment_count': int,
|
||||||
|
'duration': 21603.0,
|
||||||
|
'like_count': int,
|
||||||
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
|
'timestamp': 1749668803,
|
||||||
|
'upload_date': '20250611',
|
||||||
|
'uploader': '北枫CC',
|
||||||
|
'uploader_id': '2183525275',
|
||||||
|
'view_count': int,
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url: str):
|
def _real_extract(self, url: str):
|
||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
video_data = self._download_json(
|
moment = self._download_json(
|
||||||
'https://liveapi.huya.com/moment/getMomentContent', video_id,
|
'https://liveapi.huya.com/moment/getMomentContent',
|
||||||
query={'videoId': video_id})['data']['moment']['videoInfo']
|
video_id, query={'videoId': video_id})['data']['moment']
|
||||||
|
|
||||||
formats = []
|
formats = []
|
||||||
for definition in traverse_obj(video_data, ('definitions', lambda _, v: url_or_none(v['url']))):
|
for definition in traverse_obj(moment, (
|
||||||
formats.append({
|
'videoInfo', 'definitions', lambda _, v: url_or_none(v['m3u8']),
|
||||||
'url': definition['url'],
|
)):
|
||||||
**traverse_obj(definition, {
|
fmts = self._extract_m3u8_formats(definition['m3u8'], video_id, 'mp4', fatal=False)
|
||||||
'format_id': ('defName', {str}),
|
for fmt in fmts:
|
||||||
'width': ('width', {int_or_none}),
|
fmt.update(**traverse_obj(definition, {
|
||||||
'height': ('height', {int_or_none}),
|
|
||||||
'filesize': ('size', {int_or_none}),
|
'filesize': ('size', {int_or_none}),
|
||||||
}),
|
'format_id': ('defName', {str}),
|
||||||
})
|
'height': ('height', {int_or_none}),
|
||||||
|
'quality': ('definition', {int_or_none}),
|
||||||
|
'width': ('width', {int_or_none}),
|
||||||
|
}))
|
||||||
|
formats.extend(fmts)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
**traverse_obj(video_data, {
|
**traverse_obj(moment, {
|
||||||
|
'comment_count': ('commentCount', {int_or_none}),
|
||||||
|
'description': ('content', {clean_html}, filter),
|
||||||
|
'like_count': ('favorCount', {int_or_none}),
|
||||||
|
'timestamp': ('cTime', {int_or_none}),
|
||||||
|
}),
|
||||||
|
**traverse_obj(moment, ('videoInfo', {
|
||||||
'title': ('videoTitle', {str}),
|
'title': ('videoTitle', {str}),
|
||||||
'thumbnail': ('videoCover', {url_or_none}),
|
'categories': ('category', {str}, filter, all, filter),
|
||||||
'duration': ('videoDuration', {parse_duration}),
|
'duration': ('videoDuration', {parse_duration}),
|
||||||
|
'tags': ('tags', ..., {str}, filter, all, filter),
|
||||||
|
'thumbnail': (('videoBigCover', 'videoCover'), {url_or_none}, {update_url(query=None)}, any),
|
||||||
'uploader': ('nickName', {str}),
|
'uploader': ('nickName', {str}),
|
||||||
'uploader_id': ('uid', {str_or_none}),
|
'uploader_id': ('uid', {str_or_none}),
|
||||||
'upload_date': ('videoUploadTime', {unified_strdate}),
|
|
||||||
'view_count': ('videoPlayNum', {int_or_none}),
|
'view_count': ('videoPlayNum', {int_or_none}),
|
||||||
'comment_count': ('videoCommentNum', {int_or_none}),
|
})),
|
||||||
'like_count': ('favorCount', {int_or_none}),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,66 @@
|
|||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import js_to_json, traverse_obj
|
from ..utils import (
|
||||||
|
ExtractorError,
|
||||||
|
clean_html,
|
||||||
|
url_or_none,
|
||||||
|
)
|
||||||
|
from ..utils.traversal import subs_list_to_dict, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class MonsterSirenHypergryphMusicIE(InfoExtractor):
|
class MonsterSirenHypergryphMusicIE(InfoExtractor):
|
||||||
|
IE_NAME = 'monstersiren'
|
||||||
|
IE_DESC = '塞壬唱片'
|
||||||
|
_API_BASE = 'https://monster-siren.hypergryph.com/api'
|
||||||
_VALID_URL = r'https?://monster-siren\.hypergryph\.com/music/(?P<id>\d+)'
|
_VALID_URL = r'https?://monster-siren\.hypergryph\.com/music/(?P<id>\d+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://monster-siren.hypergryph.com/music/514562',
|
'url': 'https://monster-siren.hypergryph.com/music/514562',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '514562',
|
'id': '514562',
|
||||||
'ext': 'wav',
|
'ext': 'wav',
|
||||||
'artists': ['塞壬唱片-MSR'],
|
|
||||||
'album': 'Flame Shadow',
|
|
||||||
'title': 'Flame Shadow',
|
'title': 'Flame Shadow',
|
||||||
|
'album': 'Flame Shadow',
|
||||||
|
'artists': ['塞壬唱片-MSR'],
|
||||||
|
'description': 'md5:19e2acfcd1b65b41b29e8079ab948053',
|
||||||
|
'thumbnail': r're:https?://web\.hycdn\.cn/siren/pic/.+\.jpg',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://monster-siren.hypergryph.com/music/514518',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '514518',
|
||||||
|
'ext': 'wav',
|
||||||
|
'title': 'Heavenly Me (Instrumental)',
|
||||||
|
'album': 'Heavenly Me',
|
||||||
|
'artists': ['塞壬唱片-MSR', 'AIYUE blessed : 理名'],
|
||||||
|
'description': 'md5:ce790b41c932d1ad72eb791d1d8ae598',
|
||||||
|
'thumbnail': r're:https?://web\.hycdn\.cn/siren/pic/.+\.jpg',
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
audio_id = self._match_id(url)
|
audio_id = self._match_id(url)
|
||||||
webpage = self._download_webpage(url, audio_id)
|
song = self._download_json(f'{self._API_BASE}/song/{audio_id}', audio_id)
|
||||||
json_data = self._search_json(
|
if traverse_obj(song, 'code') != 0:
|
||||||
r'window\.g_initialProps\s*=', webpage, 'data', audio_id, transform_source=js_to_json)
|
msg = traverse_obj(song, ('msg', {str}, filter))
|
||||||
|
raise ExtractorError(
|
||||||
|
msg or 'API returned an error response', expected=bool(msg))
|
||||||
|
|
||||||
|
album = None
|
||||||
|
if album_id := traverse_obj(song, ('data', 'albumCid', {str})):
|
||||||
|
album = self._download_json(
|
||||||
|
f'{self._API_BASE}/album/{album_id}/detail', album_id, fatal=False)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': audio_id,
|
'id': audio_id,
|
||||||
'title': traverse_obj(json_data, ('player', 'songDetail', 'name')),
|
|
||||||
'url': traverse_obj(json_data, ('player', 'songDetail', 'sourceUrl')),
|
|
||||||
'ext': 'wav',
|
|
||||||
'vcodec': 'none',
|
'vcodec': 'none',
|
||||||
'artists': traverse_obj(json_data, ('player', 'songDetail', 'artists', ...)),
|
**traverse_obj(song, ('data', {
|
||||||
'album': traverse_obj(json_data, ('musicPlay', 'albumDetail', 'name')),
|
'title': ('name', {str}),
|
||||||
|
'artists': ('artists', ..., {str}),
|
||||||
|
'subtitles': ({'url': 'lyricUrl'}, all, {subs_list_to_dict(lang='en')}),
|
||||||
|
'url': ('sourceUrl', {url_or_none}),
|
||||||
|
})),
|
||||||
|
**traverse_obj(album, ('data', {
|
||||||
|
'album': ('name', {str}),
|
||||||
|
'description': ('intro', {clean_html}),
|
||||||
|
'thumbnail': ('coverUrl', {url_or_none}),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -6,9 +7,7 @@
|
|||||||
ExtractorError,
|
ExtractorError,
|
||||||
determine_ext,
|
determine_ext,
|
||||||
js_to_json,
|
js_to_json,
|
||||||
parse_qs,
|
|
||||||
traverse_obj,
|
traverse_obj,
|
||||||
urlencode_postdata,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ class IPrimaIE(InfoExtractor):
|
|||||||
_VALID_URL = r'https?://(?!cnn)(?:[^/]+)\.iprima\.cz/(?:[^/]+/)*(?P<id>[^/?#&]+)'
|
_VALID_URL = r'https?://(?!cnn)(?:[^/]+)\.iprima\.cz/(?:[^/]+/)*(?P<id>[^/?#&]+)'
|
||||||
_GEO_BYPASS = False
|
_GEO_BYPASS = False
|
||||||
_NETRC_MACHINE = 'iprima'
|
_NETRC_MACHINE = 'iprima'
|
||||||
_AUTH_ROOT = 'https://auth.iprima.cz'
|
|
||||||
access_token = None
|
access_token = None
|
||||||
|
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -86,48 +84,18 @@ def _perform_login(self, username, password):
|
|||||||
if self.access_token:
|
if self.access_token:
|
||||||
return
|
return
|
||||||
|
|
||||||
login_page = self._download_webpage(
|
|
||||||
f'{self._AUTH_ROOT}/oauth2/login', None, note='Downloading login page',
|
|
||||||
errnote='Downloading login page failed')
|
|
||||||
|
|
||||||
login_form = self._hidden_inputs(login_page)
|
|
||||||
|
|
||||||
login_form.update({
|
|
||||||
'_email': username,
|
|
||||||
'_password': password})
|
|
||||||
|
|
||||||
profile_select_html, login_handle = self._download_webpage_handle(
|
|
||||||
f'{self._AUTH_ROOT}/oauth2/login', None, data=urlencode_postdata(login_form),
|
|
||||||
note='Logging in')
|
|
||||||
|
|
||||||
# a profile may need to be selected first, even when there is only a single one
|
|
||||||
if '/profile-select' in login_handle.url:
|
|
||||||
profile_id = self._search_regex(
|
|
||||||
r'data-identifier\s*=\s*["\']?(\w+)', profile_select_html, 'profile id')
|
|
||||||
|
|
||||||
login_handle = self._request_webpage(
|
|
||||||
f'{self._AUTH_ROOT}/user/profile-select-perform/{profile_id}', None,
|
|
||||||
query={'continueUrl': '/user/login?redirect_uri=/user/'}, note='Selecting profile')
|
|
||||||
|
|
||||||
code = traverse_obj(login_handle.url, ({parse_qs}, 'code', 0))
|
|
||||||
if not code:
|
|
||||||
raise ExtractorError('Login failed', expected=True)
|
|
||||||
|
|
||||||
token_request_data = {
|
|
||||||
'scope': 'openid+email+profile+phone+address+offline_access',
|
|
||||||
'client_id': 'prima_sso',
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'code': code,
|
|
||||||
'redirect_uri': f'{self._AUTH_ROOT}/sso/auth-check'}
|
|
||||||
|
|
||||||
token_data = self._download_json(
|
token_data = self._download_json(
|
||||||
f'{self._AUTH_ROOT}/oauth2/token', None,
|
'https://ucet.iprima.cz/api/session/create', None,
|
||||||
note='Downloading token', errnote='Downloading token failed',
|
note='Logging in', errnote='Failed to log in',
|
||||||
data=urlencode_postdata(token_request_data))
|
data=json.dumps({
|
||||||
|
'email': username,
|
||||||
|
'password': password,
|
||||||
|
'deviceName': 'Windows Chrome',
|
||||||
|
}).encode(), headers={'content-type': 'application/json'})
|
||||||
|
|
||||||
self.access_token = token_data.get('access_token')
|
self.access_token = token_data['accessToken']['value']
|
||||||
if self.access_token is None:
|
if not self.access_token:
|
||||||
raise ExtractorError('Getting token failed', expected=True)
|
raise ExtractorError('Failed to fetch access token')
|
||||||
|
|
||||||
def _real_initialize(self):
|
def _real_initialize(self):
|
||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
|
|||||||
78
yt_dlp/extractor/ivoox.py
Normal file
78
yt_dlp/extractor/ivoox.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from .common import InfoExtractor
|
||||||
|
from ..utils import int_or_none, parse_iso8601, url_or_none, urljoin
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
|
class IvooxIE(InfoExtractor):
|
||||||
|
_VALID_URL = (
|
||||||
|
r'https?://(?:www\.)?ivoox\.com/(?:\w{2}/)?[^/?#]+_rf_(?P<id>[0-9]+)_1\.html',
|
||||||
|
r'https?://go\.ivoox\.com/rf/(?P<id>[0-9]+)',
|
||||||
|
)
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.ivoox.com/dex-08x30-rostros-del-mal-los-asesinos-en-audios-mp3_rf_143594959_1.html',
|
||||||
|
'md5': '993f712de5b7d552459fc66aa3726885',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '143594959',
|
||||||
|
'ext': 'mp3',
|
||||||
|
'timestamp': 1742731200,
|
||||||
|
'channel': 'DIAS EXTRAÑOS con Santiago Camacho',
|
||||||
|
'title': 'DEx 08x30 Rostros del mal: Los asesinos en serie que aterrorizaron España',
|
||||||
|
'description': 'md5:eae8b4b9740d0216d3871390b056bb08',
|
||||||
|
'uploader': 'Santiago Camacho',
|
||||||
|
'thumbnail': 'https://static-1.ivoox.com/audios/c/d/5/2/cd52f46783fe735000c33a803dce2554_XXL.jpg',
|
||||||
|
'upload_date': '20250323',
|
||||||
|
'episode': 'DEx 08x30 Rostros del mal: Los asesinos en serie que aterrorizaron España',
|
||||||
|
'duration': 11837,
|
||||||
|
'tags': ['españa', 'asesinos en serie', 'arropiero', 'historia criminal', 'mataviejas'],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://go.ivoox.com/rf/143594959',
|
||||||
|
'only_matching': True,
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.ivoox.com/en/campodelgas-28-03-2025-audios-mp3_rf_144036942_1.html',
|
||||||
|
'only_matching': True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
media_id = self._match_id(url)
|
||||||
|
webpage = self._download_webpage(url, media_id, fatal=False)
|
||||||
|
|
||||||
|
data = self._search_nuxt_data(
|
||||||
|
webpage, media_id, fatal=False, traverse=('data', 0, 'data', 'audio'))
|
||||||
|
|
||||||
|
direct_download = self._download_json(
|
||||||
|
f'https://vcore-web.ivoox.com/v1/public/audios/{media_id}/download-url', media_id, fatal=False,
|
||||||
|
note='Fetching direct download link', headers={'Referer': url})
|
||||||
|
|
||||||
|
download_paths = {
|
||||||
|
*traverse_obj(direct_download, ('data', 'downloadUrl', {str}, filter, all)),
|
||||||
|
*traverse_obj(data, (('downloadUrl', 'mediaUrl'), {str}, filter)),
|
||||||
|
}
|
||||||
|
|
||||||
|
formats = []
|
||||||
|
for path in download_paths:
|
||||||
|
formats.append({
|
||||||
|
'url': urljoin('https://ivoox.com', path),
|
||||||
|
'http_headers': {'Referer': url},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': media_id,
|
||||||
|
'formats': formats,
|
||||||
|
'uploader': self._html_search_regex(r'data-prm-author="([^"]+)"', webpage, 'author', default=None),
|
||||||
|
'timestamp': parse_iso8601(
|
||||||
|
self._html_search_regex(r'data-prm-pubdate="([^"]+)"', webpage, 'timestamp', default=None)),
|
||||||
|
'channel': self._html_search_regex(r'data-prm-podname="([^"]+)"', webpage, 'channel', default=None),
|
||||||
|
'title': self._html_search_regex(r'data-prm-title="([^"]+)"', webpage, 'title', default=None),
|
||||||
|
'thumbnail': self._og_search_thumbnail(webpage, default=None),
|
||||||
|
'description': self._og_search_description(webpage, default=None),
|
||||||
|
**self._search_json_ld(webpage, media_id, default={}),
|
||||||
|
**traverse_obj(data, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'description': ('description', {str}),
|
||||||
|
'thumbnail': ('image', {url_or_none}),
|
||||||
|
'timestamp': ('uploadDate', {parse_iso8601(delimiter=' ')}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'tags': ('tags', ..., 'name', {str}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import base64
|
|
||||||
import itertools
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
|
|
||||||
from .common import InfoExtractor
|
|
||||||
from ..utils import (
|
|
||||||
ExtractorError,
|
|
||||||
float_or_none,
|
|
||||||
int_or_none,
|
|
||||||
jwt_decode_hs256,
|
|
||||||
parse_age_limit,
|
|
||||||
try_call,
|
|
||||||
url_or_none,
|
|
||||||
)
|
|
||||||
from ..utils.traversal import traverse_obj
|
|
||||||
|
|
||||||
|
|
||||||
class JioCinemaBaseIE(InfoExtractor):
|
|
||||||
_NETRC_MACHINE = 'jiocinema'
|
|
||||||
_GEO_BYPASS = False
|
|
||||||
_ACCESS_TOKEN = None
|
|
||||||
_REFRESH_TOKEN = None
|
|
||||||
_GUEST_TOKEN = None
|
|
||||||
_USER_ID = None
|
|
||||||
_DEVICE_ID = None
|
|
||||||
_API_HEADERS = {'Origin': 'https://www.jiocinema.com', 'Referer': 'https://www.jiocinema.com/'}
|
|
||||||
_APP_NAME = {'appName': 'RJIL_JioCinema'}
|
|
||||||
_APP_VERSION = {'appVersion': '5.0.0'}
|
|
||||||
_API_SIGNATURES = 'o668nxgzwff'
|
|
||||||
_METADATA_API_BASE = 'https://content-jiovoot.voot.com/psapi'
|
|
||||||
_ACCESS_HINT = 'the `accessToken` from your browser local storage'
|
|
||||||
_LOGIN_HINT = (
|
|
||||||
'Log in with "-u phone -p <PHONE_NUMBER>" to authenticate with OTP, '
|
|
||||||
f'or use "-u token -p <ACCESS_TOKEN>" to log in with {_ACCESS_HINT}. '
|
|
||||||
'If you have previously logged in with yt-dlp and your session '
|
|
||||||
'has been cached, you can use "-u device -p <DEVICE_ID>"')
|
|
||||||
|
|
||||||
def _cache_token(self, token_type):
|
|
||||||
assert token_type in ('access', 'refresh', 'all')
|
|
||||||
if token_type in ('access', 'all'):
|
|
||||||
self.cache.store(
|
|
||||||
JioCinemaBaseIE._NETRC_MACHINE, f'{JioCinemaBaseIE._DEVICE_ID}-access', JioCinemaBaseIE._ACCESS_TOKEN)
|
|
||||||
if token_type in ('refresh', 'all'):
|
|
||||||
self.cache.store(
|
|
||||||
JioCinemaBaseIE._NETRC_MACHINE, f'{JioCinemaBaseIE._DEVICE_ID}-refresh', JioCinemaBaseIE._REFRESH_TOKEN)
|
|
||||||
|
|
||||||
def _call_api(self, url, video_id, note='Downloading API JSON', headers={}, data={}):
|
|
||||||
return self._download_json(
|
|
||||||
url, video_id, note, data=json.dumps(data, separators=(',', ':')).encode(), headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
**self._API_HEADERS,
|
|
||||||
**headers,
|
|
||||||
}, expected_status=(400, 403, 474))
|
|
||||||
|
|
||||||
def _call_auth_api(self, service, endpoint, note, headers={}, data={}):
|
|
||||||
return self._call_api(
|
|
||||||
f'https://auth-jiocinema.voot.com/{service}service/apis/v4/{endpoint}',
|
|
||||||
None, note=note, headers=headers, data=data)
|
|
||||||
|
|
||||||
def _refresh_token(self):
|
|
||||||
if not JioCinemaBaseIE._REFRESH_TOKEN or not JioCinemaBaseIE._DEVICE_ID:
|
|
||||||
raise ExtractorError('User token has expired', expected=True)
|
|
||||||
response = self._call_auth_api(
|
|
||||||
'token', 'refreshtoken', 'Refreshing token',
|
|
||||||
headers={'accesstoken': self._ACCESS_TOKEN}, data={
|
|
||||||
**self._APP_NAME,
|
|
||||||
'deviceId': self._DEVICE_ID,
|
|
||||||
'refreshToken': self._REFRESH_TOKEN,
|
|
||||||
**self._APP_VERSION,
|
|
||||||
})
|
|
||||||
refresh_token = response.get('refreshTokenId')
|
|
||||||
if refresh_token and refresh_token != JioCinemaBaseIE._REFRESH_TOKEN:
|
|
||||||
JioCinemaBaseIE._REFRESH_TOKEN = refresh_token
|
|
||||||
self._cache_token('refresh')
|
|
||||||
JioCinemaBaseIE._ACCESS_TOKEN = response['authToken']
|
|
||||||
self._cache_token('access')
|
|
||||||
|
|
||||||
def _fetch_guest_token(self):
|
|
||||||
JioCinemaBaseIE._DEVICE_ID = ''.join(random.choices(string.digits, k=10))
|
|
||||||
guest_token = self._call_auth_api(
|
|
||||||
'token', 'guest', 'Downloading guest token', data={
|
|
||||||
**self._APP_NAME,
|
|
||||||
'deviceType': 'phone',
|
|
||||||
'os': 'ios',
|
|
||||||
'deviceId': self._DEVICE_ID,
|
|
||||||
'freshLaunch': False,
|
|
||||||
'adId': self._DEVICE_ID,
|
|
||||||
**self._APP_VERSION,
|
|
||||||
})
|
|
||||||
self._GUEST_TOKEN = guest_token['authToken']
|
|
||||||
self._USER_ID = guest_token['userId']
|
|
||||||
|
|
||||||
def _call_login_api(self, endpoint, guest_token, data, note):
|
|
||||||
return self._call_auth_api(
|
|
||||||
'user', f'loginotp/{endpoint}', note, headers={
|
|
||||||
**self.geo_verification_headers(),
|
|
||||||
'accesstoken': self._GUEST_TOKEN,
|
|
||||||
**self._APP_NAME,
|
|
||||||
**traverse_obj(guest_token, 'data', {
|
|
||||||
'deviceType': ('deviceType', {str}),
|
|
||||||
'os': ('os', {str}),
|
|
||||||
})}, data=data)
|
|
||||||
|
|
||||||
def _is_token_expired(self, token):
|
|
||||||
return (try_call(lambda: jwt_decode_hs256(token)['exp']) or 0) <= int(time.time() - 180)
|
|
||||||
|
|
||||||
def _perform_login(self, username, password):
|
|
||||||
if self._ACCESS_TOKEN and not self._is_token_expired(self._ACCESS_TOKEN):
|
|
||||||
return
|
|
||||||
|
|
||||||
UUID_RE = r'[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}'
|
|
||||||
|
|
||||||
if username.lower() == 'token':
|
|
||||||
if try_call(lambda: jwt_decode_hs256(password)):
|
|
||||||
JioCinemaBaseIE._ACCESS_TOKEN = password
|
|
||||||
refresh_hint = 'the `refreshToken` UUID from your browser local storage'
|
|
||||||
refresh_token = self._configuration_arg('refresh_token', [''], ie_key=JioCinemaIE)[0]
|
|
||||||
if not refresh_token:
|
|
||||||
self.to_screen(
|
|
||||||
'To extend the life of your login session, in addition to your access token, '
|
|
||||||
'you can pass --extractor-args "jiocinema:refresh_token=REFRESH_TOKEN" '
|
|
||||||
f'where REFRESH_TOKEN is {refresh_hint}')
|
|
||||||
elif re.fullmatch(UUID_RE, refresh_token):
|
|
||||||
JioCinemaBaseIE._REFRESH_TOKEN = refresh_token
|
|
||||||
else:
|
|
||||||
self.report_warning(f'Invalid refresh_token value. Use {refresh_hint}')
|
|
||||||
else:
|
|
||||||
raise ExtractorError(
|
|
||||||
f'The password given could not be decoded as a token; use {self._ACCESS_HINT}', expected=True)
|
|
||||||
|
|
||||||
elif username.lower() == 'device' and re.fullmatch(rf'(?:{UUID_RE}|\d+)', password):
|
|
||||||
JioCinemaBaseIE._REFRESH_TOKEN = self.cache.load(JioCinemaBaseIE._NETRC_MACHINE, f'{password}-refresh')
|
|
||||||
JioCinemaBaseIE._ACCESS_TOKEN = self.cache.load(JioCinemaBaseIE._NETRC_MACHINE, f'{password}-access')
|
|
||||||
if not JioCinemaBaseIE._REFRESH_TOKEN or not JioCinemaBaseIE._ACCESS_TOKEN:
|
|
||||||
raise ExtractorError(f'Failed to load cached tokens for device ID "{password}"', expected=True)
|
|
||||||
|
|
||||||
elif username.lower() == 'phone' and re.fullmatch(r'\+?\d+', password):
|
|
||||||
self._fetch_guest_token()
|
|
||||||
guest_token = jwt_decode_hs256(self._GUEST_TOKEN)
|
|
||||||
initial_data = {
|
|
||||||
'number': base64.b64encode(password.encode()).decode(),
|
|
||||||
**self._APP_VERSION,
|
|
||||||
}
|
|
||||||
response = self._call_login_api('send', guest_token, initial_data, 'Requesting OTP')
|
|
||||||
if not traverse_obj(response, ('OTPInfo', {dict})):
|
|
||||||
raise ExtractorError('There was a problem with the phone number login attempt')
|
|
||||||
|
|
||||||
is_iphone = guest_token.get('os') == 'ios'
|
|
||||||
response = self._call_login_api('verify', guest_token, {
|
|
||||||
'deviceInfo': {
|
|
||||||
'consumptionDeviceName': 'iPhone' if is_iphone else 'Android',
|
|
||||||
'info': {
|
|
||||||
'platform': {'name': 'iPhone OS' if is_iphone else 'Android'},
|
|
||||||
'androidId': self._DEVICE_ID,
|
|
||||||
'type': 'iOS' if is_iphone else 'Android',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
**initial_data,
|
|
||||||
'otp': self._get_tfa_info('the one-time password sent to your phone'),
|
|
||||||
}, 'Submitting OTP')
|
|
||||||
if traverse_obj(response, 'code') == 1043:
|
|
||||||
raise ExtractorError('Wrong OTP', expected=True)
|
|
||||||
JioCinemaBaseIE._REFRESH_TOKEN = response['refreshToken']
|
|
||||||
JioCinemaBaseIE._ACCESS_TOKEN = response['authToken']
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ExtractorError(self._LOGIN_HINT, expected=True)
|
|
||||||
|
|
||||||
user_token = jwt_decode_hs256(JioCinemaBaseIE._ACCESS_TOKEN)['data']
|
|
||||||
JioCinemaBaseIE._USER_ID = user_token['userId']
|
|
||||||
JioCinemaBaseIE._DEVICE_ID = user_token['deviceId']
|
|
||||||
if JioCinemaBaseIE._REFRESH_TOKEN and username != 'device':
|
|
||||||
self._cache_token('all')
|
|
||||||
if self.get_param('cachedir') is not False:
|
|
||||||
self.to_screen(
|
|
||||||
f'NOTE: For subsequent logins you can use "-u device -p {JioCinemaBaseIE._DEVICE_ID}"')
|
|
||||||
elif not JioCinemaBaseIE._REFRESH_TOKEN:
|
|
||||||
JioCinemaBaseIE._REFRESH_TOKEN = self.cache.load(
|
|
||||||
JioCinemaBaseIE._NETRC_MACHINE, f'{JioCinemaBaseIE._DEVICE_ID}-refresh')
|
|
||||||
if JioCinemaBaseIE._REFRESH_TOKEN:
|
|
||||||
self._cache_token('access')
|
|
||||||
self.to_screen(f'Logging in as device ID "{JioCinemaBaseIE._DEVICE_ID}"')
|
|
||||||
if self._is_token_expired(JioCinemaBaseIE._ACCESS_TOKEN):
|
|
||||||
self._refresh_token()
|
|
||||||
|
|
||||||
|
|
||||||
class JioCinemaIE(JioCinemaBaseIE):
|
|
||||||
IE_NAME = 'jiocinema'
|
|
||||||
_VALID_URL = r'https?://(?:www\.)?jiocinema\.com/?(?:movies?/[^/?#]+/|tv-shows/(?:[^/?#]+/){3})(?P<id>\d{3,})'
|
|
||||||
_TESTS = [{
|
|
||||||
'url': 'https://www.jiocinema.com/tv-shows/agnisakshi-ek-samjhauta/1/pradeep-to-stop-the-wedding/3759931',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3759931',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'Pradeep to stop the wedding?',
|
|
||||||
'description': 'md5:75f72d1d1a66976633345a3de6d672b1',
|
|
||||||
'episode': 'Pradeep to stop the wedding?',
|
|
||||||
'episode_number': 89,
|
|
||||||
'season': 'Agnisakshi…Ek Samjhauta-S1',
|
|
||||||
'season_number': 1,
|
|
||||||
'series': 'Agnisakshi Ek Samjhauta',
|
|
||||||
'duration': 1238.0,
|
|
||||||
'thumbnail': r're:https?://.+\.jpg',
|
|
||||||
'age_limit': 13,
|
|
||||||
'season_id': '3698031',
|
|
||||||
'upload_date': '20230606',
|
|
||||||
'timestamp': 1686009600,
|
|
||||||
'release_date': '20230607',
|
|
||||||
'genres': ['Drama'],
|
|
||||||
},
|
|
||||||
'params': {'skip_download': 'm3u8'},
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.jiocinema.com/movies/bhediya/3754021/watch',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3754021',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'Bhediya',
|
|
||||||
'description': 'md5:a6bf2900371ac2fc3f1447401a9f7bb0',
|
|
||||||
'episode': 'Bhediya',
|
|
||||||
'duration': 8500.0,
|
|
||||||
'thumbnail': r're:https?://.+\.jpg',
|
|
||||||
'age_limit': 13,
|
|
||||||
'upload_date': '20230525',
|
|
||||||
'timestamp': 1685026200,
|
|
||||||
'release_date': '20230524',
|
|
||||||
'genres': ['Comedy'],
|
|
||||||
},
|
|
||||||
'params': {'skip_download': 'm3u8'},
|
|
||||||
}]
|
|
||||||
|
|
||||||
def _extract_formats_and_subtitles(self, playback, video_id):
|
|
||||||
m3u8_url = traverse_obj(playback, (
|
|
||||||
'data', 'playbackUrls', lambda _, v: v['streamtype'] == 'hls', 'url', {url_or_none}, any))
|
|
||||||
if not m3u8_url: # DRM-only content only serves dash urls
|
|
||||||
self.report_drm(video_id)
|
|
||||||
formats, subtitles = self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, m3u8_id='hls')
|
|
||||||
self._remove_duplicate_formats(formats)
|
|
||||||
|
|
||||||
return {
|
|
||||||
# '/_definst_/smil:vod/' m3u8 manifests claim to have 720p+ formats but max out at 480p
|
|
||||||
'formats': traverse_obj(formats, (
|
|
||||||
lambda _, v: '/_definst_/smil:vod/' not in v['url'] or v['height'] <= 480)),
|
|
||||||
'subtitles': subtitles,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
video_id = self._match_id(url)
|
|
||||||
if not self._ACCESS_TOKEN and self._is_token_expired(self._GUEST_TOKEN):
|
|
||||||
self._fetch_guest_token()
|
|
||||||
elif self._ACCESS_TOKEN and self._is_token_expired(self._ACCESS_TOKEN):
|
|
||||||
self._refresh_token()
|
|
||||||
|
|
||||||
playback = self._call_api(
|
|
||||||
f'https://apis-jiovoot.voot.com/playbackjv/v3/{video_id}', video_id,
|
|
||||||
'Downloading playback JSON', headers={
|
|
||||||
**self.geo_verification_headers(),
|
|
||||||
'accesstoken': self._ACCESS_TOKEN or self._GUEST_TOKEN,
|
|
||||||
**self._APP_NAME,
|
|
||||||
'deviceid': self._DEVICE_ID,
|
|
||||||
'uniqueid': self._USER_ID,
|
|
||||||
'x-apisignatures': self._API_SIGNATURES,
|
|
||||||
'x-platform': 'androidweb',
|
|
||||||
'x-platform-token': 'web',
|
|
||||||
}, data={
|
|
||||||
'4k': False,
|
|
||||||
'ageGroup': '18+',
|
|
||||||
'appVersion': '3.4.0',
|
|
||||||
'bitrateProfile': 'xhdpi',
|
|
||||||
'capability': {
|
|
||||||
'drmCapability': {
|
|
||||||
'aesSupport': 'yes',
|
|
||||||
'fairPlayDrmSupport': 'none',
|
|
||||||
'playreadyDrmSupport': 'none',
|
|
||||||
'widevineDRMSupport': 'none',
|
|
||||||
},
|
|
||||||
'frameRateCapability': [{
|
|
||||||
'frameRateSupport': '30fps',
|
|
||||||
'videoQuality': '1440p',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
'continueWatchingRequired': False,
|
|
||||||
'dolby': False,
|
|
||||||
'downloadRequest': False,
|
|
||||||
'hevc': False,
|
|
||||||
'kidsSafe': False,
|
|
||||||
'manufacturer': 'Windows',
|
|
||||||
'model': 'Windows',
|
|
||||||
'multiAudioRequired': True,
|
|
||||||
'osVersion': '10',
|
|
||||||
'parentalPinValid': True,
|
|
||||||
'x-apisignatures': self._API_SIGNATURES,
|
|
||||||
})
|
|
||||||
|
|
||||||
status_code = traverse_obj(playback, ('code', {int}))
|
|
||||||
if status_code == 474:
|
|
||||||
self.raise_geo_restricted(countries=['IN'])
|
|
||||||
elif status_code == 1008:
|
|
||||||
error_msg = 'This content is only available for premium users'
|
|
||||||
if self._ACCESS_TOKEN:
|
|
||||||
raise ExtractorError(error_msg, expected=True)
|
|
||||||
self.raise_login_required(f'{error_msg}. {self._LOGIN_HINT}', method=None)
|
|
||||||
elif status_code == 400:
|
|
||||||
raise ExtractorError('The requested content is not available', expected=True)
|
|
||||||
elif status_code is not None and status_code != 200:
|
|
||||||
raise ExtractorError(
|
|
||||||
f'JioCinema says: {traverse_obj(playback, ("message", {str})) or status_code}')
|
|
||||||
|
|
||||||
metadata = self._download_json(
|
|
||||||
f'{self._METADATA_API_BASE}/voot/v1/voot-web/content/query/asset-details',
|
|
||||||
video_id, fatal=False, query={
|
|
||||||
'ids': f'include:{video_id}',
|
|
||||||
'responseType': 'common',
|
|
||||||
'devicePlatformType': 'desktop',
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'id': video_id,
|
|
||||||
'http_headers': self._API_HEADERS,
|
|
||||||
**self._extract_formats_and_subtitles(playback, video_id),
|
|
||||||
**traverse_obj(playback, ('data', {
|
|
||||||
# fallback metadata
|
|
||||||
'title': ('name', {str}),
|
|
||||||
'description': ('fullSynopsis', {str}),
|
|
||||||
'series': ('show', 'name', {str}, filter),
|
|
||||||
'season': ('tournamentName', {str}, {lambda x: x if x != 'Season 0' else None}),
|
|
||||||
'season_number': ('episode', 'season', {int_or_none}, filter),
|
|
||||||
'episode': ('fullTitle', {str}),
|
|
||||||
'episode_number': ('episode', 'episodeNo', {int_or_none}, filter),
|
|
||||||
'age_limit': ('ageNemonic', {parse_age_limit}),
|
|
||||||
'duration': ('totalDuration', {float_or_none}),
|
|
||||||
'thumbnail': ('images', {url_or_none}),
|
|
||||||
})),
|
|
||||||
**traverse_obj(metadata, ('result', 0, {
|
|
||||||
'title': ('fullTitle', {str}),
|
|
||||||
'description': ('fullSynopsis', {str}),
|
|
||||||
'series': ('showName', {str}, filter),
|
|
||||||
'season': ('seasonName', {str}, filter),
|
|
||||||
'season_number': ('season', {int_or_none}),
|
|
||||||
'season_id': ('seasonId', {str}, filter),
|
|
||||||
'episode': ('fullTitle', {str}),
|
|
||||||
'episode_number': ('episode', {int_or_none}),
|
|
||||||
'timestamp': ('uploadTime', {int_or_none}),
|
|
||||||
'release_date': ('telecastDate', {str}),
|
|
||||||
'age_limit': ('ageNemonic', {parse_age_limit}),
|
|
||||||
'duration': ('duration', {float_or_none}),
|
|
||||||
'genres': ('genres', ..., {str}),
|
|
||||||
'thumbnail': ('seo', 'ogImage', {url_or_none}),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class JioCinemaSeriesIE(JioCinemaBaseIE):
|
|
||||||
IE_NAME = 'jiocinema:series'
|
|
||||||
_VALID_URL = r'https?://(?:www\.)?jiocinema\.com/tv-shows/(?P<slug>[\w-]+)/(?P<id>\d{3,})'
|
|
||||||
_TESTS = [{
|
|
||||||
'url': 'https://www.jiocinema.com/tv-shows/naagin/3499917',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3499917',
|
|
||||||
'title': 'naagin',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 120,
|
|
||||||
}, {
|
|
||||||
'url': 'https://www.jiocinema.com/tv-shows/mtv-splitsvilla-x5/3499820',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '3499820',
|
|
||||||
'title': 'mtv-splitsvilla-x5',
|
|
||||||
},
|
|
||||||
'playlist_mincount': 310,
|
|
||||||
}]
|
|
||||||
|
|
||||||
def _entries(self, series_id):
|
|
||||||
seasons = traverse_obj(self._download_json(
|
|
||||||
f'{self._METADATA_API_BASE}/voot/v1/voot-web/view/show/{series_id}', series_id,
|
|
||||||
'Downloading series metadata JSON', query={'responseType': 'common'}), (
|
|
||||||
'trays', lambda _, v: v['trayId'] == 'season-by-show-multifilter',
|
|
||||||
'trayTabs', lambda _, v: v['id']))
|
|
||||||
|
|
||||||
for season_num, season in enumerate(seasons, start=1):
|
|
||||||
season_id = season['id']
|
|
||||||
label = season.get('label') or season_num
|
|
||||||
for page_num in itertools.count(1):
|
|
||||||
episodes = traverse_obj(self._download_json(
|
|
||||||
f'{self._METADATA_API_BASE}/voot/v1/voot-web/content/generic/series-wise-episode',
|
|
||||||
season_id, f'Downloading season {label} page {page_num} JSON', query={
|
|
||||||
'sort': 'episode:asc',
|
|
||||||
'id': season_id,
|
|
||||||
'responseType': 'common',
|
|
||||||
'page': page_num,
|
|
||||||
}), ('result', lambda _, v: v['id'] and url_or_none(v['slug'])))
|
|
||||||
if not episodes:
|
|
||||||
break
|
|
||||||
for episode in episodes:
|
|
||||||
yield self.url_result(
|
|
||||||
episode['slug'], JioCinemaIE, **traverse_obj(episode, {
|
|
||||||
'video_id': 'id',
|
|
||||||
'video_title': ('fullTitle', {str}),
|
|
||||||
'season_number': ('season', {int_or_none}),
|
|
||||||
'episode_number': ('episode', {int_or_none}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
|
||||||
slug, series_id = self._match_valid_url(url).group('slug', 'id')
|
|
||||||
return self.playlist_result(self._entries(series_id), series_id, slug)
|
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
import functools
|
import functools
|
||||||
|
import itertools
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
InAdvancePagedList,
|
InAdvancePagedList,
|
||||||
|
ISO639Utils,
|
||||||
|
OnDemandPagedList,
|
||||||
clean_html,
|
clean_html,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
|
js_to_json,
|
||||||
make_archive_id,
|
make_archive_id,
|
||||||
|
orderedSet,
|
||||||
smuggle_url,
|
smuggle_url,
|
||||||
|
unified_strdate,
|
||||||
|
unified_timestamp,
|
||||||
unsmuggle_url,
|
unsmuggle_url,
|
||||||
url_basename,
|
url_basename,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
|
urljoin,
|
||||||
|
variadic,
|
||||||
)
|
)
|
||||||
from ..utils.traversal import traverse_obj
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class JioSaavnBaseIE(InfoExtractor):
|
class JioSaavnBaseIE(InfoExtractor):
|
||||||
|
_URL_BASE_RE = r'https?://(?:www\.)?(?:jio)?saavn\.com'
|
||||||
_API_URL = 'https://www.jiosaavn.com/api.php'
|
_API_URL = 'https://www.jiosaavn.com/api.php'
|
||||||
_VALID_BITRATES = {'16', '32', '64', '128', '320'}
|
_VALID_BITRATES = {'16', '32', '64', '128', '320'}
|
||||||
|
|
||||||
@@ -30,16 +40,20 @@ def requested_bitrates(self):
|
|||||||
f'Valid bitrates are: {", ".join(sorted(self._VALID_BITRATES, key=int))}')
|
f'Valid bitrates are: {", ".join(sorted(self._VALID_BITRATES, key=int))}')
|
||||||
return requested_bitrates
|
return requested_bitrates
|
||||||
|
|
||||||
def _extract_formats(self, song_data):
|
def _extract_formats(self, item_data):
|
||||||
|
# Show/episode JSON data has a slightly different structure than song JSON data
|
||||||
|
if media_url := traverse_obj(item_data, ('more_info', 'encrypted_media_url', {str})):
|
||||||
|
item_data.setdefault('encrypted_media_url', media_url)
|
||||||
|
|
||||||
for bitrate in self.requested_bitrates:
|
for bitrate in self.requested_bitrates:
|
||||||
media_data = self._download_json(
|
media_data = self._download_json(
|
||||||
self._API_URL, song_data['id'],
|
self._API_URL, item_data['id'],
|
||||||
f'Downloading format info for {bitrate}',
|
f'Downloading format info for {bitrate}',
|
||||||
fatal=False, data=urlencode_postdata({
|
fatal=False, data=urlencode_postdata({
|
||||||
'__call': 'song.generateAuthToken',
|
'__call': 'song.generateAuthToken',
|
||||||
'_format': 'json',
|
'_format': 'json',
|
||||||
'bitrate': bitrate,
|
'bitrate': bitrate,
|
||||||
'url': song_data['encrypted_media_url'],
|
'url': item_data['encrypted_media_url'],
|
||||||
}))
|
}))
|
||||||
if not traverse_obj(media_data, ('auth_url', {url_or_none})):
|
if not traverse_obj(media_data, ('auth_url', {url_or_none})):
|
||||||
self.report_warning(f'Unable to extract format info for {bitrate}')
|
self.report_warning(f'Unable to extract format info for {bitrate}')
|
||||||
@@ -53,24 +67,6 @@ def _extract_formats(self, song_data):
|
|||||||
'vcodec': 'none',
|
'vcodec': 'none',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_song(self, song_data, url=None):
|
|
||||||
info = traverse_obj(song_data, {
|
|
||||||
'id': ('id', {str}),
|
|
||||||
'title': ('song', {clean_html}),
|
|
||||||
'album': ('album', {clean_html}),
|
|
||||||
'thumbnail': ('image', {url_or_none}, {lambda x: re.sub(r'-\d+x\d+\.', '-500x500.', x)}),
|
|
||||||
'duration': ('duration', {int_or_none}),
|
|
||||||
'view_count': ('play_count', {int_or_none}),
|
|
||||||
'release_year': ('year', {int_or_none}),
|
|
||||||
'artists': ('primary_artists', {lambda x: x.split(', ') if x else None}),
|
|
||||||
'webpage_url': ('perma_url', {url_or_none}),
|
|
||||||
})
|
|
||||||
if webpage_url := info.get('webpage_url') or url:
|
|
||||||
info['display_id'] = url_basename(webpage_url)
|
|
||||||
info['_old_archive_ids'] = [make_archive_id(JioSaavnSongIE, info['display_id'])]
|
|
||||||
|
|
||||||
return info
|
|
||||||
|
|
||||||
def _call_api(self, type_, token, note='API', params={}):
|
def _call_api(self, type_, token, note='API', params={}):
|
||||||
return self._download_json(
|
return self._download_json(
|
||||||
self._API_URL, token, f'Downloading {note} JSON', f'Unable to download {note} JSON',
|
self._API_URL, token, f'Downloading {note} JSON', f'Unable to download {note} JSON',
|
||||||
@@ -84,19 +80,89 @@ def _call_api(self, type_, token, note='API', params={}):
|
|||||||
**params,
|
**params,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _yield_songs(self, playlist_data):
|
@staticmethod
|
||||||
for song_data in traverse_obj(playlist_data, ('songs', lambda _, v: v['id'] and v['perma_url'])):
|
def _extract_song(song_data, url=None):
|
||||||
song_info = self._extract_song(song_data)
|
info = traverse_obj(song_data, {
|
||||||
url = smuggle_url(song_info['webpage_url'], {
|
'id': ('id', {str}),
|
||||||
'id': song_data['id'],
|
'title': (('song', 'title'), {clean_html}, any),
|
||||||
'encrypted_media_url': song_data['encrypted_media_url'],
|
'album': ((None, 'more_info'), 'album', {clean_html}, any),
|
||||||
|
'duration': ((None, 'more_info'), 'duration', {int_or_none}, any),
|
||||||
|
'channel': ((None, 'more_info'), 'label', {str}, any),
|
||||||
|
'channel_id': ((None, 'more_info'), 'label_id', {str}, any),
|
||||||
|
'channel_url': ((None, 'more_info'), 'label_url', {urljoin('https://www.jiosaavn.com/')}, any),
|
||||||
|
'release_date': ((None, 'more_info'), 'release_date', {unified_strdate}, any),
|
||||||
|
'release_year': ('year', {int_or_none}),
|
||||||
|
'thumbnail': ('image', {url_or_none}, {lambda x: re.sub(r'-\d+x\d+\.', '-500x500.', x)}),
|
||||||
|
'view_count': ('play_count', {int_or_none}),
|
||||||
|
'language': ('language', {lambda x: ISO639Utils.short2long(x.casefold()) or 'und'}),
|
||||||
|
'webpage_url': ('perma_url', {url_or_none}),
|
||||||
|
'artists': ('more_info', 'artistMap', 'primary_artists', ..., 'name', {str}, filter, all),
|
||||||
})
|
})
|
||||||
yield self.url_result(url, JioSaavnSongIE, url_transparent=True, **song_info)
|
if webpage_url := info.get('webpage_url') or url:
|
||||||
|
info['display_id'] = url_basename(webpage_url)
|
||||||
|
info['_old_archive_ids'] = [make_archive_id(JioSaavnSongIE, info['display_id'])]
|
||||||
|
|
||||||
|
if primary_artists := traverse_obj(song_data, ('primary_artists', {lambda x: x.split(', ') if x else None})):
|
||||||
|
info['artists'].extend(primary_artists)
|
||||||
|
if featured_artists := traverse_obj(song_data, ('featured_artists', {str}, filter)):
|
||||||
|
info['artists'].extend(featured_artists.split(', '))
|
||||||
|
info['artists'] = orderedSet(info['artists']) or None
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_episode(episode_data, url=None):
|
||||||
|
info = JioSaavnBaseIE._extract_song(episode_data, url)
|
||||||
|
info.pop('_old_archive_ids', None)
|
||||||
|
info.update(traverse_obj(episode_data, {
|
||||||
|
'description': ('more_info', 'description', {str}),
|
||||||
|
'timestamp': ('more_info', 'release_time', {unified_timestamp}),
|
||||||
|
'series': ('more_info', 'show_title', {str}),
|
||||||
|
'series_id': ('more_info', 'show_id', {str}),
|
||||||
|
'season': ('more_info', 'season_title', {str}),
|
||||||
|
'season_number': ('more_info', 'season_no', {int_or_none}),
|
||||||
|
'season_id': ('more_info', 'season_id', {str}),
|
||||||
|
'episode_number': ('more_info', 'episode_number', {int_or_none}),
|
||||||
|
'cast': ('starring', {lambda x: x.split(', ') if x else None}),
|
||||||
|
}))
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _extract_jiosaavn_result(self, url, endpoint, response_key, parse_func):
|
||||||
|
url, smuggled_data = unsmuggle_url(url)
|
||||||
|
data = traverse_obj(smuggled_data, ({
|
||||||
|
'id': ('id', {str}),
|
||||||
|
'encrypted_media_url': ('encrypted_media_url', {str}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if 'id' in data and 'encrypted_media_url' in data:
|
||||||
|
result = {'id': data['id']}
|
||||||
|
else:
|
||||||
|
# only extract metadata if this is not a url_transparent result
|
||||||
|
data = self._call_api(endpoint, self._match_id(url))[response_key][0]
|
||||||
|
result = parse_func(data, url)
|
||||||
|
|
||||||
|
result['formats'] = list(self._extract_formats(data))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _yield_items(self, playlist_data, keys=None, parse_func=None):
|
||||||
|
"""Subclasses using this method must set _ENTRY_IE"""
|
||||||
|
if parse_func is None:
|
||||||
|
parse_func = self._extract_song
|
||||||
|
|
||||||
|
for item_data in traverse_obj(playlist_data, (
|
||||||
|
*variadic(keys, (str, bytes, dict, set)), lambda _, v: v['id'] and v['perma_url'],
|
||||||
|
)):
|
||||||
|
info = parse_func(item_data)
|
||||||
|
url = smuggle_url(info['webpage_url'], traverse_obj(item_data, {
|
||||||
|
'id': ('id', {str}),
|
||||||
|
'encrypted_media_url': ((None, 'more_info'), 'encrypted_media_url', {str}, any),
|
||||||
|
}))
|
||||||
|
yield self.url_result(url, self._ENTRY_IE, url_transparent=True, **info)
|
||||||
|
|
||||||
|
|
||||||
class JioSaavnSongIE(JioSaavnBaseIE):
|
class JioSaavnSongIE(JioSaavnBaseIE):
|
||||||
IE_NAME = 'jiosaavn:song'
|
IE_NAME = 'jiosaavn:song'
|
||||||
_VALID_URL = r'https?://(?:www\.)?(?:jiosaavn\.com/song/[^/?#]+/|saavn\.com/s/song/(?:[^/?#]+/){3})(?P<id>[^/?#]+)'
|
_VALID_URL = JioSaavnBaseIE._URL_BASE_RE + r'(?:/song/[^/?#]+/|/s/song/(?:[^/?#]+/){3})(?P<id>[^/?#]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.jiosaavn.com/song/leja-re/OQsEfQFVUXk',
|
'url': 'https://www.jiosaavn.com/song/leja-re/OQsEfQFVUXk',
|
||||||
'md5': '3b84396d15ed9e083c3106f1fa589c04',
|
'md5': '3b84396d15ed9e083c3106f1fa589c04',
|
||||||
@@ -106,12 +172,38 @@ class JioSaavnSongIE(JioSaavnBaseIE):
|
|||||||
'ext': 'm4a',
|
'ext': 'm4a',
|
||||||
'title': 'Leja Re',
|
'title': 'Leja Re',
|
||||||
'album': 'Leja Re',
|
'album': 'Leja Re',
|
||||||
'thumbnail': r're:https?://c.saavncdn.com/258/Leja-Re-Hindi-2018-20181124024539-500x500.jpg',
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
'duration': 205,
|
'duration': 205,
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
'release_year': 2018,
|
'release_year': 2018,
|
||||||
'artists': ['Sandesh Shandilya', 'Dhvani Bhanushali', 'Tanishk Bagchi'],
|
'artists': ['Sandesh Shandilya', 'Dhvani Bhanushali', 'Tanishk Bagchi'],
|
||||||
'_old_archive_ids': ['jiosaavnsong OQsEfQFVUXk'],
|
'_old_archive_ids': ['jiosaavnsong OQsEfQFVUXk'],
|
||||||
|
'channel': 'T-Series',
|
||||||
|
'language': 'hin',
|
||||||
|
'channel_id': '34297',
|
||||||
|
'channel_url': 'https://www.jiosaavn.com/label/t-series-albums/6DLuXO3VoTo_',
|
||||||
|
'release_date': '20181124',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.jiosaavn.com/song/chuttamalle/P1FfWjZkQ0Q',
|
||||||
|
'md5': '96296c58d6ce488a417ef0728fd2d680',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'O94kBTtw',
|
||||||
|
'display_id': 'P1FfWjZkQ0Q',
|
||||||
|
'ext': 'm4a',
|
||||||
|
'title': 'Chuttamalle',
|
||||||
|
'album': 'Devara Part 1 - Telugu',
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
|
'duration': 222,
|
||||||
|
'view_count': int,
|
||||||
|
'release_year': 2024,
|
||||||
|
'artists': 'count:3',
|
||||||
|
'_old_archive_ids': ['jiosaavnsong P1FfWjZkQ0Q'],
|
||||||
|
'channel': 'T-Series',
|
||||||
|
'language': 'tel',
|
||||||
|
'channel_id': '34297',
|
||||||
|
'channel_url': 'https://www.jiosaavn.com/label/t-series-albums/6DLuXO3VoTo_',
|
||||||
|
'release_date': '20240926',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.saavn.com/s/song/hindi/Saathiya/O-Humdum-Suniyo-Re/KAMiazoCblU',
|
'url': 'https://www.saavn.com/s/song/hindi/Saathiya/O-Humdum-Suniyo-Re/KAMiazoCblU',
|
||||||
@@ -119,26 +211,51 @@ class JioSaavnSongIE(JioSaavnBaseIE):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
url, smuggled_data = unsmuggle_url(url)
|
return self._extract_jiosaavn_result(url, 'song', 'songs', self._extract_song)
|
||||||
song_data = traverse_obj(smuggled_data, ({
|
|
||||||
'id': ('id', {str}),
|
|
||||||
'encrypted_media_url': ('encrypted_media_url', {str}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
if 'id' in song_data and 'encrypted_media_url' in song_data:
|
|
||||||
result = {'id': song_data['id']}
|
|
||||||
else:
|
|
||||||
# only extract metadata if this is not a url_transparent result
|
|
||||||
song_data = self._call_api('song', self._match_id(url))['songs'][0]
|
|
||||||
result = self._extract_song(song_data, url)
|
|
||||||
|
|
||||||
result['formats'] = list(self._extract_formats(song_data))
|
class JioSaavnShowIE(JioSaavnBaseIE):
|
||||||
return result
|
IE_NAME = 'jiosaavn:show'
|
||||||
|
_VALID_URL = JioSaavnBaseIE._URL_BASE_RE + r'/shows/[^/?#]+/(?P<id>[^/?#]{11,})/?(?:$|[?#])'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.jiosaavn.com/shows/non-food-ways-to-boost-your-energy/XFMcKICOCgc_',
|
||||||
|
'md5': '0733cd254cfe74ef88bea1eaedcf1f4f',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'qqzh3RKZ',
|
||||||
|
'display_id': 'XFMcKICOCgc_',
|
||||||
|
'ext': 'mp3',
|
||||||
|
'title': 'Non-Food Ways To Boost Your Energy',
|
||||||
|
'description': 'md5:26e7129644b5c6aada32b8851c3997c8',
|
||||||
|
'episode': 'Episode 1',
|
||||||
|
'timestamp': 1640563200,
|
||||||
|
'series': 'Holistic Lifestyle With Neha Ranglani',
|
||||||
|
'series_id': '52397',
|
||||||
|
'season': 'Holistic Lifestyle With Neha Ranglani',
|
||||||
|
'season_number': 1,
|
||||||
|
'season_id': '61273',
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
|
'duration': 311,
|
||||||
|
'view_count': int,
|
||||||
|
'release_year': 2021,
|
||||||
|
'language': 'eng',
|
||||||
|
'channel': 'Saavn OG',
|
||||||
|
'channel_id': '1953876',
|
||||||
|
'episode_number': 1,
|
||||||
|
'upload_date': '20211227',
|
||||||
|
'release_date': '20211227',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.jiosaavn.com/shows/himesh-reshammiya/Kr8fmfSN4vo_',
|
||||||
|
'only_matching': True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
return self._extract_jiosaavn_result(url, 'episode', 'episodes', self._extract_episode)
|
||||||
|
|
||||||
|
|
||||||
class JioSaavnAlbumIE(JioSaavnBaseIE):
|
class JioSaavnAlbumIE(JioSaavnBaseIE):
|
||||||
IE_NAME = 'jiosaavn:album'
|
IE_NAME = 'jiosaavn:album'
|
||||||
_VALID_URL = r'https?://(?:www\.)?(?:jio)?saavn\.com/album/[^/?#]+/(?P<id>[^/?#]+)'
|
_VALID_URL = JioSaavnBaseIE._URL_BASE_RE + r'/album/[^/?#]+/(?P<id>[^/?#]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.jiosaavn.com/album/96/buIOjYZDrNA_',
|
'url': 'https://www.jiosaavn.com/album/96/buIOjYZDrNA_',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -147,18 +264,19 @@ class JioSaavnAlbumIE(JioSaavnBaseIE):
|
|||||||
},
|
},
|
||||||
'playlist_count': 10,
|
'playlist_count': 10,
|
||||||
}]
|
}]
|
||||||
|
_ENTRY_IE = JioSaavnSongIE
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id = self._match_id(url)
|
display_id = self._match_id(url)
|
||||||
album_data = self._call_api('album', display_id)
|
album_data = self._call_api('album', display_id)
|
||||||
|
|
||||||
return self.playlist_result(
|
return self.playlist_result(
|
||||||
self._yield_songs(album_data), display_id, traverse_obj(album_data, ('title', {str})))
|
self._yield_items(album_data, 'songs'), display_id, traverse_obj(album_data, ('title', {str})))
|
||||||
|
|
||||||
|
|
||||||
class JioSaavnPlaylistIE(JioSaavnBaseIE):
|
class JioSaavnPlaylistIE(JioSaavnBaseIE):
|
||||||
IE_NAME = 'jiosaavn:playlist'
|
IE_NAME = 'jiosaavn:playlist'
|
||||||
_VALID_URL = r'https?://(?:www\.)?(?:jio)?saavn\.com/(?:s/playlist/(?:[^/?#]+/){2}|featured/[^/?#]+/)(?P<id>[^/?#]+)'
|
_VALID_URL = JioSaavnBaseIE._URL_BASE_RE + r'/(?:s/playlist/(?:[^/?#]+/){2}|featured/[^/?#]+/)(?P<id>[^/?#]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.jiosaavn.com/s/playlist/2279fbe391defa793ad7076929a2f5c9/mood-english/LlJ8ZWT1ibN5084vKHRj2Q__',
|
'url': 'https://www.jiosaavn.com/s/playlist/2279fbe391defa793ad7076929a2f5c9/mood-english/LlJ8ZWT1ibN5084vKHRj2Q__',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -172,15 +290,16 @@ class JioSaavnPlaylistIE(JioSaavnBaseIE):
|
|||||||
'id': 'DVR,pFUOwyXqIp77B1JF,A__',
|
'id': 'DVR,pFUOwyXqIp77B1JF,A__',
|
||||||
'title': 'Mood Hindi',
|
'title': 'Mood Hindi',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 801,
|
'playlist_mincount': 750,
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.jiosaavn.com/featured/taaza-tunes/Me5RridRfDk_',
|
'url': 'https://www.jiosaavn.com/featured/taaza-tunes/Me5RridRfDk_',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'Me5RridRfDk_',
|
'id': 'Me5RridRfDk_',
|
||||||
'title': 'Taaza Tunes',
|
'title': 'Taaza Tunes',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 301,
|
'playlist_mincount': 50,
|
||||||
}]
|
}]
|
||||||
|
_ENTRY_IE = JioSaavnSongIE
|
||||||
_PAGE_SIZE = 50
|
_PAGE_SIZE = 50
|
||||||
|
|
||||||
def _fetch_page(self, token, page):
|
def _fetch_page(self, token, page):
|
||||||
@@ -189,7 +308,7 @@ def _fetch_page(self, token, page):
|
|||||||
|
|
||||||
def _entries(self, token, first_page_data, page):
|
def _entries(self, token, first_page_data, page):
|
||||||
page_data = first_page_data if not page else self._fetch_page(token, page + 1)
|
page_data = first_page_data if not page else self._fetch_page(token, page + 1)
|
||||||
yield from self._yield_songs(page_data)
|
yield from self._yield_items(page_data, 'songs')
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id = self._match_id(url)
|
display_id = self._match_id(url)
|
||||||
@@ -199,3 +318,95 @@ def _real_extract(self, url):
|
|||||||
return self.playlist_result(InAdvancePagedList(
|
return self.playlist_result(InAdvancePagedList(
|
||||||
functools.partial(self._entries, display_id, playlist_data),
|
functools.partial(self._entries, display_id, playlist_data),
|
||||||
total_pages, self._PAGE_SIZE), display_id, traverse_obj(playlist_data, ('listname', {str})))
|
total_pages, self._PAGE_SIZE), display_id, traverse_obj(playlist_data, ('listname', {str})))
|
||||||
|
|
||||||
|
|
||||||
|
class JioSaavnShowPlaylistIE(JioSaavnBaseIE):
|
||||||
|
IE_NAME = 'jiosaavn:show:playlist'
|
||||||
|
_VALID_URL = JioSaavnBaseIE._URL_BASE_RE + r'/shows/(?P<show>[^#/?]+)/(?P<season>\d+)/[^/?#]+'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.jiosaavn.com/shows/talking-music/1/PjReFP-Sguk_',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'talking-music-1',
|
||||||
|
'title': 'Talking Music',
|
||||||
|
},
|
||||||
|
'playlist_mincount': 11,
|
||||||
|
}]
|
||||||
|
_ENTRY_IE = JioSaavnShowIE
|
||||||
|
_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
def _fetch_page(self, show_id, season_id, page):
|
||||||
|
return self._call_api('show', show_id, f'show page {page}', {
|
||||||
|
'p': page,
|
||||||
|
'__call': 'show.getAllEpisodes',
|
||||||
|
'show_id': show_id,
|
||||||
|
'season_number': season_id,
|
||||||
|
'api_version': '4',
|
||||||
|
'sort_order': 'desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
def _entries(self, show_id, season_id, page):
|
||||||
|
page_data = self._fetch_page(show_id, season_id, page + 1)
|
||||||
|
yield from self._yield_items(page_data, keys=None, parse_func=self._extract_episode)
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
show_slug, season_id = self._match_valid_url(url).group('show', 'season')
|
||||||
|
playlist_id = f'{show_slug}-{season_id}'
|
||||||
|
webpage = self._download_webpage(url, playlist_id)
|
||||||
|
|
||||||
|
show_info = self._search_json(
|
||||||
|
r'window\.__INITIAL_DATA__\s*=', webpage, 'initial data',
|
||||||
|
playlist_id, transform_source=js_to_json)['showView']
|
||||||
|
show_id = show_info['current_id']
|
||||||
|
|
||||||
|
entries = OnDemandPagedList(functools.partial(self._entries, show_id, season_id), self._PAGE_SIZE)
|
||||||
|
return self.playlist_result(
|
||||||
|
entries, playlist_id, traverse_obj(show_info, ('show', 'title', 'text', {str})))
|
||||||
|
|
||||||
|
|
||||||
|
class JioSaavnArtistIE(JioSaavnBaseIE):
|
||||||
|
IE_NAME = 'jiosaavn:artist'
|
||||||
|
_VALID_URL = JioSaavnBaseIE._URL_BASE_RE + r'/artist/[^/?#]+/(?P<id>[^/?#]+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.jiosaavn.com/artist/krsna-songs/rYLBEve2z3U_',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'rYLBEve2z3U_',
|
||||||
|
'title': 'KR$NA',
|
||||||
|
},
|
||||||
|
'playlist_mincount': 38,
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.jiosaavn.com/artist/sanam-puri-songs/SkNEv3qRhDE_',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'SkNEv3qRhDE_',
|
||||||
|
'title': 'Sanam Puri',
|
||||||
|
},
|
||||||
|
'playlist_mincount': 51,
|
||||||
|
}]
|
||||||
|
_ENTRY_IE = JioSaavnSongIE
|
||||||
|
_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
def _fetch_page(self, artist_id, page):
|
||||||
|
return self._call_api('artist', artist_id, f'artist page {page + 1}', {
|
||||||
|
'p': page,
|
||||||
|
'n_song': self._PAGE_SIZE,
|
||||||
|
'n_album': self._PAGE_SIZE,
|
||||||
|
'sub_type': '',
|
||||||
|
'includeMetaTags': '',
|
||||||
|
'api_version': '4',
|
||||||
|
'category': 'alphabetical',
|
||||||
|
'sort_order': 'asc',
|
||||||
|
})
|
||||||
|
|
||||||
|
def _entries(self, artist_id, first_page):
|
||||||
|
for page in itertools.count():
|
||||||
|
playlist_data = first_page if not page else self._fetch_page(artist_id, page)
|
||||||
|
if not traverse_obj(playlist_data, ('topSongs', ..., {dict})):
|
||||||
|
break
|
||||||
|
yield from self._yield_items(playlist_data, 'topSongs')
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
artist_id = self._match_id(url)
|
||||||
|
first_page = self._fetch_page(artist_id, 0)
|
||||||
|
|
||||||
|
return self.playlist_result(
|
||||||
|
self._entries(artist_id, first_page), artist_id,
|
||||||
|
traverse_obj(first_page, ('name', {str})))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import functools
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..networking import HEADRequest
|
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
UserNotLive,
|
UserNotLive,
|
||||||
determine_ext,
|
determine_ext,
|
||||||
float_or_none,
|
float_or_none,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
merge_dicts,
|
|
||||||
parse_iso8601,
|
parse_iso8601,
|
||||||
str_or_none,
|
str_or_none,
|
||||||
traverse_obj,
|
traverse_obj,
|
||||||
@@ -16,21 +16,17 @@
|
|||||||
|
|
||||||
|
|
||||||
class KickBaseIE(InfoExtractor):
|
class KickBaseIE(InfoExtractor):
|
||||||
def _real_initialize(self):
|
@functools.cached_property
|
||||||
self._request_webpage(
|
def _api_headers(self):
|
||||||
HEADRequest('https://kick.com/'), None, 'Setting up session', fatal=False, impersonate=True)
|
token = traverse_obj(
|
||||||
xsrf_token = self._get_cookies('https://kick.com/').get('XSRF-TOKEN')
|
self._get_cookies('https://kick.com/'),
|
||||||
if not xsrf_token:
|
('session_token', 'value', {urllib.parse.unquote}))
|
||||||
self.write_debug('kick.com did not set XSRF-TOKEN cookie')
|
return {'Authorization': f'Bearer {token}'} if token else {}
|
||||||
KickBaseIE._API_HEADERS = {
|
|
||||||
'Authorization': f'Bearer {xsrf_token.value}',
|
|
||||||
'X-XSRF-TOKEN': xsrf_token.value,
|
|
||||||
} if xsrf_token else {}
|
|
||||||
|
|
||||||
def _call_api(self, path, display_id, note='Downloading API JSON', headers={}, **kwargs):
|
def _call_api(self, path, display_id, note='Downloading API JSON', headers={}, **kwargs):
|
||||||
return self._download_json(
|
return self._download_json(
|
||||||
f'https://kick.com/api/{path}', display_id, note=note,
|
f'https://kick.com/api/{path}', display_id, note=note,
|
||||||
headers=merge_dicts(headers, self._API_HEADERS), impersonate=True, **kwargs)
|
headers={**self._api_headers, **headers}, impersonate=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class KickIE(KickBaseIE):
|
class KickIE(KickBaseIE):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import itertools
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
determine_ext,
|
determine_ext,
|
||||||
@@ -124,3 +126,43 @@ def _extract_formats(self, media_info, video_id):
|
|||||||
'vbr': ('bitrateVideo', {int_or_none}, {lambda x: None if x == -1 else x}),
|
'vbr': ('bitrateVideo', {int_or_none}, {lambda x: None if x == -1 else x}),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class KikaPlaylistIE(InfoExtractor):
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?kika\.de/[\w-]+/(?P<id>[a-z-]+\d+)'
|
||||||
|
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.kika.de/logo/logo-die-welt-und-ich-562',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'logo-die-welt-und-ich-562',
|
||||||
|
'title': 'logo!',
|
||||||
|
'description': 'md5:7b9d7f65561b82fa512f2cfb553c397d',
|
||||||
|
},
|
||||||
|
'playlist_count': 100,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _entries(self, playlist_url, playlist_id):
|
||||||
|
for page in itertools.count(1):
|
||||||
|
data = self._download_json(playlist_url, playlist_id, note=f'Downloading page {page}')
|
||||||
|
for item in traverse_obj(data, ('content', lambda _, v: url_or_none(v['api']['url']))):
|
||||||
|
yield self.url_result(
|
||||||
|
item['api']['url'], ie=KikaIE,
|
||||||
|
**traverse_obj(item, {
|
||||||
|
'id': ('id', {str}),
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'timestamp': ('date', {parse_iso8601}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
playlist_url = traverse_obj(data, ('links', 'next', {url_or_none}))
|
||||||
|
if not playlist_url:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
playlist_id = self._match_id(url)
|
||||||
|
brand_data = self._download_json(
|
||||||
|
f'https://www.kika.de/_next-api/proxy/v1/brands/{playlist_id}', playlist_id)
|
||||||
|
|
||||||
|
return self.playlist_result(
|
||||||
|
self._entries(brand_data['videoSubchannel']['videosPageUrl'], playlist_id),
|
||||||
|
playlist_id, title=brand_data.get('title'), description=brand_data.get('description'))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import itertools
|
import itertools
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
@@ -9,12 +10,12 @@
|
|||||||
int_or_none,
|
int_or_none,
|
||||||
mimetype2ext,
|
mimetype2ext,
|
||||||
srt_subtitles_timecode,
|
srt_subtitles_timecode,
|
||||||
traverse_obj,
|
|
||||||
try_get,
|
try_get,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
urljoin,
|
urljoin,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import find_elements, require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class LinkedInBaseIE(InfoExtractor):
|
class LinkedInBaseIE(InfoExtractor):
|
||||||
@@ -82,7 +83,10 @@ def _get_video_id(self, video_data, course_slug, video_slug):
|
|||||||
|
|
||||||
|
|
||||||
class LinkedInIE(LinkedInBaseIE):
|
class LinkedInIE(LinkedInBaseIE):
|
||||||
_VALID_URL = r'https?://(?:www\.)?linkedin\.com/posts/[^/?#]+-(?P<id>\d+)-\w{4}/?(?:[?#]|$)'
|
_VALID_URL = [
|
||||||
|
r'https?://(?:www\.)?linkedin\.com/posts/[^/?#]+-(?P<id>\d+)-\w{4}/?(?:[?#]|$)',
|
||||||
|
r'https?://(?:www\.)?linkedin\.com/feed/update/urn:li:activity:(?P<id>\d+)',
|
||||||
|
]
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://www.linkedin.com/posts/mishalkhawaja_sendinblueviews-toronto-digitalmarketing-ugcPost-6850898786781339649-mM20',
|
'url': 'https://www.linkedin.com/posts/mishalkhawaja_sendinblueviews-toronto-digitalmarketing-ugcPost-6850898786781339649-mM20',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -106,6 +110,9 @@ class LinkedInIE(LinkedInBaseIE):
|
|||||||
'like_count': int,
|
'like_count': int,
|
||||||
'subtitles': 'mincount:1',
|
'subtitles': 'mincount:1',
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.linkedin.com/feed/update/urn:li:activity:7016901149999955968/?utm_source=share&utm_medium=member_desktop',
|
||||||
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
@@ -271,3 +278,110 @@ def _real_extract(self, url):
|
|||||||
entries, course_slug,
|
entries, course_slug,
|
||||||
course_data.get('title'),
|
course_data.get('title'),
|
||||||
course_data.get('description'))
|
course_data.get('description'))
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedInEventsIE(LinkedInBaseIE):
|
||||||
|
IE_NAME = 'linkedin:events'
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?linkedin\.com/events/(?P<id>[\w-]+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.linkedin.com/events/7084656651378536448/comments/',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '7084656651378536448',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': '#37 Aprende a hacer una entrevista en inglés para tu próximo trabajo remoto',
|
||||||
|
'description': '¡Agarra para anotar que se viene tremendo evento!',
|
||||||
|
'duration': 1765,
|
||||||
|
'timestamp': 1689113772,
|
||||||
|
'upload_date': '20230711',
|
||||||
|
'release_timestamp': 1689174012,
|
||||||
|
'release_date': '20230712',
|
||||||
|
'live_status': 'was_live',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.linkedin.com/events/27-02energyfreedombyenergyclub7295762520814874625/comments/',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '27-02energyfreedombyenergyclub7295762520814874625',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': '27.02 Energy Freedom by Energy Club',
|
||||||
|
'description': 'md5:1292e6f31df998914c293787a02c3b91',
|
||||||
|
'duration': 6420,
|
||||||
|
'timestamp': 1739445333,
|
||||||
|
'upload_date': '20250213',
|
||||||
|
'release_timestamp': 1740657620,
|
||||||
|
'release_date': '20250227',
|
||||||
|
'live_status': 'was_live',
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_initialize(self):
|
||||||
|
if not self._get_cookies('https://www.linkedin.com/').get('li_at'):
|
||||||
|
self.raise_login_required()
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
event_id = self._match_id(url)
|
||||||
|
webpage = self._download_webpage(url, event_id)
|
||||||
|
|
||||||
|
base_data = traverse_obj(webpage, (
|
||||||
|
{find_elements(tag='code', attr='style', value='display: none')}, ..., {json.loads}, 'included', ...))
|
||||||
|
meta_data = traverse_obj(base_data, (
|
||||||
|
lambda _, v: v['$type'] == 'com.linkedin.voyager.dash.events.ProfessionalEvent', any)) or {}
|
||||||
|
|
||||||
|
live_status = {
|
||||||
|
'PAST': 'was_live',
|
||||||
|
'ONGOING': 'is_live',
|
||||||
|
'FUTURE': 'is_upcoming',
|
||||||
|
}.get(meta_data.get('lifecycleState'))
|
||||||
|
|
||||||
|
if live_status == 'is_upcoming':
|
||||||
|
player_data = {}
|
||||||
|
if event_time := traverse_obj(meta_data, ('displayEventTime', {str})):
|
||||||
|
message = f'This live event is scheduled for {event_time}'
|
||||||
|
else:
|
||||||
|
message = 'This live event has not yet started'
|
||||||
|
self.raise_no_formats(message, expected=True, video_id=event_id)
|
||||||
|
else:
|
||||||
|
# TODO: Add support for audio-only live events
|
||||||
|
player_data = traverse_obj(base_data, (
|
||||||
|
lambda _, v: v['$type'] == 'com.linkedin.videocontent.VideoPlayMetadata',
|
||||||
|
any, {require('video player data')}))
|
||||||
|
|
||||||
|
formats, subtitles = [], {}
|
||||||
|
for prog_fmts in traverse_obj(player_data, ('progressiveStreams', ..., {dict})):
|
||||||
|
for fmt_url in traverse_obj(prog_fmts, ('streamingLocations', ..., 'url', {url_or_none})):
|
||||||
|
formats.append({
|
||||||
|
'url': fmt_url,
|
||||||
|
**traverse_obj(prog_fmts, {
|
||||||
|
'width': ('width', {int_or_none}),
|
||||||
|
'height': ('height', {int_or_none}),
|
||||||
|
'tbr': ('bitRate', {int_or_none(scale=1000)}),
|
||||||
|
'filesize': ('size', {int_or_none}),
|
||||||
|
'ext': ('mediaType', {mimetype2ext}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
for m3u8_url in traverse_obj(player_data, (
|
||||||
|
'adaptiveStreams', lambda _, v: v['protocol'] == 'HLS', 'masterPlaylists', ..., 'url', {url_or_none},
|
||||||
|
)):
|
||||||
|
fmts, subs = self._extract_m3u8_formats_and_subtitles(
|
||||||
|
m3u8_url, event_id, 'mp4', m3u8_id='hls', fatal=False)
|
||||||
|
formats.extend(fmts)
|
||||||
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': event_id,
|
||||||
|
'formats': formats,
|
||||||
|
'subtitles': subtitles,
|
||||||
|
'live_status': live_status,
|
||||||
|
**traverse_obj(meta_data, {
|
||||||
|
'title': ('name', {str}),
|
||||||
|
'description': ('description', 'text', {str}),
|
||||||
|
'timestamp': ('createdAt', {int_or_none(scale=1000)}),
|
||||||
|
# timeRange.start is available when the stream is_upcoming
|
||||||
|
'release_timestamp': ('timeRange', 'start', {int_or_none(scale=1000)}),
|
||||||
|
}),
|
||||||
|
**traverse_obj(player_data, {
|
||||||
|
'duration': ('duration', {int_or_none(scale=1000)}),
|
||||||
|
# liveStreamCreatedAt is only available when the stream is_live or was_live
|
||||||
|
'release_timestamp': ('liveStreamCreatedAt', {int_or_none(scale=1000)}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import int_or_none, url_or_none
|
from ..utils import int_or_none, jwt_decode_hs256, try_call, url_or_none
|
||||||
from ..utils.traversal import require, traverse_obj
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
@@ -55,13 +59,81 @@ class LocoIE(InfoExtractor):
|
|||||||
'upload_date': '20250226',
|
'upload_date': '20250226',
|
||||||
'modified_date': '20250226',
|
'modified_date': '20250226',
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
# Requires video authorization
|
||||||
|
'url': 'https://loco.com/stream/ac854641-ae0f-497c-a8ea-4195f6d8cc53',
|
||||||
|
'md5': '0513edf85c1e65c9521f555f665387d5',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'ac854641-ae0f-497c-a8ea-4195f6d8cc53',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'DUAS CONTAS DESAFIANTE, RUSH TOP 1 NO BRASIL!',
|
||||||
|
'description': 'md5:aa77818edd6fe00dd4b6be75cba5f826',
|
||||||
|
'uploader_id': '7Y9JNAZC3Q',
|
||||||
|
'channel': 'ayellol',
|
||||||
|
'channel_follower_count': int,
|
||||||
|
'comment_count': int,
|
||||||
|
'view_count': int,
|
||||||
|
'concurrent_view_count': int,
|
||||||
|
'like_count': int,
|
||||||
|
'duration': 1229,
|
||||||
|
'thumbnail': 'https://static.ivory.getloconow.com/default_thumb/f5aa678b-6d04-45d9-a89a-859af0a8028f.jpg',
|
||||||
|
'tags': ['Gameplay', 'Carry'],
|
||||||
|
'series': 'League of Legends',
|
||||||
|
'timestamp': 1741182253,
|
||||||
|
'upload_date': '20250305',
|
||||||
|
'modified_timestamp': 1741182419,
|
||||||
|
'modified_date': '20250305',
|
||||||
|
},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
# From _app.js
|
||||||
|
_CLIENT_ID = 'TlwKp1zmF6eKFpcisn3FyR18WkhcPkZtzwPVEEC3'
|
||||||
|
_CLIENT_SECRET = 'Kp7tYlUN7LXvtcSpwYvIitgYcLparbtsQSe5AdyyCdiEJBP53Vt9J8eB4AsLdChIpcO2BM19RA3HsGtqDJFjWmwoonvMSG3ZQmnS8x1YIM8yl82xMXZGbE3NKiqmgBVU'
|
||||||
|
|
||||||
|
def _is_jwt_expired(self, token):
|
||||||
|
return jwt_decode_hs256(token)['exp'] - time.time() < 300
|
||||||
|
|
||||||
|
def _get_access_token(self, video_id):
|
||||||
|
access_token = try_call(lambda: self._get_cookies('https://loco.com')['access_token'].value)
|
||||||
|
if access_token and not self._is_jwt_expired(access_token):
|
||||||
|
return access_token
|
||||||
|
access_token = traverse_obj(self._download_json(
|
||||||
|
'https://api.getloconow.com/v3/user/device_profile/', video_id,
|
||||||
|
'Downloading access token', fatal=False, data=json.dumps({
|
||||||
|
'platform': 7,
|
||||||
|
'client_id': self._CLIENT_ID,
|
||||||
|
'client_secret': self._CLIENT_SECRET,
|
||||||
|
'model': 'Mozilla',
|
||||||
|
'os_name': 'Win32',
|
||||||
|
'os_ver': '5.0 (Windows)',
|
||||||
|
'app_ver': '5.0 (Windows)',
|
||||||
|
}).encode(), headers={
|
||||||
|
'Content-Type': 'application/json;charset=utf-8',
|
||||||
|
'DEVICE-ID': ''.join(random.choices('0123456789abcdef', k=32)) + 'live',
|
||||||
|
'X-APP-LANG': 'en',
|
||||||
|
'X-APP-LOCALE': 'en-US',
|
||||||
|
'X-CLIENT-ID': self._CLIENT_ID,
|
||||||
|
'X-CLIENT-SECRET': self._CLIENT_SECRET,
|
||||||
|
'X-PLATFORM': '7',
|
||||||
|
}), 'access_token')
|
||||||
|
if access_token and not self._is_jwt_expired(access_token):
|
||||||
|
self._set_cookie('.loco.com', 'access_token', access_token)
|
||||||
|
return access_token
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_type, video_id = self._match_valid_url(url).group('type', 'id')
|
video_type, video_id = self._match_valid_url(url).group('type', 'id')
|
||||||
webpage = self._download_webpage(url, video_id)
|
webpage = self._download_webpage(url, video_id)
|
||||||
stream = traverse_obj(self._search_nextjs_data(webpage, video_id), (
|
stream = traverse_obj(self._search_nextjs_data(webpage, video_id), (
|
||||||
'props', 'pageProps', ('liveStreamData', 'stream'), {dict}, any, {require('stream info')}))
|
'props', 'pageProps', ('liveStreamData', 'stream', 'liveStream'), {dict}, any, {require('stream info')}))
|
||||||
|
|
||||||
|
if access_token := self._get_access_token(video_id):
|
||||||
|
self._request_webpage(
|
||||||
|
'https://drm.loco.com/v1/streams/playback/', video_id,
|
||||||
|
'Downloading video authorization', fatal=False, headers={
|
||||||
|
'authorization': access_token,
|
||||||
|
}, query={
|
||||||
|
'stream_uid': stream['uid'],
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'formats': self._extract_m3u8_formats(stream['conf']['hls'], video_id),
|
'formats': self._extract_m3u8_formats(stream['conf']['hls'], video_id),
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
clean_html,
|
clean_html,
|
||||||
merge_dicts,
|
merge_dicts,
|
||||||
traverse_obj,
|
traverse_obj,
|
||||||
|
unified_timestamp,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
|
urljoin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ class LRTVODIE(LRTBaseIE):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
path, video_id = self._match_valid_url(url).groups()
|
path, video_id = self._match_valid_url(url).group('path', 'id')
|
||||||
webpage = self._download_webpage(url, video_id)
|
webpage = self._download_webpage(url, video_id)
|
||||||
|
|
||||||
media_url = self._extract_js_var(webpage, 'main_url', path)
|
media_url = self._extract_js_var(webpage, 'main_url', path)
|
||||||
@@ -106,3 +108,44 @@ def _real_extract(self, url):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return merge_dicts(clean_info, jw_data, json_ld_data)
|
return merge_dicts(clean_info, jw_data, json_ld_data)
|
||||||
|
|
||||||
|
|
||||||
|
class LRTRadioIE(LRTBaseIE):
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?lrt\.lt/radioteka/irasas/(?P<id>\d+)/(?P<path>[^?#/]+)'
|
||||||
|
_TESTS = [{
|
||||||
|
# m3u8 download
|
||||||
|
'url': 'https://www.lrt.lt/radioteka/irasas/2000359728/nemarios-eiles-apie-pragarus-ir-skaistyklas-su-aiste-kiltinaviciute',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '2000359728',
|
||||||
|
'ext': 'm4a',
|
||||||
|
'title': 'Nemarios eilės: apie pragarus ir skaistyklas su Aiste Kiltinavičiūte',
|
||||||
|
'description': 'md5:5eee9a0e86a55bf547bd67596204625d',
|
||||||
|
'timestamp': 1726143120,
|
||||||
|
'upload_date': '20240912',
|
||||||
|
'tags': 'count:5',
|
||||||
|
'thumbnail': r're:https?://.+/.+\.jpe?g',
|
||||||
|
'categories': ['Daiktiniai įrodymai'],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.lrt.lt/radioteka/irasas/2000304654/vakaras-su-knyga-svetlana-aleksijevic-cernobylio-malda-v-dalis?season=%2Fmediateka%2Faudio%2Fvakaras-su-knyga%2F2023',
|
||||||
|
'only_matching': True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
video_id, path = self._match_valid_url(url).group('id', 'path')
|
||||||
|
media = self._download_json(
|
||||||
|
'https://www.lrt.lt/radioteka/api/media', video_id,
|
||||||
|
query={'url': f'/mediateka/irasas/{video_id}/{path}'})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': video_id,
|
||||||
|
'formats': self._extract_m3u8_formats(media['playlist_item']['file'], video_id),
|
||||||
|
**traverse_obj(media, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'tags': ('tags', ..., 'name', {str}),
|
||||||
|
'categories': ('playlist_item', 'category', {str}, filter, all, filter),
|
||||||
|
'description': ('content', {clean_html}, {str}),
|
||||||
|
'timestamp': ('date', {lambda x: x.replace('.', '/')}, {unified_timestamp}),
|
||||||
|
'thumbnail': ('playlist_item', 'image', {urljoin('https://www.lrt.lt')}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,11 +167,11 @@ class LSMLTVEmbedIE(InfoExtractor):
|
|||||||
'duration': 1442,
|
'duration': 1442,
|
||||||
'upload_date': '20231121',
|
'upload_date': '20231121',
|
||||||
'title': 'D23-6000-105_cetstud',
|
'title': 'D23-6000-105_cetstud',
|
||||||
'thumbnail': 'https://store.cloudycdn.services/tmsp00060/assets/media/660858/placeholder1700589200.jpg',
|
'thumbnail': 'https://store.bstrm.net/tmsp00060/assets/media/660858/placeholder1700589200.jpg',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://ltv.lsm.lv/embed?enablesdkjs=1&c=eyJpdiI6IncwVzZmUFk2MU12enVWK1I3SUcwQ1E9PSIsInZhbHVlIjoid3FhV29vamc3T2sxL1RaRmJ5Rm1GTXozU0o2dVczdUtLK0cwZEZJMDQ2a3ZIRG5DK2pneGlnbktBQy9uazVleHN6VXhxdWIweWNvcHRDSnlISlNYOHlVZ1lpcTUrcWZSTUZPQW14TVdkMW9aOUtRWVNDcFF4eWpHNGcrT0VZbUNFQStKQk91cGpndW9FVjJIa0lpbkh3PT0iLCJtYWMiOiIyZGI1NDJlMWRlM2QyMGNhOGEwYTM2MmNlN2JlOGRhY2QyYjdkMmEzN2RlOTEzYTVkNzI1ODlhZDlhZjU4MjQ2IiwidGFnIjoiIn0=',
|
'url': 'https://ltv.lsm.lv/embed?enablesdkjs=1&c=eyJpdiI6IncwVzZmUFk2MU12enVWK1I3SUcwQ1E9PSIsInZhbHVlIjoid3FhV29vamc3T2sxL1RaRmJ5Rm1GTXozU0o2dVczdUtLK0cwZEZJMDQ2a3ZIRG5DK2pneGlnbktBQy9uazVleHN6VXhxdWIweWNvcHRDSnlISlNYOHlVZ1lpcTUrcWZSTUZPQW14TVdkMW9aOUtRWVNDcFF4eWpHNGcrT0VZbUNFQStKQk91cGpndW9FVjJIa0lpbkh3PT0iLCJtYWMiOiIyZGI1NDJlMWRlM2QyMGNhOGEwYTM2MmNlN2JlOGRhY2QyYjdkMmEzN2RlOTEzYTVkNzI1ODlhZDlhZjU4MjQ2IiwidGFnIjoiIn0=',
|
||||||
'md5': 'a1711e190fe680fdb68fd8413b378e87',
|
'md5': 'f236cef2fd5953612754e4e66be51e7a',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'wUnFArIPDSY',
|
'id': 'wUnFArIPDSY',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
@@ -198,6 +198,8 @@ class LSMLTVEmbedIE(InfoExtractor):
|
|||||||
'uploader_url': 'https://www.youtube.com/@LTV16plus',
|
'uploader_url': 'https://www.youtube.com/@LTV16plus',
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
'description': 'md5:7ff0c42ba971e3c13e4b8a2ff03b70b5',
|
'description': 'md5:7ff0c42ba971e3c13e4b8a2ff03b70b5',
|
||||||
|
'media_type': 'livestream',
|
||||||
|
'timestamp': 1652550741,
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@@ -208,7 +210,7 @@ def _real_extract(self, url):
|
|||||||
r'window\.ltvEmbedPayload\s*=', webpage, 'embed json', video_id)
|
r'window\.ltvEmbedPayload\s*=', webpage, 'embed json', video_id)
|
||||||
embed_type = traverse_obj(data, ('source', 'name', {str}))
|
embed_type = traverse_obj(data, ('source', 'name', {str}))
|
||||||
|
|
||||||
if embed_type == 'telia':
|
if embed_type in ('backscreen', 'telia'): # 'telia' only for backwards compat
|
||||||
ie_key = 'CloudyCDN'
|
ie_key = 'CloudyCDN'
|
||||||
embed_url = traverse_obj(data, ('source', 'embed_url', {url_or_none}))
|
embed_url = traverse_obj(data, ('source', 'embed_url', {url_or_none}))
|
||||||
elif embed_type == 'youtube':
|
elif embed_type == 'youtube':
|
||||||
@@ -226,9 +228,9 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class LSMReplayIE(InfoExtractor):
|
class LSMReplayIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://replay\.lsm\.lv/[^/?#]+/(?:ieraksts|statja)/[^/?#]+/(?P<id>\d+)'
|
_VALID_URL = r'https?://replay\.lsm\.lv/[^/?#]+/(?:skaties/|klausies/)?(?:ieraksts|statja)/[^/?#]+/(?P<id>\d+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://replay.lsm.lv/lv/ieraksts/ltv/311130/4-studija-zolitudes-tragedija-un-incupes-stacija',
|
'url': 'https://replay.lsm.lv/lv/skaties/ieraksts/ltv/311130/4-studija-zolitudes-tragedija-un-incupes-stacija',
|
||||||
'md5': '64f72a360ca530d5ed89c77646c9eee5',
|
'md5': '64f72a360ca530d5ed89c77646c9eee5',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '46k_d23-6000-105',
|
'id': '46k_d23-6000-105',
|
||||||
@@ -241,20 +243,23 @@ class LSMReplayIE(InfoExtractor):
|
|||||||
'thumbnail': 'https://ltv.lsm.lv/storage/media/8/7/large/5/1f9604e1.jpg',
|
'thumbnail': 'https://ltv.lsm.lv/storage/media/8/7/large/5/1f9604e1.jpg',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://replay.lsm.lv/lv/ieraksts/lr/183522/138-nepilniga-kompensejamo-zalu-sistema-pat-menesiem-dzena-pacientus-pa-aptiekam',
|
'url': 'https://replay.lsm.lv/lv/klausies/ieraksts/lr/183522/138-nepilniga-kompensejamo-zalu-sistema-pat-menesiem-dzena-pacientus-pa-aptiekam',
|
||||||
'md5': '719b33875cd1429846eeeaeec6df2830',
|
'md5': '84feb80fd7e6ec07744726a9f01cda4d',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'a342781',
|
'id': '183522',
|
||||||
'ext': 'mp3',
|
'ext': 'm4a',
|
||||||
'duration': 1823,
|
'duration': 1823,
|
||||||
'title': '#138 Nepilnīgā kompensējamo zāļu sistēma pat mēnešiem dzenā pacientus pa aptiekām',
|
'title': '#138 Nepilnīgā kompensējamo zāļu sistēma pat mēnešiem dzenā pacientus pa aptiekām',
|
||||||
'thumbnail': 'https://pic.latvijasradio.lv/public/assets/media/9/d/large_fd4675ac.jpg',
|
'thumbnail': 'https://pic.latvijasradio.lv/public/assets/media/9/d/large_fd4675ac.jpg',
|
||||||
'upload_date': '20231102',
|
'upload_date': '20231102',
|
||||||
'timestamp': 1698921060,
|
'timestamp': 1698913860,
|
||||||
'description': 'md5:7bac3b2dd41e44325032943251c357b1',
|
'description': 'md5:7bac3b2dd41e44325032943251c357b1',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://replay.lsm.lv/ru/statja/ltv/311130/4-studija-zolitudes-tragedija-un-incupes-stacija',
|
'url': 'https://replay.lsm.lv/ru/skaties/statja/ltv/355067/v-kengaragse-nacalas-ukladka-relsov',
|
||||||
|
'only_matching': True,
|
||||||
|
}, {
|
||||||
|
'url': 'https://replay.lsm.lv/lv/ieraksts/ltv/311130/4-studija-zolitudes-tragedija-un-incupes-stacija',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@@ -267,12 +272,24 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
data = self._search_nuxt_data(
|
data = self._search_nuxt_data(
|
||||||
self._fix_nuxt_data(webpage), video_id, context_name='__REPLAY__')
|
self._fix_nuxt_data(webpage), video_id, context_name='__REPLAY__')
|
||||||
|
playback_type = data['playback']['type']
|
||||||
|
|
||||||
|
if playback_type == 'playable_audio_lr':
|
||||||
|
playback_data = {
|
||||||
|
'formats': self._extract_m3u8_formats(data['playback']['service']['hls_url'], video_id),
|
||||||
|
}
|
||||||
|
elif playback_type == 'embed':
|
||||||
|
playback_data = {
|
||||||
|
'_type': 'url_transparent',
|
||||||
|
'url': data['playback']['service']['url'],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise ExtractorError(f'Unsupported playback type "{playback_type}"')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'_type': 'url_transparent',
|
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
|
**playback_data,
|
||||||
**traverse_obj(data, {
|
**traverse_obj(data, {
|
||||||
'url': ('playback', 'service', 'url', {url_or_none}),
|
|
||||||
'title': ('mediaItem', 'title'),
|
'title': ('mediaItem', 'title'),
|
||||||
'description': ('mediaItem', ('lead', 'body')),
|
'description': ('mediaItem', ('lead', 'body')),
|
||||||
'duration': ('mediaItem', 'duration', {int_or_none}),
|
'duration': ('mediaItem', 'duration', {int_or_none}),
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
|
clean_html,
|
||||||
determine_ext,
|
determine_ext,
|
||||||
extract_attributes,
|
|
||||||
int_or_none,
|
int_or_none,
|
||||||
str_to_int,
|
join_nonempty,
|
||||||
|
parse_count,
|
||||||
|
parse_duration,
|
||||||
|
parse_iso8601,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
urlencode_postdata,
|
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class ManyVidsIE(InfoExtractor):
|
class ManyVidsIE(InfoExtractor):
|
||||||
_WORKING = False
|
|
||||||
_VALID_URL = r'(?i)https?://(?:www\.)?manyvids\.com/video/(?P<id>\d+)'
|
_VALID_URL = r'(?i)https?://(?:www\.)?manyvids\.com/video/(?P<id>\d+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
# preview video
|
# preview video
|
||||||
'url': 'https://www.manyvids.com/Video/133957/everthing-about-me/',
|
'url': 'https://www.manyvids.com/Video/530341/mv-tips-tricks',
|
||||||
'md5': '03f11bb21c52dd12a05be21a5c7dcc97',
|
'md5': '738dc723f7735ee9602f7ea352a6d058',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '133957',
|
'id': '530341-preview',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'everthing about me (Preview)',
|
'title': 'MV Tips & Tricks (Preview)',
|
||||||
'uploader': 'ellyxxix',
|
'description': r're:I will take you on a tour around .{1313}$',
|
||||||
|
'thumbnail': r're:https://cdn5\.manyvids\.com/php_uploads/video_images/DestinyDiaz/.+\.jpg',
|
||||||
|
'uploader': 'DestinyDiaz',
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
|
'release_timestamp': 1508419904,
|
||||||
|
'tags': ['AdultSchool', 'BBW', 'SFW', 'TeacherFetish'],
|
||||||
|
'release_date': '20171019',
|
||||||
|
'duration': 3167.0,
|
||||||
},
|
},
|
||||||
|
'expected_warnings': ['Only extracting preview'],
|
||||||
}, {
|
}, {
|
||||||
# full video
|
# full video
|
||||||
'url': 'https://www.manyvids.com/Video/935718/MY-FACE-REVEAL/',
|
'url': 'https://www.manyvids.com/Video/935718/MY-FACE-REVEAL/',
|
||||||
@@ -34,129 +41,68 @@ class ManyVidsIE(InfoExtractor):
|
|||||||
'id': '935718',
|
'id': '935718',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'MY FACE REVEAL',
|
'title': 'MY FACE REVEAL',
|
||||||
'description': 'md5:ec5901d41808b3746fed90face161612',
|
'description': r're:Today is the day!! I am finally taking off my mask .{445}$',
|
||||||
|
'thumbnail': r're:https://ods\.manyvids\.com/1001061960/3aa5397f2a723ec4597e344df66ab845/screenshots/.+\.jpg',
|
||||||
'uploader': 'Sarah Calanthe',
|
'uploader': 'Sarah Calanthe',
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
|
'release_date': '20181110',
|
||||||
|
'tags': ['EyeContact', 'Interviews', 'MaskFetish', 'MouthFetish', 'Redhead'],
|
||||||
|
'release_timestamp': 1541851200,
|
||||||
|
'duration': 224.0,
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
_API_BASE = 'https://www.manyvids.com/bff/store/video'
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
|
video_data = self._download_json(f'{self._API_BASE}/{video_id}/private', video_id)['data']
|
||||||
|
formats, preview_only = [], True
|
||||||
|
|
||||||
real_url = f'https://www.manyvids.com/video/{video_id}/gtm.js'
|
for format_id, path in [
|
||||||
try:
|
('preview', ['teaser', 'filepath']),
|
||||||
webpage = self._download_webpage(real_url, video_id)
|
('transcoded', ['transcodedFilepath']),
|
||||||
except Exception:
|
('filepath', ['filepath']),
|
||||||
# probably useless fallback
|
]:
|
||||||
webpage = self._download_webpage(url, video_id)
|
format_url = traverse_obj(video_data, (*path, {url_or_none}))
|
||||||
|
if not format_url:
|
||||||
info = self._search_regex(
|
|
||||||
r'''(<div\b[^>]*\bid\s*=\s*(['"])pageMetaDetails\2[^>]*>)''',
|
|
||||||
webpage, 'meta details', default='')
|
|
||||||
info = extract_attributes(info)
|
|
||||||
|
|
||||||
player = self._search_regex(
|
|
||||||
r'''(<div\b[^>]*\bid\s*=\s*(['"])rmpPlayerStream\2[^>]*>)''',
|
|
||||||
webpage, 'player details', default='')
|
|
||||||
player = extract_attributes(player)
|
|
||||||
|
|
||||||
video_urls_and_ids = (
|
|
||||||
(info.get('data-meta-video'), 'video'),
|
|
||||||
(player.get('data-video-transcoded'), 'transcoded'),
|
|
||||||
(player.get('data-video-filepath'), 'filepath'),
|
|
||||||
(self._og_search_video_url(webpage, secure=False, default=None), 'og_video'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def txt_or_none(s, default=None):
|
|
||||||
return (s.strip() or default) if isinstance(s, str) else default
|
|
||||||
|
|
||||||
uploader = txt_or_none(info.get('data-meta-author'))
|
|
||||||
|
|
||||||
def mung_title(s):
|
|
||||||
if uploader:
|
|
||||||
s = re.sub(rf'^\s*{re.escape(uploader)}\s+[|-]', '', s)
|
|
||||||
return txt_or_none(s)
|
|
||||||
|
|
||||||
title = (
|
|
||||||
mung_title(info.get('data-meta-title'))
|
|
||||||
or self._html_search_regex(
|
|
||||||
(r'<span[^>]+class=["\']item-title[^>]+>([^<]+)',
|
|
||||||
r'<h2[^>]+class=["\']h2 m-0["\'][^>]*>([^<]+)'),
|
|
||||||
webpage, 'title', default=None)
|
|
||||||
or self._html_search_meta(
|
|
||||||
'twitter:title', webpage, 'title', fatal=True))
|
|
||||||
|
|
||||||
title = re.sub(r'\s*[|-]\s+ManyVids\s*$', '', title) or title
|
|
||||||
|
|
||||||
if any(p in webpage for p in ('preview_videos', '_preview.mp4')):
|
|
||||||
title += ' (Preview)'
|
|
||||||
|
|
||||||
mv_token = self._search_regex(
|
|
||||||
r'data-mvtoken=(["\'])(?P<value>(?:(?!\1).)+)\1', webpage,
|
|
||||||
'mv token', default=None, group='value')
|
|
||||||
|
|
||||||
if mv_token:
|
|
||||||
# Sets some cookies
|
|
||||||
self._download_webpage(
|
|
||||||
'https://www.manyvids.com/includes/ajax_repository/you_had_me_at_hello.php',
|
|
||||||
video_id, note='Setting format cookies', fatal=False,
|
|
||||||
data=urlencode_postdata({
|
|
||||||
'mvtoken': mv_token,
|
|
||||||
'vid': video_id,
|
|
||||||
}), headers={
|
|
||||||
'Referer': url,
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
})
|
|
||||||
|
|
||||||
formats = []
|
|
||||||
for v_url, fmt in video_urls_and_ids:
|
|
||||||
v_url = url_or_none(v_url)
|
|
||||||
if not v_url:
|
|
||||||
continue
|
continue
|
||||||
if determine_ext(v_url) == 'm3u8':
|
if determine_ext(format_url) == 'm3u8':
|
||||||
formats.extend(self._extract_m3u8_formats(
|
formats.extend(self._extract_m3u8_formats(format_url, video_id, 'mp4', m3u8_id=format_id))
|
||||||
v_url, video_id, 'mp4', entry_protocol='m3u8_native',
|
|
||||||
m3u8_id='hls'))
|
|
||||||
else:
|
else:
|
||||||
formats.append({
|
formats.append({
|
||||||
'url': v_url,
|
'url': format_url,
|
||||||
'format_id': fmt,
|
'format_id': format_id,
|
||||||
|
'preference': -10 if format_id == 'preview' else None,
|
||||||
|
'quality': 10 if format_id == 'filepath' else None,
|
||||||
|
'height': int_or_none(
|
||||||
|
self._search_regex(r'_(\d{2,3}[02468])_', format_url, 'height', default=None)),
|
||||||
})
|
})
|
||||||
|
if format_id != 'preview':
|
||||||
|
preview_only = False
|
||||||
|
|
||||||
self._remove_duplicate_formats(formats)
|
metadata = traverse_obj(
|
||||||
|
self._download_json(f'{self._API_BASE}/{video_id}', video_id, fatal=False), 'data')
|
||||||
|
title = traverse_obj(metadata, ('title', {clean_html}))
|
||||||
|
|
||||||
for f in formats:
|
if preview_only:
|
||||||
if f.get('height') is None:
|
title = join_nonempty(title, '(Preview)', delim=' ')
|
||||||
f['height'] = int_or_none(
|
video_id += '-preview'
|
||||||
self._search_regex(r'_(\d{2,3}[02468])_', f['url'], 'video height', default=None))
|
self.report_warning(
|
||||||
if '/preview/' in f['url']:
|
f'Only extracting preview. Video may be paid or subscription only. {self._login_hint()}')
|
||||||
f['format_id'] = '_'.join(filter(None, (f.get('format_id'), 'preview')))
|
|
||||||
f['preference'] = -10
|
|
||||||
if 'transcoded' in f['format_id']:
|
|
||||||
f['preference'] = f.get('preference', -1) - 1
|
|
||||||
|
|
||||||
def get_likes():
|
|
||||||
likes = self._search_regex(
|
|
||||||
rf'''(<a\b[^>]*\bdata-id\s*=\s*(['"]){video_id}\2[^>]*>)''',
|
|
||||||
webpage, 'likes', default='')
|
|
||||||
likes = extract_attributes(likes)
|
|
||||||
return int_or_none(likes.get('data-likes'))
|
|
||||||
|
|
||||||
def get_views():
|
|
||||||
return str_to_int(self._html_search_regex(
|
|
||||||
r'''(?s)<span\b[^>]*\bclass\s*=["']views-wrapper\b[^>]+>.+?<span\b[^>]+>\s*(\d[\d,.]*)\s*</span>''',
|
|
||||||
webpage, 'view count', default=None))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': title,
|
'title': title,
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'description': txt_or_none(info.get('data-meta-description')),
|
**traverse_obj(metadata, {
|
||||||
'uploader': txt_or_none(info.get('data-meta-author')),
|
'description': ('description', {clean_html}),
|
||||||
'thumbnail': (
|
'uploader': ('model', 'displayName', {clean_html}),
|
||||||
url_or_none(info.get('data-meta-image'))
|
'thumbnail': (('screenshot', 'thumbnail'), {url_or_none}, any),
|
||||||
or url_or_none(player.get('data-video-screenshot'))),
|
'view_count': ('views', {parse_count}),
|
||||||
'view_count': get_views(),
|
'like_count': ('likes', {parse_count}),
|
||||||
'like_count': get_likes(),
|
'release_timestamp': ('launchDate', {parse_iso8601}),
|
||||||
|
'duration': ('videoDuration', {parse_duration}),
|
||||||
|
'tags': ('tagList', ..., 'label', {str}, filter, all, filter),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
107
yt_dlp/extractor/mave.py
Normal file
107
yt_dlp/extractor/mave.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from .common import InfoExtractor
|
||||||
|
from ..utils import (
|
||||||
|
clean_html,
|
||||||
|
int_or_none,
|
||||||
|
parse_iso8601,
|
||||||
|
urljoin,
|
||||||
|
)
|
||||||
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
|
class MaveIE(InfoExtractor):
|
||||||
|
_VALID_URL = r'https?://(?P<channel>[\w-]+)\.mave\.digital/(?P<id>ep-\d+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://ochenlichnoe.mave.digital/ep-25',
|
||||||
|
'md5': 'aa3e513ef588b4366df1520657cbc10c',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '4035f587-914b-44b6-aa5a-d76685ad9bc2',
|
||||||
|
'ext': 'mp3',
|
||||||
|
'display_id': 'ochenlichnoe-ep-25',
|
||||||
|
'title': 'Между мной и миром: психология самооценки',
|
||||||
|
'description': 'md5:4b7463baaccb6982f326bce5c700382a',
|
||||||
|
'uploader': 'Самарский университет',
|
||||||
|
'channel': 'Очень личное',
|
||||||
|
'channel_id': 'ochenlichnoe',
|
||||||
|
'channel_url': 'https://ochenlichnoe.mave.digital/',
|
||||||
|
'view_count': int,
|
||||||
|
'like_count': int,
|
||||||
|
'dislike_count': int,
|
||||||
|
'duration': 3744,
|
||||||
|
'thumbnail': r're:https://.+/storage/podcasts/.+\.jpg',
|
||||||
|
'series': 'Очень личное',
|
||||||
|
'series_id': '2e0c3749-6df2-4946-82f4-50691419c065',
|
||||||
|
'season': 'Season 3',
|
||||||
|
'season_number': 3,
|
||||||
|
'episode': 'Episode 3',
|
||||||
|
'episode_number': 3,
|
||||||
|
'timestamp': 1747817300,
|
||||||
|
'upload_date': '20250521',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://budem.mave.digital/ep-12',
|
||||||
|
'md5': 'e1ce2780fcdb6f17821aa3ca3e8c919f',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '41898bb5-ff57-4797-9236-37a8e537aa21',
|
||||||
|
'ext': 'mp3',
|
||||||
|
'display_id': 'budem-ep-12',
|
||||||
|
'title': 'Екатерина Михайлова: "Горе от ума" не про женщин написана',
|
||||||
|
'description': 'md5:fa3bdd59ee829dfaf16e3efcb13f1d19',
|
||||||
|
'uploader': 'Полина Цветкова+Евгения Акопова',
|
||||||
|
'channel': 'Все там будем',
|
||||||
|
'channel_id': 'budem',
|
||||||
|
'channel_url': 'https://budem.mave.digital/',
|
||||||
|
'view_count': int,
|
||||||
|
'like_count': int,
|
||||||
|
'dislike_count': int,
|
||||||
|
'age_limit': 18,
|
||||||
|
'duration': 3664,
|
||||||
|
'thumbnail': r're:https://.+/storage/podcasts/.+\.jpg',
|
||||||
|
'series': 'Все там будем',
|
||||||
|
'series_id': 'fe9347bf-c009-4ebd-87e8-b06f2f324746',
|
||||||
|
'season': 'Season 2',
|
||||||
|
'season_number': 2,
|
||||||
|
'episode': 'Episode 5',
|
||||||
|
'episode_number': 5,
|
||||||
|
'timestamp': 1735538400,
|
||||||
|
'upload_date': '20241230',
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
_API_BASE_URL = 'https://api.mave.digital/'
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
channel_id, slug = self._match_valid_url(url).group('channel', 'id')
|
||||||
|
display_id = f'{channel_id}-{slug}'
|
||||||
|
webpage = self._download_webpage(url, display_id)
|
||||||
|
data = traverse_obj(
|
||||||
|
self._search_nuxt_json(webpage, display_id),
|
||||||
|
('data', lambda _, v: v['activeEpisodeData'], any, {require('podcast data')}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'display_id': display_id,
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'channel_url': f'https://{channel_id}.mave.digital/',
|
||||||
|
'vcodec': 'none',
|
||||||
|
'thumbnail': re.sub(r'_\d+(?=\.(?:jpg|png))', '', self._og_search_thumbnail(webpage, default='')) or None,
|
||||||
|
**traverse_obj(data, ('activeEpisodeData', {
|
||||||
|
'url': ('audio', {urljoin(self._API_BASE_URL)}),
|
||||||
|
'id': ('id', {str}),
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'description': ('description', {clean_html}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'season_number': ('season', {int_or_none}),
|
||||||
|
'episode_number': ('number', {int_or_none}),
|
||||||
|
'view_count': ('listenings', {int_or_none}),
|
||||||
|
'like_count': ('reactions', lambda _, v: v['type'] == 'like', 'count', {int_or_none}, any),
|
||||||
|
'dislike_count': ('reactions', lambda _, v: v['type'] == 'dislike', 'count', {int_or_none}, any),
|
||||||
|
'age_limit': ('is_explicit', {bool}, {lambda x: 18 if x else None}),
|
||||||
|
'timestamp': ('publish_date', {parse_iso8601}),
|
||||||
|
})),
|
||||||
|
**traverse_obj(data, ('podcast', 'podcast', {
|
||||||
|
'series_id': ('id', {str}),
|
||||||
|
'series': ('title', {str}),
|
||||||
|
'channel': ('title', {str}),
|
||||||
|
'uploader': ('author', {str}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from .telecinco import TelecincoBaseIE
|
from .telecinco import TelecincoBaseIE
|
||||||
from ..networking.exceptions import HTTPError
|
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
|
||||||
int_or_none,
|
int_or_none,
|
||||||
parse_iso8601,
|
parse_iso8601,
|
||||||
)
|
)
|
||||||
@@ -81,17 +79,7 @@ class MiTeleIE(TelecincoBaseIE):
|
|||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id = self._match_id(url)
|
display_id = self._match_id(url)
|
||||||
|
webpage = self._download_akamai_webpage(url, display_id)
|
||||||
try: # yt-dlp's default user-agents are too old and blocked by akamai
|
|
||||||
webpage = self._download_webpage(url, display_id, headers={
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0',
|
|
||||||
})
|
|
||||||
except ExtractorError as e:
|
|
||||||
if not isinstance(e.cause, HTTPError) or e.cause.status != 403:
|
|
||||||
raise
|
|
||||||
# Retry with impersonation if hardcoded UA is insufficient to bypass akamai
|
|
||||||
webpage = self._download_webpage(url, display_id, impersonate=True)
|
|
||||||
|
|
||||||
pre_player = self._search_json(
|
pre_player = self._search_json(
|
||||||
r'window\.\$REACTBASE_STATE\.prePlayer_mtweb\s*=',
|
r'window\.\$REACTBASE_STATE\.prePlayer_mtweb\s*=',
|
||||||
webpage, 'Pre Player', display_id)['prePlayer']
|
webpage, 'Pre Player', display_id)['prePlayer']
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
parse_iso8601,
|
parse_iso8601,
|
||||||
strip_or_none,
|
strip_or_none,
|
||||||
try_get,
|
try_get,
|
||||||
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class MixcloudBaseIE(InfoExtractor):
|
class MixcloudBaseIE(InfoExtractor):
|
||||||
@@ -37,7 +39,7 @@ class MixcloudIE(MixcloudBaseIE):
|
|||||||
'ext': 'm4a',
|
'ext': 'm4a',
|
||||||
'title': 'Cryptkeeper',
|
'title': 'Cryptkeeper',
|
||||||
'description': 'After quite a long silence from myself, finally another Drum\'n\'Bass mix with my favourite current dance floor bangers.',
|
'description': 'After quite a long silence from myself, finally another Drum\'n\'Bass mix with my favourite current dance floor bangers.',
|
||||||
'uploader': 'Daniel Holbach',
|
'uploader': 'dholbach',
|
||||||
'uploader_id': 'dholbach',
|
'uploader_id': 'dholbach',
|
||||||
'thumbnail': r're:https?://.*\.jpg',
|
'thumbnail': r're:https?://.*\.jpg',
|
||||||
'view_count': int,
|
'view_count': int,
|
||||||
@@ -46,10 +48,11 @@ class MixcloudIE(MixcloudBaseIE):
|
|||||||
'uploader_url': 'https://www.mixcloud.com/dholbach/',
|
'uploader_url': 'https://www.mixcloud.com/dholbach/',
|
||||||
'artist': 'Submorphics & Chino , Telekinesis, Porter Robinson, Enei, Breakage ft Jess Mills',
|
'artist': 'Submorphics & Chino , Telekinesis, Porter Robinson, Enei, Breakage ft Jess Mills',
|
||||||
'duration': 3723,
|
'duration': 3723,
|
||||||
'tags': [],
|
'tags': ['liquid drum and bass', 'drum and bass'],
|
||||||
'comment_count': int,
|
'comment_count': int,
|
||||||
'repost_count': int,
|
'repost_count': int,
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
|
'artists': list,
|
||||||
},
|
},
|
||||||
'params': {'skip_download': 'm3u8'},
|
'params': {'skip_download': 'm3u8'},
|
||||||
}, {
|
}, {
|
||||||
@@ -67,7 +70,7 @@ class MixcloudIE(MixcloudBaseIE):
|
|||||||
'upload_date': '20150203',
|
'upload_date': '20150203',
|
||||||
'uploader_url': 'https://www.mixcloud.com/gillespeterson/',
|
'uploader_url': 'https://www.mixcloud.com/gillespeterson/',
|
||||||
'duration': 2992,
|
'duration': 2992,
|
||||||
'tags': [],
|
'tags': ['jazz', 'soul', 'world music', 'funk'],
|
||||||
'comment_count': int,
|
'comment_count': int,
|
||||||
'repost_count': int,
|
'repost_count': int,
|
||||||
'like_count': int,
|
'like_count': int,
|
||||||
@@ -149,8 +152,6 @@ def _real_extract(self, url):
|
|||||||
elif reason:
|
elif reason:
|
||||||
raise ExtractorError('Track is restricted', expected=True)
|
raise ExtractorError('Track is restricted', expected=True)
|
||||||
|
|
||||||
title = cloudcast['name']
|
|
||||||
|
|
||||||
stream_info = cloudcast['streamInfo']
|
stream_info = cloudcast['streamInfo']
|
||||||
formats = []
|
formats = []
|
||||||
|
|
||||||
@@ -182,47 +183,39 @@ def _real_extract(self, url):
|
|||||||
self.raise_login_required(metadata_available=True)
|
self.raise_login_required(metadata_available=True)
|
||||||
|
|
||||||
comments = []
|
comments = []
|
||||||
for edge in (try_get(cloudcast, lambda x: x['comments']['edges']) or []):
|
for node in traverse_obj(cloudcast, ('comments', 'edges', ..., 'node', {dict})):
|
||||||
node = edge.get('node') or {}
|
|
||||||
text = strip_or_none(node.get('comment'))
|
text = strip_or_none(node.get('comment'))
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
user = node.get('user') or {}
|
|
||||||
comments.append({
|
comments.append({
|
||||||
'author': user.get('displayName'),
|
|
||||||
'author_id': user.get('username'),
|
|
||||||
'text': text,
|
'text': text,
|
||||||
'timestamp': parse_iso8601(node.get('created')),
|
**traverse_obj(node, {
|
||||||
|
'author': ('user', 'displayName', {str}),
|
||||||
|
'author_id': ('user', 'username', {str}),
|
||||||
|
'timestamp': ('created', {parse_iso8601}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
tags = []
|
|
||||||
for t in cloudcast.get('tags'):
|
|
||||||
tag = try_get(t, lambda x: x['tag']['name'], str)
|
|
||||||
if not tag:
|
|
||||||
tags.append(tag)
|
|
||||||
|
|
||||||
get_count = lambda x: int_or_none(try_get(cloudcast, lambda y: y[x]['totalCount']))
|
|
||||||
|
|
||||||
owner = cloudcast.get('owner') or {}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': track_id,
|
'id': track_id,
|
||||||
'title': title,
|
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'description': cloudcast.get('description'),
|
|
||||||
'thumbnail': try_get(cloudcast, lambda x: x['picture']['url'], str),
|
|
||||||
'uploader': owner.get('displayName'),
|
|
||||||
'timestamp': parse_iso8601(cloudcast.get('publishDate')),
|
|
||||||
'uploader_id': owner.get('username'),
|
|
||||||
'uploader_url': owner.get('url'),
|
|
||||||
'duration': int_or_none(cloudcast.get('audioLength')),
|
|
||||||
'view_count': int_or_none(cloudcast.get('plays')),
|
|
||||||
'like_count': get_count('favorites'),
|
|
||||||
'repost_count': get_count('reposts'),
|
|
||||||
'comment_count': get_count('comments'),
|
|
||||||
'comments': comments,
|
'comments': comments,
|
||||||
'tags': tags,
|
**traverse_obj(cloudcast, {
|
||||||
'artist': ', '.join(cloudcast.get('featuringArtistList') or []) or None,
|
'title': ('name', {str}),
|
||||||
|
'description': ('description', {str}),
|
||||||
|
'thumbnail': ('picture', 'url', {url_or_none}),
|
||||||
|
'timestamp': ('publishDate', {parse_iso8601}),
|
||||||
|
'duration': ('audioLength', {int_or_none}),
|
||||||
|
'uploader': ('owner', 'displayName', {str}),
|
||||||
|
'uploader_id': ('owner', 'username', {str}),
|
||||||
|
'uploader_url': ('owner', 'url', {url_or_none}),
|
||||||
|
'view_count': ('plays', {int_or_none}),
|
||||||
|
'like_count': ('favorites', 'totalCount', {int_or_none}),
|
||||||
|
'repost_count': ('reposts', 'totalCount', {int_or_none}),
|
||||||
|
'comment_count': ('comments', 'totalCount', {int_or_none}),
|
||||||
|
'tags': ('tags', ..., 'tag', 'name', {str}, filter, all, filter),
|
||||||
|
'artists': ('featuringArtistList', ..., {str}, filter, all, filter),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -295,7 +288,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
|
|||||||
'url': 'http://www.mixcloud.com/dholbach/',
|
'url': 'http://www.mixcloud.com/dholbach/',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'dholbach_uploads',
|
'id': 'dholbach_uploads',
|
||||||
'title': 'Daniel Holbach (uploads)',
|
'title': 'dholbach (uploads)',
|
||||||
'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
|
'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 36,
|
'playlist_mincount': 36,
|
||||||
@@ -303,7 +296,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
|
|||||||
'url': 'http://www.mixcloud.com/dholbach/uploads/',
|
'url': 'http://www.mixcloud.com/dholbach/uploads/',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'dholbach_uploads',
|
'id': 'dholbach_uploads',
|
||||||
'title': 'Daniel Holbach (uploads)',
|
'title': 'dholbach (uploads)',
|
||||||
'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
|
'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 36,
|
'playlist_mincount': 36,
|
||||||
@@ -311,7 +304,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
|
|||||||
'url': 'http://www.mixcloud.com/dholbach/favorites/',
|
'url': 'http://www.mixcloud.com/dholbach/favorites/',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'dholbach_favorites',
|
'id': 'dholbach_favorites',
|
||||||
'title': 'Daniel Holbach (favorites)',
|
'title': 'dholbach (favorites)',
|
||||||
'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
|
'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
|
||||||
},
|
},
|
||||||
# 'params': {
|
# 'params': {
|
||||||
@@ -337,7 +330,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
|
|||||||
'title': 'First Ear (stream)',
|
'title': 'First Ear (stream)',
|
||||||
'description': 'we maraud for ears',
|
'description': 'we maraud for ears',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 269,
|
'playlist_mincount': 267,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
_TITLE_KEY = 'displayName'
|
_TITLE_KEY = 'displayName'
|
||||||
@@ -361,7 +354,7 @@ class MixcloudPlaylistIE(MixcloudPlaylistBaseIE):
|
|||||||
'id': 'maxvibes_jazzcat-on-ness-radio',
|
'id': 'maxvibes_jazzcat-on-ness-radio',
|
||||||
'title': 'Ness Radio sessions',
|
'title': 'Ness Radio sessions',
|
||||||
},
|
},
|
||||||
'playlist_mincount': 59,
|
'playlist_mincount': 58,
|
||||||
}]
|
}]
|
||||||
_TITLE_KEY = 'name'
|
_TITLE_KEY = 'name'
|
||||||
_DESCRIPTION_KEY = 'description'
|
_DESCRIPTION_KEY = 'description'
|
||||||
|
|||||||
@@ -365,13 +365,15 @@ def _real_initialize(self):
|
|||||||
'All videos are only available to registered users', method='password')
|
'All videos are only available to registered users', method='password')
|
||||||
|
|
||||||
def _set_device_id(self, username):
|
def _set_device_id(self, username):
|
||||||
if not self._device_id:
|
if self._device_id:
|
||||||
self._device_id = self.cache.load(
|
return
|
||||||
self._NETRC_MACHINE, 'device_ids', default={}).get(username)
|
device_id_cache = self.cache.load(self._NETRC_MACHINE, 'device_ids', default={})
|
||||||
|
self._device_id = device_id_cache.get(username)
|
||||||
if self._device_id:
|
if self._device_id:
|
||||||
return
|
return
|
||||||
self._device_id = str(uuid.uuid4())
|
self._device_id = str(uuid.uuid4())
|
||||||
self.cache.store(self._NETRC_MACHINE, 'device_ids', {username: self._device_id})
|
device_id_cache[username] = self._device_id
|
||||||
|
self.cache.store(self._NETRC_MACHINE, 'device_ids', device_id_cache)
|
||||||
|
|
||||||
def _perform_login(self, username, password):
|
def _perform_login(self, username, password):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
class NBACVPBaseIE(TurnerBaseIE):
|
class NBACVPBaseIE(TurnerBaseIE):
|
||||||
def _extract_nba_cvp_info(self, path, video_id, fatal=False):
|
def _extract_nba_cvp_info(self, path, video_id, fatal=False):
|
||||||
return self._extract_cvp_info(
|
return self._extract_cvp_info(
|
||||||
f'http://secure.nba.com/{path}', video_id, {
|
# XXX: The 3rd argument (None) needs to be the AdobePass software_statement
|
||||||
|
f'http://secure.nba.com/{path}', video_id, None, {
|
||||||
'default': {
|
'default': {
|
||||||
'media_src': 'http://nba.cdn.turner.com/nba/big',
|
'media_src': 'http://nba.cdn.turner.com/nba/big',
|
||||||
},
|
},
|
||||||
@@ -94,6 +95,7 @@ def _extract_video(self, filter_key, filter_value):
|
|||||||
|
|
||||||
|
|
||||||
class NBAWatchEmbedIE(NBAWatchBaseIE):
|
class NBAWatchEmbedIE(NBAWatchBaseIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nba:watch:embed'
|
IE_NAME = 'nba:watch:embed'
|
||||||
_VALID_URL = NBAWatchBaseIE._VALID_URL_BASE + r'embed\?.*?\bid=(?P<id>\d+)'
|
_VALID_URL = NBAWatchBaseIE._VALID_URL_BASE + r'embed\?.*?\bid=(?P<id>\d+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -115,6 +117,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBAWatchIE(NBAWatchBaseIE):
|
class NBAWatchIE(NBAWatchBaseIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nba:watch'
|
IE_NAME = 'nba:watch'
|
||||||
_VALID_URL = NBAWatchBaseIE._VALID_URL_BASE + r'(?:nba/)?video/(?P<id>.+?(?=/index\.html)|(?:[^/]+/)*[^/?#&]+)'
|
_VALID_URL = NBAWatchBaseIE._VALID_URL_BASE + r'(?:nba/)?video/(?P<id>.+?(?=/index\.html)|(?:[^/]+/)*[^/?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -167,6 +170,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBAWatchCollectionIE(NBAWatchBaseIE):
|
class NBAWatchCollectionIE(NBAWatchBaseIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nba:watch:collection'
|
IE_NAME = 'nba:watch:collection'
|
||||||
_VALID_URL = NBAWatchBaseIE._VALID_URL_BASE + r'list/collection/(?P<id>[^/?#&]+)'
|
_VALID_URL = NBAWatchBaseIE._VALID_URL_BASE + r'list/collection/(?P<id>[^/?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -336,6 +340,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBAEmbedIE(NBABaseIE):
|
class NBAEmbedIE(NBABaseIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nba:embed'
|
IE_NAME = 'nba:embed'
|
||||||
_VALID_URL = r'https?://secure\.nba\.com/assets/amp/include/video/(?:topI|i)frame\.html\?.*?\bcontentId=(?P<id>[^?#&]+)'
|
_VALID_URL = r'https?://secure\.nba\.com/assets/amp/include/video/(?:topI|i)frame\.html\?.*?\bcontentId=(?P<id>[^?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -358,6 +363,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBAIE(NBABaseIE):
|
class NBAIE(NBABaseIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nba'
|
IE_NAME = 'nba'
|
||||||
_VALID_URL = NBABaseIE._VALID_URL_BASE + f'(?!{NBABaseIE._CHANNEL_PATH_REGEX})video/(?P<id>(?:[^/]+/)*[^/?#&]+)'
|
_VALID_URL = NBABaseIE._VALID_URL_BASE + f'(?!{NBABaseIE._CHANNEL_PATH_REGEX})video/(?P<id>(?:[^/]+/)*[^/?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -385,6 +391,7 @@ def _extract_url_results(self, team, content_id):
|
|||||||
|
|
||||||
|
|
||||||
class NBAChannelIE(NBABaseIE):
|
class NBAChannelIE(NBABaseIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nba:channel'
|
IE_NAME = 'nba:channel'
|
||||||
_VALID_URL = NBABaseIE._VALID_URL_BASE + f'(?:{NBABaseIE._CHANNEL_PATH_REGEX})/(?P<id>[^/?#&]+)'
|
_VALID_URL = NBABaseIE._VALID_URL_BASE + f'(?:{NBABaseIE._CHANNEL_PATH_REGEX})/(?P<id>[^/?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
from .adobepass import AdobePassIE
|
from .adobepass import AdobePassIE
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from .theplatform import ThePlatformIE, default_ns
|
from .theplatform import ThePlatformBaseIE, ThePlatformIE, default_ns
|
||||||
from ..networking import HEADRequest
|
from ..networking import HEADRequest
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
@@ -14,26 +14,130 @@
|
|||||||
UserNotLive,
|
UserNotLive,
|
||||||
clean_html,
|
clean_html,
|
||||||
determine_ext,
|
determine_ext,
|
||||||
|
extract_attributes,
|
||||||
float_or_none,
|
float_or_none,
|
||||||
|
get_element_html_by_class,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
join_nonempty,
|
join_nonempty,
|
||||||
|
make_archive_id,
|
||||||
mimetype2ext,
|
mimetype2ext,
|
||||||
parse_age_limit,
|
parse_age_limit,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
|
parse_iso8601,
|
||||||
remove_end,
|
remove_end,
|
||||||
smuggle_url,
|
|
||||||
traverse_obj,
|
|
||||||
try_get,
|
try_get,
|
||||||
unescapeHTML,
|
unescapeHTML,
|
||||||
unified_timestamp,
|
unified_timestamp,
|
||||||
update_url_query,
|
update_url_query,
|
||||||
url_basename,
|
url_basename,
|
||||||
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class NBCIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
class NBCUniversalBaseIE(ThePlatformBaseIE):
|
||||||
_VALID_URL = r'https?(?P<permalink>://(?:www\.)?nbc\.com/(?:classic-tv/)?[^/]+/video/[^/]+/(?P<id>(?:NBCE|n)?\d+))'
|
_GEO_COUNTRIES = ['US']
|
||||||
|
_GEO_BYPASS = False
|
||||||
|
_M3U8_RE = r'https?://[^/?#]+/prod/[\w-]+/(?P<folders>[^?#]+/)cmaf/mpeg_(?:cbcs|cenc)\w*/master_cmaf\w*\.m3u8'
|
||||||
|
|
||||||
|
def _download_nbcu_smil_and_extract_m3u8_url(self, tp_path, video_id, query):
|
||||||
|
smil = self._download_xml(
|
||||||
|
f'https://link.theplatform.com/s/{tp_path}', video_id,
|
||||||
|
'Downloading SMIL manifest', 'Failed to download SMIL manifest', query={
|
||||||
|
**query,
|
||||||
|
'format': 'SMIL', # XXX: Do not confuse "format" with "formats"
|
||||||
|
'manifest': 'm3u',
|
||||||
|
'switch': 'HLSServiceSecure', # Or else we get broken mp4 http URLs instead of HLS
|
||||||
|
}, headers=self.geo_verification_headers())
|
||||||
|
|
||||||
|
ns = f'//{{{default_ns}}}'
|
||||||
|
if url := traverse_obj(smil, (f'{ns}video/@src', lambda _, v: determine_ext(v) == 'm3u8', any)):
|
||||||
|
return url
|
||||||
|
|
||||||
|
exc = traverse_obj(smil, (f'{ns}param', lambda _, v: v.get('name') == 'exception', '@value', any))
|
||||||
|
if exc == 'GeoLocationBlocked':
|
||||||
|
self.raise_geo_restricted(countries=self._GEO_COUNTRIES)
|
||||||
|
raise ExtractorError(traverse_obj(smil, (f'{ns}ref/@abstract', ..., any)), expected=exc == 'Expired')
|
||||||
|
|
||||||
|
def _extract_nbcu_formats_and_subtitles(self, tp_path, video_id, query):
|
||||||
|
# formats='mpeg4' will return either a working m3u8 URL or an m3u8 template for non-DRM HLS
|
||||||
|
# formats='m3u+none,mpeg4' may return DRM HLS but w/the "folders" needed for non-DRM template
|
||||||
|
query['formats'] = 'm3u+none,mpeg4'
|
||||||
|
m3u8_url = self._download_nbcu_smil_and_extract_m3u8_url(tp_path, video_id, query)
|
||||||
|
|
||||||
|
if mobj := re.fullmatch(self._M3U8_RE, m3u8_url):
|
||||||
|
query['formats'] = 'mpeg4'
|
||||||
|
m3u8_tmpl = self._download_nbcu_smil_and_extract_m3u8_url(tp_path, video_id, query)
|
||||||
|
# Example: https://vod-lf-oneapp-prd.akamaized.net/prod/video/{folders}master_hls.m3u8
|
||||||
|
if '{folders}' in m3u8_tmpl:
|
||||||
|
self.write_debug('Found m3u8 URL template, formatting URL path')
|
||||||
|
m3u8_url = m3u8_tmpl.format(folders=mobj.group('folders'))
|
||||||
|
|
||||||
|
if '/mpeg_cenc' in m3u8_url or '/mpeg_cbcs' in m3u8_url:
|
||||||
|
self.report_drm(video_id)
|
||||||
|
|
||||||
|
return self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, 'mp4', m3u8_id='hls')
|
||||||
|
|
||||||
|
def _extract_nbcu_video(self, url, display_id, old_ie_key=None):
|
||||||
|
webpage = self._download_webpage(url, display_id)
|
||||||
|
settings = self._search_json(
|
||||||
|
r'<script[^>]+data-drupal-selector="drupal-settings-json"[^>]*>',
|
||||||
|
webpage, 'settings', display_id)
|
||||||
|
|
||||||
|
query = {}
|
||||||
|
tve = extract_attributes(get_element_html_by_class('tve-video-deck-app', webpage) or '')
|
||||||
|
if tve:
|
||||||
|
account_pid = tve.get('data-mpx-media-account-pid') or tve['data-mpx-account-pid']
|
||||||
|
account_id = tve['data-mpx-media-account-id']
|
||||||
|
metadata = self._parse_json(
|
||||||
|
tve.get('data-normalized-video') or '', display_id, fatal=False, transform_source=unescapeHTML)
|
||||||
|
video_id = tve.get('data-guid') or metadata['guid']
|
||||||
|
if tve.get('data-entitlement') == 'auth':
|
||||||
|
auth = settings['tve_adobe_auth']
|
||||||
|
release_pid = tve['data-release-pid']
|
||||||
|
resource = self._get_mvpd_resource(
|
||||||
|
tve.get('data-adobe-pass-resource-id') or auth['adobePassResourceId'],
|
||||||
|
tve['data-title'], release_pid, tve.get('data-rating'))
|
||||||
|
query['auth'] = self._extract_mvpd_auth(
|
||||||
|
url, release_pid, auth['adobePassRequestorId'],
|
||||||
|
resource, auth['adobePassSoftwareStatement'])
|
||||||
|
else:
|
||||||
|
ls_playlist = traverse_obj(settings, (
|
||||||
|
'ls_playlist', lambda _, v: v['defaultGuid'], any, {require('LS playlist')}))
|
||||||
|
video_id = ls_playlist['defaultGuid']
|
||||||
|
account_pid = ls_playlist.get('mpxMediaAccountPid') or ls_playlist['mpxAccountPid']
|
||||||
|
account_id = ls_playlist['mpxMediaAccountId']
|
||||||
|
metadata = traverse_obj(ls_playlist, ('videos', lambda _, v: v['guid'] == video_id, any)) or {}
|
||||||
|
|
||||||
|
tp_path = f'{account_pid}/media/guid/{account_id}/{video_id}'
|
||||||
|
formats, subtitles = self._extract_nbcu_formats_and_subtitles(tp_path, video_id, query)
|
||||||
|
tp_metadata = self._download_theplatform_metadata(tp_path, video_id, fatal=False)
|
||||||
|
parsed_info = self._parse_theplatform_metadata(tp_metadata)
|
||||||
|
self._merge_subtitles(parsed_info['subtitles'], target=subtitles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**parsed_info,
|
||||||
|
**traverse_obj(metadata, {
|
||||||
|
'title': ('title', {str}),
|
||||||
|
'description': ('description', {str}),
|
||||||
|
'duration': ('durationInSeconds', {int_or_none}),
|
||||||
|
'timestamp': ('airDate', {parse_iso8601}),
|
||||||
|
'thumbnail': ('thumbnailUrl', {url_or_none}),
|
||||||
|
'season_number': ('seasonNumber', {int_or_none}),
|
||||||
|
'episode_number': ('episodeNumber', {int_or_none}),
|
||||||
|
'episode': ('episodeTitle', {str}),
|
||||||
|
'series': ('show', {str}),
|
||||||
|
}),
|
||||||
|
'id': video_id,
|
||||||
|
'display_id': display_id,
|
||||||
|
'formats': formats,
|
||||||
|
'subtitles': subtitles,
|
||||||
|
'_old_archive_ids': [make_archive_id(old_ie_key, video_id)] if old_ie_key else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NBCIE(NBCUniversalBaseIE):
|
||||||
|
_VALID_URL = r'https?(?P<permalink>://(?:www\.)?nbc\.com/(?:classic-tv/)?[^/?#]+/video/[^/?#]+/(?P<id>\w+))'
|
||||||
_TESTS = [
|
_TESTS = [
|
||||||
{
|
{
|
||||||
'url': 'http://www.nbc.com/the-tonight-show/video/jimmy-fallon-surprises-fans-at-ben-jerrys/2848237',
|
'url': 'http://www.nbc.com/the-tonight-show/video/jimmy-fallon-surprises-fans-at-ben-jerrys/2848237',
|
||||||
@@ -49,47 +153,20 @@ class NBCIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
|||||||
'episode_number': 86,
|
'episode_number': 86,
|
||||||
'season': 'Season 2',
|
'season': 'Season 2',
|
||||||
'season_number': 2,
|
'season_number': 2,
|
||||||
'series': 'Tonight Show: Jimmy Fallon',
|
'series': 'Tonight',
|
||||||
'duration': 237.0,
|
'duration': 236.504,
|
||||||
'chapters': 'count:1',
|
'tags': 'count:2',
|
||||||
'tags': 'count:4',
|
|
||||||
'thumbnail': r're:https?://.+\.jpg',
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
'categories': ['Series/The Tonight Show Starring Jimmy Fallon'],
|
'categories': ['Series/The Tonight Show Starring Jimmy Fallon'],
|
||||||
'media_type': 'Full Episode',
|
'media_type': 'Full Episode',
|
||||||
|
'age_limit': 14,
|
||||||
|
'_old_archive_ids': ['theplatform 2848237'],
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {
|
||||||
'skip_download': 'm3u8',
|
'skip_download': 'm3u8',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'url': 'http://www.nbc.com/saturday-night-live/video/star-wars-teaser/2832821',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '2832821',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'Star Wars Teaser',
|
|
||||||
'description': 'md5:0b40f9cbde5b671a7ff62fceccc4f442',
|
|
||||||
'timestamp': 1417852800,
|
|
||||||
'upload_date': '20141206',
|
|
||||||
'uploader': 'NBCU-COM',
|
|
||||||
},
|
|
||||||
'skip': 'page not found',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
# HLS streams requires the 'hdnea3' cookie
|
|
||||||
'url': 'http://www.nbc.com/Kings/video/goliath/n1806',
|
|
||||||
'info_dict': {
|
|
||||||
'id': '101528f5a9e8127b107e98c5e6ce4638',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'Goliath',
|
|
||||||
'description': 'When an unknown soldier saves the life of the King\'s son in battle, he\'s thrust into the limelight and politics of the kingdom.',
|
|
||||||
'timestamp': 1237100400,
|
|
||||||
'upload_date': '20090315',
|
|
||||||
'uploader': 'NBCU-COM',
|
|
||||||
},
|
|
||||||
'skip': 'page not found',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
# manifest url does not have extension
|
|
||||||
'url': 'https://www.nbc.com/the-golden-globe-awards/video/oprah-winfrey-receives-cecil-b-de-mille-award-at-the-2018-golden-globes/3646439',
|
'url': 'https://www.nbc.com/the-golden-globe-awards/video/oprah-winfrey-receives-cecil-b-de-mille-award-at-the-2018-golden-globes/3646439',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '3646439',
|
'id': '3646439',
|
||||||
@@ -99,48 +176,47 @@ class NBCIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
|||||||
'episode_number': 1,
|
'episode_number': 1,
|
||||||
'season': 'Season 75',
|
'season': 'Season 75',
|
||||||
'season_number': 75,
|
'season_number': 75,
|
||||||
'series': 'The Golden Globe Awards',
|
'series': 'Golden Globes',
|
||||||
'description': 'Oprah Winfrey receives the Cecil B. de Mille Award at the 75th Annual Golden Globe Awards.',
|
'description': 'Oprah Winfrey receives the Cecil B. de Mille Award at the 75th Annual Golden Globe Awards.',
|
||||||
'uploader': 'NBCU-COM',
|
'uploader': 'NBCU-COM',
|
||||||
'upload_date': '20180107',
|
'upload_date': '20180107',
|
||||||
'timestamp': 1515312000,
|
'timestamp': 1515312000,
|
||||||
'duration': 570.0,
|
'duration': 569.703,
|
||||||
'tags': 'count:8',
|
'tags': 'count:8',
|
||||||
'thumbnail': r're:https?://.+\.jpg',
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
'chapters': 'count:1',
|
'media_type': 'Highlight',
|
||||||
|
'age_limit': 0,
|
||||||
|
'categories': ['Series/The Golden Globe Awards'],
|
||||||
|
'_old_archive_ids': ['theplatform 3646439'],
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {
|
||||||
'skip_download': 'm3u8',
|
'skip_download': 'm3u8',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
# new video_id format
|
# Needs to be extracted from webpage instead of GraphQL
|
||||||
'url': 'https://www.nbc.com/quantum-leap/video/bens-first-leap-nbcs-quantum-leap/NBCE125189978',
|
'url': 'https://www.nbc.com/paris2024/video/ali-truwit-found-purpose-pool-after-her-life-changed/para24_sww_alitruwittodayshow_240823',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'NBCE125189978',
|
'id': 'para24_sww_alitruwittodayshow_240823',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Ben\'s First Leap | NBC\'s Quantum Leap',
|
'title': 'Ali Truwit found purpose in the pool after her life changed',
|
||||||
'description': 'md5:a82762449b7ec4bb83291a7b355ebf8e',
|
'description': 'md5:c16d7489e1516593de1cc5d3f39b9bdb',
|
||||||
'uploader': 'NBCU-COM',
|
'uploader': 'NBCU-SPORTS',
|
||||||
'series': 'Quantum Leap',
|
'duration': 311.077,
|
||||||
'season': 'Season 1',
|
|
||||||
'season_number': 1,
|
|
||||||
'episode': 'Ben\'s First Leap | NBC\'s Quantum Leap',
|
|
||||||
'episode_number': 1,
|
|
||||||
'duration': 170.171,
|
|
||||||
'chapters': [],
|
|
||||||
'timestamp': 1663956155,
|
|
||||||
'upload_date': '20220923',
|
|
||||||
'tags': 'count:10',
|
|
||||||
'age_limit': 0,
|
|
||||||
'thumbnail': r're:https?://.+\.jpg',
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
'categories': ['Series/Quantum Leap 2022'],
|
'episode': 'Ali Truwit found purpose in the pool after her life changed',
|
||||||
'media_type': 'Highlight',
|
'timestamp': 1724435902.0,
|
||||||
|
'upload_date': '20240823',
|
||||||
|
'_old_archive_ids': ['theplatform para24_sww_alitruwittodayshow_240823'],
|
||||||
},
|
},
|
||||||
'params': {
|
'params': {
|
||||||
'skip_download': 'm3u8',
|
'skip_download': 'm3u8',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'url': 'https://www.nbc.com/quantum-leap/video/bens-first-leap-nbcs-quantum-leap/NBCE125189978',
|
||||||
|
'only_matching': True,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'url': 'https://www.nbc.com/classic-tv/charles-in-charge/video/charles-in-charge-pilot/n3310',
|
'url': 'https://www.nbc.com/classic-tv/charles-in-charge/video/charles-in-charge-pilot/n3310',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -151,6 +227,7 @@ class NBCIE(ThePlatformIE): # XXX: Do not subclass from concrete IE
|
|||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
_SOFTWARE_STATEMENT = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI1Yzg2YjdkYy04NDI3LTRjNDUtOGQwZi1iNDkzYmE3MmQwYjQiLCJuYmYiOjE1Nzg3MDM2MzEsImlzcyI6ImF1dGguYWRvYmUuY29tIiwiaWF0IjoxNTc4NzAzNjMxfQ.QQKIsBhAjGQTMdAqRTqhcz2Cddr4Y2hEjnSiOeKKki4nLrkDOsjQMmqeTR0hSRarraxH54wBgLvsxI7LHwKMvr7G8QpynNAxylHlQD3yhN9tFhxt4KR5wW3as02B-W2TznK9bhNWPKIyHND95Uo2Mi6rEQoq8tM9O09WPWaanE5BX_-r6Llr6dPq5F0Lpx2QOn2xYRb1T4nFxdFTNoss8GBds8OvChTiKpXMLHegLTc1OS4H_1a8tO_37jDwSdJuZ8iTyRLV4kZ2cpL6OL5JPMObD4-HQiec_dfcYgMKPiIfP9ZqdXpec2SVaCLsWEk86ZYvD97hLIQrK5rrKd1y-A'
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
permalink, video_id = self._match_valid_url(url).groups()
|
permalink, video_id = self._match_valid_url(url).groups()
|
||||||
@@ -196,62 +273,50 @@ def _real_extract(self, url):
|
|||||||
'userId': '0',
|
'userId': '0',
|
||||||
}),
|
}),
|
||||||
})['data']['bonanzaPage']['metadata']
|
})['data']['bonanzaPage']['metadata']
|
||||||
query = {
|
|
||||||
'mbr': 'true',
|
if not video_data:
|
||||||
'manifest': 'm3u',
|
# Some videos are not available via GraphQL API
|
||||||
'switch': 'HLSServiceSecure',
|
webpage = self._download_webpage(url, video_id)
|
||||||
}
|
video_data = self._search_json(
|
||||||
|
r'<script>\s*PRELOAD\s*=', webpage, 'video data',
|
||||||
|
video_id)['pages'][urllib.parse.urlparse(url).path]['base']['metadata']
|
||||||
|
|
||||||
video_id = video_data['mpxGuid']
|
video_id = video_data['mpxGuid']
|
||||||
tp_path = 'NnzsPC/media/guid/{}/{}'.format(video_data.get('mpxAccountId') or '2410887629', video_id)
|
tp_path = f'NnzsPC/media/guid/{video_data["mpxAccountId"]}/{video_id}'
|
||||||
tpm = self._download_theplatform_metadata(tp_path, video_id)
|
tpm = self._download_theplatform_metadata(tp_path, video_id, fatal=False)
|
||||||
title = tpm.get('title') or video_data.get('secondaryTitle')
|
title = traverse_obj(tpm, ('title', {str})) or video_data.get('secondaryTitle')
|
||||||
|
query = {}
|
||||||
if video_data.get('locked'):
|
if video_data.get('locked'):
|
||||||
resource = self._get_mvpd_resource(
|
resource = self._get_mvpd_resource(
|
||||||
video_data.get('resourceId') or 'nbcentertainment',
|
video_data['resourceId'], title, video_id, video_data.get('rating'))
|
||||||
title, video_id, video_data.get('rating'))
|
|
||||||
query['auth'] = self._extract_mvpd_auth(
|
query['auth'] = self._extract_mvpd_auth(
|
||||||
url, video_id, 'nbcentertainment', resource)
|
url, video_id, 'nbcentertainment', resource, self._SOFTWARE_STATEMENT)
|
||||||
theplatform_url = smuggle_url(update_url_query(
|
|
||||||
'http://link.theplatform.com/s/NnzsPC/media/guid/{}/{}'.format(video_data.get('mpxAccountId') or '2410887629', video_id),
|
|
||||||
query), {'force_smil_url': True})
|
|
||||||
|
|
||||||
# Empty string or 0 can be valid values for these. So the check must be `is None`
|
formats, subtitles = self._extract_nbcu_formats_and_subtitles(tp_path, video_id, query)
|
||||||
description = video_data.get('description')
|
parsed_info = self._parse_theplatform_metadata(tpm)
|
||||||
if description is None:
|
self._merge_subtitles(parsed_info['subtitles'], target=subtitles)
|
||||||
description = tpm.get('description')
|
|
||||||
episode_number = int_or_none(video_data.get('episodeNumber'))
|
|
||||||
if episode_number is None:
|
|
||||||
episode_number = int_or_none(tpm.get('nbcu$airOrder'))
|
|
||||||
rating = video_data.get('rating')
|
|
||||||
if rating is None:
|
|
||||||
try_get(tpm, lambda x: x['ratings'][0]['rating'])
|
|
||||||
season_number = int_or_none(video_data.get('seasonNumber'))
|
|
||||||
if season_number is None:
|
|
||||||
season_number = int_or_none(tpm.get('nbcu$seasonNumber'))
|
|
||||||
series = video_data.get('seriesShortTitle')
|
|
||||||
if series is None:
|
|
||||||
series = tpm.get('nbcu$seriesShortTitle')
|
|
||||||
tags = video_data.get('keywords')
|
|
||||||
if tags is None or len(tags) == 0:
|
|
||||||
tags = tpm.get('keywords')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'_type': 'url_transparent',
|
**traverse_obj(video_data, {
|
||||||
'age_limit': parse_age_limit(rating),
|
'description': ('description', {str}, filter),
|
||||||
'description': description,
|
'episode': ('secondaryTitle', {str}, filter),
|
||||||
'episode': title,
|
'episode_number': ('episodeNumber', {int_or_none}),
|
||||||
'episode_number': episode_number,
|
'season_number': ('seasonNumber', {int_or_none}),
|
||||||
|
'age_limit': ('rating', {parse_age_limit}),
|
||||||
|
'tags': ('keywords', ..., {str}, filter, all, filter),
|
||||||
|
'series': ('seriesShortTitle', {str}),
|
||||||
|
}),
|
||||||
|
**parsed_info,
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'ie_key': 'ThePlatform',
|
|
||||||
'season_number': season_number,
|
|
||||||
'series': series,
|
|
||||||
'tags': tags,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'url': theplatform_url,
|
'formats': formats,
|
||||||
|
'subtitles': subtitles,
|
||||||
|
'_old_archive_ids': [make_archive_id('ThePlatform', video_id)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NBCSportsVPlayerIE(InfoExtractor):
|
class NBCSportsVPlayerIE(InfoExtractor):
|
||||||
|
_WORKING = False
|
||||||
_VALID_URL_BASE = r'https?://(?:vplayer\.nbcsports\.com|(?:www\.)?nbcsports\.com/vplayer)/'
|
_VALID_URL_BASE = r'https?://(?:vplayer\.nbcsports\.com|(?:www\.)?nbcsports\.com/vplayer)/'
|
||||||
_VALID_URL = _VALID_URL_BASE + r'(?:[^/]+/)+(?P<id>[0-9a-zA-Z_]+)'
|
_VALID_URL = _VALID_URL_BASE + r'(?:[^/]+/)+(?P<id>[0-9a-zA-Z_]+)'
|
||||||
_EMBED_REGEX = [rf'(?:iframe[^>]+|var video|div[^>]+data-(?:mpx-)?)[sS]rc\s?=\s?"(?P<url>{_VALID_URL_BASE}[^\"]+)']
|
_EMBED_REGEX = [rf'(?:iframe[^>]+|var video|div[^>]+data-(?:mpx-)?)[sS]rc\s?=\s?"(?P<url>{_VALID_URL_BASE}[^\"]+)']
|
||||||
@@ -286,6 +351,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBCSportsIE(InfoExtractor):
|
class NBCSportsIE(InfoExtractor):
|
||||||
|
_WORKING = False
|
||||||
_VALID_URL = r'https?://(?:www\.)?nbcsports\.com//?(?!vplayer/)(?:[^/]+/)+(?P<id>[0-9a-z-]+)'
|
_VALID_URL = r'https?://(?:www\.)?nbcsports\.com//?(?!vplayer/)(?:[^/]+/)+(?P<id>[0-9a-z-]+)'
|
||||||
|
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
@@ -321,6 +387,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBCSportsStreamIE(AdobePassIE):
|
class NBCSportsStreamIE(AdobePassIE):
|
||||||
|
_WORKING = False
|
||||||
_VALID_URL = r'https?://stream\.nbcsports\.com/.+?\bpid=(?P<id>\d+)'
|
_VALID_URL = r'https?://stream\.nbcsports\.com/.+?\bpid=(?P<id>\d+)'
|
||||||
_TEST = {
|
_TEST = {
|
||||||
'url': 'http://stream.nbcsports.com/nbcsn/generic?pid=206559',
|
'url': 'http://stream.nbcsports.com/nbcsn/generic?pid=206559',
|
||||||
@@ -354,7 +421,7 @@ def _real_extract(self, url):
|
|||||||
source_url = video_source['ottStreamUrl']
|
source_url = video_source['ottStreamUrl']
|
||||||
is_live = video_source.get('type') == 'live' or video_source.get('status') == 'Live'
|
is_live = video_source.get('type') == 'live' or video_source.get('status') == 'Live'
|
||||||
resource = self._get_mvpd_resource('nbcsports', title, video_id, '')
|
resource = self._get_mvpd_resource('nbcsports', title, video_id, '')
|
||||||
token = self._extract_mvpd_auth(url, video_id, 'nbcsports', resource)
|
token = self._extract_mvpd_auth(url, video_id, 'nbcsports', resource, None) # XXX: None arg needs to be software_statement
|
||||||
tokenized_url = self._download_json(
|
tokenized_url = self._download_json(
|
||||||
'https://token.playmakerservices.com/cdn',
|
'https://token.playmakerservices.com/cdn',
|
||||||
video_id, data=json.dumps({
|
video_id, data=json.dumps({
|
||||||
@@ -534,22 +601,26 @@ class NBCOlympicsIE(InfoExtractor):
|
|||||||
IE_NAME = 'nbcolympics'
|
IE_NAME = 'nbcolympics'
|
||||||
_VALID_URL = r'https?://www\.nbcolympics\.com/videos?/(?P<id>[0-9a-z-]+)'
|
_VALID_URL = r'https?://www\.nbcolympics\.com/videos?/(?P<id>[0-9a-z-]+)'
|
||||||
|
|
||||||
_TEST = {
|
_TESTS = [{
|
||||||
# Geo-restricted to US
|
# Geo-restricted to US
|
||||||
'url': 'http://www.nbcolympics.com/video/justin-roses-son-leo-was-tears-after-his-dad-won-gold',
|
'url': 'https://www.nbcolympics.com/videos/watch-final-minutes-team-usas-mens-basketball-gold',
|
||||||
'md5': '54fecf846d05429fbaa18af557ee523a',
|
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': 'WjTBzDXx5AUq',
|
'id': 'SAwGfPlQ1q01',
|
||||||
'display_id': 'justin-roses-son-leo-was-tears-after-his-dad-won-gold',
|
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Rose\'s son Leo was in tears after his dad won gold',
|
'display_id': 'watch-final-minutes-team-usas-mens-basketball-gold',
|
||||||
'description': 'Olympic gold medalist Justin Rose gets emotional talking to the impact his win in men\'s golf has already had on his children.',
|
'title': 'Watch the final minutes of Team USA\'s men\'s basketball gold',
|
||||||
'timestamp': 1471274964,
|
'description': 'md5:f704f591217305c9559b23b877aa8d31',
|
||||||
'upload_date': '20160815',
|
|
||||||
'uploader': 'NBCU-SPORTS',
|
'uploader': 'NBCU-SPORTS',
|
||||||
|
'duration': 387.053,
|
||||||
|
'thumbnail': r're:https://.+/.+\.jpg',
|
||||||
|
'chapters': [],
|
||||||
|
'timestamp': 1723346984,
|
||||||
|
'upload_date': '20240811',
|
||||||
},
|
},
|
||||||
'skip': '404 Not Found',
|
}, {
|
||||||
}
|
'url': 'http://www.nbcolympics.com/video/justin-roses-son-leo-was-tears-after-his-dad-won-gold',
|
||||||
|
'only_matching': True,
|
||||||
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id = self._match_id(url)
|
display_id = self._match_id(url)
|
||||||
@@ -578,6 +649,7 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class NBCOlympicsStreamIE(AdobePassIE):
|
class NBCOlympicsStreamIE(AdobePassIE):
|
||||||
|
_WORKING = False
|
||||||
IE_NAME = 'nbcolympics:stream'
|
IE_NAME = 'nbcolympics:stream'
|
||||||
_VALID_URL = r'https?://stream\.nbcolympics\.com/(?P<id>[0-9a-z-]+)'
|
_VALID_URL = r'https?://stream\.nbcolympics\.com/(?P<id>[0-9a-z-]+)'
|
||||||
_TESTS = [
|
_TESTS = [
|
||||||
@@ -630,7 +702,8 @@ def _real_extract(self, url):
|
|||||||
event_config.get('resourceId', 'NBCOlympics'),
|
event_config.get('resourceId', 'NBCOlympics'),
|
||||||
re.sub(r'[^\w\d ]+', '', event_config['eventTitle']), pid,
|
re.sub(r'[^\w\d ]+', '', event_config['eventTitle']), pid,
|
||||||
event_config.get('ratingId', 'NO VALUE'))
|
event_config.get('ratingId', 'NO VALUE'))
|
||||||
media_token = self._extract_mvpd_auth(url, pid, event_config.get('requestorId', 'NBCOlympics'), ap_resource)
|
# XXX: The None arg below needs to be the software_statement for this requestor
|
||||||
|
media_token = self._extract_mvpd_auth(url, pid, event_config.get('requestorId', 'NBCOlympics'), ap_resource, None)
|
||||||
|
|
||||||
source_url = self._download_json(
|
source_url = self._download_json(
|
||||||
'https://tokens.playmakerservices.com/', pid, 'Retrieving tokenized URL',
|
'https://tokens.playmakerservices.com/', pid, 'Retrieving tokenized URL',
|
||||||
@@ -848,3 +921,178 @@ def _real_extract(self, url):
|
|||||||
'is_live': is_live,
|
'is_live': is_live,
|
||||||
**info,
|
**info,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BravoTVIE(NBCUniversalBaseIE):
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?(?:bravotv|oxygen)\.com/(?:[^/?#]+/)+(?P<id>[^/?#]+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.bravotv.com/top-chef/season-16/episode-15/videos/the-top-chef-season-16-winner-is',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '3923059',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'The Top Chef Season 16 Winner Is...',
|
||||||
|
'display_id': 'the-top-chef-season-16-winner-is',
|
||||||
|
'description': 'Find out who takes the title of Top Chef!',
|
||||||
|
'upload_date': '20190315',
|
||||||
|
'timestamp': 1552618860,
|
||||||
|
'season_number': 16,
|
||||||
|
'episode_number': 15,
|
||||||
|
'series': 'Top Chef',
|
||||||
|
'episode': 'Finale',
|
||||||
|
'duration': 190,
|
||||||
|
'season': 'Season 16',
|
||||||
|
'thumbnail': r're:^https://.+\.jpg',
|
||||||
|
'uploader': 'NBCU-BRAV',
|
||||||
|
'categories': ['Series', 'Series/Top Chef'],
|
||||||
|
'tags': 'count:10',
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.bravotv.com/top-chef/season-20/episode-1/london-calling',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '9000234570',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'London Calling',
|
||||||
|
'display_id': 'london-calling',
|
||||||
|
'description': 'md5:5af95a8cbac1856bd10e7562f86bb759',
|
||||||
|
'upload_date': '20230310',
|
||||||
|
'timestamp': 1678418100,
|
||||||
|
'season_number': 20,
|
||||||
|
'episode_number': 1,
|
||||||
|
'series': 'Top Chef',
|
||||||
|
'episode': 'London Calling',
|
||||||
|
'duration': 3266,
|
||||||
|
'season': 'Season 20',
|
||||||
|
'chapters': 'count:7',
|
||||||
|
'thumbnail': r're:^https://.+\.jpg',
|
||||||
|
'age_limit': 14,
|
||||||
|
'media_type': 'Full Episode',
|
||||||
|
'uploader': 'NBCU-MPAT',
|
||||||
|
'categories': ['Series/Top Chef'],
|
||||||
|
'tags': 'count:10',
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
'skip': 'This video requires AdobePass MSO credentials',
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.oxygen.com/in-ice-cold-blood/season-1/closing-night',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '3692045',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'Closing Night',
|
||||||
|
'display_id': 'closing-night',
|
||||||
|
'description': 'md5:c8a5bb523c8ef381f3328c6d9f1e4632',
|
||||||
|
'upload_date': '20230126',
|
||||||
|
'timestamp': 1674709200,
|
||||||
|
'season_number': 1,
|
||||||
|
'episode_number': 1,
|
||||||
|
'series': 'In Ice Cold Blood',
|
||||||
|
'episode': 'Closing Night',
|
||||||
|
'duration': 2629,
|
||||||
|
'season': 'Season 1',
|
||||||
|
'chapters': 'count:6',
|
||||||
|
'thumbnail': r're:^https://.+\.jpg',
|
||||||
|
'age_limit': 14,
|
||||||
|
'media_type': 'Full Episode',
|
||||||
|
'uploader': 'NBCU-MPAT',
|
||||||
|
'categories': ['Series/In Ice Cold Blood'],
|
||||||
|
'tags': ['ice-t', 'in ice cold blood', 'law and order', 'oxygen', 'true crime'],
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
'skip': 'This video requires AdobePass MSO credentials',
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.oxygen.com/in-ice-cold-blood/season-2/episode-16/videos/handling-the-horwitz-house-after-the-murder-season-2',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '3974019',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': '\'Handling The Horwitz House After The Murder (Season 2, Episode 16)',
|
||||||
|
'display_id': 'handling-the-horwitz-house-after-the-murder-season-2',
|
||||||
|
'description': 'md5:f9d638dd6946a1c1c0533a9c6100eae5',
|
||||||
|
'upload_date': '20190618',
|
||||||
|
'timestamp': 1560819600,
|
||||||
|
'season_number': 2,
|
||||||
|
'episode_number': 16,
|
||||||
|
'series': 'In Ice Cold Blood',
|
||||||
|
'episode': 'Mother Vs Son',
|
||||||
|
'duration': 68,
|
||||||
|
'season': 'Season 2',
|
||||||
|
'thumbnail': r're:^https://.+\.jpg',
|
||||||
|
'age_limit': 14,
|
||||||
|
'uploader': 'NBCU-OXY',
|
||||||
|
'categories': ['Series/In Ice Cold Blood'],
|
||||||
|
'tags': ['in ice cold blood', 'ice-t', 'law and order', 'true crime', 'oxygen'],
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.bravotv.com/below-deck/season-3/ep-14-reunion-part-1',
|
||||||
|
'only_matching': True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
display_id = self._match_id(url)
|
||||||
|
return self._extract_nbcu_video(url, display_id)
|
||||||
|
|
||||||
|
|
||||||
|
class SyfyIE(NBCUniversalBaseIE):
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?syfy\.com/[^/?#]+/(?:season-\d+/episode-\d+/(?:videos/)?|videos/)(?P<id>[^/?#]+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://www.syfy.com/face-off/season-13/episode-10/videos/keyed-up',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '3774403',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'display_id': 'keyed-up',
|
||||||
|
'title': 'Keyed Up',
|
||||||
|
'description': 'md5:feafd15bee449f212dcd3065bbe9a755',
|
||||||
|
'age_limit': 14,
|
||||||
|
'duration': 169,
|
||||||
|
'thumbnail': r're:https://www\.syfy\.com/.+/.+\.jpg',
|
||||||
|
'series': 'Face Off',
|
||||||
|
'season': 'Season 13',
|
||||||
|
'season_number': 13,
|
||||||
|
'episode': 'Through the Looking Glass Part 2',
|
||||||
|
'episode_number': 10,
|
||||||
|
'timestamp': 1533711618,
|
||||||
|
'upload_date': '20180808',
|
||||||
|
'media_type': 'Excerpt',
|
||||||
|
'uploader': 'NBCU-MPAT',
|
||||||
|
'categories': ['Series/Face Off'],
|
||||||
|
'tags': 'count:15',
|
||||||
|
'_old_archive_ids': ['theplatform 3774403'],
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}, {
|
||||||
|
'url': 'https://www.syfy.com/face-off/season-13/episode-10/through-the-looking-glass-part-2',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '3772391',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'display_id': 'through-the-looking-glass-part-2',
|
||||||
|
'title': 'Through the Looking Glass Pt.2',
|
||||||
|
'description': 'md5:90bd5dcbf1059fe3296c263599af41d2',
|
||||||
|
'age_limit': 0,
|
||||||
|
'duration': 2599,
|
||||||
|
'thumbnail': r're:https://www\.syfy\.com/.+/.+\.jpg',
|
||||||
|
'chapters': [{'start_time': 0.0, 'end_time': 679.0, 'title': '<Untitled Chapter 1>'},
|
||||||
|
{'start_time': 679.0, 'end_time': 1040.967, 'title': '<Untitled Chapter 2>'},
|
||||||
|
{'start_time': 1040.967, 'end_time': 1403.0, 'title': '<Untitled Chapter 3>'},
|
||||||
|
{'start_time': 1403.0, 'end_time': 1870.0, 'title': '<Untitled Chapter 4>'},
|
||||||
|
{'start_time': 1870.0, 'end_time': 2496.967, 'title': '<Untitled Chapter 5>'},
|
||||||
|
{'start_time': 2496.967, 'end_time': 2599, 'title': '<Untitled Chapter 6>'}],
|
||||||
|
'series': 'Face Off',
|
||||||
|
'season': 'Season 13',
|
||||||
|
'season_number': 13,
|
||||||
|
'episode': 'Through the Looking Glass Part 2',
|
||||||
|
'episode_number': 10,
|
||||||
|
'timestamp': 1672570800,
|
||||||
|
'upload_date': '20230101',
|
||||||
|
'media_type': 'Full Episode',
|
||||||
|
'uploader': 'NBCU-MPAT',
|
||||||
|
'categories': ['Series/Face Off'],
|
||||||
|
'tags': 'count:15',
|
||||||
|
'_old_archive_ids': ['theplatform 3772391'],
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
'skip': 'This video requires AdobePass MSO credentials',
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
display_id = self._match_id(url)
|
||||||
|
return self._extract_nbcu_video(url, display_id, old_ie_key='ThePlatform')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
from .art19 import Art19IE
|
from .art19 import Art19IE
|
||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
|
from ..networking import PATCHRequest
|
||||||
from ..networking.exceptions import HTTPError
|
from ..networking.exceptions import HTTPError
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
@@ -74,7 +75,7 @@ def _extract_formats(self, content_id, slug):
|
|||||||
'app_version': '23.10.0',
|
'app_version': '23.10.0',
|
||||||
'platform': 'ios',
|
'platform': 'ios',
|
||||||
})
|
})
|
||||||
return {'formats': fmts, 'subtitles': subs}
|
break
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
if isinstance(e.cause, HTTPError) and e.cause.status == 401:
|
if isinstance(e.cause, HTTPError) and e.cause.status == 401:
|
||||||
self.raise_login_required()
|
self.raise_login_required()
|
||||||
@@ -84,6 +85,9 @@ def _extract_formats(self, content_id, slug):
|
|||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
self.mark_watched(content_id, slug)
|
||||||
|
return {'formats': fmts, 'subtitles': subs}
|
||||||
|
|
||||||
def _extract_video_metadata(self, episode):
|
def _extract_video_metadata(self, episode):
|
||||||
channel_url = traverse_obj(
|
channel_url = traverse_obj(
|
||||||
episode, (('channel_slug', 'class_slug'), {urljoin('https://nebula.tv/')}), get_all=False)
|
episode, (('channel_slug', 'class_slug'), {urljoin('https://nebula.tv/')}), get_all=False)
|
||||||
@@ -111,6 +115,13 @@ def _extract_video_metadata(self, episode):
|
|||||||
'uploader_url': channel_url,
|
'uploader_url': channel_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _mark_watched(self, content_id, slug):
|
||||||
|
self._call_api(
|
||||||
|
PATCHRequest(f'https://content.api.nebula.app/{content_id.split(":")[0]}s/{content_id}/progress/'),
|
||||||
|
slug, 'Marking watched', 'Unable to mark watched', fatal=False,
|
||||||
|
data=json.dumps({'completed': True}).encode(),
|
||||||
|
headers={'content-type': 'application/json'})
|
||||||
|
|
||||||
|
|
||||||
class NebulaIE(NebulaBaseIE):
|
class NebulaIE(NebulaBaseIE):
|
||||||
IE_NAME = 'nebula:video'
|
IE_NAME = 'nebula:video'
|
||||||
@@ -322,6 +333,7 @@ def _real_extract(self, url):
|
|||||||
if not episode_url and metadata.get('premium'):
|
if not episode_url and metadata.get('premium'):
|
||||||
self.raise_login_required()
|
self.raise_login_required()
|
||||||
|
|
||||||
|
self.mark_watched(metadata['id'], slug)
|
||||||
if Art19IE.suitable(episode_url):
|
if Art19IE.suitable(episode_url):
|
||||||
return self.url_result(episode_url, Art19IE)
|
return self.url_result(episode_url, Art19IE)
|
||||||
return traverse_obj(metadata, {
|
return traverse_obj(metadata, {
|
||||||
|
|||||||
@@ -4,42 +4,100 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from .common import InfoExtractor, SearchInfoExtractor
|
from .common import InfoExtractor, SearchInfoExtractor
|
||||||
from ..networking import Request
|
|
||||||
from ..networking.exceptions import HTTPError
|
from ..networking.exceptions import HTTPError
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
OnDemandPagedList,
|
OnDemandPagedList,
|
||||||
clean_html,
|
clean_html,
|
||||||
determine_ext,
|
determine_ext,
|
||||||
|
extract_attributes,
|
||||||
float_or_none,
|
float_or_none,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
join_nonempty,
|
parse_bitrate,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
parse_iso8601,
|
parse_iso8601,
|
||||||
parse_qs,
|
parse_qs,
|
||||||
parse_resolution,
|
parse_resolution,
|
||||||
qualities,
|
qualities,
|
||||||
remove_start,
|
|
||||||
str_or_none,
|
str_or_none,
|
||||||
traverse_obj,
|
truncate_string,
|
||||||
try_get,
|
unified_timestamp,
|
||||||
unescapeHTML,
|
|
||||||
update_url_query,
|
update_url_query,
|
||||||
url_basename,
|
url_basename,
|
||||||
url_or_none,
|
url_or_none,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
urljoin,
|
urljoin,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import (
|
||||||
|
find_element,
|
||||||
|
require,
|
||||||
|
traverse_obj,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NiconicoIE(InfoExtractor):
|
class NiconicoBaseIE(InfoExtractor):
|
||||||
|
_GEO_BYPASS = False
|
||||||
|
_GEO_COUNTRIES = ['JP']
|
||||||
|
_LOGIN_BASE = 'https://account.nicovideo.jp'
|
||||||
|
_NETRC_MACHINE = 'niconico'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_logged_in(self):
|
||||||
|
return bool(self._get_cookies('https://www.nicovideo.jp').get('user_session'))
|
||||||
|
|
||||||
|
def _raise_login_error(self, message, expected=True):
|
||||||
|
raise ExtractorError(f'Unable to login: {message}', expected=expected)
|
||||||
|
|
||||||
|
def _perform_login(self, username, password):
|
||||||
|
if self.is_logged_in:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._request_webpage(
|
||||||
|
f'{self._LOGIN_BASE}/login', None, 'Requesting session cookies')
|
||||||
|
webpage = self._download_webpage(
|
||||||
|
f'{self._LOGIN_BASE}/login/redirector', None,
|
||||||
|
'Logging in', 'Unable to log in', headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': f'{self._LOGIN_BASE}/login',
|
||||||
|
}, data=urlencode_postdata({
|
||||||
|
'mail_tel': username,
|
||||||
|
'password': password,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if self.is_logged_in:
|
||||||
|
return
|
||||||
|
elif err_msg := traverse_obj(webpage, (
|
||||||
|
{find_element(cls='notice error')}, {find_element(cls='notice__text')}, {clean_html},
|
||||||
|
)):
|
||||||
|
self._raise_login_error(err_msg or 'Invalid username or password')
|
||||||
|
elif 'oneTimePw' in webpage:
|
||||||
|
post_url = self._search_regex(
|
||||||
|
r'<form[^>]+action=(["\'])(?P<url>.+?)\1', webpage, 'post url', group='url')
|
||||||
|
mfa, urlh = self._download_webpage_handle(
|
||||||
|
urljoin(self._LOGIN_BASE, post_url), None,
|
||||||
|
'Performing MFA', 'Unable to complete MFA', headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
}, data=urlencode_postdata({
|
||||||
|
'otp': self._get_tfa_info('6 digit number shown on app'),
|
||||||
|
}))
|
||||||
|
if self.is_logged_in:
|
||||||
|
return
|
||||||
|
elif 'error-code' in parse_qs(urlh.url):
|
||||||
|
err_msg = traverse_obj(mfa, ({find_element(cls='pageMainMsg')}, {clean_html}))
|
||||||
|
self._raise_login_error(err_msg or 'MFA session expired')
|
||||||
|
elif 'formError' in mfa:
|
||||||
|
err_msg = traverse_obj(mfa, (
|
||||||
|
{find_element(cls='formError')}, {find_element(tag='div')}, {clean_html}))
|
||||||
|
self._raise_login_error(err_msg or 'MFA challenge failed')
|
||||||
|
|
||||||
|
self._raise_login_error('Unexpected login error', expected=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NiconicoIE(NiconicoBaseIE):
|
||||||
IE_NAME = 'niconico'
|
IE_NAME = 'niconico'
|
||||||
IE_DESC = 'ニコニコ動画'
|
IE_DESC = 'ニコニコ動画'
|
||||||
_GEO_COUNTRIES = ['JP']
|
|
||||||
_GEO_BYPASS = False
|
|
||||||
|
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'http://www.nicovideo.jp/watch/sm22312215',
|
'url': 'http://www.nicovideo.jp/watch/sm22312215',
|
||||||
@@ -179,229 +237,6 @@ class NiconicoIE(InfoExtractor):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
_VALID_URL = r'https?://(?:(?:www\.|secure\.|sp\.)?nicovideo\.jp/watch|nico\.ms)/(?P<id>(?:[a-z]{2})?[0-9]+)'
|
_VALID_URL = r'https?://(?:(?:www\.|secure\.|sp\.)?nicovideo\.jp/watch|nico\.ms)/(?P<id>(?:[a-z]{2})?[0-9]+)'
|
||||||
_NETRC_MACHINE = 'niconico'
|
|
||||||
_API_HEADERS = {
|
|
||||||
'X-Frontend-ID': '6',
|
|
||||||
'X-Frontend-Version': '0',
|
|
||||||
'X-Niconico-Language': 'en-us',
|
|
||||||
'Referer': 'https://www.nicovideo.jp/',
|
|
||||||
'Origin': 'https://www.nicovideo.jp',
|
|
||||||
}
|
|
||||||
|
|
||||||
def _perform_login(self, username, password):
|
|
||||||
login_ok = True
|
|
||||||
login_form_strs = {
|
|
||||||
'mail_tel': username,
|
|
||||||
'password': password,
|
|
||||||
}
|
|
||||||
self._request_webpage(
|
|
||||||
'https://account.nicovideo.jp/login', None,
|
|
||||||
note='Acquiring Login session')
|
|
||||||
page = self._download_webpage(
|
|
||||||
'https://account.nicovideo.jp/login/redirector?show_button_twitter=1&site=niconico&show_button_facebook=1', None,
|
|
||||||
note='Logging in', errnote='Unable to log in',
|
|
||||||
data=urlencode_postdata(login_form_strs),
|
|
||||||
headers={
|
|
||||||
'Referer': 'https://account.nicovideo.jp/login',
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
})
|
|
||||||
if 'oneTimePw' in page:
|
|
||||||
post_url = self._search_regex(
|
|
||||||
r'<form[^>]+action=(["\'])(?P<url>.+?)\1', page, 'post url', group='url')
|
|
||||||
page = self._download_webpage(
|
|
||||||
urljoin('https://account.nicovideo.jp', post_url), None,
|
|
||||||
note='Performing MFA', errnote='Unable to complete MFA',
|
|
||||||
data=urlencode_postdata({
|
|
||||||
'otp': self._get_tfa_info('6 digits code'),
|
|
||||||
}), headers={
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
})
|
|
||||||
if 'oneTimePw' in page or 'formError' in page:
|
|
||||||
err_msg = self._html_search_regex(
|
|
||||||
r'formError["\']+>(.*?)</div>', page, 'form_error',
|
|
||||||
default='There\'s an error but the message can\'t be parsed.',
|
|
||||||
flags=re.DOTALL)
|
|
||||||
self.report_warning(f'Unable to log in: MFA challenge failed, "{err_msg}"')
|
|
||||||
return False
|
|
||||||
login_ok = 'class="notice error"' not in page
|
|
||||||
if not login_ok:
|
|
||||||
self.report_warning('Unable to log in: bad username or password')
|
|
||||||
return login_ok
|
|
||||||
|
|
||||||
def _get_heartbeat_info(self, info_dict):
|
|
||||||
video_id, video_src_id, audio_src_id = info_dict['url'].split(':')[1].split('/')
|
|
||||||
dmc_protocol = info_dict['expected_protocol']
|
|
||||||
|
|
||||||
api_data = (
|
|
||||||
info_dict.get('_api_data')
|
|
||||||
or self._parse_json(
|
|
||||||
self._html_search_regex(
|
|
||||||
'data-api-data="([^"]+)"',
|
|
||||||
self._download_webpage('https://www.nicovideo.jp/watch/' + video_id, video_id),
|
|
||||||
'API data', default='{}'),
|
|
||||||
video_id))
|
|
||||||
|
|
||||||
session_api_data = try_get(api_data, lambda x: x['media']['delivery']['movie']['session'])
|
|
||||||
session_api_endpoint = try_get(session_api_data, lambda x: x['urls'][0])
|
|
||||||
|
|
||||||
def ping():
|
|
||||||
tracking_id = traverse_obj(api_data, ('media', 'delivery', 'trackingId'))
|
|
||||||
if tracking_id:
|
|
||||||
tracking_url = update_url_query('https://nvapi.nicovideo.jp/v1/2ab0cbaa/watch', {'t': tracking_id})
|
|
||||||
watch_request_response = self._download_json(
|
|
||||||
tracking_url, video_id,
|
|
||||||
note='Acquiring permission for downloading video', fatal=False,
|
|
||||||
headers=self._API_HEADERS)
|
|
||||||
if traverse_obj(watch_request_response, ('meta', 'status')) != 200:
|
|
||||||
self.report_warning('Failed to acquire permission for playing video. Video download may fail.')
|
|
||||||
|
|
||||||
yesno = lambda x: 'yes' if x else 'no'
|
|
||||||
|
|
||||||
if dmc_protocol == 'http':
|
|
||||||
protocol = 'http'
|
|
||||||
protocol_parameters = {
|
|
||||||
'http_output_download_parameters': {
|
|
||||||
'use_ssl': yesno(session_api_data['urls'][0]['isSsl']),
|
|
||||||
'use_well_known_port': yesno(session_api_data['urls'][0]['isWellKnownPort']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
elif dmc_protocol == 'hls':
|
|
||||||
protocol = 'm3u8'
|
|
||||||
segment_duration = try_get(self._configuration_arg('segment_duration'), lambda x: int(x[0])) or 6000
|
|
||||||
parsed_token = self._parse_json(session_api_data['token'], video_id)
|
|
||||||
encryption = traverse_obj(api_data, ('media', 'delivery', 'encryption'))
|
|
||||||
protocol_parameters = {
|
|
||||||
'hls_parameters': {
|
|
||||||
'segment_duration': segment_duration,
|
|
||||||
'transfer_preset': '',
|
|
||||||
'use_ssl': yesno(session_api_data['urls'][0]['isSsl']),
|
|
||||||
'use_well_known_port': yesno(session_api_data['urls'][0]['isWellKnownPort']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if 'hls_encryption' in parsed_token and encryption:
|
|
||||||
protocol_parameters['hls_parameters']['encryption'] = {
|
|
||||||
parsed_token['hls_encryption']: {
|
|
||||||
'encrypted_key': encryption['encryptedKey'],
|
|
||||||
'key_uri': encryption['keyUri'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
protocol = 'm3u8_native'
|
|
||||||
else:
|
|
||||||
raise ExtractorError(f'Unsupported DMC protocol: {dmc_protocol}')
|
|
||||||
|
|
||||||
session_response = self._download_json(
|
|
||||||
session_api_endpoint['url'], video_id,
|
|
||||||
query={'_format': 'json'},
|
|
||||||
headers={'Content-Type': 'application/json'},
|
|
||||||
note='Downloading JSON metadata for {}'.format(info_dict['format_id']),
|
|
||||||
data=json.dumps({
|
|
||||||
'session': {
|
|
||||||
'client_info': {
|
|
||||||
'player_id': session_api_data.get('playerId'),
|
|
||||||
},
|
|
||||||
'content_auth': {
|
|
||||||
'auth_type': try_get(session_api_data, lambda x: x['authTypes'][session_api_data['protocols'][0]]),
|
|
||||||
'content_key_timeout': session_api_data.get('contentKeyTimeout'),
|
|
||||||
'service_id': 'nicovideo',
|
|
||||||
'service_user_id': session_api_data.get('serviceUserId'),
|
|
||||||
},
|
|
||||||
'content_id': session_api_data.get('contentId'),
|
|
||||||
'content_src_id_sets': [{
|
|
||||||
'content_src_ids': [{
|
|
||||||
'src_id_to_mux': {
|
|
||||||
'audio_src_ids': [audio_src_id],
|
|
||||||
'video_src_ids': [video_src_id],
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
'content_type': 'movie',
|
|
||||||
'content_uri': '',
|
|
||||||
'keep_method': {
|
|
||||||
'heartbeat': {
|
|
||||||
'lifetime': session_api_data.get('heartbeatLifetime'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'priority': session_api_data['priority'],
|
|
||||||
'protocol': {
|
|
||||||
'name': 'http',
|
|
||||||
'parameters': {
|
|
||||||
'http_parameters': {
|
|
||||||
'parameters': protocol_parameters,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'recipe_id': session_api_data.get('recipeId'),
|
|
||||||
'session_operation_auth': {
|
|
||||||
'session_operation_auth_by_signature': {
|
|
||||||
'signature': session_api_data.get('signature'),
|
|
||||||
'token': session_api_data.get('token'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'timing_constraint': 'unlimited',
|
|
||||||
},
|
|
||||||
}).encode())
|
|
||||||
|
|
||||||
info_dict['url'] = session_response['data']['session']['content_uri']
|
|
||||||
info_dict['protocol'] = protocol
|
|
||||||
|
|
||||||
# get heartbeat info
|
|
||||||
heartbeat_info_dict = {
|
|
||||||
'url': session_api_endpoint['url'] + '/' + session_response['data']['session']['id'] + '?_format=json&_method=PUT',
|
|
||||||
'data': json.dumps(session_response['data']),
|
|
||||||
# interval, convert milliseconds to seconds, then halve to make a buffer.
|
|
||||||
'interval': float_or_none(session_api_data.get('heartbeatLifetime'), scale=3000),
|
|
||||||
'ping': ping,
|
|
||||||
}
|
|
||||||
|
|
||||||
return info_dict, heartbeat_info_dict
|
|
||||||
|
|
||||||
def _extract_format_for_quality(self, video_id, audio_quality, video_quality, dmc_protocol):
|
|
||||||
|
|
||||||
if not audio_quality.get('isAvailable') or not video_quality.get('isAvailable'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
format_id = '-'.join(
|
|
||||||
[remove_start(s['id'], 'archive_') for s in (video_quality, audio_quality)] + [dmc_protocol])
|
|
||||||
|
|
||||||
vid_qual_label = traverse_obj(video_quality, ('metadata', 'label'))
|
|
||||||
|
|
||||||
return {
|
|
||||||
'url': 'niconico_dmc:{}/{}/{}'.format(video_id, video_quality['id'], audio_quality['id']),
|
|
||||||
'format_id': format_id,
|
|
||||||
'format_note': join_nonempty('DMC', vid_qual_label, dmc_protocol.upper(), delim=' '),
|
|
||||||
'ext': 'mp4', # Session API are used in HTML5, which always serves mp4
|
|
||||||
'acodec': 'aac',
|
|
||||||
'vcodec': 'h264',
|
|
||||||
**traverse_obj(audio_quality, ('metadata', {
|
|
||||||
'abr': ('bitrate', {float_or_none(scale=1000)}),
|
|
||||||
'asr': ('samplingRate', {int_or_none}),
|
|
||||||
})),
|
|
||||||
**traverse_obj(video_quality, ('metadata', {
|
|
||||||
'vbr': ('bitrate', {float_or_none(scale=1000)}),
|
|
||||||
'height': ('resolution', 'height', {int_or_none}),
|
|
||||||
'width': ('resolution', 'width', {int_or_none}),
|
|
||||||
})),
|
|
||||||
'quality': -2 if 'low' in video_quality['id'] else None,
|
|
||||||
'protocol': 'niconico_dmc',
|
|
||||||
'expected_protocol': dmc_protocol, # XXX: This is not a documented field
|
|
||||||
'http_headers': {
|
|
||||||
'Origin': 'https://www.nicovideo.jp',
|
|
||||||
'Referer': 'https://www.nicovideo.jp/watch/' + video_id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _yield_dmc_formats(self, api_data, video_id):
|
|
||||||
dmc_data = traverse_obj(api_data, ('media', 'delivery', 'movie'))
|
|
||||||
audios = traverse_obj(dmc_data, ('audios', ..., {dict}))
|
|
||||||
videos = traverse_obj(dmc_data, ('videos', ..., {dict}))
|
|
||||||
protocols = traverse_obj(dmc_data, ('session', 'protocols', ..., {str}))
|
|
||||||
if not all((audios, videos, protocols)):
|
|
||||||
return
|
|
||||||
|
|
||||||
for audio_quality, video_quality, protocol in itertools.product(audios, videos, protocols):
|
|
||||||
if fmt := self._extract_format_for_quality(video_id, audio_quality, video_quality, protocol):
|
|
||||||
yield fmt
|
|
||||||
|
|
||||||
def _yield_dms_formats(self, api_data, video_id):
|
def _yield_dms_formats(self, api_data, video_id):
|
||||||
fmt_filter = lambda _, v: v['isAvailable'] and v['id']
|
fmt_filter = lambda _, v: v['isAvailable'] and v['id']
|
||||||
@@ -450,42 +285,61 @@ def _yield_dms_formats(self, api_data, video_id):
|
|||||||
lambda _, v: v['id'] == video_fmt['format_id'], 'qualityLevel', {int_or_none}, any)) or -1
|
lambda _, v: v['id'] == video_fmt['format_id'], 'qualityLevel', {int_or_none}, any)) or -1
|
||||||
yield video_fmt
|
yield video_fmt
|
||||||
|
|
||||||
|
def _extract_server_response(self, webpage, video_id, fatal=True):
|
||||||
|
try:
|
||||||
|
return traverse_obj(
|
||||||
|
self._parse_json(self._html_search_meta('server-response', webpage) or '', video_id),
|
||||||
|
('data', 'response', {dict}, {require('server response')}))
|
||||||
|
except ExtractorError:
|
||||||
|
if not fatal:
|
||||||
|
return {}
|
||||||
|
raise
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webpage, handle = self._download_webpage_handle(
|
webpage, handle = self._download_webpage_handle(
|
||||||
'https://www.nicovideo.jp/watch/' + video_id, video_id)
|
f'https://www.nicovideo.jp/watch/{video_id}', video_id,
|
||||||
|
headers=self.geo_verification_headers())
|
||||||
if video_id.startswith('so'):
|
if video_id.startswith('so'):
|
||||||
video_id = self._match_id(handle.url)
|
video_id = self._match_id(handle.url)
|
||||||
|
|
||||||
api_data = traverse_obj(
|
api_data = self._extract_server_response(webpage, video_id)
|
||||||
self._parse_json(self._html_search_meta('server-response', webpage) or '', video_id),
|
|
||||||
('data', 'response', {dict}))
|
|
||||||
if not api_data:
|
|
||||||
raise ExtractorError('Server response data not found')
|
|
||||||
except ExtractorError as e:
|
except ExtractorError as e:
|
||||||
try:
|
try:
|
||||||
api_data = self._download_json(
|
api_data = self._download_json(
|
||||||
f'https://www.nicovideo.jp/api/watch/v3/{video_id}?_frontendId=6&_frontendVersion=0&actionTrackId=AAAAAAAAAA_{round(time.time() * 1000)}', video_id,
|
f'https://www.nicovideo.jp/api/watch/v3/{video_id}', video_id,
|
||||||
note='Downloading API JSON', errnote='Unable to fetch data')['data']
|
'Downloading API JSON', 'Unable to fetch data', query={
|
||||||
|
'_frontendId': '6',
|
||||||
|
'_frontendVersion': '0',
|
||||||
|
'actionTrackId': f'AAAAAAAAAA_{round(time.time() * 1000)}',
|
||||||
|
}, headers=self.geo_verification_headers())['data']
|
||||||
except ExtractorError:
|
except ExtractorError:
|
||||||
if not isinstance(e.cause, HTTPError):
|
if not isinstance(e.cause, HTTPError):
|
||||||
|
# Raise if original exception was from _parse_json or utils.traversal.require
|
||||||
raise
|
raise
|
||||||
|
# The webpage server response has more detailed error info than the API response
|
||||||
webpage = e.cause.response.read().decode('utf-8', 'replace')
|
webpage = e.cause.response.read().decode('utf-8', 'replace')
|
||||||
error_msg = self._html_search_regex(
|
reason_code = self._extract_server_response(
|
||||||
r'(?s)<section\s+class="(?:(?:ErrorMessage|WatchExceptionPage-message)\s*)+">(.+?)</section>',
|
webpage, video_id, fatal=False).get('reasonCode')
|
||||||
webpage, 'error reason', default=None)
|
if not reason_code:
|
||||||
if not error_msg:
|
|
||||||
raise
|
raise
|
||||||
raise ExtractorError(clean_html(error_msg), expected=True)
|
if reason_code in ('DOMESTIC_VIDEO', 'HIGH_RISK_COUNTRY_VIDEO'):
|
||||||
|
self.raise_geo_restricted(countries=self._GEO_COUNTRIES)
|
||||||
|
elif reason_code == 'HIDDEN_VIDEO':
|
||||||
|
raise ExtractorError(
|
||||||
|
'The viewing period of this video has expired', expected=True)
|
||||||
|
elif reason_code == 'DELETED_VIDEO':
|
||||||
|
raise ExtractorError('This video has been deleted', expected=True)
|
||||||
|
raise ExtractorError(f'Niconico says: {reason_code}')
|
||||||
|
|
||||||
availability = self._availability(**(traverse_obj(api_data, ('payment', 'video', {
|
availability = self._availability(**(traverse_obj(api_data, ('payment', 'video', {
|
||||||
'needs_premium': ('isPremium', {bool}),
|
'needs_premium': ('isPremium', {bool}),
|
||||||
'needs_subscription': ('isAdmission', {bool}),
|
'needs_subscription': ('isAdmission', {bool}),
|
||||||
})) or {'needs_auth': True}))
|
})) or {'needs_auth': True}))
|
||||||
formats = [*self._yield_dmc_formats(api_data, video_id),
|
|
||||||
*self._yield_dms_formats(api_data, video_id)]
|
formats = list(self._yield_dms_formats(api_data, video_id))
|
||||||
if not formats:
|
if not formats:
|
||||||
fail_msg = clean_html(self._html_search_regex(
|
fail_msg = clean_html(self._html_search_regex(
|
||||||
r'<p[^>]+\bclass="fail-message"[^>]*>(?P<msg>.+?)</p>',
|
r'<p[^>]+\bclass="fail-message"[^>]*>(?P<msg>.+?)</p>',
|
||||||
@@ -920,7 +774,7 @@ def _real_extract(self, url):
|
|||||||
return self.playlist_result(self._entries(list_id), list_id)
|
return self.playlist_result(self._entries(list_id), list_id)
|
||||||
|
|
||||||
|
|
||||||
class NiconicoLiveIE(InfoExtractor):
|
class NiconicoLiveIE(NiconicoBaseIE):
|
||||||
IE_NAME = 'niconico:live'
|
IE_NAME = 'niconico:live'
|
||||||
IE_DESC = 'ニコニコ生放送'
|
IE_DESC = 'ニコニコ生放送'
|
||||||
_VALID_URL = r'https?://(?:sp\.)?live2?\.nicovideo\.jp/(?:watch|gate)/(?P<id>lv\d+)'
|
_VALID_URL = r'https?://(?:sp\.)?live2?\.nicovideo\.jp/(?:watch|gate)/(?P<id>lv\d+)'
|
||||||
@@ -952,47 +806,41 @@ class NiconicoLiveIE(InfoExtractor):
|
|||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
_KNOWN_LATENCY = ('high', 'low')
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
webpage, urlh = self._download_webpage_handle(f'https://live.nicovideo.jp/watch/{video_id}', video_id)
|
webpage = self._download_webpage(url, video_id, expected_status=404)
|
||||||
|
if err_msg := traverse_obj(webpage, ({find_element(cls='message')}, {clean_html})):
|
||||||
|
raise ExtractorError(err_msg, expected=True)
|
||||||
|
|
||||||
embedded_data = self._parse_json(unescapeHTML(self._search_regex(
|
embedded_data = traverse_obj(webpage, (
|
||||||
r'<script\s+id="embedded-data"\s*data-props="(.+?)"', webpage, 'embedded data')), video_id)
|
{find_element(tag='script', id='embedded-data', html=True)},
|
||||||
|
{extract_attributes}, 'data-props', {json.loads}))
|
||||||
ws_url = traverse_obj(embedded_data, ('site', 'relive', 'webSocketUrl'))
|
frontend_id = traverse_obj(embedded_data, ('site', 'frontendId', {str_or_none}), default='9')
|
||||||
if not ws_url:
|
|
||||||
raise ExtractorError('The live hasn\'t started yet or already ended.', expected=True)
|
|
||||||
ws_url = update_url_query(ws_url, {
|
|
||||||
'frontend_id': traverse_obj(embedded_data, ('site', 'frontendId')) or '9',
|
|
||||||
})
|
|
||||||
|
|
||||||
hostname = remove_start(urllib.parse.urlparse(urlh.url).hostname, 'sp.')
|
|
||||||
latency = try_get(self._configuration_arg('latency'), lambda x: x[0])
|
|
||||||
if latency not in self._KNOWN_LATENCY:
|
|
||||||
latency = 'high'
|
|
||||||
|
|
||||||
|
ws_url = traverse_obj(embedded_data, (
|
||||||
|
'site', 'relive', 'webSocketUrl', {url_or_none}, {require('websocket URL')}))
|
||||||
|
ws_url = update_url_query(ws_url, {'frontend_id': frontend_id})
|
||||||
ws = self._request_webpage(
|
ws = self._request_webpage(
|
||||||
Request(ws_url, headers={'Origin': f'https://{hostname}'}),
|
ws_url, video_id, 'Connecting to WebSocket server',
|
||||||
video_id=video_id, note='Connecting to WebSocket server')
|
headers={'Origin': 'https://live.nicovideo.jp'})
|
||||||
|
|
||||||
self.write_debug('[debug] Sending HLS server request')
|
self.write_debug('Sending HLS server request')
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'type': 'startWatching',
|
|
||||||
'data': {
|
'data': {
|
||||||
'stream': {
|
|
||||||
'quality': 'abr',
|
|
||||||
'protocol': 'hls+fmp4',
|
|
||||||
'latency': latency,
|
|
||||||
'chasePlay': False,
|
|
||||||
},
|
|
||||||
'room': {
|
|
||||||
'protocol': 'webSocket',
|
|
||||||
'commentable': True,
|
|
||||||
},
|
|
||||||
'reconnect': False,
|
'reconnect': False,
|
||||||
|
'room': {
|
||||||
|
'commentable': True,
|
||||||
|
'protocol': 'webSocket',
|
||||||
},
|
},
|
||||||
|
'stream': {
|
||||||
|
'accessRightMethod': 'single_cookie',
|
||||||
|
'chasePlay': False,
|
||||||
|
'latency': 'high',
|
||||||
|
'protocol': 'hls',
|
||||||
|
'quality': 'abr',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'type': 'startWatching',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -1005,23 +853,22 @@ def _real_extract(self, url):
|
|||||||
if data.get('type') == 'stream':
|
if data.get('type') == 'stream':
|
||||||
m3u8_url = data['data']['uri']
|
m3u8_url = data['data']['uri']
|
||||||
qualities = data['data']['availableQualities']
|
qualities = data['data']['availableQualities']
|
||||||
|
cookies = data['data']['cookies']
|
||||||
break
|
break
|
||||||
elif data.get('type') == 'disconnect':
|
elif data.get('type') == 'disconnect':
|
||||||
self.write_debug(recv)
|
self.write_debug(recv)
|
||||||
raise ExtractorError('Disconnected at middle of extraction')
|
raise ExtractorError('Disconnected at middle of extraction')
|
||||||
elif data.get('type') == 'error':
|
elif data.get('type') == 'error':
|
||||||
self.write_debug(recv)
|
self.write_debug(recv)
|
||||||
message = traverse_obj(data, ('body', 'code')) or recv
|
message = traverse_obj(data, ('body', 'code', {str_or_none}), default=recv)
|
||||||
raise ExtractorError(message)
|
raise ExtractorError(message)
|
||||||
elif self.get_param('verbose', False):
|
elif self.get_param('verbose', False):
|
||||||
if len(recv) > 100:
|
self.write_debug(f'Server response: {truncate_string(recv, 100)}')
|
||||||
recv = recv[:100] + '...'
|
|
||||||
self.write_debug(f'Server said: {recv}')
|
|
||||||
|
|
||||||
title = traverse_obj(embedded_data, ('program', 'title')) or self._html_search_meta(
|
title = traverse_obj(embedded_data, ('program', 'title')) or self._html_search_meta(
|
||||||
('og:title', 'twitter:title'), webpage, 'live title', fatal=False)
|
('og:title', 'twitter:title'), webpage, 'live title', fatal=False)
|
||||||
|
|
||||||
raw_thumbs = traverse_obj(embedded_data, ('program', 'thumbnail')) or {}
|
raw_thumbs = traverse_obj(embedded_data, ('program', 'thumbnail', {dict})) or {}
|
||||||
thumbnails = []
|
thumbnails = []
|
||||||
for name, value in raw_thumbs.items():
|
for name, value in raw_thumbs.items():
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
@@ -1043,20 +890,35 @@ def _real_extract(self, url):
|
|||||||
**res,
|
**res,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for cookie in cookies:
|
||||||
|
self._set_cookie(
|
||||||
|
cookie['domain'], cookie['name'], cookie['value'],
|
||||||
|
expire_time=unified_timestamp(cookie.get('expires')), path=cookie['path'], secure=cookie['secure'])
|
||||||
|
|
||||||
|
q_iter = (q for q in qualities[1:] if not q.startswith('audio_')) # ignore initial 'abr'
|
||||||
|
a_map = {96: 'audio_low', 192: 'audio_high'}
|
||||||
|
|
||||||
formats = self._extract_m3u8_formats(m3u8_url, video_id, ext='mp4', live=True)
|
formats = self._extract_m3u8_formats(m3u8_url, video_id, ext='mp4', live=True)
|
||||||
for fmt, q in zip(formats, reversed(qualities[1:])):
|
for fmt in formats:
|
||||||
|
fmt['protocol'] = 'niconico_live'
|
||||||
|
if fmt.get('acodec') == 'none':
|
||||||
|
fmt['format_id'] = next(q_iter, fmt['format_id'])
|
||||||
|
elif fmt.get('vcodec') == 'none':
|
||||||
|
abr = parse_bitrate(fmt['url'].lower())
|
||||||
fmt.update({
|
fmt.update({
|
||||||
'format_id': q,
|
'abr': abr,
|
||||||
'protocol': 'niconico_live',
|
'acodec': 'mp4a.40.2',
|
||||||
'ws': ws,
|
'format_id': a_map.get(abr, fmt['format_id']),
|
||||||
'video_id': video_id,
|
|
||||||
'live_latency': latency,
|
|
||||||
'origin': hostname,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': title,
|
'title': title,
|
||||||
|
'downloader_options': {
|
||||||
|
'max_quality': traverse_obj(embedded_data, ('program', 'stream', 'maxQuality', {str})) or 'normal',
|
||||||
|
'ws': ws,
|
||||||
|
'ws_url': ws_url,
|
||||||
|
},
|
||||||
**traverse_obj(embedded_data, {
|
**traverse_obj(embedded_data, {
|
||||||
'view_count': ('program', 'statistics', 'watchCount'),
|
'view_count': ('program', 'statistics', 'watchCount'),
|
||||||
'comment_count': ('program', 'statistics', 'commentCount'),
|
'comment_count': ('program', 'statistics', 'commentCount'),
|
||||||
|
|||||||
@@ -1,59 +1,57 @@
|
|||||||
from .common import InfoExtractor
|
from .common import InfoExtractor
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
determine_ext,
|
UnsupportedError,
|
||||||
get_element_by_attribute,
|
clean_html,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
js_to_json,
|
parse_duration,
|
||||||
mimetype2ext,
|
parse_qs,
|
||||||
update_url_query,
|
str_or_none,
|
||||||
|
update_url,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import find_element, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class NobelPrizeIE(InfoExtractor):
|
class NobelPrizeIE(InfoExtractor):
|
||||||
_WORKING = False
|
_VALID_URL = r'https?://(?:(?:mediaplayer|www)\.)?nobelprize\.org/mediaplayer/'
|
||||||
_VALID_URL = r'https?://(?:www\.)?nobelprize\.org/mediaplayer.*?\bid=(?P<id>\d+)'
|
_TESTS = [{
|
||||||
_TEST = {
|
'url': 'https://www.nobelprize.org/mediaplayer/?id=2636',
|
||||||
'url': 'http://www.nobelprize.org/mediaplayer/?id=2636',
|
|
||||||
'md5': '04c81e5714bb36cc4e2232fee1d8157f',
|
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '2636',
|
'id': '2636',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Announcement of the 2016 Nobel Prize in Physics',
|
'title': 'Announcement of the 2016 Nobel Prize in Physics',
|
||||||
'description': 'md5:05beba57f4f5a4bbd4cf2ef28fcff739',
|
'description': 'md5:1a2d8a6ca80c88fb3b9a326e0b0e8e43',
|
||||||
|
'duration': 1560.0,
|
||||||
|
'thumbnail': r're:https?://www\.nobelprize\.org/images/.+\.jpg',
|
||||||
|
'timestamp': 1504883793,
|
||||||
|
'upload_date': '20170908',
|
||||||
},
|
},
|
||||||
}
|
}, {
|
||||||
|
'url': 'https://mediaplayer.nobelprize.org/mediaplayer/?qid=12693',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '12693',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'Nobel Lecture by Peter Higgs',
|
||||||
|
'description': 'md5:9b12e275dbe3a8138484e70e00673a05',
|
||||||
|
'duration': 1800.0,
|
||||||
|
'thumbnail': r're:https?://www\.nobelprize\.org/images/.+\.jpg',
|
||||||
|
'timestamp': 1504883793,
|
||||||
|
'upload_date': '20170908',
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
video_id = self._match_id(url)
|
video_id = traverse_obj(parse_qs(url), (
|
||||||
webpage = self._download_webpage(url, video_id)
|
('id', 'qid'), -1, {int_or_none}, {str_or_none}, any))
|
||||||
media = self._parse_json(self._search_regex(
|
if not video_id:
|
||||||
r'(?s)var\s*config\s*=\s*({.+?});', webpage,
|
raise UnsupportedError(url)
|
||||||
'config'), video_id, js_to_json)['media']
|
webpage = self._download_webpage(
|
||||||
title = media['title']
|
update_url(url, netloc='mediaplayer.nobelprize.org'), video_id)
|
||||||
|
|
||||||
formats = []
|
|
||||||
for source in media.get('source', []):
|
|
||||||
source_src = source.get('src')
|
|
||||||
if not source_src:
|
|
||||||
continue
|
|
||||||
ext = mimetype2ext(source.get('type')) or determine_ext(source_src)
|
|
||||||
if ext == 'm3u8':
|
|
||||||
formats.extend(self._extract_m3u8_formats(
|
|
||||||
source_src, video_id, 'mp4', 'm3u8_native',
|
|
||||||
m3u8_id='hls', fatal=False))
|
|
||||||
elif ext == 'f4m':
|
|
||||||
formats.extend(self._extract_f4m_formats(
|
|
||||||
update_url_query(source_src, {'hdcore': '3.7.0'}),
|
|
||||||
video_id, f4m_id='hds', fatal=False))
|
|
||||||
else:
|
|
||||||
formats.append({
|
|
||||||
'url': source_src,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
**self._search_json_ld(webpage, video_id),
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': title,
|
'title': self._html_search_meta('caption', webpage),
|
||||||
'description': get_element_by_attribute('itemprop', 'description', webpage),
|
'description': traverse_obj(webpage, (
|
||||||
'duration': int_or_none(media.get('duration')),
|
{find_element(tag='span', attr='itemprop', value='description')}, {clean_html})),
|
||||||
'formats': formats,
|
'duration': parse_duration(self._html_search_meta('duration', webpage)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,82 @@
|
|||||||
from .common import InfoExtractor
|
from .streaks import StreaksBaseIE
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
int_or_none,
|
||||||
smuggle_url,
|
parse_iso8601,
|
||||||
traverse_obj,
|
str_or_none,
|
||||||
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class NTVCoJpCUIE(InfoExtractor):
|
class NTVCoJpCUIE(StreaksBaseIE):
|
||||||
IE_NAME = 'cu.ntv.co.jp'
|
IE_NAME = 'cu.ntv.co.jp'
|
||||||
IE_DESC = 'Nippon Television Network'
|
IE_DESC = '日テレ無料TADA!'
|
||||||
_VALID_URL = r'https?://cu\.ntv\.co\.jp/(?!program)(?P<id>[^/?&#]+)'
|
_VALID_URL = r'https?://cu\.ntv\.co\.jp/(?!program-list|search)(?P<id>[\w-]+)/?(?:[?#]|$)'
|
||||||
_TEST = {
|
_TESTS = [{
|
||||||
'url': 'https://cu.ntv.co.jp/televiva-chill-gohan_181031/',
|
'url': 'https://cu.ntv.co.jp/gaki_20250525/',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '5978891207001',
|
'id': 'gaki_20250525',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': '桜エビと炒り卵がポイント! 「中華風 エビチリおにぎり」──『美虎』五十嵐美幸',
|
'title': '放送開始36年!方正ココリコが選ぶ神回&地獄回!',
|
||||||
'upload_date': '20181213',
|
'cast': 'count:2',
|
||||||
'description': 'md5:1985b51a9abc285df0104d982a325f2a',
|
'description': 'md5:1e1db556224d627d4d2f74370c650927',
|
||||||
'uploader_id': '3855502814001',
|
'display_id': 'ref:gaki_20250525',
|
||||||
'timestamp': 1544669941,
|
'duration': 1450,
|
||||||
|
'episode': '放送開始36年!方正ココリコが選ぶ神回&地獄回!',
|
||||||
|
'episode_id': '000000010172808',
|
||||||
|
'episode_number': 255,
|
||||||
|
'genres': ['variety'],
|
||||||
|
'live_status': 'not_live',
|
||||||
|
'modified_date': '20250525',
|
||||||
|
'modified_timestamp': 1748145537,
|
||||||
|
'release_date': '20250525',
|
||||||
|
'release_timestamp': 1748145539,
|
||||||
|
'series': 'ダウンタウンのガキの使いやあらへんで!',
|
||||||
|
'series_id': 'gaki',
|
||||||
|
'thumbnail': r're:https?://.+\.jpg',
|
||||||
|
'timestamp': 1748145197,
|
||||||
|
'upload_date': '20250525',
|
||||||
|
'uploader': '日本テレビ放送網',
|
||||||
|
'uploader_id': '0x7FE2',
|
||||||
},
|
},
|
||||||
'params': {
|
}]
|
||||||
# m3u8 download
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
BRIGHTCOVE_URL_TEMPLATE = 'http://players.brightcove.net/%s/default_default/index.html?videoId=%s'
|
|
||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
display_id = self._match_id(url)
|
display_id = self._match_id(url)
|
||||||
webpage = self._download_webpage(url, display_id)
|
webpage = self._download_webpage(url, display_id)
|
||||||
player_config = self._search_nuxt_data(webpage, display_id)
|
|
||||||
video_id = traverse_obj(player_config, ('movie', 'video_id'))
|
info = self._search_json(
|
||||||
if not video_id:
|
r'window\.app\s*=', webpage, 'video info',
|
||||||
raise ExtractorError('Failed to extract video ID for Brightcove')
|
display_id)['falcorCache']['catalog']['episode'][display_id]['value']
|
||||||
account_id = traverse_obj(player_config, ('player', 'account')) or '3855502814001'
|
media_id = traverse_obj(info, (
|
||||||
title = traverse_obj(player_config, ('movie', 'name'))
|
'streaks_data', 'mediaid', {str_or_none}, {require('Streaks media ID')}))
|
||||||
if not title:
|
non_phonetic = (lambda _, v: v['is_phonetic'] is False, 'value', {str})
|
||||||
og_title = self._og_search_title(webpage, fatal=False) or traverse_obj(player_config, ('player', 'title'))
|
|
||||||
if og_title:
|
|
||||||
title = og_title.split('(', 1)[0].strip()
|
|
||||||
description = (traverse_obj(player_config, ('movie', 'description'))
|
|
||||||
or self._html_search_meta(['description', 'og:description'], webpage))
|
|
||||||
return {
|
return {
|
||||||
'_type': 'url_transparent',
|
**self._extract_from_streaks_api('ntv-tada', media_id, headers={
|
||||||
'id': video_id,
|
'X-Streaks-Api-Key': 'df497719056b44059a0483b8faad1f4a',
|
||||||
'display_id': display_id,
|
}),
|
||||||
'title': title,
|
**traverse_obj(info, {
|
||||||
'description': description,
|
'id': ('content_id', {str_or_none}),
|
||||||
'url': smuggle_url(self.BRIGHTCOVE_URL_TEMPLATE % (account_id, video_id), {'geo_countries': ['JP']}),
|
'title': ('title', *non_phonetic, any),
|
||||||
'ie_key': 'BrightcoveNew',
|
'age_limit': ('is_adult_only_content', {lambda x: 18 if x else None}),
|
||||||
|
'cast': ('credit', ..., 'name', *non_phonetic),
|
||||||
|
'genres': ('genre', ..., {str}),
|
||||||
|
'release_timestamp': ('pub_date', {parse_iso8601}),
|
||||||
|
'tags': ('tags', ..., {str}),
|
||||||
|
'thumbnail': ('artwork', ..., 'url', any, {url_or_none}),
|
||||||
|
}),
|
||||||
|
**traverse_obj(info, ('tv_episode_info', {
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'episode_number': ('episode_number', {int}),
|
||||||
|
'series': ('parent_show_title', *non_phonetic, any),
|
||||||
|
'series_id': ('show_content_id', {str}),
|
||||||
|
})),
|
||||||
|
**traverse_obj(info, ('custom_data', {
|
||||||
|
'description': ('program_detail', {str}),
|
||||||
|
'episode': ('episode_title', {str}),
|
||||||
|
'episode_id': ('episode_id', {str_or_none}),
|
||||||
|
'uploader': ('network_name', {str}),
|
||||||
|
'uploader_id': ('network_id', {str}),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ class NYTimesArticleIE(NYTimesBaseIE):
|
|||||||
'thumbnail': r're:https?://\w+\.nyt.com/images/.*\.jpg',
|
'thumbnail': r're:https?://\w+\.nyt.com/images/.*\.jpg',
|
||||||
'duration': 119.0,
|
'duration': 119.0,
|
||||||
},
|
},
|
||||||
|
'skip': 'HTTP Error 500: Internal Server Error',
|
||||||
}, {
|
}, {
|
||||||
# article with audio and no video
|
# article with audio and no video
|
||||||
'url': 'https://www.nytimes.com/2023/09/29/health/mosquitoes-genetic-engineering.html',
|
'url': 'https://www.nytimes.com/2023/09/29/health/mosquitoes-genetic-engineering.html',
|
||||||
@@ -190,13 +191,14 @@ class NYTimesArticleIE(NYTimesBaseIE):
|
|||||||
'ext': 'mp3',
|
'ext': 'mp3',
|
||||||
'title': 'The Gamble: Can Genetically Modified Mosquitoes End Disease?',
|
'title': 'The Gamble: Can Genetically Modified Mosquitoes End Disease?',
|
||||||
'description': 'md5:9ff8b47acbaf7f3ca8c732f5c815be2e',
|
'description': 'md5:9ff8b47acbaf7f3ca8c732f5c815be2e',
|
||||||
'timestamp': 1695960700,
|
'timestamp': 1696008129,
|
||||||
'upload_date': '20230929',
|
'upload_date': '20230929',
|
||||||
'creator': 'Stephanie Nolen, Natalija Gormalova',
|
'creators': ['Stephanie Nolen', 'Natalija Gormalova'],
|
||||||
'thumbnail': r're:https?://\w+\.nyt.com/images/.*\.jpg',
|
'thumbnail': r're:https?://\w+\.nyt.com/images/.*\.jpg',
|
||||||
'duration': 1322,
|
'duration': 1322,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
|
# lede_media_block already has sourceId
|
||||||
'url': 'https://www.nytimes.com/2023/11/29/business/dealbook/kamala-harris-biden-voters.html',
|
'url': 'https://www.nytimes.com/2023/11/29/business/dealbook/kamala-harris-biden-voters.html',
|
||||||
'md5': '3eb5ddb1d6f86254fe4f233826778737',
|
'md5': '3eb5ddb1d6f86254fe4f233826778737',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -207,7 +209,7 @@ class NYTimesArticleIE(NYTimesBaseIE):
|
|||||||
'timestamp': 1701290997,
|
'timestamp': 1701290997,
|
||||||
'upload_date': '20231129',
|
'upload_date': '20231129',
|
||||||
'uploader': 'By The New York Times',
|
'uploader': 'By The New York Times',
|
||||||
'creator': 'Katie Rogers',
|
'creators': ['Katie Rogers'],
|
||||||
'thumbnail': r're:https?://\w+\.nyt.com/images/.*\.jpg',
|
'thumbnail': r're:https?://\w+\.nyt.com/images/.*\.jpg',
|
||||||
'duration': 97.631,
|
'duration': 97.631,
|
||||||
},
|
},
|
||||||
@@ -222,10 +224,22 @@ class NYTimesArticleIE(NYTimesBaseIE):
|
|||||||
'title': 'Drunk and Asleep on the Job: Air Traffic Controllers Pushed to the Brink',
|
'title': 'Drunk and Asleep on the Job: Air Traffic Controllers Pushed to the Brink',
|
||||||
'description': 'md5:549e5a5e935bf7d048be53ba3d2c863d',
|
'description': 'md5:549e5a5e935bf7d048be53ba3d2c863d',
|
||||||
'upload_date': '20231202',
|
'upload_date': '20231202',
|
||||||
'creator': 'Emily Steel, Sydney Ember',
|
'creators': ['Emily Steel', 'Sydney Ember'],
|
||||||
'timestamp': 1701511264,
|
'timestamp': 1701511264,
|
||||||
},
|
},
|
||||||
'playlist_count': 3,
|
'playlist_count': 3,
|
||||||
|
}, {
|
||||||
|
# lede_media_block does not have sourceId
|
||||||
|
'url': 'https://www.nytimes.com/2025/04/30/well/move/hip-mobility-routine.html',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'hip-mobility-routine',
|
||||||
|
'title': 'Tight Hips? These Moves Can Help.',
|
||||||
|
'description': 'Sitting all day is hard on your hips. Try this simple routine for better mobility.',
|
||||||
|
'creators': ['Alyssa Ages', 'Theodore Tae'],
|
||||||
|
'timestamp': 1746003629,
|
||||||
|
'upload_date': '20250430',
|
||||||
|
},
|
||||||
|
'playlist_count': 7,
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.nytimes.com/2023/12/02/business/media/netflix-squid-game-challenge.html',
|
'url': 'https://www.nytimes.com/2023/12/02/business/media/netflix-squid-game-challenge.html',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
@@ -256,14 +270,18 @@ def _extract_content_from_block(self, block):
|
|||||||
|
|
||||||
def _real_extract(self, url):
|
def _real_extract(self, url):
|
||||||
page_id = self._match_id(url)
|
page_id = self._match_id(url)
|
||||||
webpage = self._download_webpage(url, page_id)
|
webpage = self._download_webpage(url, page_id, impersonate=True)
|
||||||
art_json = self._search_json(
|
art_json = self._search_json(
|
||||||
r'window\.__preloadedData\s*=', webpage, 'media details', page_id,
|
r'window\.__preloadedData\s*=', webpage, 'media details', page_id,
|
||||||
transform_source=lambda x: x.replace('undefined', 'null'))['initialData']['data']['article']
|
transform_source=lambda x: x.replace('undefined', 'null'))['initialData']['data']['article']
|
||||||
|
content = art_json['sprinkledBody']['content']
|
||||||
|
|
||||||
blocks = traverse_obj(art_json, (
|
blocks = []
|
||||||
'sprinkledBody', 'content', ..., ('ledeMedia', None),
|
block_filter = lambda k, v: k == 'media' and v['__typename'] in ('Video', 'Audio')
|
||||||
lambda _, v: v['__typename'] in ('Video', 'Audio')))
|
if lede_media_block := traverse_obj(content, (..., 'ledeMedia', block_filter, any)):
|
||||||
|
lede_media_block.setdefault('sourceId', art_json.get('sourceId'))
|
||||||
|
blocks.append(lede_media_block)
|
||||||
|
blocks.extend(traverse_obj(content, (..., block_filter)))
|
||||||
if not blocks:
|
if not blocks:
|
||||||
raise ExtractorError('Unable to extract any media blocks from webpage')
|
raise ExtractorError('Unable to extract any media blocks from webpage')
|
||||||
|
|
||||||
@@ -273,8 +291,7 @@ def _real_extract(self, url):
|
|||||||
'sprinkledBody', 'content', ..., 'summary', 'content', ..., 'text', {str}),
|
'sprinkledBody', 'content', ..., 'summary', 'content', ..., 'text', {str}),
|
||||||
get_all=False) or self._html_search_meta(['og:description', 'twitter:description'], webpage),
|
get_all=False) or self._html_search_meta(['og:description', 'twitter:description'], webpage),
|
||||||
'timestamp': traverse_obj(art_json, ('firstPublished', {parse_iso8601})),
|
'timestamp': traverse_obj(art_json, ('firstPublished', {parse_iso8601})),
|
||||||
'creator': ', '.join(
|
'creators': traverse_obj(art_json, ('bylines', ..., 'creators', ..., 'displayName', {str})),
|
||||||
traverse_obj(art_json, ('bylines', ..., 'creators', ..., 'displayName'))), # TODO: change to 'creators' (list)
|
|
||||||
'thumbnails': self._extract_thumbnails(traverse_obj(
|
'thumbnails': self._extract_thumbnails(traverse_obj(
|
||||||
art_json, ('promotionalMedia', 'assetCrops', ..., 'renditions', ...))),
|
art_json, ('promotionalMedia', 'assetCrops', ..., 'renditions', ...))),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ def _extract_desktop(self, url):
|
|||||||
return self._extract_desktop(smuggle_url(url, {'referrer': 'https://boosty.to'}))
|
return self._extract_desktop(smuggle_url(url, {'referrer': 'https://boosty.to'}))
|
||||||
elif error:
|
elif error:
|
||||||
raise ExtractorError(error, expected=True)
|
raise ExtractorError(error, expected=True)
|
||||||
|
elif '>Access to this video is restricted</div>' in webpage:
|
||||||
|
self.raise_login_required()
|
||||||
|
|
||||||
player = self._parse_json(
|
player = self._parse_json(
|
||||||
unescapeHTML(self._search_regex(
|
unescapeHTML(self._search_regex(
|
||||||
@@ -429,7 +431,7 @@ def _extract_mobile(self, url):
|
|||||||
video_id = self._match_id(url)
|
video_id = self._match_id(url)
|
||||||
|
|
||||||
webpage = self._download_webpage(
|
webpage = self._download_webpage(
|
||||||
f'http://m.ok.ru/video/{video_id}', video_id,
|
f'https://m.ok.ru/video/{video_id}', video_id,
|
||||||
note='Downloading mobile webpage')
|
note='Downloading mobile webpage')
|
||||||
|
|
||||||
error = self._search_regex(
|
error = self._search_regex(
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from .common import InfoExtractor
|
|
||||||
|
|
||||||
|
|
||||||
class OnceIE(InfoExtractor): # XXX: Conventionally, base classes should end with BaseIE/InfoExtractor
|
|
||||||
_VALID_URL = r'https?://.+?\.unicornmedia\.com/now/(?:ads/vmap/)?[^/]+/[^/]+/(?P<domain_id>[^/]+)/(?P<application_id>[^/]+)/(?:[^/]+/)?(?P<media_item_id>[^/]+)/content\.(?:once|m3u8|mp4)'
|
|
||||||
ADAPTIVE_URL_TEMPLATE = 'http://once.unicornmedia.com/now/master/playlist/%s/%s/%s/content.m3u8'
|
|
||||||
PROGRESSIVE_URL_TEMPLATE = 'http://once.unicornmedia.com/now/media/progressive/%s/%s/%s/%s/content.mp4'
|
|
||||||
|
|
||||||
def _extract_once_formats(self, url, http_formats_preference=None):
|
|
||||||
domain_id, application_id, media_item_id = re.match(
|
|
||||||
OnceIE._VALID_URL, url).groups()
|
|
||||||
formats = self._extract_m3u8_formats(
|
|
||||||
self.ADAPTIVE_URL_TEMPLATE % (
|
|
||||||
domain_id, application_id, media_item_id),
|
|
||||||
media_item_id, 'mp4', m3u8_id='hls', fatal=False)
|
|
||||||
progressive_formats = []
|
|
||||||
for adaptive_format in formats:
|
|
||||||
# Prevent advertisement from embedding into m3u8 playlist (see
|
|
||||||
# https://github.com/ytdl-org/youtube-dl/issues/8893#issuecomment-199912684)
|
|
||||||
adaptive_format['url'] = re.sub(
|
|
||||||
r'\badsegmentlength=\d+', r'adsegmentlength=0', adaptive_format['url'])
|
|
||||||
rendition_id = self._search_regex(
|
|
||||||
r'/now/media/playlist/[^/]+/[^/]+/([^/]+)',
|
|
||||||
adaptive_format['url'], 'redition id', default=None)
|
|
||||||
if rendition_id:
|
|
||||||
progressive_format = adaptive_format.copy()
|
|
||||||
progressive_format.update({
|
|
||||||
'url': self.PROGRESSIVE_URL_TEMPLATE % (
|
|
||||||
domain_id, application_id, rendition_id, media_item_id),
|
|
||||||
'format_id': adaptive_format['format_id'].replace(
|
|
||||||
'hls', 'http'),
|
|
||||||
'protocol': 'http',
|
|
||||||
'preference': http_formats_preference,
|
|
||||||
})
|
|
||||||
progressive_formats.append(progressive_format)
|
|
||||||
self._check_formats(progressive_formats, media_item_id)
|
|
||||||
formats.extend(progressive_formats)
|
|
||||||
return formats
|
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
int_or_none,
|
int_or_none,
|
||||||
parse_qs,
|
parse_qs,
|
||||||
srt_subtitles_timecode,
|
srt_subtitles_timecode,
|
||||||
traverse_obj,
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class PanoptoBaseIE(InfoExtractor):
|
class PanoptoBaseIE(InfoExtractor):
|
||||||
@@ -345,21 +346,16 @@ def _extract_streams_formats_and_subtitles(self, video_id, streams, **fmt_kwargs
|
|||||||
subtitles = {}
|
subtitles = {}
|
||||||
for stream in streams or []:
|
for stream in streams or []:
|
||||||
stream_formats = []
|
stream_formats = []
|
||||||
http_stream_url = stream.get('StreamHttpUrl')
|
for stream_url in set(traverse_obj(stream, (('StreamHttpUrl', 'StreamUrl'), {url_or_none}))):
|
||||||
stream_url = stream.get('StreamUrl')
|
|
||||||
|
|
||||||
if http_stream_url:
|
|
||||||
stream_formats.append({'url': http_stream_url})
|
|
||||||
|
|
||||||
if stream_url:
|
|
||||||
media_type = stream.get('ViewerMediaFileTypeName')
|
media_type = stream.get('ViewerMediaFileTypeName')
|
||||||
if media_type in ('hls', ):
|
if media_type in ('hls', ):
|
||||||
m3u8_formats, stream_subtitles = self._extract_m3u8_formats_and_subtitles(stream_url, video_id)
|
fmts, subs = self._extract_m3u8_formats_and_subtitles(stream_url, video_id, m3u8_id='hls', fatal=False)
|
||||||
stream_formats.extend(m3u8_formats)
|
stream_formats.extend(fmts)
|
||||||
subtitles = self._merge_subtitles(subtitles, stream_subtitles)
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
else:
|
else:
|
||||||
stream_formats.append({
|
stream_formats.append({
|
||||||
'url': stream_url,
|
'url': stream_url,
|
||||||
|
'ext': media_type,
|
||||||
})
|
})
|
||||||
for fmt in stream_formats:
|
for fmt in stream_formats:
|
||||||
fmt.update({
|
fmt.update({
|
||||||
|
|||||||
101
yt_dlp/extractor/parti.py
Normal file
101
yt_dlp/extractor/parti.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from .common import InfoExtractor
|
||||||
|
from ..utils import UserNotLive, int_or_none, parse_iso8601, url_or_none, urljoin
|
||||||
|
from ..utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
|
class PartiBaseIE(InfoExtractor):
|
||||||
|
def _call_api(self, path, video_id, note=None):
|
||||||
|
return self._download_json(
|
||||||
|
f'https://api-backend.parti.com/parti_v2/profile/{path}', video_id, note)
|
||||||
|
|
||||||
|
|
||||||
|
class PartiVideoIE(PartiBaseIE):
|
||||||
|
IE_NAME = 'parti:video'
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?parti\.com/video/(?P<id>\d+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://parti.com/video/66284',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '66284',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'NOW LIVE ',
|
||||||
|
'upload_date': '20250327',
|
||||||
|
'categories': ['Gaming'],
|
||||||
|
'thumbnail': 'https://assets.parti.com/351424_eb9e5250-2821-484a-9c5f-ca99aa666c87.png',
|
||||||
|
'channel': 'ItZTMGG',
|
||||||
|
'timestamp': 1743044379,
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
video_id = self._match_id(url)
|
||||||
|
data = self._call_api(f'get_livestream_channel_info/recent/{video_id}', video_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': video_id,
|
||||||
|
'formats': self._extract_m3u8_formats(
|
||||||
|
urljoin('https://watch.parti.com', data['livestream_recording']), video_id, 'mp4'),
|
||||||
|
**traverse_obj(data, {
|
||||||
|
'title': ('event_title', {str}),
|
||||||
|
'channel': ('user_name', {str}),
|
||||||
|
'thumbnail': ('event_file', {url_or_none}),
|
||||||
|
'categories': ('category_name', {str}, filter, all),
|
||||||
|
'timestamp': ('event_start_ts', {int_or_none}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PartiLivestreamIE(PartiBaseIE):
|
||||||
|
IE_NAME = 'parti:livestream'
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?parti\.com/creator/(?P<service>[\w]+)/(?P<id>[\w/-]+)'
|
||||||
|
_TESTS = [{
|
||||||
|
'url': 'https://parti.com/creator/parti/Capt_Robs_Adventures',
|
||||||
|
'info_dict': {
|
||||||
|
'id': 'Capt_Robs_Adventures',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': r"re:I'm Live on Parti \d{4}-\d{2}-\d{2} \d{2}:\d{2}",
|
||||||
|
'view_count': int,
|
||||||
|
'thumbnail': r're:https://assets\.parti\.com/.+\.png',
|
||||||
|
'timestamp': 1743879776,
|
||||||
|
'upload_date': '20250405',
|
||||||
|
'live_status': 'is_live',
|
||||||
|
},
|
||||||
|
'params': {'skip_download': 'm3u8'},
|
||||||
|
}, {
|
||||||
|
'url': 'https://parti.com/creator/discord/sazboxgaming/0',
|
||||||
|
'only_matching': True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _real_extract(self, url):
|
||||||
|
service, creator_slug = self._match_valid_url(url).group('service', 'id')
|
||||||
|
|
||||||
|
encoded_creator_slug = creator_slug.replace('/', '%23')
|
||||||
|
creator_id = self._call_api(
|
||||||
|
f'get_user_by_social_media/{service}/{encoded_creator_slug}',
|
||||||
|
creator_slug, note='Fetching user ID')
|
||||||
|
|
||||||
|
data = self._call_api(
|
||||||
|
f'get_livestream_channel_info/{creator_id}', creator_id,
|
||||||
|
note='Fetching user profile feed')['channel_info']
|
||||||
|
|
||||||
|
if not traverse_obj(data, ('channel', 'is_live', {bool})):
|
||||||
|
raise UserNotLive(video_id=creator_id)
|
||||||
|
|
||||||
|
channel_info = data['channel']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': creator_slug,
|
||||||
|
'formats': self._extract_m3u8_formats(
|
||||||
|
channel_info['playback_url'], creator_slug, live=True, query={
|
||||||
|
'token': channel_info['playback_auth_token'],
|
||||||
|
'player_version': '1.17.0',
|
||||||
|
}),
|
||||||
|
'is_live': True,
|
||||||
|
**traverse_obj(data, {
|
||||||
|
'title': ('livestream_event_info', 'event_name', {str}),
|
||||||
|
'description': ('livestream_event_info', 'event_description', {str}),
|
||||||
|
'thumbnail': ('livestream_event_info', 'livestream_preview_file', {url_or_none}),
|
||||||
|
'timestamp': ('stream', 'start_time', {parse_iso8601}),
|
||||||
|
'view_count': ('stream', 'viewer_count', {int_or_none}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -340,8 +340,9 @@ def _real_extract(self, url):
|
|||||||
'channel_follower_count': ('attributes', 'patron_count', {int_or_none}),
|
'channel_follower_count': ('attributes', 'patron_count', {int_or_none}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# all-lowercase 'referer' so we can smuggle it to Generic, SproutVideo, Vimeo
|
# Must be all-lowercase 'referer' so we can smuggle it to Generic, SproutVideo, and Vimeo.
|
||||||
headers = {'referer': 'https://patreon.com/'}
|
# patreon.com URLs redirect to www.patreon.com; this matters when requesting mux.com m3u8s
|
||||||
|
headers = {'referer': 'https://www.patreon.com/'}
|
||||||
|
|
||||||
# handle Vimeo embeds
|
# handle Vimeo embeds
|
||||||
if traverse_obj(attributes, ('embed', 'provider')) == 'Vimeo':
|
if traverse_obj(attributes, ('embed', 'provider')) == 'Vimeo':
|
||||||
@@ -352,7 +353,7 @@ def _real_extract(self, url):
|
|||||||
v_url, video_id, 'Checking Vimeo embed URL', headers=headers,
|
v_url, video_id, 'Checking Vimeo embed URL', headers=headers,
|
||||||
fatal=False, errnote=False, expected_status=429): # 429 is TLS fingerprint rejection
|
fatal=False, errnote=False, expected_status=429): # 429 is TLS fingerprint rejection
|
||||||
entries.append(self.url_result(
|
entries.append(self.url_result(
|
||||||
VimeoIE._smuggle_referrer(v_url, 'https://patreon.com/'),
|
VimeoIE._smuggle_referrer(v_url, headers['referer']),
|
||||||
VimeoIE, url_transparent=True))
|
VimeoIE, url_transparent=True))
|
||||||
|
|
||||||
embed_url = traverse_obj(attributes, ('embed', 'url', {url_or_none}))
|
embed_url = traverse_obj(attributes, ('embed', 'url', {url_or_none}))
|
||||||
@@ -379,11 +380,13 @@ def _real_extract(self, url):
|
|||||||
'url': post_file['url'],
|
'url': post_file['url'],
|
||||||
})
|
})
|
||||||
elif name == 'video' or determine_ext(post_file.get('url')) == 'm3u8':
|
elif name == 'video' or determine_ext(post_file.get('url')) == 'm3u8':
|
||||||
formats, subtitles = self._extract_m3u8_formats_and_subtitles(post_file['url'], video_id)
|
formats, subtitles = self._extract_m3u8_formats_and_subtitles(
|
||||||
|
post_file['url'], video_id, headers=headers)
|
||||||
entries.append({
|
entries.append({
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'subtitles': subtitles,
|
'subtitles': subtitles,
|
||||||
|
'http_headers': headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
can_view_post = traverse_obj(attributes, 'current_user_can_view')
|
can_view_post = traverse_obj(attributes, 'current_user_can_view')
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from .youtube import YoutubeIE
|
from .youtube import YoutubeIE
|
||||||
from .zdf import ZDFBaseIE
|
from .zdf import ZDFBaseIE
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
@@ -7,44 +5,27 @@
|
|||||||
merge_dicts,
|
merge_dicts,
|
||||||
try_get,
|
try_get,
|
||||||
unified_timestamp,
|
unified_timestamp,
|
||||||
urljoin,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PhoenixIE(ZDFBaseIE):
|
class PhoenixIE(ZDFBaseIE):
|
||||||
IE_NAME = 'phoenix.de'
|
IE_NAME = 'phoenix.de'
|
||||||
_VALID_URL = r'https?://(?:www\.)?phoenix\.de/(?:[^/]+/)*[^/?#&]*-a-(?P<id>\d+)\.html'
|
_VALID_URL = r'https?://(?:www\.)?phoenix\.de/(?:[^/?#]+/)*[^/?#&]*-a-(?P<id>\d+)\.html'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
# Same as https://www.zdf.de/politik/phoenix-sendungen/wohin-fuehrt-der-protest-in-der-pandemie-100.html
|
'url': 'https://www.phoenix.de/sendungen/dokumentationen/spitzbergen-a-893349.html',
|
||||||
'url': 'https://www.phoenix.de/sendungen/ereignisse/corona-nachgehakt/wohin-fuehrt-der-protest-in-der-pandemie-a-2050630.html',
|
'md5': 'a79e86d9774d0b3f2102aff988a0bd32',
|
||||||
'md5': '34ec321e7eb34231fd88616c65c92db0',
|
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '210222_phx_nachgehakt_corona_protest',
|
'id': '221215_phx_spitzbergen',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Wohin führt der Protest in der Pandemie?',
|
'title': 'Spitzbergen',
|
||||||
'description': 'md5:7d643fe7f565e53a24aac036b2122fbd',
|
'description': 'Film von Tilmann Bünz',
|
||||||
'duration': 1691,
|
'duration': 728.0,
|
||||||
'timestamp': 1613902500,
|
'timestamp': 1555600500,
|
||||||
'upload_date': '20210221',
|
'upload_date': '20190418',
|
||||||
'uploader': 'Phoenix',
|
'uploader': 'Phoenix',
|
||||||
'series': 'corona nachgehakt',
|
'thumbnail': 'https://www.phoenix.de/sixcms/media.php/21/Bergspitzen1.png',
|
||||||
'episode': 'Wohin führt der Protest in der Pandemie?',
|
'series': 'Dokumentationen',
|
||||||
},
|
'episode': 'Spitzbergen',
|
||||||
}, {
|
|
||||||
# Youtube embed
|
|
||||||
'url': 'https://www.phoenix.de/sendungen/gespraeche/phoenix-streitgut-brennglas-corona-a-1965505.html',
|
|
||||||
'info_dict': {
|
|
||||||
'id': 'hMQtqFYjomk',
|
|
||||||
'ext': 'mp4',
|
|
||||||
'title': 'phoenix streitgut: Brennglas Corona - Wie gerecht ist unsere Gesellschaft?',
|
|
||||||
'description': 'md5:ac7a02e2eb3cb17600bc372e4ab28fdd',
|
|
||||||
'duration': 3509,
|
|
||||||
'upload_date': '20201219',
|
|
||||||
'uploader': 'phoenix',
|
|
||||||
'uploader_id': 'phoenix',
|
|
||||||
},
|
|
||||||
'params': {
|
|
||||||
'skip_download': True,
|
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.phoenix.de/entwicklungen-in-russland-a-2044720.html',
|
'url': 'https://www.phoenix.de/entwicklungen-in-russland-a-2044720.html',
|
||||||
@@ -90,8 +71,8 @@ def _real_extract(self, url):
|
|||||||
content_id = details['tracking']['nielsen']['content']['assetid']
|
content_id = details['tracking']['nielsen']['content']['assetid']
|
||||||
|
|
||||||
info = self._extract_ptmd(
|
info = self._extract_ptmd(
|
||||||
f'https://tmd.phoenix.de/tmd/2/ngplayer_2_3/vod/ptmd/phoenix/{content_id}',
|
f'https://tmd.phoenix.de/tmd/2/android_native_6/vod/ptmd/phoenix/{content_id}',
|
||||||
content_id, None, url)
|
content_id)
|
||||||
|
|
||||||
duration = int_or_none(try_get(
|
duration = int_or_none(try_get(
|
||||||
details, lambda x: x['tracking']['nielsen']['content']['length']))
|
details, lambda x: x['tracking']['nielsen']['content']['length']))
|
||||||
@@ -101,20 +82,8 @@ def _real_extract(self, url):
|
|||||||
str)
|
str)
|
||||||
episode = title if details.get('contentType') == 'episode' else None
|
episode = title if details.get('contentType') == 'episode' else None
|
||||||
|
|
||||||
thumbnails = []
|
|
||||||
teaser_images = try_get(details, lambda x: x['teaserImageRef']['layouts'], dict) or {}
|
teaser_images = try_get(details, lambda x: x['teaserImageRef']['layouts'], dict) or {}
|
||||||
for thumbnail_key, thumbnail_url in teaser_images.items():
|
thumbnails = self._extract_thumbnails(teaser_images)
|
||||||
thumbnail_url = urljoin(url, thumbnail_url)
|
|
||||||
if not thumbnail_url:
|
|
||||||
continue
|
|
||||||
thumbnail = {
|
|
||||||
'url': thumbnail_url,
|
|
||||||
}
|
|
||||||
m = re.match('^([0-9]+)x([0-9]+)$', thumbnail_key)
|
|
||||||
if m:
|
|
||||||
thumbnail['width'] = int(m.group(1))
|
|
||||||
thumbnail['height'] = int(m.group(2))
|
|
||||||
thumbnails.append(thumbnail)
|
|
||||||
|
|
||||||
return merge_dicts(info, {
|
return merge_dicts(info, {
|
||||||
'id': content_id,
|
'id': content_id,
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
|
|
||||||
|
|
||||||
class PicartoIE(InfoExtractor):
|
class PicartoIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:www.)?picarto\.tv/(?P<id>[a-zA-Z0-9]+)'
|
IE_NAME = 'picarto'
|
||||||
|
_VALID_URL = r'https?://(?:www.)?picarto\.tv/(?P<id>[^/#?]+)/?(?:$|[?#])'
|
||||||
_TEST = {
|
_TEST = {
|
||||||
'url': 'https://picarto.tv/Setz',
|
'url': 'https://picarto.tv/Setz',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -89,7 +90,8 @@ def _real_extract(self, url):
|
|||||||
|
|
||||||
|
|
||||||
class PicartoVodIE(InfoExtractor):
|
class PicartoVodIE(InfoExtractor):
|
||||||
_VALID_URL = r'https?://(?:www\.)?picarto\.tv/(?:videopopout|\w+/videos)/(?P<id>[^/?#&]+)'
|
IE_NAME = 'picarto:vod'
|
||||||
|
_VALID_URL = r'https?://(?:www\.)?picarto\.tv/(?:videopopout|\w+(?:/profile)?/videos)/(?P<id>[^/?#&]+)'
|
||||||
_TESTS = [{
|
_TESTS = [{
|
||||||
'url': 'https://picarto.tv/videopopout/ArtofZod_2017.12.12.00.13.23.flv',
|
'url': 'https://picarto.tv/videopopout/ArtofZod_2017.12.12.00.13.23.flv',
|
||||||
'md5': '3ab45ba4352c52ee841a28fb73f2d9ca',
|
'md5': '3ab45ba4352c52ee841a28fb73f2d9ca',
|
||||||
@@ -111,6 +113,18 @@ class PicartoVodIE(InfoExtractor):
|
|||||||
'channel': 'ArtofZod',
|
'channel': 'ArtofZod',
|
||||||
'age_limit': 18,
|
'age_limit': 18,
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
'url': 'https://picarto.tv/DrechuArt/profile/videos/400347',
|
||||||
|
'md5': 'f9ea54868b1d9dec40eb554b484cc7bf',
|
||||||
|
'info_dict': {
|
||||||
|
'id': '400347',
|
||||||
|
'ext': 'mp4',
|
||||||
|
'title': 'Welcome to the Show',
|
||||||
|
'thumbnail': r're:^https?://.*\.jpg',
|
||||||
|
'channel': 'DrechuArt',
|
||||||
|
'age_limit': 0,
|
||||||
|
},
|
||||||
|
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://picarto.tv/videopopout/Plague',
|
'url': 'https://picarto.tv/videopopout/Plague',
|
||||||
'only_matching': True,
|
'only_matching': True,
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
from ..utils import (
|
from ..utils import (
|
||||||
ExtractorError,
|
ExtractorError,
|
||||||
int_or_none,
|
int_or_none,
|
||||||
|
join_nonempty,
|
||||||
parse_qs,
|
parse_qs,
|
||||||
traverse_obj,
|
|
||||||
update_url_query,
|
update_url_query,
|
||||||
urlencode_postdata,
|
urlencode_postdata,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import traverse_obj, unpack
|
||||||
|
|
||||||
|
|
||||||
class PlaySuisseIE(InfoExtractor):
|
class PlaySuisseIE(InfoExtractor):
|
||||||
@@ -26,12 +27,12 @@ class PlaySuisseIE(InfoExtractor):
|
|||||||
{
|
{
|
||||||
# episode in a series
|
# episode in a series
|
||||||
'url': 'https://www.playsuisse.ch/watch/763182?episodeId=763211',
|
'url': 'https://www.playsuisse.ch/watch/763182?episodeId=763211',
|
||||||
'md5': '82df2a470b2dfa60c2d33772a8a60cf8',
|
'md5': 'e20d1ede6872a03b41905ca1060a1ef2',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '763211',
|
'id': '763211',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Knochen',
|
'title': 'Knochen',
|
||||||
'description': 'md5:8ea7a8076ba000cd9e8bc132fd0afdd8',
|
'description': 'md5:3bdd80e2ce20227c47aab1df2a79a519',
|
||||||
'duration': 3344,
|
'duration': 3344,
|
||||||
'series': 'Wilder',
|
'series': 'Wilder',
|
||||||
'season': 'Season 1',
|
'season': 'Season 1',
|
||||||
@@ -42,24 +43,33 @@ class PlaySuisseIE(InfoExtractor):
|
|||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
# film
|
# film
|
||||||
'url': 'https://www.playsuisse.ch/watch/808675',
|
'url': 'https://www.playsuisse.ch/detail/2573198',
|
||||||
'md5': '818b94c1d2d7c4beef953f12cb8f3e75',
|
'md5': '1f115bb0a5191477b1a5771643a4283d',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '808675',
|
'id': '2573198',
|
||||||
'ext': 'mp4',
|
'ext': 'mp4',
|
||||||
'title': 'Der Läufer',
|
'title': 'Azor',
|
||||||
'description': 'md5:9f61265c7e6dcc3e046137a792b275fd',
|
'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
|
||||||
'duration': 5280,
|
'genres': ['Fiction'],
|
||||||
|
'creators': ['Andreas Fontana'],
|
||||||
|
'cast': ['Fabrizio Rongione', 'Stéphanie Cléau', 'Gilles Privat', 'Alexandre Trocki'],
|
||||||
|
'location': 'France; Argentine',
|
||||||
|
'release_year': 2021,
|
||||||
|
'duration': 5981,
|
||||||
'thumbnail': 're:https://playsuisse-img.akamaized.net/',
|
'thumbnail': 're:https://playsuisse-img.akamaized.net/',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
# series (treated as a playlist)
|
# series (treated as a playlist)
|
||||||
'url': 'https://www.playsuisse.ch/detail/1115687',
|
'url': 'https://www.playsuisse.ch/detail/1115687',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'description': 'md5:e4a2ae29a8895823045b5c3145a02aa3',
|
|
||||||
'id': '1115687',
|
'id': '1115687',
|
||||||
'series': 'They all came out to Montreux',
|
'series': 'They all came out to Montreux',
|
||||||
'title': 'They all came out to Montreux',
|
'title': 'They all came out to Montreux',
|
||||||
|
'description': 'md5:0fefd8c5b4468a0bb35e916887681520',
|
||||||
|
'genres': ['Documentary'],
|
||||||
|
'creators': ['Oliver Murray'],
|
||||||
|
'location': 'Switzerland',
|
||||||
|
'release_year': 2021,
|
||||||
},
|
},
|
||||||
'playlist': [{
|
'playlist': [{
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -120,6 +130,12 @@ class PlaySuisseIE(InfoExtractor):
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
description
|
description
|
||||||
|
descriptionLong
|
||||||
|
year
|
||||||
|
contentTypes
|
||||||
|
directors
|
||||||
|
mainCast
|
||||||
|
productionCountries
|
||||||
duration
|
duration
|
||||||
episodeNumber
|
episodeNumber
|
||||||
seasonNumber
|
seasonNumber
|
||||||
@@ -215,9 +231,7 @@ def _perform_login(self, username, password):
|
|||||||
if not self._ID_TOKEN:
|
if not self._ID_TOKEN:
|
||||||
raise ExtractorError('Login failed')
|
raise ExtractorError('Login failed')
|
||||||
|
|
||||||
def _get_media_data(self, media_id):
|
def _get_media_data(self, media_id, locale=None):
|
||||||
# NOTE In the web app, the "locale" header is used to switch between languages,
|
|
||||||
# However this doesn't seem to take effect when passing the header here.
|
|
||||||
response = self._download_json(
|
response = self._download_json(
|
||||||
'https://www.playsuisse.ch/api/graphql',
|
'https://www.playsuisse.ch/api/graphql',
|
||||||
media_id, data=json.dumps({
|
media_id, data=json.dumps({
|
||||||
@@ -225,7 +239,7 @@ def _get_media_data(self, media_id):
|
|||||||
'query': self._GRAPHQL_QUERY,
|
'query': self._GRAPHQL_QUERY,
|
||||||
'variables': {'assetId': media_id},
|
'variables': {'assetId': media_id},
|
||||||
}).encode(),
|
}).encode(),
|
||||||
headers={'Content-Type': 'application/json', 'locale': 'de'})
|
headers={'Content-Type': 'application/json', 'locale': locale or 'de'})
|
||||||
|
|
||||||
return response['data']['assetV2']
|
return response['data']['assetV2']
|
||||||
|
|
||||||
@@ -234,7 +248,7 @@ def _real_extract(self, url):
|
|||||||
self.raise_login_required(method='password')
|
self.raise_login_required(method='password')
|
||||||
|
|
||||||
media_id = self._match_id(url)
|
media_id = self._match_id(url)
|
||||||
media_data = self._get_media_data(media_id)
|
media_data = self._get_media_data(media_id, traverse_obj(parse_qs(url), ('locale', 0)))
|
||||||
info = self._extract_single(media_data)
|
info = self._extract_single(media_data)
|
||||||
if media_data.get('episodes'):
|
if media_data.get('episodes'):
|
||||||
info.update({
|
info.update({
|
||||||
@@ -257,15 +271,22 @@ def _extract_single(self, media_data):
|
|||||||
self._merge_subtitles(subs, target=subtitles)
|
self._merge_subtitles(subs, target=subtitles)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': media_data['id'],
|
|
||||||
'title': media_data.get('name'),
|
|
||||||
'description': media_data.get('description'),
|
|
||||||
'thumbnails': thumbnails,
|
'thumbnails': thumbnails,
|
||||||
'duration': int_or_none(media_data.get('duration')),
|
|
||||||
'formats': formats,
|
'formats': formats,
|
||||||
'subtitles': subtitles,
|
'subtitles': subtitles,
|
||||||
'series': media_data.get('seriesName'),
|
**traverse_obj(media_data, {
|
||||||
'season_number': int_or_none(media_data.get('seasonNumber')),
|
'id': ('id', {str}),
|
||||||
'episode': media_data.get('name') if media_data.get('episodeNumber') else None,
|
'title': ('name', {str}),
|
||||||
'episode_number': int_or_none(media_data.get('episodeNumber')),
|
'description': (('descriptionLong', 'description'), {str}, any),
|
||||||
|
'genres': ('contentTypes', ..., {str}),
|
||||||
|
'creators': ('directors', ..., {str}),
|
||||||
|
'cast': ('mainCast', ..., {str}),
|
||||||
|
'location': ('productionCountries', ..., {str}, all, {unpack(join_nonempty, delim='; ')}, filter),
|
||||||
|
'release_year': ('year', {str}, {lambda x: x[:4]}, {int_or_none}),
|
||||||
|
'duration': ('duration', {int_or_none}),
|
||||||
|
'series': ('seriesName', {str}),
|
||||||
|
'season_number': ('seasonNumber', {int_or_none}),
|
||||||
|
'episode': ('name', {str}, {lambda x: x if media_data['episodeNumber'] is not None else None}),
|
||||||
|
'episode_number': ('episodeNumber', {int_or_none}),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
from ..utils import (
|
from ..utils import (
|
||||||
OnDemandPagedList,
|
OnDemandPagedList,
|
||||||
float_or_none,
|
float_or_none,
|
||||||
|
int_or_none,
|
||||||
|
orderedSet,
|
||||||
str_or_none,
|
str_or_none,
|
||||||
str_to_int,
|
|
||||||
traverse_obj,
|
|
||||||
unified_timestamp,
|
unified_timestamp,
|
||||||
|
url_or_none,
|
||||||
)
|
)
|
||||||
|
from ..utils.traversal import require, traverse_obj
|
||||||
|
|
||||||
|
|
||||||
class PodchaserIE(InfoExtractor):
|
class PodchaserIE(InfoExtractor):
|
||||||
@@ -21,24 +23,25 @@ class PodchaserIE(InfoExtractor):
|
|||||||
'id': '104365585',
|
'id': '104365585',
|
||||||
'title': 'Ep. 285 – freeze me off',
|
'title': 'Ep. 285 – freeze me off',
|
||||||
'description': 'cam ahn',
|
'description': 'cam ahn',
|
||||||
'thumbnail': r're:^https?://.*\.jpg$',
|
'thumbnail': r're:https?://.+/.+\.jpg',
|
||||||
'ext': 'mp3',
|
'ext': 'mp3',
|
||||||
'categories': ['Comedy'],
|
'categories': ['Comedy', 'News', 'Politics', 'Arts'],
|
||||||
'tags': ['comedy', 'dark humor'],
|
'tags': ['comedy', 'dark humor'],
|
||||||
'series': 'Cum Town',
|
'series': 'The Adam Friedland Show Podcast',
|
||||||
'duration': 3708,
|
'duration': 3708,
|
||||||
'timestamp': 1636531259,
|
'timestamp': 1636531259,
|
||||||
'upload_date': '20211110',
|
'upload_date': '20211110',
|
||||||
'average_rating': 4.0,
|
'average_rating': 4.0,
|
||||||
|
'series_id': '36924',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.podchaser.com/podcasts/the-bone-zone-28853',
|
'url': 'https://www.podchaser.com/podcasts/the-bone-zone-28853',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
'id': '28853',
|
'id': '28853',
|
||||||
'title': 'The Bone Zone',
|
'title': 'The Bone Zone',
|
||||||
'description': 'Podcast by The Bone Zone',
|
'description': r're:The official home of the Bone Zone podcast.+',
|
||||||
},
|
},
|
||||||
'playlist_count': 275,
|
'playlist_mincount': 275,
|
||||||
}, {
|
}, {
|
||||||
'url': 'https://www.podchaser.com/podcasts/sean-carrolls-mindscape-scienc-699349/episodes',
|
'url': 'https://www.podchaser.com/podcasts/sean-carrolls-mindscape-scienc-699349/episodes',
|
||||||
'info_dict': {
|
'info_dict': {
|
||||||
@@ -51,19 +54,33 @@ class PodchaserIE(InfoExtractor):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_episode(episode, podcast):
|
def _parse_episode(episode, podcast):
|
||||||
return {
|
info = traverse_obj(episode, {
|
||||||
'id': str(episode.get('id')),
|
'id': ('id', {int}, {str_or_none}, {require('episode ID')}),
|
||||||
'title': episode.get('title'),
|
'title': ('title', {str}),
|
||||||
'description': episode.get('description'),
|
'description': ('description', {str}),
|
||||||
'url': episode.get('audio_url'),
|
'url': ('audio_url', {url_or_none}),
|
||||||
'thumbnail': episode.get('image_url'),
|
'thumbnail': ('image_url', {url_or_none}),
|
||||||
'duration': str_to_int(episode.get('length')),
|
'duration': ('length', {int_or_none}),
|
||||||
'timestamp': unified_timestamp(episode.get('air_date')),
|
'timestamp': ('air_date', {unified_timestamp}),
|
||||||
'average_rating': float_or_none(episode.get('rating')),
|
'average_rating': ('rating', {float_or_none}),
|
||||||
'categories': list(set(traverse_obj(podcast, (('summary', None), 'categories', ..., 'text')))),
|
})
|
||||||
'tags': traverse_obj(podcast, ('tags', ..., 'text')),
|
info.update(traverse_obj(podcast, {
|
||||||
'series': podcast.get('title'),
|
'series': ('title', {str}),
|
||||||
}
|
'series_id': ('id', {int}, {str_or_none}),
|
||||||
|
'categories': (('summary', None), 'categories', ..., 'text', {str}, filter, all, {orderedSet}),
|
||||||
|
'tags': ('tags', ..., 'text', {str}),
|
||||||
|
}))
|
||||||
|
info['vcodec'] = 'none'
|
||||||
|
|
||||||
|
if info.get('series_id'):
|
||||||
|
podcast_slug = traverse_obj(podcast, ('slug', {str})) or 'podcast'
|
||||||
|
episode_slug = traverse_obj(episode, ('slug', {str})) or 'episode'
|
||||||
|
info['webpage_url'] = '/'.join((
|
||||||
|
'https://www.podchaser.com/podcasts',
|
||||||
|
'-'.join((podcast_slug[:30].rstrip('-'), info['series_id'])),
|
||||||
|
'-'.join((episode_slug[:30].rstrip('-'), info['id']))))
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
def _call_api(self, path, *args, **kwargs):
|
def _call_api(self, path, *args, **kwargs):
|
||||||
return self._download_json(f'https://api.podchaser.com/{path}', *args, **kwargs)
|
return self._download_json(f'https://api.podchaser.com/{path}', *args, **kwargs)
|
||||||
@@ -93,5 +110,5 @@ def _real_extract(self, url):
|
|||||||
OnDemandPagedList(functools.partial(self._fetch_page, podcast_id, podcast), self._PAGE_SIZE),
|
OnDemandPagedList(functools.partial(self._fetch_page, podcast_id, podcast), self._PAGE_SIZE),
|
||||||
str_or_none(podcast.get('id')), podcast.get('title'), podcast.get('description'))
|
str_or_none(podcast.get('id')), podcast.get('title'), podcast.get('description'))
|
||||||
|
|
||||||
episode = self._call_api(f'episodes/{episode_id}', episode_id)
|
episode = self._call_api(f'podcasts/{podcast_id}/episodes/{episode_id}/player_ids', episode_id)
|
||||||
return self._parse_episode(episode, podcast)
|
return self._parse_episode(episode, podcast)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user