pkg-anime-1 1.0.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: pkg-anime-1
3
+ Version: 1.0.0
4
+ License: MIT
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: aiohttp>=3.9.0
10
+ Requires-Dist: beautifulsoup4>=4.12.0
11
+ Requires-Dist: cloudscraper>=1.2.60
12
+ Requires-Dist: requests>=2.28.0
13
+ Requires-Dist: rich>=13.0.0
14
+ Requires-Dist: urllib3>=2.0.0
@@ -0,0 +1,126 @@
1
+ # beeg-dl
2
+
3
+ Python package for browsing and downloading videos from beeg.com.
4
+ Supports parallel chunked MP4 download and HLS multi-quality (240p / 360p / 480p / 720p / 1080p).
5
+ Works on Linux, macOS, Windows, and Termux (Android/ARMv7/ARM64).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install beeg-dl
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ beeg # interactive menu (browse tags, download)
17
+ beeg "teen" # browse #teen tag directly
18
+ beeg --recent # browse amateur (most active tag)
19
+
20
+ beegdl <url> # download by beeg.com URL
21
+ beegdl 967221069944483 # download by numeric file ID
22
+ beegdl <url> -q 720 # download at 720p
23
+ beegdl <url> -o ~/Downloads # custom output folder
24
+ beegdl <url> -t 8 # 8 download threads
25
+ ```
26
+
27
+ ## Python API
28
+
29
+ ```python
30
+ from beeg_dl.scraper import BeegScraper
31
+ from beeg_dl.downloader import download
32
+ from pathlib import Path
33
+
34
+ scraper = BeegScraper()
35
+
36
+ # Browse videos by tag (paginated)
37
+ videos = scraper.tag_videos("teen", limit=32, offset=0)
38
+ for v in videos:
39
+ print(v.file_id, v.title, v.duration_str, v.best_quality)
40
+
41
+ # Get full details + fresh CDN tokens for a video
42
+ video = scraper.video_detail(967221069944483)
43
+ print(video.title, video.qualities)
44
+
45
+ # Resolve a beeg.com URL
46
+ video = scraper.from_url("https://beeg.com/967221069944483")
47
+
48
+ # Get download URL (HLS or direct MP4)
49
+ url, mode = scraper.quality_url(video, quality=1080)
50
+
51
+ # Download (handles both direct MP4 and HLS automatically)
52
+ def progress(done, total, bps):
53
+ pct = done / total * 100 if total else 0
54
+ print(f"\r{pct:.1f}% {bps/1024/1024:.1f} MB/s", end="", flush=True)
55
+
56
+ path = download(url, Path("video.mp4"), quality=1080, threads=16, progress=progress)
57
+ print(f"\nSaved to {path}")
58
+ ```
59
+
60
+ ## How It Works
61
+
62
+ ### API Architecture
63
+
64
+ beeg.com is a single-page app (Vue.js) backed by a REST API at
65
+ `https://store.externulls.com` — no authentication required for reading.
66
+
67
+ | Endpoint | Description |
68
+ |---|---|
69
+ | `GET /tag/videos/{slug}?limit=N&offset=N` | Paginated video list by tag slug |
70
+ | `GET /facts/file/{file_id}` | Full video details with fresh CDN tokens |
71
+
72
+ ### Video CDN
73
+
74
+ Videos are served from `https://video.beeg.com/{token}`.
75
+ Tokens are **time-limited** (~60 s) and include an HMAC key, expiry timestamp,
76
+ and data segment. Always call `scraper.video_detail(file_id)` immediately before
77
+ downloading to get a fresh token.
78
+
79
+ Token types in the API response:
80
+ - `file.resources.fl_cdn_240` → direct MP4 at 240p
81
+ - `file.fallback` → direct MP4 at 480p
82
+ - `file.hls_resources.fl_cdn_multi` → HLS master playlist (all qualities)
83
+
84
+ ### Quality Selection
85
+
86
+ The HLS master playlist (`fl_cdn_multi`) contains streams for all available
87
+ qualities: **240p, 360p, 480p, 720p, 1080p**.
88
+ `beegdl` parses the master, selects the closest stream to your requested quality,
89
+ downloads all `.ts` segments in parallel, and muxes them with `ffmpeg` (or raw
90
+ binary concatenation if ffmpeg is not available).
91
+
92
+ ### Thumbnails
93
+
94
+ `https://thumbs.externulls.com/videos/{file_id}/{thumb_idx}.webp`
95
+
96
+ ### Search
97
+
98
+ beeg.com uses a WebSocket API (`wss://search.externulls.com`) for search — there
99
+ is no HTTP search endpoint. Use tag-based browsing as the primary discovery method.
100
+
101
+ ## Configuration
102
+
103
+ Config file: `~/.config/beeg-dl/config.json`
104
+
105
+ ```json
106
+ {
107
+ "download_dir": "~/Downloads",
108
+ "preferred_quality": 1080,
109
+ "threads": 16,
110
+ "page_size": 32,
111
+ "cache_ttl": 3600
112
+ }
113
+ ```
114
+
115
+ ## Popular Tags
116
+
117
+ `teen`, `amateur`, `milf`, `lesbian`, `anal`, `blowjob`, `hardcore`, `asian`,
118
+ `ebony`, `latina`, `blonde`, `brunette`, `bbw`, `creampie`, `cumshot`, `pov`,
119
+ `solo`, `masturbation`, `squirt`, `bdsm`, `interracial`, `big-tits`, `college`,
120
+ `step-mom`, `step-sister`, `casting`, `massage`, and more.
121
+
122
+ ## Requirements
123
+
124
+ - Python ≥ 3.10
125
+ - `cloudscraper`, `rich`, `urllib3` (auto-installed)
126
+ - `ffmpeg` (optional but recommended for HLS quality selection and muxing)
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ """Allow `python -m beeg_dl` to run the CLI."""
2
+ from .cli import beeg_main
3
+ beeg_main()
@@ -0,0 +1,57 @@
1
+ """
2
+ cache.py
3
+ ========
4
+ Simple disk cache for API responses (avoids re-fetching the same video data).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import hashlib
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional, Any
13
+
14
+ CACHE_DIR = Path.home() / ".cache" / "beeg-dl"
15
+ DEFAULT_TTL = 3600 # 1 hour
16
+
17
+
18
+ def _key_to_path(key: str) -> Path:
19
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
20
+ return CACHE_DIR / f"{h}.json"
21
+
22
+
23
+ def get(key: str, ttl: int = DEFAULT_TTL) -> Optional[Any]:
24
+ path = _key_to_path(key)
25
+ if not path.exists():
26
+ return None
27
+ try:
28
+ raw = json.loads(path.read_text())
29
+ if time.time() - raw.get("ts", 0) > ttl:
30
+ path.unlink(missing_ok=True)
31
+ return None
32
+ return raw["data"]
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ def set(key: str, data: Any) -> None:
38
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
39
+ path = _key_to_path(key)
40
+ try:
41
+ path.write_text(json.dumps({"ts": time.time(), "data": data}))
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ def clear() -> int:
47
+ """Delete all cached files. Returns count deleted."""
48
+ if not CACHE_DIR.exists():
49
+ return 0
50
+ count = 0
51
+ for f in CACHE_DIR.glob("*.json"):
52
+ try:
53
+ f.unlink()
54
+ count += 1
55
+ except Exception:
56
+ pass
57
+ return count
@@ -0,0 +1,444 @@
1
+ """
2
+ cli.py
3
+ ======
4
+ Rich interactive CLI for beeg-dl.
5
+
6
+ Entry points:
7
+ beeg — interactive browse + download menu
8
+ beeg "teen" — browse tag directly
9
+ beegdl <url> — download any beeg.com video URL or file_id
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import List, Optional
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.progress import (
21
+ BarColumn, DownloadColumn, Progress, SpinnerColumn,
22
+ TextColumn, TimeRemainingColumn, TransferSpeedColumn,
23
+ )
24
+ from rich.prompt import Confirm, Prompt
25
+ from rich.table import Table
26
+ from rich import print as rprint
27
+
28
+ from . import config as _cfg
29
+ from .models import VideoEntry
30
+ from .scraper import BeegScraper, POPULAR_TAGS
31
+ from .downloader import download
32
+
33
+ console = Console()
34
+
35
+
36
+ # ── helpers ──────────────────────────────────────────────────────────────────
37
+
38
+ def _make_progress() -> Progress:
39
+ return Progress(
40
+ SpinnerColumn(),
41
+ TextColumn("[bold blue]{task.description}"),
42
+ BarColumn(bar_width=40),
43
+ "[progress.percentage]{task.percentage:>3.0f}%",
44
+ DownloadColumn(),
45
+ TransferSpeedColumn(),
46
+ TimeRemainingColumn(),
47
+ console=console,
48
+ )
49
+
50
+
51
+ def _fmt_dur(seconds: int) -> str:
52
+ m, s = divmod(seconds, 60)
53
+ h, m = divmod(m, 60)
54
+ if h:
55
+ return f"{h}h {m:02d}m"
56
+ return f"{m}m {s:02d}s"
57
+
58
+
59
+ def _fmt_size(n: int) -> str:
60
+ for unit in ("B", "KB", "MB", "GB"):
61
+ if n < 1024:
62
+ return f"{n:.1f} {unit}"
63
+ n /= 1024
64
+ return f"{n:.1f} TB"
65
+
66
+
67
+ def _show_video_list(videos: List[VideoEntry], tag: str, page: int) -> None:
68
+ table = Table(title=f"[bold]#{tag}[/bold] — page {page}", show_lines=False,
69
+ border_style="dim", expand=True)
70
+ table.add_column("#", style="dim", width=4)
71
+ table.add_column("Title", no_wrap=False)
72
+ table.add_column("Duration", justify="right", width=8)
73
+ table.add_column("Resolution", justify="right", width=11)
74
+ table.add_column("Quality", justify="right", width=8)
75
+
76
+ for i, v in enumerate(videos, 1):
77
+ best_q = f"{v.best_quality}p" if v.best_quality else "?"
78
+ res = f"{v.width}×{v.height}" if v.width else "?"
79
+ dur = _fmt_dur(v.duration) if v.duration else "?"
80
+ table.add_row(str(i), v.title, dur, res, best_q)
81
+
82
+ console.print(table)
83
+
84
+
85
+ def _pick_quality(video: VideoEntry) -> int:
86
+ cfg = _cfg.load()
87
+ preferred = cfg.get("preferred_quality", 1080)
88
+ available = sorted({q.quality for q in video.qualities}, reverse=True)
89
+ if not available:
90
+ return preferred
91
+ # Return highest available up to preferred
92
+ for q in available:
93
+ if q <= preferred:
94
+ return q
95
+ return available[-1]
96
+
97
+
98
+ def _do_download(video: VideoEntry, scraper: BeegScraper, cfg: dict) -> None:
99
+ # Always re-fetch detail so tokens are fresh
100
+ console.print(f"[dim]Refreshing CDN tokens for video {video.file_id}…[/dim]")
101
+ fresh = scraper.video_detail(video.file_id)
102
+ if not fresh:
103
+ console.print("[red]Could not fetch video details.[/red]")
104
+ return
105
+
106
+ quality = _pick_quality(fresh)
107
+ url, mode = scraper.quality_url(fresh, quality)
108
+ if not url:
109
+ console.print("[red]No downloadable URL found.[/red]")
110
+ return
111
+
112
+ # Show confirm
113
+ sizes = {q.quality: q.size for q in fresh.qualities}
114
+ size = sizes.get(quality, 0)
115
+ console.print(Panel(
116
+ f"[bold]{fresh.title}[/bold]\n"
117
+ f"Quality: [green]{quality}p[/green] "
118
+ f"Mode: [cyan]{mode.upper()}[/cyan] "
119
+ f"Estimated size: [yellow]{_fmt_size(size) if size else 'unknown'}[/yellow]\n"
120
+ f"Duration: {_fmt_dur(fresh.duration)}\n"
121
+ f"URL: [dim]{url[:80]}…[/dim]",
122
+ title="Ready to download"
123
+ ))
124
+
125
+ safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in fresh.title)[:80]
126
+ ext = "mp4"
127
+ filename = f"{fresh.file_id}_{safe_title}.{ext}"
128
+ out_dir = Path(cfg.get("download_dir", str(Path.home() / "Downloads")))
129
+ out_path = out_dir / filename
130
+
131
+ if not Confirm.ask(f"Download to [bold]{out_path}[/bold]?", default=True):
132
+ return
133
+
134
+ out_dir.mkdir(parents=True, exist_ok=True)
135
+ threads = cfg.get("threads", 16)
136
+
137
+ task_id = None
138
+ progress_obj = _make_progress()
139
+ done_ref = [0]
140
+ total_ref = [0]
141
+ speed_ref = [0.0]
142
+ start_time = time.time()
143
+
144
+ def _on_progress(done: int, total: int, bps: float) -> None:
145
+ done_ref[0] = done
146
+ total_ref[0] = total
147
+ speed_ref[0] = bps
148
+ if task_id is not None:
149
+ progress_obj.update(task_id, completed=done,
150
+ total=total if total else None)
151
+
152
+ import threading
153
+
154
+ def _run() -> None:
155
+ nonlocal task_id
156
+ with progress_obj:
157
+ task_id = progress_obj.add_task(
158
+ f"Downloading {quality}p…",
159
+ total=size if size else None,
160
+ )
161
+ try:
162
+ download(url, out_path, quality=quality,
163
+ threads=threads, progress=_on_progress, mode=mode)
164
+ except Exception as e:
165
+ console.print(f"[red]Download failed: {e}[/red]")
166
+
167
+ t = threading.Thread(target=_run)
168
+ t.start()
169
+ t.join()
170
+
171
+ if out_path.exists():
172
+ elapsed = time.time() - start_time
173
+ size_dl = out_path.stat().st_size
174
+ console.print(
175
+ f"[green]✓ Saved:[/green] {out_path}\n"
176
+ f" Size: {_fmt_size(size_dl)} Time: {elapsed:.0f}s"
177
+ )
178
+ else:
179
+ console.print("[red]Output file not found — download may have failed.[/red]")
180
+
181
+
182
+ # ── browse flow ───────────────────────────────────────────────────────────────
183
+
184
+ def _browse_tag(tag: str, scraper: BeegScraper, cfg: dict) -> None:
185
+ page = 1
186
+ per_page = cfg.get("page_size", 32)
187
+
188
+ while True:
189
+ offset = (page - 1) * per_page
190
+ with console.status(f"[dim]Loading #{tag} page {page}…[/dim]"):
191
+ videos = scraper.tag_videos(tag, limit=per_page, offset=offset)
192
+
193
+ if not videos:
194
+ console.print(f"[yellow]No videos found for #{tag} (page {page}).[/yellow]")
195
+ if page > 1:
196
+ page -= 1
197
+ continue
198
+ return
199
+
200
+ _show_video_list(videos, tag, page)
201
+
202
+ choices = [str(i) for i in range(1, len(videos) + 1)]
203
+ console.print(
204
+ "[dim]Enter a number to download | [bold]n[/bold] = next page "
205
+ "| [bold]p[/bold] = prev page | [bold]q[/bold] = back[/dim]"
206
+ )
207
+ choice = Prompt.ask("Choice", default="q").strip().lower()
208
+
209
+ if choice == "q":
210
+ return
211
+ elif choice == "n":
212
+ page += 1
213
+ elif choice == "p" and page > 1:
214
+ page -= 1
215
+ elif choice in choices:
216
+ video = videos[int(choice) - 1]
217
+ _do_download(video, scraper, cfg)
218
+ else:
219
+ console.print("[red]Invalid choice.[/red]")
220
+
221
+
222
+ # ── main menus ────────────────────────────────────────────────────────────────
223
+
224
+ def _tag_menu(scraper: BeegScraper, cfg: dict) -> None:
225
+ console.print(Panel(
226
+ "\n".join(
227
+ " ".join(f"[cyan]{t}[/cyan]" for t in POPULAR_TAGS[i:i+5])
228
+ for i in range(0, len(POPULAR_TAGS), 5)
229
+ ),
230
+ title="Popular Tags",
231
+ ))
232
+ tag = Prompt.ask("Enter a tag slug", default="teen").strip().lower()
233
+ if tag:
234
+ _browse_tag(tag, scraper, cfg)
235
+
236
+
237
+ def _url_menu(scraper: BeegScraper, cfg: dict) -> None:
238
+ url = Prompt.ask("Paste beeg.com URL or file_id").strip()
239
+ if not url:
240
+ return
241
+ with console.status("[dim]Resolving…[/dim]"):
242
+ video = scraper.from_url(url)
243
+ if not video:
244
+ console.print("[red]Could not resolve video.[/red]")
245
+ return
246
+ console.print(Panel(
247
+ f"[bold]{video.title}[/bold]\n"
248
+ f"Duration: {_fmt_dur(video.duration)} "
249
+ f"Resolution: {video.resolution_str} "
250
+ f"Best quality: {video.best_quality}p",
251
+ title=f"Video {video.file_id}"
252
+ ))
253
+ _do_download(video, scraper, cfg)
254
+
255
+
256
+ def interactive_menu() -> None:
257
+ cfg = _cfg.load()
258
+ scraper = BeegScraper(cache_ttl=cfg.get("cache_ttl", 3600))
259
+
260
+ console.print(Panel(
261
+ "[bold cyan]beeg-dl[/bold cyan] — beeg.com downloader\n"
262
+ "[dim]Browse by tag or paste a URL to download.[/dim]",
263
+ border_style="cyan"
264
+ ))
265
+
266
+ while True:
267
+ console.print(
268
+ "\n [bold]1[/bold] Browse by tag\n"
269
+ " [bold]2[/bold] Download from URL / file ID\n"
270
+ " [bold]3[/bold] Clear cache\n"
271
+ " [bold]q[/bold] Quit\n"
272
+ )
273
+ choice = Prompt.ask("Menu", default="q").strip().lower()
274
+
275
+ if choice == "1":
276
+ _tag_menu(scraper, cfg)
277
+ elif choice == "2":
278
+ _url_menu(scraper, cfg)
279
+ elif choice == "3":
280
+ from . import cache as _c
281
+ n = _c.clear()
282
+ console.print(f"[green]Cleared {n} cached items.[/green]")
283
+ elif choice in ("q", "quit", "exit"):
284
+ console.print("[dim]Bye.[/dim]")
285
+ return
286
+ else:
287
+ console.print("[red]Invalid choice.[/red]")
288
+
289
+
290
+ # ── beeg command ──────────────────────────────────────────────────────────────
291
+
292
+ def beeg_main() -> None:
293
+ """
294
+ Entry point for the `beeg` command.
295
+
296
+ Usage
297
+ -----
298
+ beeg → interactive browse/download menu
299
+ beeg <tag> → browse that tag directly (e.g. beeg milf)
300
+ beeg <url> → resolve & download a beeg.com URL
301
+ beeg <file_id> → resolve & download by numeric file ID
302
+ beeg -r / --recent → browse the #amateur tag
303
+ beeg -V / --version → print version
304
+ beeg -h / --help → show this help
305
+ """
306
+ import argparse
307
+ from . import __version__
308
+
309
+ parser = argparse.ArgumentParser(
310
+ prog="beeg",
311
+ description=(
312
+ "beeg.com interactive downloader.\n\n"
313
+ "With no arguments: opens the interactive browse/download menu.\n"
314
+ "With a tag slug (e.g. 'teen', 'milf'): browse that tag.\n"
315
+ "With a URL or numeric file ID: download that video directly."
316
+ ),
317
+ formatter_class=argparse.RawDescriptionHelpFormatter,
318
+ )
319
+ parser.add_argument(
320
+ "target",
321
+ nargs="?",
322
+ default=None,
323
+ metavar="TAG|URL|ID",
324
+ help="Tag slug, beeg.com URL, or numeric file ID (omit for interactive menu).",
325
+ )
326
+ parser.add_argument(
327
+ "-r", "--recent",
328
+ action="store_true",
329
+ help="Browse the #amateur tag (most active).",
330
+ )
331
+ parser.add_argument(
332
+ "-q", "--quality",
333
+ type=int,
334
+ default=None,
335
+ choices=[240, 360, 480, 720, 1080],
336
+ help="Preferred quality when downloading directly (default: 1080).",
337
+ )
338
+ parser.add_argument(
339
+ "-o", "--output",
340
+ default=None,
341
+ metavar="DIR",
342
+ help="Output directory (default: from config, usually ~/Downloads).",
343
+ )
344
+ parser.add_argument(
345
+ "-V", "--version",
346
+ action="version",
347
+ version=f"beeg-dl {__version__}",
348
+ )
349
+
350
+ args = parser.parse_args()
351
+
352
+ cfg = _cfg.load()
353
+ if args.quality:
354
+ cfg["preferred_quality"] = args.quality
355
+ if args.output:
356
+ cfg["download_dir"] = args.output
357
+
358
+ scraper = BeegScraper(cache_ttl=cfg.get("cache_ttl", 3600))
359
+
360
+ # ── routing ───────────────────────────────────────────────────────────────
361
+ if args.recent:
362
+ _browse_tag("amateur", scraper, cfg)
363
+ return
364
+
365
+ if args.target is None:
366
+ # No arguments → full interactive menu
367
+ interactive_menu()
368
+ return
369
+
370
+ target = args.target.strip()
371
+
372
+ # URL or numeric file ID → resolve and download
373
+ if target.startswith("http") or target.isdigit():
374
+ with console.status("[dim]Resolving…[/dim]"):
375
+ video = scraper.from_url(target)
376
+ if not video:
377
+ console.print("[red]Could not resolve video. Check the URL or file ID.[/red]")
378
+ sys.exit(1)
379
+ console.print(Panel(
380
+ f"[bold]{video.title}[/bold]\n"
381
+ f"Duration: {_fmt_dur(video.duration)} "
382
+ f"Resolution: {video.resolution_str} "
383
+ f"Best quality: {video.best_quality}p",
384
+ title=f"Video {video.file_id}"
385
+ ))
386
+ _do_download(video, scraper, cfg)
387
+ return
388
+
389
+ # Otherwise treat as a tag slug
390
+ _browse_tag(target, scraper, cfg)
391
+
392
+
393
+ # ── beegdl command ────────────────────────────────────────────────────────────
394
+
395
+ def beegdl_main() -> None:
396
+ """
397
+ Entry point for the `beegdl` command.
398
+
399
+ Usage:
400
+ beegdl <url_or_file_id> download video
401
+ beegdl <url_or_file_id> -q 720 download at 720p
402
+ beegdl <url_or_file_id> -o /path custom output directory
403
+ """
404
+ import argparse
405
+
406
+ parser = argparse.ArgumentParser(
407
+ prog="beegdl",
408
+ description="Download a beeg.com video by URL or file ID.",
409
+ )
410
+ parser.add_argument("target", help="beeg.com video URL or numeric file ID")
411
+ parser.add_argument("-q", "--quality", type=int, default=None,
412
+ choices=[240, 360, 480, 720, 1080],
413
+ help="Preferred quality (default: from config)")
414
+ parser.add_argument("-o", "--output", default=None,
415
+ help="Output directory (default: from config)")
416
+ parser.add_argument("-t", "--threads", type=int, default=None,
417
+ help="Download threads (default: from config)")
418
+ args = parser.parse_args()
419
+
420
+ cfg = _cfg.load()
421
+ scraper = BeegScraper(cache_ttl=cfg.get("cache_ttl", 3600))
422
+
423
+ if args.quality:
424
+ cfg["preferred_quality"] = args.quality
425
+ if args.output:
426
+ cfg["download_dir"] = args.output
427
+ if args.threads:
428
+ cfg["threads"] = args.threads
429
+
430
+ with console.status("[dim]Resolving video…[/dim]"):
431
+ video = scraper.from_url(args.target)
432
+
433
+ if not video:
434
+ console.print("[red]Could not resolve video. Check the URL or file ID.[/red]")
435
+ sys.exit(1)
436
+
437
+ console.print(Panel(
438
+ f"[bold]{video.title}[/bold]\n"
439
+ f"Duration: {_fmt_dur(video.duration)} "
440
+ f"Resolution: {video.resolution_str}",
441
+ title=f"Video {video.file_id}"
442
+ ))
443
+
444
+ _do_download(video, scraper, cfg)
@@ -0,0 +1,43 @@
1
+ """
2
+ config.py
3
+ =========
4
+ User-configurable settings for beeg-dl.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import platform
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ CONFIG_PATH = Path.home() / ".config" / "beeg-dl" / "config.json"
15
+
16
+ _IS_TERMUX = (
17
+ platform.machine() in ("aarch64", "armv7l")
18
+ or platform.system() == "Android"
19
+ or "com.termux" in sys.executable
20
+ )
21
+
22
+ DEFAULTS = {
23
+ "download_dir": str(Path.home() / ("Downloads" if not _IS_TERMUX else "storage/downloads")),
24
+ "preferred_quality": 1080, # 240, 360, 480, 720, 1080 — falls back if unavailable
25
+ "threads": 4 if _IS_TERMUX else 16,
26
+ "page_size": 32,
27
+ "cache_ttl": 3600,
28
+ }
29
+
30
+
31
+ def load() -> dict:
32
+ cfg = dict(DEFAULTS)
33
+ if CONFIG_PATH.exists():
34
+ try:
35
+ cfg.update(json.loads(CONFIG_PATH.read_text()))
36
+ except Exception:
37
+ pass
38
+ return cfg
39
+
40
+
41
+ def save(cfg: dict) -> None:
42
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
43
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))