Search Deezer and MusicBrainz together; add preview clips

Search
- Albums and songs search Deezer and MusicBrainz at once and merge the
  results: duplicates keep the Deezer copy, best word matches first.
  Deezer's search misses albums it carries (Death Magnetic) and most game
  soundtracks; MusicBrainz finds them
- MusicBrainz secondary types are shown ("Soundtrack", "Live")
- Settings: Deezer + MusicBrainz (default), Deezer only, MusicBrainz only
- Search results show which service they came from

Previews
- Songs and albums get a play button (cards, rows, detail view, editor,
  and Deezer search results) for a 30-second Deezer clip; albums play
  their most popular track
- Clips are looked up fresh (Deezer URLs expire), cached 10 minutes, and
  streamed through Canon with Range support; hidden lists stay private
- Entries from MusicBrainz or added by hand are matched to Deezer on save
  so they can preview too
- "Play previews" toggle in Settings → Profile

Tests cover merging and title/artist matching; `-tags live` runs checks
against the real services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-25 10:10:15 -07:00
1 parent 6d92b46130
commit b09533cc00
20 files changed
+736 -19

No files matched your search

+12 -1
View File
@@ -2,6 +2,7 @@
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js";
import { previewButton } from "./player.js";
const boot = readBoot();
const { categories, limits, productions } = boot;
@@ -119,6 +120,7 @@ function row(it) {
),
),
h("div", { class: "rank-row-actions" },
canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
@@ -127,6 +129,11 @@ function row(it) {
);
}
// Songs and albums from Deezer, or matched to Deezer when saved, can play.
function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
}
function renderItems(flashId) {
if (!items.length) {
rankList.replaceChildren(h("li", { class: "rank-empty" },
@@ -259,6 +266,7 @@ function renderResults(res) {
const full = isFull();
const nodes = res.map((r) => {
const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · ");
const source = { deezer: "Deezer", musicbrainz: "MusicBrainz" }[r.provider];
const action = r.existingRank
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
: h("button", {
@@ -266,11 +274,14 @@ function renderResults(res) {
title: full ? "This list is full" : `Add to #${list.itemCount + 1}`,
onclick: (e) => addResult(r, e.currentTarget),
}, icon("plus"), "Add");
const art = artBox(r.imageUrl, "");
if (r.previewRef) art.append(previewButton(`/admin/preview?ref=${encodeURIComponent(r.previewRef)}`, `Play a preview of ${r.title}`, "play-btn play-btn--mini"));
return h("div", { class: "result", "data-key": resultKey(r) },
artBox(r.imageUrl, ""),
art,
h("div", { style: { minWidth: 0 } },
h("div", { class: "result-title" }, r.title),
sub && h("div", { class: "result-sub" }, sub),
source && h("span", { class: "result-source" }, source),
(r.overview || r.hint) && h("div", { class: "result-overview" }, r.overview || r.hint),
),
action,
+5 -3
View File
@@ -77,6 +77,7 @@ function profileSection() {
const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" });
const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } });
const notes = h("input", { type: "checkbox", checked: settings.showNotes });
const previews = h("input", { type: "checkbox", checked: settings.showPreviews });
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save profile");
const form = h("form", { class: "stack" },
@@ -85,13 +86,14 @@ function profileSection() {
field("Tagline", tagline),
field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."),
h("label", { class: "check" }, notes, "Show why each entry is ranked where it is"),
h("label", { class: "check" }, previews, "Play 30-second previews for songs and albums (from Deezer)"),
h("div", { class: "settings-actions" }, save),
);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const ok = await saveSettings({
ownerName: ownerName.value, siteTitle: siteTitle.value, tagline: tagline.value,
previewCount: Number(preview.value), showNotes: notes.checked,
previewCount: Number(preview.value), showNotes: notes.checked, showPreviews: previews.checked,
}, save, "Profile saved");
if (ok) {
renderAvatar();
@@ -304,8 +306,8 @@ function integrationsSection() {
}),
integration({
title: "Music · Songs & Albums", iconName: "disc", category: "album",
desc: "No key needed. Deezer has the best cover art and fast search; MusicBrainz is fully open data but slower (1 request/second).",
extra: [providerChoice("musicProvider", [["deezer", "Deezer (recommended)"], ["musicbrainz", "MusicBrainz + Cover Art Archive"]])],
desc: "No key needed. Searching both finds the most: Deezer has the best art and preview clips, and MusicBrainz covers albums Deezer's search misses plus game and film soundtracks.",
extra: [providerChoice("musicProvider", [["both", "Deezer + MusicBrainz (recommended)"], ["deezer", "Deezer only"], ["musicbrainz", "MusicBrainz only"]])],
ready: () => true,
}),
integration({
+87
View File
@@ -0,0 +1,87 @@
// One shared <audio> for 30-second previews. Any element with
// data-preview-src toggles playback; every button for the same clip shows
// the same state (a card and its detail view, for example).
import { icon, toast } from "./common.js";
const audio = new Audio();
audio.preload = "none";
let currentSrc = null;
function buttons(src) {
return [...document.querySelectorAll("[data-preview-src]")].filter((b) => b.dataset.previewSrc === src);
}
function paintButton(b, state, progress = 0) {
b.classList.toggle("is-playing", state === "playing");
b.classList.toggle("is-loading", state === "loading");
b.setAttribute("aria-pressed", String(state === "playing" || state === "loading"));
b.style.setProperty("--p", String(progress));
}
function paint(src, state, progress = 0) {
for (const b of buttons(src)) paintButton(b, state, progress);
}
function stop() {
if (!currentSrc) return;
audio.pause();
paint(currentSrc, "idle");
currentSrc = null;
}
export function togglePreview(src) {
if (currentSrc === src) {
if (audio.paused) {
audio.play();
} else {
stop();
}
return;
}
stop();
currentSrc = src;
paint(src, "loading");
audio.src = src;
audio.play().catch(() => { /* reported by the error handler */ });
}
audio.addEventListener("playing", () => currentSrc && paint(currentSrc, "playing", progress()));
audio.addEventListener("timeupdate", () => currentSrc && paint(currentSrc, audio.paused ? "idle" : "playing", progress()));
audio.addEventListener("ended", stop);
audio.addEventListener("error", () => {
if (!currentSrc) return;
for (const b of buttons(currentSrc)) {
b.disabled = true;
b.title = "No preview available";
}
paint(currentSrc, "idle");
currentSrc = null;
toast("No preview available for this one.", { error: true });
});
function progress() {
return audio.duration ? Math.round((audio.currentTime / audio.duration) * 100) : 0;
}
/** A round play/pause button for a clip. */
export function previewButton(src, label, className = "play-btn") {
const b = document.createElement("button");
b.type = "button";
b.className = className;
b.dataset.previewSrc = src;
b.setAttribute("aria-label", label);
b.setAttribute("aria-pressed", "false");
b.title = "Play 30-second preview";
b.append(icon("play"), icon("pause"));
// Not in the page yet, so paint this button directly.
if (src === currentSrc) paintButton(b, audio.paused ? "loading" : "playing", progress());
return b;
}
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-preview-src]");
if (!b || b.disabled) return;
e.preventDefault();
e.stopPropagation();
togglePreview(b.dataset.previewSrc);
});
+2
View File
@@ -1,5 +1,6 @@
// Public pages: item detail dialog and grid/list toggle.
import { h, icon, openDialog, storageGet, storageSet } from "./common.js";
import { previewButton } from "./player.js";
// Each ranked list on the page ships its items as JSON next to the markup.
const lists = [...document.querySelectorAll("[data-items]")].map((ol) => {
@@ -45,6 +46,7 @@ function renderDialog(list, idx) {
h("h3", {}, "About"),
h("p", { class: "dlg-desc" }, it.description)),
h("div", { class: "dlg-actions" },
it.preview && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline"),
it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" },
icon("external"), it.source ? `View on ${it.source}` : "Learn more"),
h("div", { class: "dlg-nav" }, prev, next),