polytool 0.2.7__tar.gz → 0.2.9__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 (35) hide show
  1. {polytool-0.2.7 → polytool-0.2.9}/PKG-INFO +1 -1
  2. {polytool-0.2.7 → polytool-0.2.9}/pyproject.toml +1 -1
  3. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/__init__.py +1 -1
  4. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/__init__.py +13 -0
  5. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/dl.py +130 -23
  6. {polytool-0.2.7 → polytool-0.2.9}/.gitignore +0 -0
  7. {polytool-0.2.7 → polytool-0.2.9}/LICENSE +0 -0
  8. {polytool-0.2.7 → polytool-0.2.9}/README.md +0 -0
  9. {polytool-0.2.7 → polytool-0.2.9}/docs/README.md +0 -0
  10. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/__main__.py +0 -0
  11. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/clip.py +0 -0
  12. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/color.py +0 -0
  13. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/convert.py +0 -0
  14. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/cron.py +0 -0
  15. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/data.py +0 -0
  16. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/enc.py +0 -0
  17. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/file.py +0 -0
  18. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/gen.py +0 -0
  19. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/img.py +0 -0
  20. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/net.py +0 -0
  21. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/pdf.py +0 -0
  22. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/qr.py +0 -0
  23. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/shot.py +0 -0
  24. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/text.py +0 -0
  25. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/vid.py +0 -0
  26. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/__init__.py +0 -0
  27. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/browsers.py +0 -0
  28. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/config.py +0 -0
  29. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/console.py +0 -0
  30. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/errors.py +0 -0
  31. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/ffmpeg.py +0 -0
  32. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/io.py +0 -0
  33. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/lazy.py +0 -0
  34. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/progress.py +0 -0
  35. {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/runtime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytool
3
- Version: 0.2.7
3
+ Version: 0.2.9
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"
7
+ version = "0.2.9"
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.7"
3
+ __version__ = "0.2.9"
4
4
  __all__ = ["__version__"]
@@ -48,12 +48,25 @@ app = typer.Typer(
48
48
  )
49
49
 
50
50
 
51
+ def _force_utf8_io() -> None:
52
+ """On Windows the default code page is often cp1252 and unicode chars
53
+ in Rich output trip ``UnicodeEncodeError``. Reconfigure stdout/stderr to
54
+ UTF-8 with a safe fallback before any Rich output happens.
55
+ """
56
+ import contextlib
57
+
58
+ for stream in (sys.stdout, sys.stderr):
59
+ with contextlib.suppress(AttributeError, OSError):
60
+ stream.reconfigure(encoding="utf-8", errors="replace")
61
+
62
+
51
63
  def run() -> None:
52
64
  """Console-script entry point — runs the Typer app and renders PolytoolError nicely.
53
65
 
54
66
  (Defined with a unique name to avoid clashing with the ``@app.callback()``
55
67
  function below — both would otherwise be named ``main`` in this module.)
56
68
  """
69
+ _force_utf8_io()
57
70
  try:
58
71
  app()
59
72
  except PolytoolError as exc:
@@ -65,11 +65,30 @@ DRM_PATTERNS = (
65
65
 
66
66
 
67
67
  _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
68
- _NOISE_RE = re.compile(
68
+
69
+ # Lines we *never* show — pure status output from yt-dlp's discovery pipeline.
70
+ _PROGRESS_NOISE_RE = re.compile(
69
71
  r"^(?:Extracting URL|Downloading\s\S+|Extracting cookies|Extracted\s|"
70
72
  r"\[\w+\]\s|Sleeping|Skipping|Deleting original file)"
71
73
  )
72
74
 
75
+ # Per-client warnings yt-dlp emits while it tries multiple YouTube extractors.
76
+ # These are informational — when at least one client succeeds, the user
77
+ # doesn't need to know that the others were skipped. We capture them in the
78
+ # logger so the n-challenge hint can still see them if the download ultimately
79
+ # fails, but we don't print them to the terminal during a successful run.
80
+ _CLIENT_NOISE_PATTERNS = (
81
+ "n challenge solving failed",
82
+ "some formats may be missing",
83
+ "client https formats require a gvs po token",
84
+ "client hls formats require a gvs po token",
85
+ "client formats require a gvs po token",
86
+ "have been skipped as they are drm protected",
87
+ "experiment that applies drm",
88
+ "skipped as they are drm",
89
+ "ensure you have a supported javascript runtime",
90
+ )
91
+
73
92
 
74
93
  def _strip(msg: object) -> str:
75
94
  text = _ANSI_RE.sub("", str(msg)).strip()
@@ -79,12 +98,22 @@ def _strip(msg: object) -> str:
79
98
  return text
80
99
 
81
100
 
101
+ def _is_noise(text: str) -> bool:
102
+ if _PROGRESS_NOISE_RE.match(text):
103
+ return True
104
+ low = text.lower()
105
+ return any(p in low for p in _CLIENT_NOISE_PATTERNS)
106
+
107
+
82
108
  class _DlLogger:
83
- """Capture yt-dlp warnings/errors so we can filter noise and surface hints."""
109
+ """Capture yt-dlp warnings/errors. Filter the noisy per-client status
110
+ warnings out of terminal output (still recorded for hint generation).
111
+ """
84
112
 
85
- def __init__(self) -> None:
113
+ def __init__(self, verbose: bool = False) -> None:
86
114
  self.warnings: list[str] = []
87
115
  self.errors: list[str] = []
116
+ self.verbose = verbose
88
117
 
89
118
  def debug(self, msg: object) -> None:
90
119
  pass
@@ -97,7 +126,7 @@ class _DlLogger:
97
126
  if not clean:
98
127
  return
99
128
  self.warnings.append(clean)
100
- if not _NOISE_RE.match(clean):
129
+ if self.verbose or not _is_noise(clean):
101
130
  err_console.print(f"[yellow]warning:[/yellow] {clean}")
102
131
 
103
132
  def error(self, msg: object) -> None:
@@ -122,7 +151,11 @@ def _make_progress() -> Progress:
122
151
 
123
152
 
124
153
  def _make_progress_hook(progress: Progress, state: dict):
125
- """Build a yt-dlp progress hook that drives the Rich progress bar."""
154
+ """Build a yt-dlp progress hook that drives the Rich progress bar.
155
+
156
+ Also stashes the final filename/size/title in *state* so the success
157
+ summary can read them after the download completes.
158
+ """
126
159
 
127
160
  def hook(d: dict) -> None:
128
161
  status = d.get("status")
@@ -132,12 +165,16 @@ def _make_progress_hook(progress: Progress, state: dict):
132
165
  info = d.get("info_dict") or {}
133
166
  title = info.get("title") or Path(d.get("filename", "download")).name
134
167
  disp = title if len(title) <= 50 else title[:47] + "..."
168
+ state["title"] = title
135
169
  tid = state.get("task_id")
136
170
  if tid is None:
137
171
  state["task_id"] = progress.add_task(disp, total=total)
138
172
  else:
139
173
  progress.update(tid, completed=downloaded, total=total, description=disp)
140
174
  elif status == "finished":
175
+ info = d.get("info_dict") or {}
176
+ state["filename"] = d.get("filename") or info.get("_filename") or ""
177
+ state["total_bytes"] = d.get("total_bytes") or d.get("total_bytes_estimate")
141
178
  tid = state.pop("task_id", None)
142
179
  if tid is not None and progress.tasks[tid].total:
143
180
  progress.update(tid, completed=progress.tasks[tid].total)
@@ -145,17 +182,62 @@ def _make_progress_hook(progress: Progress, state: dict):
145
182
  return hook
146
183
 
147
184
 
185
+ def _human_size(n: int | None) -> str:
186
+ if not n:
187
+ return "—"
188
+ f = float(n)
189
+ for unit in ("B", "KB", "MB", "GB", "TB"):
190
+ if f < 1024:
191
+ return f"{f:.1f} {unit}"
192
+ f /= 1024
193
+ return f"{f:.1f} PB"
194
+
195
+
196
+ def _human_duration(seconds: float) -> str:
197
+ if seconds < 1:
198
+ return f"{seconds * 1000:.0f}ms"
199
+ if seconds < 60:
200
+ return f"{seconds:.1f}s"
201
+ m, s = divmod(int(seconds), 60)
202
+ if m < 60:
203
+ return f"{m}m {s}s"
204
+ h, m = divmod(m, 60)
205
+ return f"{h}h {m}m {s}s"
206
+
207
+
148
208
  def _n_challenge_hint(messages: list[str]) -> str | None:
149
209
  text = " ".join(messages).lower()
150
210
  if not any(p in text for p in N_CHALLENGE_PATTERNS):
151
211
  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"
212
+
213
+ # When cookies are configured we know we're in a logged-in session — that's
214
+ # by far the most common cause of these errors (YouTube tightens access for
215
+ # accounts in their DRM A/B bucket). Try anonymous access first.
216
+ using_cookies = bool(
217
+ config.get("dl", "cookies_from_browser") or config.get("dl", "cookies_file")
218
+ )
219
+
220
+ fixes: list[str] = []
221
+ if using_cookies:
222
+ fixes.append(
223
+ "[bold]Most likely fix:[/bold] try without cookies. YouTube often gates "
224
+ "logged-in sessions into a DRM-only bucket.\n"
225
+ " [cyan]pt dl setup --clear[/cyan] [dim]# forget saved cookies (just for testing)[/dim]\n"
226
+ " [cyan]pt dl get URL[/cyan]\n"
227
+ "If that works, re-save cookies for sites that need them: "
228
+ "[cyan]pt dl setup --browser firefox[/cyan]"
229
+ )
230
+ fixes.append(
231
+ "If it still fails, make sure a JS runtime is on PATH. Polytool can manage one:\n"
155
232
  " [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."
233
+ "[dim]# downloads Deno (~50 MB) into ~/.polytool/runtime/[/dim]"
158
234
  )
235
+ fixes.append(
236
+ "Some videos are flagged DRM in your specific YouTube session — those can't "
237
+ "be downloaded by any tool. Try a different video or account."
238
+ )
239
+
240
+ return "\n\n".join(fixes)
159
241
 
160
242
 
161
243
  def _drm_hint(messages: list[str]) -> str | None:
@@ -515,6 +597,14 @@ def cmd_get(
515
597
  str | None,
516
598
  typer.Option("--video-password", help="Per-video password (Vimeo etc.)."),
517
599
  ] = None,
600
+ verbose: Annotated[
601
+ bool,
602
+ typer.Option(
603
+ "--verbose",
604
+ "-v",
605
+ help="Show every yt-dlp warning (not just the user-actionable ones).",
606
+ ),
607
+ ] = False,
518
608
  ) -> None:
519
609
  """Download a video or audio file.
520
610
 
@@ -529,7 +619,10 @@ def cmd_get(
529
619
  pt dl get URL --cookies-from-browser firefox # one-off override
530
620
  pt dl get URL --username alice --password 'hunter2'
531
621
  pt dl get URL --video-password 'secret' # Vimeo-style per-video pwd
622
+ pt dl get URL --verbose # show all warnings
532
623
  """
624
+ import time
625
+
533
626
  from polytool.core.lazy import require_extra
534
627
 
535
628
  yt_dlp = require_extra("yt_dlp", extra="dl")
@@ -539,11 +632,7 @@ def cmd_get(
539
632
  # installed, fetch Deno automatically (one-time ~50 MB) — this is what the
540
633
  # user expects from `polytool[full]`: it just works.
541
634
  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
- )
635
+ err_console.print("[dim]no JS runtime found — fetching Deno (~50 MB, one-time)...[/dim]")
547
636
  try:
548
637
  runtime.install_deno()
549
638
  runtime.ensure_runtime_in_path()
@@ -553,7 +642,7 @@ def cmd_get(
553
642
  "[dim]Run [cyan]pt dl runtime install[/cyan] manually, "
554
643
  "or install Deno/Node yourself.[/dim]"
555
644
  )
556
- logger = _DlLogger()
645
+ logger = _DlLogger(verbose=verbose)
557
646
  progress = _make_progress()
558
647
  state: dict = {}
559
648
  opts: dict = {
@@ -594,10 +683,9 @@ def cmd_get(
594
683
  if video_password is not None:
595
684
  opts["videopassword"] = video_password
596
685
  if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
597
- err_console.print(
598
- f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
599
- )
686
+ err_console.print(f"[dim]cookies: {opts['cookiesfrombrowser'][0]}[/dim]")
600
687
 
688
+ started = time.monotonic()
601
689
  try:
602
690
  with progress, yt_dlp.YoutubeDL(opts) as ydl:
603
691
  ydl.download([url])
@@ -609,7 +697,28 @@ def cmd_get(
609
697
  raise PolytoolError(
610
698
  f"Download failed: {_strip(exc)}", hint=_hint_for_error(str(exc), logger)
611
699
  ) from exc
612
- console.print("[green]Done.[/green]")
700
+
701
+ elapsed = time.monotonic() - started
702
+ title = state.get("title") or "download"
703
+ filename = state.get("filename")
704
+ size = state.get("total_bytes")
705
+ out_path = Path(filename) if filename else None
706
+
707
+ console.print()
708
+ console.print(f" [green bold]done[/green bold] [bold]{title}[/bold]")
709
+ if out_path is not None:
710
+ try:
711
+ display_path = out_path.resolve().relative_to(Path.cwd())
712
+ except (ValueError, OSError):
713
+ display_path = out_path
714
+ console.print(
715
+ f" [dim]{display_path} "
716
+ f"({_human_size(size)} in {_human_duration(elapsed)})[/dim]"
717
+ )
718
+ else:
719
+ console.print(
720
+ f" [dim]({_human_size(size)} in {_human_duration(elapsed)})[/dim]"
721
+ )
613
722
 
614
723
 
615
724
  @app.command("info")
@@ -680,9 +789,7 @@ def cmd_info(
680
789
  if video_password is not None:
681
790
  opts["videopassword"] = video_password
682
791
  if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
683
- err_console.print(
684
- f"[dim]using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
685
- )
792
+ err_console.print(f"[dim]cookies: {opts['cookiesfrombrowser'][0]}[/dim]")
686
793
 
687
794
  # process=False skips yt-dlp's format-selection step. We only want metadata,
688
795
  # and on some sites (YouTube especially) the default format selector
File without changes
File without changes
File without changes
File without changes