Red-YT-Cipher-Solver 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- red_yt_cipher_solver-0.1.0/PKG-INFO +150 -0
- red_yt_cipher_solver-0.1.0/README.md +136 -0
- red_yt_cipher_solver-0.1.0/pyproject.toml +102 -0
- red_yt_cipher_solver-0.1.0/src/red_yt_cipher_solver/__init__.py +34 -0
- red_yt_cipher_solver-0.1.0/src/red_yt_cipher_solver/__main__.py +332 -0
- red_yt_cipher_solver-0.1.0/src/red_yt_cipher_solver/_platform_support.py +22 -0
- red_yt_cipher_solver-0.1.0/src/red_yt_cipher_solver/challenges.py +312 -0
- red_yt_cipher_solver-0.1.0/src/red_yt_cipher_solver/player.py +61 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: Red-YT-Cipher-Solver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A thin wrapper over yt-dlp/ejs for deciphering YT signatures. Comes with Deno out of the box.
|
|
5
|
+
Author: Jakub Kuczys
|
|
6
|
+
Author-email: Jakub Kuczys <me@jacken.men>
|
|
7
|
+
Requires-Dist: aiohttp>=3.9.5,<4
|
|
8
|
+
Requires-Dist: deno>=2.7.1,<3
|
|
9
|
+
Requires-Dist: typing-extensions~=4.15.0
|
|
10
|
+
Requires-Dist: yarl~=1.15.2
|
|
11
|
+
Requires-Dist: yt-dlp-ejs~=0.5.0
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Red-YT-Cipher-Solver
|
|
16
|
+
|
|
17
|
+
A thin wrapper over [yt-dlp/ejs](https://github.com/yt-dlp/ejs) for deciphering YT signatures.
|
|
18
|
+
Aside from yt-dlp/ejs, this uses the [official Deno PyPI package](https://github.com/denoland/deno_pypi),
|
|
19
|
+
meaning no additional setup is required beyond installing the package and then using it either as a library
|
|
20
|
+
or as a [Lavalink-compatible cipher server](https://github.com/lavalink-devs/youtube-source#using-a-remote-cipher-server).
|
|
21
|
+
|
|
22
|
+
## Using this project
|
|
23
|
+
|
|
24
|
+
This package only functions on systems supported by Deno JS runtime.
|
|
25
|
+
At the time of writing, this includes:
|
|
26
|
+
- Windows x86_64
|
|
27
|
+
- macOS x86_64 & arm64
|
|
28
|
+
- Linux x86_64 & aarch64 with glibc 2.27 or higher
|
|
29
|
+
|
|
30
|
+
> [!NOTE]
|
|
31
|
+
> If you intend to add this as a dependency to your project and want to support other platforms as well,
|
|
32
|
+
ensure to specify appropriate environment markers for the dependency and guard your imports appropriately
|
|
33
|
+
as the `deno` dependency of this project will not install on unsupported platforms:
|
|
34
|
+
> - `pyproject.toml`
|
|
35
|
+
> ```toml
|
|
36
|
+
> [project]
|
|
37
|
+
> # [...]
|
|
38
|
+
> dependencies = [
|
|
39
|
+
> """\
|
|
40
|
+
> Red-YT-Cipher-Solver; \
|
|
41
|
+
> (sys_platform == 'win32' and platform_machine == 'AMD64') \
|
|
42
|
+
> or (sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) \
|
|
43
|
+
> or (sys_platform == 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'arm64')) \
|
|
44
|
+
> """,
|
|
45
|
+
> ]
|
|
46
|
+
>
|
|
47
|
+
> # [...]
|
|
48
|
+
> ```
|
|
49
|
+
> - `requirements.txt`
|
|
50
|
+
> ```
|
|
51
|
+
> Red-YT-Cipher-Solver; (sys_platform == 'win32' and platform_machine == 'AMD64') or (sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) or (sys_platform == 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'arm64'))
|
|
52
|
+
> # [...]
|
|
53
|
+
> ```
|
|
54
|
+
|
|
55
|
+
### Installation
|
|
56
|
+
|
|
57
|
+
Install the package:
|
|
58
|
+
- Linux & macOS
|
|
59
|
+
```console
|
|
60
|
+
python3.10 -m venv red_yt_cipher_solver
|
|
61
|
+
. red_yt_cipher_solver/bin/activate
|
|
62
|
+
python -m pip install -U Red-YT-Cipher-Solver
|
|
63
|
+
```
|
|
64
|
+
- Windows
|
|
65
|
+
```powershell
|
|
66
|
+
py -3.10 -m venv red_yt_cipher_solver
|
|
67
|
+
red_yt_cipher_solver\Scripts\Activate.ps1
|
|
68
|
+
python -m pip install -U Red-YT-Cipher-Solver
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Running as a server
|
|
72
|
+
|
|
73
|
+
Run the server with the default configuration (listening on `http://localhost:2334` with no authentication):
|
|
74
|
+
```console
|
|
75
|
+
red-yt-cipher-solver serve
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
To specify custom hostname and port, use the positional arguments:
|
|
79
|
+
```console
|
|
80
|
+
red-yt-cipher-solver 0.0.0.0 4242
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can require the clients to send an `Authorization` header with a token
|
|
84
|
+
by specifying one in the `RED_YT_CIPHER_SERVER_TOKEN` environment variable.
|
|
85
|
+
|
|
86
|
+
### Using as a standalone solver
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
$ red-yt-cipher-solver solve --help
|
|
90
|
+
usage: red-yt-cipher-solver solve [-h] [--encrypted-signature ENCRYPTED_SIGNATURE]
|
|
91
|
+
[--n-param N_PARAM] [--signature-key SIGNATURE_KEY]
|
|
92
|
+
[--include-player-content]
|
|
93
|
+
player_url stream_url
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Solve a JS challenge request using the yt-dlp/ejs solver:
|
|
97
|
+
```console
|
|
98
|
+
red-yt-cipher-solver solve \
|
|
99
|
+
/s/player/00c52fa0/player_ias.vflset/de_DE/base.js \
|
|
100
|
+
"https://rr4---sn-4g5e6nzl.googlevideo.com/videoplayback?expire=1772476120&n=Fc9IL2b0xD7Lybd7&ei=..." \
|
|
101
|
+
--encrypted-signature "R=ANHkhNZInqwBBEsHpvykqHsygJje6J4T_Q-aL2VO7PkCQIC4ruoYYg2TeWFSfKXFTeQF=B_hR1UlnJw75Wfb24g6nQgIQRw4MNqEHA"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Using as a library
|
|
105
|
+
|
|
106
|
+
While this is mostly a thin wrapper over yt-dlp/ejs, it does come with Deno out of the box,
|
|
107
|
+
so it might be of interest to some to use that wrapper directly.
|
|
108
|
+
|
|
109
|
+
The following functions are currently exposed in `red_yt_cipher_solver`
|
|
110
|
+
|
|
111
|
+
#### `solve_js_challenges()` / `solve_js_challenges_sync()`
|
|
112
|
+
|
|
113
|
+
Solve JS challenge requests using the yt-dlp/ejs solver.
|
|
114
|
+
|
|
115
|
+
The variant without the `_sync` suffix is an asynchronous function.
|
|
116
|
+
|
|
117
|
+
**Arguments:**
|
|
118
|
+
- `player_content` (`str`) - The content of the player script.
|
|
119
|
+
- `*requests` (`JsChallengeRequest`) - The JS challenge requests to solve.
|
|
120
|
+
|
|
121
|
+
**Returns:**<br>
|
|
122
|
+
`SolveOutput` - The parsed output from yt-dlp/ejs solver.
|
|
123
|
+
|
|
124
|
+
**Raises:**
|
|
125
|
+
- `SolveOutputError` - Some or all of the challenge requests could not be solved.
|
|
126
|
+
- `UnsupportedGLibCError` - The glibc version used on this system is unsupported.
|
|
127
|
+
- `subprocess.CalledProcessError` - The yt-dlp/ejs script crashed/Deno failed to run.
|
|
128
|
+
|
|
129
|
+
#### `get_sts()`
|
|
130
|
+
|
|
131
|
+
Get timestamp from the player script.
|
|
132
|
+
|
|
133
|
+
**Parameters:**
|
|
134
|
+
- `player_content` (`str`) - The content of the player script.
|
|
135
|
+
|
|
136
|
+
**Returns:**<br>
|
|
137
|
+
`str` - The timestamp extracted from the player script. When this is an empty string, the timestamp could not be found.
|
|
138
|
+
|
|
139
|
+
#### `normalize_player_url()`
|
|
140
|
+
|
|
141
|
+
Normalize the provided player URL.
|
|
142
|
+
|
|
143
|
+
This will prepend the YT URL to a path-only URL in case of relative URLs
|
|
144
|
+
and validate the URL and its hostname in case of absolute URLs.
|
|
145
|
+
|
|
146
|
+
**Parameters:**
|
|
147
|
+
- `player_url` (`str`) - The player URL.
|
|
148
|
+
|
|
149
|
+
**Returns:**<br>
|
|
150
|
+
`str` - The normalized player URL.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Red-YT-Cipher-Solver
|
|
2
|
+
|
|
3
|
+
A thin wrapper over [yt-dlp/ejs](https://github.com/yt-dlp/ejs) for deciphering YT signatures.
|
|
4
|
+
Aside from yt-dlp/ejs, this uses the [official Deno PyPI package](https://github.com/denoland/deno_pypi),
|
|
5
|
+
meaning no additional setup is required beyond installing the package and then using it either as a library
|
|
6
|
+
or as a [Lavalink-compatible cipher server](https://github.com/lavalink-devs/youtube-source#using-a-remote-cipher-server).
|
|
7
|
+
|
|
8
|
+
## Using this project
|
|
9
|
+
|
|
10
|
+
This package only functions on systems supported by Deno JS runtime.
|
|
11
|
+
At the time of writing, this includes:
|
|
12
|
+
- Windows x86_64
|
|
13
|
+
- macOS x86_64 & arm64
|
|
14
|
+
- Linux x86_64 & aarch64 with glibc 2.27 or higher
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> If you intend to add this as a dependency to your project and want to support other platforms as well,
|
|
18
|
+
ensure to specify appropriate environment markers for the dependency and guard your imports appropriately
|
|
19
|
+
as the `deno` dependency of this project will not install on unsupported platforms:
|
|
20
|
+
> - `pyproject.toml`
|
|
21
|
+
> ```toml
|
|
22
|
+
> [project]
|
|
23
|
+
> # [...]
|
|
24
|
+
> dependencies = [
|
|
25
|
+
> """\
|
|
26
|
+
> Red-YT-Cipher-Solver; \
|
|
27
|
+
> (sys_platform == 'win32' and platform_machine == 'AMD64') \
|
|
28
|
+
> or (sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) \
|
|
29
|
+
> or (sys_platform == 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'arm64')) \
|
|
30
|
+
> """,
|
|
31
|
+
> ]
|
|
32
|
+
>
|
|
33
|
+
> # [...]
|
|
34
|
+
> ```
|
|
35
|
+
> - `requirements.txt`
|
|
36
|
+
> ```
|
|
37
|
+
> Red-YT-Cipher-Solver; (sys_platform == 'win32' and platform_machine == 'AMD64') or (sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) or (sys_platform == 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'arm64'))
|
|
38
|
+
> # [...]
|
|
39
|
+
> ```
|
|
40
|
+
|
|
41
|
+
### Installation
|
|
42
|
+
|
|
43
|
+
Install the package:
|
|
44
|
+
- Linux & macOS
|
|
45
|
+
```console
|
|
46
|
+
python3.10 -m venv red_yt_cipher_solver
|
|
47
|
+
. red_yt_cipher_solver/bin/activate
|
|
48
|
+
python -m pip install -U Red-YT-Cipher-Solver
|
|
49
|
+
```
|
|
50
|
+
- Windows
|
|
51
|
+
```powershell
|
|
52
|
+
py -3.10 -m venv red_yt_cipher_solver
|
|
53
|
+
red_yt_cipher_solver\Scripts\Activate.ps1
|
|
54
|
+
python -m pip install -U Red-YT-Cipher-Solver
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Running as a server
|
|
58
|
+
|
|
59
|
+
Run the server with the default configuration (listening on `http://localhost:2334` with no authentication):
|
|
60
|
+
```console
|
|
61
|
+
red-yt-cipher-solver serve
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To specify custom hostname and port, use the positional arguments:
|
|
65
|
+
```console
|
|
66
|
+
red-yt-cipher-solver 0.0.0.0 4242
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You can require the clients to send an `Authorization` header with a token
|
|
70
|
+
by specifying one in the `RED_YT_CIPHER_SERVER_TOKEN` environment variable.
|
|
71
|
+
|
|
72
|
+
### Using as a standalone solver
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
$ red-yt-cipher-solver solve --help
|
|
76
|
+
usage: red-yt-cipher-solver solve [-h] [--encrypted-signature ENCRYPTED_SIGNATURE]
|
|
77
|
+
[--n-param N_PARAM] [--signature-key SIGNATURE_KEY]
|
|
78
|
+
[--include-player-content]
|
|
79
|
+
player_url stream_url
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Solve a JS challenge request using the yt-dlp/ejs solver:
|
|
83
|
+
```console
|
|
84
|
+
red-yt-cipher-solver solve \
|
|
85
|
+
/s/player/00c52fa0/player_ias.vflset/de_DE/base.js \
|
|
86
|
+
"https://rr4---sn-4g5e6nzl.googlevideo.com/videoplayback?expire=1772476120&n=Fc9IL2b0xD7Lybd7&ei=..." \
|
|
87
|
+
--encrypted-signature "R=ANHkhNZInqwBBEsHpvykqHsygJje6J4T_Q-aL2VO7PkCQIC4ruoYYg2TeWFSfKXFTeQF=B_hR1UlnJw75Wfb24g6nQgIQRw4MNqEHA"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Using as a library
|
|
91
|
+
|
|
92
|
+
While this is mostly a thin wrapper over yt-dlp/ejs, it does come with Deno out of the box,
|
|
93
|
+
so it might be of interest to some to use that wrapper directly.
|
|
94
|
+
|
|
95
|
+
The following functions are currently exposed in `red_yt_cipher_solver`
|
|
96
|
+
|
|
97
|
+
#### `solve_js_challenges()` / `solve_js_challenges_sync()`
|
|
98
|
+
|
|
99
|
+
Solve JS challenge requests using the yt-dlp/ejs solver.
|
|
100
|
+
|
|
101
|
+
The variant without the `_sync` suffix is an asynchronous function.
|
|
102
|
+
|
|
103
|
+
**Arguments:**
|
|
104
|
+
- `player_content` (`str`) - The content of the player script.
|
|
105
|
+
- `*requests` (`JsChallengeRequest`) - The JS challenge requests to solve.
|
|
106
|
+
|
|
107
|
+
**Returns:**<br>
|
|
108
|
+
`SolveOutput` - The parsed output from yt-dlp/ejs solver.
|
|
109
|
+
|
|
110
|
+
**Raises:**
|
|
111
|
+
- `SolveOutputError` - Some or all of the challenge requests could not be solved.
|
|
112
|
+
- `UnsupportedGLibCError` - The glibc version used on this system is unsupported.
|
|
113
|
+
- `subprocess.CalledProcessError` - The yt-dlp/ejs script crashed/Deno failed to run.
|
|
114
|
+
|
|
115
|
+
#### `get_sts()`
|
|
116
|
+
|
|
117
|
+
Get timestamp from the player script.
|
|
118
|
+
|
|
119
|
+
**Parameters:**
|
|
120
|
+
- `player_content` (`str`) - The content of the player script.
|
|
121
|
+
|
|
122
|
+
**Returns:**<br>
|
|
123
|
+
`str` - The timestamp extracted from the player script. When this is an empty string, the timestamp could not be found.
|
|
124
|
+
|
|
125
|
+
#### `normalize_player_url()`
|
|
126
|
+
|
|
127
|
+
Normalize the provided player URL.
|
|
128
|
+
|
|
129
|
+
This will prepend the YT URL to a path-only URL in case of relative URLs
|
|
130
|
+
and validate the URL and its hostname in case of absolute URLs.
|
|
131
|
+
|
|
132
|
+
**Parameters:**
|
|
133
|
+
- `player_url` (`str`) - The player URL.
|
|
134
|
+
|
|
135
|
+
**Returns:**<br>
|
|
136
|
+
`str` - The normalized player URL.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "Red-YT-Cipher-Solver"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A thin wrapper over yt-dlp/ejs for deciphering YT signatures. Comes with Deno out of the box."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Jakub Kuczys", email = "me@jacken.men" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"aiohttp>=3.9.5,<4",
|
|
12
|
+
"deno>=2.7.1,<3",
|
|
13
|
+
"typing-extensions~=4.15.0",
|
|
14
|
+
"yarl~=1.15.2",
|
|
15
|
+
"yt-dlp-ejs~=0.5.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
red-yt-cipher-solver = "red_yt_cipher_solver.__main__:main"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"ruff>=0.15.4",
|
|
28
|
+
"ty>=0.0.19",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 99
|
|
33
|
+
|
|
34
|
+
[tool.ruff.format]
|
|
35
|
+
line-ending = "lf"
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = [
|
|
39
|
+
# pyflakes rules
|
|
40
|
+
"F",
|
|
41
|
+
# pycodestyle rules
|
|
42
|
+
"E",
|
|
43
|
+
"W",
|
|
44
|
+
# import sorting
|
|
45
|
+
"I",
|
|
46
|
+
# pyupgrade rules
|
|
47
|
+
"UP",
|
|
48
|
+
# flake8-bugbear rules
|
|
49
|
+
"B",
|
|
50
|
+
# flake8-bandit rules
|
|
51
|
+
"S",
|
|
52
|
+
# builtin shadowing rules
|
|
53
|
+
"A",
|
|
54
|
+
# unused arguments
|
|
55
|
+
"ARG",
|
|
56
|
+
# refurb rules
|
|
57
|
+
"FURB",
|
|
58
|
+
"FURB148", # unnecessary-enumerate (in preview)
|
|
59
|
+
# flake8-simplify rules
|
|
60
|
+
"SIM",
|
|
61
|
+
# flake8-logging rules
|
|
62
|
+
"LOG",
|
|
63
|
+
# flake8-logging-format rules
|
|
64
|
+
"G",
|
|
65
|
+
# flake8-comprehensions rules
|
|
66
|
+
"C4",
|
|
67
|
+
# "boolean trap" rules
|
|
68
|
+
"FBT",
|
|
69
|
+
# disallow implicitly concatenated strings on a single line
|
|
70
|
+
# and explicitly concatenated strings
|
|
71
|
+
"ISC",
|
|
72
|
+
# perflint rules
|
|
73
|
+
"PERF",
|
|
74
|
+
# pygrep-hooks rules
|
|
75
|
+
"PGH",
|
|
76
|
+
# flake8-pie rules
|
|
77
|
+
"PIE",
|
|
78
|
+
# flake8-return rules
|
|
79
|
+
"RET",
|
|
80
|
+
# collection of rules unique to Ruff
|
|
81
|
+
"RUF",
|
|
82
|
+
# leftover `breakpoint()`
|
|
83
|
+
"T100",
|
|
84
|
+
# flake8-tidy-imports rules
|
|
85
|
+
"TID",
|
|
86
|
+
]
|
|
87
|
+
ignore = [
|
|
88
|
+
# setattr is needed to make type checkers happy when creating new attributes on objects
|
|
89
|
+
"B010",
|
|
90
|
+
# disable __all__ sorting
|
|
91
|
+
# see https://github.com/astral-sh/ruff/issues/20952
|
|
92
|
+
"RUF022",
|
|
93
|
+
# `assert` is expected to be used in code (to appease type checkers)
|
|
94
|
+
"S101",
|
|
95
|
+
# the idea of checking untrusted input is not a bad one but this is prone to false positives
|
|
96
|
+
"S603",
|
|
97
|
+
]
|
|
98
|
+
preview = true
|
|
99
|
+
explicit-preview-rules = true
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint.isort]
|
|
102
|
+
combine-as-imports = true
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .challenges import (
|
|
4
|
+
JsChallengeErrorResponse,
|
|
5
|
+
JsChallengeRequest,
|
|
6
|
+
JsChallengeResponse,
|
|
7
|
+
JsChallengeResultResponse,
|
|
8
|
+
JsChallengeType,
|
|
9
|
+
NChallengeRequest,
|
|
10
|
+
SigChallengeRequest,
|
|
11
|
+
SolveOutput,
|
|
12
|
+
SolveOutputError,
|
|
13
|
+
solve_js_challenges,
|
|
14
|
+
solve_js_challenges_sync,
|
|
15
|
+
)
|
|
16
|
+
from .player import get_sts, normalize_player_url
|
|
17
|
+
|
|
18
|
+
__all__ = (
|
|
19
|
+
# .challenges
|
|
20
|
+
"JsChallengeRequest",
|
|
21
|
+
"JsChallengeType",
|
|
22
|
+
"JsChallengeErrorResponse",
|
|
23
|
+
"JsChallengeResponse",
|
|
24
|
+
"JsChallengeResultResponse",
|
|
25
|
+
"NChallengeRequest",
|
|
26
|
+
"SigChallengeRequest",
|
|
27
|
+
"SolveOutput",
|
|
28
|
+
"SolveOutputError",
|
|
29
|
+
"solve_js_challenges",
|
|
30
|
+
"solve_js_challenges_sync",
|
|
31
|
+
# .player
|
|
32
|
+
"get_sts",
|
|
33
|
+
"normalize_player_url",
|
|
34
|
+
)
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import dataclasses
|
|
6
|
+
import enum
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any, NoReturn, TypeVar
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
import yarl
|
|
18
|
+
from aiohttp import web
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
21
|
+
from . import _platform_support, challenges, player
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def post_route(path: str) -> Callable[[_T], _T]:
|
|
28
|
+
def decorator(func: _T) -> _T:
|
|
29
|
+
app_routes = getattr(func, "__app_routes__", [])
|
|
30
|
+
app_routes.append(("POST", path))
|
|
31
|
+
setattr(func, "__app_routes__", app_routes)
|
|
32
|
+
return func
|
|
33
|
+
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_required_key(payload: dict[str, str], key: str) -> str:
|
|
38
|
+
try:
|
|
39
|
+
return payload[key]
|
|
40
|
+
except KeyError:
|
|
41
|
+
raise web.HTTPBadRequest(reason=f"required key {key} is missing") from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class App:
|
|
45
|
+
def __init__(self, host: str, port: int, *, token: str = "") -> None:
|
|
46
|
+
self.host = host
|
|
47
|
+
self.port = port
|
|
48
|
+
self.token = token
|
|
49
|
+
self.web_app = web.Application(middlewares=[self._auth_middleware, self._error_middleware])
|
|
50
|
+
self.web_app.add_routes(self._get_routes())
|
|
51
|
+
self._stopped = asyncio.Event()
|
|
52
|
+
self._runner: web.AppRunner | None = None
|
|
53
|
+
self._site: web.TCPSite | None = None
|
|
54
|
+
self.session: aiohttp.ClientSession
|
|
55
|
+
|
|
56
|
+
@web.middleware
|
|
57
|
+
async def _auth_middleware(
|
|
58
|
+
self, request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
59
|
+
) -> web.StreamResponse:
|
|
60
|
+
if self.token:
|
|
61
|
+
auth_header = request.headers.get("Authorization")
|
|
62
|
+
if auth_header != self.token:
|
|
63
|
+
raise web.HTTPUnauthorized()
|
|
64
|
+
|
|
65
|
+
return await handler(request)
|
|
66
|
+
|
|
67
|
+
@web.middleware
|
|
68
|
+
async def _error_middleware(
|
|
69
|
+
self, request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
70
|
+
) -> web.StreamResponse:
|
|
71
|
+
try:
|
|
72
|
+
return await handler(request)
|
|
73
|
+
except web.HTTPException as ex:
|
|
74
|
+
if ex.empty_body:
|
|
75
|
+
raise
|
|
76
|
+
return web.json_response({"error": ex.reason}, status=ex.status_code)
|
|
77
|
+
|
|
78
|
+
def _get_routes(self) -> list[web.RouteDef]:
|
|
79
|
+
routes: list[web.RouteDef] = []
|
|
80
|
+
for base in reversed(self.__class__.__mro__):
|
|
81
|
+
for attr_name, attr_value in base.__dict__.items():
|
|
82
|
+
app_routes = getattr(attr_value, "__app_routes__", None)
|
|
83
|
+
if not app_routes:
|
|
84
|
+
continue
|
|
85
|
+
method = getattr(self, attr_name)
|
|
86
|
+
for route_method, route_path in app_routes:
|
|
87
|
+
routes.append(web.route(route_method, route_path, method))
|
|
88
|
+
return routes
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> Self:
|
|
91
|
+
await self.async_initialize()
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(
|
|
95
|
+
self,
|
|
96
|
+
exc_type: type[BaseException] | None,
|
|
97
|
+
exc_value: BaseException | None,
|
|
98
|
+
traceback: TracebackType | None,
|
|
99
|
+
/,
|
|
100
|
+
) -> None:
|
|
101
|
+
await self.close()
|
|
102
|
+
|
|
103
|
+
async def async_initialize(self) -> None:
|
|
104
|
+
self.session = aiohttp.ClientSession()
|
|
105
|
+
|
|
106
|
+
async def close(self) -> None:
|
|
107
|
+
await self.session.close()
|
|
108
|
+
|
|
109
|
+
async def start(self) -> None:
|
|
110
|
+
log.info("Starting the server...")
|
|
111
|
+
self._runner = web.AppRunner(self.web_app)
|
|
112
|
+
await self._runner.setup()
|
|
113
|
+
self._site = web.TCPSite(self._runner, self.host, self.port)
|
|
114
|
+
await self._site.start()
|
|
115
|
+
log.info("The server has been started on http://%s:%s", self.host, self.port)
|
|
116
|
+
|
|
117
|
+
async def stop(self) -> None:
|
|
118
|
+
log.info("Stopping the server...")
|
|
119
|
+
if self._site is not None:
|
|
120
|
+
await self._site.stop()
|
|
121
|
+
if self._runner is not None:
|
|
122
|
+
await self._runner.cleanup()
|
|
123
|
+
self._stopped.set()
|
|
124
|
+
|
|
125
|
+
async def run(self) -> int:
|
|
126
|
+
self._stopped.clear()
|
|
127
|
+
await self.start()
|
|
128
|
+
try:
|
|
129
|
+
await self._stopped.wait()
|
|
130
|
+
except asyncio.CancelledError:
|
|
131
|
+
await self.stop()
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
@post_route("/get_sts")
|
|
137
|
+
async def get_sts(self, request: web.Request) -> web.Response:
|
|
138
|
+
payload = await request.json()
|
|
139
|
+
try:
|
|
140
|
+
player_url = player.normalize_player_url(get_required_key(payload, "player_url"))
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise web.HTTPBadRequest(reason=str(exc)) from None
|
|
143
|
+
|
|
144
|
+
player_content = await _get_player_content(self.session, player_url)
|
|
145
|
+
sts = player.get_sts(player_content)
|
|
146
|
+
if not sts:
|
|
147
|
+
raise web.HTTPNotFound(reason="timestamp could not be found in the player script")
|
|
148
|
+
|
|
149
|
+
return web.json_response({"sts": sts})
|
|
150
|
+
|
|
151
|
+
@post_route("/resolve_url")
|
|
152
|
+
async def resolve_url(self, request: web.Request) -> web.Response:
|
|
153
|
+
payload = await request.json()
|
|
154
|
+
try:
|
|
155
|
+
player_url = player.normalize_player_url(get_required_key(payload, "player_url"))
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise web.HTTPBadRequest(reason=str(exc)) from None
|
|
158
|
+
encrypted_signature = payload.get("encrypted_signature")
|
|
159
|
+
signature_key = payload.get("signature_key") or "sig"
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
stream_url = yarl.URL(get_required_key(payload, "stream_url"))
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise web.HTTPBadRequest(reason="stream URL appears to be invalid") from None
|
|
165
|
+
n_param = payload.get("n_param")
|
|
166
|
+
if not n_param:
|
|
167
|
+
n_param = stream_url.query.get("n")
|
|
168
|
+
if not n_param:
|
|
169
|
+
raise web.HTTPBadRequest(reason="n_param not found in request or stream_url")
|
|
170
|
+
|
|
171
|
+
player_content = await _get_player_content(self.session, player_url)
|
|
172
|
+
challenge_requests: list[challenges.JsChallengeRequest] = []
|
|
173
|
+
|
|
174
|
+
n_challenge_request = challenges.NChallengeRequest([n_param])
|
|
175
|
+
challenge_requests.append(n_challenge_request)
|
|
176
|
+
|
|
177
|
+
sig_challenge_request: challenges.SigChallengeRequest | None = None
|
|
178
|
+
if encrypted_signature:
|
|
179
|
+
sig_challenge_request = challenges.SigChallengeRequest([encrypted_signature])
|
|
180
|
+
challenge_requests.append(sig_challenge_request)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
solve_output = await challenges.solve_js_challenges(
|
|
184
|
+
player_content, *challenge_requests
|
|
185
|
+
)
|
|
186
|
+
except challenges.SolveOutputError as exc:
|
|
187
|
+
if not exc.responses:
|
|
188
|
+
raise web.HTTPNotFound(
|
|
189
|
+
reason=f"error occurred during challenge request: {exc}"
|
|
190
|
+
) from exc
|
|
191
|
+
n_challenge_response = exc[n_challenge_request]
|
|
192
|
+
sig_challenge_response = exc[sig_challenge_request] if sig_challenge_request else None
|
|
193
|
+
if sig_challenge_response is not None and not sig_challenge_response:
|
|
194
|
+
raise web.HTTPNotFound(
|
|
195
|
+
reason=f"error occurred during challenge request: {exc}"
|
|
196
|
+
) from exc
|
|
197
|
+
else:
|
|
198
|
+
n_challenge_response = solve_output[n_challenge_request]
|
|
199
|
+
sig_challenge_response = (
|
|
200
|
+
solve_output[sig_challenge_request] if sig_challenge_request else None
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
query = stream_url.query.copy()
|
|
204
|
+
|
|
205
|
+
if n_challenge_response:
|
|
206
|
+
query["n"] = n_challenge_response.solutions[0]
|
|
207
|
+
|
|
208
|
+
if sig_challenge_response:
|
|
209
|
+
query[signature_key] = sig_challenge_response.solutions[0]
|
|
210
|
+
query.pop("s", None)
|
|
211
|
+
|
|
212
|
+
resolved_url = stream_url.with_query(query)
|
|
213
|
+
|
|
214
|
+
return web.json_response({"resolved_url": str(resolved_url)})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def _get_player_content(session: aiohttp.ClientSession, player_url: str) -> str:
|
|
218
|
+
# TODO: cache players
|
|
219
|
+
async with session.get(player_url) as resp:
|
|
220
|
+
return await resp.text()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def _serve_command(args: argparse.Namespace) -> int:
|
|
224
|
+
logging.basicConfig(level=logging.INFO)
|
|
225
|
+
token = os.getenv("RED_YT_CIPHER_SERVER_TOKEN", "")
|
|
226
|
+
|
|
227
|
+
async with App(args.host, args.port, token=token) as app:
|
|
228
|
+
return await app.run()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _solve_command(args: argparse.Namespace) -> int:
|
|
232
|
+
player_url = player.normalize_player_url(args.player_url)
|
|
233
|
+
stream_url = yarl.URL(args.stream_url)
|
|
234
|
+
n_param = args.n_param
|
|
235
|
+
if not n_param:
|
|
236
|
+
n_param = stream_url.query.get("n")
|
|
237
|
+
if not n_param:
|
|
238
|
+
print("n_param not provided in stream_url nor by the --n-param option", file=sys.stderr)
|
|
239
|
+
return 2
|
|
240
|
+
|
|
241
|
+
challenge_requests: list[challenges.JsChallengeRequest] = []
|
|
242
|
+
|
|
243
|
+
n_challenge_request = challenges.NChallengeRequest([n_param])
|
|
244
|
+
challenge_requests.append(n_challenge_request)
|
|
245
|
+
|
|
246
|
+
sig_challenge_request = None
|
|
247
|
+
if args.encrypted_signature:
|
|
248
|
+
sig_challenge_request = challenges.SigChallengeRequest([args.encrypted_signature])
|
|
249
|
+
challenge_requests.append(sig_challenge_request)
|
|
250
|
+
|
|
251
|
+
async with aiohttp.ClientSession() as session:
|
|
252
|
+
player_content = await _get_player_content(session, player_url)
|
|
253
|
+
try:
|
|
254
|
+
solve_output = await challenges.solve_js_challenges(player_content, *challenge_requests)
|
|
255
|
+
except challenges.SolveOutputError as exc:
|
|
256
|
+
print(f"error occurred during challenge request: {exc}", file=sys.stderr)
|
|
257
|
+
return 2
|
|
258
|
+
|
|
259
|
+
query = stream_url.query.copy()
|
|
260
|
+
|
|
261
|
+
if solve_output[n_challenge_request]:
|
|
262
|
+
query["n"] = solve_output[n_challenge_request].solutions[0]
|
|
263
|
+
|
|
264
|
+
if sig_challenge_request and solve_output[sig_challenge_request]:
|
|
265
|
+
query[args.signature_key] = solve_output[sig_challenge_request].solutions[0]
|
|
266
|
+
query.pop("s", None)
|
|
267
|
+
|
|
268
|
+
resolved_url = stream_url.with_query(query)
|
|
269
|
+
|
|
270
|
+
data: dict[str, Any] = {}
|
|
271
|
+
if args.include_player_content:
|
|
272
|
+
data["player"] = player_content
|
|
273
|
+
data["preprocessed_player"] = solve_output.preprocessed_player
|
|
274
|
+
data["resolved_url"] = str(resolved_url)
|
|
275
|
+
data["player_url"] = player_url
|
|
276
|
+
data["sts"] = player.get_sts(player_content)
|
|
277
|
+
solve_output_data = dataclasses.asdict(solve_output)
|
|
278
|
+
solve_output_data.pop("preprocessed_player", None)
|
|
279
|
+
data["solve_output"] = solve_output_data
|
|
280
|
+
|
|
281
|
+
def default(o: Any) -> Any:
|
|
282
|
+
if isinstance(o, enum.Enum):
|
|
283
|
+
return o.value
|
|
284
|
+
raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")
|
|
285
|
+
|
|
286
|
+
print(json.dumps(data, indent=4, default=default))
|
|
287
|
+
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _main() -> NoReturn:
|
|
292
|
+
parser = argparse.ArgumentParser()
|
|
293
|
+
subparsers = parser.add_subparsers(required=True, help="command to run")
|
|
294
|
+
|
|
295
|
+
serve_parser = subparsers.add_parser(
|
|
296
|
+
"serve", help="Run a Lavalink-compatible YT cipher server."
|
|
297
|
+
)
|
|
298
|
+
serve_parser.set_defaults(func=_serve_command)
|
|
299
|
+
serve_parser.add_argument("host", nargs="?", default="localhost")
|
|
300
|
+
serve_parser.add_argument("port", type=int, nargs="?", default=2334)
|
|
301
|
+
|
|
302
|
+
solve_parser = subparsers.add_parser(
|
|
303
|
+
"solve", help="Solve JS challenge request using the yt-dlp/ejs solver."
|
|
304
|
+
)
|
|
305
|
+
solve_parser.add_argument("player_url")
|
|
306
|
+
solve_parser.add_argument("stream_url")
|
|
307
|
+
solve_parser.add_argument("--encrypted-signature")
|
|
308
|
+
solve_parser.add_argument("--n-param")
|
|
309
|
+
solve_parser.add_argument("--signature-key", default="sig")
|
|
310
|
+
solve_parser.add_argument("--include-player-content", action="store_true", default=False)
|
|
311
|
+
solve_parser.set_defaults(func=_solve_command)
|
|
312
|
+
|
|
313
|
+
args = parser.parse_args()
|
|
314
|
+
raise SystemExit(await args.func(args))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main() -> NoReturn:
|
|
318
|
+
if _platform_support.GLIBC_UNSUPPORTED:
|
|
319
|
+
print(
|
|
320
|
+
f"The minimum supported version of glibc is {_platform_support.MIN_SUPPORTED_GLIBC}",
|
|
321
|
+
file=sys.stderr,
|
|
322
|
+
)
|
|
323
|
+
raise SystemExit(1)
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
asyncio.run(_main())
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
raise SystemExit(128 + signal.Signals.SIGINT) from None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == "__main__":
|
|
332
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_glibc_version() -> tuple[int, int] | None:
|
|
8
|
+
if sys.platform != "linux":
|
|
9
|
+
return None
|
|
10
|
+
try:
|
|
11
|
+
libc_ver = platform.libc_ver()
|
|
12
|
+
except OSError:
|
|
13
|
+
return None
|
|
14
|
+
if libc_ver[0] != "glibc":
|
|
15
|
+
return None
|
|
16
|
+
parts = libc_ver[1].split(".")
|
|
17
|
+
return (int(parts[0]), int(parts[1]))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
MIN_SUPPORTED_GLIBC = (2, 27)
|
|
21
|
+
GLIBC_VERSION = _get_glibc_version()
|
|
22
|
+
GLIBC_UNSUPPORTED = GLIBC_VERSION and GLIBC_VERSION < MIN_SUPPORTED_GLIBC
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import enum
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import Final, Literal, TypedDict
|
|
10
|
+
|
|
11
|
+
import deno
|
|
12
|
+
import yt_dlp_ejs.yt.solver
|
|
13
|
+
from typing_extensions import NotRequired
|
|
14
|
+
|
|
15
|
+
from . import _platform_support
|
|
16
|
+
|
|
17
|
+
_DENO_BIN: Final = deno.find_deno_bin()
|
|
18
|
+
_DENO_ARGS: Final = (
|
|
19
|
+
"--ext=js",
|
|
20
|
+
"--no-code-cache",
|
|
21
|
+
"--no-prompt",
|
|
22
|
+
"--no-remote",
|
|
23
|
+
"--no-lock",
|
|
24
|
+
"--node-modules-dir=none",
|
|
25
|
+
"--no-config",
|
|
26
|
+
"--no-npm",
|
|
27
|
+
"--cached-only",
|
|
28
|
+
"-",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = (
|
|
32
|
+
"JsChallengeType",
|
|
33
|
+
"NChallengeRequest",
|
|
34
|
+
"SigChallengeRequest",
|
|
35
|
+
"JsChallengeRequest",
|
|
36
|
+
"JsChallengeResultResponse",
|
|
37
|
+
"JsChallengeErrorResponse",
|
|
38
|
+
"JsChallengeResponse",
|
|
39
|
+
"SolveOutputError",
|
|
40
|
+
"SolveOutput",
|
|
41
|
+
"solve_js_challenges",
|
|
42
|
+
"solve_js_challenges_sync",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JsChallengeType(enum.Enum):
|
|
47
|
+
N = "n"
|
|
48
|
+
SIG = "sig"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclasses.dataclass(frozen=True)
|
|
52
|
+
class NChallengeRequest:
|
|
53
|
+
challenges: list[str] = dataclasses.field(default_factory=list)
|
|
54
|
+
type: Literal[JsChallengeType.N] = dataclasses.field(init=False, default=JsChallengeType.N)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclasses.dataclass(frozen=True)
|
|
58
|
+
class SigChallengeRequest:
|
|
59
|
+
challenges: list[str] = dataclasses.field(default_factory=list)
|
|
60
|
+
type: Literal[JsChallengeType.SIG] = dataclasses.field(init=False, default=JsChallengeType.SIG)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
JsChallengeRequest = NChallengeRequest | SigChallengeRequest
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _PlayerInput(TypedDict):
|
|
67
|
+
type: Literal["player"]
|
|
68
|
+
player: str
|
|
69
|
+
requests: list[_Request]
|
|
70
|
+
output_preprocessed: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _PreprocessedInput(TypedDict):
|
|
74
|
+
type: Literal["preprocessed"]
|
|
75
|
+
preprocessed_player: str
|
|
76
|
+
requests: list[_Request]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_Input = _PlayerInput | _PreprocessedInput
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Request(TypedDict):
|
|
83
|
+
type: Literal["n", "sig"]
|
|
84
|
+
challenges: list[str]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _ResultChallengeResponse(TypedDict):
|
|
88
|
+
type: Literal["result"]
|
|
89
|
+
data: dict[str, str]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _ErrorChallengeResponse(TypedDict):
|
|
93
|
+
type: Literal["error"]
|
|
94
|
+
error: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_ChallengeResponse = _ResultChallengeResponse | _ErrorChallengeResponse
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _ResultOutput(TypedDict):
|
|
101
|
+
type: Literal["result"]
|
|
102
|
+
preprocessed_player: NotRequired[str]
|
|
103
|
+
responses: list[_ChallengeResponse]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _ErrorOutput(TypedDict):
|
|
107
|
+
type: Literal["error"]
|
|
108
|
+
error: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_Output = _ResultOutput | _ErrorOutput
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _construct_stdin(
|
|
115
|
+
player: str, requests: Iterable[JsChallengeRequest], /, *, preprocessed: bool = False
|
|
116
|
+
) -> bytes:
|
|
117
|
+
json_requests: list[_Request] = [
|
|
118
|
+
{
|
|
119
|
+
"type": request.type.value,
|
|
120
|
+
"challenges": request.challenges,
|
|
121
|
+
}
|
|
122
|
+
for request in requests
|
|
123
|
+
]
|
|
124
|
+
data: _Input = (
|
|
125
|
+
{
|
|
126
|
+
"type": "preprocessed",
|
|
127
|
+
"preprocessed_player": player,
|
|
128
|
+
"requests": json_requests,
|
|
129
|
+
}
|
|
130
|
+
if preprocessed
|
|
131
|
+
else {
|
|
132
|
+
"type": "player",
|
|
133
|
+
"player": player,
|
|
134
|
+
"requests": json_requests,
|
|
135
|
+
"output_preprocessed": True,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return (
|
|
139
|
+
f"{yt_dlp_ejs.yt.solver.lib()}\n"
|
|
140
|
+
"Object.assign(globalThis, lib);\n"
|
|
141
|
+
f"{yt_dlp_ejs.yt.solver.core()}\n"
|
|
142
|
+
f"console.log(JSON.stringify(jsc({json.dumps(data)})));"
|
|
143
|
+
).encode()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclasses.dataclass(frozen=True)
|
|
147
|
+
class JsChallengeResultResponse:
|
|
148
|
+
request: JsChallengeRequest
|
|
149
|
+
data: dataclasses.InitVar[dict[str, str]]
|
|
150
|
+
solutions: tuple[str] = dataclasses.field(init=False)
|
|
151
|
+
|
|
152
|
+
def __post_init__(self, data: dict[str, str]) -> None:
|
|
153
|
+
object.__setattr__(
|
|
154
|
+
self, "solutions", tuple(data[challenge] for challenge in self.request.challenges)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def __bool__(self) -> Literal[True]:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclasses.dataclass(frozen=True)
|
|
162
|
+
class JsChallengeErrorResponse:
|
|
163
|
+
request: JsChallengeRequest
|
|
164
|
+
message: str
|
|
165
|
+
|
|
166
|
+
def __bool__(self) -> Literal[False]:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
JsChallengeResponse = JsChallengeResultResponse | JsChallengeErrorResponse
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SolveOutputError(Exception):
|
|
174
|
+
"""Failed to solve JS challenge requests."""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
requests: tuple[JsChallengeRequest, ...],
|
|
179
|
+
message: str,
|
|
180
|
+
/,
|
|
181
|
+
*,
|
|
182
|
+
responses: tuple[JsChallengeResponse, ...] | None = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
super().__init__(message)
|
|
185
|
+
self.requests = requests
|
|
186
|
+
self.responses = responses
|
|
187
|
+
|
|
188
|
+
def __getitem__(self, request: JsChallengeRequest) -> JsChallengeResponse:
|
|
189
|
+
if self.responses is None:
|
|
190
|
+
raise TypeError("no responses are available")
|
|
191
|
+
return self.responses[self.requests.index(request)]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclasses.dataclass(frozen=True)
|
|
195
|
+
class SolveOutput:
|
|
196
|
+
requests: tuple[JsChallengeRequest, ...]
|
|
197
|
+
responses: tuple[JsChallengeResultResponse, ...]
|
|
198
|
+
preprocessed_player: str | None
|
|
199
|
+
|
|
200
|
+
def __getitem__(self, request: JsChallengeRequest) -> JsChallengeResultResponse:
|
|
201
|
+
return self.responses[self.requests.index(request)]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_output(requests: tuple[JsChallengeRequest, ...], stdout: bytes) -> SolveOutput:
|
|
205
|
+
data: _Output = json.loads(stdout)
|
|
206
|
+
if data["type"] == "error":
|
|
207
|
+
raise SolveOutputError(requests, data["error"])
|
|
208
|
+
|
|
209
|
+
responses: list[JsChallengeResponse] = []
|
|
210
|
+
result_responses: list[JsChallengeResultResponse] = []
|
|
211
|
+
has_errors = False
|
|
212
|
+
for request, response_data in zip(requests, data["responses"], strict=True):
|
|
213
|
+
if response_data["type"] == "error":
|
|
214
|
+
has_errors = True
|
|
215
|
+
responses.append(JsChallengeErrorResponse(request, response_data["error"]))
|
|
216
|
+
else:
|
|
217
|
+
response = JsChallengeResultResponse(request, response_data["data"])
|
|
218
|
+
responses.append(response)
|
|
219
|
+
result_responses.append(response)
|
|
220
|
+
|
|
221
|
+
if has_errors:
|
|
222
|
+
raise SolveOutputError(
|
|
223
|
+
requests,
|
|
224
|
+
"Some of the challenge requests could not be solved",
|
|
225
|
+
responses=tuple(responses),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return SolveOutput(requests, tuple(result_responses), data.get("preprocessed_player"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class UnsupportedGLibCError(Exception):
|
|
232
|
+
"""The glibc version used on this system is unsupported."""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def solve_js_challenges(player_content: str, *requests: JsChallengeRequest) -> SolveOutput:
|
|
236
|
+
"""
|
|
237
|
+
Solve JS challenge requests using the yt-dlp/ejs solver.
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
player_content: str
|
|
242
|
+
The content of the player script.
|
|
243
|
+
*requests: JsChallengeRequest
|
|
244
|
+
The JS challenge requests to solve.
|
|
245
|
+
|
|
246
|
+
Returns
|
|
247
|
+
-------
|
|
248
|
+
SolveOutput
|
|
249
|
+
The parsed output from yt-dlp/ejs solver.
|
|
250
|
+
|
|
251
|
+
Raises
|
|
252
|
+
------
|
|
253
|
+
SolveOutputError
|
|
254
|
+
Some or all of the challenge requests could not be solved.
|
|
255
|
+
UnsupportedGLibCError
|
|
256
|
+
The glibc version used on this system is unsupported.
|
|
257
|
+
subprocess.CalledProcessError
|
|
258
|
+
The yt-dlp/ejs script crashed/Deno failed to run.
|
|
259
|
+
"""
|
|
260
|
+
if _platform_support.GLIBC_UNSUPPORTED:
|
|
261
|
+
raise UnsupportedGLibCError(
|
|
262
|
+
f"The minimum supported version of glibc is {_platform_support.MIN_SUPPORTED_GLIBC}"
|
|
263
|
+
)
|
|
264
|
+
args = (_DENO_BIN, *_DENO_ARGS)
|
|
265
|
+
proc = await asyncio.create_subprocess_exec(
|
|
266
|
+
*args,
|
|
267
|
+
stdin=asyncio.subprocess.PIPE,
|
|
268
|
+
stdout=asyncio.subprocess.PIPE,
|
|
269
|
+
)
|
|
270
|
+
stdout, _ = await proc.communicate(_construct_stdin(player_content, requests))
|
|
271
|
+
if proc.returncode is None:
|
|
272
|
+
raise RuntimeError("unreachable")
|
|
273
|
+
if proc.returncode != 0:
|
|
274
|
+
raise subprocess.CalledProcessError(proc.returncode, args, stdout)
|
|
275
|
+
return _parse_output(requests, stdout)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def solve_js_challenges_sync(player_content: str, *requests: JsChallengeRequest) -> SolveOutput:
|
|
279
|
+
"""
|
|
280
|
+
Solve JS challenge requests using the yt-dlp/ejs solver.
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
player_content: str
|
|
285
|
+
The content of the player script.
|
|
286
|
+
*requests: JsChallengeRequest
|
|
287
|
+
The JS challenge requests to solve.
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
SolveOutput
|
|
292
|
+
The parsed output from yt-dlp/ejs solver.
|
|
293
|
+
|
|
294
|
+
Raises
|
|
295
|
+
------
|
|
296
|
+
SolveOutputError
|
|
297
|
+
Some or all of the challenge requests could not be solved.
|
|
298
|
+
UnsupportedGLibCError
|
|
299
|
+
The glibc version used on this system is unsupported.
|
|
300
|
+
subprocess.CalledProcessError
|
|
301
|
+
The yt-dlp/ejs script crashed/Deno failed to run.
|
|
302
|
+
"""
|
|
303
|
+
if _platform_support.GLIBC_UNSUPPORTED:
|
|
304
|
+
raise UnsupportedGLibCError(
|
|
305
|
+
f"The minimum supported version of glibc is {_platform_support.MIN_SUPPORTED_GLIBC}"
|
|
306
|
+
)
|
|
307
|
+
proc = subprocess.run(
|
|
308
|
+
(_DENO_BIN, *_DENO_ARGS),
|
|
309
|
+
input=_construct_stdin(player_content, requests),
|
|
310
|
+
check=True,
|
|
311
|
+
)
|
|
312
|
+
return _parse_output(requests, proc.stdout)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import yarl
|
|
4
|
+
|
|
5
|
+
_STS_RE = re.compile(r"(signatureTimestamp|sts):(\d+)")
|
|
6
|
+
VALID_YT_HOSTNAMES = ("youtube.com", "www.youtube.com", "m.youtube.com")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = ("VALID_YT_HOSTNAMES", "normalize_player_url", "get_sts")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_player_url(player_url: str) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Normalize the provided player URL.
|
|
15
|
+
|
|
16
|
+
This will prepend the YT URL to a path-only URL in case of relative URLs
|
|
17
|
+
and validate the URL and its hostname in case of absolute URLs.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
-------
|
|
21
|
+
player_url: str
|
|
22
|
+
The player URL.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
str
|
|
27
|
+
The normalized player URL.
|
|
28
|
+
"""
|
|
29
|
+
if player_url.startswith("/"):
|
|
30
|
+
if player_url.startswith("/s/player"):
|
|
31
|
+
return f"https://www.youtube.com{player_url}"
|
|
32
|
+
raise ValueError(f"invalid player path: {player_url}")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
url = yarl.URL(player_url)
|
|
36
|
+
if url.host in VALID_YT_HOSTNAMES:
|
|
37
|
+
return player_url
|
|
38
|
+
raise ValueError(f"unexpected hostname in player url: {player_url}")
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise ValueError(f"invalid player url: {player_url}") from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_sts(player_content: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Get timestamp from the player script.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
player_content: str
|
|
50
|
+
The content of the player script.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
str
|
|
55
|
+
The timestamp extracted from the player script.
|
|
56
|
+
When this is an empty string, the timestamp could not be found.
|
|
57
|
+
"""
|
|
58
|
+
match = _STS_RE.search(player_content)
|
|
59
|
+
if not match:
|
|
60
|
+
return ""
|
|
61
|
+
return match.group(2)
|