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 +15 -0
- gingertea/__main__.py +5 -0
- gingertea/app.py +45 -0
- gingertea/assets/cup.png +0 -0
- gingertea/cli.py +100 -0
- gingertea/config.py +67 -0
- gingertea/engine/__init__.py +0 -0
- gingertea/engine/deps.py +213 -0
- gingertea/engine/errors.py +130 -0
- gingertea/engine/local.py +1033 -0
- gingertea/engine/options.py +204 -0
- gingertea/engine/probe.py +118 -0
- gingertea/engine/runner.py +174 -0
- gingertea/engine/update.py +144 -0
- gingertea/logo.py +59 -0
- gingertea/py.typed +0 -0
- gingertea/screens/__init__.py +0 -0
- gingertea/screens/convert.py +240 -0
- gingertea/screens/files.py +420 -0
- gingertea/screens/main.py +635 -0
- gingertea/screens/settings.py +143 -0
- gingertea/screens/sites.py +100 -0
- gingertea/styles.tcss +585 -0
- gingertea/theme.py +104 -0
- gingertea/widgets/__init__.py +0 -0
- gingertea/widgets/queue.py +254 -0
- gingertea/widgets/thumbnail.py +58 -0
- gingertea/widgets/video_card.py +120 -0
- gingertea-0.1.0.dist-info/METADATA +214 -0
- gingertea-0.1.0.dist-info/RECORD +33 -0
- gingertea-0.1.0.dist-info/WHEEL +4 -0
- gingertea-0.1.0.dist-info/entry_points.txt +3 -0
- gingertea-0.1.0.dist-info/licenses/LICENSE +22 -0
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
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")
|
gingertea/assets/cup.png
ADDED
|
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
|
gingertea/engine/deps.py
ADDED
|
@@ -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))
|