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.
@@ -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)