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 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
+ ]