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.
- pkg_anime_1-1.0.0/PKG-INFO +14 -0
- pkg_anime_1-1.0.0/README.md +126 -0
- pkg_anime_1-1.0.0/beeg_dl/__init__.py +1 -0
- pkg_anime_1-1.0.0/beeg_dl/__main__.py +3 -0
- pkg_anime_1-1.0.0/beeg_dl/cache.py +57 -0
- pkg_anime_1-1.0.0/beeg_dl/cli.py +444 -0
- pkg_anime_1-1.0.0/beeg_dl/config.py +43 -0
- pkg_anime_1-1.0.0/beeg_dl/downloader.py +404 -0
- pkg_anime_1-1.0.0/beeg_dl/http_client.py +189 -0
- pkg_anime_1-1.0.0/beeg_dl/models.py +72 -0
- pkg_anime_1-1.0.0/beeg_dl/scraper.py +313 -0
- pkg_anime_1-1.0.0/pkg_anime_1.egg-info/PKG-INFO +14 -0
- pkg_anime_1-1.0.0/pkg_anime_1.egg-info/SOURCES.txt +17 -0
- pkg_anime_1-1.0.0/pkg_anime_1.egg-info/dependency_links.txt +1 -0
- pkg_anime_1-1.0.0/pkg_anime_1.egg-info/entry_points.txt +3 -0
- pkg_anime_1-1.0.0/pkg_anime_1.egg-info/requires.txt +6 -0
- pkg_anime_1-1.0.0/pkg_anime_1.egg-info/top_level.txt +1 -0
- pkg_anime_1-1.0.0/pyproject.toml +31 -0
- pkg_anime_1-1.0.0/setup.cfg +4 -0
|
@@ -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,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))
|