polytool 0.2.2__tar.gz → 0.2.4__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.2 → polytool-0.2.4}/PKG-INFO +1 -1
- {polytool-0.2.2 → polytool-0.2.4}/pyproject.toml +1 -1
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/__init__.py +1 -1
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/dl.py +64 -15
- polytool-0.2.4/src/polytool/core/browsers.py +162 -0
- {polytool-0.2.2 → polytool-0.2.4}/.gitignore +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/LICENSE +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/README.md +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/docs/README.md +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/__main__.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/__init__.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/clip.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/color.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/convert.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/cron.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/data.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/enc.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/file.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/gen.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/img.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/net.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/pdf.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/qr.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/shot.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/text.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/cli/vid.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/__init__.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/config.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/console.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/errors.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/ffmpeg.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/io.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/src/polytool/core/lazy.py +0 -0
- {polytool-0.2.2 → polytool-0.2.4}/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.4
|
|
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.4"
|
|
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"
|
|
@@ -8,6 +8,13 @@ from typing import Annotated
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
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
|
+
)
|
|
11
18
|
from polytool.core.console import console, err_console
|
|
12
19
|
from polytool.core.errors import PolytoolError
|
|
13
20
|
|
|
@@ -17,18 +24,9 @@ app = typer.Typer(
|
|
|
17
24
|
no_args_is_help=True,
|
|
18
25
|
)
|
|
19
26
|
|
|
20
|
-
# Browsers yt-dlp's
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"firefox",
|
|
24
|
-
"edge",
|
|
25
|
-
"brave",
|
|
26
|
-
"chromium",
|
|
27
|
-
"opera",
|
|
28
|
-
"safari",
|
|
29
|
-
"vivaldi",
|
|
30
|
-
"whale",
|
|
31
|
-
)
|
|
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
|
|
32
30
|
|
|
33
31
|
BOT_CHECK_HINTS = (
|
|
34
32
|
"sign in",
|
|
@@ -43,10 +41,15 @@ BOT_CHECK_HINTS = (
|
|
|
43
41
|
|
|
44
42
|
|
|
45
43
|
def _parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | None]:
|
|
46
|
-
"""Parse a yt-dlp
|
|
44
|
+
"""Parse a polytool browser spec and return a yt-dlp-ready tuple.
|
|
47
45
|
|
|
48
46
|
Format: ``browser[+keyring][:profile][::container]``.
|
|
49
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
|
+
|
|
50
53
|
>>> _parse_browser_spec("chrome")
|
|
51
54
|
('chrome', None, None, None)
|
|
52
55
|
>>> _parse_browser_spec("firefox:default-release")
|
|
@@ -70,6 +73,29 @@ def _parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | N
|
|
|
70
73
|
f"Invalid --cookies-from-browser spec: {spec!r}",
|
|
71
74
|
hint="Format: browser[+keyring][:profile][::container].",
|
|
72
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
|
+
)
|
|
73
99
|
return (browser, profile or None, keyring or None, container or None)
|
|
74
100
|
|
|
75
101
|
|
|
@@ -388,13 +414,36 @@ def cmd_info(
|
|
|
388
414
|
f"[dim]Using saved cookies-from-browser: {opts['cookiesfrombrowser'][0]}[/dim]"
|
|
389
415
|
)
|
|
390
416
|
|
|
417
|
+
# process=False skips yt-dlp's format-selection step. We only want metadata,
|
|
418
|
+
# and on some sites (YouTube especially) the default format selector
|
|
419
|
+
# fails with "Requested format is not available" on otherwise-valid videos.
|
|
391
420
|
try:
|
|
392
421
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
393
|
-
info = ydl.extract_info(url, download=False)
|
|
422
|
+
info = ydl.extract_info(url, download=False, process=False)
|
|
394
423
|
except Exception as exc:
|
|
395
424
|
raise PolytoolError(f"Could not fetch info: {exc}", hint=_bot_check_hint(str(exc))) from exc
|
|
396
425
|
|
|
397
|
-
|
|
426
|
+
if info is None:
|
|
427
|
+
raise PolytoolError("yt-dlp returned no metadata for this URL.")
|
|
428
|
+
|
|
429
|
+
interesting = (
|
|
430
|
+
"title",
|
|
431
|
+
"uploader",
|
|
432
|
+
"channel",
|
|
433
|
+
"duration",
|
|
434
|
+
"view_count",
|
|
435
|
+
"like_count",
|
|
436
|
+
"upload_date",
|
|
437
|
+
"live_status",
|
|
438
|
+
"webpage_url",
|
|
439
|
+
)
|
|
440
|
+
shown = False
|
|
398
441
|
for key in interesting:
|
|
399
442
|
if key in info and info[key] is not None:
|
|
400
443
|
console.print(f"[cyan]{key}[/cyan]: {info[key]}")
|
|
444
|
+
shown = True
|
|
445
|
+
# Playlists and some sites return only `entries` and a few top-level fields.
|
|
446
|
+
if not shown and info.get("_type") == "playlist":
|
|
447
|
+
console.print(f"[cyan]playlist[/cyan]: {info.get('title') or info.get('id')}")
|
|
448
|
+
entries = info.get("entries") or []
|
|
449
|
+
console.print(f"[cyan]entries[/cyan]: {len(list(entries))}")
|
|
@@ -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}")
|
|
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
|