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.
- {polytool-0.2.7 → polytool-0.2.9}/PKG-INFO +1 -1
- {polytool-0.2.7 → polytool-0.2.9}/pyproject.toml +1 -1
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/__init__.py +1 -1
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/__init__.py +13 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/dl.py +130 -23
- {polytool-0.2.7 → polytool-0.2.9}/.gitignore +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/LICENSE +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/README.md +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/docs/README.md +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/__main__.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/clip.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/color.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/convert.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/cron.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/data.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/enc.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/file.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/gen.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/img.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/net.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/pdf.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/qr.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/shot.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/text.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/cli/vid.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/__init__.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/browsers.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/config.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/console.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/errors.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/ffmpeg.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/io.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/lazy.py +0 -0
- {polytool-0.2.7 → polytool-0.2.9}/src/polytool/core/progress.py +0 -0
- {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.
|
|
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
|
+
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"
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|