feat(shop): country-scoped Apple ids in audio — AU-preferred music.apple.com link + clean preview

audio_sync mirrors apple_id ({cc:album_id}) + apple_song_id (kept raw, country preserved).
/shop/audio parses it: AU>US>first → music.apple.com/{cc}/album/{id}, per-track preview array
→ first clean 30s url. Kiosk Listen uses apple_preview + an Apple Music ↗ link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 23:17:06 +10:00
parent 7e9d5028b0
commit aa56e874ad
3 changed files with 54 additions and 21 deletions

View File

@ -146,24 +146,52 @@ async def shop_release(release_id: int, db=Depends(get_db)):
return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies}
def _apple_id(raw):
"""apple_id is country-scoped {"<cc>":"<album_id>"} (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 for a release — Apple 30s preview, Beatport/Bandcamp embeds, YouTube."""
"""Playable / embeddable previews — Apple 30s preview + country-scoped Apple link, Beatport/Bandcamp, 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
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()
audio = dict(a) if a else None
if audio and isinstance(audio.get("apple_preview_url"), str) and audio["apple_preview_url"].lstrip().startswith("["):
try:
arr = json.loads(audio["apple_preview_url"])
audio["apple_preview_url"] = next((u for u in arr if u), None)
except (ValueError, TypeError):
audio["apple_preview_url"] = None
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": audio, "videos": vids}
if not a:
return {"apple": None, "apple_preview": None, "videos": vids}
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"], "videos": vids}
class WantIn(BaseModel):

View File

@ -21,7 +21,7 @@ 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,
release_id bigint PRIMARY KEY, apple_id text, apple_song_id text, 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);
@ -30,9 +30,11 @@ CREATE TABLE disc_release_video (
"""
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"]
# apple_id = country-scoped {"<cc>": "<album_id>"} (essential: country + the id; single-vs-album = the
# Discogs release itself, via format). apple_song_id = song-level. Both kept RAW so nothing's lost.
A_COLS = ["release_id", "apple_id", "apple_song_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"]

View File

@ -189,14 +189,17 @@ async function openDetail(rid){
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>`;
const d=await fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), v=d.videos||[];
let html='';
if(d.apple_preview){
html=`<button class="listenBtn" id="lbtn" onclick="togglePreview('${esc(d.apple_preview)}')">▶ 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;
if(yt) html=`<button class="listenBtn" onclick="playYT('${yt}')">▶ Watch / listen</button>`;
else if(d.beatport_embed) html=d.beatport_embed;
else if(d.bandcamp_embed) html=d.bandcamp_embed;
}
if(d.apple && d.apple.url) html+=` <a href="${esc(d.apple.url)}" target="_blank" style="color:var(--mut);font-size:18px;margin-left:14px"> Apple Music ↗</a>`;
z.innerHTML=html;
}
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;