Files
J621/backend/apps/library/e621.py
T
JakeBreath 03dd235f3a Follows: followed tags/pools, feeds, unseen badges and blacklist cloud
Backend (new apps.follows):
- FollowedTag/FollowedPool/FollowedPost models; per-user follows with
  unseen tracking, plus FollowCloud for the cached blacklist cloud.
- Two periodic commands sharing one fetch path: sync_followed_tags and
  sync_followed_pools fetch each followed tag/pool's newest posts (one
  e621 search per unique follow), store unseen feed rows, refresh covers
  and pool metadata; both fall back to anonymous e621 access.
- API: /api/follows/tags|pools (follow, unfollow, mark seen), a merged
  feed with per-follow filtering, and /api/follows/cloud/ which rebuilds
  the blacklisted-tag cloud in a daemon thread when its 10 min cache is
  stale (polling returns building/ready).
- e621 client now supports anonymous reads; trimmed posts carry preview
  URLs for covers and feed tiles.

Frontend:
- /followed page: follow forms, cover cards with unseen badges and
  Mark seen, merged feed with filter/unseen toggle, and a blacklist
  cloud panel that polls while building. Followed nav entry added.
2026-09-17 14:09:30 -05:00

94 lines
3.1 KiB
Python

"""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