4272a19d40
The single _FETCH_TIMEOUT=3000s meant different things per host: a TOTAL wall-clock for mega (subprocess), but only a per-read socket timeout for HTTP hosts (requests' timeout is the idle gap between bytes, never a total). So a stalled HTTP connection tied up a download-worker slot AND the per-host serialize lock for ~50 min before failing (operator-flagged 2026-06-17). Split into two limits in external_fetch: - read timeout (_READ_TIMEOUT=60s, with _CONNECT_TIMEOUT=30s) → requests gets (connect, read); a stalled socket now fails in ~60s. - total budget (_TOTAL_TIMEOUT=30min) → enforced as a wall-clock deadline across chunks in _stream_to_file (HTTP has no total-download timeout), and passed as the subprocess total for mega. fetch_external() signature: timeout= → read_timeout=/total_timeout=. gdrive (gdown) self-manages; the celery hard limit is the outer backstop. Also lowered the per-host lock TTL 3600→2400 so a worker that dies holding it can't wedge a host's links much past one fetch's budget. Each external link is already one Celery task (sweep enqueues one fetch_external_link.delay per link), so these budgets are per-link. Tests: total-budget-exceeded cleans the .part; HTTP gets (connect, read); mega gets the total. Worker fakes updated to **kwargs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""Fetchers for off-platform file hosts (mega / gdrive / mediafire / dropbox /
|
|
pixeldrain).
|
|
|
|
A shared, reusable subsystem: given an external_link URL, fetch the file(s) into
|
|
a destination directory and report the outcome. The download worker (separate
|
|
slice) drives these off the external_link ledger; any in-house downloader can
|
|
call `fetch_external()` directly.
|
|
|
|
No single tool covers all five hosts, so a small registry maps host → fetch
|
|
function behind one signature:
|
|
|
|
fetch_external(host, url, dest_dir, *, read_timeout, total_timeout, should_stop)
|
|
-> FetchResult
|
|
|
|
Backends:
|
|
- dropbox : force the direct-download variant (dl=1) + stream GET.
|
|
- pixeldrain : GET the /api/file/{id} endpoint.
|
|
- mediafire : scrape the download page for the direct link + stream GET.
|
|
- gdrive : gdown (handles the confirm-token + virus-scan interstitial).
|
|
- mega : `megatools dl` subprocess (public link incl. #key); needs the
|
|
`megatools` binary in the runtime image (Debian apt package).
|
|
|
|
Public links work credential-free (rule 26); per-host creds are a later Settings
|
|
concern. Plain-HTTP homelab — no secure-context API. The HTTP / gdown /
|
|
subprocess calls go through module-level seams so unit tests run without
|
|
network, gdown, or MEGAcmd.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
import requests
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_CHUNK = 1 << 16
|
|
# Two distinct limits, because conflating them (the old single 3000s value) meant
|
|
# a stalled HTTP connection tied up a download-worker slot + the per-host lock for
|
|
# ~50 min before failing (operator-flagged 2026-06-17):
|
|
# * READ timeout — max idle gap between bytes on an HTTP socket. A stalled host
|
|
# (socket open, nothing flowing) is the common failure mode; a short read
|
|
# timeout fails it fast. This is what requests' `timeout` actually enforces —
|
|
# per-read, never a total.
|
|
# * TOTAL budget — generous wall-clock cap for a file that IS actively
|
|
# transferring (big films/packs). Enforced as a deadline across chunks, since
|
|
# no HTTP client timeout bounds the total. Also the subprocess total for mega.
|
|
_CONNECT_TIMEOUT = 30.0
|
|
_READ_TIMEOUT = 60.0
|
|
_TOTAL_TIMEOUT = 1800.0 # 30 min per fetch
|
|
_USER_AGENT = (
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
|
)
|
|
# MediaFire's download page embeds the real file URL in a download-button href.
|
|
_MEDIAFIRE_RE = re.compile(
|
|
r'href="(https://download[^"]+?\.mediafire\.com/[^"]+)"', re.IGNORECASE
|
|
)
|
|
_CD_FILENAME_RE = re.compile(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', re.IGNORECASE)
|
|
|
|
|
|
@dataclass
|
|
class FetchResult:
|
|
files: list[Path] = field(default_factory=list)
|
|
bytes: int = 0
|
|
error: str | None = None
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.error is None and bool(self.files)
|
|
|
|
|
|
class ExternalFetchError(Exception):
|
|
"""A fetch failed in a way worth recording on the link's last_error."""
|
|
|
|
|
|
# -- seams (monkeypatched in tests) ----------------------------------------
|
|
|
|
def _http_get(url: str, *, timeout: float, headers: dict | None = None,
|
|
stream: bool = True) -> requests.Response:
|
|
hdrs = {"User-Agent": _USER_AGENT}
|
|
if headers:
|
|
hdrs.update(headers)
|
|
return requests.get(url, timeout=timeout, headers=hdrs, stream=stream)
|
|
|
|
|
|
def _gdown_download(url: str, out_dir: str) -> str | None:
|
|
"""Download a Google Drive url into out_dir via gdown; return the written
|
|
path. Imported lazily so the dep is optional at module-import time."""
|
|
# Lazy import: keeps gdown an optional dep that isn't needed to import this
|
|
# module (e.g. the no-DB unit lane that never exercises a real fetch).
|
|
import gdown
|
|
# A trailing sep tells gdown to keep the server-side filename inside out_dir.
|
|
return gdown.download(url, output=out_dir + os.sep, quiet=True, fuzzy=True)
|
|
|
|
|
|
def _run_mega_get(url: str, out_dir: str, *, timeout: float) -> None:
|
|
"""Download a mega.nz public link (key in the #fragment) into out_dir via
|
|
`megatools dl` (the Debian `megatools` package). Raises ExternalFetchError
|
|
on non-zero exit."""
|
|
# Fixed argv (not shell): only `url` is external input, passed positionally,
|
|
# so there's no shell-injection surface.
|
|
proc = subprocess.run(
|
|
["megatools", "dl", "--path", out_dir, url],
|
|
capture_output=True, text=True, timeout=timeout, check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise ExternalFetchError(
|
|
f"megatools dl exit {proc.returncode}: {(proc.stderr or '').strip()[:300]}"
|
|
)
|
|
|
|
|
|
# -- helpers ---------------------------------------------------------------
|
|
|
|
def _safe_name(name: str, fallback: str) -> str:
|
|
name = os.path.basename((name or "").strip().strip('"'))
|
|
# Strip path separators / control chars; never empty.
|
|
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip(". ")
|
|
return name or fallback
|
|
|
|
|
|
def _filename_from(resp: requests.Response, url: str, fallback: str) -> str:
|
|
cd = resp.headers.get("Content-Disposition", "")
|
|
m = _CD_FILENAME_RE.search(cd)
|
|
if m:
|
|
return _safe_name(m.group(1), fallback)
|
|
path_name = os.path.basename(urlsplit(url).path)
|
|
return _safe_name(path_name, fallback)
|
|
|
|
|
|
def _stream_to_file(resp: requests.Response, dest: Path,
|
|
should_stop: Callable[[], bool],
|
|
*, deadline: float | None = None) -> int:
|
|
"""Stream a response body to `dest` (atomic via .part). Returns byte count.
|
|
Honors should_stop and the total-budget `deadline` (a time.monotonic() value)
|
|
between chunks; the partial file is removed on either abort."""
|
|
part = dest.with_name(dest.name + ".part")
|
|
total = 0
|
|
try:
|
|
with part.open("wb") as fh:
|
|
for chunk in resp.iter_content(chunk_size=_CHUNK):
|
|
if should_stop():
|
|
raise ExternalFetchError("stopped")
|
|
if deadline is not None and time.monotonic() > deadline:
|
|
raise ExternalFetchError("exceeded total fetch budget")
|
|
if chunk:
|
|
fh.write(chunk)
|
|
total += len(chunk)
|
|
except BaseException:
|
|
part.unlink(missing_ok=True)
|
|
raise
|
|
os.replace(part, dest)
|
|
return total
|
|
|
|
|
|
def _get_to_dir(url: str, dest_dir: Path, *, read_timeout: float,
|
|
total_timeout: float, should_stop: Callable[[], bool],
|
|
fallback: str, headers: dict | None = None) -> FetchResult:
|
|
# (connect, read): a short read timeout fails a stalled socket fast; the total
|
|
# budget is enforced separately as a deadline across chunks (requests has no
|
|
# total-download timeout).
|
|
resp = _http_get(
|
|
url, timeout=(_CONNECT_TIMEOUT, read_timeout), headers=headers, stream=True
|
|
)
|
|
if resp.status_code != 200:
|
|
return FetchResult(error=f"HTTP {resp.status_code} for {url}")
|
|
name = _filename_from(resp, url, fallback)
|
|
dest = dest_dir / name
|
|
written = _stream_to_file(
|
|
resp, dest, should_stop, deadline=time.monotonic() + total_timeout
|
|
)
|
|
return FetchResult(files=[dest], bytes=written)
|
|
|
|
|
|
# -- per-host fetchers -----------------------------------------------------
|
|
|
|
def _fetch_dropbox(url: str, dest_dir: Path, *, read_timeout: float,
|
|
total_timeout: float,
|
|
should_stop: Callable[[], bool]) -> FetchResult:
|
|
# Force the direct-download variant: dl=1 (Dropbox serves an HTML preview
|
|
# for dl=0). Rewrite/insert the param rather than string-replace so ?dl=0,
|
|
# &dl=0, and a missing param all resolve.
|
|
parts = urlsplit(url)
|
|
q = dict(parse_qsl(parts.query))
|
|
q["dl"] = "1"
|
|
direct = urlunsplit(parts._replace(query=urlencode(q)))
|
|
return _get_to_dir(direct, dest_dir, read_timeout=read_timeout,
|
|
total_timeout=total_timeout, should_stop=should_stop,
|
|
fallback="dropbox-file")
|
|
|
|
|
|
def _fetch_pixeldrain(url: str, dest_dir: Path, *, read_timeout: float,
|
|
total_timeout: float,
|
|
should_stop: Callable[[], bool]) -> FetchResult:
|
|
# /u/{id} (and /l/{id}) → the API file endpoint.
|
|
file_id = urlsplit(url).path.rstrip("/").split("/")[-1]
|
|
if not file_id:
|
|
return FetchResult(error=f"no pixeldrain id in {url}")
|
|
api = f"https://pixeldrain.com/api/file/{file_id}"
|
|
return _get_to_dir(api, dest_dir, read_timeout=read_timeout,
|
|
total_timeout=total_timeout, should_stop=should_stop,
|
|
fallback=f"{file_id}.bin")
|
|
|
|
|
|
def _fetch_mediafire(url: str, dest_dir: Path, *, read_timeout: float,
|
|
total_timeout: float,
|
|
should_stop: Callable[[], bool]) -> FetchResult:
|
|
page = _http_get(url, timeout=(_CONNECT_TIMEOUT, read_timeout), stream=False)
|
|
if page.status_code != 200:
|
|
return FetchResult(error=f"HTTP {page.status_code} for mediafire page")
|
|
m = _MEDIAFIRE_RE.search(page.text or "")
|
|
if not m:
|
|
return FetchResult(error="mediafire direct link not found on page")
|
|
return _get_to_dir(m.group(1), dest_dir, read_timeout=read_timeout,
|
|
total_timeout=total_timeout, should_stop=should_stop,
|
|
fallback="mediafire-file")
|
|
|
|
|
|
def _fetch_gdrive(url: str, dest_dir: Path, *, read_timeout: float,
|
|
total_timeout: float,
|
|
should_stop: Callable[[], bool]) -> FetchResult:
|
|
# gdown manages its own HTTP session/timeouts; the task's celery hard limit is
|
|
# the outer backstop. read_timeout/total_timeout are accepted for a uniform
|
|
# registry signature but not separately enforceable here.
|
|
out = _gdown_download(url, str(dest_dir))
|
|
if not out:
|
|
return FetchResult(error="gdown returned no file (quota / private?)")
|
|
p = Path(out)
|
|
if not p.exists():
|
|
return FetchResult(error=f"gdown reported {out} but it is missing")
|
|
return FetchResult(files=[p], bytes=p.stat().st_size)
|
|
|
|
|
|
def _fetch_mega(url: str, dest_dir: Path, *, read_timeout: float,
|
|
total_timeout: float,
|
|
should_stop: Callable[[], bool]) -> FetchResult:
|
|
before = set(dest_dir.iterdir()) if dest_dir.exists() else set()
|
|
# megatools is a subprocess: its timeout IS a total wall-clock cap (the read
|
|
# timeout has no analogue here), so the total budget applies directly.
|
|
_run_mega_get(url, str(dest_dir), timeout=total_timeout)
|
|
new = [p for p in dest_dir.iterdir() if p not in before and p.is_file()]
|
|
if not new:
|
|
return FetchResult(error="mega-get wrote no new file")
|
|
return FetchResult(files=new, bytes=sum(p.stat().st_size for p in new))
|
|
|
|
|
|
_REGISTRY: dict[str, Callable[..., FetchResult]] = {
|
|
"dropbox": _fetch_dropbox,
|
|
"pixeldrain": _fetch_pixeldrain,
|
|
"mediafire": _fetch_mediafire,
|
|
"gdrive": _fetch_gdrive,
|
|
"mega": _fetch_mega,
|
|
}
|
|
|
|
SUPPORTED_HOSTS = tuple(_REGISTRY)
|
|
|
|
|
|
def fetch_external(host: str, url: str, dest_dir: Path, *,
|
|
read_timeout: float = _READ_TIMEOUT,
|
|
total_timeout: float = _TOTAL_TIMEOUT,
|
|
should_stop: Callable[[], bool] = lambda: False) -> FetchResult:
|
|
"""Fetch `url` (a `host` link) into `dest_dir`. Returns a FetchResult; never
|
|
raises — any backend error (transport, read/total timeout, non-200, scrape
|
|
miss, subprocess failure, stop) is captured on `.error` so the worker can
|
|
record it and move on.
|
|
|
|
`read_timeout` fails a stalled HTTP socket fast (idle gap between bytes);
|
|
`total_timeout` is the generous wall-clock cap for a large file that is
|
|
actively transferring (and the subprocess total for mega)."""
|
|
fetcher = _REGISTRY.get(host)
|
|
if fetcher is None:
|
|
return FetchResult(error=f"unsupported host {host!r}")
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
return fetcher(url, dest_dir, read_timeout=read_timeout,
|
|
total_timeout=total_timeout, should_stop=should_stop)
|
|
except requests.RequestException as exc:
|
|
return FetchResult(error=f"transport error: {exc}")
|
|
except subprocess.TimeoutExpired:
|
|
return FetchResult(error="timed out")
|
|
except ExternalFetchError as exc:
|
|
return FetchResult(error=str(exc))
|
|
except Exception as exc: # never let a backend quirk kill the worker
|
|
log.warning("external fetch (%s) failed for %s: %s", host, url, exc)
|
|
return FetchResult(error=f"{type(exc).__name__}: {exc}")
|