ytui-media 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.
app.py ADDED
@@ -0,0 +1,364 @@
1
+ """Terminal TUI Application for yt-dlp."""
2
+
3
+ import sys
4
+ from typing import Optional
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.screen import Screen
9
+ from textual.widgets import Footer, Header
10
+
11
+ from config import AppConfig
12
+ from ffmpeg_utils import ensure_ffmpeg_in_path, is_ffmpeg_available
13
+ from history import HistoryManager
14
+ from manager import DownloadManager
15
+ from rtl_utils import set_rtl_mode
16
+ from screens.download_screen import DownloadScreen
17
+ from screens.ffmpeg_modal import FfmpegSetupModal
18
+ from screens.history_screen import HistoryScreen
19
+ from screens.search_screen import SearchScreen
20
+ from screens.settings_screen import SettingsScreen
21
+ from themes import register_all_themes
22
+
23
+
24
+
25
+ class YtDlpApp(App):
26
+ """Clean, high-density terminal tool with customizable theme profiles."""
27
+
28
+ TITLE = "YTUI"
29
+ SUB_TITLE = "Terminal Media Downloader"
30
+ ENABLE_COMMAND_PALETTE = False
31
+
32
+ CSS = """
33
+ * {
34
+ scrollbar-background: $surface;
35
+ scrollbar-background-hover: $surface;
36
+ scrollbar-background-active: $panel;
37
+ scrollbar-color: $boost;
38
+ scrollbar-color-hover: $secondary;
39
+ scrollbar-color-active: $primary;
40
+ scrollbar-size-vertical: 1;
41
+ scrollbar-size-horizontal: 1;
42
+ }
43
+ Screen {
44
+ background: $background;
45
+ color: $foreground;
46
+ layout: vertical;
47
+ width: 100%;
48
+ height: 100%;
49
+ margin: 0;
50
+ padding: 0;
51
+ scrollbar-background: $surface;
52
+ scrollbar-color: $boost;
53
+ scrollbar-color-hover: $secondary;
54
+ scrollbar-color-active: $primary;
55
+ scrollbar-size-vertical: 1;
56
+ scrollbar-size-horizontal: 1;
57
+ }
58
+ ScrollBar {
59
+ background: $surface;
60
+ color: $boost;
61
+ }
62
+ ScrollBar.-vertical {
63
+ width: 1;
64
+ background: $surface;
65
+ }
66
+ ScrollBar.-horizontal {
67
+ height: 1;
68
+ background: $surface;
69
+ }
70
+ ScrollBar > .scrollbar--bar {
71
+ color: $boost;
72
+ background: $surface;
73
+ }
74
+ ScrollBar > .scrollbar--bar:hover {
75
+ color: $secondary;
76
+ background: $surface;
77
+ }
78
+ ScrollBar.-active > .scrollbar--bar {
79
+ color: $primary;
80
+ background: $panel;
81
+ }
82
+ ScrollBarCorner {
83
+ background: $surface;
84
+ }
85
+ Header {
86
+ background: $surface;
87
+ color: $primary;
88
+ height: 1;
89
+ dock: top;
90
+ }
91
+ Footer {
92
+ background: $surface;
93
+ color: $text-muted;
94
+ height: 1;
95
+ dock: bottom;
96
+ }
97
+ Footer > .footer--key {
98
+ background: $panel;
99
+ color: $primary;
100
+ text-style: bold;
101
+ padding: 0 1;
102
+ }
103
+ Footer > .footer--description {
104
+ color: $text-muted;
105
+ padding: 0 1 0 0;
106
+ }
107
+ Footer > .footer--highlight {
108
+ background: $boost;
109
+ color: $foreground;
110
+ }
111
+ Input {
112
+ background: $surface;
113
+ border: none;
114
+ height: 1;
115
+ padding: 0 1;
116
+ color: $foreground;
117
+ }
118
+ Input:focus {
119
+ background: $panel;
120
+ color: $foreground;
121
+ text-style: bold;
122
+ }
123
+ Checkbox {
124
+ background: transparent;
125
+ color: $foreground;
126
+ border: none;
127
+ height: auto;
128
+ padding: 0;
129
+ }
130
+ Checkbox:focus {
131
+ background: transparent;
132
+ color: $primary;
133
+ text-style: bold;
134
+ }
135
+ Checkbox > .toggle--label {
136
+ color: $foreground;
137
+ background: transparent;
138
+ }
139
+ Checkbox:focus > .toggle--label {
140
+ color: $primary;
141
+ }
142
+ Checkbox > .toggle--button {
143
+ color: $surface;
144
+ background: $boost;
145
+ }
146
+ Checkbox:focus > .toggle--button {
147
+ color: $background;
148
+ background: $primary;
149
+ }
150
+ Checkbox.-on > .toggle--button {
151
+ color: $background;
152
+ background: $primary;
153
+ }
154
+ DataTable {
155
+ background: $background;
156
+ border: none;
157
+ padding: 0;
158
+ color: $foreground;
159
+ }
160
+ DataTable > .datatable--header {
161
+ background: $surface;
162
+ color: $primary;
163
+ text-style: bold;
164
+ border-bottom: solid $border;
165
+ }
166
+ DataTable > .datatable--cursor {
167
+ background: $panel;
168
+ color: $primary;
169
+ text-style: bold;
170
+ }
171
+ DataTable > .datatable--hover {
172
+ background: $boost;
173
+ }
174
+ ListView {
175
+ background: $background;
176
+ padding: 0;
177
+ }
178
+ ListItem {
179
+ background: transparent;
180
+ margin: 0;
181
+ padding: 0 1;
182
+ border: none;
183
+ color: $foreground;
184
+ }
185
+ ListItem:focus {
186
+ background: $panel;
187
+ color: $primary;
188
+ text-style: bold;
189
+ }
190
+ ProgressBar {
191
+ height: 1;
192
+ padding: 0;
193
+ border: none;
194
+ }
195
+ ProgressBar > .progressbar--bar {
196
+ color: $primary;
197
+ background: $panel;
198
+ }
199
+ ProgressBar > .progressbar--complete {
200
+ color: $success;
201
+ }
202
+ Checkbox {
203
+ background: transparent;
204
+ border: none;
205
+ padding: 0 1;
206
+ height: 1;
207
+ }
208
+ Checkbox > .toggle--label {
209
+ color: $foreground;
210
+ }
211
+ Select {
212
+ background: $surface;
213
+ border: none;
214
+ height: 1;
215
+ padding: 0 1;
216
+ }
217
+ SelectCurrent {
218
+ border: none;
219
+ height: 1;
220
+ padding: 0 1;
221
+ }
222
+ Button {
223
+ height: 1;
224
+ min-width: 6;
225
+ padding: 0 1;
226
+ border: none;
227
+ background: $panel;
228
+ color: $foreground;
229
+ }
230
+ Button:focus, Button:hover {
231
+ background: $primary;
232
+ color: $background;
233
+ text-style: bold;
234
+ }
235
+ Button.-primary {
236
+ background: $primary;
237
+ color: $background;
238
+ text-style: bold;
239
+ }
240
+ Button.-success {
241
+ background: $success;
242
+ color: $background;
243
+ text-style: bold;
244
+ }
245
+ Button.-warning {
246
+ background: $warning;
247
+ color: $background;
248
+ text-style: bold;
249
+ }
250
+ Button.-error {
251
+ background: $error;
252
+ color: $background;
253
+ text-style: bold;
254
+ }
255
+ ToastHolder {
256
+ dock: top;
257
+ align: right top;
258
+ height: auto;
259
+ width: auto;
260
+ }
261
+ Toast {
262
+ background: $surface;
263
+ color: $foreground;
264
+ border: solid $border;
265
+ border-left: thick $primary;
266
+ padding: 0 1;
267
+ width: auto;
268
+ min-width: 32;
269
+ max-width: 60;
270
+ }
271
+ Toast > .toast--title {
272
+ color: $primary;
273
+ text-style: bold;
274
+ }
275
+ Toast > .toast--message {
276
+ color: $text-muted;
277
+ }
278
+ Toast.-information {
279
+ border-left: thick $primary;
280
+ }
281
+ Toast.-warning {
282
+ border-left: thick $warning;
283
+ }
284
+ Toast.-error {
285
+ border-left: thick $error;
286
+ }
287
+ LoadingIndicator {
288
+ color: $primary;
289
+ background: transparent;
290
+ height: 1;
291
+ }
292
+ """
293
+
294
+ BINDINGS = [
295
+ Binding("ctrl+s", "switch_to_search", "Search", show=True),
296
+ Binding("ctrl+j", "switch_to_downloads", "Queue", show=True),
297
+ Binding("ctrl+y", "switch_to_history", "History", show=True),
298
+ Binding("ctrl+o", "switch_to_settings", "Config", show=True),
299
+ Binding("ctrl+q", "quit_app", "Quit", show=True),
300
+ ]
301
+
302
+ SCREENS = {
303
+ "search_screen": SearchScreen,
304
+ "download_screen": DownloadScreen,
305
+ "history_screen": HistoryScreen,
306
+ "settings_screen": SettingsScreen,
307
+ }
308
+
309
+ def __init__(self, **kwargs):
310
+ super().__init__(**kwargs)
311
+ self.config = AppConfig.load()
312
+ self.history = HistoryManager()
313
+ self.manager = DownloadManager.get_instance(config=self.config, history=self.history)
314
+
315
+ def on_mount(self) -> None:
316
+ # Register all custom theme profiles
317
+ register_all_themes(self)
318
+
319
+ # Apply persisted theme and RTL mode
320
+ try:
321
+ self.theme = self.config.theme
322
+ except Exception:
323
+ pass
324
+ try:
325
+ set_rtl_mode(getattr(self.config, "rtl_mode", "reshaped_bidi"))
326
+ except Exception:
327
+ pass
328
+
329
+ # Ensure FFmpeg is configured in PATH
330
+ ensure_ffmpeg_in_path(getattr(self.config, "ffmpeg_location", None))
331
+
332
+ self.push_screen("search_screen")
333
+
334
+ # Prompt for FFmpeg download on first run / if missing
335
+ if not is_ffmpeg_available(getattr(self.config, "ffmpeg_location", None)) and not getattr(self.config, "skip_ffmpeg_check", False):
336
+ self.push_screen(FfmpegSetupModal(is_first_launch=True))
337
+
338
+ def action_switch_to_search(self) -> None:
339
+ self.switch_screen("search_screen")
340
+
341
+ def action_switch_to_downloads(self) -> None:
342
+ self.switch_screen("download_screen")
343
+
344
+ def action_switch_to_history(self) -> None:
345
+ self.switch_screen("history_screen")
346
+
347
+ def action_switch_to_settings(self) -> None:
348
+ self.switch_screen("settings_screen")
349
+
350
+ def on_unmount(self) -> None:
351
+ self.manager.shutdown()
352
+
353
+ def action_quit_app(self) -> None:
354
+ self.manager.shutdown()
355
+ self.exit()
356
+
357
+
358
+ def main():
359
+ app = YtDlpApp()
360
+ app.run()
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()
config.py ADDED
@@ -0,0 +1,232 @@
1
+ """Configuration manager for YTUI."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+
11
+ def sanitize_path(raw_path: Optional[str]) -> str:
12
+ """Sanitize user-entered path strings: strip quotes, expand variables, resolve tildes."""
13
+ if not raw_path:
14
+ return ""
15
+ # Strip surrounding single/double quotes, whitespace, and smart quotes
16
+ cleaned = str(raw_path).strip().strip('"\'“”‘’').strip()
17
+ if not cleaned:
18
+ return ""
19
+ # Expand environment variables (e.g. %USERPROFILE%, %APPDATA%)
20
+ cleaned = os.path.expandvars(cleaned)
21
+ # Expand user home tilde (~)
22
+ cleaned = os.path.expanduser(cleaned)
23
+ return cleaned
24
+
25
+
26
+ DEFAULT_FILENAME_TEMPLATE = "%(title)s [%(id)s].%(ext)s"
27
+ SPONSORBLOCK_CATEGORIES = (
28
+ "sponsor",
29
+ "selfpromo",
30
+ "interaction",
31
+ "intro",
32
+ "outro",
33
+ "preview",
34
+ "filler",
35
+ "music_offtopic",
36
+ "hook",
37
+ )
38
+ DEFAULT_SPONSORBLOCK_CATEGORIES = "sponsor,selfpromo"
39
+
40
+
41
+ def sanitize_filename_template(template: Optional[str]) -> str:
42
+ """Sanitize and validate filename template to prevent directory traversal outside download directory."""
43
+ if not template or not isinstance(template, str):
44
+ return DEFAULT_FILENAME_TEMPLATE
45
+ cleaned = str(template).strip().strip('"\'“”‘’').strip()
46
+ if not cleaned:
47
+ return DEFAULT_FILENAME_TEMPLATE
48
+ # Block absolute paths (POSIX / Windows) or drive specifications
49
+ if cleaned.startswith(("/", "\\")) or (len(cleaned) >= 2 and cleaned[1] == ":"):
50
+ return DEFAULT_FILENAME_TEMPLATE
51
+ p = Path(cleaned)
52
+ if p.is_absolute():
53
+ return DEFAULT_FILENAME_TEMPLATE
54
+ # Block path traversal segments (..)
55
+ parts = cleaned.replace("\\", "/").split("/")
56
+ if ".." in parts:
57
+ return DEFAULT_FILENAME_TEMPLATE
58
+ return cleaned
59
+
60
+
61
+ def sanitize_sponsorblock_categories(categories: Optional[str]) -> str:
62
+ """Return a normalized comma-separated list of removable SponsorBlock categories."""
63
+ requested = str(categories or "").lower().split(",")
64
+ valid_categories = []
65
+ for category in requested:
66
+ clean_category = category.strip()
67
+ if clean_category in SPONSORBLOCK_CATEGORIES and clean_category not in valid_categories:
68
+ valid_categories.append(clean_category)
69
+ return ",".join(valid_categories) or DEFAULT_SPONSORBLOCK_CATEGORIES
70
+
71
+
72
+ def atomic_json_save(path: Path, data: Any, indent: int = 2, file_mode: int = 0o600, dir_mode: int = 0o700) -> None:
73
+ """Atomically write JSON data to path using a temporary file and os.replace."""
74
+ target_path = Path(path).resolve() if not isinstance(path, Path) else path
75
+ parent_dir = target_path.parent
76
+ parent_dir.mkdir(parents=True, exist_ok=True)
77
+ if os.name == "posix":
78
+ try:
79
+ os.chmod(parent_dir, dir_mode)
80
+ except OSError:
81
+ pass
82
+
83
+ tmp_path = parent_dir / f"{target_path.name}.tmp"
84
+ try:
85
+ with open(tmp_path, "w", encoding="utf-8") as f:
86
+ json.dump(data, f, indent=indent)
87
+ if os.name == "posix":
88
+ try:
89
+ os.chmod(tmp_path, file_mode)
90
+ except OSError:
91
+ pass
92
+ os.replace(tmp_path, target_path)
93
+ except Exception:
94
+ if tmp_path.exists():
95
+ try:
96
+ tmp_path.unlink()
97
+ except OSError:
98
+ pass
99
+ raise
100
+
101
+
102
+ def get_default_download_dir() -> str:
103
+ """Return default user Downloads folder or current directory."""
104
+ home = Path.home()
105
+ downloads = home / "Downloads"
106
+ if downloads.exists():
107
+ return str(downloads)
108
+ return str(home)
109
+
110
+
111
+ def get_config_dir() -> Path:
112
+ """Return platform-appropriate configuration directory."""
113
+ if sys.platform == "win32":
114
+ app_data = os.environ.get("APPDATA")
115
+ if app_data:
116
+ base = Path(app_data)
117
+ else:
118
+ base = Path.home() / "AppData" / "Roaming"
119
+ else:
120
+ base = Path.home() / ".config"
121
+ legacy_dir = base / "yt-dlp-tui"
122
+ cfg_dir = base / "ytui"
123
+ if not cfg_dir.exists() and legacy_dir.exists():
124
+ cfg_dir = legacy_dir
125
+ else:
126
+ cfg_dir.mkdir(parents=True, exist_ok=True)
127
+ if os.name == "posix":
128
+ try:
129
+ os.chmod(cfg_dir, 0o700)
130
+ except OSError:
131
+ pass
132
+ return cfg_dir
133
+
134
+
135
+ @dataclass
136
+ class AppConfig:
137
+ """Strongly typed application configuration settings."""
138
+
139
+ # Download paths & naming
140
+ download_dir: str = field(default_factory=get_default_download_dir)
141
+ filename_template: str = DEFAULT_FILENAME_TEMPLATE
142
+
143
+ def __post_init__(self) -> None:
144
+ if self.cookies_file:
145
+ self.cookies_file = sanitize_path(self.cookies_file)
146
+ if self.download_dir:
147
+ self.download_dir = sanitize_path(self.download_dir) or get_default_download_dir()
148
+ self.filename_template = sanitize_filename_template(self.filename_template)
149
+ self.sponsorblock_categories = sanitize_sponsorblock_categories(self.sponsorblock_categories)
150
+
151
+ # Bandwidth & concurrency
152
+ max_concurrent_downloads: int = 3
153
+ rate_limit: str = "0" # e.g., '0' for unlimited, '2M', '500K'
154
+
155
+ # Network resiliency & auto-resume
156
+ retries: int = 10
157
+ fragment_retries: int = 10
158
+ continuedl: bool = True
159
+
160
+ # Authentication & Cookies
161
+ browser_cookies: str = "none" # none, chrome, firefox, edge, brave, opera, vivaldi, safari
162
+ cookies_file: str = ""
163
+
164
+ # Subtitles
165
+ download_subtitles: bool = False
166
+ auto_generated_subtitles: bool = False
167
+ subtitle_mode: str = "embed" # 'embed', 'external', or 'both'
168
+ subtitle_langs: str = "en"
169
+
170
+ # Thumbnails & Chapters
171
+ download_thumbnail: bool = False
172
+ thumbnail_mode: str = "embed" # 'embed' or 'file'
173
+ embed_chapters: bool = False
174
+ split_chapters: bool = False
175
+ remove_sponsor_segments: bool = False
176
+ sponsorblock_categories: str = DEFAULT_SPONSORBLOCK_CATEGORIES
177
+
178
+ # Metadata
179
+ embed_metadata: bool = True
180
+
181
+ # Media Engine & FFmpeg
182
+ ffmpeg_location: str = ""
183
+ skip_ffmpeg_check: bool = False
184
+
185
+ # Proxy & Geo-bypass
186
+ proxy: str = ""
187
+ geo_bypass: bool = True
188
+
189
+ # Appearance & Display
190
+ theme: str = "shadcn-zinc"
191
+ rtl_mode: str = "reshaped_bidi" # 'reshaped_bidi', 'native_raw', 'bidi_only', 'disabled'
192
+
193
+ @classmethod
194
+ def config_file_path(cls) -> Path:
195
+ return get_config_dir() / "config.json"
196
+
197
+ @classmethod
198
+ def load(cls) -> "AppConfig":
199
+ path = cls.config_file_path()
200
+ if not path.exists():
201
+ config = cls()
202
+ config.save()
203
+ return config
204
+ try:
205
+ with open(path, "r", encoding="utf-8") as f:
206
+ data = json.load(f)
207
+ # Filter out keys that don't match dataclass fields
208
+ valid_keys = {f.name for f in cls.__dataclass_fields__.values()}
209
+ filtered = {k: v for k, v in data.items() if k in valid_keys}
210
+ return cls(**filtered)
211
+ except Exception:
212
+ return cls()
213
+
214
+ def save(self) -> None:
215
+ path = self.config_file_path()
216
+ try:
217
+ atomic_json_save(path, asdict(self), indent=2, file_mode=0o600, dir_mode=0o700)
218
+ except Exception as e:
219
+ print(f"Warning: Could not save config: {e}", file=sys.stderr)
220
+
221
+ def to_dict(self) -> Dict[str, Any]:
222
+ return asdict(self)
223
+
224
+ def update(self, **kwargs: Any) -> None:
225
+ for k, v in kwargs.items():
226
+ if hasattr(self, k):
227
+ setattr(self, k, v)
228
+ if "filename_template" in kwargs:
229
+ self.filename_template = sanitize_filename_template(self.filename_template)
230
+ if "sponsorblock_categories" in kwargs:
231
+ self.sponsorblock_categories = sanitize_sponsorblock_categories(self.sponsorblock_categories)
232
+ self.save()