polytool 0.2.1__tar.gz → 0.2.3__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.1 → polytool-0.2.3}/PKG-INFO +1 -1
- {polytool-0.2.1 → polytool-0.2.3}/docs/README.md +1 -0
- {polytool-0.2.1 → polytool-0.2.3}/pyproject.toml +1 -1
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/__init__.py +1 -1
- polytool-0.2.3/src/polytool/cli/dl.py +426 -0
- polytool-0.2.3/src/polytool/core/browsers.py +162 -0
- polytool-0.2.3/src/polytool/core/config.py +85 -0
- polytool-0.2.1/src/polytool/cli/dl.py +0 -102
- {polytool-0.2.1 → polytool-0.2.3}/.gitignore +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/LICENSE +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/README.md +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/__main__.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/__init__.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/clip.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/color.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/convert.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/cron.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/data.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/enc.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/file.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/gen.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/img.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/net.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/pdf.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/qr.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/shot.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/text.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/cli/vid.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/__init__.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/console.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/errors.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/ffmpeg.py +2 -2
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/io.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/lazy.py +0 -0
- {polytool-0.2.1 → polytool-0.2.3}/src/polytool/core/progress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: polytool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -7,6 +7,7 @@ Detailed reference for every command in `polytool` (binary `pt`).
|
|
|
7
7
|
## Quick links
|
|
8
8
|
|
|
9
9
|
- **[Install](install.md)** — `uv tool install`, slim vs full, extras, Windows quoting
|
|
10
|
+
- **[Auth & external services](auth.md)** — which verbs touch the network and how to authenticate
|
|
10
11
|
- **[Troubleshooting](troubleshooting.md)** — every common gotcha with a fix
|
|
11
12
|
- **[Architecture](architecture.md)** — package layout, lazy imports, error model
|
|
12
13
|
|
|
@@ -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.3"
|
|
8
8
|
description = "One-binary CLI bundling 26 everyday utilities — image/video/PDF conversion, background removal, OCR, QR codes, hashing, downloads, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Download media from YouTube and 1000+ sites via yt-dlp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from polytool.core import config
|
|
11
|
+
from polytool.core.browsers import (
|
|
12
|
+
ALL_BROWSERS,
|
|
13
|
+
FIREFOX_FORK_DIRS,
|
|
14
|
+
YT_DLP_NATIVE_BROWSERS,
|
|
15
|
+
find_firefox_fork_data_dir,
|
|
16
|
+
resolve_firefox_profile_dir,
|
|
17
|
+
)
|
|
18
|
+
from polytool.core.console import console, err_console
|
|
19
|
+
from polytool.core.errors import PolytoolError
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="dl",
|
|
23
|
+
help="Download media from YouTube and 1000+ sites (yt-dlp).",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Browsers `pt dl setup` accepts. Includes yt-dlp's native list plus the
|
|
28
|
+
# Firefox forks we resolve ourselves (zen, librewolf, waterfox, floorp, mullvad).
|
|
29
|
+
SUPPORTED_BROWSERS = ALL_BROWSERS
|
|
30
|
+
|
|
31
|
+
BOT_CHECK_HINTS = (
|
|
32
|
+
"sign in",
|
|
33
|
+
"confirm you",
|
|
34
|
+
"not a bot",
|
|
35
|
+
"captcha",
|
|
36
|
+
"private video",
|
|
37
|
+
"members-only",
|
|
38
|
+
"login required",
|
|
39
|
+
"rate limit",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | None]:
|
|
44
|
+
"""Parse a polytool browser spec and return a yt-dlp-ready tuple.
|
|
45
|
+
|
|
46
|
+
Format: ``browser[+keyring][:profile][::container]``.
|
|
47
|
+
|
|
48
|
+
For yt-dlp's native browsers this is a pass-through. For Firefox forks
|
|
49
|
+
(zen / librewolf / waterfox / floorp / mullvad), we locate the user's
|
|
50
|
+
data dir + profile directory and return ``('firefox', <abs-path>, …)`` —
|
|
51
|
+
yt-dlp's firefox extractor accepts an absolute profile path.
|
|
52
|
+
|
|
53
|
+
>>> _parse_browser_spec("chrome")
|
|
54
|
+
('chrome', None, None, None)
|
|
55
|
+
>>> _parse_browser_spec("firefox:default-release")
|
|
56
|
+
('firefox', 'default-release', None, None)
|
|
57
|
+
>>> _parse_browser_spec("firefox+gnomekeyring:default::personal")
|
|
58
|
+
('firefox', 'default', 'gnomekeyring', 'personal')
|
|
59
|
+
"""
|
|
60
|
+
rest = spec
|
|
61
|
+
container: str | None = None
|
|
62
|
+
if "::" in rest:
|
|
63
|
+
rest, _, container = rest.partition("::")
|
|
64
|
+
profile: str | None = None
|
|
65
|
+
if ":" in rest:
|
|
66
|
+
rest, _, profile = rest.partition(":")
|
|
67
|
+
keyring: str | None = None
|
|
68
|
+
if "+" in rest:
|
|
69
|
+
rest, _, keyring = rest.partition("+")
|
|
70
|
+
browser = rest.strip().lower()
|
|
71
|
+
if not browser:
|
|
72
|
+
raise PolytoolError(
|
|
73
|
+
f"Invalid --cookies-from-browser spec: {spec!r}",
|
|
74
|
+
hint="Format: browser[+keyring][:profile][::container].",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Firefox forks: resolve profile dir ourselves, pass it to yt-dlp as firefox.
|
|
78
|
+
if browser in FIREFOX_FORK_DIRS:
|
|
79
|
+
data_dir = find_firefox_fork_data_dir(browser)
|
|
80
|
+
if data_dir is None:
|
|
81
|
+
raise PolytoolError(
|
|
82
|
+
f"Could not find {browser!r} data directory.",
|
|
83
|
+
hint=(
|
|
84
|
+
f"Make sure {browser} is installed and has been launched at "
|
|
85
|
+
f"least once on this machine."
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
profile_dir = resolve_firefox_profile_dir(data_dir, profile)
|
|
90
|
+
except FileNotFoundError as exc:
|
|
91
|
+
raise PolytoolError(str(exc)) from exc
|
|
92
|
+
return ("firefox", str(profile_dir), keyring or None, container or None)
|
|
93
|
+
|
|
94
|
+
if browser not in YT_DLP_NATIVE_BROWSERS:
|
|
95
|
+
raise PolytoolError(
|
|
96
|
+
f"Unknown browser {browser!r}.",
|
|
97
|
+
hint=f"Supported: {', '.join(SUPPORTED_BROWSERS)}.",
|
|
98
|
+
)
|
|
99
|
+
return (browser, profile or None, keyring or None, container or None)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_cookies(
|
|
103
|
+
cookies_from_browser: str | None,
|
|
104
|
+
cookies_file: Path | None,
|
|
105
|
+
) -> tuple[str | None, Path | None]:
|
|
106
|
+
"""Pick the active cookie source: explicit flags first, then saved config."""
|
|
107
|
+
if cookies_from_browser and cookies_file:
|
|
108
|
+
raise PolytoolError("Use --cookies-from-browser OR --cookies, not both.")
|
|
109
|
+
if cookies_from_browser:
|
|
110
|
+
return cookies_from_browser, None
|
|
111
|
+
if cookies_file:
|
|
112
|
+
return None, cookies_file
|
|
113
|
+
saved_browser = config.get("dl", "cookies_from_browser")
|
|
114
|
+
saved_file = config.get("dl", "cookies_file")
|
|
115
|
+
if saved_browser:
|
|
116
|
+
return saved_browser, None
|
|
117
|
+
if saved_file:
|
|
118
|
+
return None, Path(saved_file)
|
|
119
|
+
return None, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _apply_cookie_opts(
|
|
123
|
+
opts: dict,
|
|
124
|
+
cookies_from_browser: str | None,
|
|
125
|
+
cookies_file: Path | None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Mutate *opts* in-place to add yt-dlp cookie options."""
|
|
128
|
+
browser_spec, file_path = _resolve_cookies(cookies_from_browser, cookies_file)
|
|
129
|
+
if browser_spec:
|
|
130
|
+
opts["cookiesfrombrowser"] = _parse_browser_spec(browser_spec)
|
|
131
|
+
elif file_path:
|
|
132
|
+
if not file_path.exists():
|
|
133
|
+
raise PolytoolError(f"Cookie file not found: {file_path}")
|
|
134
|
+
opts["cookiefile"] = str(file_path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _bot_check_hint(message: str) -> str | None:
|
|
138
|
+
low = message.lower()
|
|
139
|
+
if not any(s in low for s in BOT_CHECK_HINTS):
|
|
140
|
+
return None
|
|
141
|
+
if config.get("dl", "cookies_from_browser") or config.get("dl", "cookies_file"):
|
|
142
|
+
return (
|
|
143
|
+
"Saved cookies didn't satisfy the site. "
|
|
144
|
+
"Re-run [cyan]pt dl setup[/cyan] (maybe pick a different browser/profile)."
|
|
145
|
+
)
|
|
146
|
+
return (
|
|
147
|
+
"This site is blocking unauthenticated requests. "
|
|
148
|
+
"Run [cyan]pt dl setup[/cyan] once to configure cookies, "
|
|
149
|
+
"or pass [cyan]--cookies-from-browser BROWSER[/cyan] per call."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command("setup")
|
|
154
|
+
def cmd_setup(
|
|
155
|
+
browser: Annotated[
|
|
156
|
+
str | None,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--browser",
|
|
159
|
+
"-b",
|
|
160
|
+
help=f"Save a default browser ({', '.join(SUPPORTED_BROWSERS)}). "
|
|
161
|
+
"Accepts the same browser[+keyring][:profile][::container] format as yt-dlp.",
|
|
162
|
+
),
|
|
163
|
+
] = None,
|
|
164
|
+
cookies_file: Annotated[
|
|
165
|
+
Path | None,
|
|
166
|
+
typer.Option("--cookies", help="Save the path to a Netscape-format cookies.txt file."),
|
|
167
|
+
] = None,
|
|
168
|
+
clear: Annotated[bool, typer.Option("--clear", help="Remove the saved cookie config.")] = False,
|
|
169
|
+
show: Annotated[bool, typer.Option("--show", help="Print the saved cookie config.")] = False,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""One-time setup: tell `pt dl` where to fetch cookies from.
|
|
172
|
+
|
|
173
|
+
After running this, [cyan]pt dl get[/cyan] and [cyan]pt dl info[/cyan] will
|
|
174
|
+
automatically use your saved cookies — no need to pass flags every time.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
|
|
178
|
+
pt dl setup # interactive prompt
|
|
179
|
+
pt dl setup --browser firefox # save firefox as the default
|
|
180
|
+
pt dl setup --browser chrome:Default
|
|
181
|
+
pt dl setup --cookies cookies.txt
|
|
182
|
+
pt dl setup --show # print current saved config
|
|
183
|
+
pt dl setup --clear # forget saved cookies
|
|
184
|
+
"""
|
|
185
|
+
from polytool.core.config import CONFIG_PATH
|
|
186
|
+
|
|
187
|
+
if show:
|
|
188
|
+
b = config.get("dl", "cookies_from_browser")
|
|
189
|
+
f = config.get("dl", "cookies_file")
|
|
190
|
+
if not b and not f:
|
|
191
|
+
console.print("[dim]No saved dl cookie config.[/dim]")
|
|
192
|
+
return
|
|
193
|
+
if b:
|
|
194
|
+
console.print(f"[cyan]cookies-from-browser[/cyan]: {b}")
|
|
195
|
+
if f:
|
|
196
|
+
console.print(f"[cyan]cookies-file[/cyan]: {f}")
|
|
197
|
+
console.print(f"[dim](stored at {CONFIG_PATH})[/dim]")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if clear:
|
|
201
|
+
config.unset("dl", "cookies_from_browser")
|
|
202
|
+
config.unset("dl", "cookies_file")
|
|
203
|
+
console.print("[green]Cleared saved dl cookie config.[/green]")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if browser is not None and cookies_file is not None:
|
|
207
|
+
raise PolytoolError("Use --browser OR --cookies, not both.")
|
|
208
|
+
|
|
209
|
+
if browser is None and cookies_file is None:
|
|
210
|
+
# Interactive prompt.
|
|
211
|
+
console.print("Which browser should [cyan]pt dl[/cyan] pull cookies from?\n")
|
|
212
|
+
for i, name in enumerate(SUPPORTED_BROWSERS, 1):
|
|
213
|
+
console.print(f" [cyan]{i}[/cyan]) {name}")
|
|
214
|
+
console.print(f" [cyan]{len(SUPPORTED_BROWSERS) + 1}[/cyan]) [dim]none / not now[/dim]")
|
|
215
|
+
choice_s = typer.prompt("\nPick a number", type=str)
|
|
216
|
+
try:
|
|
217
|
+
choice = int(choice_s.strip())
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
raise PolytoolError(f"Not a number: {choice_s!r}") from exc
|
|
220
|
+
if choice == len(SUPPORTED_BROWSERS) + 1:
|
|
221
|
+
console.print("[dim]Skipped. Run pt dl setup any time to configure.[/dim]")
|
|
222
|
+
return
|
|
223
|
+
if not 1 <= choice <= len(SUPPORTED_BROWSERS):
|
|
224
|
+
raise PolytoolError("Out of range.")
|
|
225
|
+
browser = SUPPORTED_BROWSERS[choice - 1]
|
|
226
|
+
|
|
227
|
+
if browser is not None:
|
|
228
|
+
# Validate the spec parses (catches typos before the user hits a real error later).
|
|
229
|
+
_parse_browser_spec(browser)
|
|
230
|
+
config.set_(browser, "dl", "cookies_from_browser")
|
|
231
|
+
config.unset("dl", "cookies_file")
|
|
232
|
+
console.print(
|
|
233
|
+
f"[green]Saved.[/green] [cyan]pt dl[/cyan] will use cookies from "
|
|
234
|
+
f"[bold]{browser}[/bold] from now on."
|
|
235
|
+
)
|
|
236
|
+
console.print(f"[dim](config at {CONFIG_PATH})[/dim]")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
assert cookies_file is not None
|
|
240
|
+
if not cookies_file.exists():
|
|
241
|
+
raise PolytoolError(f"Cookie file not found: {cookies_file}")
|
|
242
|
+
resolved = cookies_file.resolve()
|
|
243
|
+
config.set_(str(resolved), "dl", "cookies_file")
|
|
244
|
+
config.unset("dl", "cookies_from_browser")
|
|
245
|
+
console.print(f"[green]Saved.[/green] [cyan]pt dl[/cyan] will use cookies from {resolved}.")
|
|
246
|
+
console.print(f"[dim](config at {CONFIG_PATH})[/dim]")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.command("get")
|
|
250
|
+
def cmd_get(
|
|
251
|
+
url: Annotated[str, typer.Argument(help="Media URL")],
|
|
252
|
+
output_dir: Annotated[Path, typer.Option("--output", "-o", help="Output directory")] = Path(),
|
|
253
|
+
audio_only: Annotated[
|
|
254
|
+
bool,
|
|
255
|
+
typer.Option("--audio-only", "-a", help="Download audio (mp3) only"),
|
|
256
|
+
] = False,
|
|
257
|
+
format_: Annotated[
|
|
258
|
+
str | None,
|
|
259
|
+
typer.Option("--format", "-f", help="yt-dlp format selector (e.g. 'best', '720p')"),
|
|
260
|
+
] = None,
|
|
261
|
+
template: Annotated[
|
|
262
|
+
str,
|
|
263
|
+
typer.Option(
|
|
264
|
+
"--template",
|
|
265
|
+
"-t",
|
|
266
|
+
help="Output filename template (yt-dlp syntax)",
|
|
267
|
+
),
|
|
268
|
+
] = "%(title)s.%(ext)s",
|
|
269
|
+
cookies_from_browser: Annotated[
|
|
270
|
+
str | None,
|
|
271
|
+
typer.Option(
|
|
272
|
+
"--cookies-from-browser",
|
|
273
|
+
help="Override saved config: load cookies from BROWSER[+keyring][:profile][::container].",
|
|
274
|
+
),
|
|
275
|
+
] = None,
|
|
276
|
+
cookies_file: Annotated[
|
|
277
|
+
Path | None,
|
|
278
|
+
typer.Option(
|
|
279
|
+
"--cookies",
|
|
280
|
+
help="Override saved config: path to a Netscape-format cookies.txt file.",
|
|
281
|
+
),
|
|
282
|
+
] = None,
|
|
283
|
+
username: Annotated[
|
|
284
|
+
str | None,
|
|
285
|
+
typer.Option("--username", "-u", help="Account username (for sites with login auth)."),
|
|
286
|
+
] = None,
|
|
287
|
+
password: Annotated[
|
|
288
|
+
str | None,
|
|
289
|
+
typer.Option(
|
|
290
|
+
"--password",
|
|
291
|
+
"-p",
|
|
292
|
+
help="Account password. Tip: omit and yt-dlp will prompt securely.",
|
|
293
|
+
),
|
|
294
|
+
] = None,
|
|
295
|
+
video_password: Annotated[
|
|
296
|
+
str | None,
|
|
297
|
+
typer.Option("--video-password", help="Per-video password (Vimeo etc.)."),
|
|
298
|
+
] = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Download a video or audio file.
|
|
301
|
+
|
|
302
|
+
Cookies are taken from your [cyan]pt dl setup[/cyan] config by default;
|
|
303
|
+
override per-call with --cookies-from-browser / --cookies.
|
|
304
|
+
|
|
305
|
+
Examples:
|
|
306
|
+
|
|
307
|
+
pt dl get 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
308
|
+
pt dl get URL --audio-only
|
|
309
|
+
pt dl get URL --format 720p -o ./downloads/
|
|
310
|
+
pt dl get URL --cookies-from-browser firefox # one-off override
|
|
311
|
+
pt dl get URL --username alice --password 'hunter2'
|
|
312
|
+
pt dl get URL --video-password 'secret' # Vimeo-style per-video pwd
|
|
313
|
+
"""
|
|
314
|
+
from polytool.core.lazy import require_extra
|
|
315
|
+
|
|
316
|
+
yt_dlp = require_extra("yt_dlp", extra="dl")
|
|
317
|
+
|
|
318
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
opts: dict = {
|
|
320
|
+
"outtmpl": str(output_dir / template),
|
|
321
|
+
"noplaylist": False,
|
|
322
|
+
"quiet": False,
|
|
323
|
+
"no_warnings": False,
|
|
324
|
+
"progress": True,
|
|
325
|
+
}
|
|
326
|
+
if audio_only:
|
|
327
|
+
opts["format"] = "bestaudio/best"
|
|
328
|
+
opts["postprocessors"] = [
|
|
329
|
+
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}
|
|
330
|
+
]
|
|
331
|
+
elif format_:
|
|
332
|
+
opts["format"] = format_
|
|
333
|
+
|
|
334
|
+
_apply_cookie_opts(opts, cookies_from_browser, cookies_file)
|
|
335
|
+
if username is not None:
|
|
336
|
+
opts["username"] = username
|
|
337
|
+
if password is not None:
|
|
338
|
+
opts["password"] = password
|
|
339
|
+
if video_password is not None:
|
|
340
|
+
opts["videopassword"] = video_password
|
|
341
|
+
if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
|
|
342
|
+
err_console.print(
|
|
343
|
+
f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
348
|
+
ydl.download([url])
|
|
349
|
+
except yt_dlp.utils.DownloadError as exc:
|
|
350
|
+
raise PolytoolError(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
|
|
351
|
+
except Exception as exc:
|
|
352
|
+
raise PolytoolError(f"Download failed: {exc}", hint=_bot_check_hint(str(exc))) from exc
|
|
353
|
+
console.print("[green]Done.[/green]")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@app.command("info")
|
|
357
|
+
def cmd_info(
|
|
358
|
+
url: Annotated[str, typer.Argument(help="Media URL")],
|
|
359
|
+
cookies_from_browser: Annotated[
|
|
360
|
+
str | None,
|
|
361
|
+
typer.Option(
|
|
362
|
+
"--cookies-from-browser",
|
|
363
|
+
help="Override saved config: load cookies from BROWSER[+keyring][:profile][::container].",
|
|
364
|
+
),
|
|
365
|
+
] = None,
|
|
366
|
+
cookies_file: Annotated[
|
|
367
|
+
Path | None,
|
|
368
|
+
typer.Option(
|
|
369
|
+
"--cookies",
|
|
370
|
+
help="Override saved config: path to a Netscape-format cookies.txt file.",
|
|
371
|
+
),
|
|
372
|
+
] = None,
|
|
373
|
+
username: Annotated[
|
|
374
|
+
str | None,
|
|
375
|
+
typer.Option("--username", "-u", help="Account username (for sites with login auth)."),
|
|
376
|
+
] = None,
|
|
377
|
+
password: Annotated[
|
|
378
|
+
str | None,
|
|
379
|
+
typer.Option(
|
|
380
|
+
"--password",
|
|
381
|
+
"-p",
|
|
382
|
+
help="Account password. Tip: omit and yt-dlp will prompt securely.",
|
|
383
|
+
),
|
|
384
|
+
] = None,
|
|
385
|
+
video_password: Annotated[
|
|
386
|
+
str | None,
|
|
387
|
+
typer.Option("--video-password", help="Per-video password (Vimeo etc.)."),
|
|
388
|
+
] = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Show metadata about a URL without downloading.
|
|
391
|
+
|
|
392
|
+
Cookies are taken from your [cyan]pt dl setup[/cyan] config by default.
|
|
393
|
+
|
|
394
|
+
Examples:
|
|
395
|
+
|
|
396
|
+
pt dl info 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
397
|
+
pt dl info URL --cookies-from-browser chrome
|
|
398
|
+
pt dl info URL --username alice --password hunter2
|
|
399
|
+
"""
|
|
400
|
+
from polytool.core.lazy import require_extra
|
|
401
|
+
|
|
402
|
+
yt_dlp = require_extra("yt_dlp", extra="dl")
|
|
403
|
+
|
|
404
|
+
opts: dict = {"quiet": True, "no_warnings": True}
|
|
405
|
+
_apply_cookie_opts(opts, cookies_from_browser, cookies_file)
|
|
406
|
+
if username is not None:
|
|
407
|
+
opts["username"] = username
|
|
408
|
+
if password is not None:
|
|
409
|
+
opts["password"] = password
|
|
410
|
+
if video_password is not None:
|
|
411
|
+
opts["videopassword"] = video_password
|
|
412
|
+
if "cookiesfrombrowser" in opts and not (cookies_from_browser or cookies_file):
|
|
413
|
+
err_console.print(
|
|
414
|
+
f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
419
|
+
info = ydl.extract_info(url, download=False)
|
|
420
|
+
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")
|
|
424
|
+
for key in interesting:
|
|
425
|
+
if key in info and info[key] is not None:
|
|
426
|
+
console.print(f"[cyan]{key}[/cyan]: {info[key]}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Cross-platform helpers for finding browser data directories.
|
|
2
|
+
|
|
3
|
+
yt-dlp natively supports a fixed set of browsers (chrome / firefox / edge /
|
|
4
|
+
brave / chromium / opera / safari / vivaldi / whale). This module fills in
|
|
5
|
+
the gap for **Firefox forks** that aren't in yt-dlp's list — Zen Browser,
|
|
6
|
+
LibreWolf, Waterfox, Floorp, and Mullvad Browser — by:
|
|
7
|
+
|
|
8
|
+
1. Locating the fork's data directory cross-platform.
|
|
9
|
+
2. Reading its ``profiles.ini`` (standard Firefox format) to find the
|
|
10
|
+
default profile (or the named profile the user asked for).
|
|
11
|
+
3. Returning the absolute profile path.
|
|
12
|
+
|
|
13
|
+
We then hand that path to yt-dlp's ``firefox`` extractor, which accepts a
|
|
14
|
+
profile *directory path* (not just a name) — so cookies from Zen et al.
|
|
15
|
+
work transparently.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import configparser
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Browsers that yt-dlp's `cookiesfrombrowser` understands natively.
|
|
26
|
+
YT_DLP_NATIVE_BROWSERS: frozenset[str] = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"chrome",
|
|
29
|
+
"firefox",
|
|
30
|
+
"edge",
|
|
31
|
+
"brave",
|
|
32
|
+
"chromium",
|
|
33
|
+
"opera",
|
|
34
|
+
"safari",
|
|
35
|
+
"vivaldi",
|
|
36
|
+
"whale",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Firefox forks → list of platform basenames to probe (in order).
|
|
41
|
+
# Different installers/distros use different casings, hence the multiple options.
|
|
42
|
+
FIREFOX_FORK_DIRS: dict[str, tuple[str, ...]] = {
|
|
43
|
+
"zen": ("zen",),
|
|
44
|
+
"librewolf": ("librewolf", "LibreWolf"),
|
|
45
|
+
"waterfox": ("waterfox", "Waterfox"),
|
|
46
|
+
"floorp": ("floorp", "Floorp"),
|
|
47
|
+
"mullvad": ("MullvadBrowser", "mullvadbrowser"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# All browser names we accept on the CLI (for help text / interactive picker /
|
|
51
|
+
# validation). Order is stable: native first, then forks.
|
|
52
|
+
ALL_BROWSERS: tuple[str, ...] = (
|
|
53
|
+
"chrome",
|
|
54
|
+
"firefox",
|
|
55
|
+
"edge",
|
|
56
|
+
"brave",
|
|
57
|
+
"chromium",
|
|
58
|
+
"opera",
|
|
59
|
+
"safari",
|
|
60
|
+
"vivaldi",
|
|
61
|
+
"whale",
|
|
62
|
+
"zen",
|
|
63
|
+
"librewolf",
|
|
64
|
+
"waterfox",
|
|
65
|
+
"floorp",
|
|
66
|
+
"mullvad",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _platform_data_dirs(basename: str) -> list[Path]:
|
|
71
|
+
"""Return candidate data dirs for *basename* on the current OS."""
|
|
72
|
+
home = Path.home()
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
appdata = Path(os.environ.get("APPDATA") or str(home / "AppData/Roaming"))
|
|
75
|
+
local = Path(os.environ.get("LOCALAPPDATA") or str(home / "AppData/Local"))
|
|
76
|
+
return [appdata / basename, local / basename]
|
|
77
|
+
if sys.platform == "darwin":
|
|
78
|
+
return [home / "Library/Application Support" / basename]
|
|
79
|
+
# Linux / BSD / other unix
|
|
80
|
+
return [home / f".{basename}", home / ".config" / basename]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_firefox_fork_data_dir(name: str) -> Path | None:
|
|
84
|
+
"""Return the data dir of a Firefox fork, or ``None`` if not installed.
|
|
85
|
+
|
|
86
|
+
>>> # find_firefox_fork_data_dir("zen") → e.g. PosixPath('/home/u/.zen')
|
|
87
|
+
"""
|
|
88
|
+
basenames = FIREFOX_FORK_DIRS.get(name.lower())
|
|
89
|
+
if not basenames:
|
|
90
|
+
return None
|
|
91
|
+
for basename in basenames:
|
|
92
|
+
for candidate in _platform_data_dirs(basename):
|
|
93
|
+
if candidate.is_dir():
|
|
94
|
+
return candidate
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_firefox_profile_dir(data_dir: Path, profile_name: str | None = None) -> Path:
|
|
99
|
+
"""Resolve a Firefox-style profile directory inside *data_dir*.
|
|
100
|
+
|
|
101
|
+
Reads ``data_dir/profiles.ini`` and returns the absolute path of:
|
|
102
|
+
|
|
103
|
+
* the profile whose ``Name=`` matches *profile_name* (if given), or
|
|
104
|
+
* the install's ``Default=`` profile (preferred), or
|
|
105
|
+
* the first profile with ``Default=1``, or
|
|
106
|
+
* the first profile listed.
|
|
107
|
+
|
|
108
|
+
Falls back to scanning ``data_dir/Profiles/`` directly if ``profiles.ini``
|
|
109
|
+
is missing — useful for the rare case where a fork doesn't ship one yet.
|
|
110
|
+
|
|
111
|
+
Raises ``FileNotFoundError`` if nothing matches.
|
|
112
|
+
"""
|
|
113
|
+
ini = data_dir / "profiles.ini"
|
|
114
|
+
if ini.exists():
|
|
115
|
+
cp = configparser.ConfigParser(strict=False)
|
|
116
|
+
cp.read(ini, encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
profile_sections: list[tuple[str, str, bool, bool]] = []
|
|
119
|
+
install_default: str | None = None
|
|
120
|
+
for section in cp.sections():
|
|
121
|
+
low = section.lower()
|
|
122
|
+
if low.startswith("profile"):
|
|
123
|
+
name = cp.get(section, "Name", fallback="")
|
|
124
|
+
path = cp.get(section, "Path", fallback="")
|
|
125
|
+
is_relative = cp.getboolean(section, "IsRelative", fallback=True)
|
|
126
|
+
is_default = cp.getint(section, "Default", fallback=0) == 1
|
|
127
|
+
if path:
|
|
128
|
+
profile_sections.append((name, path, is_relative, is_default))
|
|
129
|
+
elif low.startswith("install") and install_default is None:
|
|
130
|
+
d = cp.get(section, "Default", fallback="")
|
|
131
|
+
if d:
|
|
132
|
+
install_default = d
|
|
133
|
+
|
|
134
|
+
if profile_name:
|
|
135
|
+
for name, path, is_relative, _ in profile_sections:
|
|
136
|
+
if profile_name in (name, path):
|
|
137
|
+
return data_dir / path if is_relative else Path(path)
|
|
138
|
+
raise FileNotFoundError(f"No Firefox profile named {profile_name!r} in {ini}")
|
|
139
|
+
|
|
140
|
+
if install_default:
|
|
141
|
+
return data_dir / install_default
|
|
142
|
+
for _name, path, is_relative, is_default in profile_sections:
|
|
143
|
+
if is_default:
|
|
144
|
+
return data_dir / path if is_relative else Path(path)
|
|
145
|
+
if profile_sections:
|
|
146
|
+
_name, path, is_relative, _ = profile_sections[0]
|
|
147
|
+
return data_dir / path if is_relative else Path(path)
|
|
148
|
+
raise FileNotFoundError(f"No profiles listed in {ini}")
|
|
149
|
+
|
|
150
|
+
# No profiles.ini — try the Profiles/ directory directly.
|
|
151
|
+
profiles_root = data_dir / "Profiles"
|
|
152
|
+
if profiles_root.is_dir():
|
|
153
|
+
children = sorted(p for p in profiles_root.iterdir() if p.is_dir())
|
|
154
|
+
if profile_name:
|
|
155
|
+
for c in children:
|
|
156
|
+
# Profile directories look like xxxxxxxx.<name>
|
|
157
|
+
if c.name.endswith(f".{profile_name}") or c.name == profile_name:
|
|
158
|
+
return c
|
|
159
|
+
raise FileNotFoundError(f"No Firefox-style profile {profile_name!r} in {profiles_root}")
|
|
160
|
+
if children:
|
|
161
|
+
return children[0]
|
|
162
|
+
raise FileNotFoundError(f"Could not find profiles.ini or Profiles/ in {data_dir}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Persistent user config for polytool, stored as TOML at ``~/.polytool/config.toml``.
|
|
2
|
+
|
|
3
|
+
Tiny stdlib-only key-value store (read with ``tomllib``, write with ``tomli_w``).
|
|
4
|
+
Used so per-user preferences survive across invocations — e.g. which browser
|
|
5
|
+
``pt dl`` should pull cookies from.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import tomli_w
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path.home() / ".polytool"
|
|
17
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve() -> tuple[Path, Path]:
|
|
21
|
+
"""Return the (dir, file) pair currently in use.
|
|
22
|
+
|
|
23
|
+
Indirection so tests can monkeypatch CONFIG_PATH and have ``save()`` honor it.
|
|
24
|
+
"""
|
|
25
|
+
return CONFIG_PATH.parent, CONFIG_PATH
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load() -> dict[str, Any]:
|
|
29
|
+
"""Read the config file. Returns ``{}`` if missing or unreadable."""
|
|
30
|
+
_, path = _resolve()
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return {}
|
|
33
|
+
try:
|
|
34
|
+
with path.open("rb") as f:
|
|
35
|
+
return tomllib.load(f)
|
|
36
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save(data: dict[str, Any]) -> None:
|
|
41
|
+
"""Write the full config dict (creates parent dir if needed)."""
|
|
42
|
+
parent, path = _resolve()
|
|
43
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
path.write_text(tomli_w.dumps(data), encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get(*keys: str, default: Any = None) -> Any:
|
|
48
|
+
"""Read a nested key. ``get("dl", "cookies_from_browser")`` returns
|
|
49
|
+
``data['dl']['cookies_from_browser']`` or ``default``.
|
|
50
|
+
"""
|
|
51
|
+
cur: Any = load()
|
|
52
|
+
for k in keys:
|
|
53
|
+
if not isinstance(cur, dict) or k not in cur:
|
|
54
|
+
return default
|
|
55
|
+
cur = cur[k]
|
|
56
|
+
return cur
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_(value: Any, *keys: str) -> None:
|
|
60
|
+
"""Set a nested key, creating intermediate tables as needed."""
|
|
61
|
+
if not keys:
|
|
62
|
+
raise ValueError("At least one key required")
|
|
63
|
+
data = load()
|
|
64
|
+
cur: dict[str, Any] = data
|
|
65
|
+
for k in keys[:-1]:
|
|
66
|
+
if k not in cur or not isinstance(cur[k], dict):
|
|
67
|
+
cur[k] = {}
|
|
68
|
+
cur = cur[k]
|
|
69
|
+
cur[keys[-1]] = value
|
|
70
|
+
save(data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def unset(*keys: str) -> None:
|
|
74
|
+
"""Delete a nested key. Silent no-op if the key is missing."""
|
|
75
|
+
if not keys:
|
|
76
|
+
return
|
|
77
|
+
data = load()
|
|
78
|
+
cur: Any = data
|
|
79
|
+
for k in keys[:-1]:
|
|
80
|
+
if not isinstance(cur, dict) or k not in cur:
|
|
81
|
+
return
|
|
82
|
+
cur = cur[k]
|
|
83
|
+
if isinstance(cur, dict) and keys[-1] in cur:
|
|
84
|
+
del cur[keys[-1]]
|
|
85
|
+
save(data)
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
"""Download media from YouTube and 1000+ sites via yt-dlp."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Annotated
|
|
7
|
-
|
|
8
|
-
import typer
|
|
9
|
-
|
|
10
|
-
from polytool.core.console import console
|
|
11
|
-
from polytool.core.errors import PolytoolError
|
|
12
|
-
|
|
13
|
-
app = typer.Typer(
|
|
14
|
-
name="dl",
|
|
15
|
-
help="Download media from YouTube and 1000+ sites (yt-dlp).",
|
|
16
|
-
no_args_is_help=True,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@app.command("get")
|
|
21
|
-
def cmd_get(
|
|
22
|
-
url: Annotated[str, typer.Argument(help="Media URL")],
|
|
23
|
-
output_dir: Annotated[Path, typer.Option("--output", "-o", help="Output directory")] = Path(),
|
|
24
|
-
audio_only: Annotated[
|
|
25
|
-
bool,
|
|
26
|
-
typer.Option("--audio-only", "-a", help="Download audio (mp3) only"),
|
|
27
|
-
] = False,
|
|
28
|
-
format_: Annotated[
|
|
29
|
-
str | None,
|
|
30
|
-
typer.Option("--format", "-f", help="yt-dlp format selector (e.g. 'best', '720p')"),
|
|
31
|
-
] = None,
|
|
32
|
-
template: Annotated[
|
|
33
|
-
str,
|
|
34
|
-
typer.Option(
|
|
35
|
-
"--template",
|
|
36
|
-
"-t",
|
|
37
|
-
help="Output filename template (yt-dlp syntax)",
|
|
38
|
-
),
|
|
39
|
-
] = "%(title)s.%(ext)s",
|
|
40
|
-
) -> None:
|
|
41
|
-
"""Download a video or audio file.
|
|
42
|
-
|
|
43
|
-
Examples:
|
|
44
|
-
|
|
45
|
-
pt dl get 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
46
|
-
pt dl get URL --audio-only
|
|
47
|
-
pt dl get URL --format 720p -o ./downloads/
|
|
48
|
-
"""
|
|
49
|
-
from polytool.core.lazy import require_extra
|
|
50
|
-
|
|
51
|
-
yt_dlp = require_extra("yt_dlp", extra="dl")
|
|
52
|
-
|
|
53
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
-
opts: dict = {
|
|
55
|
-
"outtmpl": str(output_dir / template),
|
|
56
|
-
"noplaylist": False,
|
|
57
|
-
"quiet": False,
|
|
58
|
-
"no_warnings": False,
|
|
59
|
-
"progress": True,
|
|
60
|
-
}
|
|
61
|
-
if audio_only:
|
|
62
|
-
opts["format"] = "bestaudio/best"
|
|
63
|
-
opts["postprocessors"] = [
|
|
64
|
-
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}
|
|
65
|
-
]
|
|
66
|
-
elif format_:
|
|
67
|
-
opts["format"] = format_
|
|
68
|
-
|
|
69
|
-
try:
|
|
70
|
-
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
71
|
-
ydl.download([url])
|
|
72
|
-
except yt_dlp.utils.DownloadError as exc:
|
|
73
|
-
raise PolytoolError(f"Download failed: {exc}") from exc
|
|
74
|
-
except Exception as exc:
|
|
75
|
-
raise PolytoolError(f"Download failed: {exc}") from exc
|
|
76
|
-
console.print("[green]Done.[/green]")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@app.command("info")
|
|
80
|
-
def cmd_info(
|
|
81
|
-
url: Annotated[str, typer.Argument(help="Media URL")],
|
|
82
|
-
) -> None:
|
|
83
|
-
"""Show metadata about a URL without downloading.
|
|
84
|
-
|
|
85
|
-
Examples:
|
|
86
|
-
|
|
87
|
-
pt dl info 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
88
|
-
"""
|
|
89
|
-
from polytool.core.lazy import require_extra
|
|
90
|
-
|
|
91
|
-
yt_dlp = require_extra("yt_dlp", extra="dl")
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl:
|
|
95
|
-
info = ydl.extract_info(url, download=False)
|
|
96
|
-
except Exception as exc:
|
|
97
|
-
raise PolytoolError(f"Could not fetch info: {exc}") from exc
|
|
98
|
-
|
|
99
|
-
interesting = ("title", "uploader", "duration", "view_count", "upload_date", "webpage_url")
|
|
100
|
-
for key in interesting:
|
|
101
|
-
if key in info and info[key] is not None:
|
|
102
|
-
console.print(f"[cyan]{key}[/cyan]: {info[key]}")
|
|
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
|
|
@@ -26,10 +26,10 @@ def ffmpeg_path() -> str:
|
|
|
26
26
|
if iio is not None:
|
|
27
27
|
return iio.get_ffmpeg_exe()
|
|
28
28
|
|
|
29
|
-
from polytool.core.errors import PolytoolError
|
|
30
|
-
|
|
31
29
|
from rich.markup import escape
|
|
32
30
|
|
|
31
|
+
from polytool.core.errors import PolytoolError
|
|
32
|
+
|
|
33
33
|
raise PolytoolError(
|
|
34
34
|
"ffmpeg not found.",
|
|
35
35
|
hint="Install ffmpeg system-wide, or install the 'vid' extra: "
|
|
File without changes
|
|
File without changes
|
|
File without changes
|