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

+40
View File
@@ -210,6 +210,43 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
.item-tags .badge { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-block; }
.item-year { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; flex: none; }
/* ---------- preview play buttons ---------- */
.item { position: relative; }
.play-btn {
position: absolute; top: 8px; right: 8px; z-index: 2;
display: grid; place-items: center; width: 36px; height: 36px; padding: 0;
border: 0; border-radius: 50%; cursor: pointer;
background: rgba(10, 10, 14, 0.72); color: #fff;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: background 0.15s, transform 0.15s;
}
.play-btn:hover { background: var(--accent); color: var(--accent-text); transform: scale(1.06); }
.play-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.play-btn .icon { width: 15px; height: 15px; fill: currentColor; stroke: none; }
.play-btn .icon:first-child { margin-left: 2px; }
.play-btn .icon:last-child, .play-btn.is-playing .icon:first-child { display: none; }
.play-btn.is-playing .icon:last-child { display: block; }
.play-btn.is-playing, .play-btn.is-loading { background: var(--accent); color: var(--accent-text); }
/* Progress ring around the button while a clip plays. */
.play-btn::before {
content: ""; position: absolute; inset: -3px; border-radius: 50%; pointer-events: none;
background: conic-gradient(var(--accent) calc(var(--p, 0) * 1%), transparent 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
opacity: 0; transition: opacity 0.2s;
}
.play-btn.is-playing::before { opacity: 1; }
.play-btn.is-loading .icon:first-child { animation: pulse 0.9s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.35; } }
.play-btn--inline, .play-btn--mini { position: relative; top: auto; right: auto; flex: none; box-shadow: none; }
.play-btn--inline { width: 32px; height: 32px; }
.play-btn--inline:not(.is-playing):not(.is-loading):not(:hover) { background: var(--surface-2); color: var(--text); }
.play-btn--mini { position: absolute; right: 3px; bottom: 3px; width: 24px; height: 24px; }
.play-btn--mini .icon { width: 11px; height: 11px; }
.result-art { position: relative; }
.result-source { display: inline-block; margin-top: 3px; font-size: 10.5px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
/* ---------- ranked items: rows ---------- */
.items--rows { display: flex; flex-direction: column; border-top: 1px solid var(--border); }
.items--rows .item { border-bottom: 1px solid var(--border); }
@@ -230,6 +267,9 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
.items--rows .item--top3 .item-rank { color: var(--bronze); }
.items--rows .item-body { order: 3; flex: 1; }
.items--rows .item-title { font-size: 16px; -webkit-line-clamp: 1; }
.items--rows .play-btn { top: 50%; right: 10px; transform: translateY(-50%); }
.items--rows .play-btn:hover { transform: translateY(-50%) scale(1.06); }
.items--rows .item:has(.play-btn) .item-btn { padding-right: 60px; }
.items--rows .item-note { color: var(--muted); font-size: 13.5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; }
@media (min-width: 900px) {
.page-home .items--rows { display: grid; grid-template-columns: 1fr 1fr; column-gap: 32px; border-top: 0; }
+2
View File
@@ -7,6 +7,8 @@
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol>
<symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol>
<symbol id="star" viewBox="0 0 24 24"><path d="M11.5 2.9a.5.5 0 0 1 1 0l2.3 4.7a2 2 0 0 0 1.5 1.1l5.2.8a.5.5 0 0 1 .3.9l-3.8 3.6a2 2 0 0 0-.6 1.8l.9 5.2a.5.5 0 0 1-.8.5l-4.6-2.4a2 2 0 0 0-1.9 0l-4.6 2.4a.5.5 0 0 1-.8-.5l.9-5.2a2 2 0 0 0-.6-1.8L2.3 10.4a.5.5 0 0 1 .3-.9l5.2-.8a2 2 0 0 0 1.5-1.1z"/></symbol>
<symbol id="play" viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.3-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></symbol>
<symbol id="pause" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></symbol>
<symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol>
<symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+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),