open-downloader-cli 2.2.1__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.
odl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ فارسی: بسته‌ی Open Downloader CLI — یک دانلودر ساده، امن و رزیوم‌پذیر یوتیوب بر پایه‌ی yt-dlp.
3
+ English: The Open Downloader CLI package — a simple, secure, resumable YouTube downloader built on yt-dlp.
4
+ """
5
+
6
+ from .constants import ODL_VERSION as __version__
7
+
8
+ __all__ = ["__version__"]
odl/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ فارسی: امکان اجرای بسته با دستور «python -m odl».
3
+ English: Allows running the package with "python -m odl".
4
+ """
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
odl/cli.py ADDED
@@ -0,0 +1,478 @@
1
+ """
2
+ فارسی: پارسر آرگومان‌های خط فرمان و تابع main() که همه‌ی ماژول‌های دیگر را به هم متصل می‌کند.
3
+ English: The command-line argument parser and the main() function that wires all other modules together.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import platform
10
+ import sys
11
+
12
+ import yt_dlp
13
+
14
+ from . import constants as c
15
+ from . import state
16
+ from .config import load_config, parse_set_argument, save_default_config, write_config
17
+ from .cookies import (
18
+ CookieError,
19
+ find_and_import_cookies_automatically,
20
+ print_cookie_export_guide,
21
+ print_cookie_status,
22
+ reset_encrypted_cookies,
23
+ resolve_cookies_path,
24
+ secure_cookies_setup,
25
+ try_automatic_cookie_import,
26
+ )
27
+ from .diagnostics import run_check_self_update, run_check_update, run_doctor, run_update
28
+ from .downloader import build_extractor_args, download_single
29
+ from .playlist import download_playlist
30
+ from .proxy_pool import ProxyPoolError, load_proxy_candidates, resolve_working_proxy, save_cached_proxy, test_proxy
31
+ from .state import console
32
+
33
+
34
+ def build_arg_parser() -> argparse.ArgumentParser:
35
+ """
36
+ فارسی: پارسر آرگومان‌های خط فرمان را می‌سازد.
37
+ English: Build the command-line argument parser.
38
+ """
39
+ parser = argparse.ArgumentParser(
40
+ prog="odl",
41
+ description="Open Downloader CLI (odl) — a simple, resumable YouTube downloader built on yt-dlp",
42
+ )
43
+ parser.add_argument("url", nargs="?", help="YouTube video or playlist URL")
44
+ parser.add_argument("-p", "--playlist", action="store_true", help="playlist mode")
45
+ parser.add_argument(
46
+ "-q",
47
+ "--quality",
48
+ type=int,
49
+ default=None,
50
+ help=f"video quality: {', '.join(map(str, c.ALLOWED_QUALITIES))}",
51
+ )
52
+ parser.add_argument("-s", "--sub-en", action="store_true", help="download English subtitles")
53
+ parser.add_argument("-fs", "--sub-fa", action="store_true", help="download Persian subtitles (if available)")
54
+ parser.add_argument("-a", "--audio-only", action="store_true", help="audio only (mp3)")
55
+ parser.add_argument("-o", "--output", type=str, default=None, help="custom output directory")
56
+ parser.add_argument("-b", "--batch", type=int, default=None, help="number of concurrent downloads in playlist mode")
57
+ parser.add_argument(
58
+ "-x",
59
+ "--proxy",
60
+ type=str,
61
+ default=None,
62
+ help="proxy address, e.g. socks5h://127.0.0.1:9050",
63
+ )
64
+ parser.add_argument(
65
+ "--proxy-pool",
66
+ type=str,
67
+ default=None,
68
+ metavar="FILE_OR_URL",
69
+ help="a local file or URL with one proxy per line; odl will automatically "
70
+ "test, cache, and rotate through them (beginners: no need to know "
71
+ "which proxy actually works)",
72
+ )
73
+ parser.add_argument(
74
+ "--proxy-pool-refresh",
75
+ action="store_true",
76
+ help="ignore the cached proxy and re-scan the whole --proxy-pool source",
77
+ )
78
+ parser.add_argument(
79
+ "--test-proxies",
80
+ action="store_true",
81
+ help="test every proxy in --proxy-pool (or the configured proxy_pool_source) "
82
+ "and show which ones work, without downloading anything",
83
+ )
84
+ parser.add_argument(
85
+ "--player-client",
86
+ type=str,
87
+ default=None,
88
+ help="force a specific YouTube playback client, e.g. 'android' (helps bypass bot detection)",
89
+ )
90
+ parser.add_argument(
91
+ "--bypass",
92
+ action="store_true",
93
+ help="lighter/faster extraction that skips extra YouTube webpage requests (may miss some formats or subtitles)",
94
+ )
95
+ parser.add_argument(
96
+ "--secure-cookies",
97
+ action="store_true",
98
+ help="encrypt the current cookies.txt file and store a secure version",
99
+ )
100
+ parser.add_argument(
101
+ "--reset-cookies",
102
+ action="store_true",
103
+ help="delete the encrypted cookie file (use this if you forgot the master password)",
104
+ )
105
+ parser.add_argument(
106
+ "--import-cookies",
107
+ action="store_true",
108
+ help="force a fresh automatic cookie import even if an encrypted cookie file already exists",
109
+ )
110
+ parser.add_argument(
111
+ "--cookie-status",
112
+ action="store_true",
113
+ help="show whether cookies are encrypted/plaintext/missing and when they were imported",
114
+ )
115
+ parser.add_argument(
116
+ "--no-estimate",
117
+ action="store_true",
118
+ help="skip the (potentially slow) total-size estimation step for large playlists",
119
+ )
120
+ parser.add_argument(
121
+ "--debug",
122
+ action="store_true",
123
+ help="show detailed diagnostic info and full tracebacks on error",
124
+ )
125
+ parser.add_argument(
126
+ "--doctor",
127
+ action="store_true",
128
+ help="check the health of the installation (Python, yt-dlp, ffmpeg, cookies, permissions)",
129
+ )
130
+ parser.add_argument(
131
+ "--check-update",
132
+ action="store_true",
133
+ help="check whether a newer yt-dlp version is available, without installing it",
134
+ )
135
+ parser.add_argument(
136
+ "--update",
137
+ action="store_true",
138
+ help="update yt-dlp to the latest version",
139
+ )
140
+ parser.add_argument(
141
+ "--check-self-update",
142
+ action="store_true",
143
+ help="check whether a newer version of Open Downloader CLI itself is available",
144
+ )
145
+ parser.add_argument(
146
+ "--config",
147
+ action="store_true",
148
+ help="show the current configuration settings",
149
+ )
150
+ parser.add_argument(
151
+ "--set",
152
+ type=str,
153
+ default=None,
154
+ metavar="KEY=VALUE",
155
+ help="change a configuration setting, e.g. --set quality=720",
156
+ )
157
+ parser.add_argument("--version", action="version", version=f"Open Downloader CLI (odl) {c.ODL_VERSION}")
158
+ return parser
159
+
160
+
161
+ def _run_show_config() -> None:
162
+ """
163
+ فارسی: تنظیمات فعلی را در یک جدول نمایش می‌دهد.
164
+ English: Display the current settings in a table.
165
+ """
166
+ from rich.panel import Panel
167
+ from rich.table import Table
168
+
169
+ cfg = load_config()
170
+ table = Table(show_header=False, box=None)
171
+ for key, value in cfg.items():
172
+ if key == "cookies":
173
+ continue
174
+ table.add_row(f"[bold]{key}[/bold]", str(value) if value is not None else "[dim]not set[/dim]")
175
+ console.print(Panel(table, title="Current Configuration", border_style="cyan"))
176
+ console.print(f"[dim]Config file: {c.CONFIG_FILE}[/dim]")
177
+
178
+
179
+ def _run_set_config(arg: str) -> None:
180
+ """
181
+ فارسی: یک تنظیم را تغییر می‌دهد و روی فایل کانفیگ ذخیره می‌کند.
182
+ English: Change one setting and save it to the config file.
183
+ """
184
+ try:
185
+ key, value = parse_set_argument(arg)
186
+ except ValueError as e:
187
+ console.print(f"[red]{e}[/red]")
188
+ sys.exit(1)
189
+
190
+ cfg = load_config()
191
+ cfg[key] = value
192
+ write_config(cfg)
193
+ console.print(f"[green]✔ {key} set to {value!r}[/green]")
194
+
195
+
196
+ def _run_test_proxies(source: str) -> None:
197
+ """
198
+ فارسی: تمام کاندیدهای منبع پروکسی را تست می‌کند، نتیجه را در یک جدول
199
+ نشان می‌دهد، و اولین پروکسی سالم را کش می‌کند. دانلودی انجام
200
+ نمی‌شود؛ فقط برای دیدن وضعیت پروکسی‌ها قبل از یک دانلود واقعی است.
201
+ English: Test every candidate from the proxy source, show the results
202
+ in a table, and cache the first working one. No download
203
+ happens; this is only for checking proxy status before a real
204
+ download.
205
+ """
206
+ from rich.table import Table
207
+
208
+ console.print(f"[cyan]Testing proxies from: {source}[/cyan]")
209
+ try:
210
+ candidates = load_proxy_candidates(source)
211
+ except ProxyPoolError as e:
212
+ console.print(f"[red]{e}[/red]")
213
+ return
214
+
215
+ table = Table(title="Proxy Test Results")
216
+ table.add_column("#", justify="right")
217
+ table.add_column("Proxy")
218
+ table.add_column("Status", justify="center")
219
+ table.add_column("Latency", justify="right")
220
+
221
+ first_working: str | None = None
222
+ for i, candidate in enumerate(candidates, start=1):
223
+ console.print(f"[dim]Testing {i}/{len(candidates)}: {candidate}...[/dim]")
224
+ result = test_proxy(candidate)
225
+ if result.ok:
226
+ latency = f"{result.latency_ms:.0f} ms" if result.latency_ms is not None else "-"
227
+ table.add_row(str(i), candidate, "[green]✔ works[/green]", latency)
228
+ if first_working is None:
229
+ first_working = candidate
230
+ else:
231
+ table.add_row(str(i), candidate, "[red]✘ failed[/red]", "-")
232
+
233
+ console.print(table)
234
+
235
+ if first_working:
236
+ save_cached_proxy(source, first_working)
237
+ console.print(f"[green]✔ Cached working proxy for next time: {first_working}[/green]")
238
+ else:
239
+ console.print("[red]None of the proxies worked.[/red]")
240
+
241
+
242
+ def _resolve_proxy_via_pool(source: str, force_refresh: bool) -> str | None:
243
+ """
244
+ فارسی: پروکسی سالم را از استخر می‌گیرد و پیشرفت تست را روی ترمینال
245
+ نشان می‌دهد. اگر منبع در دسترس نبود یا هیچ پروکسی کار نکرد،
246
+ فاتال نیست — بدون پروکسی ادامه داده می‌شود.
247
+ English: Get a working proxy from the pool and show test progress on
248
+ the terminal. If the source is unreachable or no proxy works,
249
+ it's not fatal — continues without a proxy.
250
+ """
251
+
252
+ def on_event(i: int, total: int, result) -> None:
253
+ if i == 0:
254
+ status = "[green]✔ still works[/green]" if result.ok else "[yellow]✘ dead now[/yellow]"
255
+ console.print(f"[dim]Re-checking cached proxy {result.proxy}: {status}[/dim]")
256
+ else:
257
+ status = "[green]✔[/green]" if result.ok else "[red]✘[/red]"
258
+ console.print(f"[dim]{status} proxy {i}/{total}: {result.proxy}[/dim]")
259
+
260
+ try:
261
+ proxy = resolve_working_proxy(source, force_refresh=force_refresh, on_event=on_event)
262
+ except ProxyPoolError as e:
263
+ console.print(f"[red]Proxy pool error: {e}[/red]")
264
+ return None
265
+
266
+ if proxy is None:
267
+ console.print("[yellow]No working proxy found in the pool; continuing without a proxy.[/yellow]")
268
+ return proxy
269
+
270
+
271
+ def _noop_cleanup() -> None:
272
+ """
273
+ فارسی: تابع cleanup پیش‌فرض وقتی هنوز فایل موقت کوکی‌ای ساخته نشده.
274
+ English: Default no-op cleanup before any temp cookie file exists.
275
+ """
276
+ return None
277
+
278
+
279
+ def _exit_on_cookie_error(e: CookieError) -> None:
280
+ """
281
+ فارسی: پیام CookieError را روی ترمینال نشان می‌دهد و با کد ۱ خارج
282
+ می‌شود. این تنها جایی‌ست که چنین خطایی به بستن کامل پردازه
283
+ منجر می‌شود؛ لایه‌ی core (cookies.py) دیگر خودش sys.exit صدا
284
+ نمی‌زند — یعنی وقتی این تابع از داخل یک GUI صدا زده بشه، به‌جای
285
+ همین سه خط، می‌شه یه دیالوگ خطا نشون داد بدون این‌که کل اپ ببنده.
286
+ English: Print the CookieError's message and exit with code 1. This is
287
+ the only place such an error results in killing the whole
288
+ process; the core layer (cookies.py) no longer calls
289
+ sys.exit itself — meaning when this is called from a GUI
290
+ context instead, these three lines can become an error
291
+ dialog without the whole app disappearing.
292
+ """
293
+ console.print(f"[red]{e}[/red]")
294
+ sys.exit(1)
295
+
296
+
297
+ def main() -> None:
298
+ args = build_arg_parser().parse_args()
299
+ state.set_debug(args.debug)
300
+
301
+ if args.doctor:
302
+ run_doctor()
303
+ sys.exit(0)
304
+
305
+ if args.check_update:
306
+ run_check_update()
307
+ sys.exit(0)
308
+
309
+ if args.update:
310
+ run_update()
311
+ sys.exit(0)
312
+
313
+ if args.check_self_update:
314
+ run_check_self_update()
315
+ sys.exit(0)
316
+
317
+ if args.config:
318
+ _run_show_config()
319
+ sys.exit(0)
320
+
321
+ if args.set:
322
+ _run_set_config(args.set)
323
+ sys.exit(0)
324
+
325
+ if args.test_proxies:
326
+ cfg_for_proxy_test = load_config()
327
+ source = args.proxy_pool or cfg_for_proxy_test.get("proxy_pool_source")
328
+ if not source:
329
+ console.print(
330
+ "[red]No proxy pool source given. Use --proxy-pool <file_or_url>, "
331
+ "or set a default with 'odl --set proxy_pool_source=<file_or_url>'.[/red]"
332
+ )
333
+ sys.exit(1)
334
+ _run_test_proxies(source)
335
+ sys.exit(0)
336
+
337
+ if args.cookie_status:
338
+ print_cookie_status()
339
+ sys.exit(0)
340
+
341
+ if args.reset_cookies:
342
+ reset_encrypted_cookies()
343
+ sys.exit(0)
344
+
345
+ if args.secure_cookies:
346
+ try:
347
+ secure_cookies_setup()
348
+ except CookieError as e:
349
+ _exit_on_cookie_error(e)
350
+ sys.exit(0)
351
+
352
+ # فارسی: چک نبود URL باید قبل از منطق کوکی باشد، وگرنه کاربری که فقط
353
+ # می‌خواهد راهنما را ببیند، اول با پرامپت رمز عبور کوکی مواجه می‌شود.
354
+ # English: The missing-URL check must come before the cookie logic,
355
+ # otherwise a user who just wants to see the help text is
356
+ # first confronted with a cookie master-password prompt.
357
+ if not args.url:
358
+ build_arg_parser().print_help()
359
+ sys.exit(1)
360
+
361
+ try_automatic_cookie_import(force=args.import_cookies)
362
+
363
+ find_and_import_cookies_automatically(force=args.import_cookies)
364
+
365
+ auto_cookies_path: str | None = None
366
+ auto_cleanup: c.CleanupFn = _noop_cleanup
367
+ if c.COOKIES_DEFAULT.exists():
368
+ try:
369
+ auto_cookies_path, auto_cleanup = secure_cookies_setup(auto=True)
370
+ except CookieError as e:
371
+ _exit_on_cookie_error(e)
372
+
373
+ cfg = load_config()
374
+ save_default_config()
375
+
376
+ quality = args.quality if args.quality is not None else cfg.get("quality", c.DEFAULT_QUALITY)
377
+ if quality not in c.ALLOWED_QUALITIES:
378
+ console.print(f"[red]Quality {quality} is not valid.[/red]")
379
+ console.print(f"Allowed qualities: {', '.join(map(str, c.ALLOWED_QUALITIES))}")
380
+ sys.exit(1)
381
+
382
+ out_dir = args.output if args.output else cfg.get("download_dir", str(c.DOWNLOAD_DIR_DEFAULT))
383
+ batch_size = args.batch if args.batch is not None else cfg.get("batch_size", c.BATCH_SIZE)
384
+ # فارسی: این چک صرفاً برای --set نیست؛ فایل کانفیگ ممکن است دستی
385
+ # ویرایش شده باشد، پس باید همینجا هم دوباره اعتبارسنجی شود
386
+ # وگرنه playlist.py با batch_size<=0 کرش می‌کند.
387
+ # English: This check is not only for --set; the config file may have
388
+ # been hand-edited, so it must be re-validated here as well,
389
+ # otherwise playlist.py crashes with batch_size<=0.
390
+ if batch_size < 1:
391
+ console.print(f"[red]batch_size must be 1 or greater, got {batch_size}.[/red]")
392
+ sys.exit(1)
393
+
394
+ # فارسی: اولویت پروکسی: (۱) --proxy صریح همیشه برنده است، (۲) اگر
395
+ # استخر پروکسی تنظیم شده، پروکسی سالم را خودکار پیدا می‌کند،
396
+ # (۳) در غیر این صورت پروکسی ثابتِ کانفیگ (اگر باشد).
397
+ # English: Proxy priority: (1) an explicit --proxy always wins, (2) if
398
+ # a proxy pool is configured, automatically find a working
399
+ # proxy, (3) otherwise the static config proxy (if any).
400
+ proxy_pool_source = args.proxy_pool or cfg.get("proxy_pool_source")
401
+ if args.proxy:
402
+ proxy = args.proxy
403
+ elif proxy_pool_source:
404
+ proxy = _resolve_proxy_via_pool(proxy_pool_source, args.proxy_pool_refresh)
405
+ else:
406
+ proxy = cfg.get("proxy")
407
+
408
+ allow_client_fallback = not bool(args.player_client)
409
+
410
+ if args.player_client:
411
+ cfg["player_client"] = args.player_client
412
+ bypass = args.bypass or cfg.get("bypass", False)
413
+ extractor_args = build_extractor_args(cfg, bypass)
414
+
415
+ if auto_cookies_path:
416
+ cookies_path, cleanup_cookies = auto_cookies_path, auto_cleanup
417
+ else:
418
+ try:
419
+ cookies_path, cleanup_cookies = resolve_cookies_path(cfg)
420
+ except CookieError as e:
421
+ _exit_on_cookie_error(e)
422
+
423
+ if cookies_path is None:
424
+ print_cookie_export_guide()
425
+
426
+ if state.DEBUG:
427
+ from rich.panel import Panel
428
+
429
+ console.print(
430
+ Panel(
431
+ f"Python: {platform.python_version()}\n"
432
+ f"odl: {c.ODL_VERSION}\n"
433
+ f"yt-dlp: {getattr(yt_dlp.version, '__version__', 'unknown')}\n"
434
+ f"OS: {platform.system()} {platform.release()}\n"
435
+ f"Proxy: {proxy or 'none'}\n"
436
+ f"Player client override: {cfg.get('player_client') or 'auto'}\n"
437
+ f"Cookies: {'yes' if cookies_path else 'no'}\n"
438
+ f"Quality: {quality}p\n"
439
+ f"Extractor args: {extractor_args}",
440
+ title="DEBUG INFO",
441
+ border_style="magenta",
442
+ )
443
+ )
444
+
445
+ try:
446
+ if args.playlist:
447
+ download_playlist(
448
+ args.url,
449
+ cookies_path,
450
+ quality,
451
+ args.sub_en,
452
+ args.sub_fa,
453
+ out_dir,
454
+ args.audio_only,
455
+ batch_size,
456
+ proxy,
457
+ extractor_args,
458
+ allow_client_fallback,
459
+ args.no_estimate,
460
+ )
461
+ else:
462
+ download_single(
463
+ args.url,
464
+ cookies_path,
465
+ quality,
466
+ args.sub_en,
467
+ args.sub_fa,
468
+ out_dir,
469
+ args.audio_only,
470
+ proxy,
471
+ extractor_args,
472
+ allow_client_fallback,
473
+ )
474
+ except KeyboardInterrupt:
475
+ console.print("\n[yellow]Stopped. Run the same command again to resume automatically.[/yellow]")
476
+ sys.exit(130)
477
+ finally:
478
+ cleanup_cookies()
odl/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """
2
+ فارسی: خواندن/نوشتن فایل کانفیگ کاربر، و توابع کمکی برای دستورات
3
+ «--config» (نمایش) و «--set» (تغییر تنظیمات).
4
+ English: Reading/writing the user config file, and helper functions for
5
+ the "--config" (display) and "--set" (change settings) commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from . import constants as c
13
+ from .logging_setup import log_warning
14
+
15
+ _SETTABLE_KEYS = {
16
+ "quality": int,
17
+ "download_dir": str,
18
+ "batch_size": int,
19
+ "proxy": str,
20
+ "proxy_pool_source": str,
21
+ "player_client": str,
22
+ "bypass": bool,
23
+ }
24
+
25
+
26
+ def load_config() -> dict:
27
+ """
28
+ فارسی: تنظیمات کاربر رو از فایل کانفیگ می‌خونه و با مقادیر پیش‌فرض ترکیب می‌کنه.
29
+ English: Load user settings from the config file, merged with sane defaults.
30
+ """
31
+ defaults = {
32
+ "cookies": str(c.COOKIES_DEFAULT),
33
+ "quality": c.DEFAULT_QUALITY,
34
+ "download_dir": str(c.DOWNLOAD_DIR_DEFAULT),
35
+ "batch_size": c.BATCH_SIZE,
36
+ "proxy": None,
37
+ "proxy_pool_source": None,
38
+ "player_client": None,
39
+ "bypass": False,
40
+ }
41
+ if c.CONFIG_FILE.exists():
42
+ try:
43
+ with open(c.CONFIG_FILE, encoding="utf-8") as f:
44
+ user_cfg = json.load(f)
45
+ defaults.update(user_cfg)
46
+ except Exception as e:
47
+ # فارسی: به‌جای بی‌صدا نادیده گرفتن، حداقل در فایل لاگ ثبت می‌شود
48
+ # که کانفیگ کاربر خراب بوده و به مقادیر پیش‌فرض برگشته‌ایم.
49
+ # English: Instead of silently ignoring it, at least log that the
50
+ # user's config was corrupted and defaults were used.
51
+ log_warning(f"Config file at {c.CONFIG_FILE} could not be read ({e}); using defaults.")
52
+ return defaults
53
+
54
+
55
+ def save_default_config() -> None:
56
+ """
57
+ فارسی: اگه فایل کانفیگ وجود نداشت، یک نسخه‌ی پیش‌فرض می‌سازه.
58
+ English: Create a default config file if one doesn't already exist.
59
+ """
60
+ c.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
61
+ if not c.CONFIG_FILE.exists():
62
+ with open(c.CONFIG_FILE, "w", encoding="utf-8") as f:
63
+ json.dump(
64
+ {
65
+ "cookies": str(c.COOKIES_DEFAULT),
66
+ "quality": c.DEFAULT_QUALITY,
67
+ "download_dir": str(c.DOWNLOAD_DIR_DEFAULT),
68
+ "batch_size": c.BATCH_SIZE,
69
+ "proxy": None,
70
+ "proxy_pool_source": None,
71
+ "player_client": None,
72
+ "bypass": False,
73
+ },
74
+ f,
75
+ ensure_ascii=False,
76
+ indent=2,
77
+ )
78
+
79
+
80
+ def write_config(cfg: dict) -> None:
81
+ """
82
+ فارسی: دیکشنری تنظیمات را کامل روی فایل کانفیگ می‌نویسد.
83
+ English: Write a full settings dict to the config file.
84
+ """
85
+ c.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
86
+ with open(c.CONFIG_FILE, "w", encoding="utf-8") as f:
87
+ json.dump(cfg, f, ensure_ascii=False, indent=2)
88
+
89
+
90
+ def parse_set_argument(arg: str) -> tuple[str, object]:
91
+ """
92
+ فارسی: یک آرگومان به‌شکل «کلید=مقدار» (مثل quality=720) را پارس کرده
93
+ و مقدار را به نوع درست تبدیل می‌کند.
94
+ English: Parse a "key=value" argument (e.g. quality=720) and convert
95
+ the value to the correct type.
96
+
97
+ Returns:
98
+ (key, converted_value)
99
+ """
100
+ if "=" not in arg:
101
+ raise ValueError(f"Invalid format '{arg}', expected key=value")
102
+
103
+ key, raw_value = arg.split("=", 1)
104
+ key = key.strip()
105
+ raw_value = raw_value.strip()
106
+
107
+ if key not in _SETTABLE_KEYS:
108
+ allowed = ", ".join(sorted(_SETTABLE_KEYS))
109
+ raise ValueError(f"Unknown setting '{key}'. Allowed: {allowed}")
110
+
111
+ value_type = _SETTABLE_KEYS[key]
112
+
113
+ if raw_value.lower() in ("none", "null", ""):
114
+ return key, None
115
+
116
+ if value_type is bool:
117
+ if raw_value.lower() in ("true", "1", "yes"):
118
+ return key, True
119
+ if raw_value.lower() in ("false", "0", "no"):
120
+ return key, False
121
+ raise ValueError(f"'{key}' expects true/false, got '{raw_value}'")
122
+
123
+ if value_type is int:
124
+ try:
125
+ int_value = int(raw_value)
126
+ except ValueError as err:
127
+ raise ValueError(f"'{key}' expects an integer, got '{raw_value}'") from err
128
+ if key == "batch_size" and int_value < 1:
129
+ raise ValueError(f"'batch_size' must be 1 or greater, got {int_value}")
130
+ if key == "quality" and int_value not in c.ALLOWED_QUALITIES:
131
+ allowed = ", ".join(map(str, c.ALLOWED_QUALITIES))
132
+ raise ValueError(f"'quality' must be one of: {allowed}")
133
+ return key, int_value
134
+
135
+ return key, raw_value