feat(images): RecordGod WebP cover cache (/img/r/{release_id}) — same-VPS, configurable DISC_IMAGE_DIR, no DealGod dep

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 21:45:31 +10:00
parent af3a611667
commit ddaa6ecedf
4 changed files with 59 additions and 1 deletions

53
app/disc_images.py Normal file
View File

@ -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"})

View File

@ -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 = [

View File

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

View File

@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 ? `<img src="/img/r/${it.release_id}" onerror="this.onerror=null;this.src='${(it.thumb||'').replace(/'/g,'')}'">` : (it.thumb?`<img src="${esc(it.thumb)}">`:'<span class=ph></span>');
// ── 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=>`<div class="ri" onclick='locate(${it.crate_id||'null'}, ${JSON.stringify(it.sku||'')})'>
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
${imgTag(it)}
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div>
<div class="muted">${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · <b class="${it.in_stock?'pink':'oos'}">${money(it.price)}</b></div>
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin</span>'}</div></div>`).join('')