"""Minimal e621 API client for server-side matching and metadata refresh. The SPA talks to e621 directly for browsing; this client exists for work the browser cannot do reliably: long batch scans, and requests tied to a library item rather than an open page. It uses the requesting user's stored credentials and a global throttle (e621 asks for at most two requests per second). """ import threading import time import requests from django.conf import settings REQUEST_INTERVAL = 0.5 # seconds between requests, per process class E621Error(Exception): """A non-404 failure while talking to e621.""" class E621NotFound(E621Error): """The requested post does not exist (HTTP 404).""" _throttle_lock = threading.Lock() _last_request_at = 0.0 def credentials_configured(user): return bool(user is not None and getattr(user, "e621_configured", False)) def _wait_for_slot(): global _last_request_at with _throttle_lock: delay = _last_request_at + REQUEST_INTERVAL - time.monotonic() if delay > 0: time.sleep(delay) _last_request_at = time.monotonic() def get(user, path, params=None, timeout=30, require_auth=True): """GET an e621 API path using the user's credentials. Reads that e621 serves anonymously (searches, pools, tags) can pass require_auth=False; matching endpoints keep requiring credentials. Raises E621NotFound for 404s and E621Error for everything else that isn't a 2xx, so callers never see requests exceptions. """ configured = credentials_configured(user) if require_auth and not configured: raise E621Error("Configure your e621 credentials in Account first.") base = (getattr(user, "e621_base_url", "") or "https://e621.net").rstrip("/") _wait_for_slot() try: response = requests.get( f"{base}{path}", params=params, auth=(user.e621_username, user.e621_api_key) if configured else None, headers={"User-Agent": settings.USER_AGENT}, timeout=timeout, ) except requests.RequestException as exc: raise E621Error(f"Could not reach e621: {exc}") from exc if response.status_code == 404: raise E621NotFound(f"e621 returned 404 for {path}") if response.status_code >= 400: raise E621Error(f"e621 replied {response.status_code} for {path}") try: return response.json() except ValueError as exc: raise E621Error("e621 returned an unexpected response.") from exc def find_post_by_md5(user, md5): """The e621 post with this exact MD5, or None.""" payload = get(user, "/posts.json", params={"tags": f"md5:{md5}", "limit": 1}) posts = payload.get("posts") if isinstance(payload, dict) else None if not posts: return None return posts[0] def fetch_post(user, post_id): """One post by id. Raises E621NotFound when the post is gone.""" payload = get(user, f"/posts/{int(post_id)}.json") post = payload.get("post") if isinstance(payload, dict) else None if not isinstance(post, dict): raise E621Error("e621 returned an unexpected post payload.") return post