diff --git a/app/main.py b/app/main.py index 75e41e0..4f1ce34 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,20 @@ +import pathlib + from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from . import __version__ from .wowplatter_v1 import router as wowplatter_v1_router +from .virtual import router as virtual_router app = FastAPI(title="RECORDGOD", version=__version__) app.include_router(wowplatter_v1_router) +app.include_router(virtual_router) + +# Serve the raw Three.js storefront same-origin (so /store can fetch /virtual/scene, no CORS). +WEBSTORE = pathlib.Path(__file__).resolve().parent.parent / "webstore" +if WEBSTORE.is_dir(): + app.mount("/store", StaticFiles(directory=str(WEBSTORE), html=True), name="store") @app.get("/healthz") diff --git a/app/virtual.py b/app/virtual.py new file mode 100644 index 0000000..b84ffb2 --- /dev/null +++ b/app/virtual.py @@ -0,0 +1,97 @@ +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)]}, + } diff --git a/migrate_virtual.py b/migrate_virtual.py new file mode 100644 index 0000000..1a46e39 --- /dev/null +++ b/migrate_virtual.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Lift the WowPlatter 3D-store geometry (wp_rmp_disc_virtual_*) into RecordGod Postgres. + +These tables are read-mostly scene config (~700 rows total) — we do NOT redesign them, just +relocate verbatim so the RecordGod /virtual/scene endpoint can serve the same payload payload.php +built, but from Postgres instead of a full WordPress bootstrap. Auto-discovers every +wp_rmp_disc_virtual* table and mirrors it as virtual_* in recordgod. + + python migrate_virtual.py +""" +import os +import pathlib +import re + +import pymysql +import psycopg + +WP_CONFIG = os.getenv("WP_CONFIG", "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php") +PG_DSN = os.getenv("RECORDGOD_DSN", "postgresql://localhost/recordgod") +SOCK = os.getenv("MARIA_SOCK", "/opt/homebrew/var/mysql/mysql.sock") + + +def wp_creds(): + t = pathlib.Path(WP_CONFIG).read_text() + g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1) + return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD") + + +def pgtype(mysql_type): + t = mysql_type.lower() + if t in ("tinyint", "smallint", "mediumint", "int", "integer", "bigint"): + return "bigint" + if t in ("decimal", "numeric"): + return "numeric" + if t in ("float", "double"): + return "double precision" + if t in ("datetime", "timestamp"): + return "timestamptz" + if t == "date": + return "date" + return "text" # varchar/char/text/longtext/enum/json — kept as-is; geometry config, not redesigned + + +def main(): + name, user, pw = wp_creds() + my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name, + cursorclass=pymysql.cursors.DictCursor) + pg = psycopg.connect(PG_DSN, autocommit=False) + c = my.cursor() + + c.execute("""SELECT table_name FROM information_schema.tables + WHERE table_schema=%s AND table_name LIKE 'wp_rmp_disc_virtual%%' + ORDER BY table_name""", (name,)) + tables = [r["table_name"] for r in c.fetchall()] + + for src in tables: + dst = src.replace("wp_rmp_disc_", "") # virtual_crate, virtual_rack, ... + c.execute("""SELECT column_name, data_type FROM information_schema.columns + WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position""", + (name, src)) + cols = c.fetchall() + coldefs = ", ".join(f'"{col["column_name"]}" {pgtype(col["data_type"])}' for col in cols) + names = [col["column_name"] for col in cols] + + pg.execute(f'DROP TABLE IF EXISTS "{dst}"') + pg.execute(f'CREATE TABLE "{dst}" ({coldefs})') + + c.execute(f"SELECT * FROM `{src}`") + rows = [tuple(r[n] for n in names) for r in c.fetchall()] + if rows: + ph = ",".join(["%s"] * len(names)) + quoted = ",".join(f'"{n}"' for n in names) + with pg.cursor() as cur: + cur.executemany(f'INSERT INTO "{dst}" ({quoted}) VALUES ({ph})', rows) + print(f" {dst:28} {len(rows):>5}") + + pg.commit() + print("done.") + + +if __name__ == "__main__": + main() diff --git a/webstore/index.html b/webstore/index.html new file mode 100644 index 0000000..542cd37 --- /dev/null +++ b/webstore/index.html @@ -0,0 +1,173 @@ + + +
+ + +