fix(images): /img/r serves local copy else 302 to discogs webp (server-side fetch is CF-blocked); drop unused pillow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 21:48:08 +10:00
parent ddaa6ecedf
commit 0b679ba079
2 changed files with 17 additions and 32 deletions

View File

@ -1,24 +1,21 @@
import hashlib import hashlib
import io
import os import os
from pathlib import Path from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, RedirectResponse
from PIL import Image
from sqlalchemy import text from sqlalchemy import text
from .db import engine from .db import engine
# Local WebP cache for Discogs release covers. RecordGod + DealGod share a VPS, so there's no point # Release-cover images for RecordGod. The stable URL the UI uses is /img/r/<release_id>:
# in a remote image service or a second copy — RecordGod compresses + serves covers itself. # • if a LOCAL WebP copy exists (DISC_IMAGE_DIR), serve it — self-hosted, tiny, store-owned;
# Storage path is CONFIGURABLE (DISC_IMAGE_DIR env): a default now, per-store storage override later # • else 302 → the Discogs thumb. Discogs already serves a ~150px q40 WebP (tiny), and a BROWSER can
# when shops host their own. Covers are tiny (q72, longest side <=600px). On-demand: converted on first # load it even though server-side fetch is Cloudflare-403'd — so we redirect rather than proxy.
# request, then served from disk. See RECORDGOD_NAVIGATOR_PLAN / recordgod-engine. # DISC_IMAGE_DIR is CONFIGURABLE (default now; per-store storage override later when shops host their own).
# Local copies get populated by a backfill from WowPlatter's already-downloaded images / the harvester —
# NOT by a server-side i.discogs fetch (that's blocked). Until then, the redirect gives working tiny WebP.
IMAGE_DIR = Path(os.getenv("DISC_IMAGE_DIR", "/app/disc_images")) IMAGE_DIR = Path(os.getenv("DISC_IMAGE_DIR", "/app/disc_images"))
WEBP_QUALITY = 72
MAX_DIM = 600
router = APIRouter() router = APIRouter()
@ -30,24 +27,13 @@ def _path(release_id: int) -> Path:
@router.get("/img/r/{release_id}") @router.get("/img/r/{release_id}")
async def release_image(release_id: int): async def release_image(release_id: int):
"""Tiny WebP of a release cover — convert from disc_release.thumb on first hit, then serve from disk."""
p = _path(release_id) p = _path(release_id)
if not p.exists(): if p.exists():
async with engine.connect() as conn: return FileResponse(p, media_type="image/webp",
row = (await conn.execute(text("SELECT thumb FROM disc_release WHERE id = :r"), headers={"Cache-Control": "public, max-age=2592000"})
{"r": release_id})).first() async with engine.connect() as conn:
src = row[0] if row else None row = (await conn.execute(text("SELECT thumb FROM disc_release WHERE id = :r"),
if not src: {"r": release_id})).first()
raise HTTPException(404, "no source image") if row and row[0]:
try: return RedirectResponse(row[0], status_code=302)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c: raise HTTPException(404, "no image")
r = await c.get(src, headers={"User-Agent": "Mozilla/5.0 (RecordGod)"})
r.raise_for_status()
img = Image.open(io.BytesIO(r.content)).convert("RGB")
img.thumbnail((MAX_DIM, MAX_DIM))
p.parent.mkdir(parents=True, exist_ok=True)
img.save(p, "WEBP", quality=WEBP_QUALITY, method=6)
except Exception:
raise HTTPException(502, "image fetch/convert failed")
return FileResponse(p, media_type="image/webp",
headers={"Cache-Control": "public, max-age=2592000"})

View File

@ -3,5 +3,4 @@ uvicorn[standard]
sqlalchemy[asyncio] sqlalchemy[asyncio]
asyncpg asyncpg
httpx httpx
pillow
cryptography cryptography