diff --git a/app/disc_images.py b/app/disc_images.py
new file mode 100644
index 0000000..7ae45f8
--- /dev/null
+++ b/app/disc_images.py
@@ -0,0 +1,53 @@
+import hashlib
+import io
+import os
+from pathlib import Path
+
+import httpx
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
+from PIL import Image
+from sqlalchemy import text
+
+from .db import engine
+
+# Local WebP cache for Discogs release covers. RecordGod + DealGod share a VPS, so there's no point
+# in a remote image service or a second copy — RecordGod compresses + serves covers itself.
+# Storage path is CONFIGURABLE (DISC_IMAGE_DIR env): a default now, per-store storage override later
+# when shops host their own. Covers are tiny (q72, longest side <=600px). On-demand: converted on first
+# request, then served from disk. See RECORDGOD_NAVIGATOR_PLAN / recordgod-engine.
+IMAGE_DIR = Path(os.getenv("DISC_IMAGE_DIR", "/app/disc_images"))
+WEBP_QUALITY = 72
+MAX_DIM = 600
+
+router = APIRouter()
+
+
+def _path(release_id: int) -> Path:
+ h = hashlib.sha256(str(release_id).encode()).hexdigest()
+ return IMAGE_DIR / h[:2] / f"{h}.webp"
+
+
+@router.get("/img/r/{release_id}")
+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)
+ if not p.exists():
+ async with engine.connect() as conn:
+ row = (await conn.execute(text("SELECT thumb FROM disc_release WHERE id = :r"),
+ {"r": release_id})).first()
+ src = row[0] if row else None
+ if not src:
+ raise HTTPException(404, "no source image")
+ try:
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
+ 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"})
diff --git a/app/main.py b/app/main.py
index cc4412b..e62053c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -24,6 +24,7 @@ from .shop_routes import router as shop_router # noqa: E402
from .sales_routes import router as sales_router # noqa: E402
from .navigator_routes import router as navigator_router # noqa: E402
from .collections_routes import router as collections_router # noqa: E402
+from .disc_images import router as disc_images_router # noqa: E402
from .db import engine # noqa: E402
from sqlalchemy import text as _sqltext # noqa: E402
@@ -37,6 +38,7 @@ app.include_router(shop_router)
app.include_router(sales_router)
app.include_router(navigator_router)
app.include_router(collections_router)
+app.include_router(disc_images_router)
_STARTUP_DDL = [
diff --git a/requirements.txt b/requirements.txt
index 20a610f..1c7f24d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,5 @@ uvicorn[standard]
sqlalchemy[asyncio]
asyncpg
httpx
+pillow
cryptography
diff --git a/site/search.html b/site/search.html
index bf5a40f..61fd340 100644
--- a/site/search.html
+++ b/site/search.html
@@ -110,6 +110,8 @@ const hdr=()=>({'Authorization':'Bearer '+TOKEN});
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+// WebP cover via RecordGod's own image cache; fall back to the raw discogs thumb if the cover can't convert
+const imgTag=it=> it.release_id ? `` : (it.thumb?`
`:'');
// ── facing convention (copied from WowPlatter production, see RECORDGOD_NAVIGATOR_PLAN §3) ──
const CELL=0.34;
@@ -326,7 +328,7 @@ async function run(){
const d=await get('/admin/inventory?q='+encodeURIComponent(q));
$('#count').textContent=`${d.total} match${d.total===1?'':'es'} — click to locate`;
$('#res').innerHTML=(d.items||[]).map(it=>`