import asyncio import json import re import httpx from fastapi import APIRouter, Depends, Query, HTTPException, Header from pydantic import BaseModel from sqlalchemy import text from . import vault _YT = re.compile(r"(?:v=|youtu\.be/|embed/|/v/)([\w-]{11})") def _ytid(uri): if not uri: return None m = _YT.search(uri) if m: return m.group(1) return uri if re.fullmatch(r"[\w-]{11}", uri) else None from .db import get_db from .layout_routes import DEFAULTS # PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here. # No auth (theme + in-stock catalog aren't secret); driven by what the builder saves. router = APIRouter(prefix="/shop", tags=["shop"]) @router.get("/config") async def shop_config(db=Depends(get_db)): row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first() cfg = row[0] if row else None if isinstance(cfg, str): cfg = json.loads(cfg) return cfg or DEFAULTS @router.get("/catalog") async def shop_catalog(q: str = Query(""), page: int = Query(1, ge=1), db=Depends(get_db)): per = 24 where = ["i.in_stock", "i.store_id = 1"] params = {"per": per, "off": (page - 1) * per} if q: where.append("(coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)") params["q"] = f"%{q}%" w = " AND ".join(where) items = [dict(r) for r in (await db.execute(text(f""" SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb, i.price::float AS price, i.condition, i.kind FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id WHERE {w} ORDER BY i.updated_at DESC NULLS LAST LIMIT :per OFFSET :off"""), params)).mappings()] cparams = {k: v for k, v in params.items() if k not in ("per", "off")} total = (await db.execute(text( f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}" ), cparams)).scalar() return {"items": items, "page": page, "per": per, "total": total} @router.get("/artist/{artist_id}") async def shop_artist(artist_id: int, db=Depends(get_db)): r = (await db.execute(text("SELECT id, name, realname FROM disc_artist WHERE id=:i"), {"i": artist_id})).mappings().first() return {"artist": dict(r) if r else {"id": artist_id, "name": f"Artist {artist_id}"}} @router.get("/label/{label_id}") async def shop_label(label_id: int, db=Depends(get_db)): r = (await db.execute(text("SELECT id, name, profile FROM disc_label WHERE id=:i"), {"i": label_id})).mappings().first() return {"label": dict(r) if r else {"id": label_id, "name": f"Label {label_id}"}} @router.get("/browse") async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Query(""), fmt: str = Query(""), artist: int | None = None, label: int | None = None, year_min: int | None = None, year_max: int | None = None, price_min: float | None = None, price_max: float | None = None, sort: str = Query("new"), page: int = Query(1, ge=1), db=Depends(get_db)): """Faceted, release-grouped catalog — the public Browse page. One row per release with its cheapest copy.""" per = 24 where = ["i.in_stock", "i.store_id = 1", "i.release_id IS NOT NULL"] params = {"per": per, "off": (page - 1) * per} if q: where.append("(coalesce(i.title, dr.title) ILIKE :q OR dr.artists_sort ILIKE :q)"); params["q"] = f"%{q}%" if genre: where.append("EXISTS (SELECT 1 FROM disc_release_genre g WHERE g.release_id=i.release_id AND g.genre_name=:genre)"); params["genre"] = genre if style: where.append("EXISTS (SELECT 1 FROM disc_release_style s WHERE s.release_id=i.release_id AND s.style_name=:style)"); params["style"] = style if fmt: where.append("EXISTS (SELECT 1 FROM disc_release_format f WHERE f.release_id=i.release_id AND f.name ILIKE :fmt)"); params["fmt"] = f"%{fmt}%" if artist: where.append("EXISTS (SELECT 1 FROM disc_release_artist ra WHERE ra.release_id=i.release_id AND ra.artist_id=:artist)"); params["artist"] = artist if label: where.append("EXISTS (SELECT 1 FROM disc_release_label rl WHERE rl.release_id=i.release_id AND rl.label_id=:label)"); params["label"] = label if year_min: where.append("dr.year >= :ymin"); params["ymin"] = year_min if year_max: where.append("dr.year <= :ymax"); params["ymax"] = year_max if price_min: where.append("i.price >= :pmin"); params["pmin"] = price_min if price_max: where.append("i.price <= :pmax"); params["pmax"] = price_max w = " AND ".join(where) order = {"new": "max(i.updated_at) DESC NULLS LAST", "price_asc": "min(i.price) ASC", "price_desc": "min(i.price) DESC", "year": "dr.year DESC NULLS LAST", "artist": "dr.artists_sort", "title": "min(coalesce(i.title, dr.title))"}.get(sort, "max(i.updated_at) DESC NULLS LAST") items = [dict(r) for r in (await db.execute(text(f""" SELECT i.release_id, coalesce(min(i.title), dr.title) AS title, dr.artists_sort AS artist, dr.year, dr.country, min(i.price)::float AS price, count(*) AS copies, (SELECT thumb FROM disc_cache dc WHERE dc.release_id=i.release_id) AS thumb, (SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=i.release_id ORDER BY ra.position LIMIT 1) AS artist_id, (SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre g WHERE g.release_id=i.release_id) AS genre FROM inventory i JOIN disc_release dr ON dr.id = i.release_id WHERE {w} GROUP BY i.release_id, dr.title, dr.artists_sort, dr.year, dr.country ORDER BY {order} LIMIT :per OFFSET :off"""), params)).mappings()] cp = {k: v for k, v in params.items() if k not in ("per", "off")} total = (await db.execute(text( f"SELECT count(DISTINCT i.release_id) FROM inventory i JOIN disc_release dr ON dr.id=i.release_id WHERE {w}" ), cp)).scalar() return {"items": items, "page": page, "per": per, "total": total} @router.get("/facets") async def shop_facets(db=Depends(get_db)): async def top(tbl, col): return [dict(r) for r in (await db.execute(text(f""" SELECT x.{col} AS name, count(DISTINCT i.release_id) AS n FROM {tbl} x JOIN inventory i ON i.release_id = x.release_id WHERE i.in_stock AND i.store_id=1 AND x.{col} IS NOT NULL AND x.{col} <> '' GROUP BY x.{col} ORDER BY n DESC LIMIT 24"""))).mappings()] return {"genres": await top("disc_release_genre", "genre_name"), "styles": await top("disc_release_style", "style_name"), "formats": await top("disc_release_format", "name")} @router.get("/release/{release_id}") async def shop_release(release_id: int, db=Depends(get_db)): """Public release detail — metadata + tracklist + in-stock copies (with the Woo buy link).""" dr = (await db.execute(text(""" SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.notes, dr.master_id, (SELECT thumb FROM disc_cache dc WHERE dc.release_id=dr.id) AS thumb, (SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=dr.id ORDER BY ra.position LIMIT 1) AS artist_id, (SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label, (SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format, (SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre, (SELECT string_agg(DISTINCT style_name, ', ') FROM disc_release_style WHERE release_id=dr.id) AS style FROM disc_release dr WHERE dr.id = :r"""), {"r": release_id})).mappings().first() tracks = [dict(r) for r in (await db.execute(text(""" SELECT position, title, duration FROM disc_release_track WHERE release_id=:r ORDER BY sequence NULLS LAST, position """), {"r": release_id})).mappings()] copies = [dict(r) for r in (await db.execute(text(""" SELECT i.sku, i.price::float AS price, i.condition, i.sleeve_cond, i.product_url, c.label_text AS crate, r.name AS rack FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id LEFT JOIN virtual_rack r ON r.id = c.rack_id WHERE i.release_id=:r AND i.in_stock AND i.store_id=1 ORDER BY i.price """), {"r": release_id})).mappings()] return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies} def _apple_id(raw): """apple_id is country-scoped {"":""} (some rows double-JSON-encoded). Prefer AU (this is an AU store), then US, then whatever country is present → a real music.apple.com link.""" if not raw: return None try: v = json.loads(raw) if isinstance(v, str): v = json.loads(v) except (ValueError, TypeError): return None if not isinstance(v, dict) or not v: return None cc = "au" if "au" in v else ("us" if "us" in v else next(iter(v))) aid = v.get(cc) return {"country": cc, "album_id": aid, "url": f"https://music.apple.com/{cc}/album/{aid}" if aid else None} @router.get("/audio/{release_id}") async def shop_audio(release_id: int, db=Depends(get_db)): """Playable / embeddable previews — Apple 30s preview + country-scoped Apple link, Beatport/Bandcamp, YouTube.""" a = (await db.execute(text(""" SELECT apple_id, apple_url, apple_preview_url, beatport_id, beatport_url, beatport_embed, beatport_genre, spotify_id, bandcamp_url, bandcamp_embed, soundcloud_url FROM disc_release_audio WHERE release_id = :r"""), {"r": release_id})).mappings().first() youtube = await _live_youtube(db, release_id) if not a: return {"apple": None, "apple_preview": None, "youtube": youtube} pv = a["apple_preview_url"] previews = [] if isinstance(pv, str) and pv.lstrip().startswith("["): try: previews = [u for u in json.loads(pv) if u] except (ValueError, TypeError): previews = [] elif pv: previews = [pv] return {"apple": _apple_id(a["apple_id"]), "apple_url": a["apple_url"], "apple_preview": previews[0] if previews else None, "apple_previews": previews, "beatport_url": a["beatport_url"], "beatport_embed": a["beatport_embed"], "beatport_id": a["beatport_id"], "bandcamp_url": a["bandcamp_url"], "bandcamp_embed": a["bandcamp_embed"], "soundcloud_url": a["soundcloud_url"], "spotify_id": a["spotify_id"], "youtube": youtube} async def _live_youtube(db, release_id): """Pick a NON-dead YouTube clip — validates unchecked ones via YouTube oembed (200=live) and caches `dead`.""" rows = [dict(r) for r in (await db.execute(text( "SELECT DISTINCT uri, dead FROM disc_release_video WHERE release_id=:r AND uri<>'' ORDER BY uri LIMIT 8" ), {"r": release_id})).mappings()] unchecked = [v for v in rows if v["dead"] is None][:4] if unchecked: async with httpx.AsyncClient(timeout=8) as c: async def chk(v): yid = _ytid(v["uri"]) if not yid: return v["uri"], True try: rr = await c.get("https://www.youtube.com/oembed", params={"url": "https://youtu.be/" + yid, "format": "json"}) return v["uri"], rr.status_code != 200 except Exception: return v["uri"], None # network blip — leave unchecked for next time checked = await asyncio.gather(*[chk(v) for v in unchecked]) wrote = False for uri, dead in checked: if dead is not None: await db.execute(text("UPDATE disc_release_video SET dead=:d, checked_at=now() " "WHERE release_id=:r AND uri=:u"), {"d": dead, "r": release_id, "u": uri}) for v in rows: if v["uri"] == uri: v["dead"] = dead wrote = True if wrote: await db.commit() for v in rows: if v["dead"] is False: yid = _ytid(v["uri"]) if yid: return yid return None @router.get("/audio-tracks/{release_id}") async def shop_audio_tracks(release_id: int, db=Depends(get_db)): """Per-track 30s previews — lazily resolved from each track's Apple song id via the iTunes lookup API, then cached.""" rows = [dict(r) for r in (await db.execute(text(""" SELECT sequence, position, title, apple_id, apple_preview FROM disc_release_track WHERE release_id=:r AND apple_id ~ '^[0-9]+$' ORDER BY sequence NULLS LAST, position"""), {"r": release_id})).mappings()] need = [t["apple_id"] for t in rows if not t["apple_preview"]] if need: found = {} try: async with httpx.AsyncClient(timeout=15) as c: rr = await c.get("https://itunes.apple.com/lookup", params={"id": ",".join(need[:190]), "country": "au", "entity": "song"}) found = {str(x.get("trackId")): x.get("previewUrl") for x in rr.json().get("results", []) if x.get("previewUrl")} except Exception: found = {} for aid, prev in found.items(): await db.execute(text("UPDATE disc_release_track SET apple_preview=:p WHERE release_id=:r AND apple_id=:a"), {"p": prev, "r": release_id, "a": aid}) if found: await db.commit() for t in rows: if not t["apple_preview"]: t["apple_preview"] = found.get(t["apple_id"]) return {"tracks": [{"position": t["position"], "title": t["title"], "preview": t["apple_preview"]} for t in rows if t["apple_preview"]]} class WantIn(BaseModel): email: str artist: str = "" title: str = "" name: str = "" phone: str = "" format: str = "" max_price: float | None = None delivery_preference: str = "either" postcode: str = "" notes: str = "" release_id: int | None = None @router.post("/wantlist") async def shop_wantlist(body: WantIn, db=Depends(get_db)): """Public 'request a record we don't stock' intake — no auth, keyed by email (guest or known customer).""" if "@" not in body.email or not (body.artist.strip() or body.title.strip()): raise HTTPException(422, "email and an artist or title are required") pref = body.delivery_preference if body.delivery_preference in ("pickup", "post", "either") else "either" rid = (await db.execute(text(""" INSERT INTO wantlist (release_id, artist, title, name, phone, email, format, max_price, delivery_preference, postcode, notes) VALUES (:release_id, :artist, :title, :name, :phone, :email, :format, :max_price, :pref, :postcode, :notes) RETURNING id"""), {**body.model_dump(), "pref": pref})).scalar() await db.commit() return {"ok": True, "id": rid} # ─── WordPress / Woo bridge ────────────────────────────────────────────────────────────────── # The thin RecordGod WP plugin renders the storefront from /shop/*, creates Woo products on the fly # at add-to-cart (via /shop/item), quotes postage (/shop/shipping/quote), and posts back completed # orders (/shop/woo-order, bridge-key gated) so RecordGod stays the single source of truth. @router.get("/item/{sku}") async def shop_item(sku: str, db=Depends(get_db)): """One inventory item by SKU — what the bridge needs to mint a Woo product on the fly.""" r = (await db.execute(text(""" SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb, i.price::float AS price, i.in_stock, i.condition, i.release_id FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id WHERE i.sku = :s AND i.store_id = 1"""), {"s": sku})).mappings().first() if not r: raise HTTPException(404, "item not found") return dict(r) @router.get("/shipping/quote") async def shop_shipping_quote(units: int | None = None, weight_g: int | None = None, db=Depends(get_db)): """Public AusPost quote for the WC shipping method (rates aren't secret).""" if weight_g is None: weight_g = max(1, units or 1) * 280 parcels = max(1, -(-weight_g // 5000)) per = -(-weight_g // parcels) rows = await db.execute(text( "SELECT service_key, price::float AS price FROM post_flat_rate " "WHERE :w BETWEEN weight_min_g AND weight_max_g"), {"w": per}) seen = {r["service_key"]: r["price"] for r in rows.mappings()} opts = [{"service": k, "label": k.replace("_", " ").title(), "price": round(v * parcels, 2)} for k, v in sorted(seen.items(), key=lambda x: x[1])] return {"weight_g": weight_g, "parcels": parcels, "options": opts} class WooOrderIn(BaseModel): order_number: str email: str | None = None name: str | None = None total: float = 0 items: list[dict] = [] # [{sku, name, qty, price}] @router.post("/woo-order") async def woo_order(body: WooOrderIn, x_bridge_key: str = Header(None), db=Depends(get_db)): """Record a completed Woo order back into RecordGod (mark sold + log the online sale). Bridge-key gated.""" key = await vault.get_secret(db, "bridge_key") if not key or x_bridge_key != key: raise HTTPException(401, "bad or missing bridge key") sn = "WC-" + str(body.order_number) if (await db.execute(text("SELECT 1 FROM sales WHERE sale_number = :n"), {"n": sn})).first(): return {"ok": True, "duplicate": True} cust = None if body.email: c = (await db.execute(text("SELECT id FROM customer WHERE lower(email)=lower(:e) ORDER BY id LIMIT 1"), {"e": body.email})).first() cust = c[0] if c else None sale_id = (await db.execute(text(""" INSERT INTO sales (sale_number, customer_id, subtotal, total, status, payment_method, payment_status, amount_paid, sale_date, created_at) VALUES (:sn, :cust, :tot, :tot, 'completed', 'woo', 'paid', :tot, now(), now()) RETURNING id"""), {"sn": sn, "cust": cust, "tot": body.total})).scalar() for it in body.items: await db.execute(text(""" INSERT INTO sale_items (sale_id, sku, item_name, qty, unit_price, line_total) VALUES (:s, :sku, :n, :q, :p, :lt)"""), {"s": sale_id, "sku": it.get("sku"), "n": it.get("name") or it.get("sku"), "q": it.get("qty", 1), "p": it.get("price", 0), "lt": float(it.get("price", 0)) * int(it.get("qty", 1))}) skus = [it["sku"] for it in body.items if it.get("sku")] if skus: await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now() " "WHERE sku = ANY(:s) AND store_id = 1"), {"s": skus}) await db.commit() return {"ok": True, "sale_id": sale_id, "sale_number": sn}