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 +8 -0
- odl/__main__.py +9 -0
- odl/cli.py +478 -0
- odl/config.py +135 -0
- odl/constants.py +73 -0
- odl/cookies.py +646 -0
- odl/diagnostics.py +171 -0
- odl/downloader.py +358 -0
- odl/errors.py +63 -0
- odl/logging_setup.py +90 -0
- odl/models.py +94 -0
- odl/playlist.py +309 -0
- odl/playlist_state.py +79 -0
- odl/proxy_pool.py +260 -0
- odl/state.py +21 -0
- open_downloader_cli-2.2.1.dist-info/METADATA +480 -0
- open_downloader_cli-2.2.1.dist-info/RECORD +21 -0
- open_downloader_cli-2.2.1.dist-info/WHEEL +5 -0
- open_downloader_cli-2.2.1.dist-info/entry_points.txt +2 -0
- open_downloader_cli-2.2.1.dist-info/licenses/LICENSE +21 -0
- open_downloader_cli-2.2.1.dist-info/top_level.txt +1 -0
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
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
|