farouk04 commited on
Commit
f58e53b
Β·
1 Parent(s): 9b9f599

Swap yt-dlp for SaverAPI

Browse files
Files changed (4) hide show
  1. README.md +16 -3
  2. detector.py +155 -27
  3. requirements.txt +1 -2
  4. saverapi_client.py +241 -0
README.md CHANGED
@@ -20,7 +20,7 @@ per-window fake probabilities alongside a final prediction.
20
 
21
  ## How it works
22
 
23
- 1. Video is downloaded via `yt-dlp` (CPU, no GPU quota used)
24
  2. Frames are decoded and normalized with `decord` (CPU)
25
  3. V-JEPA 2.1 encodes clips into patch token sequences (GPU)
26
  4. AttentiveClassifier cross-attends over all tokens and outputs a fake probability (GPU)
@@ -35,14 +35,27 @@ Set these in **Settings β†’ Repository secrets**:
35
  | `HF_TOKEN` | Your HuggingFace access token |
36
  | `ENCODER_REPO` | HF repo ID where `vjepa2_1_vitg_384.pt` is stored |
37
  | `PROBE_REPO` | HF repo ID where `attentive_probe_optimized.pt` is stored |
 
38
 
39
  `ENCODER_REPO` and `PROBE_REPO` can point to the same private repo.
40
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  ## Files in this Space
42
 
43
  | File | Purpose |
44
  |---|---|
45
- | `app.py` | Main Gradio app + ZeroGPU integration |
46
  | `inference_core.py` | Model architecture + video windowing (unchanged from training) |
47
- | `requirements.txt` | Python deps (`decord`, `yt-dlp`) |
48
  | `packages.txt` | System dep (`ffmpeg`) |
 
20
 
21
  ## How it works
22
 
23
+ 1. Video is downloaded via the **SaverAPI** HTTP API (CPU, no GPU quota used). A pool of up to 10 API keys is rotated automatically with sticky failover.
24
  2. Frames are decoded and normalized with `decord` (CPU)
25
  3. V-JEPA 2.1 encodes clips into patch token sequences (GPU)
26
  4. AttentiveClassifier cross-attends over all tokens and outputs a fake probability (GPU)
 
35
  | `HF_TOKEN` | Your HuggingFace access token |
36
  | `ENCODER_REPO` | HF repo ID where `vjepa2_1_vitg_384.pt` is stored |
37
  | `PROBE_REPO` | HF repo ID where `attentive_probe_optimized.pt` is stored |
38
+ | `SAVER_API_KEY_1` … `SAVER_API_KEY_10` | SaverAPI keys (one secret per key; unset ones are ignored) |
39
 
40
  `ENCODER_REPO` and `PROBE_REPO` can point to the same private repo.
41
 
42
+ ### Optional SaverAPI tuning secrets
43
+
44
+ | Secret | Default | Purpose |
45
+ |---|---|---|
46
+ | `SAVER_API_KEY_COOLDOWN_SEC` | `3600` | How long a failing key is skipped before being re-tested |
47
+ | `SAVER_API_LOOKUP_TIMEOUT_SEC` | `15` | Timeout for SaverAPI's JSON lookup |
48
+ | `SAVER_API_DOWNLOAD_TIMEOUT_SEC` | `60` | Timeout for the streamed byte pull |
49
+ | `SAVER_API_MAX_RETRIES_PER_REQUEST` | `3` | How many keys to try before failing a single user request |
50
+ | `SAVER_API_MAX_FILESIZE_MB` | `100` | Hard cap on downloaded video size |
51
+ | `SAVER_API_STARTUP_PROBE_URL` | _(unset)_ | A known-good video URL; if set, all keys are probed at Space boot |
52
+ | `SKIP_STARTUP_PROBE` | _(unset)_ | Set to `1` to skip the boot probe entirely |
53
+
54
  ## Files in this Space
55
 
56
  | File | Purpose |
57
  |---|---|
58
+ | `detector.py` | Main Gradio app + ZeroGPU integration + SaverAPI client + key rotator |
59
  | `inference_core.py` | Model architecture + video windowing (unchanged from training) |
60
+ | `requirements.txt` | Python deps (`decord`, `requests`) |
61
  | `packages.txt` | System dep (`ffmpeg`) |
detector.py CHANGED
@@ -3,7 +3,6 @@ V-JEPA 2.1 ViT-Giant + AttentiveClassifier probe
3
  """
4
  import glob
5
  import os
6
- import subprocess
7
  import tempfile
8
  import warnings
9
  import time
@@ -22,6 +21,7 @@ import spaces
22
  import torch
23
  from huggingface_hub import hf_hub_download, HfApi
24
  from inference_core import AttentiveClassifier, get_video_windows
 
25
 
26
  # ─────────────────────────────────────────────
27
  # CONFIG & SECRETS
@@ -36,6 +36,14 @@ DATASET_REPO_ID = os.environ.get("DATASET_REPO_ID", "")
36
  TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
37
  TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
38
 
 
 
 
 
 
 
 
 
39
  # ─────────────────────────────────────────────
40
  # MODEL LOADING (CPU)
41
  # ─────────────────────────────────────────────
@@ -94,33 +102,148 @@ def extract_platform(url: str) -> str:
94
  else:
95
  return "Direct Link / Other"
96
 
97
- def download_video(url: str) -> tuple[bool, str]:
98
- tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
99
- tmp.close()
100
- cmd = [
101
- "yt-dlp",
102
- "-f", "best[height<=720]/best",
103
- "--max-filesize", "100M",
104
- "--match-filter", "duration <= 120",
105
- "--no-playlist",
106
- "--no-warnings",
107
- "--force-overwrites",
108
- "-o", tmp.name,
109
- url,
110
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  try:
112
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
113
- candidates = sorted(glob.glob(tmp.name + "*") + [tmp.name])
114
- actual_file = next((f for f in candidates if os.path.exists(f) and os.path.getsize(f) > 0), None)
115
- if actual_file is None:
116
- if "does not pass filter" in result.stdout or "does not pass filter" in result.stderr:
117
- return False, "Video length exceeds the maximum 2-minute limit."
118
- return False, f"yt-dlp wrote no output. stderr: {result.stderr[:400]}"
119
- return True, actual_file
120
- except subprocess.TimeoutExpired:
121
- return False, "Download timed out (90s limit)."
122
- except FileNotFoundError:
123
- return False, "yt-dlp not installed."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  # ─────────────────────────────────────────────
126
  # GPU INFERENCE
@@ -391,4 +514,9 @@ with gr.Blocks(title="Deepfake Detector") as demo:
391
  outputs=[label_out, conf_out, breakdown_out, session_state],
392
  )
393
 
 
 
 
 
 
394
  demo.launch()
 
3
  """
4
  import glob
5
  import os
 
6
  import tempfile
7
  import warnings
8
  import time
 
21
  import torch
22
  from huggingface_hub import hf_hub_download, HfApi
23
  from inference_core import AttentiveClassifier, get_video_windows
24
+ from saverapi_client import saverapi_fetch
25
 
26
  # ─────────────────────────────────────────────
27
  # CONFIG & SECRETS
 
36
  TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
37
  TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
38
 
39
+ # ─────────────────────────────────────────────
40
+ # SAVERAPI ROTATOR CONFIG (the client itself lives in saverapi_client.py)
41
+ # ─────────────────────────────────────────────
42
+ SAVER_API_KEY_COOLDOWN_SEC = int(os.environ.get("SAVER_API_KEY_COOLDOWN_SEC", "3600"))
43
+ SAVER_API_MAX_RETRIES_PER_REQUEST = int(os.environ.get("SAVER_API_MAX_RETRIES_PER_REQUEST", "3"))
44
+ SAVER_API_MAX_FILESIZE_MB = int(os.environ.get("SAVER_API_MAX_FILESIZE_MB", "100"))
45
+ SAVER_API_STARTUP_PROBE_URL = os.environ.get("SAVER_API_STARTUP_PROBE_URL", "").strip()
46
+
47
  # ─────────────────────────────────────────────
48
  # MODEL LOADING (CPU)
49
  # ─────────────────────────────────────────────
 
102
  else:
103
  return "Direct Link / Other"
104
 
105
+
106
+ # ─────────────────────────────────────────────
107
+ # SAVERAPI KEY ROTATOR
108
+ # ─────────────────────────────────────────────
109
+ class KeyRotator:
110
+ """Sticky-failover pool of SaverAPI keys.
111
+
112
+ Reads keys from SAVER_API_KEY_1..SAVER_API_KEY_10. On any error, the
113
+ failing key is marked dead for `cooldown_sec` seconds; the cursor
114
+ advances past it so the next call uses a different live key. When the
115
+ cooldown expires, the key re-enters the pool automatically. State is
116
+ in-memory only; resets on Space restart.
117
+ """
118
+ KEY_ENV_NAMES = [f"SAVER_API_KEY_{i}" for i in range(1, 11)]
119
+
120
+ def __init__(self, cooldown_sec: int):
121
+ self.keys = [os.environ.get(name, "").strip() for name in self.KEY_ENV_NAMES]
122
+ self.keys = [k for k in self.keys if k] # drop empties
123
+ self.cooldown_sec = cooldown_sec
124
+ self._dead_until: dict = {} # key -> unix-ts when it recovers
125
+ self._lock = threading.Lock()
126
+ self._cursor = 0 # sticky index: prefers this slot
127
+
128
+ def get_key(self) -> str | None:
129
+ with self._lock:
130
+ if not self.keys:
131
+ return None
132
+ n = len(self.keys)
133
+ for offset in range(n):
134
+ idx = (self._cursor + offset) % n
135
+ key = self.keys[idx]
136
+ if self._dead_until.get(key, 0) <= time.time():
137
+ return key
138
+ return None # all keys currently in cooldown
139
+
140
+ def mark_dead(self, key: str) -> None:
141
+ with self._lock:
142
+ self._dead_until[key] = time.time() + self.cooldown_sec
143
+ try:
144
+ self._cursor = (self.keys.index(key) + 1) % len(self.keys)
145
+ except (ValueError, ZeroDivisionError):
146
+ pass
147
+
148
+ def status(self) -> dict:
149
+ with self._lock:
150
+ now = time.time()
151
+ return {
152
+ "configured": len(self.keys),
153
+ "live": sum(1 for k in self.keys if self._dead_until.get(k, 0) <= now),
154
+ "dead": [self.mask(k) for k, ts in self._dead_until.items() if ts > now],
155
+ "cursor_index": self._cursor,
156
+ }
157
+
158
+ @staticmethod
159
+ def mask(key: str) -> str:
160
+ if len(key) <= 10:
161
+ return "***"
162
+ return f"{key[:6]}***{key[-4:]}"
163
+
164
+
165
+ key_rotator = KeyRotator(cooldown_sec=SAVER_API_KEY_COOLDOWN_SEC)
166
+ print(f"[SaverAPI] Rotator initialised with {len(key_rotator.keys)} key(s).")
167
+
168
+
169
+ def _saverapi_fetch_one(url: str, key: str) -> tuple[bool, str]:
170
+ """Wrapper around the client that normalises its return to (ok, file_or_err).
171
+
172
+ On success: (True, file_path)
173
+ On failure: (False, "key-level:<reason>") or (False, "<human message>")
174
+ The caller uses the "key-level:" prefix to decide whether to mark the key dead.
175
+ """
176
  try:
177
+ return saverapi_fetch(url, key, max_filesize_mb=SAVER_API_MAX_FILESIZE_MB)
178
+ except Exception as e:
179
+ return False, f"key-level:exception: {type(e).__name__}: {e}"
180
+
181
+
182
+ def download_video(url: str) -> tuple[bool, str]:
183
+ """Tries up to N keys in sequence. Marks a key dead only on key-level errors;
184
+ URL-level errors (bad URL, unsupported platform, etc.) are returned to the
185
+ user without consuming a key from the pool.
186
+ """
187
+ if not url.lower().startswith(("http://", "https://")):
188
+ return False, "Unsupported URL scheme. Use http:// or https://"
189
+
190
+ last_err = ""
191
+ for attempt in range(SAVER_API_MAX_RETRIES_PER_REQUEST):
192
+ key = key_rotator.get_key()
193
+ if key is None:
194
+ return False, "SaverAPI key pool exhausted; try again later."
195
+
196
+ ok, payload = _saverapi_fetch_one(url, key)
197
+ if ok:
198
+ return True, payload
199
+
200
+ last_err = payload
201
+ if last_err.startswith("key-level:"):
202
+ key_rotator.mark_dead(key)
203
+ print(f"[SaverAPI] key {KeyRotator.mask(key)} failed (attempt {attempt + 1}/"
204
+ f"{SAVER_API_MAX_RETRIES_PER_REQUEST}): {last_err[:200]}")
205
+ continue
206
+ # URL-level error β€” do NOT burn a key, return to the user.
207
+ return False, last_err
208
+
209
+ return False, f"SaverAPI: all {SAVER_API_MAX_RETRIES_PER_REQUEST} retries failed. Last: {last_err[:200]}"
210
+
211
+
212
+ def _startup_probe():
213
+ """Best-effort probe of each configured key at Space boot.
214
+
215
+ Disabled by default (set SAVER_API_STARTUP_PROBE_URL to a known-good
216
+ video URL to enable, or set SKIP_STARTUP_PROBE=1 to silence the
217
+ 'no probe URL' log). Marks dead keys so the first real user request
218
+ doesn't hit a dead key.
219
+ """
220
+ if os.environ.get("SKIP_STARTUP_PROBE", "").lower() in ("1", "true", "yes"):
221
+ print("[SaverAPI] Startup probe skipped (SKIP_STARTUP_PROBE=1).")
222
+ return
223
+ if not SAVER_API_STARTUP_PROBE_URL:
224
+ print("[SaverAPI] No SAVER_API_STARTUP_PROBE_URL set; skipping startup probe.")
225
+ return
226
+ if not key_rotator.keys:
227
+ print("[SaverAPI] No keys configured; skipping startup probe.")
228
+ return
229
+
230
+ print(f"[SaverAPI] Probing {len(key_rotator.keys)} key(s) with {SAVER_API_STARTUP_PROBE_URL} ...")
231
+ for key in key_rotator.keys:
232
+ ok, payload = _saverapi_fetch_one(SAVER_API_STARTUP_PROBE_URL, key)
233
+ if ok:
234
+ try:
235
+ os.remove(payload)
236
+ except OSError:
237
+ pass
238
+ print(f"[SaverAPI] key {KeyRotator.mask(key)}: OK")
239
+ continue
240
+ # Only mark dead on key-level failures. URL-level errors mean the probe
241
+ # URL itself is bad, not the key.
242
+ if payload.startswith("key-level:"):
243
+ key_rotator.mark_dead(key)
244
+ print(f"[SaverAPI] key {KeyRotator.mask(key)}: DEAD ({payload[:100]})")
245
+ else:
246
+ print(f"[SaverAPI] key {KeyRotator.mask(key)}: probe-URL error ({payload[:100]})")
247
 
248
  # ─────────────────────────────────────────────
249
  # GPU INFERENCE
 
514
  outputs=[label_out, conf_out, breakdown_out, session_state],
515
  )
516
 
517
+ # ─────────────────────────────────────────────
518
+ # STARTUP PROBE (best-effort key health check)
519
+ # ─────────────────────────────────────────────
520
+ _startup_probe()
521
+
522
  demo.launch()
requirements.txt CHANGED
@@ -7,8 +7,7 @@ einops
7
  # Video decoding
8
  decord
9
 
10
- # Social media video download
11
- yt-dlp
12
 
13
 
14
  gradio
 
7
  # Video decoding
8
  decord
9
 
10
+ # Social media video download (SaverAPI HTTP, no yt-dlp)
 
11
 
12
 
13
  gradio
saverapi_client.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SaverAPI video fetcher.
3
+
4
+ Dispatches to the right SaverAPI endpoint based on URL:
5
+ - YouTube / youtu.be / youtube Shorts -> /api/youtube-api
6
+ - Everything else (Instagram, TikTok, X, ...) -> /api/all-in-one-downloader-api
7
+
8
+ Stdlib only (urllib). Runs anywhere Python 3.7+ is installed, no pip needed.
9
+
10
+ Response shapes handled:
11
+ all-in-one / IG+TT : {"error": false, "download_url": "https://...", ...}
12
+ all-in-one / Vimeo : {"medias": [{"url": "https://...", "quality": "..."}, ...], ...}
13
+ all-in-one / others : {"url": "https://...", "source": "...", ...} (less common)
14
+ all-in-one URL-err : {"error": true, "message": "..."} (URL-level, do NOT mark key dead)
15
+ all-in-one / key : HTTP 402 + {"message": "Insufficient credits"} (KEY-level, mark dead)
16
+ youtube success : {"status": "tunnel", "url": "https://...", ...}
17
+ youtube error : (unconfirmed β€” passed through as best-effort)
18
+ Cloudflare block : HTTP 403, body contains "error code: 1010" (key/network, mark dead)
19
+ """
20
+ import json
21
+ import os
22
+ import sys
23
+ import tempfile
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+
28
+ # ── Endpoints ──────────────────────────────────────────────────────────────
29
+ ALL_IN_ONE_URL = "https://saverapi.net/api/all-in-one-downloader-api"
30
+ YOUTUBE_URL = "https://saverapi.net/api/youtube-api"
31
+
32
+ # ── Cloudflare 1010 bypass: send a real-browser User-Agent ────────────────
33
+ BROWSER_HEADERS = {
34
+ "User-Agent": (
35
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
36
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
37
+ "Chrome/120.0.0.0 Safari/537.36"
38
+ ),
39
+ "Accept": "application/json, text/plain, */*",
40
+ "Accept-Language": "en-US,en;q=0.9",
41
+ "Origin": "https://saverapi.net",
42
+ "Referer": "https://saverapi.net/",
43
+ }
44
+
45
+ # ── Tuning ─────────────────────────────────────────────────────────────────
46
+ LOOKUP_TIMEOUT_SEC = 15
47
+ DOWNLOAD_TIMEOUT_SEC = 60
48
+ DEFAULT_MAX_FILESIZE_MB = 100
49
+ YOUTUBE_DEFAULT_QUALITY = "720" # SaverAPI's param is misspelled "farmat"
50
+
51
+
52
+ # ── Platform detection ────────────────────────────────────────────────────
53
+ def _is_youtube(url: str) -> bool:
54
+ u = url.lower()
55
+ return ("youtube.com" in u) or ("youtu.be" in u) or ("youtube-nocookie.com" in u)
56
+
57
+
58
+ # ── Step 1: ask SaverAPI for a direct video URL ──────────────────────────
59
+ def _saverapi_lookup(url: str, api_key: str) -> tuple[bool, str, str]:
60
+ """Returns (ok, direct_video_url, error_message)."""
61
+ if _is_youtube(url):
62
+ endpoint = YOUTUBE_URL
63
+ params = {"url": url, "farmat": YOUTUBE_DEFAULT_QUALITY}
64
+ else:
65
+ endpoint = ALL_IN_ONE_URL
66
+ params = {"url": url}
67
+
68
+ full_url = f"{endpoint}?{urllib.parse.urlencode(params)}"
69
+ req = urllib.request.Request(
70
+ full_url,
71
+ headers={**BROWSER_HEADERS, "x-api-key": api_key, "Content-Type": "application/json"},
72
+ method="GET",
73
+ )
74
+
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=LOOKUP_TIMEOUT_SEC) as r:
77
+ status = r.status
78
+ body = r.read().decode("utf-8", errors="replace")
79
+ except urllib.error.HTTPError as e:
80
+ body = e.read().decode("utf-8", errors="replace")
81
+ if e.code == 403 and "error code" in body:
82
+ return False, "", "key-level:cloudflare"
83
+ if e.code == 402 and "insufficient" in body.lower():
84
+ return False, "", "key-level:insufficient-credits"
85
+ return False, "", f"key-level:http-{e.code}: {body[:200]}"
86
+ except Exception as e:
87
+ return False, "", f"key-level:network: {type(e).__name__}: {e}"
88
+
89
+ if status != 200:
90
+ return False, "", f"key-level:http-{status}: {body[:200]}"
91
+
92
+ try:
93
+ result = json.loads(body)
94
+ except json.JSONDecodeError:
95
+ return False, "", f"Non-JSON response: {body[:200]}"
96
+
97
+ if not isinstance(result, dict):
98
+ return False, "", f"Unexpected response (not an object): {str(result)[:200]}"
99
+
100
+ # YouTube success: {"status": "tunnel", "url": "https://..."}
101
+ if result.get("status") == "tunnel" and result.get("url"):
102
+ return True, result["url"], ""
103
+
104
+ # Vimeo-style success: {"medias": [{"url": "...", "quality": "..."}, ...], ...}
105
+ medias = result.get("medias")
106
+ if isinstance(medias, list) and medias:
107
+ # SaverAPI lists medias best-first; pick the first with a URL.
108
+ for m in medias:
109
+ if isinstance(m, dict) and m.get("url"):
110
+ return True, m["url"], ""
111
+ return False, "", "medias[] present but no url in any item"
112
+
113
+ # all-in-one success: {"error": false, "download_url": "https://..."}
114
+ if result.get("error") is False:
115
+ direct = result.get("download_url")
116
+ if direct:
117
+ return True, direct, ""
118
+ return False, "", "all-in-one: error=false but no download_url"
119
+
120
+ # all-in-one URL-level error: {"error": true, "message": "..."}
121
+ if result.get("error") is True:
122
+ return False, "", f"SaverAPI: {result.get('message', 'unspecified error')}"
123
+
124
+ # YouTube non-tunnel status (unconfirmed error shape β€” pass through)
125
+ if "status" in result:
126
+ return False, "", f"YouTube API status={result.get('status')!r}"
127
+
128
+ # Generic fallback: some platforms return a top-level "url" + "source"
129
+ if result.get("url") and result.get("source"):
130
+ return True, result["url"], ""
131
+
132
+ return False, "", f"Unexpected shape: {str(result)[:200]}"
133
+
134
+
135
+ # ── Step 2: stream the direct URL to a tempfile with a size cap ──────────
136
+ def _stream_to_file(direct_url: str, max_bytes: int) -> tuple[bool, str]:
137
+ # Strip saverapi.net-specific headers (Referer/Origin) β€” those are for the
138
+ # SaverAPI lookup, not for the underlying CDN (twimg.com, fbcdn.net, etc.).
139
+ # Keep User-Agent + Accept so the CDN sees a real browser.
140
+ headers = {
141
+ "User-Agent": BROWSER_HEADERS["User-Agent"],
142
+ "Accept": "*/*",
143
+ }
144
+ req = urllib.request.Request(direct_url, headers=headers, method="GET")
145
+
146
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
147
+ tmp.close()
148
+ try:
149
+ with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT_SEC) as r:
150
+ # Peek first 1 KB to catch HTML error pages
151
+ first_chunk = r.read(1024)
152
+ if first_chunk.lstrip().lower().startswith((b"<!doctype", b"<html")):
153
+ return False, "download_url returned HTML, not a video"
154
+
155
+ bytes_written = 0
156
+ with open(tmp.name, "wb") as f:
157
+ if first_chunk:
158
+ f.write(first_chunk)
159
+ bytes_written = len(first_chunk)
160
+ if bytes_written > max_bytes:
161
+ return False, f"Exceeds {max_bytes // (1024 * 1024)} MB cap"
162
+ while True:
163
+ chunk = r.read(64 * 1024)
164
+ if not chunk:
165
+ break
166
+ bytes_written += len(chunk)
167
+ if bytes_written > max_bytes:
168
+ return False, f"Exceeds {max_bytes // (1024 * 1024)} MB cap"
169
+ f.write(chunk)
170
+ if bytes_written == 0:
171
+ return False, "Empty response"
172
+ return True, tmp.name
173
+ except urllib.error.HTTPError as e:
174
+ try:
175
+ os.remove(tmp.name)
176
+ except OSError:
177
+ pass
178
+ return False, f"Download HTTP {e.code}"
179
+ except Exception as e:
180
+ try:
181
+ os.remove(tmp.name)
182
+ except OSError:
183
+ pass
184
+ return False, f"Download error: {type(e).__name__}: {e}"
185
+
186
+
187
+ # ── Public entry point ────────────────────────────────────────────────────
188
+ def saverapi_fetch(
189
+ url: str,
190
+ api_key: str,
191
+ max_filesize_mb: int = DEFAULT_MAX_FILESIZE_MB,
192
+ ) -> tuple[bool, str]:
193
+ """
194
+ Fetch a video from a supported platform via SaverAPI.
195
+
196
+ Args:
197
+ url: Public video URL (Instagram, TikTok, YouTube, ...)
198
+ api_key: SaverAPI key
199
+ max_filesize_mb: Hard cap on downloaded video size (default 100)
200
+
201
+ Returns:
202
+ (True, file_path) on success β€” caller must `os.remove(path)` when done
203
+ (False, err_message) on any failure
204
+ """
205
+ if not url.lower().startswith(("http://", "https://")):
206
+ return False, "Unsupported URL scheme (use http:// or https://)"
207
+
208
+ ok, direct, err = _saverapi_lookup(url, api_key)
209
+ if not ok:
210
+ return False, err
211
+
212
+ return _stream_to_file(direct, max_filesize_mb * 1024 * 1024)
213
+
214
+
215
+ # ── Quick self-test ───────────────────────────────────────────────────────
216
+ if __name__ == "__main__":
217
+ import time
218
+ api_key = os.environ.get("SAVER_API_KEY", "").strip()
219
+ if not api_key:
220
+ print("Set SAVER_API_KEY env var first.", file=sys.stderr)
221
+ sys.exit(1)
222
+
223
+ tests = [
224
+ "https://www.instagram.com/reel/DZTCpI2SQsq",
225
+ "https://www.tiktok.com/@scout2015/video/6718335390845095173",
226
+ "https://www.facebook.com/reel/763412386794044/",
227
+ "https://x.com/traderpodcaste/status/2063797483629629691",
228
+ "https://vimeo.com/76979871",
229
+ "https://www.youtube.com/shorts/LXb3EKWsInQ",
230
+ ]
231
+ for u in tests:
232
+ print(f"\n→ {u}")
233
+ t0 = time.time()
234
+ ok, result = saverapi_fetch(u, api_key)
235
+ dt = time.time() - t0
236
+ if ok:
237
+ size = os.path.getsize(result) // 1024
238
+ print(f" βœ… {size} KB in {dt:.1f}s β†’ {result}")
239
+ os.remove(result)
240
+ else:
241
+ print(f" ❌ {result}")