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:
parent
af3a611667
commit
ddaa6ecedf
53
app/disc_images.py
Normal file
53
app/disc_images.py
Normal 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"})
|
||||
@ -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 = [
|
||||
|
||||
@ -3,4 +3,5 @@ uvicorn[standard]
|
||||
sqlalchemy[asyncio]
|
||||
asyncpg
|
||||
httpx
|
||||
pillow
|
||||
cryptography
|
||||
|
||||
@ -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 ? `<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('')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user