polytool 0.2.4__tar.gz → 0.2.7__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.
Files changed (36) hide show
  1. {polytool-0.2.4 → polytool-0.2.7}/PKG-INFO +3 -1
  2. {polytool-0.2.4 → polytool-0.2.7}/pyproject.toml +2 -2
  3. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/__init__.py +1 -1
  4. polytool-0.2.7/src/polytool/cli/__init__.py +211 -0
  5. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/dl.py +283 -11
  6. polytool-0.2.7/src/polytool/core/runtime.py +162 -0
  7. polytool-0.2.4/src/polytool/cli/__init__.py +0 -98
  8. {polytool-0.2.4 → polytool-0.2.7}/.gitignore +0 -0
  9. {polytool-0.2.4 → polytool-0.2.7}/LICENSE +0 -0
  10. {polytool-0.2.4 → polytool-0.2.7}/README.md +0 -0
  11. {polytool-0.2.4 → polytool-0.2.7}/docs/README.md +0 -0
  12. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/__main__.py +0 -0
  13. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/clip.py +0 -0
  14. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/color.py +0 -0
  15. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/convert.py +0 -0
  16. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/cron.py +0 -0
  17. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/data.py +0 -0
  18. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/enc.py +0 -0
  19. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/file.py +0 -0
  20. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/gen.py +0 -0
  21. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/img.py +0 -0
  22. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/net.py +0 -0
  23. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/pdf.py +0 -0
  24. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/qr.py +0 -0
  25. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/shot.py +0 -0
  26. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/text.py +0 -0
  27. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/cli/vid.py +0 -0
  28. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/__init__.py +0 -0
  29. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/browsers.py +0 -0
  30. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/config.py +0 -0
  31. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/console.py +0 -0
  32. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/errors.py +0 -0
  33. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/ffmpeg.py +0 -0
  34. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/io.py +0 -0
  35. {polytool-0.2.4 → polytool-0.2.7}/src/polytool/core/lazy.py +0 -0
  36. {polytool-0.2.4 → polytool-0.2.7}/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.4
3
+ Version: 0.2.7
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
@@ -48,6 +48,7 @@ Requires-Dist: pytest-cov>=5; extra == 'dev'
48
48
  Requires-Dist: pytest>=8; extra == 'dev'
49
49
  Requires-Dist: ruff>=0.5; extra == 'dev'
50
50
  Provides-Extra: dl
51
+ Requires-Dist: yt-dlp-ejs>=0.8; extra == 'dl'
51
52
  Requires-Dist: yt-dlp>=2024.5; extra == 'dl'
52
53
  Provides-Extra: full
53
54
  Requires-Dist: ascii-magic>=2; extra == 'full'
@@ -70,6 +71,7 @@ Requires-Dist: pytesseract>=0.3; extra == 'full'
70
71
  Requires-Dist: pyzbar>=0.1; extra == 'full'
71
72
  Requires-Dist: rembg[cpu]>=2.0; extra == 'full'
72
73
  Requires-Dist: resvg-py>=0.1; extra == 'full'
74
+ Requires-Dist: yt-dlp-ejs>=0.8; extra == 'full'
73
75
  Requires-Dist: yt-dlp>=2024.5; extra == 'full'
74
76
  Provides-Extra: img
75
77
  Requires-Dist: ascii-magic>=2; extra == 'img'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polytool"
7
- version = "0.2.4"
7
+ version = "0.2.7"
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"
@@ -58,7 +58,7 @@ img = [
58
58
  ]
59
59
  vid = ["ffmpeg-python>=0.2", "imageio-ffmpeg>=0.5"]
60
60
  pdf = ["pypdf>=4", "pikepdf>=8", "pdfplumber>=0.11", "pymupdf>=1.24"]
61
- dl = ["yt-dlp>=2024.5"]
61
+ dl = ["yt-dlp>=2024.5", "yt-dlp-ejs>=0.8"]
62
62
  ocr = ["pytesseract>=0.3", "easyocr>=1.7"]
63
63
  ai = ["rembg[cpu]>=2.0"]
64
64
  qr-decode = ["pyzbar>=0.1"]
@@ -1,4 +1,4 @@
1
1
  """polytool — one-binary CLI bundling 26 everyday utilities."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.7"
4
4
  __all__ = ["__version__"]
@@ -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,215 @@ 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
+ # Patterns that indicate per-session DRM lock — not something we can solve.
60
+ DRM_PATTERNS = (
61
+ "drm protected",
62
+ "drm-protected",
63
+ "experiment that applies drm",
64
+ )
65
+
66
+
67
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
68
+ _NOISE_RE = re.compile(
69
+ r"^(?:Extracting URL|Downloading\s\S+|Extracting cookies|Extracted\s|"
70
+ r"\[\w+\]\s|Sleeping|Skipping|Deleting original file)"
71
+ )
72
+
73
+
74
+ def _strip(msg: object) -> str:
75
+ text = _ANSI_RE.sub("", str(msg)).strip()
76
+ for prefix in ("WARNING:", "ERROR:", "[generic]", "[youtube]"):
77
+ if text.startswith(prefix):
78
+ text = text[len(prefix) :].strip()
79
+ return text
80
+
81
+
82
+ class _DlLogger:
83
+ """Capture yt-dlp warnings/errors so we can filter noise and surface hints."""
84
+
85
+ def __init__(self) -> None:
86
+ self.warnings: list[str] = []
87
+ self.errors: list[str] = []
88
+
89
+ def debug(self, msg: object) -> None:
90
+ pass
91
+
92
+ def info(self, msg: object) -> None:
93
+ pass
94
+
95
+ def warning(self, msg: object) -> None:
96
+ clean = _strip(msg)
97
+ if not clean:
98
+ return
99
+ self.warnings.append(clean)
100
+ if not _NOISE_RE.match(clean):
101
+ err_console.print(f"[yellow]warning:[/yellow] {clean}")
102
+
103
+ def error(self, msg: object) -> None:
104
+ clean = _strip(msg)
105
+ if clean:
106
+ self.errors.append(clean)
107
+
108
+
109
+ def _make_progress() -> Progress:
110
+ """Rich progress bar tuned for downloads (title · bar · % · size · speed · ETA)."""
111
+ return Progress(
112
+ SpinnerColumn(),
113
+ TextColumn("[bold cyan]{task.description}"),
114
+ BarColumn(bar_width=None),
115
+ TextColumn("[progress.percentage]{task.percentage:>5.1f}%"),
116
+ DownloadColumn(),
117
+ TransferSpeedColumn(),
118
+ TimeRemainingColumn(),
119
+ console=err_console,
120
+ transient=False,
121
+ )
122
+
123
+
124
+ def _make_progress_hook(progress: Progress, state: dict):
125
+ """Build a yt-dlp progress hook that drives the Rich progress bar."""
126
+
127
+ def hook(d: dict) -> None:
128
+ status = d.get("status")
129
+ if status == "downloading":
130
+ total = d.get("total_bytes") or d.get("total_bytes_estimate")
131
+ downloaded = d.get("downloaded_bytes", 0)
132
+ info = d.get("info_dict") or {}
133
+ title = info.get("title") or Path(d.get("filename", "download")).name
134
+ disp = title if len(title) <= 50 else title[:47] + "..."
135
+ tid = state.get("task_id")
136
+ if tid is None:
137
+ state["task_id"] = progress.add_task(disp, total=total)
138
+ else:
139
+ progress.update(tid, completed=downloaded, total=total, description=disp)
140
+ elif status == "finished":
141
+ tid = state.pop("task_id", None)
142
+ if tid is not None and progress.tasks[tid].total:
143
+ progress.update(tid, completed=progress.tasks[tid].total)
144
+
145
+ return hook
146
+
147
+
148
+ def _n_challenge_hint(messages: list[str]) -> str | None:
149
+ text = " ".join(messages).lower()
150
+ if not any(p in text for p in N_CHALLENGE_PATTERNS):
151
+ return None
152
+ return (
153
+ "YouTube needs a JavaScript runtime to solve its n-challenge.\n\n"
154
+ "Polytool can manage one for you (no system install needed):\n"
155
+ " [cyan]pt dl runtime install[/cyan] "
156
+ "[dim]# downloads Deno (~50 MB) into ~/.polytool/runtime/[/dim]\n\n"
157
+ "Then re-run [cyan]pt dl get[/cyan]. The runtime is auto-detected on every call."
158
+ )
159
+
160
+
161
+ def _drm_hint(messages: list[str]) -> str | None:
162
+ text = " ".join(messages).lower()
163
+ if not any(p in text for p in DRM_PATTERNS):
164
+ return None
165
+ return (
166
+ "YouTube has flagged this video as DRM-protected for your current session.\n"
167
+ "This is a per-account / per-session A/B experiment yt-dlp can't bypass.\n\n"
168
+ "Workarounds (any one usually works):\n"
169
+ " • Try without cookies: [cyan]pt dl get URL --cookies-from-browser ''[/cyan]\n"
170
+ " • Use a different browser profile: [cyan]pt dl setup --browser chrome[/cyan]\n"
171
+ " • Try a different YouTube account.\n"
172
+ " • Pick a different video — only some are DRM-flagged in your session."
173
+ )
174
+
175
+
176
+ def _hint_for_error(message: str, logger: _DlLogger) -> str | None:
177
+ """Pick the best hint for a failure: DRM > bot-check > n-challenge."""
178
+ pool = [message, *logger.warnings, *logger.errors]
179
+ return _drm_hint(pool) or _bot_check_hint(message) or _n_challenge_hint(pool)
180
+
181
+
182
+ # --------------------------------------------------------------------------- #
183
+ # `pt dl runtime` — manage the bundled JS runtime
184
+ # --------------------------------------------------------------------------- #
185
+ runtime_app = typer.Typer(
186
+ name="runtime",
187
+ help="Manage the bundled JavaScript runtime (Deno) used to solve YouTube's n-challenge.",
188
+ no_args_is_help=True,
189
+ )
190
+
191
+
192
+ @runtime_app.command("install")
193
+ def cmd_runtime_install(
194
+ force: Annotated[
195
+ bool, typer.Option("--force", help="Re-download even if already installed.")
196
+ ] = False,
197
+ ) -> None:
198
+ """Download Deno into ~/.polytool/runtime/ for yt-dlp to use.
199
+
200
+ Examples:
201
+
202
+ pt dl runtime install
203
+ pt dl runtime install --force
204
+ """
205
+ sys_path = runtime.system_runtime_path()
206
+ if sys_path and not force:
207
+ console.print(
208
+ f"[green]A system JS runtime is already on PATH:[/green] {sys_path}\n"
209
+ "[dim]No managed Deno needed. Pass --force to install anyway.[/dim]"
210
+ )
211
+ return
212
+ if runtime.is_managed_deno_installed() and not force:
213
+ console.print(
214
+ f"[green]Managed Deno already installed:[/green] {runtime.deno_binary_path()}\n"
215
+ "[dim]Pass --force to re-download.[/dim]"
216
+ )
217
+ return
218
+ try:
219
+ binary = runtime.install_deno()
220
+ except Exception as exc:
221
+ raise PolytoolError(f"Could not install Deno: {exc}") from exc
222
+ runtime.ensure_runtime_in_path()
223
+ console.print(f"[green]Installed Deno[/green] at [bold]{binary}[/bold]")
224
+
225
+
226
+ @runtime_app.command("show")
227
+ def cmd_runtime_show() -> None:
228
+ """Show the current JS-runtime status (system or managed).
229
+
230
+ Examples:
231
+
232
+ pt dl runtime show
233
+ """
234
+ sys_path = runtime.system_runtime_path()
235
+ if sys_path:
236
+ console.print(f"[cyan]system runtime:[/cyan] {sys_path}")
237
+ if runtime.is_managed_deno_installed():
238
+ console.print(f"[cyan]managed deno:[/cyan] {runtime.deno_binary_path()}")
239
+ if not sys_path and not runtime.is_managed_deno_installed():
240
+ console.print("[dim]No JS runtime found.[/dim]")
241
+ console.print("Run [cyan]pt dl runtime install[/cyan] to fetch one.")
242
+
243
+
244
+ @runtime_app.command("clear")
245
+ def cmd_runtime_clear() -> None:
246
+ """Remove the managed Deno install at ~/.polytool/runtime/.
247
+
248
+ Examples:
249
+
250
+ pt dl runtime clear
251
+ """
252
+ if not runtime.RUNTIME_DIR.exists():
253
+ console.print("[dim]Nothing to clear — managed runtime dir doesn't exist.[/dim]")
254
+ return
255
+ runtime.remove_runtime()
256
+ console.print(f"[green]Removed[/green] {runtime.RUNTIME_DIR}")
257
+
258
+
259
+ app.add_typer(runtime_app, name="runtime")
260
+
42
261
 
43
262
  def _parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | None]:
44
263
  """Parse a polytool browser spec and return a yt-dlp-ready tuple.
@@ -316,12 +535,43 @@ def cmd_get(
316
535
  yt_dlp = require_extra("yt_dlp", extra="dl")
317
536
 
318
537
  output_dir.mkdir(parents=True, exist_ok=True)
538
+ # Make sure a JS runtime is reachable before we hit yt-dlp. If nothing is
539
+ # installed, fetch Deno automatically (one-time ~50 MB) — this is what the
540
+ # user expects from `polytool[full]`: it just works.
541
+ if runtime.ensure_runtime_in_path() is None:
542
+ err_console.print(
543
+ "[dim]No JS runtime found — polytool will fetch Deno once "
544
+ "(~50 MB into ~/.polytool/runtime/) so YouTube's n-challenge "
545
+ "can be solved.[/dim]"
546
+ )
547
+ try:
548
+ runtime.install_deno()
549
+ runtime.ensure_runtime_in_path()
550
+ except Exception as exc:
551
+ err_console.print(
552
+ f"[yellow]warning:[/yellow] auto-install failed: {exc}\n"
553
+ "[dim]Run [cyan]pt dl runtime install[/cyan] manually, "
554
+ "or install Deno/Node yourself.[/dim]"
555
+ )
556
+ logger = _DlLogger()
557
+ progress = _make_progress()
558
+ state: dict = {}
319
559
  opts: dict = {
320
560
  "outtmpl": str(output_dir / template),
321
561
  "noplaylist": False,
322
- "quiet": False,
323
- "no_warnings": False,
324
- "progress": True,
562
+ "quiet": True, # silence yt-dlp's stdout — we render our own UI
563
+ "no_warnings": True, # warnings flow through our logger instead
564
+ "noprogress": True, # we render a Rich progress bar
565
+ "logger": logger,
566
+ "progress_hooks": [_make_progress_hook(progress, state)],
567
+ # Try multiple YouTube clients — `tv` alone often fails the n-challenge
568
+ # while web/android/ios still return usable formats. yt-dlp aggregates
569
+ # formats across all clients before selecting.
570
+ "extractor_args": {
571
+ "youtube": {
572
+ "player_client": ["web", "web_safari", "mweb", "android", "ios", "tv"],
573
+ }
574
+ },
325
575
  }
326
576
  if audio_only:
327
577
  opts["format"] = "bestaudio/best"
@@ -330,6 +580,11 @@ def cmd_get(
330
580
  ]
331
581
  elif format_:
332
582
  opts["format"] = format_
583
+ else:
584
+ # `bv*+ba/b/18` — best video+audio, else best single, else fallback
585
+ # to YouTube's reliable 360p mp4 (format 18) which never needs the
586
+ # n-challenge solver and is available almost universally.
587
+ opts["format"] = "bv*+ba/b/18"
333
588
 
334
589
  _apply_cookie_opts(opts, cookies_from_browser, cookies_file)
335
590
  if username is not None:
@@ -340,16 +595,20 @@ def cmd_get(
340
595
  opts["videopassword"] = video_password
341
596
  if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
342
597
  err_console.print(
343
- f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
598
+ f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
344
599
  )
345
600
 
346
601
  try:
347
- with yt_dlp.YoutubeDL(opts) as ydl:
602
+ with progress, yt_dlp.YoutubeDL(opts) as ydl:
348
603
  ydl.download([url])
349
604
  except yt_dlp.utils.DownloadError as exc:
350
- raise PolytoolError(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
605
+ raise PolytoolError(
606
+ f"Download failed: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
607
+ ) from exc
351
608
  except Exception as exc:
352
- raise PolytoolError(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
609
+ raise PolytoolError(
610
+ f"Download failed: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
611
+ ) from exc
353
612
  console.print("[green]Done.[/green]")
354
613
 
355
614
 
@@ -401,7 +660,18 @@ def cmd_info(
401
660
 
402
661
  yt_dlp = require_extra("yt_dlp", extra="dl")
403
662
 
404
- opts: dict = {"quiet": True, "no_warnings": True}
663
+ runtime.ensure_runtime_in_path()
664
+ logger = _DlLogger()
665
+ opts: dict = {
666
+ "quiet": True,
667
+ "no_warnings": True,
668
+ "logger": logger,
669
+ "extractor_args": {
670
+ "youtube": {
671
+ "player_client": ["web", "web_safari", "mweb", "android", "ios", "tv"],
672
+ }
673
+ },
674
+ }
405
675
  _apply_cookie_opts(opts, cookies_from_browser, cookies_file)
406
676
  if username is not None:
407
677
  opts["username"] = username
@@ -411,7 +681,7 @@ def cmd_info(
411
681
  opts["videopassword"] = video_password
412
682
  if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
413
683
  err_console.print(
414
- f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
684
+ f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
415
685
  )
416
686
 
417
687
  # process=False skips yt-dlp's format-selection step. We only want metadata,
@@ -421,7 +691,9 @@ def cmd_info(
421
691
  with yt_dlp.YoutubeDL(opts) as ydl:
422
692
  info = ydl.extract_info(url, download=False, process=False)
423
693
  except Exception as exc:
424
- raise PolytoolError(f"Could not fetch info: {exc}", hint=_bot_check_hint(str(exc))) from exc
694
+ raise PolytoolError(
695
+ f"Could not fetch info: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
696
+ ) from exc
425
697
 
426
698
  if info is None:
427
699
  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