feat: deploy-ready — env-aware Discogs enrichment + Dockerfile

- app/mirror.py: centralised enrichment, DISCOGS_MIRROR_KIND selects full (ultra `release`)
  vs vps (`discogs_release`); virtual.py + wowplatter_v1.py use it.
- Dockerfile + .dockerignore for the VPS container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-21 13:08:49 +10:00
parent e1fcf94f93
commit 9b8ecdb84d
5 changed files with 54 additions and 29 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.venv/
.git/
__pycache__/
*.pyc
.env

7
Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8010
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

31
app/mirror.py Normal file
View File

@ -0,0 +1,31 @@
import os
from sqlalchemy import text
from .db import MirrorSession
# Enrichment from the Discogs mirror. Two known shapes, selected by env so the same code runs
# on ultra (the full dump) and on the VPS (dealgod's discogs_release subset):
# full → table `release` cols artists_sort / thumb / estimated_weight (ultra)
# vps → table `discogs_release` cols artist (no cover/weight) (dealgod-db)
KIND = os.getenv("DISCOGS_MIRROR_KIND", "full")
if KIND == "vps":
_SQL = ("SELECT id, title, artist, NULL::text AS thumb, NULL::int AS weight "
"FROM discogs_release WHERE id = ANY(:ids)")
else:
_SQL = ("SELECT id, title, artists_sort AS artist, "
"COALESCE(thumb_local_url, thumb) AS thumb, estimated_weight AS weight "
"FROM release WHERE id = ANY(:ids)")
async def enrich(ids):
"""release_id(s) → {id: {title, artist, thumb, weight}}. Best-effort: mirror down → {}."""
ids = [i for i in set(ids) if i is not None]
if not ids:
return {}
try:
async with MirrorSession() as m:
rows = await m.execute(text(_SQL), {"ids": ids})
return {r["id"]: dict(r) for r in rows.mappings()}
except Exception:
return {}

View File

@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import text from sqlalchemy import text
from .db import get_db, MirrorSession from .db import get_db
from .mirror import enrich as mirror_enrich
# The virtual store API — PUBLIC read (it's the customer-facing storefront). Replaces # The virtual store API — PUBLIC read (it's the customer-facing storefront). Replaces
# WowPlatter's payload.php: same scene shape, but from Postgres instead of a full WP bootstrap. # WowPlatter's payload.php: same scene shape, but from Postgres instead of a full WP bootstrap.
@ -13,25 +14,10 @@ async def _all(db, table, where=""):
return [dict(r) for r in rows.mappings()] return [dict(r) for r in rows.mappings()]
async def _enrich(release_ids):
"""Pull title/artist/cover from the discogs_full mirror for a set of release ids."""
ids = [i for i in release_ids if i is not None]
if not ids:
return {}
try:
async with MirrorSession() as m:
rows = await m.execute(text(
"SELECT id, title, artists_sort, COALESCE(thumb_local_url, thumb) AS thumb "
"FROM release WHERE id = ANY(:ids)"), {"ids": ids})
return {r["id"]: dict(r) for r in rows.mappings()}
except Exception:
return {} # mirror down → scene still renders, just without covers/titles
def _attach(recs, meta): def _attach(recs, meta):
for r in recs: for r in recs:
m = meta.get(r["release_id"], {}) m = meta.get(r["release_id"], {})
r["title"], r["artist"], r["thumb"] = m.get("title"), m.get("artists_sort"), m.get("thumb") r["title"], r["artist"], r["thumb"] = m.get("title"), m.get("artist"), m.get("thumb")
return recs return recs
@ -48,7 +34,7 @@ async def scene(db=Depends(get_db)):
FROM inventory FROM inventory
WHERE in_stock AND crate_id IS NOT NULL AND release_id IS NOT NULL WHERE in_stock AND crate_id IS NOT NULL AND release_id IS NOT NULL
ORDER BY crate_id, slot_number NULLS LAST"""))).mappings()] ORDER BY crate_id, slot_number NULLS LAST"""))).mappings()]
recs = _attach(recs, await _enrich({r["release_id"] for r in recs})) recs = _attach(recs, await mirror_enrich({r["release_id"] for r in recs}))
return { return {
"space": active, "spaces": spaces, "space": active, "spaces": spaces,
@ -72,7 +58,7 @@ async def crate_records(crate_id: int, db=Depends(get_db)):
SELECT release_id, slot_number, sku, price, condition SELECT release_id, slot_number, sku, price, condition
FROM inventory WHERE crate_id = :c AND in_stock AND release_id IS NOT NULL FROM inventory WHERE crate_id = :c AND in_stock AND release_id IS NOT NULL
ORDER BY slot_number NULLS LAST"""), {"c": crate_id})).mappings()] ORDER BY slot_number NULLS LAST"""), {"c": crate_id})).mappings()]
recs = _attach(recs, await _enrich({r["release_id"] for r in recs})) recs = _attach(recs, await mirror_enrich({r["release_id"] for r in recs}))
return {"ok": True, "crate_id": crate_id, "records": recs} return {"ok": True, "crate_id": crate_id, "records": recs}

View File

@ -7,7 +7,8 @@ from sqlalchemy import text
from . import __version__ from . import __version__
from .auth import require_token from .auth import require_token
from .db import get_db, MirrorSession from .db import get_db
from .mirror import enrich as mirror_enrich
# The wowplatter/v1 contract — RecordGod's front door (see RECORDGOD_PLAN.md §3). # The wowplatter/v1 contract — RecordGod's front door (see RECORDGOD_PLAN.md §3).
# Routes 1, 2, 5 live here. Routes 3-4 (price/labels) are deliberately NOT defined yet: # Routes 1, 2, 5 live here. Routes 3-4 (price/labels) are deliberately NOT defined yet:
@ -76,15 +77,10 @@ async def inventory_intake(body: IntakeIn, ident=Depends(require_token), db=Depe
title = artist = None title = artist = None
weight = None weight = None
if body.release_id: if body.release_id:
try: # ponytail: mirror down → stage un-enriched rather than fail the intake # ponytail: mirror down → stage un-enriched rather than fail the intake (enrich() swallows)
async with MirrorSession() as mirror: meta = (await mirror_enrich([body.release_id])).get(body.release_id)
m = (await mirror.execute(text( if meta:
"SELECT title, artists_sort, estimated_weight FROM release WHERE id = :r" title, artist, weight = meta["title"], meta["artist"], meta["weight"]
), {"r": body.release_id})).mappings().first()
if m:
title, artist, weight = m["title"], m["artists_sort"], m["estimated_weight"]
except Exception:
pass
sku = body.sku or _new_sku() sku = body.sku or _new_sku()
await db.execute(text(""" await db.execute(text("""