RECORDGOD/app/virtual.py
type-two 8b48010b50 feat(virtual): lift the 3D store out of WordPress — scene API + raw Three.js renderer
- migrate_virtual.py: all 15 wp_rmp_disc_virtual_* tables → Postgres (~700 rows).
- app/virtual.py (public): GET /virtual/scene (geometry + slim front covers enriched
  from discogs_full), /virtual/crate/{id}/records (digging), /virtual/locate (locate-stock).
  Replaces payload.php — no WordPress bootstrap.
- webstore/index.html: walkable raw Three.js store (room/racks/crates from the data,
  pointer-lock FPS, click-to-dig hook), served same-origin at /store.
- Proven: 51 racks, 346 crates, 277 covers; locate release→crate works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:59:52 +10:00

98 lines
4.4 KiB
Python

from fastapi import APIRouter, Depends
from sqlalchemy import text
from .db import get_db, MirrorSession
# 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.
router = APIRouter(prefix="/virtual", tags=["virtual-store"])
async def _all(db, table, where=""):
rows = await db.execute(text(f"SELECT * FROM {table} {where}"))
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):
for r in recs:
m = meta.get(r["release_id"], {})
r["title"], r["artist"], r["thumb"] = m.get("title"), m.get("artists_sort"), m.get("thumb")
return recs
@router.get("/scene")
async def scene(db=Depends(get_db)):
"""Everything the renderer needs at boot. Records are SLIM — one front cover per crate;
the full per-crate list is lazy-loaded on dig via /virtual/crate/{id}/records."""
spaces = await _all(db, "virtual_space", "WHERE visible='y'")
active = spaces[0] if len(spaces) == 1 else next(
(s for s in spaces if s.get("is_default") == "y"), spaces[0] if spaces else None)
recs = [dict(r) for r in (await db.execute(text("""
SELECT DISTINCT ON (crate_id) crate_id, slot_number, release_id, sku, price, condition
FROM inventory
WHERE in_stock AND crate_id IS NOT NULL AND release_id IS NOT NULL
ORDER BY crate_id, slot_number NULLS LAST"""))).mappings()]
recs = _attach(recs, await _enrich({r["release_id"] for r in recs}))
return {
"space": active, "spaces": spaces,
"racks": await _all(db, "virtual_rack", "WHERE visible='y'"),
"rack_types": await _all(db, "virtual_rack_type"),
"rack_type_levels": await _all(db, "virtual_rack_type_level"),
"crates": await _all(db, "virtual_crate", "WHERE visible='y'"),
"crate_types": await _all(db, "virtual_crate_type"),
"cameras": await _all(db, "virtual_camera"),
"lights": await _all(db, "virtual_light"),
"decals": await _all(db, "virtual_decal", "WHERE visible <> 'n'"),
"portals": await _all(db, "virtual_portal"),
"records": recs,
}
@router.get("/crate/{crate_id}/records")
async def crate_records(crate_id: int, db=Depends(get_db)):
"""Full contents of one crate — loaded when the shopper starts digging it."""
recs = [dict(r) for r in (await db.execute(text("""
SELECT release_id, slot_number, sku, price, condition
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()]
recs = _attach(recs, await _enrich({r["release_id"] for r in recs}))
return {"ok": True, "crate_id": crate_id, "records": recs}
@router.get("/locate")
async def locate(release_id: int, db=Depends(get_db)):
"""Locate-stock: where in the physical store is this record? Returns the crate + position
so the UI can fly the camera there and flash the bin."""
row = (await db.execute(text("""
SELECT i.crate_id, i.slot_number, i.sku,
c.name, c.label_text, c.pos_x, c.pos_y, c.pos_z
FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id
WHERE i.release_id = :r AND i.in_stock AND i.crate_id IS NOT NULL
ORDER BY i.slot_number NULLS LAST LIMIT 1"""), {"r": release_id})).mappings().first()
if not row:
return {"ok": True, "located": False, "release_id": release_id}
d = dict(row)
return {
"ok": True, "located": True, "release_id": release_id,
"crate_id": d["crate_id"], "slot_number": d["slot_number"], "sku": d["sku"],
"crate": {"name": d["name"], "label_text": d["label_text"],
"pos": [float(d["pos_x"] or 0), float(d["pos_y"] or 0), float(d["pos_z"] or 0)]},
}