gingertea 0.1.0__py3-none-any.whl

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.
gingertea/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """gingertea: a terminal video downloader and converter built on yt-dlp."""
2
+
3
+
4
+ def __getattr__(name: str):
5
+ # Looked up on first use: importlib.metadata adds ~15 ms to every launch otherwise.
6
+ if name == "__version__":
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ value = version("gingertea")
11
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
12
+ value = "0.0.0"
13
+ globals()["__version__"] = value
14
+ return value
15
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
gingertea/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
gingertea/app.py ADDED
@@ -0,0 +1,45 @@
1
+ """The Textual application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+
7
+ from textual.app import App
8
+
9
+ from . import config
10
+ from .screens.main import MainScreen
11
+ from .theme import THEMES
12
+
13
+
14
+ class GingerTeaApp(App):
15
+ CSS_PATH = "styles.tcss"
16
+ TITLE = "gingertea"
17
+ ENABLE_COMMAND_PALETTE = True
18
+
19
+ def __init__(self, initial_url: str = "") -> None:
20
+ super().__init__()
21
+ self.initial_url = initial_url
22
+ # Set when PyPI has a newer gingertea; the CLI starts the upgrade after the app exits.
23
+ self.pending_app_update: str | None = None
24
+ self.settings = config.load()
25
+
26
+ def on_mount(self) -> None:
27
+ for theme in THEMES:
28
+ self.register_theme(theme)
29
+ saved = self.settings.theme
30
+ self.theme = saved if saved in self.available_themes else "tea-house"
31
+ self.theme_changed_signal.subscribe(self, self._remember_theme)
32
+
33
+ def _remember_theme(self, theme) -> None:
34
+ if theme.name != self.settings.theme:
35
+ self.save_settings(replace(self.settings, theme=theme.name))
36
+
37
+ def get_default_screen(self) -> MainScreen:
38
+ return MainScreen(self.initial_url)
39
+
40
+ def save_settings(self, settings: config.Settings) -> None:
41
+ self.settings = settings
42
+ try:
43
+ config.save(settings)
44
+ except OSError as exc:
45
+ self.notify(f"Couldn't save settings: {exc}", severity="error")
Binary file
gingertea/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command-line entry point: `gingertea [URL or FILE]`, `gingertea setup`, `gingertea update`,
2
+ `gingertea --version`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+
9
+ from . import __version__
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ argv = sys.argv[1:] if argv is None else argv
14
+ if argv[:1] == ["update"]:
15
+ return _update()
16
+ if argv[:1] == ["setup"]:
17
+ return _setup()
18
+
19
+ parser = argparse.ArgumentParser(
20
+ prog="gingertea",
21
+ description="Download or convert videos from YouTube and 1,300+ other sites.",
22
+ epilog="Run `gingertea setup` to download ffmpeg now, `gingertea update` to update yt-dlp.",
23
+ )
24
+ parser.add_argument(
25
+ "url", nargs="?", default="", help="video link (or file to convert) to open straight away"
26
+ )
27
+ parser.add_argument("--version", action="version", version=_version_text())
28
+ args = parser.parse_args(argv)
29
+
30
+ from .app import GingerTeaApp
31
+
32
+ app = GingerTeaApp(initial_url=args.url)
33
+ app.run()
34
+ if app.pending_app_update:
35
+ from .engine import update
36
+
37
+ if update.start_self_upgrade():
38
+ print(
39
+ f"Updating gingertea {__version__} → {app.pending_app_update} in the background."
40
+ " It'll be ready next time you run gingertea."
41
+ )
42
+ return 0
43
+
44
+
45
+ def _version_text() -> str:
46
+ from .engine.update import installed_version
47
+
48
+ return f"gingertea {__version__} (yt-dlp {installed_version()})"
49
+
50
+
51
+ def _setup() -> int:
52
+ """Download gingertea's own ffmpeg now rather than on first use. The installers run this."""
53
+ from .engine import deps, local
54
+
55
+ if deps.bundled_ffmpeg_ready():
56
+ print("ffmpeg is already set up.")
57
+ else:
58
+ print("Getting ffmpeg for gingertea (one time, about 50 MB)…")
59
+ try:
60
+ tool = deps.fetch_bundled_ffmpeg(show_progress=True)
61
+ except Exception as exc:
62
+ print(f"Couldn't download ffmpeg: {exc}")
63
+ print("gingertea will try again the first time it needs ffmpeg.")
64
+ return 1
65
+ print(f"✓ ffmpeg is in {tool.path}")
66
+ if system := deps.system_ffmpeg():
67
+ print(f" Your own ffmpeg in {system.path} is used too, for anything it can do.")
68
+
69
+ dirs = [t.path for t in deps.ffmpeg_candidates()]
70
+
71
+ def has(encoder: str) -> bool:
72
+ return any(deps.ffmpeg_has_encoder(d, encoder) for d in dirs)
73
+
74
+ for kind in ("video", "audio", "image"):
75
+ formats = local.formats_for(kind)
76
+ missing = [f.upper() for f in formats if local.missing_encoder(kind, f, has)]
77
+ line = f" {len(formats) - len(missing)} {kind} formats ready"
78
+ print(line + (f" (not available: {', '.join(missing)})" if missing else ""))
79
+ return 0
80
+
81
+
82
+ def _update() -> int:
83
+ from .engine import update
84
+
85
+ current = update.installed_version()
86
+ latest = update.latest_version()
87
+ if latest and update._key(latest) <= update._key(current):
88
+ print(f"yt-dlp {current} is already the latest version.")
89
+ return 0
90
+ print(f"Updating yt-dlp {current} → {latest or 'latest'}…")
91
+ print("$ " + " ".join(update.upgrade_command()))
92
+ ok, output = update.upgrade()
93
+ if output:
94
+ print(output)
95
+ print("Done." if ok else "Update failed.")
96
+ return 0 if ok else 1
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())
gingertea/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """User settings, stored as TOML in the platform config directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import asdict, dataclass, fields
7
+ from pathlib import Path
8
+
9
+ import platformdirs
10
+ import tomli_w
11
+
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else: # pragma: no cover
15
+ import tomli as tomllib
16
+
17
+ APP_NAME = "gingertea"
18
+
19
+
20
+ def default_download_dir() -> str:
21
+ return str(Path(platformdirs.user_downloads_dir()))
22
+
23
+
24
+ def config_path() -> Path:
25
+ return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False)) / "config.toml"
26
+
27
+
28
+ @dataclass
29
+ class Settings:
30
+ download_dir: str = ""
31
+ # Last choice made in the Convert dialog, so it opens where the user left it.
32
+ last_kind: str = "video"
33
+ last_video_format: str = "mp4"
34
+ last_video_quality: str = "best"
35
+ last_audio_format: str = "mp3"
36
+ last_audio_quality: str = "best"
37
+ last_image_format: str = "jpg"
38
+ last_image_quality: str = "best"
39
+ last_frames_format: str = "png"
40
+ last_frames_quality: str = "1"
41
+ # Browser cookies stay off until the user opts in for a login-only video.
42
+ cookies_browser: str = ""
43
+ theme: str = "tea-house"
44
+
45
+ def __post_init__(self) -> None:
46
+ if not self.download_dir:
47
+ self.download_dir = default_download_dir()
48
+
49
+ @property
50
+ def download_path(self) -> Path:
51
+ return Path(self.download_dir).expanduser()
52
+
53
+
54
+ def load(path: Path | None = None) -> Settings:
55
+ path = path or config_path()
56
+ try:
57
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
58
+ except (OSError, tomllib.TOMLDecodeError):
59
+ return Settings()
60
+ known = {f.name for f in fields(Settings)}
61
+ return Settings(**{k: v for k, v in data.items() if k in known and isinstance(v, str)})
62
+
63
+
64
+ def save(settings: Settings, path: Path | None = None) -> None:
65
+ path = path or config_path()
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text(tomli_w.dumps(asdict(settings)), encoding="utf-8")
File without changes
@@ -0,0 +1,213 @@
1
+ """Locate ffmpeg/ffprobe and a JavaScript runtime.
2
+
3
+ Binaries on PATH win. Otherwise fall back to gingertea's own copies: a static ffmpeg
4
+ build (downloaded once, by `gingertea setup` or on first use) and the PyPI `deno` wheel.
5
+ Converting local files can use both ffmpegs: whichever has the encoder a format needs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import stat
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import urllib.request
18
+ import zipfile
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from functools import cache
22
+ from pathlib import Path
23
+
24
+ # Static ffmpeg + ffprobe builds, one zip per platform holding a folder named after the key.
25
+ # These are the builds the static-ffmpeg package (3.0) downloads; gingertea fetches them
26
+ # itself so it doesn't install that package's unrelated dependencies (twine, keyring, ...).
27
+ FFMPEG_ZIPS = {
28
+ "win32": "https://github.com/zackees/ffmpeg_bins/raw/main/v8.0/win32.zip",
29
+ "darwin": "https://github.com/zackees/ffmpeg_bins/raw/main/v8.0/darwin.zip",
30
+ "darwin_arm64": "https://github.com/zackees/ffmpeg_bins/raw/main/v8.0/darwin_arm64.zip",
31
+ "linux": "https://github.com/zackees/ffmpeg_bins/raw/main/v8.0/linux.zip",
32
+ "linux_arm64": "https://github.com/zackees/ffmpeg_bins/raw/main/v8.0/linux_arm64.zip",
33
+ }
34
+ CRUMB = "installed.crumb"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Tool:
39
+ path: str
40
+ bundled: bool
41
+
42
+
43
+ def system_ffmpeg() -> Tool | None:
44
+ ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
45
+ if ffmpeg and ffprobe and Path(ffmpeg).parent == Path(ffprobe).parent:
46
+ return Tool(str(Path(ffmpeg).parent), bundled=False)
47
+ return None
48
+
49
+
50
+ def platform_key() -> str:
51
+ arm = platform.machine().lower() in ("arm64", "aarch64")
52
+ if sys.platform == "win32":
53
+ return "win32"
54
+ if sys.platform in ("darwin", "linux"):
55
+ return f"{sys.platform}_arm64" if arm else sys.platform
56
+ return sys.platform
57
+
58
+
59
+ def bundled_dir() -> Path:
60
+ """Where the static build lives. Outside the app's environment, so it survives upgrades."""
61
+ import platformdirs
62
+
63
+ data = platformdirs.user_data_dir("gingertea", appauthor=False)
64
+ return Path(data) / "ffmpeg" / platform_key()
65
+
66
+
67
+ def bundled_ffmpeg_ready() -> bool:
68
+ return (bundled_dir() / CRUMB).exists()
69
+
70
+
71
+ def bundled_ffmpeg() -> Tool | None:
72
+ """The downloaded static build, if it's there."""
73
+ return Tool(str(bundled_dir()), bundled=True) if bundled_ffmpeg_ready() else None
74
+
75
+
76
+ def ffmpeg_candidates() -> list[Tool]:
77
+ """Every ffmpeg gingertea can use, the system's first."""
78
+ return [t for t in (system_ffmpeg(), bundled_ffmpeg()) if t is not None]
79
+
80
+
81
+ def fetch_bundled_ffmpeg(show_progress: bool = False) -> Tool:
82
+ """Download the static build if it isn't there yet (about 50 MB, once). Blocks.
83
+
84
+ `show_progress` prints a percentage; leave it off inside the TUI. Safe to run from two
85
+ processes at once: each unpacks into its own folder and the first to finish wins.
86
+ """
87
+ target = bundled_dir()
88
+ if (target / CRUMB).exists():
89
+ return Tool(str(target), bundled=True)
90
+ key = platform_key()
91
+ url = FFMPEG_ZIPS.get(key)
92
+ if url is None:
93
+ raise OSError(f"No ffmpeg build for {key}. Install ffmpeg yourself.")
94
+ target.parent.mkdir(parents=True, exist_ok=True)
95
+ work = Path(tempfile.mkdtemp(prefix=".download-", dir=target.parent))
96
+ try:
97
+ archive = work / "ffmpeg.zip"
98
+ _download(url, archive, show_progress)
99
+ with zipfile.ZipFile(archive) as zf:
100
+ zf.extractall(work / "unpacked") # zipfile keeps members inside this folder
101
+ archive.unlink()
102
+ unpacked = work / "unpacked" / key
103
+ exe = ".exe" if sys.platform == "win32" else ""
104
+ for name in ("ffmpeg", "ffprobe"):
105
+ path = unpacked / f"{name}{exe}"
106
+ if not path.is_file():
107
+ raise OSError(f"The ffmpeg download has no {path.name}.")
108
+ if not exe:
109
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
110
+ (unpacked / CRUMB).write_text(f"installed from {url} on {datetime.now()}")
111
+ if target.exists() and not (target / CRUMB).exists():
112
+ shutil.rmtree(target, ignore_errors=True) # a broken earlier attempt
113
+ try:
114
+ os.replace(unpacked, target)
115
+ except OSError:
116
+ if not (target / CRUMB).exists(): # not just another process winning the race
117
+ raise
118
+ finally:
119
+ shutil.rmtree(work, ignore_errors=True)
120
+ return Tool(str(target), bundled=True)
121
+
122
+
123
+ def _download(url: str, dest: Path, show_progress: bool) -> None:
124
+ request = urllib.request.Request(url, headers={"User-Agent": "gingertea"})
125
+ with (
126
+ urllib.request.urlopen(request, timeout=60, context=_tls()) as resp,
127
+ open(dest, "wb") as out,
128
+ ):
129
+ total = int(resp.headers.get("Content-Length") or 0)
130
+ done, shown = 0, -1
131
+ while chunk := resp.read(1 << 18):
132
+ out.write(chunk)
133
+ done += len(chunk)
134
+ if show_progress and total and (pct := done * 100 // total) != shown:
135
+ print(f"\r {pct}% of {total / 2**20:.0f} MB", end="", flush=True)
136
+ shown = pct
137
+ if show_progress:
138
+ print()
139
+
140
+
141
+ def _tls():
142
+ """certifi's CA bundle when it's there (it comes with yt-dlp), else the system's."""
143
+ import ssl
144
+
145
+ try:
146
+ import certifi
147
+
148
+ return ssl.create_default_context(cafile=certifi.where())
149
+ except ImportError:
150
+ return ssl.create_default_context()
151
+
152
+
153
+ def find_ffmpeg(fetch: bool = True) -> Tool | None:
154
+ """Return the directory holding ffmpeg + ffprobe.
155
+
156
+ With `fetch`, downloads the bundled build if nothing else is available
157
+ (about 70 MB, once). That call blocks, so run it off the UI thread.
158
+ """
159
+ if tool := system_ffmpeg():
160
+ return tool
161
+ if not fetch:
162
+ return bundled_ffmpeg()
163
+ return fetch_bundled_ffmpeg()
164
+
165
+
166
+ def find_deno() -> Tool | None:
167
+ if path := shutil.which("deno"):
168
+ return Tool(path, bundled=False)
169
+ try:
170
+ from deno import find_deno_bin
171
+
172
+ return Tool(str(find_deno_bin()), bundled=True)
173
+ except Exception:
174
+ return None
175
+
176
+
177
+ def js_runtimes() -> dict[str, dict[str, str]]:
178
+ """yt-dlp `js_runtimes` option: needed for full YouTube support."""
179
+ if deno := find_deno():
180
+ return {"deno": {"path": deno.path}}
181
+ # Let yt-dlp try its own discovery (node, bun, ...) if Deno is somehow missing.
182
+ return {name: {} for name in ("deno", "node", "bun") if shutil.which(name)}
183
+
184
+
185
+ @cache
186
+ def ffmpeg_encoders(ffmpeg_dir: str) -> frozenset[str] | None:
187
+ """Every encoder this ffmpeg build has, or None if it couldn't be asked."""
188
+ exe = shutil.which("ffmpeg", path=ffmpeg_dir) or "ffmpeg"
189
+ try:
190
+ out = subprocess.run(
191
+ [exe, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=10
192
+ ).stdout
193
+ except (OSError, subprocess.TimeoutExpired):
194
+ return None
195
+ return frozenset(parts[1] for line in out.splitlines() if len(parts := line.split()) > 1)
196
+
197
+
198
+ def ffmpeg_has_encoder(ffmpeg_dir: str, encoder: str) -> bool:
199
+ names = ffmpeg_encoders(ffmpeg_dir)
200
+ return True if names is None else encoder in names
201
+
202
+
203
+ class SilentLogger:
204
+ """yt-dlp logger that drops everything; errors still arrive as exceptions.
205
+
206
+ Without it yt-dlp writes "ERROR: ..." to stderr even in quiet mode, which
207
+ would scribble over the full-screen UI.
208
+ """
209
+
210
+ def debug(self, msg: str) -> None:
211
+ pass
212
+
213
+ info = warning = error = debug
@@ -0,0 +1,130 @@
1
+ """Translate yt-dlp failures into plain language plus a suggested next step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ LOGIN = "login"
9
+ OUTDATED = "outdated"
10
+ NETWORK = "network"
11
+ UNAVAILABLE = "unavailable"
12
+ UNSUPPORTED = "unsupported"
13
+ PLAYLIST = "playlist"
14
+ GENERIC = "generic"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FriendlyError:
19
+ kind: str
20
+ message: str
21
+ hint: str = ""
22
+ detail: str = "" # yt-dlp's own first line, shown small so odd cases can be diagnosed
23
+
24
+
25
+ _RULES: list[tuple[str, tuple[str, ...], str, str]] = [
26
+ (
27
+ UNSUPPORTED,
28
+ ("invalid data found when processing input", "isn't audio, video or an image"),
29
+ "gingertea can't read this file.",
30
+ "It may be damaged, or it isn't audio, video or an image.",
31
+ ),
32
+ (
33
+ PLAYLIST,
34
+ ("playlist or channel",),
35
+ "That's a playlist or channel.",
36
+ "Paste a link to a single video.",
37
+ ),
38
+ (
39
+ LOGIN,
40
+ ("not a bot",),
41
+ "YouTube wants to check you're not a bot.",
42
+ "Choose a browser where you are signed in to YouTube, then try again. "
43
+ "If it still fails, try again later.",
44
+ ),
45
+ (
46
+ LOGIN,
47
+ (
48
+ "sign in to confirm your age",
49
+ "age-restricted",
50
+ "inappropriate for some users",
51
+ "members-only",
52
+ "join this channel",
53
+ "sign in to confirm you",
54
+ "login required",
55
+ "requires authentication",
56
+ "use --cookies",
57
+ ),
58
+ "This video needs you to be signed in.",
59
+ "Use cookies from your browser to sign in.",
60
+ ),
61
+ (
62
+ UNAVAILABLE,
63
+ (
64
+ "private video",
65
+ "video unavailable",
66
+ "has been removed",
67
+ "not available in your country",
68
+ "this video is not available",
69
+ "premieres in",
70
+ ),
71
+ "This video isn't available.",
72
+ "It may be private, removed, region-locked or not out yet.",
73
+ ),
74
+ (
75
+ UNSUPPORTED,
76
+ ("unsupported url", "is not a valid url"),
77
+ "That link isn't a video gingertea can read.",
78
+ "Check the link and try again.",
79
+ ),
80
+ (
81
+ OUTDATED,
82
+ (
83
+ "http error 403",
84
+ "signature extraction failed",
85
+ "nsig extraction failed",
86
+ "unable to extract",
87
+ "requested format is not available",
88
+ "n challenge",
89
+ "please report this issue",
90
+ "only images are available",
91
+ ),
92
+ "The site may have changed how it serves videos.",
93
+ "Press u to update yt-dlp, then try again.",
94
+ ),
95
+ (
96
+ NETWORK,
97
+ (
98
+ "getaddrinfo",
99
+ "network is unreachable",
100
+ "timed out",
101
+ "connection refused",
102
+ "temporary failure in name resolution",
103
+ "nodename nor servname",
104
+ "connection reset",
105
+ ),
106
+ "Couldn't reach the site.",
107
+ "Check your internet connection and try again.",
108
+ ),
109
+ ]
110
+
111
+ _PREFIX = re.compile(r"^(ERROR:\s*)?(\[[^\]]+\]\s*)?([\w-]+:\s+)?")
112
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
113
+
114
+
115
+ def clean(text: str) -> str:
116
+ line = _ANSI.sub("", text).strip().splitlines()[0] if text.strip() else ""
117
+ return _PREFIX.sub("", line).strip() or "Something went wrong."
118
+
119
+
120
+ def explain(exc: BaseException | str) -> FriendlyError:
121
+ text = str(exc)
122
+ low = text.lower()
123
+ for kind, needles, message, hint in _RULES:
124
+ if any(n in low for n in needles):
125
+ return FriendlyError(kind, message, hint, clean(text))
126
+ if "ffmpeg" in low and ("not found" in low or "not installed" in low):
127
+ return FriendlyError(
128
+ GENERIC, "ffmpeg is missing.", "Install ffmpeg, or reinstall gingertea."
129
+ )
130
+ return FriendlyError(GENERIC, clean(text))