polytool 0.2.4__tar.gz → 0.2.6__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.
- {polytool-0.2.4 → polytool-0.2.6}/PKG-INFO +1 -1
- {polytool-0.2.4 → polytool-0.2.6}/pyproject.toml +1 -1
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/__init__.py +1 -1
- polytool-0.2.6/src/polytool/cli/__init__.py +211 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/dl.py +261 -11
- polytool-0.2.6/src/polytool/core/runtime.py +162 -0
- polytool-0.2.4/src/polytool/cli/__init__.py +0 -98
- {polytool-0.2.4 → polytool-0.2.6}/.gitignore +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/LICENSE +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/README.md +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/docs/README.md +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/__main__.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/clip.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/color.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/convert.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/cron.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/data.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/enc.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/file.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/gen.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/img.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/net.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/pdf.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/qr.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/shot.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/text.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/cli/vid.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/__init__.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/browsers.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/config.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/console.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/errors.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/ffmpeg.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/io.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/lazy.py +0 -0
- {polytool-0.2.4 → polytool-0.2.6}/src/polytool/core/progress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: polytool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: One-binary CLI bundling 26 everyday utilities — image/video/PDF conversion, background removal, OCR, QR codes, hashing, downloads, and more
|
|
5
5
|
Project-URL: Homepage, https://github.com/k6w/polytool
|
|
6
6
|
Project-URL: Repository, https://github.com/k6w/polytool
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "polytool"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "One-binary CLI bundling 26 everyday utilities — image/video/PDF conversion, background removal, OCR, QR codes, hashing, downloads, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Root Typer app — subcommand groups are added by their own modules.
|
|
2
|
+
|
|
3
|
+
Subcommand modules must keep top-level imports light (typer/rich/stdlib only).
|
|
4
|
+
Heavy imports (Pillow, ffmpeg, rembg) happen inside command bodies via
|
|
5
|
+
`polytool.core.lazy.require_extra`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from polytool import __version__
|
|
17
|
+
from polytool.cli import (
|
|
18
|
+
clip,
|
|
19
|
+
color,
|
|
20
|
+
convert,
|
|
21
|
+
cron,
|
|
22
|
+
data,
|
|
23
|
+
dl,
|
|
24
|
+
enc,
|
|
25
|
+
file,
|
|
26
|
+
gen,
|
|
27
|
+
img,
|
|
28
|
+
net,
|
|
29
|
+
pdf,
|
|
30
|
+
qr,
|
|
31
|
+
shot,
|
|
32
|
+
text,
|
|
33
|
+
vid,
|
|
34
|
+
)
|
|
35
|
+
from polytool.core import runtime
|
|
36
|
+
from polytool.core.console import console, err_console
|
|
37
|
+
from polytool.core.errors import PolytoolError, render_panel
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(
|
|
40
|
+
name="polytool",
|
|
41
|
+
help="polytool — one-binary CLI bundling 26 everyday utilities (pt for short).",
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
add_completion=False,
|
|
44
|
+
rich_markup_mode="rich",
|
|
45
|
+
# We handle PolytoolError ourselves via the `main()` wrapper below; disable
|
|
46
|
+
# Typer's pretty-traceback so it doesn't squash our hints.
|
|
47
|
+
pretty_exceptions_enable=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run() -> None:
|
|
52
|
+
"""Console-script entry point — runs the Typer app and renders PolytoolError nicely.
|
|
53
|
+
|
|
54
|
+
(Defined with a unique name to avoid clashing with the ``@app.callback()``
|
|
55
|
+
function below — both would otherwise be named ``main`` in this module.)
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
app()
|
|
59
|
+
except PolytoolError as exc:
|
|
60
|
+
render_panel(exc.message, exc.hint)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _version_callback(value: bool) -> None:
|
|
65
|
+
if value:
|
|
66
|
+
typer.echo(f"polytool {__version__}")
|
|
67
|
+
raise typer.Exit
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.callback()
|
|
71
|
+
def main(
|
|
72
|
+
version: bool = typer.Option(
|
|
73
|
+
False,
|
|
74
|
+
"--version",
|
|
75
|
+
"-V",
|
|
76
|
+
callback=_version_callback,
|
|
77
|
+
is_eager=True,
|
|
78
|
+
help="Show version and exit.",
|
|
79
|
+
),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""polytool — one-binary CLI bundling 26 everyday utilities."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("setup")
|
|
85
|
+
def cmd_setup(
|
|
86
|
+
yes: Annotated[
|
|
87
|
+
bool,
|
|
88
|
+
typer.Option("--yes", "-y", help="Skip prompts; install everything."),
|
|
89
|
+
] = False,
|
|
90
|
+
skip_runtime: Annotated[
|
|
91
|
+
bool,
|
|
92
|
+
typer.Option("--skip-runtime", help="Don't fetch Deno (used by `pt dl get`)."),
|
|
93
|
+
] = False,
|
|
94
|
+
skip_chromium: Annotated[
|
|
95
|
+
bool,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--skip-chromium", help="Don't fetch Playwright Chromium (used by `pt shot web`)."
|
|
98
|
+
),
|
|
99
|
+
] = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""One-shot post-install: fetch every binary the [full] extra needs.
|
|
102
|
+
|
|
103
|
+
Most polytool features work the moment you `uv tool install 'polytool[full]'`.
|
|
104
|
+
A handful need extra one-time downloads that aren't pip packages:
|
|
105
|
+
|
|
106
|
+
- **Deno (~50 MB)** — solves YouTube's n-challenge for `pt dl get`.
|
|
107
|
+
- **Playwright Chromium (~150 MB)** — used by `pt shot web`.
|
|
108
|
+
|
|
109
|
+
`pt setup` fetches both. Run it once and you're done; you can also run
|
|
110
|
+
each install separately via `pt dl runtime install` and `pt shot install`.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
|
|
114
|
+
pt setup # interactive; default-yes to each step
|
|
115
|
+
pt setup -y # silent; install everything
|
|
116
|
+
pt setup --skip-chromium # everything except Chromium
|
|
117
|
+
"""
|
|
118
|
+
steps: list[tuple[str, str, callable]] = [] # type: ignore[type-arg]
|
|
119
|
+
|
|
120
|
+
if not skip_runtime:
|
|
121
|
+
steps.append(
|
|
122
|
+
(
|
|
123
|
+
"JS runtime (Deno)",
|
|
124
|
+
"for `pt dl get` to solve YouTube's n-challenge",
|
|
125
|
+
_install_runtime_step,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
if not skip_chromium:
|
|
129
|
+
steps.append(
|
|
130
|
+
(
|
|
131
|
+
"Playwright Chromium",
|
|
132
|
+
"for `pt shot web`",
|
|
133
|
+
_install_chromium_step,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if not steps:
|
|
138
|
+
console.print("[dim]Nothing to do — both --skip-* flags set.[/dim]")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
console.print("[bold]polytool setup[/bold]\n")
|
|
142
|
+
console.print("This will fetch one-time downloads needed by certain commands:\n")
|
|
143
|
+
for name, why, _ in steps:
|
|
144
|
+
console.print(f" • [cyan]{name}[/cyan] — {why}")
|
|
145
|
+
console.print()
|
|
146
|
+
|
|
147
|
+
if not yes and not typer.confirm("Continue?", default=True):
|
|
148
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
failures: list[str] = []
|
|
152
|
+
for name, _why, step in steps:
|
|
153
|
+
console.print(f"[bold]→ {name}[/bold]")
|
|
154
|
+
try:
|
|
155
|
+
step()
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
failures.append(f"{name}: {exc}")
|
|
158
|
+
err_console.print(f"[red]failed:[/red] {exc}")
|
|
159
|
+
console.print()
|
|
160
|
+
|
|
161
|
+
if failures:
|
|
162
|
+
msg = "Some steps failed:\n " + "\n ".join(failures)
|
|
163
|
+
raise PolytoolError(msg)
|
|
164
|
+
console.print("[green]All set.[/green] You can now use every polytool command.")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _install_runtime_step() -> None:
|
|
168
|
+
sys_runtime = runtime.system_runtime_path()
|
|
169
|
+
if sys_runtime:
|
|
170
|
+
console.print(f"[green]system runtime already on PATH:[/green] {sys_runtime}")
|
|
171
|
+
return
|
|
172
|
+
if runtime.is_managed_deno_installed():
|
|
173
|
+
console.print(f"[green]already installed:[/green] {runtime.deno_binary_path()}")
|
|
174
|
+
return
|
|
175
|
+
binary = runtime.install_deno()
|
|
176
|
+
runtime.ensure_runtime_in_path()
|
|
177
|
+
console.print(f"[green]installed:[/green] {binary}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _install_chromium_step() -> None:
|
|
181
|
+
try:
|
|
182
|
+
subprocess.run(
|
|
183
|
+
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
184
|
+
check=True,
|
|
185
|
+
)
|
|
186
|
+
except FileNotFoundError as exc:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
"Playwright Python package missing. Install with: uv tool install 'polytool[shot]'"
|
|
189
|
+
) from exc
|
|
190
|
+
console.print("[green]Chromium installed.[/green]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
app.add_typer(enc.app, name="enc")
|
|
194
|
+
app.add_typer(gen.app, name="gen")
|
|
195
|
+
app.add_typer(color.app, name="color")
|
|
196
|
+
app.add_typer(convert.app, name="convert")
|
|
197
|
+
app.add_typer(text.app, name="text")
|
|
198
|
+
app.add_typer(data.app, name="data")
|
|
199
|
+
app.add_typer(qr.app, name="qr")
|
|
200
|
+
app.add_typer(clip.app, name="clip")
|
|
201
|
+
app.add_typer(cron.app, name="cron")
|
|
202
|
+
app.add_typer(net.app, name="net")
|
|
203
|
+
app.add_typer(file.app, name="file")
|
|
204
|
+
app.add_typer(img.app, name="img")
|
|
205
|
+
app.add_typer(pdf.app, name="pdf")
|
|
206
|
+
app.add_typer(vid.app, name="vid")
|
|
207
|
+
app.add_typer(dl.app, name="dl")
|
|
208
|
+
app.add_typer(shot.app, name="shot")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = ["app"]
|
|
@@ -2,12 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Annotated
|
|
7
8
|
|
|
8
9
|
import typer
|
|
10
|
+
from rich.progress import (
|
|
11
|
+
BarColumn,
|
|
12
|
+
DownloadColumn,
|
|
13
|
+
Progress,
|
|
14
|
+
SpinnerColumn,
|
|
15
|
+
TextColumn,
|
|
16
|
+
TimeRemainingColumn,
|
|
17
|
+
TransferSpeedColumn,
|
|
18
|
+
)
|
|
9
19
|
|
|
10
|
-
from polytool.core import config
|
|
20
|
+
from polytool.core import config, runtime
|
|
11
21
|
from polytool.core.browsers import (
|
|
12
22
|
ALL_BROWSERS,
|
|
13
23
|
FIREFOX_FORK_DIRS,
|
|
@@ -39,6 +49,193 @@ BOT_CHECK_HINTS = (
|
|
|
39
49
|
"rate limit",
|
|
40
50
|
)
|
|
41
51
|
|
|
52
|
+
# Patterns that indicate YouTube's n-challenge / JS-runtime gap.
|
|
53
|
+
N_CHALLENGE_PATTERNS = (
|
|
54
|
+
"n challenge",
|
|
55
|
+
"only images are available",
|
|
56
|
+
"requested format is not available",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
61
|
+
_NOISE_RE = re.compile(
|
|
62
|
+
r"^(?:Extracting URL|Downloading\s\S+|Extracting cookies|Extracted\s|"
|
|
63
|
+
r"\[\w+\]\s|Sleeping|Skipping|Deleting original file)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _strip(msg: object) -> str:
|
|
68
|
+
text = _ANSI_RE.sub("", str(msg)).strip()
|
|
69
|
+
for prefix in ("WARNING:", "ERROR:", "[generic]", "[youtube]"):
|
|
70
|
+
if text.startswith(prefix):
|
|
71
|
+
text = text[len(prefix) :].strip()
|
|
72
|
+
return text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _DlLogger:
|
|
76
|
+
"""Capture yt-dlp warnings/errors so we can filter noise and surface hints."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.warnings: list[str] = []
|
|
80
|
+
self.errors: list[str] = []
|
|
81
|
+
|
|
82
|
+
def debug(self, msg: object) -> None:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def info(self, msg: object) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def warning(self, msg: object) -> None:
|
|
89
|
+
clean = _strip(msg)
|
|
90
|
+
if not clean:
|
|
91
|
+
return
|
|
92
|
+
self.warnings.append(clean)
|
|
93
|
+
if not _NOISE_RE.match(clean):
|
|
94
|
+
err_console.print(f"[yellow]warning:[/yellow] {clean}")
|
|
95
|
+
|
|
96
|
+
def error(self, msg: object) -> None:
|
|
97
|
+
clean = _strip(msg)
|
|
98
|
+
if clean:
|
|
99
|
+
self.errors.append(clean)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_progress() -> Progress:
|
|
103
|
+
"""Rich progress bar tuned for downloads (title · bar · % · size · speed · ETA)."""
|
|
104
|
+
return Progress(
|
|
105
|
+
SpinnerColumn(),
|
|
106
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
107
|
+
BarColumn(bar_width=None),
|
|
108
|
+
TextColumn("[progress.percentage]{task.percentage:>5.1f}%"),
|
|
109
|
+
DownloadColumn(),
|
|
110
|
+
TransferSpeedColumn(),
|
|
111
|
+
TimeRemainingColumn(),
|
|
112
|
+
console=err_console,
|
|
113
|
+
transient=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _make_progress_hook(progress: Progress, state: dict):
|
|
118
|
+
"""Build a yt-dlp progress hook that drives the Rich progress bar."""
|
|
119
|
+
|
|
120
|
+
def hook(d: dict) -> None:
|
|
121
|
+
status = d.get("status")
|
|
122
|
+
if status == "downloading":
|
|
123
|
+
total = d.get("total_bytes") or d.get("total_bytes_estimate")
|
|
124
|
+
downloaded = d.get("downloaded_bytes", 0)
|
|
125
|
+
info = d.get("info_dict") or {}
|
|
126
|
+
title = info.get("title") or Path(d.get("filename", "download")).name
|
|
127
|
+
disp = title if len(title) <= 50 else title[:47] + "..."
|
|
128
|
+
tid = state.get("task_id")
|
|
129
|
+
if tid is None:
|
|
130
|
+
state["task_id"] = progress.add_task(disp, total=total)
|
|
131
|
+
else:
|
|
132
|
+
progress.update(tid, completed=downloaded, total=total, description=disp)
|
|
133
|
+
elif status == "finished":
|
|
134
|
+
tid = state.pop("task_id", None)
|
|
135
|
+
if tid is not None and progress.tasks[tid].total:
|
|
136
|
+
progress.update(tid, completed=progress.tasks[tid].total)
|
|
137
|
+
|
|
138
|
+
return hook
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _n_challenge_hint(messages: list[str]) -> str | None:
|
|
142
|
+
text = " ".join(messages).lower()
|
|
143
|
+
if not any(p in text for p in N_CHALLENGE_PATTERNS):
|
|
144
|
+
return None
|
|
145
|
+
return (
|
|
146
|
+
"YouTube needs a JavaScript runtime to solve its n-challenge.\n\n"
|
|
147
|
+
"Polytool can manage one for you (no system install needed):\n"
|
|
148
|
+
" [cyan]pt dl runtime install[/cyan] "
|
|
149
|
+
"[dim]# downloads Deno (~50 MB) into ~/.polytool/runtime/[/dim]\n\n"
|
|
150
|
+
"Then re-run [cyan]pt dl get[/cyan]. The runtime is auto-detected on every call."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _hint_for_error(message: str, logger: _DlLogger) -> str | None:
|
|
155
|
+
"""Pick the best hint for a failure: bot-check first, then n-challenge."""
|
|
156
|
+
pool = [message, *logger.warnings, *logger.errors]
|
|
157
|
+
return _bot_check_hint(message) or _n_challenge_hint(pool)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
# `pt dl runtime` — manage the bundled JS runtime
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
runtime_app = typer.Typer(
|
|
164
|
+
name="runtime",
|
|
165
|
+
help="Manage the bundled JavaScript runtime (Deno) used to solve YouTube's n-challenge.",
|
|
166
|
+
no_args_is_help=True,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@runtime_app.command("install")
|
|
171
|
+
def cmd_runtime_install(
|
|
172
|
+
force: Annotated[
|
|
173
|
+
bool, typer.Option("--force", help="Re-download even if already installed.")
|
|
174
|
+
] = False,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Download Deno into ~/.polytool/runtime/ for yt-dlp to use.
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
|
|
180
|
+
pt dl runtime install
|
|
181
|
+
pt dl runtime install --force
|
|
182
|
+
"""
|
|
183
|
+
sys_path = runtime.system_runtime_path()
|
|
184
|
+
if sys_path and not force:
|
|
185
|
+
console.print(
|
|
186
|
+
f"[green]A system JS runtime is already on PATH:[/green] {sys_path}\n"
|
|
187
|
+
"[dim]No managed Deno needed. Pass --force to install anyway.[/dim]"
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
if runtime.is_managed_deno_installed() and not force:
|
|
191
|
+
console.print(
|
|
192
|
+
f"[green]Managed Deno already installed:[/green] {runtime.deno_binary_path()}\n"
|
|
193
|
+
"[dim]Pass --force to re-download.[/dim]"
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
try:
|
|
197
|
+
binary = runtime.install_deno()
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
raise PolytoolError(f"Could not install Deno: {exc}") from exc
|
|
200
|
+
runtime.ensure_runtime_in_path()
|
|
201
|
+
console.print(f"[green]Installed Deno[/green] at [bold]{binary}[/bold]")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@runtime_app.command("show")
|
|
205
|
+
def cmd_runtime_show() -> None:
|
|
206
|
+
"""Show the current JS-runtime status (system or managed).
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
|
|
210
|
+
pt dl runtime show
|
|
211
|
+
"""
|
|
212
|
+
sys_path = runtime.system_runtime_path()
|
|
213
|
+
if sys_path:
|
|
214
|
+
console.print(f"[cyan]system runtime:[/cyan] {sys_path}")
|
|
215
|
+
if runtime.is_managed_deno_installed():
|
|
216
|
+
console.print(f"[cyan]managed deno:[/cyan] {runtime.deno_binary_path()}")
|
|
217
|
+
if not sys_path and not runtime.is_managed_deno_installed():
|
|
218
|
+
console.print("[dim]No JS runtime found.[/dim]")
|
|
219
|
+
console.print("Run [cyan]pt dl runtime install[/cyan] to fetch one.")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@runtime_app.command("clear")
|
|
223
|
+
def cmd_runtime_clear() -> None:
|
|
224
|
+
"""Remove the managed Deno install at ~/.polytool/runtime/.
|
|
225
|
+
|
|
226
|
+
Examples:
|
|
227
|
+
|
|
228
|
+
pt dl runtime clear
|
|
229
|
+
"""
|
|
230
|
+
if not runtime.RUNTIME_DIR.exists():
|
|
231
|
+
console.print("[dim]Nothing to clear — managed runtime dir doesn't exist.[/dim]")
|
|
232
|
+
return
|
|
233
|
+
runtime.remove_runtime()
|
|
234
|
+
console.print(f"[green]Removed[/green] {runtime.RUNTIME_DIR}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
app.add_typer(runtime_app, name="runtime")
|
|
238
|
+
|
|
42
239
|
|
|
43
240
|
def _parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | None]:
|
|
44
241
|
"""Parse a polytool browser spec and return a yt-dlp-ready tuple.
|
|
@@ -316,12 +513,43 @@ def cmd_get(
|
|
|
316
513
|
yt_dlp = require_extra("yt_dlp", extra="dl")
|
|
317
514
|
|
|
318
515
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
# Make sure a JS runtime is reachable before we hit yt-dlp. If nothing is
|
|
517
|
+
# installed, fetch Deno automatically (one-time ~50 MB) — this is what the
|
|
518
|
+
# user expects from `polytool[full]`: it just works.
|
|
519
|
+
if runtime.ensure_runtime_in_path() is None:
|
|
520
|
+
err_console.print(
|
|
521
|
+
"[dim]No JS runtime found — polytool will fetch Deno once "
|
|
522
|
+
"(~50 MB into ~/.polytool/runtime/) so YouTube's n-challenge "
|
|
523
|
+
"can be solved.[/dim]"
|
|
524
|
+
)
|
|
525
|
+
try:
|
|
526
|
+
runtime.install_deno()
|
|
527
|
+
runtime.ensure_runtime_in_path()
|
|
528
|
+
except Exception as exc:
|
|
529
|
+
err_console.print(
|
|
530
|
+
f"[yellow]warning:[/yellow] auto-install failed: {exc}\n"
|
|
531
|
+
"[dim]Run [cyan]pt dl runtime install[/cyan] manually, "
|
|
532
|
+
"or install Deno/Node yourself.[/dim]"
|
|
533
|
+
)
|
|
534
|
+
logger = _DlLogger()
|
|
535
|
+
progress = _make_progress()
|
|
536
|
+
state: dict = {}
|
|
319
537
|
opts: dict = {
|
|
320
538
|
"outtmpl": str(output_dir / template),
|
|
321
539
|
"noplaylist": False,
|
|
322
|
-
"quiet":
|
|
323
|
-
"no_warnings":
|
|
324
|
-
"
|
|
540
|
+
"quiet": True, # silence yt-dlp's stdout — we render our own UI
|
|
541
|
+
"no_warnings": True, # warnings flow through our logger instead
|
|
542
|
+
"noprogress": True, # we render a Rich progress bar
|
|
543
|
+
"logger": logger,
|
|
544
|
+
"progress_hooks": [_make_progress_hook(progress, state)],
|
|
545
|
+
# Try multiple YouTube clients — `tv` alone often fails the n-challenge
|
|
546
|
+
# while web/android/ios still return usable formats. yt-dlp aggregates
|
|
547
|
+
# formats across all clients before selecting.
|
|
548
|
+
"extractor_args": {
|
|
549
|
+
"youtube": {
|
|
550
|
+
"player_client": ["web", "web_safari", "mweb", "android", "ios", "tv"],
|
|
551
|
+
}
|
|
552
|
+
},
|
|
325
553
|
}
|
|
326
554
|
if audio_only:
|
|
327
555
|
opts["format"] = "bestaudio/best"
|
|
@@ -330,6 +558,11 @@ def cmd_get(
|
|
|
330
558
|
]
|
|
331
559
|
elif format_:
|
|
332
560
|
opts["format"] = format_
|
|
561
|
+
else:
|
|
562
|
+
# `bv*+ba/b/18` — best video+audio, else best single, else fallback
|
|
563
|
+
# to YouTube's reliable 360p mp4 (format 18) which never needs the
|
|
564
|
+
# n-challenge solver and is available almost universally.
|
|
565
|
+
opts["format"] = "bv*+ba/b/18"
|
|
333
566
|
|
|
334
567
|
_apply_cookie_opts(opts, cookies_from_browser, cookies_file)
|
|
335
568
|
if username is not None:
|
|
@@ -340,16 +573,20 @@ def cmd_get(
|
|
|
340
573
|
opts["videopassword"] = video_password
|
|
341
574
|
if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
|
|
342
575
|
err_console.print(
|
|
343
|
-
f"[dim]
|
|
576
|
+
f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
|
|
344
577
|
)
|
|
345
578
|
|
|
346
579
|
try:
|
|
347
|
-
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
580
|
+
with progress, yt_dlp.YoutubeDL(opts) as ydl:
|
|
348
581
|
ydl.download([url])
|
|
349
582
|
except yt_dlp.utils.DownloadError as exc:
|
|
350
|
-
raise PolytoolError(
|
|
583
|
+
raise PolytoolError(
|
|
584
|
+
f"Download failed: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
|
|
585
|
+
) from exc
|
|
351
586
|
except Exception as exc:
|
|
352
|
-
raise PolytoolError(
|
|
587
|
+
raise PolytoolError(
|
|
588
|
+
f"Download failed: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
|
|
589
|
+
) from exc
|
|
353
590
|
console.print("[green]Done.[/green]")
|
|
354
591
|
|
|
355
592
|
|
|
@@ -401,7 +638,18 @@ def cmd_info(
|
|
|
401
638
|
|
|
402
639
|
yt_dlp = require_extra("yt_dlp", extra="dl")
|
|
403
640
|
|
|
404
|
-
|
|
641
|
+
runtime.ensure_runtime_in_path()
|
|
642
|
+
logger = _DlLogger()
|
|
643
|
+
opts: dict = {
|
|
644
|
+
"quiet": True,
|
|
645
|
+
"no_warnings": True,
|
|
646
|
+
"logger": logger,
|
|
647
|
+
"extractor_args": {
|
|
648
|
+
"youtube": {
|
|
649
|
+
"player_client": ["web", "web_safari", "mweb", "android", "ios", "tv"],
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
}
|
|
405
653
|
_apply_cookie_opts(opts, cookies_from_browser, cookies_file)
|
|
406
654
|
if username is not None:
|
|
407
655
|
opts["username"] = username
|
|
@@ -411,7 +659,7 @@ def cmd_info(
|
|
|
411
659
|
opts["videopassword"] = video_password
|
|
412
660
|
if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
|
|
413
661
|
err_console.print(
|
|
414
|
-
f"[dim]
|
|
662
|
+
f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
|
|
415
663
|
)
|
|
416
664
|
|
|
417
665
|
# process=False skips yt-dlp's format-selection step. We only want metadata,
|
|
@@ -421,7 +669,9 @@ def cmd_info(
|
|
|
421
669
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
422
670
|
info = ydl.extract_info(url, download=False, process=False)
|
|
423
671
|
except Exception as exc:
|
|
424
|
-
raise PolytoolError(
|
|
672
|
+
raise PolytoolError(
|
|
673
|
+
f"Could not fetch info: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
|
|
674
|
+
) from exc
|
|
425
675
|
|
|
426
676
|
if info is None:
|
|
427
677
|
raise PolytoolError("yt-dlp returned no metadata for this URL.")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Managed JavaScript runtime — Deno, downloaded on demand.
|
|
2
|
+
|
|
3
|
+
Some yt-dlp downloads (notably YouTube's "n-challenge") require an external
|
|
4
|
+
JS runtime to solve a piece of obfuscated JavaScript. Rather than make users
|
|
5
|
+
install Node/Deno system-wide, polytool can fetch a Deno binary into
|
|
6
|
+
``~/.polytool/runtime/`` and prepend that directory to ``PATH`` for yt-dlp's
|
|
7
|
+
subprocess lookups.
|
|
8
|
+
|
|
9
|
+
* Single binary, ~50-80 MB depending on platform.
|
|
10
|
+
* No admin rights needed — lives under the user's home dir.
|
|
11
|
+
* Removed cleanly via ``pt dl runtime clear``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import zipfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from polytool.core.console import err_console
|
|
25
|
+
|
|
26
|
+
RUNTIME_DIR: Path = Path.home() / ".polytool" / "runtime"
|
|
27
|
+
|
|
28
|
+
# We download from GitHub's "latest" redirect so the version follows upstream.
|
|
29
|
+
_DENO_LATEST_BASE = "https://github.com/denoland/deno/releases/latest/download"
|
|
30
|
+
|
|
31
|
+
# Names of system runtimes yt-dlp's EJS plugin will recognize on PATH.
|
|
32
|
+
_RECOGNIZED_RUNTIMES: tuple[str, ...] = ("deno", "node", "bun")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _deno_archive_name() -> str:
|
|
36
|
+
"""Return the Deno release archive name for this platform/arch."""
|
|
37
|
+
machine = platform.machine().lower()
|
|
38
|
+
if machine in {"x86_64", "amd64", "x64"}:
|
|
39
|
+
arch = "x86_64"
|
|
40
|
+
elif machine in {"arm64", "aarch64"}:
|
|
41
|
+
arch = "aarch64"
|
|
42
|
+
else:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"Unsupported CPU arch for managed Deno install: {machine!r}. "
|
|
45
|
+
"Install Deno or Node manually."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if sys.platform == "win32":
|
|
49
|
+
return f"deno-{arch}-pc-windows-msvc.zip"
|
|
50
|
+
if sys.platform == "darwin":
|
|
51
|
+
return f"deno-{arch}-apple-darwin.zip"
|
|
52
|
+
if sys.platform.startswith("linux"):
|
|
53
|
+
return f"deno-{arch}-unknown-linux-gnu.zip"
|
|
54
|
+
raise RuntimeError(f"Unsupported platform for managed Deno install: {sys.platform!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def deno_binary_path() -> Path:
|
|
58
|
+
"""Where the managed Deno binary lives on disk."""
|
|
59
|
+
name = "deno.exe" if sys.platform == "win32" else "deno"
|
|
60
|
+
return RUNTIME_DIR / name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def system_runtime_path() -> str | None:
|
|
64
|
+
"""Return the absolute path of any JS runtime already on the user's PATH."""
|
|
65
|
+
for tool in _RECOGNIZED_RUNTIMES:
|
|
66
|
+
found = shutil.which(tool)
|
|
67
|
+
if found:
|
|
68
|
+
return found
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_managed_deno_installed() -> bool:
|
|
73
|
+
"""True iff polytool's own Deno binary exists and is runnable."""
|
|
74
|
+
p = deno_binary_path()
|
|
75
|
+
if not p.exists():
|
|
76
|
+
return False
|
|
77
|
+
if sys.platform == "win32":
|
|
78
|
+
return True
|
|
79
|
+
return bool(p.stat().st_mode & stat.S_IXUSR)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def install_deno() -> Path:
|
|
83
|
+
"""Download Deno into ``RUNTIME_DIR`` and return the path to the binary.
|
|
84
|
+
|
|
85
|
+
Streams the zip with a Rich progress bar (this is a ~50-80 MB download).
|
|
86
|
+
Overwrites any existing binary.
|
|
87
|
+
"""
|
|
88
|
+
import httpx
|
|
89
|
+
from rich.progress import (
|
|
90
|
+
BarColumn,
|
|
91
|
+
DownloadColumn,
|
|
92
|
+
Progress,
|
|
93
|
+
SpinnerColumn,
|
|
94
|
+
TextColumn,
|
|
95
|
+
TimeRemainingColumn,
|
|
96
|
+
TransferSpeedColumn,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
archive_name = _deno_archive_name()
|
|
101
|
+
url = f"{_DENO_LATEST_BASE}/{archive_name}"
|
|
102
|
+
archive_path = RUNTIME_DIR / archive_name
|
|
103
|
+
|
|
104
|
+
err_console.print(f"[dim]downloading {archive_name}[/dim]")
|
|
105
|
+
progress = Progress(
|
|
106
|
+
SpinnerColumn(),
|
|
107
|
+
TextColumn("[bold cyan]Deno"),
|
|
108
|
+
BarColumn(),
|
|
109
|
+
DownloadColumn(),
|
|
110
|
+
TransferSpeedColumn(),
|
|
111
|
+
TimeRemainingColumn(),
|
|
112
|
+
console=err_console,
|
|
113
|
+
transient=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with progress, httpx.stream("GET", url, follow_redirects=True, timeout=300) as resp:
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
total = int(resp.headers.get("content-length") or 0) or None
|
|
119
|
+
task = progress.add_task("Deno", total=total)
|
|
120
|
+
with archive_path.open("wb") as f:
|
|
121
|
+
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
|
122
|
+
f.write(chunk)
|
|
123
|
+
progress.update(task, advance=len(chunk))
|
|
124
|
+
|
|
125
|
+
err_console.print("[dim]extracting...[/dim]")
|
|
126
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
127
|
+
zf.extractall(RUNTIME_DIR)
|
|
128
|
+
archive_path.unlink()
|
|
129
|
+
|
|
130
|
+
binary = deno_binary_path()
|
|
131
|
+
if not binary.exists():
|
|
132
|
+
raise RuntimeError(f"Deno extracted but binary not found at {binary}")
|
|
133
|
+
if sys.platform != "win32":
|
|
134
|
+
binary.chmod(binary.stat().st_mode | 0o755)
|
|
135
|
+
return binary
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_runtime_in_path() -> str | None:
|
|
139
|
+
"""Make sure a JS runtime is reachable to subprocesses.
|
|
140
|
+
|
|
141
|
+
Returns the absolute path of the runtime in use, or ``None`` if none is
|
|
142
|
+
available. If the managed Deno is installed but its directory isn't on
|
|
143
|
+
``PATH`` yet, we prepend it (in-process only — no shell rc files touched).
|
|
144
|
+
"""
|
|
145
|
+
sys_path = system_runtime_path()
|
|
146
|
+
if sys_path:
|
|
147
|
+
return sys_path
|
|
148
|
+
if is_managed_deno_installed():
|
|
149
|
+
binary = deno_binary_path()
|
|
150
|
+
parent = str(binary.parent)
|
|
151
|
+
current_path = os.environ.get("PATH", "")
|
|
152
|
+
segments = current_path.split(os.pathsep)
|
|
153
|
+
if parent not in segments:
|
|
154
|
+
os.environ["PATH"] = parent + os.pathsep + current_path
|
|
155
|
+
return str(binary)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def remove_runtime() -> None:
|
|
160
|
+
"""Delete the managed runtime directory."""
|
|
161
|
+
if RUNTIME_DIR.exists():
|
|
162
|
+
shutil.rmtree(RUNTIME_DIR)
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
"""Root Typer app — subcommand groups are added by their own modules.
|
|
2
|
-
|
|
3
|
-
Subcommand modules must keep top-level imports light (typer/rich/stdlib only).
|
|
4
|
-
Heavy imports (Pillow, ffmpeg, rembg) happen inside command bodies via
|
|
5
|
-
`polytool.core.lazy.require_extra`.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import sys
|
|
11
|
-
|
|
12
|
-
import typer
|
|
13
|
-
|
|
14
|
-
from polytool import __version__
|
|
15
|
-
from polytool.cli import (
|
|
16
|
-
clip,
|
|
17
|
-
color,
|
|
18
|
-
convert,
|
|
19
|
-
cron,
|
|
20
|
-
data,
|
|
21
|
-
dl,
|
|
22
|
-
enc,
|
|
23
|
-
file,
|
|
24
|
-
gen,
|
|
25
|
-
img,
|
|
26
|
-
net,
|
|
27
|
-
pdf,
|
|
28
|
-
qr,
|
|
29
|
-
shot,
|
|
30
|
-
text,
|
|
31
|
-
vid,
|
|
32
|
-
)
|
|
33
|
-
from polytool.core.errors import PolytoolError, render_panel
|
|
34
|
-
|
|
35
|
-
app = typer.Typer(
|
|
36
|
-
name="polytool",
|
|
37
|
-
help="polytool — one-binary CLI bundling 26 everyday utilities (pt for short).",
|
|
38
|
-
no_args_is_help=True,
|
|
39
|
-
add_completion=False,
|
|
40
|
-
rich_markup_mode="rich",
|
|
41
|
-
# We handle PolytoolError ourselves via the `main()` wrapper below; disable
|
|
42
|
-
# Typer's pretty-traceback so it doesn't squash our hints.
|
|
43
|
-
pretty_exceptions_enable=False,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def run() -> None:
|
|
48
|
-
"""Console-script entry point — runs the Typer app and renders PolytoolError nicely.
|
|
49
|
-
|
|
50
|
-
(Defined with a unique name to avoid clashing with the ``@app.callback()``
|
|
51
|
-
function below — both would otherwise be named ``main`` in this module.)
|
|
52
|
-
"""
|
|
53
|
-
try:
|
|
54
|
-
app()
|
|
55
|
-
except PolytoolError as exc:
|
|
56
|
-
render_panel(exc.message, exc.hint)
|
|
57
|
-
sys.exit(1)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _version_callback(value: bool) -> None:
|
|
61
|
-
if value:
|
|
62
|
-
typer.echo(f"polytool {__version__}")
|
|
63
|
-
raise typer.Exit
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@app.callback()
|
|
67
|
-
def main(
|
|
68
|
-
version: bool = typer.Option(
|
|
69
|
-
False,
|
|
70
|
-
"--version",
|
|
71
|
-
"-V",
|
|
72
|
-
callback=_version_callback,
|
|
73
|
-
is_eager=True,
|
|
74
|
-
help="Show version and exit.",
|
|
75
|
-
),
|
|
76
|
-
) -> None:
|
|
77
|
-
"""polytool — one-binary CLI bundling 26 everyday utilities."""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
app.add_typer(enc.app, name="enc")
|
|
81
|
-
app.add_typer(gen.app, name="gen")
|
|
82
|
-
app.add_typer(color.app, name="color")
|
|
83
|
-
app.add_typer(convert.app, name="convert")
|
|
84
|
-
app.add_typer(text.app, name="text")
|
|
85
|
-
app.add_typer(data.app, name="data")
|
|
86
|
-
app.add_typer(qr.app, name="qr")
|
|
87
|
-
app.add_typer(clip.app, name="clip")
|
|
88
|
-
app.add_typer(cron.app, name="cron")
|
|
89
|
-
app.add_typer(net.app, name="net")
|
|
90
|
-
app.add_typer(file.app, name="file")
|
|
91
|
-
app.add_typer(img.app, name="img")
|
|
92
|
-
app.add_typer(pdf.app, name="pdf")
|
|
93
|
-
app.add_typer(vid.app, name="vid")
|
|
94
|
-
app.add_typer(dl.app, name="dl")
|
|
95
|
-
app.add_typer(shot.app, name="shot")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
__all__ = ["app"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|