opens natively (both excluded).
//
// This used to live inside updateRow, which meant a row only became clickable
// once its scan had REPORTED. While a scan is running — or after ↻ reset the
// rows — Discogs and Bandcamp sit there as full rows (they never fold into the
// compact strip) with no handler at all, so clicking them did nothing, even
// though the compact icons beside them were already clickable. Wiring is now
// its own function, called as soon as the search-fallback URLs exist, so a
// pending row behaves exactly like a compact icon: click → provider search.
//
// The destination is read from the anchor at click time rather than captured,
// so the same wiring serves a pending row and a resolved one: updateRow points
// the anchor at the found URL, and anything that isn't the stashed search URL
// counts as "found".
function wireRowOpen(p) {
const row = document.getElementById(`row-${p}`);
const a = document.getElementById(`mb-online-${p}`);
if (!row || !a) return;
const open = (e, preferSearch) => {
if (e.target.closest('.pc-cell-ico') || e.target.closest('a')) return;
const search = a.dataset.searchUrl || null;
const href = a.getAttribute('href');
const found = href && href !== search && /^https?:\/\//.test(href) ? href : null;
const dest = preferSearch ? (search || found) : (found || search);
if (dest) { e.preventDefault(); window.open(dest, '_blank', 'noopener'); }
};
row.onclick = (e) => open(e, false);
row.oncontextmenu = (e) => open(e, true);
// Only claim to be clickable when there IS somewhere to go — before the
// search fallbacks are seeded there isn't.
const href = a.getAttribute('href');
row.style.cursor = (a.dataset.searchUrl || (href && /^https?:\/\//.test(href))) ? 'pointer' : '';
}
// #641: middle click (button 1) runs `fn`, or nothing when fn is null. The mousedown is
// swallowed, so the browser doesn't start autoscrolling instead. #653: so does Alt+click, for a
// laptop's touchpad that has no middle button; it is caught before the element's own click and
// the row's, which would add normally or open the page. #641 (majkinetor): "I miss the background
// option with middle click. Lets use CTRL middle click … ctrl + alt + click forces to background":
// with Ctrl (⌘ on a Mac) held, `fn(true)` adds in the background.
const pcBgKey = e => !!(e.ctrlKey || e.metaKey);
function pcWireForce(el, fn) {
el._pcForce = fn || null;
el.onmousedown = fn ? (e) => { if (e.button === 1) e.preventDefault(); } : null;
el.onauxclick = fn ? (e) => { if (e.button !== 1) return; e.preventDefault(); fn(pcBgKey(e)); } : null;
if (!el._pcForceAlt) {
el._pcForceAlt = true;
el.addEventListener('click', (e) => {
if (!e.altKey || !el._pcForce) return;
e.preventDefault(); e.stopImmediatePropagation();
el._pcForce(pcBgKey(e));
}, true);
}
}
function updateRow(p, { url, mbTracks, remoteTracks, year, label, source, fromCache, format, masterState, hiddenTracks, barcode }) {
const a = document.getElementById(`mb-online-${p}`);
const ico = document.getElementById(`ico-${p}`);
const val = document.getElementById(`val-${p}`);
if (url) a.href = url;
const fromMbRels = source === 'MB rels';
// Barcode mismatch (#182): MB has a barcode, the found item exposes one, and
// they differ → a different release per MB guidelines. Surfaced as a subtle
// left bar (CSS) + tooltip/log only; the barcode itself is never shown in the
// dashboard. Providers whose API hides the barcode (Apple, Bandcamp) pass none.
const bcDiff = !!(url && MB_BARCODE && barcode && normBarcode(barcode) !== normBarcode(MB_BARCODE));
// Format incompatibility (#182) — only marked when the option is on, since
// "digital link on a physical release" is common enough to be noise otherwise.
const fmtDiff = !!(url && GM_getValue('pc:respect-format', true) && formatMismatch(p, format));
// Source-on-hover: tooltip on the provider name. "via MB rels", "via
// Wikidata", "via API search · cached", etc. Replaces the visible badge
// that used to live next to the track count.
if (url) {
const parts = [];
if (source) parts.push(`via ${source}`);
if (fromCache) parts.push('cached');
a.title = parts.length ? `${PROVIDER_NAME[p]} URL — ${parts.join(' · ')}` : '';
} else {
a.title = `No ${PROVIDER_NAME[p]} URL found`;
}
// val is the platform's track count as a bare number, coloured by match
// against the MB-side number (shown in the header). No more "(N/M trks)".
if (remoteTracks != null) {
// Bandcamp hidden download-only tracks (#183): mark the count with a "ⁿ"
// superscript + tooltip so the editor knows N of the tracks aren't
// streamable (the count itself already includes them).
val.textContent = String(remoteTracks) + (hiddenTracks > 0 ? 'ⁿ' : '');
const hiddenNote = hiddenTracks > 0 ? `\n${hiddenTracks} download-only track(s) hidden from streaming on Bandcamp` : '';
if (parseInt(remoteTracks, 10) === parseInt(mbTracks, 10)) {
ico.textContent = '✓';
const tone = fromCache ? '#5B82B0' : '#008000';
ico.style.color = tone;
val.style.color = tone;
val.title = `${remoteTracks} tracks, matches MB — ${fromCache ? 'blue: from the local cache' : 'green: fetched live this scan'}` + hiddenNote;
} else {
ico.textContent = '~'; ico.style.color = 'var(--mbu-warn)';
val.style.color = 'var(--mbu-warn)';
val.title = `${remoteTracks} tracks, MB has ${mbTracks}${fromCache ? ' (from the local cache)' : ''}` + hiddenNote;
}
} else if (url) {
ico.textContent = '?'; ico.style.color = 'var(--mbu-text-weak)';
val.textContent = '?'; val.style.color = 'var(--mbu-text-weak)';
val.title = 'Track count could not be read';
} else {
ico.textContent = '×'; ico.style.color = 'var(--mbu-error)';
val.textContent = '—'; val.style.color = 'var(--mbu-error)';
val.title = `No ${PROVIDER_NAME[p]} release found`;
}
// Circle MB-rels rows regardless of glyph — circle says "URL is in MB".
ico.classList.toggle('pc-ico-circled', fromMbRels);
// Withheld by barcode/format confidence (#182): present it like a mismatch
// (grayed, not clickable) instead of a ✓ that silently does nothing on click.
const blocked = !!(url && !fromMbRels && (barcodeBlocks(p) || formatBlocks(p)));
// Click-to-add on the main icon for verified ✓ + not-already-in-MB (and not withheld).
const canAdd = url && ico.textContent === '✓' && !fromMbRels && !blocked;
// #641: middle click adds a confirmed (✓) link anyway — over a strict barcode/format
// withholding, which sometimes keeps back a legitimate find — in the foreground
const canForce = !!(url && ico.textContent === '✓' && !fromMbRels);
const forceTip = blocked ? ' · middle-click or Alt+click: add it anyway (with Ctrl: in the background)' : ' · middle-click or Alt+click: add it even if strict settings would withhold it (with Ctrl: in the background)';
ico.style.cursor = canAdd || (blocked && canForce) ? 'pointer' : '';
ico.title = canAdd ? `Click to add ${PROVIDER_NAME[p]} URL to MB · right-click: add it silently in the background${forceTip}` : (blocked ? `Withheld from + / ↗ — barcode/format confidence is on (see the coloured bar)${canForce ? forceTip : ''}` : '');
ico.onclick = canAdd ? () => addSingleUrl(p) : null;
ico.oncontextmenu = canAdd ? (e) => { e.preventDefault(); addSingleUrl(p, true); } : null;
pcWireForce(ico, canForce ? (bg) => addSingleUrl(p, bg, true) : null);
// Icons-mode encoding — TWO INDEPENDENT dimensions:
// presence (pc-st-*) drives the icon fade + name colour: match = full · mismatch = gray · notfound = faint
// in-MB (pc-inmb) draws the marker (circle/glow) — orthogonal, so an in-MB row still grays on a mismatch
const row = document.getElementById(`row-${p}`);
if (row) {
const g = ico.textContent; // '✓' match · '~' mismatch · '?' found-no-count · '×' not found
// a withheld (barcode/format-blocked) link reads as a mismatch, not a clean match (#182)
const presence = blocked ? 'mismatch' : g === '×' ? 'notfound' : g === '~' ? 'mismatch' : 'match';
row.classList.remove('pc-st-notfound', 'pc-st-mismatch', 'pc-st-match');
row.classList.add('pc-st-' + presence);
row.classList.toggle('pc-blocked', blocked);
row.classList.toggle('pc-inmb', fromMbRels);
// subtle left bar when the found release's barcode differs from MB's, or
// (when the format option is on) its format is incompatible with MB's (#182)
row.classList.toggle('pc-barcode-diff', bcDiff);
row.dataset.barcodeDiff = bcDiff ? '1' : '';
row.classList.toggle('pc-format-diff', fmtDiff);
const diffTips = [];
if (bcDiff) diffTips.push(`Different barcode than MB (MB ${MB_BARCODE} · ${PROVIDER_NAME[p]} ${barcode}) — likely a different release`);
if (fmtDiff) diffTips.push(`Format incompatible with MB release (MB ${MB_FORMAT} · ${PROVIDER_NAME[p]} ${normalizeFormat(format) || 'Digital'}) — likely a different release`);
// strict-mode withholding with no known mismatch (barcode/format couldn't be confirmed)
if (blocked && !diffTips.length) diffTips.push(`Withheld from + / ↗ — couldn't confirm barcode/format (strict mode)`);
if (diffTips.length) row.title = diffTips.join(' · ');
else if (row.title && /Different barcode|Format incompatible|Withheld from/.test(row.title)) row.title = '';
wireRowOpen(p); // #173 — see below; re-wired here so the cursor tracks the new state
}
const plat = document.getElementById(`plat-${p}`);
if (plat) {
plat.style.cursor = canAdd || (blocked && canForce) ? 'pointer' : 'default';
plat.onclick = canAdd ? () => addSingleUrl(p) : null; // click-to-add works on the brand icon too
plat.oncontextmenu = canAdd ? (e) => { e.preventDefault(); addSingleUrl(p, true); } : null;
pcWireForce(plat, canForce ? (bg) => addSingleUrl(p, bg, true) : null);
plat.title = canAdd ? `Click to add ${PROVIDER_NAME[p]} URL to MB · right-click: add it silently in the background${forceTip}` : (url ? a.title + (blocked && canForce ? forceTip : '') : `No ${PROVIDER_NAME[p]} URL found`);
}
// Discogs gets a master state in the left slot. Other platforms have an
// empty slot of the same width so the rows still align vertically.
const masterEl = document.getElementById(`master-${p}`);
if (masterEl) {
if (p === 'discogs' && masterState) {
applyMasterIcon(masterEl, masterState);
} else {
masterEl.innerHTML = '';
masterEl.onclick = null;
masterEl.style.cursor = 'default';
masterEl.title = '';
masterEl.classList.remove('pc-ico-circled');
}
}
// Meta cells: year · format · label, each its own grid cell so the columns
// align across providers (1-row layout) — see setMetaCells.
// re-stripped here too, so a label cached before a stripCopyright fix shows clean (#644)
setMetaCells(`year-${p}`, `format-${p}`, `label-${p}`, year, format, label ? stripCopyright(label) : label);
refreshCompactStrip(); // #355
}
// #355: rebuild the "compact unmatched providers" strip from the rows' current state.
// Model: everything starts COMPACT (a strip of dimmed icons) and each provider RISES
// into a full row once it MATCHES — so pending + not-found stay in the strip, only a
// found row is shown in full. Discogs and Bandcamp always keep their full rows;
// disabled providers (prov_ off) are skipped. A row that leaves the strip gets a
// subtle rise animation so the panel doesn't jump as results stream in. Cheap enough
// to run after every updateRow.
function refreshCompactStrip() {
const strip = document.getElementById('pc-compact-strip');
if (!strip) return;
const unmatched = container.classList.contains('pc-compact-unmatched');
const lowConf = container.classList.contains('pc-compact-nonstrict'); // #653
strip.textContent = '';
if (unmatched || lowConf) PROVIDER_ORDER.forEach(p => {
if (p === 'discogs' || p === 'bandcamp') return;
const row = document.getElementById(`row-${p}`);
if (!row) return;
// #653: two options. Unmatched: pending and not-found rows stay in the strip.
// Low-confidence: a found-but-mismatched (wrong barcode/format, or withheld) row too;
// with it off that row keeps its full row, so its tracks/year/label show. A clean
// match always rises into a full row, and so does a link that's already IN MB
// (pc-inmb) — you added it, so show it.
const isMismatch = row.classList.contains('pc-st-mismatch');
const isMatch = row.classList.contains('pc-st-match');
const foldable = isMismatch ? lowConf : !isMatch && unmatched;
const compact = foldable && !row.classList.contains('pc-inmb') && GM_getValue(`pc:prov_${p}`, true);
const was = row.classList.contains('pc-compacted');
row.classList.toggle('pc-compacted', compact);
if (!compact) {
if (was) { row.classList.remove('pc-rise'); void row.offsetWidth; row.classList.add('pc-rise'); } // rose out on a match
return;
}
const a = document.getElementById(`mb-online-${p}`);
// a mismatch (found but wrong release) keeps a subtle amber ring so the
// "found but wrong" signal isn't lost when it's folded into the strip.
const mismatch = isMismatch;
const ico = document.createElement('span');
ico.className = 'pc-compact-ico' + (mismatch ? ' pc-compact-mismatch' : '');
// #641: a withheld match (✓, folded into the strip as a mismatch) takes the same
// middle click as its full row's icon: add it anyway, in the foreground
const canForce = !!(a && /^https?:\/\//.test(a.getAttribute('href') || '') && document.getElementById(`ico-${p}`)?.textContent.trim() === '✓' && !row.classList.contains('pc-inmb'));
ico.title = `${PROVIDER_NAME[p]} — ${mismatch ? 'found but a different release · click to open it' : 'click to search'}${canForce ? ' · middle-click or Alt+click: add it anyway (with Ctrl: in the background)' : ''}`;
pcWireForce(ico, canForce ? (bg) => addSingleUrl(p, bg, true) : null);
ico.innerHTML = stIcon(p, 16);
ico.addEventListener('click', () => {
// behave exactly like clicking the (uncompacted) row: open what was FOUND
// when there is one (a mismatch has a found URL), else the provider search.
const href = a && a.getAttribute('href');
const found = href && /^https?:\/\//.test(href) ? href : null;
const dest = found || (a && a.dataset.searchUrl) || null;
if (dest) window.open(dest, '_blank', 'noopener');
else row.click(); // last-resort fallback (e.g. a pending row)
});
strip.appendChild(ico);
});
else PROVIDER_ORDER.forEach(p => { const r = document.getElementById(`row-${p}`); if (r) r.classList.remove('pc-compacted'); });
strip.classList.toggle('pc-has-icons', strip.children.length > 0);
}
// Fill the three meta cells (year / format / label) for a row. The "·"
// separators TRAIL year and format, but only when a later field follows — so
// the same cell content reads correctly in both layouts: aligned columns in
// 1-row ("2026 · | Digital · | Selections Lab"), and a joined line in 2-row
// ("2026 · Digital · Selections Lab"). Format is normalized (Digital Media →
// Digital); the full label is set as a tooltip so a truncated label (CSS
// ellipsis) is still readable on hover.
function setMetaCells(yearId, formatId, labelId, year, format, label) {
const yEl = document.getElementById(yearId);
const fEl = document.getElementById(formatId);
const lEl = document.getElementById(labelId);
const fmt = format ? normalizeFormat(format) : '';
const hasY = !!year, hasF = !!fmt, hasL = !!label;
// 2-digit year to save space in the compact row (#173); full year in tooltip.
const y2 = hasY ? String(year).slice(-2) : '';
if (yEl) { yEl.textContent = hasY ? (hasF || hasL ? `${y2} ·` : y2) : ''; yEl.title = hasY ? String(year) : ''; }
if (fEl) {
fEl.textContent = ''; fEl.title = hasF ? fmt : '';
if (hasF) {
const marker = fmtMarker(fmt); fEl.appendChild(marker);
// recognized formats render BOTH the circle and a text span; the "Format marker" option toggles
// which shows via a panel class, so switching needs no re-scan. Unknown formats stay text-only.
if (marker.nodeType === 1) { const t = document.createElement('span'); t.className = 'pc-fmt-txt'; t.textContent = fmt; fEl.appendChild(t); }
if (hasL) fEl.appendChild(document.createTextNode(' ·'));
}
}
if (lEl) { lEl.textContent = hasL ? label : ''; lEl.title = hasL ? label : ''; }
}
// MB's "Digital Media" is the spec name, but in a tight sidebar "Digital"
// communicates the same thing. Applied at display time so the cache and
// scan paths can keep MB's canonical value.
function normalizeFormat(s) { return String(s || '').replace(/\bDigital\s*Media\b/i, 'Digital'); }
// Format-family quadrant marker (#350): collapse any format(s) to Digital / Vinyl / CD / Cassette and draw
// them as a single circle split into 4 coloured quadrants (present family = its colour). Compact (1 glyph)
// for multi-format releases where the format text (Discogs/Bandcamp) would otherwise eat horizontal space.
const PC_FMT_COLOR = { Vinyl: '#2b2b2b', Cassette: '#9a6b3f', CD: '#7d8894', Digital: '#4a90d9' };
function pcFormatFamily(f) {
f = String(f || '').toLowerCase();
if (/cassette|tape/.test(f)) return 'Cassette';
if (/vinyl|shellac|flexi|\blp\b|7"|10"|12"/.test(f)) return 'Vinyl';
if (/cd|sacd|dvd|blu-?ray|hd-?dvd|minidisc|umd|disc/.test(f)) return 'CD';
if (/digital|file|stream|web|download|lossless|flac|mp3|wav|aac|hi-?res/.test(f)) return 'Digital';
return '';
}
const pcFormatFamilies = fmt => [...new Set(String(fmt || '').split(/[,+/]|\s+&\s+/).map(pcFormatFamily).filter(Boolean))];
function fmtMarker(fmt) {
const fams = pcFormatFamilies(fmt);
if (!fams.length) return document.createTextNode(normalizeFormat(fmt) || ''); // unknown → keep the text
const NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '13'); svg.setAttribute('height', '13');
svg.setAttribute('class', 'pc-fmt');
// quadrants: Vinyl top-right · Cassette top-left · CD bottom-left · Digital bottom-right
const QUAD = { Vinyl: [-90, 0], Cassette: [180, 270], CD: [90, 180], Digital: [0, 90] };
const cx = 12, cy = 12, r = 11, rad = d => d * Math.PI / 180;
for (const fam in QUAD) {
const [a0, a1] = QUAD[fam];
const x0 = cx + r * Math.cos(rad(a0)), y0 = cy + r * Math.sin(rad(a0)), x1 = cx + r * Math.cos(rad(a1)), y1 = cy + r * Math.sin(rad(a1));
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', `M${cx},${cy} L${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 0 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`);
// #564: an unlit quadrant and the hairline between quadrants were a fixed
// pale grey and a fixed near-white, so the marker read as a white disc on a
// dark panel. Set through .style rather than setAttribute: a presentation
// attribute is not a reliable place for var(), an inline style is.
// #564: through .style and a token, for the same reason the unlit one is —
// a presentation attribute cannot carry var(), and a fixed #2b2b2b vinyl
// is invisible on a dark panel.
if (fams.includes(fam)) path.style.fill = `var(--pc-fmt-${fam.toLowerCase()}, ${PC_FMT_COLOR[fam]})`;
else path.style.fill = 'var(--mbu-bg-sunken)';
path.style.stroke = 'var(--mbu-bg)'; path.setAttribute('stroke-width', '0.8');
svg.appendChild(path);
}
return svg;
}
// Strip leading copyright noise from a label string so the meta line doesn't
// show the year twice (year · label). Handles ℗/© or "(P)/(C)", a BARE leading
// year (Tidal often does "2026 Label" with no symbol), and repeated/stacked
// prefixes ("℗ 2017 © 2017 Label"), and marks run together (Amazon Music's "℗© 2025 Label", #644).
// Returns null when nothing's left.
function stripCopyright(text) {
let t = String(text || '').trim(), prev;
do { prev = t; t = t.replace(/^\s*(?:(?:[℗©]|\([pc]\))\s*)*(?:19|20)\d{2}\s*|^\s*(?:(?:[℗©]|\([pc]\))\s*)+/i, ''); } while (t !== prev);
return t.trim() || null;
}
// Apply Discogs master-state to the master slot. State shape:
// { glyph: '✓'|'~'|'×', circled: bool, clickable: bool, title: str, addMasterUrl?: str }
function applyMasterIcon(el, state) {
el.textContent = state.glyph;
el.style.color = state.muted ? 'var(--mbu-text-weak)' : 'var(--mbu-info)';
el.title = state.title;
el.classList.toggle('pc-ico-circled', !!state.circled);
if (state.clickable && state.addMasterUrl) {
el.style.cursor = 'pointer';
el.onclick = () => addMasterUrl(state.addMasterUrl);
} else {
el.style.cursor = 'default';
el.onclick = null;
}
}
// Build the Discogs master-state object given the URL Discogs found + the
// URL MB has on the release-group already. Drives applyMasterIcon().
function discogsMasterState(cachedMasterUrl, existingDiscogsMaster) {
if (!cachedMasterUrl) {
// No master from a resolved Discogs release — but if the MB
// release-group already carries a Discogs master, use it (it's the
// authoritative RG-level link, same as honouring any existing MB rel).
if (existingDiscogsMaster) {
return { glyph: '✓', circled: true, clickable: false, masterUrl: existingDiscogsMaster,
title: `Discogs master on the release-group: ${existingDiscogsMaster}` };
}
// Discogs returns no master_id when the release simply isn't part of a
// master group (one-off pressings, niche labels). Show a muted "—" so
// the user can distinguish "release has no master" from "we haven't
// scanned yet" without the slot looking like a bug.
return { glyph: '—', circled: false, clickable: false, title: 'Discogs release has no master entry', muted: true };
}
if (existingDiscogsMaster === cachedMasterUrl) {
return { glyph: '✓', circled: true, clickable: false, masterUrl: cachedMasterUrl, title: 'Discogs master URL is on the release-group' };
}
if (existingDiscogsMaster) {
return { glyph: '~', circled: true, clickable: false, masterUrl: existingDiscogsMaster, title: `MB has a different Discogs master on the release-group: ${existingDiscogsMaster}` };
}
return { glyph: '✓', circled: false, clickable: true, masterUrl: cachedMasterUrl, title: 'Click to add this Discogs master URL to the release-group', addMasterUrl: cachedMasterUrl };
}
// ─── Helpers ───────────────────────────────────────────────────────────────
// DDG redirects search hits through /l/?uddg=. Decode it.
function decodeDdgRedirect(href) {
const m = href.match(/[?&]uddg=([^&]+)/);
if (!m) return null;
try { return decodeURIComponent(m[1]); } catch { return null; }
}
// Search Brave first (handles concurrent calls cleanly, returns the canonical
// open.spotify.com / *.bandcamp.com URLs verbatim); fall back to DDG HTML if
// Brave is blocked. Returns up to `maxN` unique URLs that pass `urlFilter` —
// callers verify each candidate by fetching its server-side metadata, since
// the first search hit is often a different album by the same artist (e.g.
// MMW returns "20" for the Stone-series query).
//
// Engines are sometimes rate-limited per IP. We try them in order and stop
// at the first one that yields any usable result.
async function searchWeb(query, urlFilter, label, maxN = 5) {
const engines = [
{
name: 'Brave',
url: `https://search.brave.com/search?q=${encodeURIComponent(query)}`,
// Brave's anti-bot keys on a detailed Chrome UA being PRESENT, not absent.
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' },
extract(html) {
// Brave embeds raw result URLs directly inside — easy to
// pluck. We collect every external link and let `urlFilter` triage.
return [...html.matchAll(/href="(https?:\/\/[^"]+)"/g)].map(m => m[1]);
},
},
{
name: 'DDG',
url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
// Counter-intuitive: DDG's anti-bot blocks specific Chrome UAs but lets
// through a bare "Mozilla/5.0". Verified 2026-05-28.
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/' },
extract(html) {
const out = [];
for (const m of html.matchAll(/href="(\/\/duckduckgo\.com\/l\/\?uddg=[^"]+)"/g)) {
const decoded = decodeDdgRedirect(m[1]);
if (decoded) out.push(decoded);
}
// Also fall through to direct URL matches (some result layouts).
for (const m of html.matchAll(/https?:\/\/[^\s"<>]+/g)) out.push(m[0]);
return out;
},
},
];
for (const eng of engines) {
appendLog(label, `${eng.name}: ${eng.url}`);
const res = await queueSearch(() => gmGet(eng.url, { headers: eng.headers }));
const sizeHint = res.responseText.length;
appendLog(label, `${eng.name}: status=${res.status} ${sizeHint}b in ${res.ms}ms`);
// Engines distinguish rate-limit from real success differently:
// Brave -> HTTP 429 ("Too Many Requests")
// DDG -> HTTP 202 with a tiny <14 KB anti-bot page
// Surface both clearly in the log so the user knows when to wait.
if (res.status === 429 || res.status === 503) {
appendLog(label, `${eng.name}: rate-limited (HTTP ${res.status}) — try again later`, 'warn');
continue;
}
if (!res.ok || !res.responseText) { appendLog(label, `${eng.name}: HTTP failure (status=${res.status})`, 'warn'); continue; }
if (sizeHint < 5000 || res.status === 202) {
appendLog(label, `${eng.name}: response too small (${sizeHint}b) — likely anti-bot block`, 'warn');
continue;
}
const all = eng.extract(res.responseText);
const matches = [];
const seen = new Set();
for (const u of all) {
if (!urlFilter(u)) continue;
// Strip query strings and "/intl-xx/" locale segments before dedup —
// the same album often surfaces 5+ times with different locale prefixes.
const norm = u.replace(/\/intl-[a-z-]+\//, '/').replace(/\?.*$/, '').replace(/#.*$/, '');
if (seen.has(norm)) continue;
seen.add(norm);
matches.push(norm);
if (matches.length >= maxN) break;
}
appendLog(label, `${eng.name}: ${all.length} total hrefs, ${matches.length} unique candidates after filter`);
if (matches.length) return matches;
appendLog(label, `${eng.name}: no candidate matched filter`, 'warn');
}
appendLog(label, `All search engines failed to match`, 'error');
return [];
}
// ─── Candidate scoring ──────────────────────────────────────────────────────
// When the first search hit is wrong (e.g. MMW "20" instead of MMW
// "The Stone: Issue Four"), we need a way to pick the right one from a few
// candidates. Strategy: fetch each candidate's server-side metadata and score
// against the MB release's track count + title.
// #623 (sweep, X10): any script. Keeping only [a-z0-9] turned a non-Latin title into ""
// — an empty search, and no title or artist signal in scoring. Diacritics still fold on
// Latin, Greek and Cyrillic letters; other scripts keep their marks (voiced kana).
function normName(s) {
return (s || '').toLowerCase().normalize('NFKD').replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC').replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').trim();
}
// Punctuation-stripped form for use in search-engine queries. Quoting exact
// phrases is too rigid: MB might have "Space Echo: The Mystery…!" while the
// Bandcamp page renders it "Space Echo - The Mystery…" (no colon, no bang).
// Stripping punctuation to spaces lets the engine token-match either form;
// the verifier later picks the right candidate by track count + normName.
function searchTerms(s) {
return (s || '').normalize('NFKD').replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC').replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').replace(/\s+/g, ' ').trim();
}
// Token-set overlap. `mode` controls strictness:
// 'max' — ratio against the larger side (default). Strict. Right for
// album/title comparison: rejects "Self (The Remixes)" vs
// "Jaiye Omo (The Remixes)" (share only "the" + "remixes").
// 'min' — ratio against the smaller side. Lenient. Right for artist
// comparison: MB credits often shorter than streaming-platform
// credits (e.g. MB "Hamad Kalkaba" vs Apple "Hamad Kalkaba &
// The Golden Sounds" — all of MB's tokens are in Apple's, so
// a min-based 0.8 threshold accepts it).
function tokenMatch(a, b, mode = 'max', threshold = 0.6) {
const na = normName(a), nb = normName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const tokensOf = s => new Set(s.split(' ').filter(t => t.length >= 2));
const ta = tokensOf(na), tb = tokensOf(nb);
if (!ta.size || !tb.size) return false;
let common = 0;
for (const t of ta) if (tb.has(t)) common++;
const denom = mode === 'min' ? Math.min(ta.size, tb.size) : Math.max(ta.size, tb.size);
return common / denom >= threshold;
}
const titleSimilar = (a, b) => tokenMatch(a, b, 'max', 0.6);
// #324: min-mode leniency is for the intended direction only — the MB credit being
// SHORTER than the platform's (e.g. "Hamad Kalkaba" ⊂ "Hamad Kalkaba & The Golden
// Sounds"). When the *candidate* is the shorter side it has dropped MB tokens — often
// a disambiguator like the "61" in "UFO 61" vs a bare "UFO!" — so be strict (max),
// or a wrong same-prefixed release scores a full artist match. (Root cause of #314.)
const artistTokenCount = s => normName(s).split(' ').filter(t => t.length >= 2).length;
const artistSimilar = (cand, mb) => tokenMatch(cand, mb, artistTokenCount(mb) <= artistTokenCount(cand) ? 'min' : 'max', 0.8);
// Score: tracks +100 / ±2 +30, title +60, artist +80 (non-VA).
//
// Two subtleties:
//
// - The artist-mismatch penalty (-80) only applies when the title also
// fails to match. Compilation reissues commonly credit the curator
// label as `byArtist` on Bandcamp (e.g. "Analog Africa") while MB
// attributes the recording to the actual artist ("Hamad Kalkaba").
// If the title matches strongly, accept the mismatch as a likely
// label/series credit.
//
// - Pickable threshold (≥120) means a candidate needs at least one
// non-tracks signal (title OR artist) to be trusted. Tracks alone
// (100) is not enough — that was the bug behind picking the Jaiye
// Omo album for Om Unit's "Self (The Remixes)" purely because both
// happened to be 4-track EPs.
// Two non-empty titles that share ZERO significant tokens = a clearly different
// album (e.g. "Mambo Loco" vs "Para Bailar A Millón"). #324: an artist with many
// same-track-count albums (tracks 100 + artist 80) would otherwise be picked on a
// totally different title; a disjoint title is a strong negative signal.
function titleDisjoint(a, b) {
const na = normName(a), nb = normName(b);
if (!na || !nb) return false;
const T = s => new Set(s.split(' ').filter(t => t.length >= 2));
const ta = T(na), tb = T(nb);
if (!ta.size || !tb.size) return false;
for (const t of ta) if (tb.has(t)) return false;
return true;
}
function scoreCandidate(meta, mbTracks, mbAlbum, mbArtist, isVA) {
if (!meta) return -1;
let s = 0;
if (meta.tracks != null) {
if (meta.tracks === mbTracks) s += 100;
else if (Math.abs(meta.tracks - mbTracks) <= 2) s += 30;
}
const titleMatch = meta.title && titleSimilar(meta.title, mbAlbum);
if (titleMatch) s += 60;
// #324: a clearly different title outweighs tracks+artist (an album the same
// artist also made, with the same track count, is a common false positive).
else if (meta.title && mbAlbum && titleDisjoint(meta.title, mbAlbum)) s -= 100;
if (!isVA && mbArtist && meta.artist) {
if (artistSimilar(meta.artist, mbArtist)) s += 80;
else if (!titleMatch) s -= 80;
}
return s;
}
// Drive a per-candidate verifier loop. Returns the best { url, meta, score }
// across the candidate list, plus a per-candidate log table for diagnostics.
// `fetchMeta(url)` returns `{ tracks, title, year, label }` (any field may be null).
async function pickBestCandidate(candidates, fetchMeta, mbTracks, mbAlbum, label, mbArtist, isVA) {
const scored = [];
for (const url of candidates) {
const meta = await fetchMeta(url);
const score = scoreCandidate(meta, mbTracks, mbAlbum, mbArtist, isVA);
scored.push({ url, meta, score });
appendLog(label, ` cand score=${score} tracks=${meta?.tracks ?? '?'} artist="${meta?.artist || '?'}" title="${meta?.title || '?'}" url=${url}`);
// Short-circuit on a high-confidence match (tracks + (title or artist)).
if (score >= 150) break;
}
if (!scored.length) return null;
scored.sort((a, b) => b.score - a.score);
return scored[0];
}
// Per-platform-per-MBID cache. Stores the full resolved row so a return visit
// to a release page does ZERO network calls: no MB-rels-driven detail fetch,
// no Wikidata SPARQL, no search, no embed/album-page parse. The ↻ button
// clears entries when the user wants to force a re-scan.
//
// Schema (JSON-encoded value): { url, tracks, year, label, source } where
// `url` may be null when we've definitively concluded "no match exists on
// this platform" (so we don't keep re-searching for niche releases that
// genuinely aren't on Spotify/Bandcamp).
// #501 follow-up (majkinetor, live: his script-manager config was cluttered
// with pc:cache:v2:*/pc:mbdata:*/pc:pending:* entries riding along in a sync
// backup) — these are same-origin (musicbrainz.org-only @match, no
// cross-domain handoff need, unlike e.g. falcon's Harmony bridge or
// isrc_scout's Beatport OAuth handoff), so localStorage is the right layer:
// it's still shared across every musicbrainz.org tab, just not swept into a
// script-manager sync/backup the way a real setting should be.
// #556: "the same album" identity for two URL strings.
//
// MusicBrainz stores whatever URL the editor was given, and the provider
// searches here produce a different spelling of the same page all the time —
// `open.spotify.com/intl-de/album/X` vs `/album/X`, `music.apple.com/us/album/
// slug/123` vs `/album/123`, `qobuz.com/us-en/album/…`, `listen.tidal.com` vs
// `tidal.com/browse/album/…`. The `existing` map above already anticipates every
// one of those variants; the comparison that consumed it did not, and used
// `===`. A missed match leaves a link that IS on the release looking un-added,
// so the panel re-queues it and the editor no-ops on a duplicate.
//
// Compare on a provider id where there is one (they are stable and unambiguous),
// otherwise on a host+path normalised for the noise MB and the providers add.
// ⚠ The provider table lives INSIDE pcUrlKey, memoised on the function object,
// and must stay there. As a module-level `const` it sat below the IIFE's
// early-return for /release//edit, so injectInto — which runs on exactly
// that path — hit it in the temporal dead zone: `ReferenceError: Cannot access
// 'PC_URL_ID' before initialization`, thrown out of the whole inject run.
// It only fired for a URL MusicBrainz had rewritten, because matchRowByUrl
// short-circuits on `h === url` first, so identically-spelled links (Deezer,
// Tidal) landed and the run then died on the first rewritten one (Qobuz) —
// taking every remaining URL with it. Same hazard TYPE_FORCE is inlined for.
function pcUrlKey(u) {
const ID = pcUrlKey._id || (pcUrlKey._id = [
[/open\.spotify\.com\/(?:intl-[a-z-]+\/)?album\/([a-z0-9]+)/i, 'spotify'],
[/music\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/?#]+\/)?(\d+)/i, 'apple'],
[/(?:www\.)?deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/i, 'deezer'],
[/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/i, 'tidal'],
[/(?:www\.)?discogs\.com\/(?:[a-z-]+\/)?release\/(\d+)/i, 'discogs'],
[/(?:www\.)?discogs\.com\/(?:[a-z-]+\/)?master\/(\d+)/i, 'discogsmaster'],
[/(?:www\.)?beatport\.com\/release\/[^/]+\/(\d+)/i, 'beatport'],
// #639: the id is in the query string, which the normalisation below drops, so
// without this every YouTube Music album would count as the same link
[/music\.youtube\.com\/(?:playlist\?(?:[^#]*&)?list=|browse\/)(OLAK5uy_[\w-]+|MPREb_[\w-]+)/i, 'ytmusic'],
[/music\.amazon\.[a-z.]+\/albums\/([A-Z0-9]{10})/i, 'amazonmusic'], // #644: any country's domain, one album
]);
const s = String(u || '').trim();
if (!s) return '';
for (const [re, name] of ID) { const m = s.match(re); if (m) return name + ':' + m[1].toLowerCase(); }
// no stable id (bandcamp, qobuz, volumo, hdtracks, soundcloud, audiomack) — normalise the
// spelling instead: scheme, common host prefixes, locale segment, query and
// fragment, trailing slash.
try {
const url = new URL(s);
const host = url.hostname.toLowerCase().replace(/^(?:www|m|listen|play|open)\./, '');
let path = url.pathname.replace(/\/+$/, '');
path = path.replace(/^\/(?:[a-z]{2}-[a-z]{2}|[a-z]{2}|intl-[a-z-]+|browse)(?=\/)/i, '');
return host + path.toLowerCase();
} catch (e) { return s.toLowerCase().replace(/[?#].*$/, '').replace(/\/+$/, ''); }
}
function pcSameUrl(a, b) { return !!a && !!b && pcUrlKey(a) === pcUrlKey(b); }
/* #623 (sweep, X11): both caches below grew by one entry per platform for every
release ever visited, and never expired. A cached "no match" was permanent, so a
release that reached a platform later was never searched there again; and once
musicbrainz.org's ~5 MB of localStorage filled up, every write failed silently —
the platform logins' token refreshes included. Entries now carry their time (_t):
a "not found" is searched again after 14 days, a found link after 90, the
release's MusicBrainz data after 30. A daily prune drops what expired and keeps
the newest 500 releases.
⚠ Functions, not consts: the release editor's helper reads the cache (injectInto →
cacheGet, #423) and runs above the script's early return, where a const declared down
here is still in its temporal dead zone — the trap PC_URL_ID fell into (#556). */
function pcTtlOf(key, v) {
const TTL = { miss: 14 * 864e5, hit: 90 * 864e5, mbdata: 30 * 864e5 };
return key.startsWith('pc:mbdata:') ? TTL.mbdata : (v && v.url ? TTL.hit : TTL.miss);
}
function pcMaxReleases() { return 500; }
function pcLsGet(key) { // the entry, or null when absent, unreadable or expired (then removed)
const raw = localStorage.getItem(key);
if (!raw) return null;
let v; try { v = JSON.parse(raw); } catch { return null; }
if (v && v._t && Date.now() - v._t > pcTtlOf(key, v)) { try { localStorage.removeItem(key); } catch (e) {} return null; }
return v;
}
function pcLsSet(key, entry) {
if (!entry) return;
try { localStorage.setItem(key, JSON.stringify({ ...entry, _t: Date.now() })); } catch (e) {}
}
function pcPruneCache() {
try {
const now = Date.now(), today = new Date(now).toISOString().slice(0, 10);
if (localStorage.getItem('pc:cache:pruned') === today) return;
localStorage.setItem('pc:cache:pruned', today);
const keys = [];
for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && (k.startsWith('pc:cache:v2:') || k.startsWith('pc:mbdata:'))) keys.push(k); }
const newest = new Map(); // release mbid → its newest entry's time
let expired = 0, stamped = 0, evicted = 0;
for (const k of keys) {
let v; try { v = JSON.parse(localStorage.getItem(k)); } catch (e) { v = null; }
if (!v) { localStorage.removeItem(k); expired++; continue; }
// an entry from before #623 has no time: its age starts now, rather than
// the whole cache expiring (and being searched again) at once
if (!v._t) { v._t = now; localStorage.setItem(k, JSON.stringify(v)); stamped++; }
if (now - v._t > pcTtlOf(k, v)) { localStorage.removeItem(k); expired++; continue; }
const mbid = k.slice(k.lastIndexOf(':') + 1);
newest.set(mbid, Math.max(newest.get(mbid) || 0, v._t));
}
if (newest.size > pcMaxReleases()) {
const oldest = [...newest].sort((a, b) => a[1] - b[1]).slice(0, newest.size - pcMaxReleases());
oldest.forEach(([mbid]) => cacheClear(mbid));
evicted = oldest.length;
}
appendLog('System', `Cache prune: ${keys.length} entr${keys.length === 1 ? 'y' : 'ies'} for ${newest.size} release(s) — ${expired} expired, ${evicted} release(s) over the ${pcMaxReleases()} cap dropped${stamped ? `, ${stamped} older entr${stamped === 1 ? 'y' : 'ies'} dated today` : ''}`);
} catch (e) { appendLog('System', `Cache prune failed: ${e.message}`, 'warn'); }
}
function cacheKey(mbid, platform) { return `pc:cache:v2:${platform}:${mbid}`; } // v2: entries now carry `barcode` (#182)
function cacheGet(mbid, platform) { return pcLsGet(cacheKey(mbid, platform)); }
function cacheSet(mbid, platform, entry) { pcLsSet(cacheKey(mbid, platform), entry); }
function cacheClear(mbid) {
for (const p of ALL_PROVIDERS) localStorage.removeItem(cacheKey(mbid, p)); // all providers — not a stale hardcoded subset (else ↻ leaves Tidal/Beatport/Volumo cached)
localStorage.removeItem(mbDataKey(mbid));
}
// MB-level metadata cache (artist, album, mbTracks, etc.) — written once per
// MBID after a successful release fetch. When MB returns 503 (rate-limited)
// or otherwise fails, we fall back to this cache so a tab switch to a
// previously-scanned release still renders its cached rows instead of
// halting on "Halted: API status 503".
function mbDataKey(mbid) { return `pc:mbdata:${mbid}`; }
function mbDataGet(mbid) { return pcLsGet(mbDataKey(mbid)); }
function mbDataSet(mbid, entry) { pcLsSet(mbDataKey(mbid), entry); }
pcPruneCache(); // once a day (#623)
// Apply a cached row to the UI and log the hit. Preserves the cache entry's
// original source (e.g. 'MB rels', 'Wikidata', 'search') so updateRow can
// still decide whether to circle the ✓; cache-state is conveyed separately
// via the `fromCache: true` flag (which drives the steel-blue tint).
function applyCachedRow(platform, label, cached, mbTracks, masterState) {
appendLog(label, `Cache hit: url=${cached.url || '(no match)'} tracks=${cached.tracks ?? '?'} year=${cached.year || '?'} label=${cached.label || '?'} src=${cached.source || '?'}`, 'ok');
updateRow(platform, {
url: cached.url,
mbTracks,
remoteTracks: cached.tracks ?? null,
year: cached.year ?? null,
label: cached.label ?? null,
format: cached.format ?? null,
source: cached.source || null,
fromCache: true,
masterState: masterState ?? null,
hiddenTracks: cached.hiddenTracks ?? 0,
barcode: cached.barcode ?? null,
});
}
// (Old discogsMasterExtra pill replaced by the master-slot state object —
// see discogsMasterState() / applyMasterIcon() above.)
// ─── Wikidata fast path ─────────────────────────────────────────────────────
// Wikidata curates external IDs (Spotify P2205, Apple Music P5121, AllMusic
// P1729) against MB release-group IDs (P436) and release IDs (P5813). When a
// release has a Wikidata entity, the data is human-edited and effectively
// 100% precise — much better than ranking 5 search-engine candidates by
// track count. Recall is the trade-off: niche / very-recent releases usually
// have no Wikidata entry, in which case we fall back to web search.
async function lookupWikidata(releaseGroupMbid, releaseMbid) {
if (!releaseGroupMbid && !releaseMbid) return null;
// Union of release-group and release lookups in one query — avoids two
// round-trips when one or the other might be the indexed entity.
const sparql = `SELECT ?spotify ?apple ?allmusic ?tidal ?beatport WHERE {
${releaseGroupMbid ? ` { ?item wdt:P436 "${releaseGroupMbid}" }` : ''}
${releaseGroupMbid && releaseMbid ? ' UNION' : ''}
${releaseMbid ? ` { ?item wdt:P5813 "${releaseMbid}" }` : ''}
OPTIONAL { ?item wdt:P2205 ?spotify }
OPTIONAL { ?item wdt:P5121 ?apple }
OPTIONAL { ?item wdt:P1729 ?allmusic }
OPTIONAL { ?item wdt:P4577 ?tidal }
OPTIONAL { ?item wdt:P11312 ?beatport }
} LIMIT 5`;
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparql)}&format=json`;
appendLog('Wikidata', `SPARQL lookup rg=${releaseGroupMbid || '-'} rel=${releaseMbid || '-'}`);
// 8 s, not gmGet's 15 s: query.wikidata.org often stalls, and the providers
// that use this answer (Spotify/Apple/Tidal/Beatport) wait for it — on a
// timeout they just fall through to their own search.
const res = await gmGet(url, { headers: { 'Accept': 'application/sparql-results+json' }, timeout: 8000 });
appendLog('Wikidata', `status=${res.status} ${res.responseText.length}b in ${res.ms}ms`);
if (!res.ok) { appendLog('Wikidata', res.error === 'timeout' ? `timed out after ${res.ms}ms — continuing without it` : `lookup failed`, 'warn'); return null; }
let data;
try { data = JSON.parse(res.responseText); } catch (e) { appendLog('Wikidata', `JSON parse: ${e.message}`, 'error'); return null; }
const bindings = data.results?.bindings || [];
if (!bindings.length) {
appendLog('Wikidata', `no entity matches release-group/release MBID — falling back to search`, 'warn');
return null;
}
// Pick first binding that has any populated field; if multiple bindings
// disagree (rare), trust the first row.
const b = bindings.find(r => r.spotify || r.apple || r.allmusic || r.tidal || r.beatport) || bindings[0];
const out = {
spotifyId: b.spotify?.value || null,
appleId: b.apple?.value || null,
allmusicId: b.allmusic?.value || null,
tidalId: b.tidal?.value || null,
beatportId: b.beatport?.value || null,
};
appendLog('Wikidata', `match: spotify=${out.spotifyId || '-'} apple=${out.appleId || '-'} allmusic=${out.allmusicId || '-'} tidal=${out.tidalId || '-'} beatport=${out.beatportId || '-'}`, 'ok');
return out;
}
// Wallstream (tools.wallstream.com, #602) proxies Spotify's catalog search with
// its own app token, so `q=upc:` resolves a barcode → exact-UPC Spotify
// album — the only unauthenticated UPC route for Spotify (the embed/API don't
// expose a UPC without an app token). Replaced SAMBL, which stopped returning
// Spotify results and blocked every scanner while it ran. No CORS header →
// GM_xmlhttpRequest (@connect tools.wallstream.com).
// Spotify's upc: filter matches only the exact string Spotify stored, leading
// zeros included (0859381157694 hits, 859381157694 doesn't), so try the MB
// barcode verbatim, then its 12/13/14-digit zero-padded forms, first hit wins.
// Count with items.length — the response's `total` can be 0 with items present.
function spotifyUpcVariants(barcode) {
const raw = String(barcode || '').trim();
if (!raw) return [];
const out = [raw];
if (/^\d+$/.test(raw)) {
const core = raw.replace(/^0+/, '');
for (const n of [12, 13, 14]) if (core.length <= n) out.push(core.padStart(n, '0'));
}
return [...new Set(out)];
}
async function lookupWallstreamSpotify(barcode) {
const variants = spotifyUpcVariants(barcode);
if (!variants.length) return null;
appendLog('Wallstream', `Spotify barcode lookup ${barcode} (forms: ${variants.join(', ')})`);
for (const code of variants) {
const url = `https://tools.wallstream.com/api/spotify/isrc?q=${encodeURIComponent('upc:' + code)}`;
const r = await gmGet(url, { timeout: 8000, anonymous: true });
appendLog('Wallstream', `upc:${code} → status=${r.status} ${r.responseText.length}b in ${r.ms}ms${r.error ? ' (' + r.error + ')' : ''}`);
if (!r.ok) { appendLog('Wallstream', `lookup failed — falling back to Wikidata/search`, 'warn'); return null; }
let j; try { j = JSON.parse(r.responseText); } catch (e) { appendLog('Wallstream', `JSON parse: ${e.message}`, 'error'); return null; }
const albums = j?.items?.albums?.items || [];
appendLog('Wallstream', `upc:${code} → ${albums.length} album(s)${albums.length ? ': ' + albums.map(x => `"${x.name}" ${x.external_urls?.spotify || x.id}`).join(' | ') : ''}`);
const hit = albums.find(x => x?.external_urls?.spotify || x?.id);
if (hit) {
const albumUrl = hit.external_urls?.spotify || `https://open.spotify.com/album/${hit.id}`;
appendLog('Wallstream', `exact-barcode Spotify album (upc:${code}): ${albumUrl}`, 'ok');
return albumUrl;
}
}
appendLog('Wallstream', `no Spotify album for any barcode form`, 'warn');
return null;
}
// Concurrent search-engine queries from the same IP can trip anti-bot pages.
// Serialize them on one chain so two scanners never hit the same engine at once.
let searchChain = Promise.resolve();
function queueSearch(fn) { const p = searchChain.then(fn, fn); searchChain = p.catch(() => {}); return p; }
// ─── Per-platform scanners ─────────────────────────────────────────────────
// Fetch Spotify album metadata via the server-rendered /embed/album/ page.
// The embed ships an inline JSON blob; we extract title + track count + year +
// label by regex (lighter and more tolerant than full-tree parsing).
async function fetchSpotifyMeta(albumUrl) {
const idMatch = albumUrl.match(/album\/([a-zA-Z0-9]{22})/);
if (!idMatch) return null;
const embedUrl = `https://open.spotify.com/embed/album/${idMatch[1]}`;
const er = await gmGet(embedUrl);
if (!er.ok) return null;
const html = er.responseText;
const trackUris = [...html.matchAll(/"uri":"spotify:track:[a-zA-Z0-9]+"/g)];
const titleMatch = html.match(/"name"\s*:\s*"([^"]+)"/);
const yearMatch = html.match(/"releaseDate":"(\d{4})-/) || html.match(/"year"\s*:\s*(\d{4})/);
const labelMatch = html.match(/"label":"([^"]+)"/) || html.match(/"copyrights":\s*\[\s*\{\s*"text"\s*:\s*"([^"]+)"/);
// Spotify embed stores the album-level artist credit as the first
// `"subtitle":"…"` field (subsequent subtitles are per-track artist
// credits). The bigger Spotify metadata schemas use `"artists":[…]`
// but the embed compresses them all to `subtitle`.
const subtitleMatch = html.match(/"subtitle"\s*:\s*"([^"]+)"/);
const artist = subtitleMatch?.[1] || null;
return {
tracks: trackUris.length || null,
title: titleMatch?.[1] || null,
year: yearMatch?.[1] || null,
label: labelMatch?.[1] || null,
artist,
};
}
async function scanSpotify({ artist, album, mbTracks, existingUrl, mbid, wikidataSpotifyId, isVariousArtists, barcode }) {
const label = 'Spotify';
// Cache hit WITH URL → use it and skip everything else. A cached "no
// match" (url:null) is NOT a short-circuit — a fresh Wikidata answer
// can still override it. Only if Wikidata also has nothing AND no
// existing rel do we fall back to rendering the cached no-match
// (without rerunning search engines; ↻ forces full retry).
const cached = cacheGet(mbid, 'spotify');
if (cached?.url && (!existingUrl || existingUrl === cached.url)) {
appendLog('Wallstream', `skipped — Spotify URL cached from a previous scan (↻ to re-run): ${cached.url}`);
applyCachedRow('spotify', label, cached, mbTracks);
return;
}
let albumUrl = existingUrl;
let source = null;
let bestMeta = null;
let exactBarcode = false; // (#182) true when the URL was resolved by exact UPC
// Barcode lookup (#602) runs here, inside the Spotify scanner, so it no
// longer delays the other providers' scans. Every skip is logged, so an
// empty Wallstream log never leaves you guessing whether it ran.
let barcodeUrl = null;
if (albumUrl) appendLog('Wallstream', `skipped — Spotify already linked in MB: ${albumUrl}`);
else if (!barcode) appendLog('Wallstream', `skipped — release has no barcode`);
else barcodeUrl = await lookupWallstreamSpotify(barcode);
if (albumUrl) {
appendLog(label, `Using existing MB URL: ${albumUrl}`, 'ok');
source = 'MB rels';
} else if (barcodeUrl) {
// Wallstream resolved the exact-barcode Spotify album. It beats
// Wikidata/search, which can point at a different-barcode edition.
albumUrl = barcodeUrl; source = 'Wallstream (barcode)'; exactBarcode = true;
appendLog(label, `Wallstream barcode match → ${albumUrl}`, 'ok');
} else if (wikidataSpotifyId) {
albumUrl = `https://open.spotify.com/album/${wikidataSpotifyId}`;
appendLog(label, `Wikidata answer: ${albumUrl}`, 'ok');
source = 'Wikidata';
} else if (cached) {
// Cache says "no match", no MB rel, Wikidata had no answer — surface
// the cached state without re-running web search (the user already
// saw this; they have ↻ to force a fresh search).
appendLog(label, `No match (cached from previous scan — use ↻ to force a re-search)`, 'warn');
applyCachedRow('spotify', label, cached, mbTracks);
return;
} else {
// Restrict the `site:` filter to the /album/ path so artist pages,
// playlists, tracks, and shows never enter the candidate list. Use
// punctuation-stripped tokens (not a quoted exact phrase) so the
// engine matches "Space Echo - The Mystery..." against MB's
// "Space Echo: The Mystery...!". For VA compilations drop the artist
// term because the literal phrase "Various Artists" doesn't appear
// on streaming pages — labels host compilations under their own name.
const albumT = searchTerms(album);
const artistT = searchTerms(artist);
const q = isVariousArtists
? `site:open.spotify.com/album/ ${albumT}`
: `site:open.spotify.com/album/ ${artistT} ${albumT}`;
const candidates = await searchWeb(q, u => /open\.spotify\.com\/(?:intl-[a-z-]+\/)?album\/[a-zA-Z0-9]{22}/.test(u), label);
if (!candidates.length) {
// Cache "no match" so a refresh-less page re-visit doesn't re-search.
cacheSet(mbid, 'spotify', { url: null, tracks: null, year: null, label: null, source: 'search' });
updateRow('spotify', { url: null, mbTracks, remoteTracks: null });
return;
}
appendLog(label, `Verifying ${candidates.length} candidate(s) by tracks + title + artist…`);
const best = await pickBestCandidate(candidates, fetchSpotifyMeta, mbTracks, album, label, artist, isVariousArtists);
if (!best || best.score < 120) {
appendLog(label, `No verifiable match (best score=${best?.score ?? 'n/a'}) — leaving URL unset`, 'warn');
cacheSet(mbid, 'spotify', { url: null, tracks: null, year: null, label: null, source: 'search' });
updateRow('spotify', { url: null, mbTracks, remoteTracks: null });
return;
}
albumUrl = best.url;
bestMeta = best.meta;
appendLog(label, `Picked best (score=${best.score}): ${albumUrl}`, best.score >= 150 ? 'ok' : 'warn');
source = 'search';
}
const meta = bestMeta || await fetchSpotifyMeta(albumUrl);
if (meta) {
appendLog(label, `Embed parsed: tracks=${meta.tracks} title="${meta.title}" year=${meta.year || '?'} label=${meta.label || '?'}`, meta.tracks ? 'ok' : 'warn');
} else {
appendLog(label, `Embed fetch failed`, 'error');
}
// The embed strips year/label entirely. Fetch the public album page once
// we've settled on a URL to enrich year for display — the JSON-LD blob
// includes `datePublished`. Label is still unobtainable without OAuth.
let displayYear = meta?.year ?? null;
if (!displayYear) {
try {
const pr = await gmGet(albumUrl);
if (pr.ok) {
const m = pr.responseText.match(/"datePublished"\s*:\s*"(\d{4})/);
if (m) {
displayYear = m[1];
appendLog(label, `Album page year: ${displayYear}`);
}
}
} catch (_) { /* enrichment is best-effort */ }
}
const tracks = meta?.tracks ?? null;
const lbl = meta?.label ?? null;
const sbc = exactBarcode ? MB_BARCODE : null; // exact UPC → MB barcode (no mismatch); else unknown
cacheSet(mbid, 'spotify', { url: albumUrl, tracks, year: displayYear, label: lbl, source, barcode: sbc });
updateRow('spotify', { url: albumUrl, mbTracks, remoteTracks: tracks, year: displayYear, label: lbl, source, barcode: sbc });
}
// ─── Qobuz ───────────────────────────────────────────────────────────────────
// Qobuz's catalogue API (api.json/0.2) with app_id `712109809`: `album/search`
// works unauthenticated (barcode-first search + UPC capture, like the other API
// providers), but `album/get` — which also carries `tracks_count` + `upc` for a
// direct verify — is session-gated (404/401 anonymously, #353). So when the user
// has logged in via ⚙ Setup → Auth (token in mbtools:qobuz), we attach it and the
// API path becomes reliable; otherwise Qobuz falls back to scraping the
// server-rendered store page. (#201/#353, shapes per chaban-mb / Harmony.)
const QOBUZ_APP_ID = '712109809';
function qobuzApi(path) {
const headers = { Accept: 'application/json', 'X-App-Id': QOBUZ_APP_ID };
const t = qbRead(); // #353 the user's stored token unlocks album/get
if (t && t.token) headers['X-User-Auth-Token'] = t.token;
return gmGet(`https://www.qobuz.com/api.json/0.2/${path}${path.includes('?') ? '&' : '?'}app_id=${QOBUZ_APP_ID}`, { headers })
.then(r => { if (!r.ok) return null; try { return JSON.parse(r.responseText); } catch { return null; } });
}
// the album slug-id is the API's album_id (e.g. .../album//vft3hpnx5c3lc)
function qobuzAlbumId(url) { const m = String(url || '').match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]+)/i); return m ? m[1] : null; }
const qobuzMetaFromApi = d => (d && d.id) ? {
tracks: d.tracks_count ?? null,
title: d.title || null,
year: String(d.release_date_original || d.release_date_stream || '').slice(0, 4) || null,
label: (d.label && d.label.name) || null,
artist: (d.artist && d.artist.name) || null,
barcode: d.upc || null,
} : null;
const qzDec = s => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/?39;/g, "'").replace(/([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/(\d+);/g, (_, n) => String.fromCodePoint(+n));
// Normalise any Qobuz album URL to the server-rendered www store page. open./play.
// are SPA shells with no credits in the HTML, and an MB rel is often the slug-less
// open form — a wrong-slug www URL 301-redirects to the canonical page, so this
// always lands somewhere scrapeable. (URL shapes per Harmony; #201, chaban-mb)
function qobuzStoreUrl(albumUrl) {
const m = String(albumUrl || '').match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]+)/i);
if (!m) return albumUrl;
if (/^https?:\/\/www\.qobuz\.com\/[a-z]{2}-[a-z]{2}\/album\/[^/]+\/[a-z0-9]+/i.test(albumUrl)) return albumUrl; // already a www store page
return `https://www.qobuz.com/us-en/album/x/${m[1]}`;
}
async function fetchQobuzMeta(albumUrl) {
// Prefer the API (structured + carries the UPC); fall back to scraping the
// store page where the API returns nothing (geo) or 429s. #201/#353
const id = qobuzAlbumId(albumUrl);
if (id) {
const signedIn = qbLoggedIn();
const m = qobuzMetaFromApi(await qobuzApi(`album/get?album_id=${encodeURIComponent(id)}`));
if (m && m.tracks) { m._via = 'api'; return m; } // tagged so scanQobuz can log the real source (see below)
// #353 say WHY we're scraping so a logged-in user isn't left wondering: not signed in on
// THIS origin → prompt; signed in but the API gave nothing → note it (geo / stale session).
appendLog('Qobuz', signedIn
? `album/get returned no data despite being signed in — scraping instead (Qobuz may be geo-restricting this session)`
: `not signed in to Qobuz on this site — scraping. Log in via ⚙ Setup → Auth for the reliable album/get path (track count + barcode)`,
signedIn ? 'warn' : 'info');
}
const scraped = await fetchQobuzScrape(albumUrl);
if (scraped) scraped._via = 'page';
return scraped;
}
async function fetchQobuzScrape(albumUrl) {
let r = await gmGet(qobuzStoreUrl(albumUrl));
// Qobuz rate-limits very aggressively (429 after only a few requests, #201).
// One polite retry honouring Retry-After; if it persists, report it distinctly
// so the scanner can leave the row retryable instead of caching a false "no match".
if (r.status === 429) {
const ra = parseInt((String(r.responseHeaders || '').match(/retry-after:\s*(\d+)/i) || [])[1], 10);
await new Promise(z => setTimeout(z, Math.min((ra > 0 ? ra : 3) * 1000, 10000)));
r = await gmGet(qobuzStoreUrl(albumUrl));
}
if (r.status === 429) return { rateLimited: true };
if (!r.ok || !r.responseText) return null;
const html = r.responseText;
// Track count: the page renders an empty duplicate of every track row for
// its responsive layout, so the `track__info` blocks double-count. Each
// real track instead carries a unique add-to-cart marker — count those.
const trackIds = new Set([...html.matchAll(/popinAddToCartBtnPlayerTrack(\d+)/g)].map(m => m[1]));
// Album name + year from the JSON-LD MusicAlbum block (clean, unlike og:title).
let title = null, year = null;
for (const m of html.matchAll(/