Spaces:
Sleeping
Sleeping
Swap yt-dlp for SaverAPI
Browse files- README.md +16 -3
- detector.py +155 -27
- requirements.txt +1 -2
- 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
|
| 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 |
-
| `
|
| 46 |
| `inference_core.py` | Model architecture + video windowing (unchanged from training) |
|
| 47 |
-
| `requirements.txt` | Python deps (`decord`, `
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
try:
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
return False, "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}")
|