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 +364 -0
- config.py +232 -0
- ffmpeg_utils.py +342 -0
- history.py +145 -0
- manager.py +681 -0
- rtl_utils.py +110 -0
- screens/__init__.py +21 -0
- screens/download_screen.py +582 -0
- screens/ffmpeg_modal.py +296 -0
- screens/format_screen.py +502 -0
- screens/help_screen.py +1 -0
- screens/history_screen.py +361 -0
- screens/playlist_screen.py +603 -0
- screens/search_screen.py +362 -0
- screens/settings_screen.py +747 -0
- themes.py +440 -0
- widgets/__init__.py +1 -0
- widgets/format_column.py +152 -0
- widgets/progress_card.py +77 -0
- ytdlp_engine.py +1132 -0
- ytui_media-0.1.0.dist-info/METADATA +427 -0
- ytui_media-0.1.0.dist-info/RECORD +26 -0
- ytui_media-0.1.0.dist-info/WHEEL +5 -0
- ytui_media-0.1.0.dist-info/entry_points.txt +3 -0
- ytui_media-0.1.0.dist-info/licenses/LICENSE +21 -0
- ytui_media-0.1.0.dist-info/top_level.txt +10 -0
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()
|