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=>`
- ${it.thumb?``:''} + ${imgTag(it)}
${esc(it.title||it.sku)}
${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · ${money(it.price)}
${it.crate?`📍 ${esc(it.crate)}`:'· no bin'}
`).join('')