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:
parent
bab0826fea
commit
16c85184cd
@ -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}
|
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):
|
class WantIn(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
artist: str = ""
|
artist: str = ""
|
||||||
|
|||||||
78
audio_sync.py
Normal file
78
audio_sync.py
Normal 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()
|
||||||
@ -63,6 +63,10 @@
|
|||||||
#detail .tracks{flex:1;overflow-y:auto;border-top:1px solid var(--line);padding-top:14px}
|
#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{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}
|
#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%}
|
.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)}
|
.bigprice{font-size:46px;font-weight:800;color:var(--primary)}
|
||||||
</style>
|
</style>
|
||||||
@ -101,6 +105,7 @@
|
|||||||
<h1 id="dTitle"></h1>
|
<h1 id="dTitle"></h1>
|
||||||
<div class="artist" id="dArtist"></div>
|
<div class="artist" id="dArtist"></div>
|
||||||
<div class="meta" id="dMeta"></div>
|
<div class="meta" id="dMeta"></div>
|
||||||
|
<div id="dListen"></div>
|
||||||
<div class="locate" id="dLocate"></div>
|
<div class="locate" id="dLocate"></div>
|
||||||
<div class="tracks" id="dTracks"></div>
|
<div class="tracks" id="dTracks"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,7 +135,7 @@ async function buildWall(){
|
|||||||
|
|
||||||
/* idle: return to attract after 75s of no touch */
|
/* idle: return to attract after 75s of no touch */
|
||||||
function resetIdle(){ clearTimeout(IDLE); IDLE=setTimeout(goAttract, 75000); }
|
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 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'); }
|
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);
|
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'; } }
|
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';
|
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();
|
boot();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user