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 .sales_routes import router as sales_router # noqa: E402
|
||||||
from .navigator_routes import router as navigator_router # noqa: E402
|
from .navigator_routes import router as navigator_router # noqa: E402
|
||||||
from .collections_routes import router as collections_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 .db import engine # noqa: E402
|
||||||
from sqlalchemy import text as _sqltext # 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(sales_router)
|
||||||
app.include_router(navigator_router)
|
app.include_router(navigator_router)
|
||||||
app.include_router(collections_router)
|
app.include_router(collections_router)
|
||||||
|
app.include_router(disc_images_router)
|
||||||
|
|
||||||
|
|
||||||
_STARTUP_DDL = [
|
_STARTUP_DDL = [
|
||||||
|
|||||||
@ -3,4 +3,5 @@ uvicorn[standard]
|
|||||||
sqlalchemy[asyncio]
|
sqlalchemy[asyncio]
|
||||||
asyncpg
|
asyncpg
|
||||||
httpx
|
httpx
|
||||||
|
pillow
|
||||||
cryptography
|
cryptography
|
||||||
|
|||||||
@ -110,6 +110,8 @@ const hdr=()=>({'Authorization':'Bearer '+TOKEN});
|
|||||||
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
|
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
|
||||||
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
|
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
|
||||||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
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) ──
|
// ── facing convention (copied from WowPlatter production, see RECORDGOD_NAVIGATOR_PLAN §3) ──
|
||||||
const CELL=0.34;
|
const CELL=0.34;
|
||||||
@ -326,7 +328,7 @@ async function run(){
|
|||||||
const d=await get('/admin/inventory?q='+encodeURIComponent(q));
|
const d=await get('/admin/inventory?q='+encodeURIComponent(q));
|
||||||
$('#count').textContent=`${d.total} match${d.total===1?'':'es'} — click to locate`;
|
$('#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||'')})'>
|
$('#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="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>
|
<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('')
|
${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