feat(shop): wire audio/video previews — kiosk Listen (Apple 30s / YouTube / Beatport)

audio_sync.py mirrors WowPlatter disc_release_audio (22.5k; apple_preview_url, beatport_embed,
bandcamp…) + disc_release_video (231k YouTube) into Postgres. /shop/audio/{id} returns playable
sources. Kiosk detail gets a ▶ Listen: Apple 30s preview (instant <audio>), else YouTube embed,
else Beatport/Bandcamp embed; stops on close/idle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 23:10:02 +10:00
parent bab0826fea
commit 16c85184cd
3 changed files with 116 additions and 2 deletions

View File

@ -146,6 +146,19 @@ async def shop_release(release_id: int, db=Depends(get_db)):
return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies}
@router.get("/audio/{release_id}")
async def shop_audio(release_id: int, db=Depends(get_db)):
"""Playable / embeddable previews for a release — Apple 30s preview, Beatport/Bandcamp embeds, YouTube."""
a = (await db.execute(text("""
SELECT apple_preview_url, apple_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()]
return {"audio": dict(a) if a else None, "videos": vids}
class WantIn(BaseModel):
email: str
artist: str = ""

78
audio_sync.py Normal file
View File

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Mirror WowPlatter's audio/video link tables → RecordGod Postgres (for kiosk/storefront previews).
python3 audio_sync.py | ssh -C root@100.94.195.115 \
'docker exec -i recordgod-db psql -U recordgod -d recordgod -q -v ON_ERROR_STOP=1'
disc_release_audio = one wide row per release (apple_preview_url = a direct 30s preview, beatport_embed,
bandcamp, spotify); disc_release_video = YouTube clips per release/track. All rows mirrored the JOIN to
disc_release naturally filters to what the shop stocks. Re-run after a fresh Beatport/Apple enrich.
"""
import re
import pathlib
import sys
import pymysql
WP_CONFIG = "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php"
SOCK = "/opt/homebrew/var/mysql/mysql.sock"
SCHEMA = """
SET synchronous_commit = off;
DROP TABLE IF EXISTS disc_release_audio, disc_release_video CASCADE;
CREATE TABLE disc_release_audio (
release_id bigint PRIMARY KEY, apple_url text, apple_preview_url text,
beatport_id text, beatport_url text, beatport_embed text, beatport_genre text,
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);
"""
INDEXES = "CREATE INDEX ON disc_release_video (release_id);\nANALYZE disc_release_audio;\n"
A_COLS = ["release_id", "apple_url", "apple_preview_url", "beatport_id", "beatport_url", "beatport_embed",
"beatport_genre", "spotify_id", "tidal_id", "deezer_id", "bandcamp_url", "bandcamp_embed",
"soundcloud_url", "allmusic_url", "local_url"]
V_COLS = ["release_id", "track_id", "title", "uri", "embed", "duration"]
def 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 cp(v):
if v is None or v == "":
return r"\N"
return (str(v).replace("\\", "\\\\").replace("\t", "\\t")
.replace("\n", "\\n").replace("\r", "\\r"))
def copy_block(out, table, cols, rows):
out.write(f"COPY {table} ({','.join(cols)}) FROM stdin;\n")
for r in rows:
out.write("\t".join(cp(r[c]) for c in cols) + "\n")
out.write("\\.\n\n")
print(f" {table:22} {len(rows):>7,}", file=sys.stderr)
def main():
name, user, pw = creds()
my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name,
cursorclass=pymysql.cursors.DictCursor)
c = my.cursor()
P = "wp_rmp_disc_"
out = sys.stdout
out.write(SCHEMA)
print("emitting audio/video:", file=sys.stderr)
c.execute(f"SELECT {','.join(A_COLS)} FROM {P}release_audio")
copy_block(out, "disc_release_audio", A_COLS, c.fetchall())
c.execute(f"SELECT {','.join(V_COLS)} FROM {P}release_video WHERE uri IS NOT NULL AND uri <> ''")
copy_block(out, "disc_release_video", V_COLS, c.fetchall())
out.write(INDEXES)
print("done", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@ -63,6 +63,10 @@
#detail .tracks{flex:1;overflow-y:auto;border-top:1px solid var(--line);padding-top:14px}
#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}
.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}
.closeX{position:absolute;top:30px;right:36px;font-size:40px;color:var(--mut);z-index:40;width:70px;height:70px;display:flex;align-items:center;justify-content:center;background:var(--panel);border-radius:50%}
.bigprice{font-size:46px;font-weight:800;color:var(--primary)}
</style>
@ -101,6 +105,7 @@
<h1 id="dTitle"></h1>
<div class="artist" id="dArtist"></div>
<div class="meta" id="dMeta"></div>
<div id="dListen"></div>
<div class="locate" id="dLocate"></div>
<div class="tracks" id="dTracks"></div>
</div>
@ -130,7 +135,7 @@ async function buildWall(){
/* idle: return to attract after 75s of no touch */
function resetIdle(){ clearTimeout(IDLE); IDLE=setTimeout(goAttract, 75000); }
function goAttract(){ clearTimeout(IDLE); show('attract'); $('#q').value=''; }
function goAttract(){ clearTimeout(IDLE); stopAudio(); show('attract'); $('#q').value=''; }
function enterBrowse(){ show('browse'); resetIdle(); load(''); setTimeout(()=>$('#q').focus(),100); }
function show(id){ document.querySelectorAll('.screen').forEach(s=>s.classList.remove('on')); $('#'+id).classList.add('on'); }
@ -179,8 +184,26 @@ async function openDetail(rid){
if(c0 && c0.product_url){ qb.style.display='flex'; $('#dQrPrice').textContent=money(c0.price);
try{ new QRCode(qel,{text:c0.product_url,width:120,height:120,correctLevel:QRCode.CorrectLevel.M}); }catch(e){ qb.style.display='none'; } }
else qb.style.display='none';
loadAudio(r.id);
}
function closeDetail(){ show('browse'); resetIdle(); }
function ytId(uri){ const m=String(uri||'').match(/(?:v=|youtu\.be\/|embed\/)([\w-]{11})/); return m?m[1]:(/^[\w-]{11}$/.test(uri||'')?uri:null); }
async function loadAudio(rid){
const z=$('#dListen'); z.innerHTML='';
const d=await fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), a=d.audio||{}, v=d.videos||[];
if(a.apple_preview_url){
z.innerHTML=`<button class="listenBtn" id="lbtn" onclick="togglePreview('${esc(a.apple_preview_url)}')">▶ Listen <span class="muted">· 30s preview</span></button><audio id="kAudio" onended="resetListenBtn()"></audio>`;
} else { const yt=v.map(x=>ytId(x.uri)).find(Boolean);
if(yt) z.innerHTML=`<button class="listenBtn" onclick="playYT('${yt}')">▶ Watch / listen</button>`;
else if(a.beatport_embed) z.innerHTML=a.beatport_embed;
else if(a.bandcamp_embed) z.innerHTML=a.bandcamp_embed;
}
}
function resetListenBtn(){ const b=$('#lbtn'); if(b) b.innerHTML='▶ Listen <span class="muted">· 30s preview</span>'; }
function togglePreview(url){ const au=$('#kAudio'), b=$('#lbtn'); if(!au) return;
if(au.paused){ if(au.src!==url) au.src=url; au.play(); b.innerHTML='⏸ Pause'; } else { au.pause(); resetListenBtn(); } }
function playYT(id){ $('#dListen').innerHTML=`<iframe src="https://www.youtube.com/embed/${id}?autoplay=1&rel=0" allow="autoplay" allowfullscreen></iframe>`; }
function stopAudio(){ const a=$('#kAudio'); if(a) a.pause(); document.querySelectorAll('#dListen iframe').forEach(f=>f.remove()); }
function closeDetail(){ stopAudio(); show('browse'); resetIdle(); }
boot();
</script>