import asyncio
import html
import json
import logging
import re
import secrets
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, Query, HTTPException, Header
from fastapi.responses import HTMLResponse
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"])
log = logging.getLogger("recordgod")
async def _tracking_head(db) -> str:
"""Analytics/marketing snippets for the storefront
— Meta Pixel, Umami, GA4 (all opt-in via settings)."""
rows = await db.execute(text(
"SELECT setting_key, setting_value FROM sales_setting "
"WHERE setting_key IN ('meta_pixel_id','umami_website_id','umami_src','ga4_id') AND is_active"))
s = {r["setting_key"]: (r["setting_value"] or "").strip() for r in rows.mappings()}
out = []
if s.get("meta_pixel_id"):
pid = s["meta_pixel_id"]
out.append(""
f'')
if s.get("umami_website_id"):
src = s.get("umami_src") or "https://cloud.umami.is/script.js"
out.append(f'')
if s.get("ga4_id"):
g = s["ga4_id"]
out.append(f''
"")
return "".join(out)
@router.get("/page/{slug}", response_class=HTMLResponse)
async def shop_page(slug: str, db=Depends(get_db)):
"""Server-rendered content page (About/Shipping/Returns/…) — SEO-friendly HTML, the WP Pages public view."""
p = (await db.execute(text(
"SELECT title, body, seo_title, seo_description FROM page WHERE slug = :s AND published"),
{"s": slug})).mappings().first()
if not p:
raise HTTPException(404, "page not found")
name = (await db.execute(text("SELECT setting_value FROM sales_setting WHERE setting_key='store_name'"))).scalar() or "Store"
e = html.escape
title = p["seo_title"] or f'{p["title"]} · {name}'
desc = p["seo_description"] or ""
trk = await _tracking_head(db)
page = (f''
f''
f'{e(title)}'
f''
f''
f'{trk}
{p["body"]}')
return HTMLResponse(page)
@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("/local-previews/{release_id}")
async def shop_local_previews(release_id: int):
"""Local preview MP3s for the DECKS mixer — files in PREVIEWS_DIR named {release_id}.mp3
or {release_id}-