From 988e6437cb93d495d5bdc3b2459b6fbb9b7985bc Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 23 Jun 2026 23:44:17 +1000 Subject: [PATCH] feat(shop): per-track Apple previews (lazy iTunes) + jog mini-player + dead-YouTube check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /shop/audio-tracks/{id}: resolves each track's preview from its Apple song id via iTunes lookup (au), cached in disc_release_track.apple_preview. /shop/audio validates YouTube via oembed (200=live), caches dead in disc_release_video.dead, returns one live id. release.html + kiosk.html get a per-track ▶ on each tracklist row feeding a mini-player with a jog/seek bar (falls back to a single Listen). Co-Authored-By: Claude Opus 4.8 --- app/main.py | 4 +++ app/shop_routes.py | 90 +++++++++++++++++++++++++++++++++++++++++++--- audio_sync.py | 3 +- disc_sync.py | 2 +- site/kiosk.html | 57 +++++++++++++++++++++-------- site/release.html | 65 ++++++++++++++++++++++++--------- 6 files changed, 184 insertions(+), 37 deletions(-) diff --git a/app/main.py b/app/main.py index b2ff2ef..c246d38 100644 --- a/app/main.py +++ b/app/main.py @@ -76,6 +76,10 @@ _STARTUP_DDL = [ "ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_tendered numeric(10,2)", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS notes text", + # audio: lazily-fetched per-track Apple previews + YouTube dead-link cache (re-added here in case a sync ran) + "ALTER TABLE disc_release_track ADD COLUMN IF NOT EXISTS apple_preview text", + "ALTER TABLE disc_release_video ADD COLUMN IF NOT EXISTS dead boolean", + "ALTER TABLE disc_release_video ADD COLUMN IF NOT EXISTS checked_at timestamptz", "ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0", "ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)", # auto-id for new POS rows (migrated tables came without sequences) diff --git a/app/shop_routes.py b/app/shop_routes.py index cab5d4c..4bee46a 100644 --- a/app/shop_routes.py +++ b/app/shop_routes.py @@ -1,9 +1,23 @@ +import asyncio import json +import re +import httpx from fastapi import APIRouter, Depends, Query, HTTPException from pydantic import BaseModel from sqlalchemy import text +_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 @@ -172,11 +186,9 @@ async def shop_audio(release_id: int, db=Depends(get_db)): 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() - vids = [dict(r) for r in (await db.execute(text(""" - SELECT title, uri, duration FROM disc_release_video WHERE release_id = :r AND uri <> '' LIMIT 8 - """), {"r": release_id})).mappings()] + youtube = await _live_youtube(db, release_id) if not a: - return {"apple": None, "apple_preview": None, "videos": vids} + return {"apple": None, "apple_preview": None, "youtube": youtube} pv = a["apple_preview_url"] previews = [] if isinstance(pv, str) and pv.lstrip().startswith("["): @@ -191,7 +203,75 @@ async def shop_audio(release_id: int, db=Depends(get_db)): "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"], "videos": vids} + "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 id, uri, dead FROM disc_release_video WHERE release_id=:r AND uri<>'' ORDER BY id 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["id"], True + try: + rr = await c.get("https://www.youtube.com/oembed", + params={"url": "https://youtu.be/" + yid, "format": "json"}) + return v["id"], rr.status_code != 200 + except Exception: + return v["id"], None # network blip — leave unchecked for next time + checked = await asyncio.gather(*[chk(v) for v in unchecked]) + wrote = False + for vid, dead in checked: + if dead is not None: + await db.execute(text("UPDATE disc_release_video SET dead=:d, checked_at=now() WHERE id=:i"), + {"d": dead, "i": vid}) + for v in rows: + if v["id"] == vid: + 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): diff --git a/audio_sync.py b/audio_sync.py index 46c3acf..d290952 100644 --- a/audio_sync.py +++ b/audio_sync.py @@ -26,7 +26,8 @@ CREATE TABLE disc_release_audio ( spotify_id text, tidal_id text, deezer_id text, bandcamp_url text, bandcamp_embed text, soundcloud_url text, allmusic_url text, local_url text); CREATE TABLE disc_release_video ( - release_id bigint, track_id text, title text, uri text, embed int, duration int); + release_id bigint, track_id text, title text, uri text, embed int, duration int, + dead boolean, checked_at timestamptz); """ INDEXES = "CREATE INDEX ON disc_release_video (release_id);\nANALYZE disc_release_audio;\n" diff --git a/disc_sync.py b/disc_sync.py index 75844e2..82cc3cb 100644 --- a/disc_sync.py +++ b/disc_sync.py @@ -47,7 +47,7 @@ CREATE TABLE disc_release_identifier (release_id bigint, type text, value text); CREATE TABLE disc_release_artist (release_id bigint, artist_id bigint, artist_name text, role text, join_string text, position text); CREATE TABLE disc_master_genre (master_id bigint, genre_id int, genre_name text); CREATE TABLE disc_master_style (master_id bigint, style_id int, style_name text); -CREATE TABLE disc_release_track (release_id bigint, sequence int, position text, title text, duration text, track_id text, apple_id text, bpm numeric, musical_key text); +CREATE TABLE disc_release_track (release_id bigint, sequence int, position text, title text, duration text, track_id text, apple_id text, apple_preview text, bpm numeric, musical_key text); """ INDEXES = """ diff --git a/site/kiosk.html b/site/kiosk.html index dfffe44..19a3d28 100644 --- a/site/kiosk.html +++ b/site/kiosk.html @@ -64,6 +64,13 @@ #detail .tk{display:flex;justify-content:space-between;font-size:21px;padding:7px 0;border-bottom:1px solid #1c1c22;color:#d6d6de} #detail .tk .n{color:var(--mut);width:54px} #dListen{margin-bottom:16px} + #detail .tk .tkplay{width:40px;flex-shrink:0} + .pbtn{cursor:pointer;border:0;border-radius:50%;width:36px;height:36px;background:var(--primary);color:#11070c;font-size:16px} + .pbtn.on{background:var(--accent)} + #kplayer{position:fixed;left:0;right:0;bottom:0;display:none;align-items:center;gap:18px;background:var(--panel);border-top:1px solid var(--line);padding:18px 30px;z-index:60} + #kplayer #kTitle{min-width:160px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:24px;font-weight:600} + #kplayer input[type=range]{flex:1;accent-color:var(--primary);height:8px} + #kplayer .pp{width:64px;height:64px;border-radius:50%;border:0;background:var(--primary);color:#11070c;font-size:26px;cursor:pointer} .listenBtn{background:var(--primary);color:#11070c;border:0;border-radius:14px;padding:16px 30px;font:700 26px var(--font),system-ui;display:inline-flex;align-items:center;gap:10px;cursor:pointer} .listenBtn .muted{color:#5a1030;font-weight:500;font-size:18px} #dListen iframe{width:100%;height:240px;border:0;border-radius:14px} @@ -111,6 +118,15 @@ +
+ +
+ + 0:00 + +
+ + diff --git a/site/release.html b/site/release.html index 97f4b81..842b1ba 100644 --- a/site/release.html +++ b/site/release.html @@ -27,6 +27,12 @@ .sec{margin-top:26px}.sec h3{font-size:15px;border-bottom:1px solid var(--line);padding-bottom:6px} table{width:100%;border-collapse:collapse;font-size:14px}td{padding:6px 4px;border-bottom:1px solid var(--line)} .muted{color:var(--mut)}.desc{color:#c8c8d0;line-height:1.6;font-size:14px;white-space:pre-wrap} + td.play{width:34px;padding:4px 0} + .pbtn{cursor:pointer;border:0;border-radius:50%;width:30px;height:30px;background:var(--primary);color:#10070c;font-size:12px} + .pbtn.on{background:var(--accent)} + input[type=range]{accent-color:var(--primary);height:5px} + #player{position:fixed;left:0;right:0;bottom:0;display:none;align-items:center;gap:14px;background:var(--panel);border-top:1px solid var(--line);padding:12px 22px;z-index:50} + #player #pTitle{min-width:120px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600} @media(max-width:760px){.top{grid-template-columns:1fr}} @@ -36,6 +42,14 @@
loading…
+
+ +
+ + 0:00 + +
+