socdl 0.1.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.
- socdl/__init__.py +14 -0
- socdl/__main__.py +5 -0
- socdl/cli.py +443 -0
- socdl/config.py +111 -0
- socdl/engines/__init__.py +13 -0
- socdl/engines/base.py +117 -0
- socdl/engines/ffmpeg.py +71 -0
- socdl/engines/gallerydl.py +81 -0
- socdl/engines/instaloader_engine.py +115 -0
- socdl/engines/ytdlp.py +135 -0
- socdl/history.py +99 -0
- socdl/i18n/__init__.py +39 -0
- socdl/i18n/en.py +66 -0
- socdl/i18n/id.py +66 -0
- socdl/platforms.py +104 -0
- socdl/router.py +65 -0
- socdl/ui.py +106 -0
- socdl/updater.py +46 -0
- socdl/watcher.py +57 -0
- socdl-0.1.1.dist-info/METADATA +404 -0
- socdl-0.1.1.dist-info/RECORD +24 -0
- socdl-0.1.1.dist-info/WHEEL +4 -0
- socdl-0.1.1.dist-info/entry_points.txt +2 -0
- socdl-0.1.1.dist-info/licenses/LICENSE +21 -0
socdl/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""socdl - Social media downloader CLI.
|
|
2
|
+
|
|
3
|
+
A friendly, fast command-line tool to download photos & videos from
|
|
4
|
+
Instagram, TikTok, YouTube, Twitter/X, Reddit, Facebook and more.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.1"
|
|
8
|
+
__author__ = "Erzam Bayu"
|
|
9
|
+
__license__ = "MIT"
|
|
10
|
+
__url__ = "https://github.com/Erzambayu/socdl"
|
|
11
|
+
|
|
12
|
+
from .platforms import detect_platform # re-export for convenience
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__", "detect_platform"]
|
socdl/__main__.py
ADDED
socdl/cli.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""socdl command-line entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
13
|
+
|
|
14
|
+
from . import __version__, history, updater
|
|
15
|
+
from . import config as cfgmod
|
|
16
|
+
from .i18n import set_lang, t
|
|
17
|
+
from .platforms import detect_platform
|
|
18
|
+
from .router import download as route_download
|
|
19
|
+
from .ui import (
|
|
20
|
+
console,
|
|
21
|
+
err,
|
|
22
|
+
hr,
|
|
23
|
+
kv,
|
|
24
|
+
muted,
|
|
25
|
+
notice,
|
|
26
|
+
ok,
|
|
27
|
+
print_banner,
|
|
28
|
+
print_help_table,
|
|
29
|
+
print_history,
|
|
30
|
+
prompt_url,
|
|
31
|
+
warn,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
URL_RE = re.compile(r"https?://\S+")
|
|
35
|
+
|
|
36
|
+
SUBCOMMANDS = {"watch", "history", "config", "update", "detect"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Windows UTF-8 sanity
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
def _fix_windows_encoding() -> None:
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
try:
|
|
45
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
46
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
os.system("")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
def _load_cfg_and_lang(lang_override: Optional[str] = None) -> cfgmod.Config:
|
|
54
|
+
cfg = cfgmod.load()
|
|
55
|
+
set_lang(lang_override or cfg.language or "en")
|
|
56
|
+
return cfg
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_urls(text: str) -> list[str]:
|
|
60
|
+
return [m.group(0).rstrip(",;)]}") for m in URL_RE.finditer(text or "")]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_batch_file(path: str) -> list[str]:
|
|
64
|
+
urls: list[str] = []
|
|
65
|
+
with open(path, encoding="utf-8") as f:
|
|
66
|
+
for line in f:
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line or line.startswith("#"):
|
|
69
|
+
continue
|
|
70
|
+
urls.extend(_extract_urls(line) or ([line] if line.startswith("http") else []))
|
|
71
|
+
return urls
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _maybe_notify_update() -> None:
|
|
75
|
+
info = updater.is_outdated()
|
|
76
|
+
if info:
|
|
77
|
+
current, latest = info
|
|
78
|
+
muted(t("update_available", current=current, latest=latest))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
def _handle_url(url: str, cfg: cfgmod.Config) -> bool:
|
|
83
|
+
url = url.strip().rstrip(",;")
|
|
84
|
+
det = detect_platform(url)
|
|
85
|
+
|
|
86
|
+
hr()
|
|
87
|
+
kv(t("platform"), det.label)
|
|
88
|
+
kv(t("kind"), det.kind)
|
|
89
|
+
kv(t("url"), url)
|
|
90
|
+
|
|
91
|
+
if det.platform == "unknown":
|
|
92
|
+
warn(t("unknown_platform"))
|
|
93
|
+
|
|
94
|
+
engine_used = ["-"]
|
|
95
|
+
with Progress(
|
|
96
|
+
SpinnerColumn(style="brand"),
|
|
97
|
+
TextColumn("[accent]{task.description}"),
|
|
98
|
+
BarColumn(bar_width=None),
|
|
99
|
+
TimeElapsedColumn(),
|
|
100
|
+
console=console,
|
|
101
|
+
transient=True,
|
|
102
|
+
) as progress:
|
|
103
|
+
task = progress.add_task(f"{t('downloading')}...", start=True, total=None)
|
|
104
|
+
|
|
105
|
+
def on_engine(name: str) -> None:
|
|
106
|
+
engine_used[0] = name
|
|
107
|
+
progress.update(task, description=f"{t('downloading')} · [muted]{name}[/muted]")
|
|
108
|
+
|
|
109
|
+
result = route_download(url, det, cfg, progress_cb=on_engine)
|
|
110
|
+
|
|
111
|
+
if result.ok:
|
|
112
|
+
ok(t("success", path=str(result.output_dir)))
|
|
113
|
+
else:
|
|
114
|
+
err(t("fail_generic", code=result.exit_code) +
|
|
115
|
+
(f" {result.message}" if result.message else ""))
|
|
116
|
+
if det.platform == "instagram" and not cfg.instagram_login:
|
|
117
|
+
muted(t("ig_login_needed"))
|
|
118
|
+
|
|
119
|
+
if cfg.log_history:
|
|
120
|
+
history.record(
|
|
121
|
+
platform=det.platform,
|
|
122
|
+
kind=det.kind,
|
|
123
|
+
url=url,
|
|
124
|
+
engine=result.engine,
|
|
125
|
+
status="success" if result.ok else "failed",
|
|
126
|
+
output_dir=str(result.output_dir),
|
|
127
|
+
message=result.message,
|
|
128
|
+
)
|
|
129
|
+
return result.ok
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Interactive mode
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
def _interactive(cfg: cfgmod.Config) -> None:
|
|
136
|
+
print_banner()
|
|
137
|
+
kv(t("folder"), str(cfg.resolved_output_dir()))
|
|
138
|
+
muted(t("prompt_hint"))
|
|
139
|
+
console.print()
|
|
140
|
+
|
|
141
|
+
while True:
|
|
142
|
+
try:
|
|
143
|
+
raw = prompt_url()
|
|
144
|
+
except (EOFError, KeyboardInterrupt):
|
|
145
|
+
console.print()
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
if not raw:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if raw.startswith("/"):
|
|
152
|
+
parts = raw[1:].split(maxsplit=1)
|
|
153
|
+
cmd = parts[0].lower() if parts else ""
|
|
154
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
155
|
+
if not _handle_command(cmd, arg, cfg):
|
|
156
|
+
break
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if raw.lower() in ("q", "quit", "exit", ":q"):
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
urls = _extract_urls(raw)
|
|
163
|
+
if not urls:
|
|
164
|
+
warn(t("no_url"))
|
|
165
|
+
continue
|
|
166
|
+
for u in urls:
|
|
167
|
+
_handle_url(u, cfg)
|
|
168
|
+
|
|
169
|
+
ok(t("goodbye"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _handle_command(cmd: str, arg: str, cfg: cfgmod.Config) -> bool:
|
|
173
|
+
if cmd in ("quit", "exit", "q"):
|
|
174
|
+
return False
|
|
175
|
+
if cmd == "help":
|
|
176
|
+
print_help_table()
|
|
177
|
+
return True
|
|
178
|
+
if cmd == "config":
|
|
179
|
+
_open_path(cfgmod.config_path())
|
|
180
|
+
notice(str(cfgmod.config_path()))
|
|
181
|
+
return True
|
|
182
|
+
if cmd == "history":
|
|
183
|
+
print_history(history.recent(limit=20))
|
|
184
|
+
return True
|
|
185
|
+
if cmd == "open":
|
|
186
|
+
_open_path(cfg.resolved_output_dir())
|
|
187
|
+
return True
|
|
188
|
+
if cmd == "paste":
|
|
189
|
+
try:
|
|
190
|
+
import pyperclip
|
|
191
|
+
text = pyperclip.paste()
|
|
192
|
+
except Exception:
|
|
193
|
+
text = ""
|
|
194
|
+
urls = _extract_urls(text)
|
|
195
|
+
if not urls:
|
|
196
|
+
warn(t("watch_no_clip"))
|
|
197
|
+
else:
|
|
198
|
+
for u in urls:
|
|
199
|
+
_handle_url(u, cfg)
|
|
200
|
+
return True
|
|
201
|
+
if cmd == "watch":
|
|
202
|
+
_run_watcher(cfg)
|
|
203
|
+
return True
|
|
204
|
+
if cmd == "lang":
|
|
205
|
+
lang = (arg or "").strip().lower()
|
|
206
|
+
if lang in ("en", "id"):
|
|
207
|
+
set_lang(lang)
|
|
208
|
+
cfg.language = lang
|
|
209
|
+
cfgmod.save(cfg)
|
|
210
|
+
ok(t("cfg_saved"))
|
|
211
|
+
else:
|
|
212
|
+
warn("Usage: /lang en | /lang id")
|
|
213
|
+
return True
|
|
214
|
+
warn(f"Unknown command: /{cmd}")
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _open_path(path: Path) -> None:
|
|
219
|
+
if path.suffix:
|
|
220
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
else:
|
|
222
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
try:
|
|
224
|
+
if sys.platform == "win32":
|
|
225
|
+
os.startfile(str(path)) # noqa
|
|
226
|
+
elif sys.platform == "darwin":
|
|
227
|
+
subprocess.run(["open", str(path)], check=False)
|
|
228
|
+
else:
|
|
229
|
+
subprocess.run(["xdg-open", str(path)], check=False)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_watcher(cfg: cfgmod.Config) -> None:
|
|
235
|
+
from .watcher import watch
|
|
236
|
+
ok(t("watch_started"))
|
|
237
|
+
muted(t("watch_hint"))
|
|
238
|
+
try:
|
|
239
|
+
watch(on_link=lambda u: (notice(t("watch_detected"), "accent"), _handle_url(u, cfg)))
|
|
240
|
+
except KeyboardInterrupt:
|
|
241
|
+
console.print()
|
|
242
|
+
warn(t("watch_stopped"))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Click CLI
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
@click.group(
|
|
249
|
+
invoke_without_command=True,
|
|
250
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
251
|
+
)
|
|
252
|
+
@click.version_option(__version__, "-V", "--version", prog_name="socdl")
|
|
253
|
+
@click.option("--lang", type=click.Choice(["en", "id"]), default=None, help="Force UI language.")
|
|
254
|
+
@click.option("-o", "--output", type=click.Path(file_okay=False), default=None,
|
|
255
|
+
help="Override output directory.")
|
|
256
|
+
@click.option("-q", "--quality",
|
|
257
|
+
type=click.Choice(["best", "1080p", "720p", "480p", "360p", "audio"]),
|
|
258
|
+
default=None, help="Override quality.")
|
|
259
|
+
@click.option("-f", "--file", "batch_file", type=click.Path(exists=True, dir_okay=False),
|
|
260
|
+
default=None, help="Download URLs listed in a text file (one per line).")
|
|
261
|
+
@click.pass_context
|
|
262
|
+
def cli(ctx: click.Context, lang: Optional[str], output: Optional[str],
|
|
263
|
+
quality: Optional[str], batch_file: Optional[str]) -> None:
|
|
264
|
+
"""socdl - download photos & videos from social media, fast."""
|
|
265
|
+
_fix_windows_encoding()
|
|
266
|
+
cfg = _load_cfg_and_lang(lang)
|
|
267
|
+
if output:
|
|
268
|
+
cfg.output_dir = output
|
|
269
|
+
if quality:
|
|
270
|
+
cfg.quality = quality
|
|
271
|
+
|
|
272
|
+
ctx.obj = cfg
|
|
273
|
+
|
|
274
|
+
if ctx.invoked_subcommand is None and batch_file:
|
|
275
|
+
print_banner()
|
|
276
|
+
for u in _read_batch_file(batch_file):
|
|
277
|
+
_handle_url(u, cfg)
|
|
278
|
+
if cfg.check_updates:
|
|
279
|
+
_maybe_notify_update()
|
|
280
|
+
elif ctx.invoked_subcommand is None:
|
|
281
|
+
_interactive(cfg)
|
|
282
|
+
if cfg.check_updates:
|
|
283
|
+
_maybe_notify_update()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- subcommands ----
|
|
287
|
+
|
|
288
|
+
@cli.command("watch")
|
|
289
|
+
@click.pass_obj
|
|
290
|
+
def cmd_watch(cfg: cfgmod.Config) -> None:
|
|
291
|
+
"""Start clipboard watcher - auto-download any supported link you copy."""
|
|
292
|
+
print_banner()
|
|
293
|
+
_run_watcher(cfg)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@cli.command("history")
|
|
297
|
+
@click.option("-n", "--limit", default=20, show_default=True, help="How many entries to show.")
|
|
298
|
+
@click.option("-p", "--platform", default=None, help="Filter by platform.")
|
|
299
|
+
@click.option("--clear", "do_clear", is_flag=True, help="Delete all history.")
|
|
300
|
+
@click.pass_obj
|
|
301
|
+
def cmd_history(cfg: cfgmod.Config, limit: int, platform: Optional[str], do_clear: bool) -> None:
|
|
302
|
+
"""Show or clear download history."""
|
|
303
|
+
if do_clear:
|
|
304
|
+
n = history.clear()
|
|
305
|
+
ok(f"Deleted {n} entries.")
|
|
306
|
+
return
|
|
307
|
+
print_history(history.recent(limit=limit, platform=platform))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@cli.command("config")
|
|
311
|
+
@click.option("--show", is_flag=True, help="Print current config to stdout.")
|
|
312
|
+
@click.option("--reset", is_flag=True, help="Overwrite with defaults.")
|
|
313
|
+
@click.option("--set", "kv_pairs", multiple=True, metavar="KEY=VALUE",
|
|
314
|
+
help="Set a config value, e.g. --set quality=1080p")
|
|
315
|
+
@click.pass_obj
|
|
316
|
+
def cmd_config(cfg: cfgmod.Config, show: bool, reset: bool, kv_pairs: tuple[str, ...]) -> None:
|
|
317
|
+
"""Show or edit the config file."""
|
|
318
|
+
if reset:
|
|
319
|
+
cfg = cfgmod.Config()
|
|
320
|
+
cfgmod.save(cfg)
|
|
321
|
+
ok(t("cfg_saved"))
|
|
322
|
+
|
|
323
|
+
if kv_pairs:
|
|
324
|
+
for pair in kv_pairs:
|
|
325
|
+
if "=" not in pair:
|
|
326
|
+
warn(f"Ignored: {pair}")
|
|
327
|
+
continue
|
|
328
|
+
k, v = pair.split("=", 1)
|
|
329
|
+
k = k.strip()
|
|
330
|
+
v = v.strip()
|
|
331
|
+
if not hasattr(cfg, k):
|
|
332
|
+
warn(f"Unknown key: {k}")
|
|
333
|
+
continue
|
|
334
|
+
current = getattr(cfg, k)
|
|
335
|
+
if isinstance(current, bool):
|
|
336
|
+
setattr(cfg, k, v.lower() in ("1", "true", "yes", "on"))
|
|
337
|
+
elif isinstance(current, int):
|
|
338
|
+
try:
|
|
339
|
+
setattr(cfg, k, int(v))
|
|
340
|
+
except ValueError:
|
|
341
|
+
warn(f"Not an int: {v}")
|
|
342
|
+
else:
|
|
343
|
+
setattr(cfg, k, v)
|
|
344
|
+
cfgmod.save(cfg)
|
|
345
|
+
ok(t("cfg_saved"))
|
|
346
|
+
|
|
347
|
+
if show or (not reset and not kv_pairs):
|
|
348
|
+
console.print(f"[muted]{cfgmod.config_path()}[/muted]")
|
|
349
|
+
for k, v in cfg.to_dict().items():
|
|
350
|
+
kv(k, str(v))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@cli.command("update")
|
|
354
|
+
def cmd_update() -> None:
|
|
355
|
+
"""Upgrade underlying downloaders (yt-dlp, instaloader, gallery-dl)."""
|
|
356
|
+
pkgs = ["yt-dlp", "instaloader", "gallery-dl"]
|
|
357
|
+
notice(f"Upgrading: {', '.join(pkgs)}", "accent")
|
|
358
|
+
code = subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", *pkgs])
|
|
359
|
+
if code == 0:
|
|
360
|
+
ok("Downloaders upgraded.")
|
|
361
|
+
else:
|
|
362
|
+
err("Upgrade failed.")
|
|
363
|
+
|
|
364
|
+
info = updater.is_outdated()
|
|
365
|
+
if info:
|
|
366
|
+
current, latest = info
|
|
367
|
+
notice(t("update_available", current=current, latest=latest), "warn")
|
|
368
|
+
else:
|
|
369
|
+
ok(t("update_uptodate", current=__version__))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@cli.command("detect")
|
|
373
|
+
@click.argument("url")
|
|
374
|
+
def cmd_detect(url: str) -> None:
|
|
375
|
+
"""Print detected platform/kind for a URL (debug helper)."""
|
|
376
|
+
det = detect_platform(url)
|
|
377
|
+
kv("platform", det.platform)
|
|
378
|
+
kv("kind", det.kind)
|
|
379
|
+
kv("target", str(det.target))
|
|
380
|
+
kv("label", det.label)
|
|
381
|
+
kv("folder", det.folder_name)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
def _looks_like_url(s: str) -> bool:
|
|
386
|
+
return s.startswith(("http://", "https://"))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _grab_flag_value(argv: list[str], flags: tuple[str, ...]) -> Optional[str]:
|
|
390
|
+
for i, tok in enumerate(argv):
|
|
391
|
+
if tok in flags and i + 1 < len(argv):
|
|
392
|
+
return argv[i + 1]
|
|
393
|
+
for f in flags:
|
|
394
|
+
if tok.startswith(f + "="):
|
|
395
|
+
return tok.split("=", 1)[1]
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _quick_download_mode(argv: list[str]) -> bool:
|
|
400
|
+
"""Handle bare `socdl <url>...` before Click sees the URL as a subcommand."""
|
|
401
|
+
for tok in argv:
|
|
402
|
+
if tok in SUBCOMMANDS or tok in ("-h", "--help", "-V", "--version"):
|
|
403
|
+
return False
|
|
404
|
+
urls = [a for a in argv if _looks_like_url(a)]
|
|
405
|
+
if not urls:
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
lang = _grab_flag_value(argv, ("--lang",))
|
|
409
|
+
output = _grab_flag_value(argv, ("-o", "--output"))
|
|
410
|
+
quality = _grab_flag_value(argv, ("-q", "--quality"))
|
|
411
|
+
batch_file = _grab_flag_value(argv, ("-f", "--file"))
|
|
412
|
+
|
|
413
|
+
cfg = _load_cfg_and_lang(lang)
|
|
414
|
+
if output:
|
|
415
|
+
cfg.output_dir = output
|
|
416
|
+
if quality:
|
|
417
|
+
cfg.quality = quality
|
|
418
|
+
|
|
419
|
+
print_banner()
|
|
420
|
+
all_urls = list(urls)
|
|
421
|
+
if batch_file:
|
|
422
|
+
all_urls += _read_batch_file(batch_file)
|
|
423
|
+
for u in all_urls:
|
|
424
|
+
_handle_url(u, cfg)
|
|
425
|
+
if cfg.check_updates:
|
|
426
|
+
_maybe_notify_update()
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def main() -> None:
|
|
431
|
+
_fix_windows_encoding()
|
|
432
|
+
argv = sys.argv[1:]
|
|
433
|
+
try:
|
|
434
|
+
if argv and _quick_download_mode(argv):
|
|
435
|
+
return
|
|
436
|
+
cli(standalone_mode=True)
|
|
437
|
+
except KeyboardInterrupt:
|
|
438
|
+
console.print()
|
|
439
|
+
sys.exit(130)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
if __name__ == "__main__":
|
|
443
|
+
main()
|
socdl/config.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Config loader/writer for socdl."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
from platformdirs import user_config_dir, user_downloads_dir
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
APP_NAME = "socdl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_dir() -> Path:
|
|
22
|
+
return Path(user_config_dir(APP_NAME, appauthor=False))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_path() -> Path:
|
|
26
|
+
return config_dir() / "config.toml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def data_dir() -> Path:
|
|
30
|
+
"""Where history.db, sessions, etc. live."""
|
|
31
|
+
d = config_dir() / "data"
|
|
32
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_output_dir() -> Path:
|
|
37
|
+
"""Default: ~/Downloads/socdl-downloads (avoid clashing with a repo folder named 'socdl')."""
|
|
38
|
+
return Path(user_downloads_dir()) / "socdl-downloads"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Config:
|
|
43
|
+
# I/O
|
|
44
|
+
output_dir: str = "" # empty -> default_output_dir()
|
|
45
|
+
subfolder_per_platform: bool = True
|
|
46
|
+
subfolder_per_uploader: bool = True
|
|
47
|
+
|
|
48
|
+
# Behavior
|
|
49
|
+
language: str = "en" # "en" | "id"
|
|
50
|
+
quality: str = "best" # best|1080p|720p|480p|360p|audio
|
|
51
|
+
embed_metadata: bool = True
|
|
52
|
+
embed_thumbnail: bool = True
|
|
53
|
+
concurrent_fragments: int = 4
|
|
54
|
+
restrict_filenames: bool = True
|
|
55
|
+
|
|
56
|
+
# Features
|
|
57
|
+
check_updates: bool = True
|
|
58
|
+
log_history: bool = True
|
|
59
|
+
|
|
60
|
+
# Instagram
|
|
61
|
+
instagram_login: str = ""
|
|
62
|
+
|
|
63
|
+
# Cookies (for private / age-restricted content)
|
|
64
|
+
cookies_from_browser: str = "" # chrome|firefox|edge|brave|opera|vivaldi
|
|
65
|
+
|
|
66
|
+
def resolved_output_dir(self) -> Path:
|
|
67
|
+
return Path(self.output_dir).expanduser() if self.output_dir else default_output_dir()
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return asdict(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load() -> Config:
|
|
74
|
+
path = config_path()
|
|
75
|
+
if not path.exists():
|
|
76
|
+
cfg = Config()
|
|
77
|
+
save(cfg)
|
|
78
|
+
return cfg
|
|
79
|
+
try:
|
|
80
|
+
with path.open("rb") as f:
|
|
81
|
+
data = tomllib.load(f)
|
|
82
|
+
return _from_dict(data)
|
|
83
|
+
except Exception:
|
|
84
|
+
return Config()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def save(cfg: Config) -> Path:
|
|
88
|
+
path = config_path()
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
with path.open("wb") as f:
|
|
91
|
+
tomli_w.dump(_to_serializable(cfg.to_dict()), f)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _from_dict(data: dict[str, Any]) -> Config:
|
|
96
|
+
known = set(Config.__dataclass_fields__)
|
|
97
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
98
|
+
try:
|
|
99
|
+
return Config(**filtered)
|
|
100
|
+
except TypeError:
|
|
101
|
+
return Config()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _to_serializable(d: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
out = {}
|
|
106
|
+
for k, v in d.items():
|
|
107
|
+
out[k] = "" if v is None else v
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["Config", "load", "save", "config_path", "config_dir", "data_dir", "default_output_dir"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Engine wrappers around yt-dlp, instaloader, and gallery-dl."""
|
|
2
|
+
from .base import EngineResult, run_subprocess
|
|
3
|
+
from .gallerydl import GalleryDLEngine
|
|
4
|
+
from .instaloader_engine import InstaloaderEngine
|
|
5
|
+
from .ytdlp import YtDlpEngine
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EngineResult",
|
|
9
|
+
"run_subprocess",
|
|
10
|
+
"YtDlpEngine",
|
|
11
|
+
"InstaloaderEngine",
|
|
12
|
+
"GalleryDLEngine",
|
|
13
|
+
]
|
socdl/engines/base.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Shared engine helpers.
|
|
2
|
+
|
|
3
|
+
Engines can run in two modes:
|
|
4
|
+
|
|
5
|
+
* **subprocess** — when an external `yt-dlp` / `gallery-dl` / `instaloader`
|
|
6
|
+
executable is on PATH (or importable as a module via a real Python
|
|
7
|
+
interpreter). This gives users the exact upstream behavior and easy
|
|
8
|
+
upgrades.
|
|
9
|
+
|
|
10
|
+
* **in-process** — when socdl is frozen with PyInstaller (`sys.frozen`),
|
|
11
|
+
or when no external executable is available but the library is
|
|
12
|
+
importable. We call the library API directly instead of re-spawning
|
|
13
|
+
`sys.executable` (which would point at socdl itself in a frozen build).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_frozen() -> bool:
|
|
27
|
+
"""True when running from a PyInstaller / py2exe style bundle."""
|
|
28
|
+
return getattr(sys, "frozen", False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EngineResult:
|
|
33
|
+
ok: bool
|
|
34
|
+
exit_code: int
|
|
35
|
+
engine: str
|
|
36
|
+
output_dir: Path
|
|
37
|
+
message: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
MODULE_ALIAS = {
|
|
41
|
+
"yt-dlp": "yt_dlp",
|
|
42
|
+
"gallery-dl": "gallery_dl",
|
|
43
|
+
"instaloader": "instaloader",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_executable(name: str) -> Optional[list[str]]:
|
|
48
|
+
"""Return `[exe]` if a standalone executable is on PATH, else None."""
|
|
49
|
+
exe = shutil.which(name)
|
|
50
|
+
if exe:
|
|
51
|
+
return [exe]
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def module_available(module: str) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
__import__(module)
|
|
58
|
+
return True
|
|
59
|
+
except ImportError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_command(name: str) -> Optional[list[str]]:
|
|
64
|
+
"""Return a runnable command list, or None if not available.
|
|
65
|
+
|
|
66
|
+
Never falls back to `[sys.executable, "-m", ...]` when frozen, because
|
|
67
|
+
`sys.executable` would be the socdl binary itself (infinite recursion).
|
|
68
|
+
"""
|
|
69
|
+
exe = find_executable(name)
|
|
70
|
+
if exe:
|
|
71
|
+
return exe
|
|
72
|
+
if is_frozen():
|
|
73
|
+
return None
|
|
74
|
+
mod = MODULE_ALIAS.get(name)
|
|
75
|
+
if mod and module_available(mod):
|
|
76
|
+
return [sys.executable, "-m", mod]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_subprocess(cmd: Sequence[str], *, quiet: bool = False) -> int:
|
|
81
|
+
"""Run subprocess, streaming stdout/stderr to terminal. Returns exit code."""
|
|
82
|
+
try:
|
|
83
|
+
proc = subprocess.run(
|
|
84
|
+
list(cmd),
|
|
85
|
+
check=False,
|
|
86
|
+
stdout=None if not quiet else subprocess.DEVNULL,
|
|
87
|
+
stderr=None if not quiet else subprocess.DEVNULL,
|
|
88
|
+
)
|
|
89
|
+
return proc.returncode
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return 127
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
return 130
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BaseEngine:
|
|
97
|
+
name: str = "base"
|
|
98
|
+
module: str = ""
|
|
99
|
+
|
|
100
|
+
# -- availability -------------------------------------------------------
|
|
101
|
+
def is_available(self) -> bool:
|
|
102
|
+
return self.find_command() is not None or self.module_available()
|
|
103
|
+
|
|
104
|
+
def find_command(self) -> Optional[list[str]]:
|
|
105
|
+
return find_command(self.name)
|
|
106
|
+
|
|
107
|
+
def module_available(self) -> bool:
|
|
108
|
+
return bool(self.module) and module_available(self.module)
|
|
109
|
+
|
|
110
|
+
def should_run_in_process(self) -> bool:
|
|
111
|
+
"""Prefer the in-process API when we can't spawn a real subprocess."""
|
|
112
|
+
if self.find_command() is not None:
|
|
113
|
+
return False
|
|
114
|
+
return self.module_available()
|
|
115
|
+
|
|
116
|
+
def base_cmd(self) -> Optional[list[str]]:
|
|
117
|
+
return self.find_command()
|