polytool 0.2.3__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.
Files changed (36) hide show
  1. {polytool-0.2.3 → polytool-0.2.6}/PKG-INFO +1 -1
  2. {polytool-0.2.3 → polytool-0.2.6}/pyproject.toml +1 -1
  3. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/__init__.py +1 -1
  4. polytool-0.2.6/src/polytool/cli/__init__.py +211 -0
  5. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/dl.py +287 -14
  6. polytool-0.2.6/src/polytool/core/runtime.py +162 -0
  7. polytool-0.2.3/src/polytool/cli/__init__.py +0 -98
  8. {polytool-0.2.3 → polytool-0.2.6}/.gitignore +0 -0
  9. {polytool-0.2.3 → polytool-0.2.6}/LICENSE +0 -0
  10. {polytool-0.2.3 → polytool-0.2.6}/README.md +0 -0
  11. {polytool-0.2.3 → polytool-0.2.6}/docs/README.md +0 -0
  12. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/__main__.py +0 -0
  13. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/clip.py +0 -0
  14. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/color.py +0 -0
  15. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/convert.py +0 -0
  16. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/cron.py +0 -0
  17. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/data.py +0 -0
  18. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/enc.py +0 -0
  19. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/file.py +0 -0
  20. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/gen.py +0 -0
  21. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/img.py +0 -0
  22. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/net.py +0 -0
  23. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/pdf.py +0 -0
  24. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/qr.py +0 -0
  25. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/shot.py +0 -0
  26. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/text.py +0 -0
  27. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/cli/vid.py +0 -0
  28. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/__init__.py +0 -0
  29. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/browsers.py +0 -0
  30. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/config.py +0 -0
  31. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/console.py +0 -0
  32. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/errors.py +0 -0
  33. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/ffmpeg.py +0 -0
  34. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/io.py +0 -0
  35. {polytool-0.2.3 → polytool-0.2.6}/src/polytool/core/lazy.py +0 -0
  36. {polytool-0.2.3 → 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
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.3"
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"
@@ -1,4 +1,4 @@
1
1
  """polytool — one-binary CLI bundling 26 everyday utilities."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.6"
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,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": False,
323
- "no_warnings": False,
324
- "progress": True,
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]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/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(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
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(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
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
- opts: dict = {"quiet": True, "no_warnings": True}
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,16 +659,41 @@ 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]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
662
+ f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
415
663
  )
416
664
 
665
+ # process=False skips yt-dlp's format-selection step. We only want metadata,
666
+ # and on some sites (YouTube especially) the default format selector
667
+ # fails with "Requested format is not available" on otherwise-valid videos.
417
668
  try:
418
669
  with yt_dlp.YoutubeDL(opts) as ydl:
419
- info = ydl.extract_info(url, download=False)
670
+ info = ydl.extract_info(url, download=False, process=False)
420
671
  except Exception as exc:
421
- raise PolytoolError(f"Could not fetch info: {exc}", hint=_bot_check_hint(str(exc))) from exc
422
-
423
- interesting = ("title", "uploader", "duration", "view_count", "upload_date", "webpage_url")
672
+ raise PolytoolError(
673
+ f"Could not fetch info: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
674
+ ) from exc
675
+
676
+ if info is None:
677
+ raise PolytoolError("yt-dlp returned no metadata for this URL.")
678
+
679
+ interesting = (
680
+ "title",
681
+ "uploader",
682
+ "channel",
683
+ "duration",
684
+ "view_count",
685
+ "like_count",
686
+ "upload_date",
687
+ "live_status",
688
+ "webpage_url",
689
+ )
690
+ shown = False
424
691
  for key in interesting:
425
692
  if key in info and info[key] is not None:
426
693
  console.print(f"[cyan]{key}[/cyan]: {info[key]}")
694
+ shown = True
695
+ # Playlists and some sites return only `entries` and a few top-level fields.
696
+ if not shown and info.get("_type") == "playlist":
697
+ console.print(f"[cyan]playlist[/cyan]: {info.get('title') or info.get('id')}")
698
+ entries = info.get("entries") or []
699
+ console.print(f"[cyan]entries[/cyan]: {len(list(entries))}")
@@ -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