yoinktui 1.0.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.
- yoinktui/__init__.py +12 -0
- yoinktui/__main__.py +368 -0
- yoinktui/app.py +192 -0
- yoinktui/assets/logo-dark.png +0 -0
- yoinktui/assets/logo-light.png +0 -0
- yoinktui/backend/__init__.py +95 -0
- yoinktui/backend/config.py +666 -0
- yoinktui/backend/errors.py +184 -0
- yoinktui/backend/library.py +260 -0
- yoinktui/backend/models.py +219 -0
- yoinktui/backend/presets.py +108 -0
- yoinktui/backend/queue.py +448 -0
- yoinktui/backend/ytdlp.py +1116 -0
- yoinktui/cli.py +585 -0
- yoinktui/tui/__init__.py +41 -0
- yoinktui/tui/app.tcss +745 -0
- yoinktui/tui/markup.py +15 -0
- yoinktui/tui/screens.py +2324 -0
- yoinktui/tui/widgets.py +1078 -0
- yoinktui-1.0.0.dist-info/METADATA +398 -0
- yoinktui-1.0.0.dist-info/RECORD +24 -0
- yoinktui-1.0.0.dist-info/WHEEL +4 -0
- yoinktui-1.0.0.dist-info/entry_points.txt +3 -0
- yoinktui-1.0.0.dist-info/licenses/LICENSE +21 -0
yoinktui/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""yoinktui - a creative TUI for downloading YouTube media.
|
|
2
|
+
|
|
3
|
+
Backend: yt-dlp (extraction) + ffmpeg (merge/postprocess), orchestrated the
|
|
4
|
+
way yt-x does it, but with an entirely original interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try: # pyproject.toml is the single source of truth for the version
|
|
10
|
+
__version__ = version("yoinktui")
|
|
11
|
+
except PackageNotFoundError: # pragma: no cover - running from a raw source tree
|
|
12
|
+
__version__ = "0.0.0+source"
|
yoinktui/__main__.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Package entry point: ``python -m yoinktui``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from yoinktui import __version__
|
|
10
|
+
from yoinktui.backend.config import (
|
|
11
|
+
default_config_path,
|
|
12
|
+
load_config,
|
|
13
|
+
to_toml,
|
|
14
|
+
write_default_config,
|
|
15
|
+
)
|
|
16
|
+
from yoinktui.backend.ytdlp import check_dependencies
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="yoinktui",
|
|
22
|
+
description="Steam-style TUI download manager powered by yt-dlp.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"urls",
|
|
26
|
+
nargs="*",
|
|
27
|
+
metavar="URL",
|
|
28
|
+
help="media URL(s) to download without TUI (or '-' for stdin)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--config",
|
|
33
|
+
type=Path,
|
|
34
|
+
metavar="PATH",
|
|
35
|
+
help="configuration file (default: ~/.config/yoinktui/config.toml)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--print-config",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="print the effective config as TOML and exit",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--write-default-config",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="write a default config file and exit",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--doctor",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="check yt-dlp/ffmpeg, config, and paths, then exit",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
download = parser.add_argument_group("download options")
|
|
54
|
+
download.add_argument(
|
|
55
|
+
"-o",
|
|
56
|
+
"--output-dir",
|
|
57
|
+
"--download-dir",
|
|
58
|
+
type=Path,
|
|
59
|
+
dest="download_dir",
|
|
60
|
+
metavar="DIR",
|
|
61
|
+
help="where downloads are saved (default: ~/Videos/yoinktui)",
|
|
62
|
+
)
|
|
63
|
+
download.add_argument(
|
|
64
|
+
"-f",
|
|
65
|
+
"--format",
|
|
66
|
+
dest="format_selector",
|
|
67
|
+
metavar="FORMAT",
|
|
68
|
+
help="format selector for yt-dlp (e.g. 137, bestvideo+bestaudio)",
|
|
69
|
+
)
|
|
70
|
+
download.add_argument(
|
|
71
|
+
"-m",
|
|
72
|
+
"--mode",
|
|
73
|
+
dest="mode",
|
|
74
|
+
choices=("video", "vonly", "audio"),
|
|
75
|
+
help="download mode: video, vonly, or audio",
|
|
76
|
+
)
|
|
77
|
+
download.add_argument(
|
|
78
|
+
"--preset",
|
|
79
|
+
metavar="NAME",
|
|
80
|
+
help="apply download settings from a named preset (e.g. music, phone)",
|
|
81
|
+
)
|
|
82
|
+
download.add_argument(
|
|
83
|
+
"--output-template",
|
|
84
|
+
metavar="TEMPLATE",
|
|
85
|
+
help="custom yt-dlp output template (e.g. '%%(title)s.%%(ext)s')",
|
|
86
|
+
)
|
|
87
|
+
download.add_argument(
|
|
88
|
+
"--quality-cap",
|
|
89
|
+
type=int,
|
|
90
|
+
metavar="P",
|
|
91
|
+
help="maximum video height, 0 = best (default: 1080)",
|
|
92
|
+
)
|
|
93
|
+
download.add_argument(
|
|
94
|
+
"--container",
|
|
95
|
+
choices=("mkv", "mp4", "auto"),
|
|
96
|
+
help="merge container (default: mkv)",
|
|
97
|
+
)
|
|
98
|
+
download.add_argument(
|
|
99
|
+
"--audio-format",
|
|
100
|
+
choices=("mp3", "m4a", "opus", "flac", "best"),
|
|
101
|
+
help="audio extraction format (default: mp3)",
|
|
102
|
+
)
|
|
103
|
+
download.add_argument("--audio-quality", metavar="Q", help="audio quality (default: 0)")
|
|
104
|
+
download.add_argument(
|
|
105
|
+
"--concurrent-fragments",
|
|
106
|
+
type=int,
|
|
107
|
+
metavar="N",
|
|
108
|
+
help="fragments to download in parallel (0 = yt-dlp default)",
|
|
109
|
+
)
|
|
110
|
+
download.add_argument("--rate-limit", metavar="RATE", help="download rate limit, e.g. 5M")
|
|
111
|
+
download.add_argument(
|
|
112
|
+
"--default-mode",
|
|
113
|
+
choices=("video", "vonly", "audio"),
|
|
114
|
+
help="mode preselected on the format screen",
|
|
115
|
+
)
|
|
116
|
+
download.add_argument(
|
|
117
|
+
"--write-subtitles",
|
|
118
|
+
"--write-subs",
|
|
119
|
+
action="store_true",
|
|
120
|
+
default=None,
|
|
121
|
+
dest="write_subtitles",
|
|
122
|
+
help="download and embed subtitles",
|
|
123
|
+
)
|
|
124
|
+
download.add_argument(
|
|
125
|
+
"--no-write-subtitles",
|
|
126
|
+
"--no-subs",
|
|
127
|
+
action="store_false",
|
|
128
|
+
dest="write_subtitles",
|
|
129
|
+
help="do not download subtitles",
|
|
130
|
+
)
|
|
131
|
+
download.add_argument(
|
|
132
|
+
"--embed-metadata",
|
|
133
|
+
action="store_true",
|
|
134
|
+
default=None,
|
|
135
|
+
dest="embed_metadata",
|
|
136
|
+
help="embed metadata tags into media file",
|
|
137
|
+
)
|
|
138
|
+
download.add_argument(
|
|
139
|
+
"--no-embed-metadata",
|
|
140
|
+
action="store_false",
|
|
141
|
+
dest="embed_metadata",
|
|
142
|
+
help="do not embed metadata tags",
|
|
143
|
+
)
|
|
144
|
+
download.add_argument(
|
|
145
|
+
"--embed-thumbnail",
|
|
146
|
+
action="store_true",
|
|
147
|
+
default=None,
|
|
148
|
+
dest="embed_thumbnail",
|
|
149
|
+
help="embed cover/thumbnail artwork into media file",
|
|
150
|
+
)
|
|
151
|
+
download.add_argument(
|
|
152
|
+
"--no-embed-thumbnail",
|
|
153
|
+
action="store_false",
|
|
154
|
+
dest="embed_thumbnail",
|
|
155
|
+
help="do not embed cover artwork",
|
|
156
|
+
)
|
|
157
|
+
download.add_argument(
|
|
158
|
+
"--keep-original",
|
|
159
|
+
action="store_true",
|
|
160
|
+
default=None,
|
|
161
|
+
dest="keep_original",
|
|
162
|
+
help="keep original video/audio files after extraction or merging",
|
|
163
|
+
)
|
|
164
|
+
download.add_argument(
|
|
165
|
+
"--theme", metavar="NAME", help="theme name (dark/light or a Textual theme)"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
playlist = parser.add_argument_group("playlist options")
|
|
169
|
+
playlist.add_argument(
|
|
170
|
+
"--yes-playlist",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="download the entire playlist if URL refers to one",
|
|
173
|
+
)
|
|
174
|
+
playlist.add_argument(
|
|
175
|
+
"--no-playlist",
|
|
176
|
+
action="store_true",
|
|
177
|
+
help="only download single video if URL has both video and playlist",
|
|
178
|
+
)
|
|
179
|
+
playlist.add_argument(
|
|
180
|
+
"--playlist-items",
|
|
181
|
+
metavar="ITEMS",
|
|
182
|
+
help="playlist item indices to download (e.g. '1,2,5-8')",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
automation = parser.add_argument_group("headless & automation options")
|
|
186
|
+
automation.add_argument(
|
|
187
|
+
"-a",
|
|
188
|
+
"--batch-file",
|
|
189
|
+
type=Path,
|
|
190
|
+
metavar="FILE",
|
|
191
|
+
help="file containing URLs to download (one per line, or '-' for stdin)",
|
|
192
|
+
)
|
|
193
|
+
automation.add_argument(
|
|
194
|
+
"-q",
|
|
195
|
+
"--quiet",
|
|
196
|
+
action="store_true",
|
|
197
|
+
help="suppress download progress output (errors still print to stderr)",
|
|
198
|
+
)
|
|
199
|
+
automation.add_argument(
|
|
200
|
+
"--json",
|
|
201
|
+
action="store_true",
|
|
202
|
+
help="stream progress and results as newline-delimited JSON (NDJSON)",
|
|
203
|
+
)
|
|
204
|
+
automation.add_argument(
|
|
205
|
+
"--dry-run",
|
|
206
|
+
action="store_true",
|
|
207
|
+
help="simulate download; probe metadata and print plan without fetching media",
|
|
208
|
+
)
|
|
209
|
+
automation.add_argument(
|
|
210
|
+
"--print-command",
|
|
211
|
+
action="store_true",
|
|
212
|
+
help="print the exact yt-dlp command line invocation",
|
|
213
|
+
)
|
|
214
|
+
automation.add_argument(
|
|
215
|
+
"--no-history",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help="do not record downloads in the SQLite history/library database",
|
|
218
|
+
)
|
|
219
|
+
automation.add_argument(
|
|
220
|
+
"--tui",
|
|
221
|
+
action="store_true",
|
|
222
|
+
help="launch the interactive TUI even if a URL is provided",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
network = parser.add_argument_group("network options")
|
|
226
|
+
network.add_argument(
|
|
227
|
+
"--cookies-from-browser",
|
|
228
|
+
metavar="BROWSER[:PROFILE]",
|
|
229
|
+
help="pass browser cookies to yt-dlp (chrome, firefox, edge, brave, ...)",
|
|
230
|
+
)
|
|
231
|
+
network.add_argument(
|
|
232
|
+
"--cookies",
|
|
233
|
+
type=Path,
|
|
234
|
+
metavar="FILE",
|
|
235
|
+
dest="cookies_file",
|
|
236
|
+
help="Netscape-format cookies.txt file exported from a browser",
|
|
237
|
+
)
|
|
238
|
+
network.add_argument(
|
|
239
|
+
"--impersonate",
|
|
240
|
+
metavar="CLIENT[:OS]",
|
|
241
|
+
dest="impersonate",
|
|
242
|
+
help="client to impersonate for anti-bot bypass (e.g. chrome)",
|
|
243
|
+
)
|
|
244
|
+
network.add_argument(
|
|
245
|
+
"--proxy",
|
|
246
|
+
metavar="URL",
|
|
247
|
+
help="proxy for yt-dlp, e.g. socks5://127.0.0.1:1080",
|
|
248
|
+
)
|
|
249
|
+
network.add_argument(
|
|
250
|
+
"--yt-dlp-arg",
|
|
251
|
+
action="append",
|
|
252
|
+
default=[],
|
|
253
|
+
dest="yt_dlp_args",
|
|
254
|
+
metavar="ARG",
|
|
255
|
+
help="extra yt-dlp argument; repeatable (use --yt-dlp-arg=--flag for flags)",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
ui = parser.add_argument_group("UI options")
|
|
259
|
+
ui.add_argument("--no-color", action="store_true", default=None, help="disable colour output")
|
|
260
|
+
ui.add_argument("--ascii", action="store_true", default=None, help="prefer ASCII glyphs")
|
|
261
|
+
return parser
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _cli_overrides(args: argparse.Namespace) -> dict[str, object]:
|
|
265
|
+
overrides: dict[str, object] = {
|
|
266
|
+
"download_dir": args.download_dir,
|
|
267
|
+
"quality_cap": args.quality_cap,
|
|
268
|
+
"container": args.container,
|
|
269
|
+
"audio_format": args.audio_format,
|
|
270
|
+
"audio_quality": args.audio_quality,
|
|
271
|
+
"concurrent_fragments": args.concurrent_fragments,
|
|
272
|
+
"rate_limit": args.rate_limit,
|
|
273
|
+
"default_mode": args.mode or args.default_mode,
|
|
274
|
+
"theme": args.theme,
|
|
275
|
+
"cookies_from_browser": args.cookies_from_browser,
|
|
276
|
+
"cookies_file": args.cookies_file,
|
|
277
|
+
"impersonate": args.impersonate,
|
|
278
|
+
"proxy": args.proxy,
|
|
279
|
+
"extra_args": tuple(args.yt_dlp_args) if args.yt_dlp_args else None,
|
|
280
|
+
"no_color": args.no_color,
|
|
281
|
+
"ascii": args.ascii,
|
|
282
|
+
}
|
|
283
|
+
if getattr(args, "write_subtitles", None) is not None:
|
|
284
|
+
overrides["write_subtitles"] = args.write_subtitles
|
|
285
|
+
if getattr(args, "embed_metadata", None) is not None:
|
|
286
|
+
overrides["embed_metadata"] = args.embed_metadata
|
|
287
|
+
if getattr(args, "embed_thumbnail", None) is not None:
|
|
288
|
+
overrides["embed_thumbnail"] = args.embed_thumbnail
|
|
289
|
+
if getattr(args, "keep_original", None) is not None:
|
|
290
|
+
overrides["keep_original"] = args.keep_original
|
|
291
|
+
return overrides
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _run_doctor(config_path: Path | None) -> int:
|
|
295
|
+
report = check_dependencies()
|
|
296
|
+
print("YoinkTUI doctor")
|
|
297
|
+
print(report.as_text())
|
|
298
|
+
|
|
299
|
+
config = load_config(config_path)
|
|
300
|
+
print(f"\nconfig file: {config_path or default_config_path()}")
|
|
301
|
+
print(f"download dir: {config.download_dir}")
|
|
302
|
+
try:
|
|
303
|
+
config.download_dir.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
probe = config.download_dir / ".yoinktui-write-test"
|
|
305
|
+
probe.write_text("ok", encoding="utf-8")
|
|
306
|
+
probe.unlink()
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
print(f"✗ download dir is not writable: {exc}")
|
|
309
|
+
return 1
|
|
310
|
+
print("✓ download dir is writable")
|
|
311
|
+
if config.cookies_file:
|
|
312
|
+
print(f"✓ cookies file: {config.cookies_file}")
|
|
313
|
+
elif config.cookies_from_browser:
|
|
314
|
+
print(f"✓ cookies from browser: {config.cookies_from_browser}")
|
|
315
|
+
if config.impersonate:
|
|
316
|
+
print(f"✓ impersonate client: {config.impersonate}")
|
|
317
|
+
for warning in config.warnings:
|
|
318
|
+
print(f"! {warning}")
|
|
319
|
+
return 0 if report.ok else 1
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main(argv: list[str] | None = None) -> int:
|
|
323
|
+
parser = _build_parser()
|
|
324
|
+
args = parser.parse_args(argv)
|
|
325
|
+
|
|
326
|
+
if args.write_default_config:
|
|
327
|
+
try:
|
|
328
|
+
path = write_default_config(args.config)
|
|
329
|
+
except FileExistsError:
|
|
330
|
+
target = args.config or default_config_path()
|
|
331
|
+
print(f"Config already exists: {target}")
|
|
332
|
+
return 1
|
|
333
|
+
print(f"Wrote {path}")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
if args.doctor:
|
|
337
|
+
return _run_doctor(args.config)
|
|
338
|
+
|
|
339
|
+
config = load_config(args.config, cli=_cli_overrides(args))
|
|
340
|
+
|
|
341
|
+
if args.print_config:
|
|
342
|
+
print(to_toml(config), end="")
|
|
343
|
+
for warning in config.warnings:
|
|
344
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
if not args.quiet:
|
|
348
|
+
for warning in config.warnings:
|
|
349
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
350
|
+
|
|
351
|
+
is_headless = not args.tui and bool(
|
|
352
|
+
args.urls or args.batch_file or args.dry_run or args.print_command
|
|
353
|
+
)
|
|
354
|
+
if is_headless:
|
|
355
|
+
from yoinktui.cli import run_headless
|
|
356
|
+
|
|
357
|
+
return run_headless(args, config)
|
|
358
|
+
|
|
359
|
+
from yoinktui.app import YoinkTUIApp
|
|
360
|
+
|
|
361
|
+
initial_url = args.urls[0] if args.urls else ""
|
|
362
|
+
app = YoinkTUIApp(config=config, initial_url=initial_url)
|
|
363
|
+
app.run()
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
sys.exit(main())
|
yoinktui/app.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Main application class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from textual.app import App
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.theme import Theme
|
|
12
|
+
|
|
13
|
+
from yoinktui.backend.config import AppConfig, load_config, load_last_url
|
|
14
|
+
from yoinktui.backend.library import LibraryStore
|
|
15
|
+
from yoinktui.backend.models import FormatInfo, MediaInfo
|
|
16
|
+
from yoinktui.backend.queue import DownloadQueue, QueueStore
|
|
17
|
+
from yoinktui.backend.ytdlp import DependencyReport, check_dependencies
|
|
18
|
+
from yoinktui.tui.screens import QueueScreen, StartupErrorScreen, UrlScreen
|
|
19
|
+
|
|
20
|
+
HIGH_CONTRAST_THEME = Theme(
|
|
21
|
+
name="high-contrast",
|
|
22
|
+
primary="#ffffff",
|
|
23
|
+
secondary="#ffff00",
|
|
24
|
+
accent="#00ffff",
|
|
25
|
+
foreground="#ffffff",
|
|
26
|
+
background="#000000",
|
|
27
|
+
surface="#000000",
|
|
28
|
+
panel="#000000",
|
|
29
|
+
warning="#ffff00",
|
|
30
|
+
error="#ff4444",
|
|
31
|
+
success="#44ff44",
|
|
32
|
+
dark=True,
|
|
33
|
+
variables={
|
|
34
|
+
"bg": "#000000",
|
|
35
|
+
"surface": "#000000",
|
|
36
|
+
"surface-raised": "#111111",
|
|
37
|
+
"surface-hover": "#222222",
|
|
38
|
+
"text": "#ffffff",
|
|
39
|
+
"text-muted": "#cccccc",
|
|
40
|
+
"accent": "#00ffff",
|
|
41
|
+
"accent-dim": "#00ffff80",
|
|
42
|
+
"warning": "#ffff00",
|
|
43
|
+
"error": "#ff4444",
|
|
44
|
+
"success": "#44ff44",
|
|
45
|
+
"border": "#ffffff",
|
|
46
|
+
"border-focus": "#ffff00",
|
|
47
|
+
"overlay": "#000000f0",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = ["AppConfig", "AppState", "YoinkTUIApp", "load_config"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class AppState:
|
|
56
|
+
"""Runtime state shared between screens.
|
|
57
|
+
|
|
58
|
+
Kept per app instance so tests (and future windows) never share mutable
|
|
59
|
+
globals. Durable history and the queue live in the backend stores; this
|
|
60
|
+
object carries the in-memory queue and the last URL used by the screens.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
last_url: str = ""
|
|
64
|
+
current_url: str = ""
|
|
65
|
+
current_info: MediaInfo | None = None
|
|
66
|
+
selected_format: FormatInfo | None = None
|
|
67
|
+
mode: str = "video"
|
|
68
|
+
queue: DownloadQueue = field(default_factory=DownloadQueue)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class YoinkTUIApp(App):
|
|
72
|
+
"""YoinkTUI — Steam-style TUI for downloading media with yt-dlp."""
|
|
73
|
+
|
|
74
|
+
TITLE = "YoinkTUI"
|
|
75
|
+
SUB_TITLE = "Snatch media from 1000+ sites"
|
|
76
|
+
CSS_PATH = "tui/app.tcss"
|
|
77
|
+
BINDINGS = [
|
|
78
|
+
Binding("ctrl+q", "quit", "Quit", show=True),
|
|
79
|
+
Binding("ctrl+d", "toggle_dark", "Toggle theme", show=True),
|
|
80
|
+
Binding("question_mark", "help", "Help", key_display="?", show=True),
|
|
81
|
+
Binding("f1", "help", "Help", show=False),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
config: AppConfig
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
config: AppConfig | None = None,
|
|
89
|
+
*,
|
|
90
|
+
state: AppState | None = None,
|
|
91
|
+
initial_url: str = "",
|
|
92
|
+
) -> None:
|
|
93
|
+
self.initial_url = initial_url
|
|
94
|
+
resolved = config or load_config()
|
|
95
|
+
# Textual reads NO_COLOR in App.__init__ and installs its monochrome
|
|
96
|
+
# line filter. Bridge our config value to it without leaking the env
|
|
97
|
+
# var into child processes or later App instances.
|
|
98
|
+
previous_no_color = os.environ.get("NO_COLOR")
|
|
99
|
+
if resolved.no_color:
|
|
100
|
+
os.environ["NO_COLOR"] = "1"
|
|
101
|
+
try:
|
|
102
|
+
super().__init__()
|
|
103
|
+
finally:
|
|
104
|
+
if resolved.no_color:
|
|
105
|
+
if previous_no_color is None:
|
|
106
|
+
os.environ.pop("NO_COLOR", None)
|
|
107
|
+
else:
|
|
108
|
+
os.environ["NO_COLOR"] = previous_no_color
|
|
109
|
+
self.config = resolved
|
|
110
|
+
with contextlib.suppress(Exception):
|
|
111
|
+
self.register_theme(HIGH_CONTRAST_THEME)
|
|
112
|
+
try:
|
|
113
|
+
self.library = LibraryStore()
|
|
114
|
+
self.library.mark_interrupted()
|
|
115
|
+
except Exception as exc: # pragma: no cover - filesystem edge case
|
|
116
|
+
self.library = LibraryStore(":memory:")
|
|
117
|
+
self.config.warnings.append(f"History is unavailable: {exc}")
|
|
118
|
+
self.queue_store = QueueStore()
|
|
119
|
+
if state is None:
|
|
120
|
+
self.state = AppState(
|
|
121
|
+
last_url=load_last_url(),
|
|
122
|
+
queue=self.queue_store.load(),
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
self.state = state
|
|
126
|
+
self.dependency_report: DependencyReport | None = None
|
|
127
|
+
try:
|
|
128
|
+
self.config.download_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
self.config.warnings.append(f"Could not create download directory: {exc}")
|
|
131
|
+
|
|
132
|
+
def on_mount(self) -> None:
|
|
133
|
+
self._apply_theme()
|
|
134
|
+
report = check_dependencies()
|
|
135
|
+
self.dependency_report = report
|
|
136
|
+
if not report.ok:
|
|
137
|
+
self.push_screen(StartupErrorScreen(report))
|
|
138
|
+
return
|
|
139
|
+
for warning in (*report.warnings, *self.config.warnings):
|
|
140
|
+
self.notify(warning, severity="warning", timeout=10, markup=False)
|
|
141
|
+
self.push_screen(UrlScreen(initial_url=self.initial_url))
|
|
142
|
+
if self.state.queue.has_resumable():
|
|
143
|
+
self.push_screen(QueueScreen(queue=self.state.queue))
|
|
144
|
+
|
|
145
|
+
def _apply_theme(self) -> None:
|
|
146
|
+
theme = (self.config.theme or "").strip()
|
|
147
|
+
lowered = theme.lower()
|
|
148
|
+
if lowered in ("high-contrast", "highcontrast"):
|
|
149
|
+
self.theme = "high-contrast"
|
|
150
|
+
self.dark = True
|
|
151
|
+
elif lowered in ("dark", "textual-dark"):
|
|
152
|
+
self.theme = "textual-dark"
|
|
153
|
+
self.dark = True
|
|
154
|
+
elif lowered in ("light", "textual-light"):
|
|
155
|
+
self.theme = "textual-light"
|
|
156
|
+
self.dark = False
|
|
157
|
+
elif theme:
|
|
158
|
+
try:
|
|
159
|
+
self.theme = theme
|
|
160
|
+
except Exception:
|
|
161
|
+
self.notify(
|
|
162
|
+
f"Unknown theme {theme!r}; keeping the default.",
|
|
163
|
+
severity="warning",
|
|
164
|
+
markup=False,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def action_toggle_dark(self) -> None:
|
|
168
|
+
self.dark = not self.dark
|
|
169
|
+
self.theme = "textual-dark" if self.dark else "textual-light"
|
|
170
|
+
|
|
171
|
+
def action_help(self) -> None:
|
|
172
|
+
from yoinktui.tui.screens import HelpModal
|
|
173
|
+
|
|
174
|
+
self.push_screen(HelpModal())
|
|
175
|
+
|
|
176
|
+
async def action_quit(self) -> None:
|
|
177
|
+
"""Cancel workers before exiting so external tools don't become orphans."""
|
|
178
|
+
for screen in list(self.screen_stack):
|
|
179
|
+
cancel = getattr(screen, "cancel_work", None)
|
|
180
|
+
if callable(cancel):
|
|
181
|
+
with contextlib.suppress(Exception):
|
|
182
|
+
cancel()
|
|
183
|
+
self.queue_store.save(self.state.queue)
|
|
184
|
+
with contextlib.suppress(Exception):
|
|
185
|
+
self.library.close()
|
|
186
|
+
self.exit()
|
|
187
|
+
|
|
188
|
+
def on_unmount(self) -> None:
|
|
189
|
+
"""Flush the queue and close history for exits that bypass action_quit."""
|
|
190
|
+
self.queue_store.save(self.state.queue)
|
|
191
|
+
with contextlib.suppress(Exception):
|
|
192
|
+
self.library.close()
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Backend package: yt-dlp orchestration, config, and domain models."""
|
|
2
|
+
|
|
3
|
+
from yoinktui.backend.config import (
|
|
4
|
+
AppConfig,
|
|
5
|
+
ConfigError,
|
|
6
|
+
config_dir,
|
|
7
|
+
config_to_dict,
|
|
8
|
+
data_dir,
|
|
9
|
+
default_config_path,
|
|
10
|
+
load_config,
|
|
11
|
+
state_dir,
|
|
12
|
+
to_toml,
|
|
13
|
+
write_default_config,
|
|
14
|
+
)
|
|
15
|
+
from yoinktui.backend.errors import BackendError, DownloadError, ErrorReason, classify_error
|
|
16
|
+
from yoinktui.backend.library import HistoryRecord, LibraryStore
|
|
17
|
+
from yoinktui.backend.models import FormatInfo, MediaInfo
|
|
18
|
+
from yoinktui.backend.presets import Preset, builtin_presets
|
|
19
|
+
from yoinktui.backend.queue import (
|
|
20
|
+
DownloadPolicy,
|
|
21
|
+
DownloadQueue,
|
|
22
|
+
QueueItem,
|
|
23
|
+
QueueStore,
|
|
24
|
+
safe_filename,
|
|
25
|
+
)
|
|
26
|
+
from yoinktui.backend.ytdlp import (
|
|
27
|
+
DependencyReport,
|
|
28
|
+
DependencyStatus,
|
|
29
|
+
DownloadControl,
|
|
30
|
+
PlaylistEntry,
|
|
31
|
+
PlaylistInfo,
|
|
32
|
+
ProgressEvent,
|
|
33
|
+
Section,
|
|
34
|
+
audio_selector,
|
|
35
|
+
check_dependencies,
|
|
36
|
+
download,
|
|
37
|
+
format_timecode,
|
|
38
|
+
parse_timecode,
|
|
39
|
+
probe,
|
|
40
|
+
probe_playlist,
|
|
41
|
+
resolve_merge_format,
|
|
42
|
+
section_slug,
|
|
43
|
+
section_spec,
|
|
44
|
+
tool_available,
|
|
45
|
+
tool_version,
|
|
46
|
+
video_selector,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AppConfig",
|
|
51
|
+
"BackendError",
|
|
52
|
+
"ConfigError",
|
|
53
|
+
"DependencyReport",
|
|
54
|
+
"DependencyStatus",
|
|
55
|
+
"DownloadControl",
|
|
56
|
+
"DownloadError",
|
|
57
|
+
"DownloadPolicy",
|
|
58
|
+
"DownloadQueue",
|
|
59
|
+
"ErrorReason",
|
|
60
|
+
"FormatInfo",
|
|
61
|
+
"HistoryRecord",
|
|
62
|
+
"LibraryStore",
|
|
63
|
+
"MediaInfo",
|
|
64
|
+
"PlaylistEntry",
|
|
65
|
+
"PlaylistInfo",
|
|
66
|
+
"Preset",
|
|
67
|
+
"ProgressEvent",
|
|
68
|
+
"QueueItem",
|
|
69
|
+
"QueueStore",
|
|
70
|
+
"Section",
|
|
71
|
+
"audio_selector",
|
|
72
|
+
"builtin_presets",
|
|
73
|
+
"check_dependencies",
|
|
74
|
+
"classify_error",
|
|
75
|
+
"config_dir",
|
|
76
|
+
"config_to_dict",
|
|
77
|
+
"data_dir",
|
|
78
|
+
"default_config_path",
|
|
79
|
+
"download",
|
|
80
|
+
"format_timecode",
|
|
81
|
+
"load_config",
|
|
82
|
+
"parse_timecode",
|
|
83
|
+
"probe",
|
|
84
|
+
"probe_playlist",
|
|
85
|
+
"resolve_merge_format",
|
|
86
|
+
"safe_filename",
|
|
87
|
+
"section_slug",
|
|
88
|
+
"section_spec",
|
|
89
|
+
"state_dir",
|
|
90
|
+
"to_toml",
|
|
91
|
+
"tool_available",
|
|
92
|
+
"tool_version",
|
|
93
|
+
"video_selector",
|
|
94
|
+
"write_default_config",
|
|
95
|
+
]
|