// ==UserScript== // @name First Contact // @namespace https://musicbrainz.org/ // @version 2026.10.3 // @description Import a release into MusicBrainz from the platform's album page with one click: the release editor opens with the title, tracklist, artists, date, label, barcode and link filled in. Each artist's platform link is handed to Apollo Editor for matching. By default the album page is also saved on the Internet Archive (it sends each imported URL to archive.org; off in the settings), and the edit note links the snapshot. Platforms: Deezer, Bandcamp, Discogs, Apple Music, Tidal, Qobuz, Beatport, Spotify, YouTube Music, Volumo, HDtracks, SoundCloud, Amazon Music, Audiomack. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPkZpcnN0IENvbnRhY3Q8L3RpdGxlPgogIDwhLS0gdHJhY3RvciBiZWFtIC0tPgogIDxwYXRoIGQ9Ik01MCA0NCBMNzggNDQgTDEwNCAxMTIgTDI0IDExMiBaIiBmaWxsPSIjZjZjNDMxIiBvcGFjaXR5PSIwLjkiLz4KICA8cGF0aCBkPSJNNTYgNDQgTDcyIDQ0IEw4OCAxMTIgTDQwIDExMiBaIiBmaWxsPSIjZmRlNjhhIiBvcGFjaXR5PSIwLjciLz4KICA8IS0tIGxhbmRpbmcgcmluZyAtLT4KICA8ZWxsaXBzZSBjeD0iNjQiIGN5PSIxMTQiIHJ4PSI0MiIgcnk9IjgiIGZpbGw9IiMxZTIzNDYiIHN0cm9rZT0iI2M5Y2RmMiIgc3Ryb2tlLXdpZHRoPSIzIi8+CiAgPGVsbGlwc2UgY3g9IjY0IiBjeT0iMTEzIiByeD0iMjYiIHJ5PSI0LjIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Y2YzQzMSIgc3Ryb2tlLXdpZHRoPSIyLjQiLz4KICA8IS0tIHNhdWNlcjogYSBwYWxlIHJpbSwgc28gaXQgc2hvd3Mgb24gYSBkYXJrIGJhY2tncm91bmQgdG9vIC0tPgogIDxwYXRoIGQ9Ik00MCAyNiBBMjQgMTggMCAwIDEgODggMjYgWiIgZmlsbD0iIzFlMjM0NiIgc3Ryb2tlPSIjYzljZGYyIiBzdHJva2Utd2lkdGg9IjMiLz4KICA8cGF0aCBkPSJNNTAgMTggQTEwIDYgMCAwIDEgNjAgMTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyLjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDxlbGxpcHNlIGN4PSI2NCIgY3k9IjMyIiByeD0iNTYiIHJ5PSIxMyIgZmlsbD0iIzFlMjM0NiIgc3Ryb2tlPSIjYzljZGYyIiBzdHJva2Utd2lkdGg9IjMiLz4KICA8Y2lyY2xlIGN4PSIzOCIgY3k9IjMyIiByPSI0LjQiIGZpbGw9IiNmZmYiLz4KICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjM0IiByPSI0LjQiIGZpbGw9IiNmZmYiLz4KICA8Y2lyY2xlIGN4PSI5MCIgY3k9IjMyIiByPSI0LjQiIGZpbGw9IiNmZmYiLz4KICA8IS0tIHRoZSByZWxlYXNlIGJlaW5nIGJlYW1lZCB1cDogYSBwdXJwbGUgaGV4YWdvbiB3aXRoIGEgbm90ZSAtLT4KICA8cGF0aCBkPSJNNjQgNTggTDg0IDY5LjUgTDg0IDkyLjUgTDY0IDEwNCBMNDQgOTIuNSBMNDQgNjkuNSBaIiBmaWxsPSIjN2I0ZmQ2Ii8+CiAgPHBhdGggZD0iTTY0IDU4IEw4NCA2OS41IEw2NCA4MSBMNDQgNjkuNSBaIiBmaWxsPSIjOWI3MmVhIi8+CiAgPHBhdGggZD0iTTYwIDcyIEw3NCA2OSBMNzQgODkiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIzLjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNNjAgNzIgTDYwIDkyIiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMy40Ii8+CiAgPGVsbGlwc2UgY3g9IjU2IiBjeT0iOTIiIHJ4PSI0LjYiIHJ5PSIzLjYiIGZpbGw9IiNmZmYiLz4KICA8ZWxsaXBzZSBjeD0iNzAiIGN5PSI4OSIgcng9IjQuNiIgcnk9IjMuNiIgZmlsbD0iI2ZmZiIvPgogIDwhLS0gc3BhcmtsZXMgLS0+CiAgPHBhdGggZD0iTTE4IDU0IGwyLjUgNiA2IDIuNSAtNiAyLjUgLTIuNSA2IC0yLjUgLTYgLTYgLTIuNSA2IC0yLjUgWiIgZmlsbD0iI2Y2YzQzMSIvPgogIDxwYXRoIGQ9Ik0xMDggNTIgbDIuNSA2IDYgMi41IC02IDIuNSAtMi41IDYgLTIuNSAtNiAtNiAtMi41IDYgLTIuNSBaIiBmaWxsPSIjMWUyMzQ2Ii8+CiAgPHBhdGggZD0iTTExMiA4MiBsMS44IDQuMiA0LjIgMS44IC00LjIgMS44IC0xLjggNC4yIC0xLjggLTQuMiAtNC4yIC0xLjggNC4yIC0xLjggWiIgZmlsbD0iI2Y2YzQzMSIvPgogIDxwYXRoIGQ9Ik0xNCA4NiBsMS44IDQuMiA0LjIgMS44IC00LjIgMS44IC0xLjggNC4yIC0xLjggLTQuMiAtNC4yIC0xLjggNC4yIC0xLjggWiIgZmlsbD0iIzFlMjM0NiIvPgo8L3N2Zz4K // @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/first_contact/README.md // @match https://www.deezer.com/* // @match https://*.bandcamp.com/album/* // @match https://www.discogs.com/* // @match https://music.apple.com/* // @match https://tidal.com/* // @match https://listen.tidal.com/* // @match https://www.qobuz.com/*/album/* // @match https://www.beatport.com/* // @match https://open.spotify.com/* // @match https://music.youtube.com/* // @match https://volumo.com/* // @match https://www.hdtracks.com/* // @match https://soundcloud.com/* // @match https://audiomack.com/* // @match https://music.amazon.com/* // @match https://music.amazon.co.uk/* // @match https://music.amazon.de/* // @match https://music.amazon.fr/* // @match https://music.amazon.it/* // @match https://music.amazon.es/* // @match https://music.amazon.ca/* // @match https://music.amazon.co.jp/* // @match https://music.amazon.com.au/* // @match https://music.amazon.com.br/* // @match https://music.amazon.com.mx/* // @match https://music.amazon.in/* // @match https://*.musicbrainz.org/release/add* // @noframes // @run-at document-start // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues // @grant unsafeWindow // @grant window.close // @connect api.deezer.com // @connect api.discogs.com // @connect music.apple.com // @connect amp-api.music.apple.com // @connect auth.tidal.com // @connect openapi.tidal.com // @connect api.beatport.com // @connect api-partner.spotify.com // @connect spclient.wg.spotify.com // @connect music.youtube.com // @connect volumo.com // @connect hdtracks.azurewebsites.net // @connect soundcloud.com // @connect a-v2.sndcdn.com // @connect api-v2.soundcloud.com // @connect api.audiomack.com // @connect music.amazon.com // @connect na.mesk.skill.music.a2z.com // @connect web.archive.org // ==/UserScript== (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('first_contact', 'First Contact')) return; const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; const SCRIPT = 'first_contact'; const NAME = 'First Contact'; const HOMEPAGE = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/first_contact/README.md'; const ON_MB = /(^|\.)musicbrainz\.org$/.test(location.hostname); const ICON_SVG = ''; // MusicBrainz's special-purpose artists that a platform names verbatim. const VARIOUS_ARTISTS_MBID = '89ad4ac3-39f7-470e-963a-56509c546377'; // Handoffs (the platform links of every credited artist) wait in GM storage for the release // editor tab. They are small; a day is plenty for a tab that was opened and left. const HANDOFF_PREFIX = 'fc.handoff.'; // majkinetor: "handoff probably shouldn't spam the settings?" — every import left its handoff in // the script's storage for a day. Now the release editor takes it out once it has it (keeping a // copy for that tab, so a reload still finds it), and one that was never picked up (the editor // never opened) goes after an hour. const HANDOFF_TTL_MS = 3600 * 1000; const SERVERS = ['musicbrainz.org', 'beta.musicbrainz.org', 'test.musicbrainz.org']; /* ── activity log: the shared window and buffer (mbuLog, in the ST-UI block below) ── */ const Log = mbuLog({ name: NAME, version: VERSION, header: NAME + ' — activity log', key: 'fc.logwin', before: () => injectStyle() }); Log.info(mbuStartupInfo(NAME)); mbuToast.log = (kind, msg) => (kind === 'warn' ? Log.warn(msg) : kind === 'ok' ? Log.ok(msg) : Log.info(msg)); function settings() { const s = Object.assign({ server: 'musicbrainz.org', iconOnly: false, gearOnHover: false, closeAfter: false, archive: true, iaKey: '', iaSecret: '' }, GM_getValue('fc.settings', {})); if (!SERVERS.includes(s.server)) s.server = 'musicbrainz.org'; return s; } function saveSettings(s) { GM_setValue('fc.settings', s); } /* ── network ─────────────────────────────────────────────────────────────── */ // A GET through the manager (no CORS): the response text, or throws. `headers` add to the request; // an error carries the HTTP status (`e.status`) for a caller that retries on one. function gmText(url, headers) { const t0 = Date.now(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: Object.assign({ Accept: 'application/json' }, headers || {}), timeout: 20000, anonymous: true, onload: r => { Log.debug(`GET ${url} → ${r.status}, ${(r.responseText || '').length} b in ${Date.now() - t0} ms`); if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`HTTP ${r.status} for ${url}`), { status: r.status })); resolve(r.responseText || ''); }, onerror: () => reject(new Error(`network error for ${url}`)), ontimeout: () => reject(new Error(`timeout for ${url}`)), }); }); } async function gmJson(url, headers) { const text = await gmText(url, headers); try { return JSON.parse(text); } catch (e) { throw new Error(`bad JSON from ${url}: ${e.message}`); } } // A form POST through the manager: the parsed JSON answer, or throws. function gmPostJson(url, body, headers) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: body, timeout: 20000, anonymous: true, headers: Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, headers || {}), onload: r => { Log.debug(`POST ${url} → ${r.status}`); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* not JSON */ } if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`HTTP ${r.status} for ${url}${j && j.error ? ': ' + j.error : ''}`), { status: r.status })); resolve(j); }, onerror: () => reject(new Error(`network error for ${url}`)), ontimeout: () => reject(new Error(`timeout for ${url}`)), }); }); } // Run fn over items with at most `limit` in flight; results keep the input order. async function mapLimit(items, limit, fn) { const out = new Array(items.length); let next = 0; const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; } /* ── shared parsing helpers (platform-neutral) ───────────────────────────── */ // A "(feat. A and B)" / "[ft. A & B]" / "feat. A, B" clause in a title. Returns the title // without it and the featured names, in order. function splitFeat(title) { const s = String(title || ''); const re = /\s*[([]\s*(?:feat\.?|ft\.?|featuring)\s+([^)\]]+)[)\]]|(?:^|\s+)(?:feat\.?|ft\.?|featuring)\s+(.+)$/i; const m = s.match(re); if (!m) return { title: s.trim(), feat: [] }; const names = (m[1] || m[2]).split(/\s*(?:,|&|\band\b)\s*/i).map(n => n.trim()).filter(Boolean); return { title: (s.slice(0, m.index) + s.slice(m.index + m[0].length)).replace(/\s{2,}/g, ' ').trim(), feat: names }; } const normName = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[\s.'’]+/g, ' ').trim(); // Artist list → MB artist credit: mains joined "A, B & C", then " feat. " and the featured // ones the same way. Each entry keeps the platform link for Apollo. function toCredit(mains, feats) { const join = (list, last) => list.map((a, i) => Object.assign({}, a, { join: i === list.length - 1 ? last : i === list.length - 2 ? ' & ' : ', ' })); const m = join(mains, feats.length ? ' feat. ' : ''); const f = join(feats, ''); return m.concat(f); } // Split a platform's flat contributor list into mains and featured by the title's feat. clause. function creditFromTitle(contributors, featNames) { const featSet = new Set(featNames.map(normName)); const mains = [], feats = []; for (const c of contributors) (featSet.has(normName(c.name)) ? feats : mains).push(c); // a featured name the platform didn't list as a contributor still belongs in the credit for (const n of featNames) if (!feats.some(c => normName(c.name) === normName(n))) feats.push({ name: n }); if (!mains.length && feats.length) mains.push(feats.shift()); // never a credit that starts with " feat." return toCredit(mains, feats); } // The primary type a release looks like, after murdos's fnGuessReleaseType (mbimport.js, // github.com/murdos/musicbrainz-userscripts), most confident first: // 1. an "EP" / "E.P." token in the title; // 2. "Single" ending the title ("Song - Single", "Song (Single)") — or anywhere in it, within // 8 tracks and 50 minutes (it's common English, so it needs the guard); // 3. every track the same song once versions are taken off ("Song", "Song (Remix)", // "Song (Instrumental)") → Single; // 4. by size: 7+ tracks or over 30 minutes → Album; up to 7 minutes → Single; else EP from // 2 tracks up. Without every length: 1 track Single, 3–6 EP, 7+ Album, 2 left open. // Returns { type: 'Album' | 'EP' | 'Single' | null, why, explicit } — explicit when the title // says so, which then outranks a platform that calls everything an album. const TYPE_VERSION_MARKER = /\b(?:a ?cap+el+a|acoustic|alt(?:ernate)?|bootleg|clean|club|demo|dirty|dub|edit|explicit|extended|instrumental|karaoke|live|mix|mono|original|radio|remaster(?:ed)?|remix|rework|short|slowed|sped[ -]up|stereo|version|vip|vocal)\b/i; // (the brackets are { / } escapes so the test harness's brace matching reads the function whole) function normTrackTitle(title) { return String(title || '').normalize('NFKC').toLocaleLowerCase() .replace(/\s*[([\x7B]([^\])\x7D]*?)[\])\x7D]/g, (m, inner) => (TYPE_VERSION_MARKER.test(inner) ? '' : m)) .replace(/\s*[-–—:]\s*([^\n]*)$/, (m, tail) => (TYPE_VERSION_MARKER.test(tail) ? '' : m)) .replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); } function guessReleaseType(title, tracks) { const n = tracks.length; if (!n) return { type: null, why: 'no tracks' }; const t = String(title || ''); const lengths = tracks.map(x => x.lengthMs); const ms = lengths.every(l => l > 0) ? lengths.reduce((a, l) => a + l, 0) : NaN; const min = ms / 60000; if (/(?:^|[\s([\-–])E\.?P\b\.?/i.test(t)) return { type: 'EP', why: 'the title says EP', explicit: true }; if (/[\s([\-–]single\s*[)\]]?$/i.test(t)) return { type: 'Single', why: 'the title ends in Single', explicit: true }; if (/\bsingle\b/i.test(t) && n <= 8 && !(min > 50)) return { type: 'Single', why: 'the title says Single', explicit: true }; if (n >= 2) { const norm = tracks.map(x => normTrackTitle(x.title)); if (norm[0] && norm.every(x => x === norm[0])) return { type: 'Single', why: `every track is "${norm[0]}" in another version` }; } if (!Number.isFinite(ms)) { if (n === 1) return { type: 'Single', why: 'one track' }; if (n >= 3 && n <= 6) return { type: 'EP', why: `${n} tracks` }; if (n >= 7) return { type: 'Album', why: `${n} tracks` }; return { type: null, why: 'two tracks without lengths' }; } if (n >= 7) return { type: 'Album', why: `${n} tracks` }; if (min > 30) return { type: 'Album', why: `${Math.round(min)} minutes` }; if (min < 1) return { type: null, why: 'under a minute' }; if (min <= 7) return { type: 'Single', why: `${n} track(s), ${min.toFixed(1)} minutes` }; if (n >= 2) return { type: 'EP', why: `${n} tracks, ${Math.round(min)} minutes` }; return { type: null, why: `one track of ${Math.round(min)} minutes` }; } // Latn when every letter in the titles is Latin; nothing otherwise (left for the editor). // #650 (majkinetor): "fail to add multiple labels 'Crystal Method / Geffen'". A platform that has // one label field writes two labels in it with " / " between them (Apple, Qobuz). Only a spaced // slash splits: "AC/DC Records" is one name. A split label loses the platform's link, which is // the combined name's page, not either label's. function splitLabels(labels) { const out = []; for (const l of labels || []) { const parts = String(l.name || '').split(/\s+\/\s+/).map(x => x.trim()).filter(Boolean); if (parts.length < 2) { out.push(l); continue; } Log.info(`label "${l.name}" is ${parts.length} labels: ${parts.join(' | ')}`); parts.forEach(name => out.push({ name, catno: l.catno || '' })); } return out; } // A label from a copyright line, for a platform that has no label field (Tidal, #650: "This tidal // release didn't add label (Outpost Recordings)"). "℗ 2020 Outpost Recordings" → Outpost Recordings; // "℗ 2013 Daft Life Limited under exclusive license to Columbia Records, a Division of Sony Music // Entertainment" → Columbia Records, the label the release came out on. Anything that doesn't read // as one name (several years and owners, "All rights reserved" alone) gives none. function labelFromCopyright(text) { let t = String(text || '').trim(); if (!t) return null; t = t.replace(/^(?:\(?[℗©]\)?|\([pc]\))\s*/i, '').replace(/^(?:\d{4}\s*(?:[-–,]\s*\d{4}\s*)?)+/, '').trim(); const lic = t.match(/\bunder (?:exclusive )?licen[cs]e to\s+(.+)$/i); if (lic) t = lic[1]; t = t.replace(/,\s*(?:a|an)\s+(?:division|label|imprint|company)\b.*$/i, '').replace(/\.?\s*all rights reserved\.?$/i, '').replace(/[.,;\s]+$/, '').trim(); if (!t || t.length > 60 || /[℗©]|\b\d{4}\b/.test(t) || /^(?:all rights reserved|under licen[cs]e)/i.test(t)) return null; return t; } // #650 (majkinetor): "lets make FC annotation also per platform (enable by default)". Kept as the // platforms it is turned off on ({ platformName: true }), so a new platform starts on. The old // one-for-all setting (off by default) is dropped once: every platform starts on. const ANNOTATION_OFF_KEY = 'fc.annotation.off'; function annotationOn(name) { const s = GM_getValue('fc.settings', {}) || {}; if (Object.prototype.hasOwnProperty.call(s, 'annotation')) { delete s.annotation; GM_setValue('fc.settings', s); Log.info('Annotation from the platform\'s notes is now per platform, on everywhere to start with'); } return !(GM_getValue(ANNOTATION_OFF_KEY, {}) || {})[name]; } function setAnnotationOn(name, on) { const all = Object.assign({}, GM_getValue(ANNOTATION_OFF_KEY, {}) || {}); if (on) delete all[name]; else all[name] = true; GM_setValue(ANNOTATION_OFF_KEY, all); } // #650 (majkinetor): "we should add annotations (should be optional) from all providers (Qobuz // above has it, BC almost always has it, Discogs has notes etc.)". A platform's notes as plain // text: its HTML's breaks and paragraphs become lines, the rest of the markup goes. function notesText(...parts) { const one = x => { let t = String(x || ''); if (/<[a-z][^>]*>|&[a-z#0-9]+;/i.test(t)) { t = t.replace(//gi, '\n').replace(/<\/(?:p|div|li|h\d)>/gi, '\n\n'); const d = document.createElement('textarea'); d.innerHTML = mbuHtml(t.replace(/<[^>]+>/g, '')); t = d.value; } return t.replace(/\r\n?/g, '\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); }; return parts.map(one).filter(Boolean).join('\n\n') || null; } // A compilation a platform credits to one of its artists (#650: Spotify credits "African Pearls - // Guinée - Cultural Revolution" to Bembeya Jazz National, who plays 2 of its 25 tracks). When the // release's artists are on fewer than half the tracks and the tracks have five or more artists, // it is Various Artists, as MusicBrainz credits a compilation. Returns why, or null. function variousArtistsWhy(rel) { if (!rel.credit.length || rel.credit.some(c => c.mbid === VARIOUS_ARTISTS_MBID)) return null; const tracks = [].concat(...rel.mediums.map(m => m.tracks)); if (tracks.length < 5) return null; const mains = new Set(rel.credit.map(c => normName(c.artistName || c.name))); const on = tracks.filter(t => (t.credit || []).some(c => mains.has(normName(c.artistName || c.name)))).length; const artists = new Set([].concat(...tracks.map(t => (t.credit || []).slice(0, 1).map(c => normName(c.artistName || c.name))))); if (on * 2 >= tracks.length || artists.size < 5) return null; return `${rel.credit.map(c => c.name).join(', ')} on ${on} of ${tracks.length} tracks, ${artists.size} track artists`; } function guessScript(texts) { const letters = texts.join(' ').replace(/[^\p{L}]/gu, ''); if (!letters) return null; return /^[\p{Script=Latin}]+$/u.test(letters) ? 'Latn' : null; } /* ── providers ─────────────────────────────────────────────────────────────── One object per platform. Kept deliberately thin: recognise the album page, fetch the release and return it in First Contact's model. Everything MusicBrainz-specific (the seed, the handoff) is shared code below; matching is Apollo's. Model: { source, url, title, credit:[Credit], types:[primary, ...secondary], status, packaging, date:{year,month,day}, country, barcode, labels:[{name, catno}], urls:[{url, linkType}], mediums:[{ format, name, tracks:[{ title, lengthMs, isrc, url, credit:[Credit] }] }] } Credit = { name, url?, mbid?, join } */ const DEEZER = { id: 'deezer', name: 'Deezer', host: /^www\.deezer\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?album\/(\d+)\/?$/i); return m ? m[1] : null; }, albumUrl: id => `https://www.deezer.com/album/${id}`, // #659: the page is built in the browser, so its snapshot is near empty; the API answer FC reads is the evidence archiveApi: id => `https://api.deezer.com/album/${id}`, artistUrl: id => `https://www.deezer.com/artist/${id}`, TYPES: { album: ['Album'], ep: ['EP'], single: ['Single'], compile: ['Album', 'Compilation'] }, VARIOUS: 5080, async fetchRelease(id, progress) { const album = await gmJson(`https://api.deezer.com/album/${id}`); if (album.error) throw new Error(`Deezer: ${album.error.message || album.error.type} (album ${id})`); Log.info(`Deezer album ${id}: "${album.title}" by ${(album.contributors || []).map(c => `${c.name} [${c.role}]`).join(', ') || album.artist?.name} · ${album.nb_tracks} track(s) · ${album.record_type} · ${album.release_date} · UPC ${album.upc || '—'} · label "${album.label || ''}"`); // the album's own tracks list is capped; page through the tracks endpoint const tracks = []; let next = `https://api.deezer.com/album/${id}/tracks?limit=100`; while (next) { const page = await gmJson(next); if (page.error) throw new Error(`Deezer: ${page.error.message || page.error.type} (tracks of ${id})`); tracks.push(...(page.data || [])); next = page.next || null; } Log.info(`Deezer: ${tracks.length} track(s) listed${tracks.length !== album.nb_tracks ? ` (album says ${album.nb_tracks})` : ''}`); // Per-track contributors: the tracks list carries only the main artist. Deezer marks // featured artists "Main" too, so the title's feat. clause decides who is featured. let done = 0; const details = await mapLimit(tracks, 4, async t => { let d = null; for (let attempt = 0; attempt < 3 && !d; attempt++) { try { const r = await gmJson(`https://api.deezer.com/track/${t.id}`); if (r.error && r.error.code === 4) { Log.debug(`Deezer quota hit on track ${t.id}, retrying`); await new Promise(res => setTimeout(res, 1200)); continue; } if (r.error) { Log.warn(`Deezer track ${t.id}: ${r.error.message || r.error.type}`); break; } d = r; } catch (e) { Log.warn(`Deezer track ${t.id}: ${e.message}`); } } progress && progress(++done, tracks.length); return d; }); const artistOf = c => ({ name: c.name, url: this.artistUrl(c.id), platformId: c.id }); const mediums = []; tracks.forEach((t, i) => { const d = details[i] || {}; // title_short has no version; title_version can repeat the feat. clause const short = splitFeat(t.title_short || t.title); const ver = splitFeat(t.title_version || ''); const feat = short.feat.length ? short.feat : ver.feat; let title = short.title; if (ver.title && !/^\(?\s*original mix\s*\)?$/i.test(ver.title)) title += ' ' + ver.title; const contributors = (d.contributors && d.contributors.length ? d.contributors : [t.artist]).filter(Boolean).map(artistOf); const credit = creditFromTitle(contributors, feat); const disc = t.disk_number || d.disk_number || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title, lengthMs: (t.duration || 0) * 1000 || null, isrc: t.isrc || d.isrc || null, url: t.link || null, credit }); Log.debug(`track ${disc}.${mediums[disc - 1].tracks.length}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${t.duration}s, ${t.isrc || 'no ISRC'})${d.contributors ? '' : ' [no track details: main artist only]'}`); }); const at = splitFeat(album.title); const albumContribs = (album.contributors && album.contributors.length ? album.contributors : [album.artist]).filter(Boolean) .filter(c => c.role !== 'Featured' || at.feat.length).map(artistOf); let credit = creditFromTitle(albumContribs, at.feat); if (album.artist && album.artist.id === this.VARIOUS) credit = [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, url: this.artistUrl(this.VARIOUS), join: '' }]; const [y, m, dd] = String(album.release_date || '').split('-').map(n => parseInt(n, 10)); return { source: this.id, url: this.albumUrl(id), title: at.title, credit, types: this.TYPES[album.record_type] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: dd || null }, country: 'XW', barcode: album.upc || null, labels: album.label ? [{ name: album.label, catno: '' }] : [], urls: [{ url: this.albumUrl(id), linkType: 85 }], // 85 = stream for free mediums, }; }, }; // Bandcamp: everything is on the album page itself (the tralbum JSON and the ld+json), so // nothing is fetched. Only the artist's own page has a link: track artists on a label's // compilation are names. const BANDCAMP = { id: 'bandcamp', name: 'Bandcamp', host: /(^|\.)bandcamp\.com$/, albumId(loc) { return /^\/album\/[^/]+\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, VARIOUS: /^various( artists)?$/i, // a "04 Mar 2011 00:00:00 GMT" date, read in UTC date(s) { const d = s ? new Date(s) : null; return d && !isNaN(d) ? { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() } : { year: null, month: null, day: null }; }, async fetchRelease(path, progress) { const tEl = document.querySelector('script[data-tralbum]'); if (!tEl) throw new Error('Bandcamp: no album data on this page (tralbum)'); const t = JSON.parse(tEl.dataset.tralbum); let ld = null; try { ld = JSON.parse((document.querySelector('script[type="application/ld+json"]') || {}).textContent || 'null'); } catch (e) { Log.warn(`Bandcamp ld+json: ${e.message}`); } const cur = t.current || {}; const url = (t.url || location.origin + path).replace(/^http:/, 'https:'); const by = ld && ld.byArtist ? { name: ld.byArtist.name, url: ld.byArtist['@id'] || null } : null; const pub = ld && ld.publisher ? { name: ld.publisher.name, url: ld.publisher['@id'] || null } : null; const albumArtist = t.artist || cur.artist || (by && by.name) || ''; Log.info(`Bandcamp album "${cur.title}" by ${albumArtist} · ${(t.trackinfo || []).length} track(s) · released ${t.album_release_date || cur.release_date} · UPC ${cur.upc || '—'} · by ${by ? by.name + ' ' + (by.url || '(no page)') : '—'} · publisher ${pub ? pub.name + ' ' + (pub.url || '') : '—'}${t.album_is_preorder ? ' · PREORDER' : ''}`); let band = null; try { band = JSON.parse((document.querySelector('script[data-band]') || {}).dataset?.band || 'null'); } catch (e) { Log.debug(`Bandcamp data-band: ${e.message}`); } // the account this page belongs to: an artist's own, or a label's const account = { name: (band && band.name) || (pub && pub.name) || '', url: (pub && pub.url) || location.origin }; Log.debug(`Bandcamp account: ${account.name} ${account.url}`); const isVarious = this.VARIOUS.test(albumArtist); // "Future Funk Squad, Omega Sparx, Stu Brootal, The Crystal Method" is four artists; // the account's own name is never split ("Earth, Wind & Fire" on its own page) const splitNames = s => normName(s) === normName(account.name) ? [s] : String(s).split(/\s*,\s*|\s+&\s+/).map(n => n.trim()).filter(Boolean); // a Bandcamp page for an artist: byArtist's own, or the account's when it is that artist's const linkFor = n => (by && by.url && normName(n) === normName(by.name)) ? by.url : normName(n) === normName(account.name) ? account.url : null; const artistsOf = s => splitNames(s).map(n => ({ name: n, url: linkFor(n) })); const albumArtists = isVarious ? [] : artistsOf(albumArtist); const at = splitFeat(cur.title || ''); const credit = isVarious ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, at.feat); const tracks = (t.trackinfo || []).map((x, i) => { let title = x.title || ''; const trackArtist = x.artist || null; // a compilation's track titles repeat the artist: "Michna - Triple Chrome Dipped" if (trackArtist && title.toLowerCase().startsWith(trackArtist.toLowerCase() + ' - ')) title = title.slice(trackArtist.length + 3); const tf = splitFeat(title); const mains = trackArtist && normName(trackArtist) !== normName(albumArtist) ? artistsOf(trackArtist) : isVarious ? [{ name: trackArtist || 'Various Artists' }] : albumArtists; const tc = creditFromTitle(mains, tf.feat); progress && progress(i + 1, t.trackinfo.length); return { title: tf.title, lengthMs: x.duration ? Math.round(x.duration * 1000) : null, isrc: null, url: x.title_link ? new URL(x.title_link, url).href : null, credit: tc, }; }); tracks.forEach((x, i) => Log.debug(`track ${i + 1}: "${x.title}" — ${x.credit.map(c => c.name + c.join).join('')} (${x.lengthMs ? x.lengthMs / 1000 + 's' : 'no length'})`)); // a label account publishing someone else's album: that's the label. An account that // belongs to one of the credited artists is not. const label = pub && pub.name && (!by || !by.url || pub.url !== by.url) && normName(pub.name) !== normName(albumArtist) && !albumArtists.some(a => normName(a.name) === normName(pub.name)) ? pub.name : null; const streamable = t.hasAudio && (t.trackinfo || []).some(x => x.streaming); return { source: this.id, annotation: notesText(cur.about, cur.credits), // the album's about and credits url, title: at.title, credit, types: [], // Bandcamp doesn't say; the title's EP / Single, or one track, decide it (importCurrent) status: 'official', packaging: 'None', date: this.date(t.album_release_date || cur.release_date), country: 'XW', barcode: cur.upc || null, labels: label ? [{ name: label, catno: '' }] : [], // 74 = purchase for download, 85 = stream for free: both, on one URL, as MB allows urls: [{ url, linkType: 74 }].concat(streamable ? [{ url, linkType: 85 }] : []), mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Discogs: the public API (api.discogs.com/releases/), no token. The mappings are those of // murdos's Discogs importer (github.com/murdos/musicbrainz-userscripts): its country and media // tables, LP = 12" vinyl, sides A/B → medium 1, C/D → 2, sub-tracks folded into their index track. const DISCOGS_COUNTRIES = { Worldwide: "XW", Afghanistan: "AF", Albania: "AL", Algeria: "DZ", "American Samoa": "AS", Andorra: "AD", Angola: "AO", Anguilla: "AI", Antarctica: "AQ", "Antigua and Barbuda": "AG", Argentina: "AR", Armenia: "AM", Aruba: "AW", Australia: "AU", Austria: "AT", Azerbaijan: "AZ", Bahamas: "BS", Bahrain: "BH", Bangladesh: "BD", Barbados: "BB", "Barbados, The": "BB", Belarus: "BY", Belgium: "BE", Belize: "BZ", Benin: "BJ", Bermuda: "BM", Bhutan: "BT", Bolivia: "BO", Croatia: "HR", Botswana: "BW", "Bouvet Island": "BV", Brazil: "BR", "British Indian Ocean Territory": "IO", "Brunei Darussalam": "BN", Bulgaria: "BG", "Burkina Faso": "BF", Burundi: "BI", Cambodia: "KH", Cameroon: "CM", Canada: "CA", "Cape Verde": "CV", "Cayman Islands": "KY", "Central African Republic": "CF", Chad: "TD", Chile: "CL", China: "CN", "Christmas Island": "CX", "Cocos (Keeling) Islands": "CC", Colombia: "CO", Comoros: "KM", Congo: "CG", "Cook Islands": "CK", "Costa Rica": "CR", "Virgin Islands, British": "VG", Cuba: "CU", Cyprus: "CY", "Czech Republic": "CZ", Denmark: "DK", Djibouti: "DJ", Dominica: "DM", "Dominican Republic": "DO", Ecuador: "EC", Egypt: "EG", "El Salvador": "SV", "Equatorial Guinea": "GQ", Eritrea: "ER", Estonia: "EE", Ethiopia: "ET", "Falkland Islands (Malvinas)": "FK", "Faroe Islands": "FO", Fiji: "FJ", Finland: "FI", France: "FR", "French Guiana": "GF", "French Polynesia": "PF", "French Southern Territories": "TF", Gabon: "GA", Gambia: "GM", Georgia: "GE", Germany: "DE", Ghana: "GH", Gibraltar: "GI", Greece: "GR", Greenland: "GL", Grenada: "GD", Guadeloupe: "GP", Guam: "GU", Guatemala: "GT", Guinea: "GN", "Guinea-Bissau": "GW", Guyana: "GY", Haiti: "HT", "Virgin Islands, U.S.": "VI", Honduras: "HN", "Hong Kong": "HK", Hungary: "HU", Iceland: "IS", India: "IN", Indonesia: "ID", "Wallis and Futuna": "WF", Iraq: "IQ", Ireland: "IE", Israel: "IL", Italy: "IT", Jamaica: "JM", Japan: "JP", Jordan: "JO", Kazakhstan: "KZ", Kenya: "KE", Kiribati: "KI", Kuwait: "KW", Kyrgyzstan: "KG", "Lao People's Democratic Republic": "LA", Latvia: "LV", Lebanon: "LB", Lesotho: "LS", Liberia: "LR", "Libyan Arab Jamahiriya": "LY", Liechtenstein: "LI", Lithuania: "LT", Luxembourg: "LU", Montserrat: "MS", Macedonia: "MK", Madagascar: "MG", Malawi: "MW", Malaysia: "MY", Maldives: "MV", Mali: "ML", Malta: "MT", "Marshall Islands": "MH", Martinique: "MQ", Mauritania: "MR", Mauritius: "MU", Mayotte: "YT", Mexico: "MX", "Micronesia, Federated States of": "FM", Morocco: "MA", Monaco: "MC", Mongolia: "MN", Mozambique: "MZ", Myanmar: "MM", Namibia: "NA", Nauru: "NR", Nepal: "NP", Netherlands: "NL", "Netherlands Antilles": "AN", "New Caledonia": "NC", "New Zealand": "NZ", Nicaragua: "NI", Niger: "NE", Nigeria: "NG", Niue: "NU", "Norfolk Island": "NF", "Northern Mariana Islands": "MP", Norway: "NO", Oman: "OM", Pakistan: "PK", Palau: "PW", Panama: "PA", "Papua New Guinea": "PG", Paraguay: "PY", Peru: "PE", Philippines: "PH", Pitcairn: "PN", Poland: "PL", Portugal: "PT", "Puerto Rico": "PR", Qatar: "QA", Reunion: "RE", Romania: "RO", "Russian Federation": "RU", Russia: "RU", Rwanda: "RW", "Saint Kitts and Nevis": "KN", "Saint Lucia": "LC", "Saint Vincent and The Grenadines": "VC", Samoa: "WS", "San Marino": "SM", "Sao Tome and Principe": "ST", "Saudi Arabia": "SA", Senegal: "SN", Seychelles: "SC", "Sierra Leone": "SL", Singapore: "SG", Slovenia: "SI", "Solomon Islands": "SB", Somalia: "SO", "South Africa": "ZA", Spain: "ES", "Sri Lanka": "LK", Sudan: "SD", Suriname: "SR", Swaziland: "SZ", Sweden: "SE", Switzerland: "CH", "Syrian Arab Republic": "SY", Tajikistan: "TJ", "Tanzania, United Republic of": "TZ", Thailand: "TH", Togo: "TG", Tokelau: "TK", Tonga: "TO", "Trinidad & Tobago": "TT", Tunisia: "TN", Turkey: "TR", Turkmenistan: "TM", "Turks and Caicos Islands": "TC", Tuvalu: "TV", Uganda: "UG", Ukraine: "UA", "United Arab Emirates": "AE", UK: "GB", US: "US", "United States Minor Outlying Islands": "UM", Uruguay: "UY", Uzbekistan: "UZ", Vanuatu: "VU", "Vatican City State (Holy See)": "VA", Venezuela: "VE", "Viet Nam": "VN", "Western Sahara": "EH", Yemen: "YE", Zambia: "ZM", Zimbabwe: "ZW", Taiwan: "TW", "[Worldwide]": "XW", Europe: "XE", USSR: "SU", "East Germany (historical, 1949-1990)": "XG", Czechoslovakia: "XC", "Congo, Republic of the": "CD", Slovakia: "SK", "Bosnia & Herzegovina": "BA", "Korea (North), Democratic People's Republic of": "KP", "North Korea": "KP", "Korea (South), Republic of": "KR", "South Korea": "KR", Montenegro: "ME", "South Georgia and the South Sandwich Islands": "GS", "Palestinian Territory": "PS", Macao: "MO", "Timor-Leste": "TL", "<85>land Islands": "AX", Guernsey: "GG", "Isle of Man": "IM", Jersey: "JE", Serbia: "RS", "Saint Barthélemy": "BL", "Saint Martin": "MF", Moldova: "MD", Yugoslavia: "YU", "Serbia and Montenegro": "CS", "Côte d'Ivoire": "CI", "Heard Island and McDonald Islands": "HM", "Iran, Islamic Republic of": "IR", "Saint Pierre and Miquelon": "PM", "Saint Helena": "SH", "Svalbard and Jan Mayen": "SJ" }; const DISCOGS_MEDIA = { "8-Track Cartridge": "Cartridge", Acetate: "Acetate", "Acetate7\"": "7\" Acetate", "Acetate10\"": "10\" Acetate", "Acetate12\"": "12\" Acetate", Betamax: "Betamax", "Blu-ray": "Blu-ray", "Blu-ray-R": "Blu-ray", Cassette: "Cassette", CD: "CD", CDr: "CD-R", CDV: "CDV", "CD+G": "CD+G", Cylinder: "Wax Cylinder", DAT: "DAT", Datassette: "Other", DCC: "DCC", DVD: "DVD", DVDr: "DVD", "DVD-Audio": "DVD-Audio", "DVD-Video": "DVD-Video", "Edison Disc": "Vinyl", File: "Digital Media", "Flexi-disc": "Vinyl", "Floppy Disk": "Other", HDCD: "HDCD", "HD DVD": "HD-DVD", "HD DVD-R": "HD-DVD", Hybrid: "Other", Laserdisc: "LaserDisc", "Memory Stick": "USB Flash Drive", Microcassette: "Other", Minidisc: "MiniDisc", MVD: "Other", "Reel-To-Reel": "Reel-to-reel", SACD: "SACD", SelectaVision: "Other", Shellac: "Shellac", "Shellac7\"": "7\" Shellac", "Shellac10\"": "10\" Shellac", "Shellac12\"": "12\" Shellac", SVCD: "SVCD", UMD: "UMD", VCD: "VCD", VHS: "VHS", "Video 2000": "Other", Vinyl: "Vinyl", "Vinyl7\"": "7\" Vinyl", "Vinyl10\"": "10\" Vinyl", "Vinyl12\"": "12\" Vinyl", "Lathe Cut": "Phonograph record" }; const DISCOGS_PACKAGING = [[/cardboard|paper/, 'Cardboard/Paper Sleeve'], [/digi[\s\-‐]?pac?k/, 'Digipak'], [/keepcase/, 'Keep Case'], [/slimjewel/, 'Slim Jewel Case'], [/jewel/, 'Jewel Case'], [/gatefold|digisleeve/, 'Gatefold Cover']]; const DISCOGS = { id: 'discogs', name: 'Discogs', host: /^(www\.)?discogs\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:[a-z]{2}\/)?release\/(\d+)(?:-[^/]*)?\/?$/i); return m ? m[1] : null; }, VARIOUS: 194, NO_LABEL: 750, // "Not On Label" noNum: n => String(n || '').replace(/ \(\d+\)$/, ''), // Discogs joins: "&", ",", "Feat.", "Vs", "And", "With", "x" → MB style join(j) { const t = String(j || '').trim(); if (!t) return ''; if (t === ',') return ', '; const k = t.toLowerCase().replace(/\.$/, ''); const map = { feat: ' feat. ', featuring: ' feat. ', ft: ' feat. ', vs: ' vs. ', and: ' and ', with: ' with ', x: ' x ', '&': ' & ', '+': ' + ', '/': ' / ' }; return map[k] || ` ${t} `; }, artist(a) { if (a.id === this.VARIOUS) return { name: 'Various Artists', artistName: 'Various Artists', url: null, mbid: VARIOUS_ARTISTS_MBID }; return { name: a.anv || this.noNum(a.name), artistName: this.noNum(a.name), url: a.id ? `https://www.discogs.com/artist/${a.id}` : null, mbid: null, }; }, credit(artists, featuring) { const out = (artists || []).map(a => Object.assign(this.artist(a), { join: this.join(a.join) })); if (out.length) out[out.length - 1].join = ''; const feats = (featuring || []).filter(f => !out.some(o => o.url && o.url === this.artist(f).url)); if (feats.length) { if (out.length) out[out.length - 1].join = ' feat. '; feats.forEach((a, i) => out.push(Object.assign(this.artist(a), { join: i === feats.length - 1 ? '' : i === feats.length - 2 ? ' & ' : ', ' }))); } return out; }, ms(d) { const p = String(d || '').split(':').map(Number); return p.length > 1 && p.every(n => Number.isFinite(n)) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, async fetchRelease(id) { const r = await gmJson(`https://api.discogs.com/releases/${id}`); if (!r || !r.title) throw new Error(`Discogs: no release ${id}${r && r.message ? ` (${r.message})` : ''}`); Log.info(`Discogs release ${id}: "${r.title}" by ${(r.artists || []).map(a => a.name + (a.join ? ' ' + a.join : '')).join(' ')} · ${r.country || '—'} · ${r.released || '—'} · formats ${(r.formats || []).map(f => `${f.qty}×${f.name} [${(f.descriptions || []).join(', ')}]${f.text ? ' "' + f.text + '"' : ''}`).join(' + ')} · ${(r.tracklist || []).length} tracklist row(s)`); // formats → one MB format per medium, plus type, status, packaging const formats = []; let primary = null, status = 'official', packaging = null; const secondary = []; for (const f of r.formats || []) { let fmt = DISCOGS_MEDIA[f.name]; for (const d of f.descriptions || []) { if (/7"|10"|12"/.test(d) && DISCOGS_MEDIA[f.name + d]) fmt = DISCOGS_MEDIA[f.name + d]; if (/^(VCD|SVCD|CD\+G|HDCD|DVD-Audio|DVD-Video)$/.test(d) && DISCOGS_MEDIA[d]) fmt = DISCOGS_MEDIA[d]; if (f.name === 'Vinyl' && d === 'LP') fmt = '12" Vinyl'; if (f.name === 'CD' && d === 'Mini') fmt = '8cm CD'; if (/Promo|Smplr/.test(d)) status = 'promotion'; if (/Unofficial Release/.test(d)) status = 'bootleg'; if (/Compilation/.test(d) && !secondary.includes('Compilation')) secondary.push('Compilation'); if (/^Album/.test(d)) primary = primary || 'Album'; if (/Single(?! Sided)/.test(d)) primary = 'Single'; if (/^(EP|Mini-Album)$/.test(d)) primary = 'EP'; } const text = String(f.text || '').toLowerCase().replace(/[\s-]/g, ''); for (const [re, p] of DISCOGS_PACKAGING) if (!packaging && re.test(text)) packaging = p; if (fmt) for (let q = 0; q < (parseInt(f.qty, 10) || 1); q++) formats.push(fmt); else if (f.name !== 'Box Set' && f.name !== 'All Media') Log.warn(`Discogs format "${f.name}" has no MusicBrainz format`); } Log.debug(`formats → ${formats.join(', ') || 'none'} · type ${primary || '—'}${secondary.length ? ' + ' + secondary.join(' + ') : ''} · status ${status} · packaging ${packaging || '—'}`); // tracklist → mediums by position: "1-3" / "CD2-4" / "2.4" (medium-track), "A1" (sides, // two per medium), "1" (a number that starts over begins the next medium) const mediums = []; let heading = '', med = 1, last = 0, odd = false; const releaseCredit = this.credit(r.artists); for (const t of r.tracklist || []) { // a heading names the medium that follows: "CD 1 Routine" → "Routine" if (t.type_ === 'heading') { heading = String(t.title || '').replace(/^(?:CD|Disc|Disk|DVD|LP|Vinyl|Side)\s*\d+\s*[-:–.]?\s*/i, ''); continue; } if (t.type_ !== 'track' && t.type_ !== 'index') continue; let title = String(t.title || '').replace(/´/g, '’'); let pos = t.position || ''; let len = this.ms(t.duration); if (t.type_ === 'index' && t.sub_tracks) { const subs = t.sub_tracks.filter(x => x.type_ === 'track'); if (!pos && subs[0]) pos = subs[0].position || ''; if (subs.length) title += (title ? ': ' : '') + subs.map(x => x.title || '[unknown]').join(' / '); if (!len) { const sum = subs.reduce((a, x) => a + (this.ms(x.duration) || 0), 0); len = sum || null; } } if (!pos || /^(video|mp3)/i.test(pos)) { Log.debug(`skipped tracklist row "${title}" (position "${t.position}")`); continue; } let m; if ((m = pos.match(/^(?:[a-z]+)?(\d+)[.-](\d+)/i))) { med = +m[1]; last = +m[2]; } else if (/^[A-Z]\d*$/i.test(pos)) { med = (((32 | pos.charCodeAt(0)) - 97) >> 1) + 1; last++; } else if ((m = pos.match(/^(\d+)/))) { if (+m[1] <= last) med++; last = +m[1]; } else { odd = true; last++; } while (mediums.length < med) { mediums.push({ format: formats[mediums.length] || formats[formats.length - 1] || null, name: heading, tracks: [] }); heading = ''; } const feat = (t.extraartists || []).filter(e => /^Featuring\b/.test(e.role || '')); const credit = t.artists && t.artists.length ? this.credit(t.artists, feat) : feat.length ? this.credit(r.artists, feat) : releaseCredit; const medium = mediums[med - 1]; const sided = /Vinyl|Cassette|Shellac|Acetate/.test(medium.format || ''); medium.tracks.push({ title, lengthMs: len, isrc: null, url: null, credit, number: sided && /^[A-Z]+[.-]?\d*$/i.test(pos) ? pos : null }); Log.debug(`track ${pos} → medium ${med}: "${title}" — ${credit.map(c => c.name + c.join).join('')}${len ? '' : ' (no length)'}`); } if (odd) Log.warn('Discogs: some track positions could not be read; check the medium split'); const empty = mediums.filter(x => !x.tracks.length).length; if (empty) Log.warn(`Discogs: ${empty} medium(s) without tracks`); if (mediums.length === 1) mediums[0].name = ''; // labels; the same label listed twice with its catalog number written two ways is kept once const seen = new Set(), labels = []; for (const l of r.labels || []) { const none = l.id === this.NO_LABEL; const name = none ? '[no label]' : this.noNum(l.name); const catno = /^none$/i.test(l.catno || '') ? '[none]' : (l.catno || ''); const key = normName(name) + '|' + catno.replace(/[\s.-]/g, '').toLowerCase(); if (seen.has(key)) continue; seen.add(key); labels.push({ name, catno, url: none ? null : `https://www.discogs.com/label/${l.id}`, mbid: none ? '157afde4-4bf5-4039-8ad2-5a15acc85176' : null }); } const barcode = ((r.identifiers || []).find(i => i.type === 'Barcode' && i.value) || {}).value; const [y, mo, d] = String(r.released || '').split(/\D+/).map(n => parseInt(n, 10)); const country = DISCOGS_COUNTRIES[r.country] || null; if (r.country && !country) Log.info(`Discogs country "${r.country}" is no single MusicBrainz country; left for you`); const url = `https://www.discogs.com/release/${id}`; return { source: this.id, annotation: notesText(r.notes), url, title: r.title, credit: releaseCredit, types: primary ? [primary].concat(secondary) : secondary.length ? ['Album'].concat(secondary) : [], status, packaging, date: { year: y || null, month: mo || null, day: d || null }, country, barcode: barcode ? barcode.replace(/[^\dX]/gi, '') : null, labels, urls: [{ url, linkType: 76 }], // 76 = discogs mediums, }; }, }; // Apple Music: its own catalogue API (amp-api.music.apple.com), read with the bearer token the // web player's public JS carries — the way Platform Check, ISRC Scout and Credit Hoarder read // it (#627). Unlike the iTunes Search API it has the UPC, each track's ISRC, the label and every // track artist's Apple id. The storefront is the page's (music.apple.com//album/…). const APPLE = { id: 'apple', name: 'Apple Music', host: /^music\.apple\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)\/?$/i); return m ? `${m[1].toLowerCase()}/${m[2]}` : null; }, // #659: amp-api needs a token Wayback can't send; the public iTunes lookup has the same album and tracks archiveApi(id) { const [cc, n] = id.split('/'); return `https://itunes.apple.com/lookup?id=${n}&entity=song&country=${cc}`; }, TOKEN_KEY: 'fc.apple-token', _tok: null, async token(fresh) { if (this._tok && !fresh) return this._tok; if (!fresh) { const c = GM_getValue(this.TOKEN_KEY, null); if (c && c.t && Date.now() - c.at < 12 * 3600e3) return (this._tok = c.t); } Log.info('Apple Music: fetching the web player\'s token'); const home = await gmText('https://music.apple.com/us/browse', { Accept: 'text/html' }); const asset = (home.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0]; if (!asset) throw new Error('Apple Music: the web player\'s JS was not found'); const js = await gmText('https://music.apple.com' + asset, { Accept: '*/*' }); const tok = (js.match(/eyJ[A-Za-z0-9._-]{80,}/) || [])[0]; if (!tok) throw new Error('Apple Music: no token in the web player\'s JS'); GM_setValue(this.TOKEN_KEY, { t: tok, at: Date.now() }); return (this._tok = tok); }, // one amp-api read; a 401 is a rotated token: fetch a new one, once async amp(path) { for (let attempt = 0; ; attempt++) { const tok = await this.token(attempt > 0); try { return await gmJson(`https://amp-api.music.apple.com${path}${path.includes('?') ? '&' : '?'}l=en-US`, { Authorization: 'Bearer ' + tok, Origin: 'https://music.apple.com' }); } catch (e) { if (e.status === 401 && attempt === 0) { Log.warn('Apple Music: the token was refused (401), fetching a new one'); continue; } throw e; } } }, // "Daft Punk, Pharrell Williams & Nile Rodgers" → its names, each with the Apple artist of that // name when the song lists one artists(text, rel, sf) { const byName = new Map((rel || []).map(a => [normName(a.attributes && a.attributes.name), a])); const url = a => (a.attributes && a.attributes.url) || `https://music.apple.com/${sf}/artist/${a.id}`; const names = String(text || '').split(/\s*,\s*|\s+&\s+/).map(n => n.trim()).filter(Boolean); const out = names.map(n => { const a = byName.get(normName(n)); return { name: n, url: a ? url(a) : null }; }); // a listed artist the text leaves out (rare) still belongs in the credit for (const a of rel || []) if (!out.some(o => normName(o.name) === normName(a.attributes && a.attributes.name))) out.push({ name: a.attributes ? a.attributes.name : String(a.id), url: url(a) }); return out; }, async fetchRelease(key, progress) { const [sf, id] = key.split('/'); const j = await this.amp(`/v1/catalog/${sf}/albums/${id}?include=tracks,artists&include[songs]=artists`); const a = j && j.data && j.data[0]; if (!a) throw new Error(`Apple Music: no album ${id} in the "${sf}" storefront`); const at = a.attributes || {}; Log.info(`Apple Music album ${sf}/${id}: "${at.name}" by ${at.artistName} · ${at.trackCount} track(s) · ${at.releaseDate} · UPC ${at.upc || '—'} · label "${at.recordLabel || ''}"${at.isSingle ? ' · single' : ''}${at.isCompilation ? ' · compilation' : ''}`); const items = (a.relationships.tracks.data || []).slice(); for (let next = a.relationships.tracks.next; next;) { const page = await this.amp(next + (next.includes('include') ? '' : '&include[songs]=artists')); items.push(...(page.data || [])); next = page.next || null; } const videos = items.filter(t => t.type !== 'songs'); if (videos.length) Log.info(`Apple Music: ${videos.length} music video(s) left out`); const songs = items.filter(t => t.type === 'songs'); const mediums = []; songs.forEach((t, i) => { const x = t.attributes || {}; const tf = splitFeat(x.name || ''); const listed = this.artists(x.artistName, t.relationships && t.relationships.artists && t.relationships.artists.data, sf); const credit = creditFromTitle(listed, tf.feat); const disc = x.discNumber || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: x.durationInMillis || null, isrc: x.isrc || null, url: x.url || null, credit }); progress && progress(i + 1, songs.length); Log.debug(`track ${disc}.${x.trackNumber}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${x.isrc || 'no ISRC'})`); }); // " - Single" / " - EP" is Apple's label for the release, not part of its title const suffix = (String(at.name || '').match(/\s+-\s+(Single|EP)$/) || [])[1]; const af = splitFeat(String(at.name || '').replace(/\s+-\s+(Single|EP)$/, '')); const relArtists = a.relationships.artists && a.relationships.artists.data; const credit = /^various artists$/i.test(at.artistName || '') ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(this.artists(at.artistName, relArtists, sf), af.feat); const [y, m, d] = String(at.releaseDate || '').split('-').map(n => parseInt(n, 10)); const url = (at.url || `https://music.apple.com/${sf}/album/${id}`).replace(/\?.*$/, ''); return { source: this.id, annotation: notesText(at.editorialNotes && (at.editorialNotes.standard || at.editorialNotes.short)), url, title: af.title, credit, types: (suffix ? [suffix] : at.isSingle ? ['Single'] : []).concat(at.isCompilation ? ['Compilation'] : []), status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: at.upc || null, labels: at.recordLabel ? [{ name: at.recordLabel, catno: '' }] : [], urls: [{ url, linkType: 980 }], // 980 = streaming page (paid) mediums, }; }, }; // Tidal: the official catalogue API (openapi.tidal.com/v2) with an app token from the // client-credentials grant — no login — as ISRC Scout reads it. One request has the album, its // tracks and every artist (include=artists,items,items.artists); longer albums page on. Tidal // has no label field, only a copyright line, which names the label most of the time: the label is // read from it (labelFromCopyright), and left for you when it doesn't read as one name. const TIDAL = { id: 'tidal', name: 'Tidal', host: /^(listen\.)?tidal\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:browse\/)?album\/(\d+)\/?$/); return m ? m[1] : null; }, CLIENT: 'cRhhDJDpYXXBn82U:K7UX40jDOZ5p4y4JMYZgoiwKi7jymTHWcLMb4gkewKs=', COUNTRIES: ['US', 'GB', 'DE'], TOKEN_KEY: 'fc.tidal-token', TYPES: { ALBUM: ['Album'], EP: ['EP'], SINGLE: ['Single'] }, artistUrl: id => `https://tidal.com/artist/${id}`, async token() { const c = GM_getValue(this.TOKEN_KEY, null); if (c && c.t && Date.now() < c.exp - 60000) return c.t; Log.info('Tidal: fetching an app token'); const j = await gmPostJson('https://auth.tidal.com/v1/oauth2/token', 'grant_type=client_credentials', { Authorization: 'Basic ' + btoa(this.CLIENT) }); if (!j || !j.access_token) throw new Error('Tidal: no app token'); GM_setValue(this.TOKEN_KEY, { t: j.access_token, exp: Date.now() + (j.expires_in || 14400) * 1000 }); return j.access_token; }, // one read; a 429 (Tidal throttles hard) waits as long as it says, three times at most async get(path) { for (let attempt = 0; ; attempt++) { try { return await gmJson('https://openapi.tidal.com/v2' + path, { Authorization: 'Bearer ' + await this.token(), Accept: 'application/vnd.api+json' }); } catch (e) { if (e.status === 401 && attempt === 0) { GM_deleteValue(this.TOKEN_KEY); continue; } if (e.status !== 429 || attempt >= 3) throw e; const wait = 1000 * 2 ** attempt; Log.warn(`Tidal: throttled (429), retrying in ${wait / 1000} s`); await new Promise(r => setTimeout(r, wait)); } } }, secs(iso) { const m = String(iso || '').match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/); return m ? ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) : 0; }, async fetchRelease(id, progress) { let j = null, cc = null; for (const c of this.COUNTRIES) { try { j = await this.get(`/albums/${id}?countryCode=${c}&include=artists,items,items.artists`); cc = c; break; } catch (e) { if (e.status !== 404) throw e; Log.info(`Tidal: album ${id} is not in the ${c} catalogue`); } } if (!j || !j.data) throw new Error(`Tidal: album ${id} is in none of the ${this.COUNTRIES.join(', ')} catalogues`); const a = j.data, at = a.attributes || {}; const included = new Map(); const keep = list => (list || []).forEach(x => included.set(x.type + ':' + x.id, x)); keep(j.included); const refs = ((a.relationships.items || {}).data || []).slice(); for (let next = ((a.relationships.items || {}).links || {}).next; next;) { const page = await this.get(next.replace(/^.*\/v2/, '') + (/[?&]include=/.test(next) ? '' : '&include=items,items.artists')); keep(page.included); refs.push(...(page.data || [])); next = (page.links || {}).next || null; } Log.info(`Tidal album ${id} (${cc}): "${at.title}" · ${at.albumType} · ${refs.length} item(s) · ${at.releaseDate} · UPC ${at.barcodeId || '—'} · ${(at.copyright || {}).text || 'no copyright line'}`); const artist = r => { const x = included.get('artists:' + r.id); return { name: x && x.attributes ? x.attributes.name : String(r.id), url: this.artistUrl(r.id) }; }; const mediums = []; let videos = 0; refs.forEach((r, i) => { if (r.type !== 'tracks') { videos++; return; } const t = included.get('tracks:' + r.id) || {}; const x = t.attributes || {}; const tf = splitFeat(x.title || ''); let title = tf.title; const vf = splitFeat(x.version || ''); if (vf.title) title += ` (${vf.title.replace(/^\((.*)\)$/, '$1')})`; const listed = ((t.relationships && t.relationships.artists && t.relationships.artists.data) || []).map(artist); const credit = creditFromTitle(listed, tf.feat.length ? tf.feat : vf.feat); const disc = (r.meta && r.meta.volumeNumber) || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title, lengthMs: this.secs(x.duration) * 1000 || null, isrc: x.isrc || null, url: `https://tidal.com/track/${r.id}`, credit }); progress && progress(i + 1, refs.length); Log.debug(`track ${disc}.${r.meta && r.meta.trackNumber}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${x.isrc || 'no ISRC'})`); }); if (videos) Log.info(`Tidal: ${videos} video(s) left out`); const af = splitFeat(at.title || ''); const listed = ((a.relationships.artists || {}).data || []).map(artist); const credit = listed.length === 1 && /^various artists$/i.test(listed[0].name) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(listed, af.feat); const [y, m, d] = String(at.releaseDate || '').split('-').map(n => parseInt(n, 10)); const url = `https://tidal.com/album/${id}`; return { source: this.id, url, title: af.title, credit, types: this.TYPES[at.albumType] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: at.barcodeId || null, labels: (lbl => (lbl ? [{ name: lbl, catno: '' }] : []))(labelFromCopyright((at.copyright || {}).text)), urls: [{ url, linkType: 980 }], // 980 = streaming page (paid) mediums, }; }, }; // Qobuz: the store page itself (www.qobuz.com//album//). Qobuz's API answers // only from the countries it serves, but the store page is rendered in full everywhere, so // nothing is fetched. The page's per-track artist is unreliable (Qobuz shows a member, like // Thomas Bangalter on a Daft Punk track), so an album by one artist credits its main artists on // every track, with the title's feat.; only a Various Artists album takes the track's artist. const QOBUZ = { id: 'qobuz', name: 'Qobuz', host: /^www\.qobuz\.com$/, albumId(loc) { return /^\/[a-z]{2}-[a-z]{2}\/album\/[^/]+\/[A-Za-z0-9]+\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, hms(t) { const p = String(t || '').trim().split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, text: el => (el ? el.textContent.replace(/\s+/g, ' ').trim() : ''), async fetchRelease(path, progress, doc) { doc = doc || document; let album = null, product = null; for (const sc of doc.querySelectorAll('script[type="application/ld+json"]')) { try { const j = JSON.parse(sc.textContent); if (j['@type'] === 'MusicAlbum') album = j; if (j['@type'] === 'Product') product = j; } catch (e) { Log.debug(`Qobuz ld+json: ${e.message}`); } } const title = this.text(doc.querySelector('.album-meta__title .album-title')) || (album && album.name) || ''; if (!title) throw new Error('Qobuz: no album on this page'); const root = new URL(path, location.origin); const mains = [...doc.querySelectorAll('.album-meta__item')].filter(li => /^\s*Main artists?\s*:/i.test(li.textContent)) .flatMap(li => [...li.querySelectorAll('a[href*="/interpreter/"]')]) .map(a => ({ name: (a.getAttribute('title') || this.text(a)).trim(), url: new URL(a.getAttribute('href'), root).href })); const shown = this.text(doc.querySelector('.album-meta__title .artist-name')); const labelA = [...doc.querySelectorAll('.album-meta__item a[href*="/label/"]')][0]; const label = this.text(labelA); const upc = String((product && product.sku) || '').replace(/\D/g, ''); const box = doc.querySelector('#playerTracks'); const nb = box ? parseInt(box.getAttribute('data-nbTracks'), 10) : NaN, shownN = box ? parseInt(box.getAttribute('data-nbTracksDisplayed'), 10) : NaN; Log.info(`Qobuz album ${path}: "${title}" by ${shown} · main artists ${mains.map(m => m.name).join(', ') || '—'} · label "${label}" · ${album && album.datePublished} · UPC ${upc || '—'} · ${nb} track(s)${shownN < nb ? `, ${shownN} on the page` : ''}`); if (shownN < nb) Log.warn(`Qobuz: the page lists only ${shownN} of ${nb} tracks; the rest are left out`); const various = /^various artists$/i.test(shown) || (mains.length === 1 && /^various artists$/i.test(mains[0].name)); const albumArtists = mains.length ? mains : (shown ? [{ name: shown }] : []); // one row per real track: the page also renders empty copies of each row for its layout const rows = [...doc.querySelectorAll('#playerTracks div.track[data-track]')]; const mediums = []; let disc = 1, last = 0; rows.forEach((row, i) => { const name = this.text(row.querySelector('.track__item--name')); const n = parseInt(this.text(row.querySelector('.track__item--number')), 10) || (last + 1); if (n <= last) disc++; // numbering starts over: the next disc last = n; const tf = splitFeat(name); let credit; if (various) { const raw = this.text(row.querySelector('.track__item--artist')); const af = splitFeat(raw); credit = creditFromTitle(String(af.title).split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(x => ({ name: x })), tf.feat.length ? tf.feat : af.feat); } else credit = creditFromTitle(albumArtists, tf.feat); while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: this.hms(this.text(row.querySelector('.track__item--duration'))), isrc: null, url: null, credit }); progress && progress(i + 1, rows.length); Log.debug(`track ${disc}.${n}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')}`); }); const af = splitFeat(title); const credit = various ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, af.feat); const [y, m, d] = String((album && album.datePublished) || '').split('-').map(x => parseInt(x, 10)); const url = location.origin + path; return { source: this.id, annotation: notesText((doc.querySelector('#description .album-block__text') || {}).innerHTML), // the album review url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: /^\d{12,14}$/.test(upc) ? upc : null, labels: label ? [{ name: label, catno: '', url: labelA ? new URL(labelA.getAttribute('href'), root).href : null }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download mediums, }; }, }; // Beatport: the release page's Next.js data (__NEXT_DATA__), the way Harmony reads it: the // release, and its tracks in a second query. The site is a single-page app, so after an in-app // navigation the page's data is stale and the release page is fetched again (same origin, so it // passes the site's bot check). More than one page of tracks is read from Beatport's API with // the anonymous token the page carries. const BEATPORT = { id: 'beatport', name: 'Beatport', host: /^www\.beatport\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:[a-z]{2}\/)?release\/[^/]+\/(\d+)\/?$/i); return m ? m[1] : null; }, TYPES: { Album: ['Album'], EP: ['EP'], Single: ['Single'], Compilation: ['Album', 'Compilation'] }, url: (kind, x) => `https://www.beatport.com/${kind}/${x.slug || '-'}/${x.id}`, // the page's __NEXT_DATA__ when it is this release's, else the release page fetched anew async nextData(id, doc) { const read = d => { const el = d.querySelector('script#__NEXT_DATA__'); return el ? JSON.parse(el.textContent) : null; }; const here = read(doc || document); const rel = here && here.props && here.props.pageProps && here.props.pageProps.release; if (rel && String(rel.id) === String(id)) { Log.debug('Beatport: the page\'s own data is this release\'s'); return here; } Log.info(`Beatport: the page's data is ${rel ? 'release ' + rel.id : 'not a release'}; fetching release ${id}`); const r = await fetch(`${location.origin}/release/-/${id}`, { credentials: 'include' }); if (!r.ok) throw new Error(`Beatport: HTTP ${r.status} for release ${id}`); const got = read(new DOMParser().parseFromString(await r.text(), 'text/html')); if (!got) throw new Error('Beatport: no __NEXT_DATA__ on the release page (bot check?)'); return got; }, async fetchRelease(id, progress, doc) { const nd = await this.nextData(id, doc); const pp = nd.props.pageProps; const r = pp.release; if (!r) throw new Error(`Beatport: no release ${id} in the page data`); const q = ((pp.dehydratedState || {}).queries || []).map(x => x.state && x.state.data).find(d => d && Array.isArray(d.results) && d.results.some(t => t.release && String(t.release.id) === String(id))); if (!q) throw new Error('Beatport: no tracks in the page data'); const results = q.results.slice(); for (let next = q.next; next;) { const tok = pp.anonSession && pp.anonSession.access_token; if (!tok) { Log.warn(`Beatport: ${q.count} tracks but no token to read past ${results.length}`); break; } // the page names an internal host; the same path answers on the public one const page = await gmJson(next.replace(/^https?:\/\/[^/]+/, 'https://api.beatport.com'), { Authorization: 'Bearer ' + tok }); results.push(...(page.results || [])); next = page.next || null; } Log.info(`Beatport release ${id}: "${r.name}" by ${(r.artists || []).map(a => a.name).join(', ')} · type ${(r.type || {}).name} · ${r.track_count} track(s), ${results.length} read · ${r.new_release_date} · UPC ${r.upc || '—'} · label "${(r.label || {}).name}" ${r.catalog_number || ''}`); // release.tracks lists the track URLs last to first; a track it misses keeps the API's order const byUrl = new Map(results.map(t => [t.url, t])); const order = (r.tracks || []).slice().reverse().map(u => byUrl.get(u)).filter(Boolean); const tracks = order.length === results.length ? order : results; if (tracks !== order) Log.warn(`Beatport: the release's track list matched ${order.length} of ${results.length} tracks; using the API's order`); const artist = a => ({ name: a.name, url: this.url('artist', a) }); const list = tracks.map((t, i) => { const tf = splitFeat(t.name || ''); const title = tf.title + (t.mix_name && !/^original mix$/i.test(t.mix_name) ? ` (${t.mix_name})` : ''); const credit = creditFromTitle((t.artists || []).map(artist), tf.feat); progress && progress(i + 1, tracks.length); Log.debug(`track ${i + 1}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); return { title, lengthMs: t.length_ms || null, isrc: t.isrc || null, url: this.url('track', t), credit }; }); // Beatport credits every track artist to the release; many of them mean Various Artists const ra = r.artists || []; const af = splitFeat(r.name || ''); const credit = ra.length > 4 || (ra.length === 1 && /^various artists$/i.test(ra[0].name)) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(ra.map(artist), af.feat); const [y, m, d] = String(r.new_release_date || r.publish_date || '').split('-').map(n => parseInt(n, 10)); const url = this.url('release', r); return { source: this.id, annotation: notesText(r.desc), url, title: af.title, credit, types: this.TYPES[(r.type || {}).name] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: r.upc || null, labels: r.label ? [{ name: r.label.name, catno: r.catalog_number || '', url: this.url('label', r.label) }] : [], // 74 = purchase for download, 980 = streaming page (paid) urls: [{ url, linkType: 74 }].concat(r.is_available_for_streaming ? [{ url, linkType: 980 }] : []), mediums: [{ format: 'Digital Media', name: '', tracks: list }], }; }, }; // Spotify: the web player's own album query (api-partner.spotify.com, "getAlbum"). The player's // token is refused by Spotify's public API, and the query needs the player's token, client token // and the query's current id, so FC listens to the player's requests from the start (early) and // replays the album query with what it heard. The barcode comes from the player's metadata service // (spclient, the same token), which the album query doesn't have. No ISRCs. const SPOTIFY = { id: 'spotify', name: 'Spotify', host: /^open\.spotify\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/(?:intl-[a-z-]+\/)?album\/([A-Za-z0-9]{22})\/?$/); return m ? m[1] : null; }, TYPES: { ALBUM: ['Album'], SINGLE: ['Single'], EP: ['EP'], COMPILATION: ['Album', 'Compilation'] }, // #650 (majkinetor: "Spotify fails" — the player's album query was never heard). The hook took // only fetch(url, init) with a string body: a Request object, an XHR, or a userscript manager // that starts the script after the player's first query all slipped past it. Now the token // comes from ANY authorised request of the player (fetch or XHR, Request or init), the query id // from its getAlbum when heard and a known one otherwise, and the log says what was heard. QUERY_URL: 'https://api-partner.spotify.com/pathfinder/v2/query', GETALBUM_HASH: '6a74b456cd1735c9193d9e8ec8cc5184cad7ce13572210315229db3975964361', // the player's getAlbum id on 2026-10-01 KEEP: ['authorization', 'client-token', 'app-platform', 'spotify-app-version', 'accept-language'], auth: null, // { headers } of the player's last authorised request hash: null, // getAlbum's query id, when the player's own getAlbum was heard seen: 0, // requests to Spotify's APIs the hook saw (for the log) note(url, headers, body) { if (!/^https:\/\/[^/]*spotify\.com\//.test(url) || /open\.spotify\.com\//.test(url)) return; this.seen++; const h = {}; for (const k of this.KEEP) if (headers[k]) h[k] = headers[k]; if (h.authorization) { if (!this.auth) Log.debug(`Spotify: heard the player's token (${url.replace(/\?.*$/, '')}; ${Object.keys(h).join(', ')})`); this.auth = { headers: h }; } if (typeof body === 'string' && /"getAlbum"/.test(body)) { try { const hash = JSON.parse(body).extensions.persistedQuery.sha256Hash; if (hash && hash !== this.hash) Log.debug(`Spotify: heard the player's album query (id ${hash.slice(0, 12)}…)`); if (hash) this.hash = hash; } catch (e) { Log.debug(`Spotify: an album query I couldn't read: ${e.message}`); } } }, early() { const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const self = this; const lower = src => { const h = {}; try { if (src && typeof src.forEach === 'function') src.forEach((v, k) => { h[String(k).toLowerCase()] = v; }); else if (src) Object.keys(src).forEach(k => { h[k.toLowerCase()] = src[k]; }); } catch (e) { /* unreadable headers */ } return h; }; const origFetch = w.fetch; if (typeof origFetch === 'function') { const hook = function (input, init) { try { const isReq = input && typeof input === 'object' && 'url' in input; const url = String(isReq ? input.url : input || ''); const headers = Object.assign(isReq ? lower(input.headers) : {}, lower(init && init.headers)); const body = init && typeof init.body === 'string' ? init.body : null; if (body !== null || !isReq) self.note(url, headers, body); else if (/pathfinder/.test(url)) { // a Request carries its body inside: read a copy, the page reads the original self.note(url, headers, null); input.clone().text().then(t => self.note(url, {}, t), () => {}); } else self.note(url, headers, null); } catch (e) { Log.debug(`Spotify hook (fetch): ${e.message}`); } return origFetch.apply(this, arguments); }; w.fetch = typeof exportFunction === 'function' ? exportFunction(hook, w) : hook; } const XP = w.XMLHttpRequest && w.XMLHttpRequest.prototype; if (XP) { const oOpen = XP.open, oSet = XP.setRequestHeader, oSend = XP.send; const wrap = fn => (typeof exportFunction === 'function' ? exportFunction(fn, w) : fn); XP.open = wrap(function (method, url) { try { this.__fcUrl = String(url); this.__fcH = {}; } catch (e) { /* ignore */ } return oOpen.apply(this, arguments); }); XP.setRequestHeader = wrap(function (k, v) { try { if (this.__fcH) this.__fcH[String(k).toLowerCase()] = v; } catch (e) { /* ignore */ } return oSet.apply(this, arguments); }); XP.send = wrap(function (body) { try { if (this.__fcUrl) self.note(this.__fcUrl, this.__fcH || {}, typeof body === 'string' ? body : null); } catch (e) { Log.debug(`Spotify hook (XHR): ${e.message}`); } return oSend.apply(this, arguments); }); } Log.debug(`Spotify: listening to the player's requests (fetch${XP ? ' and XHR' : ''}, document ${document.readyState})`); this.inPage(); }, /* majkinetor's log: "the hook saw 0 request(s) to Spotify's APIs", hooked while the document was still loading. His userscript manager runs the script in its own sandbox, where unsafeWindow's fetch isn't the page's: the hook above wraps a fetch the player never calls. So the same hook also goes into the page itself, as a script — Spotify's policy refuses inline scripts but allows blob: ones — and tells the userscript what it hears with a DOM event, which crosses the sandbox. In a manager that does share the page's window both hooks run; a request heard twice is the same request. */ inPage() { const EV = 'first-contact:spotify-heard'; document.addEventListener(EV, e => { try { const d = JSON.parse(e.detail); if (!this._fromPage) { this._fromPage = true; Log.debug('Spotify: the in-page hook is hearing the player'); } this.note(d.url, d.headers || {}, d.body); } catch (x) { Log.debug(`Spotify (in-page hook): ${x.message}`); } }); const code = '(' + function (EV) { if (window.__fcSpotifyHook) return; window.__fcSpotifyHook = true; const tell = (url, headers, body) => { try { document.dispatchEvent(new CustomEvent(EV, { detail: JSON.stringify({ url: String(url), headers, body: typeof body === 'string' ? body : null }) })); } catch (e) { /* nothing to tell with */ } }; const lower = src => { const h = {}; try { if (src && typeof src.forEach === 'function') src.forEach((v, k) => { h[String(k).toLowerCase()] = v; }); else if (src) Object.keys(src).forEach(k => { h[k.toLowerCase()] = src[k]; }); } catch (e) { /* unreadable */ } return h; }; const spot = u => /^https:\/\/[^/]*spotify\.com\//.test(u) && !/open\.spotify\.com\//.test(u); const of = window.fetch; window.fetch = function (input, init) { try { const isReq = input && typeof input === 'object' && 'url' in input; const url = String(isReq ? input.url : input || ''); if (spot(url)) { const headers = Object.assign(isReq ? lower(input.headers) : {}, lower(init && init.headers)); if (init && typeof init.body === 'string') tell(url, headers, init.body); else { tell(url, headers, null); if (isReq && /pathfinder/.test(url)) input.clone().text().then(t => tell(url, {}, t), () => {}); } } } catch (e) { /* never in the player's way */ } return of.apply(this, arguments); }; const XP = XMLHttpRequest.prototype, oo = XP.open, os = XP.setRequestHeader, osd = XP.send; XP.open = function (m, u) { this.__fcU = String(u); this.__fcH = {}; return oo.apply(this, arguments); }; XP.setRequestHeader = function (k, v) { if (this.__fcH) this.__fcH[String(k).toLowerCase()] = v; return os.apply(this, arguments); }; XP.send = function (b) { try { if (this.__fcU && spot(this.__fcU)) tell(this.__fcU, this.__fcH || {}, typeof b === 'string' ? b : null); } catch (e) { /* never in the way */ } return osd.apply(this, arguments); }; } + ')(' + JSON.stringify(EV) + ');'; try { const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); const el = document.createElement('script'); let src = url; try { // a page that enforces Trusted Types takes a script URL only from a policy if (window.trustedTypes && window.trustedTypes.createPolicy) src = window.trustedTypes.createPolicy('first-contact-' + Math.random().toString(36).slice(2, 7), { createScriptURL: x => x }).createScriptURL(url); } catch (e) { /* no policy: the plain URL */ } el.src = src; el.onload = () => { Log.debug('Spotify: the in-page hook is in'); el.remove(); URL.revokeObjectURL(url); }; el.onerror = () => Log.warn('Spotify: the page refused the in-page hook'); // at document-start there may be no yet to put it in: wait for it const put = () => { const at = document.head || document.documentElement; if (at) { at.appendChild(el); return true; } return false; }; if (!put()) new MutationObserver((m, o) => { if (put()) o.disconnect(); }).observe(document, { childList: true, subtree: true }); } catch (e) { Log.warn(`Spotify: no in-page hook: ${e.message}`); } }, post(body) { const url = this.QUERY_URL, headers = this.auth.headers; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: JSON.stringify(body), timeout: 20000, anonymous: true, headers: Object.assign({}, headers, { 'content-type': 'application/json;charset=UTF-8', accept: 'application/json' }), onload: r => { Log.debug(`POST ${url} (getAlbum offset ${body.variables.offset}) → ${r.status}, ${(r.responseText || '').length} b`); if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`Spotify: HTTP ${r.status} for the album query`), { status: r.status })); try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error(`Spotify: bad JSON: ${e.message}`)); } }, onerror: () => reject(new Error('Spotify: network error')), ontimeout: () => reject(new Error('Spotify: timeout')), }); }); }, idOf: uri => String(uri || '').split(':').pop(), // base62 id → the 32-hex gid the metadata service names things by gidOf(id) { const A = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; let n = 0n; for (const c of id) n = n * 62n + BigInt(A.indexOf(c)); return n.toString(16).padStart(32, '0'); }, // majkinetor: "FC didn't get barcode" (album 0QPZBEO7W3gho2UiFBPJz2, UPC 881626542866). The // player's album query has none; its metadata service has the album's external ids (upc). // Optional: without it the import goes on, barcode empty. upc(id) { const url = `https://spclient.wg.spotify.com/metadata/4/album/${this.gidOf(id)}?market=from_token`; return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 15000, anonymous: true, headers: Object.assign({}, this.auth.headers, { accept: 'application/json' }), onload: r => { let upc = null; try { upc = ((JSON.parse(r.responseText).external_id || []).find(x => /^upc$/i.test(x.type) && x.id) || {}).id || null; } catch (e) { /* not JSON: no barcode */ } Log.debug(`GET ${url} → ${r.status}, ${(r.responseText || '').length} b, UPC ${upc || '—'}`); resolve(upc && /^\d{8,14}$/.test(upc) ? upc : null); }, onerror: () => { Log.warn('Spotify: no barcode (the metadata service could not be reached)'); resolve(null); }, ontimeout: () => { Log.warn('Spotify: no barcode (the metadata service timed out)'); resolve(null); }, }); }); }, async fetchRelease(id, progress) { for (let i = 0; !this.auth && i < 50; i++) await new Promise(r => setTimeout(r, 100)); Log.info(`Spotify: the hook saw ${this.seen} request(s) to Spotify's APIs; token ${this.auth ? 'heard' : 'not heard'}; album query id ${this.hash ? 'heard' : 'not heard, using the known one'}`); if (!this.auth) throw new Error(this.seen ? 'Spotify: the player\u2019s requests carried no token; reload the page and try again' : 'Spotify: no request of the player was heard. Your userscript manager may start First Contact too late on Spotify; reload the page, and if it persists, copy the log to #650'); const hash = this.hash || this.GETALBUM_HASH; const page = offset => this.post({ variables: { uri: `spotify:album:${id}`, locale: '', offset, limit: 50 }, operationName: 'getAlbum', extensions: { persistedQuery: { version: 1, sha256Hash: hash } } }); const upcP = this.upc(id); // alongside the album query const first = await page(0); const a = first && first.data && first.data.albumUnion; if (!a || !a.name) throw new Error(`Spotify: no album ${id}${first && first.errors ? ': ' + JSON.stringify(first.errors).slice(0, 200) : ''}`); const items = (a.tracksV2.items || []).slice(); while (items.length < a.tracksV2.totalCount) { const more = await page(items.length); const got = (((more.data || {}).albumUnion || {}).tracksV2 || {}).items || []; if (!got.length) break; items.push(...got); } Log.info(`Spotify album ${id}: "${a.name}" by ${a.artists.items.map(x => x.profile.name).join(', ')} · ${a.type} · ${items.length} of ${a.tracksV2.totalCount} track(s) · ${a.date && a.date.isoString} (${a.date && a.date.precision}) · label "${a.label || ''}" · UPC ${(await upcP) || '—'}`); const artist = x => ({ name: x.profile.name, url: `https://open.spotify.com/artist/${this.idOf(x.uri)}` }); const mediums = []; items.forEach((it, i) => { const t = it.track || {}; const tf = splitFeat(t.name || ''); const credit = creditFromTitle(((t.artists || {}).items || []).map(artist), tf.feat); const disc = t.discNumber || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: (t.duration && t.duration.totalMilliseconds) || null, isrc: null, url: `https://open.spotify.com/track/${this.idOf(t.uri)}`, credit }); progress && progress(i + 1, items.length); Log.debug(`track ${disc}.${t.trackNumber}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')}`); }); const ra = a.artists.items || []; const af = splitFeat(a.name); const credit = ra.length === 1 && /^various artists$/i.test(ra[0].profile.name) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(ra.map(artist), af.feat); const p = (a.date && a.date.precision) || ''; const [y, m, d] = String((a.date && a.date.isoString) || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = `https://open.spotify.com/album/${id}`; return { source: this.id, url, title: af.title, credit, types: this.TYPES[a.type] || [], status: 'official', packaging: 'None', date: { year: y || null, month: p === 'YEAR' ? null : m || null, day: p === 'DAY' ? d || null : null }, country: 'XW', barcode: await upcP, labels: a.label ? [{ name: a.label, catno: '' }] : [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums, }; }, }; // YouTube Music: the API its own web player uses (youtubei/v1), which answers anonymous // requests — as Platform Check and Credit Hoarder read it. The album page (browse MPREb_…) has // the title, the kind (Album / EP / Single), the year, and each track with its artists' channels // and length; an album playlist (OLAK5uy_…) names its album page. No label, barcode, ISRCs or // full date: the player doesn't show them. YouTube Music lists a featured artist as a main // one, so feat. comes from the title, as on Deezer. const YTMUSIC = { id: 'ytmusic', name: 'YouTube Music', host: /^music\.youtube\.com$/, albumId(loc) { const b = loc.pathname.match(/^\/browse\/(MPREb_[\w-]+)\/?$/); if (b) return b[1]; const l = loc.pathname === '/playlist' && String(loc.search || '').match(/[?&]list=(OLAK5uy_[\w-]+)/); return l ? 'list:' + l[1] : null; }, API: 'https://music.youtube.com/youtubei/v1/', CLIENT: { clientName: 'WEB_REMIX', clientVersion: '1.20250101.01.00', hl: 'en', gl: 'US' }, TYPES: { Album: ['Album'], EP: ['EP'], Single: ['Single'] }, call(endpoint, body) { const url = `${this.API}${endpoint}?prettyPrint=false`; const t0 = Date.now(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: JSON.stringify(Object.assign({ context: { client: this.CLIENT } }, body)), timeout: 20000, anonymous: true, headers: { 'Content-Type': 'application/json' }, onload: r => { Log.debug(`POST ${url} ${body.browseId || ''} → ${r.status}, ${(r.responseText || '').length} b in ${Date.now() - t0} ms`); if (r.status < 200 || r.status >= 300) return reject(new Error(`YouTube Music: HTTP ${r.status} for ${body.browseId}`)); try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error(`YouTube Music: bad JSON for ${body.browseId}`)); } }, onerror: () => reject(new Error('YouTube Music: network error')), ontimeout: () => reject(new Error('YouTube Music: timeout')), }); }); }, text: t => (t && t.runs ? t.runs.map(x => x.text).join('') : (t && t.simpleText) || ''), walk(o, fn) { if (!o || typeof o !== 'object') return; fn(o); for (const k in o) this.walk(o[k], fn); }, // the artists a run list links: "Daft Punk, Pharrell Williams & Nile Rodgers", each with its channel artists(t) { return ((t && t.runs) || []).filter(r => { const b = r.navigationEndpoint && r.navigationEndpoint.browseEndpoint; return b && /^UC/.test(b.browseId || ''); }).map(r => ({ name: r.text.trim(), url: `https://music.youtube.com/channel/${r.navigationEndpoint.browseEndpoint.browseId}` })); }, hms(s) { const p = String(s || '').trim().split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, n) => a * 60 + n, 0) * 1000 : null; }, async fetchRelease(key, progress) { let id = key; if (/^list:/.test(key)) { const list = key.slice(5); const pl = await this.call('browse', { browseId: 'VL' + list }); id = (JSON.stringify(pl).match(/"(MPREb_[\w-]+)"/) || [])[1]; Log.info(`YouTube Music: playlist ${list} → album page ${id || 'none'}`); if (!id) throw new Error(`YouTube Music: playlist ${list} names no album`); } const j = await this.call('browse', { browseId: id }); let h = null, shelf = null; this.walk(j, o => { if (!h && o.musicResponsiveHeaderRenderer) h = o.musicResponsiveHeaderRenderer; if (!shelf && o.musicShelfRenderer) shelf = o.musicShelfRenderer; }); if (!h || !shelf) throw new Error(`YouTube Music: no album on ${id} (the page may have changed)`); const title = this.text(h.title); const sub = this.text(h.subtitle).split(' • '); // "Album • 2013" const kind = sub[0] || '', year = parseInt(sub.find(s => /^\d{4}$/.test(s)) || '', 10) || null; const albumArtists = this.artists(h.straplineTextOne); const canon = (j.microformat && j.microformat.microformatDataRenderer && j.microformat.microformatDataRenderer.urlCanonical) || ''; const list = (canon.match(/[?&]list=(OLAK5uy_[\w-]+)/) || [])[1]; const rows = (shelf.contents || []).map(c => c.musicResponsiveListItemRenderer).filter(Boolean); Log.info(`YouTube Music album ${id}: "${title}" by ${this.text(h.straplineTextOne)} · ${kind} · ${year || 'no year'} · ${this.text(h.secondSubtitle)} · ${rows.length} row(s)${list ? ' · playlist ' + list : ''}`); const tracks = rows.map((r, i) => { const cols = (r.flexColumns || []).map(c => c.musicResponsiveListItemFlexColumnRenderer && c.musicResponsiveListItemFlexColumnRenderer.text); const name = this.text(cols[0]); const tf = splitFeat(name); const listed = this.artists(cols[1]); const credit = creditFromTitle(listed.length ? listed : albumArtists, tf.feat); const len = this.hms(this.text((((r.fixedColumns || [])[0] || {}).musicResponsiveListItemFixedColumnRenderer || {}).text)); const vid = ((((cols[0] || {}).runs || [])[0] || {}).navigationEndpoint || {}).watchEndpoint; progress && progress(i + 1, rows.length); Log.debug(`track ${this.text(r.index) || i + 1}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${len || '?'} ms)`); return { title: tf.title, lengthMs: len, isrc: null, url: vid && vid.videoId ? `https://music.youtube.com/watch?v=${vid.videoId}` : null, credit }; }); const af = splitFeat(title); const credit = /^various artists$/i.test(this.text(h.straplineTextOne)) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists.length ? albumArtists : [{ name: this.text(h.straplineTextOne) }], af.feat); const url = list ? `https://music.youtube.com/playlist?list=${list}` : `https://music.youtube.com/browse/${id}`; return { source: this.id, annotation: notesText(this.text(h.description && h.description.musicDescriptionShelfRenderer && h.description.musicDescriptionShelfRenderer.description)), url, title: af.title, credit, types: this.TYPES[kind] || [], status: 'official', packaging: 'None', date: { year, month: null, day: null }, country: 'XW', barcode: null, labels: [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Volumo: its public API (volumo.com/api/v1), as ISRC Scout and Platform Check read it — no token. // The album page is /album/- (or /album/); the barcode form asks // /album_by_icpn, the id form /albums. Every track has its ISRC and every artist its Volumo id. const VOLUMO = { id: 'volumo', name: 'Volumo', host: /^(www\.)?volumo\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/album\/(\d+)(?:-[^/]*)?\/?$/); return m ? m[1] : null; }, artist: a => ({ name: a.name, url: `https://volumo.com/artist/${a.id}` }), async fetchRelease(id, progress) { const j = await gmJson('https://volumo.com/api/v1' + (id.length >= 12 ? '/album_by_icpn/' : '/albums/') + id); const a = Array.isArray(j) ? j[0] : (j && (j.album || j)); if (!a || !a.title) throw new Error(`Volumo: no album ${id}`); const list = a.tracks || []; Log.info(`Volumo album ${id}: "${a.title}" by ${(a.artists || []).map(x => x.name).join(', ')} · ${list.length} track(s) · ${a.original_release_date || a.release_start_at || 'no date'} · UPC ${a.icpn || '—'} · label "${(a.recordlabel || {}).name || ''}" ${a.catalog_number || ''}`); const mediums = []; list.forEach((t, i) => { const tf = splitFeat(t.title || ''); const title = tf.title + (t.version && !/^original mix$/i.test(t.version) ? ` (${t.version})` : ''); const credit = creditFromTitle((t.artists || []).map(this.artist).concat((t.featured_artists || []).map(this.artist)), tf.feat.length ? tf.feat : (t.featured_artists || []).map(x => x.name)); const disc = t.disc_number || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title, lengthMs: Math.round(t.duration) || null, isrc: t.isrc || null, url: t.id ? `https://volumo.com/track/${t.id}` : null, credit }); progress && progress(i + 1, list.length); Log.debug(`track ${i + 1}: "${title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); }); const ra = a.artists || []; const af = splitFeat(a.title); // a label's sampler credits every track artist to the release: many of them mean Various Artists const credit = ra.length > 4 || (ra.length === 1 && /^various artists$/i.test(ra[0].name)) ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(ra.map(this.artist), af.feat); const [y, m, d] = String(a.original_release_date || a.release_start_at || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = `https://volumo.com/album/${a.icpn || a.id}`; // the slug-less form (Platform Check #202) return { source: this.id, annotation: notesText(a.description), url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: a.icpn || null, labels: a.recordlabel && a.recordlabel.name ? [{ name: a.recordlabel.name, catno: a.catalog_number || '' }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download mediums, }; }, }; // HDtracks: its public API (hdtracks.azurewebsites.net/api/v1), as ISRC Scout reads it — one // call has the album and every track with its ISRC. The site routes in the address's hash // (www.hdtracks.com/#/album/). HDtracks names artists only, with no artist pages. const HDTRACKS = { id: 'hdtracks', name: 'HDtracks', host: /^(www\.)?hdtracks\.com$/, albumId(loc) { const m = String(loc.hash || '').match(/^#\/album\/([a-f0-9]{24})\b/i) || loc.pathname.match(/^\/album\/([a-f0-9]{24})\/?$/i); return m ? m[1] : null; }, API: 'https://hdtracks.azurewebsites.net/api/v1', async fetchRelease(id, progress) { const j = await gmJson(`${this.API}/album/${id}`); if (!j || !j.id) throw new Error(`HDtracks: no album ${id}`); // an unknown id answers 200 with nothing in it const list = (j.tracks || []).slice().sort((a, b) => (a.discIndex || 1) - (b.discIndex || 1) || (a.index || 0) - (b.index || 0)); Log.info(`HDtracks album ${id}: "${j.name}" by ${j.mainArtist} · ${list.length} track(s) · ${j.release || j.originalRelease || 'no date'} · UPC ${j.upc || '—'} · label "${j.label || ''}" · ${j.quality || ''}`); const names = text => String(text || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(name => ({ name })); const mediums = []; list.forEach((t, i) => { const tf = splitFeat(t.name || ''); const credit = creditFromTitle(names(t.mainArtist || j.mainArtist), tf.feat); const disc = t.discIndex || 1; while (mediums.length < disc) mediums.push({ format: 'Digital Media', name: '', tracks: [] }); mediums[disc - 1].tracks.push({ title: tf.title, lengthMs: t.duration ? Math.round(t.duration * 1000) : null, isrc: t.isrc || null, url: null, credit }); progress && progress(i + 1, list.length); Log.debug(`track ${disc}.${t.index}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); }); const af = splitFeat(j.name || ''); const credit = /^various artists$/i.test(j.mainArtist || '') ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(names(j.mainArtist), af.feat); const [y, m, d] = String(j.release || j.originalRelease || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = `https://www.hdtracks.com/#/album/${id}`; return { source: this.id, annotation: notesText(j.notes), url, title: af.title, credit, types: [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: j.upc || null, labels: j.label ? [{ name: j.label, catno: '' }] : [], urls: [{ url, linkType: 74 }], // 74 = purchase for download mediums, }; }, }; // SoundCloud: its public API (api-v2.soundcloud.com) with the client id the web player's JS // carries, as ISRC Scout reads it. A set (//sets/) is the release; a set the label // distributed has each track's ISRC, the barcode and the label in the track's publisher data. const SOUNDCLOUD = { id: 'soundcloud', name: 'SoundCloud', host: /^soundcloud\.com$/, albumId(loc) { return /^\/[^/]+\/sets\/[^/]+\/?$/.test(loc.pathname) ? loc.pathname.replace(/\/$/, '') : null; }, API: 'https://api-v2.soundcloud.com', CID_KEY: 'fc.soundcloud-cid', TYPES: { album: ['Album'], ep: ['EP'], single: ['Single'], compilation: ['Album', 'Compilation'] }, async clientId(fresh) { const c = !fresh && GM_getValue(this.CID_KEY, null); if (c && c.id && Date.now() - c.at < 12 * 3600e3) return c.id; Log.info('SoundCloud: reading the web player\'s client id'); const home = await gmText('https://soundcloud.com/discover', { Accept: 'text/html' }); const assets = [...home.matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g)].map(m => m[0]).reverse(); for (const a of assets) { let js; try { js = await gmText(a, { Accept: '*/*' }); } catch (e) { continue; } const m = js.match(/client_id\s*[:=]\s*"([a-zA-Z0-9]{20,40})"/); if (m) { GM_setValue(this.CID_KEY, { id: m[1], at: Date.now() }); return m[1]; } } throw new Error('SoundCloud: no client id in the web player\'s JS'); }, async api(path) { for (let attempt = 0; ; attempt++) { const cid = await this.clientId(attempt > 0); try { return await gmJson(`${this.API}${path}${path.includes('?') ? '&' : '?'}client_id=${cid}`); } catch (e) { if ((e.status === 401 || e.status === 403) && attempt === 0) { Log.warn('SoundCloud: the client id was refused, reading a new one'); continue; } throw e; } } }, async fetchRelease(path, progress) { const page = `https://soundcloud.com${path}`; const pl = await this.api('/resolve?url=' + encodeURIComponent(page)); if (!pl || pl.kind !== 'playlist') throw new Error('SoundCloud: not a set'); // the set names its first tracks in full and the rest by id: read those in batches of 50 const byId = new Map((pl.tracks || []).filter(t => t && t.title).map(t => [t.id, t])); const missing = (pl.tracks || []).filter(t => t && !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) (await this.api('/tracks?ids=' + missing.slice(i, i + 50).join(','))).forEach(t => byId.set(t.id, t)); const list = (pl.tracks || []).map(t => byId.get(t.id)).filter(Boolean); const pm0 = (list[0] && list[0].publisher_metadata) || {}; Log.info(`SoundCloud set ${path}: "${pl.title}" by ${pl.user && pl.user.username} · ${pl.set_type || 'set'} · ${list.length} track(s) · ${pl.release_date || pl.published_at || pl.created_at || 'no date'} · label "${pl.label_name || ''}" · UPC ${pm0.upc_or_ean || '—'}`); const uploader = pl.user || {}; const linkFor = name => (uploader.username && normName(name) === normName(uploader.username) ? uploader.permalink_url : null); // only the uploader has a page we know const names = text => String(text || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(name => ({ name, url: linkFor(name) })); const tracks = list.map((t, i) => { const pm = t.publisher_metadata || {}; const tf = splitFeat(t.title || ''); const credit = creditFromTitle(names(pm.artist || uploader.username), tf.feat); progress && progress(i + 1, list.length); Log.debug(`track ${i + 1}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${pm.isrc || 'no ISRC'})`); return { title: tf.title, lengthMs: t.full_duration || t.duration || null, isrc: pm.isrc || null, url: t.permalink_url || null, credit }; }); const artists = [...new Set(list.map(t => (t.publisher_metadata || {}).artist).filter(Boolean))]; const af = splitFeat(pl.title || ''); const credit = artists.length > 4 ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(names(artists.length === 1 ? artists[0] : uploader.username), af.feat); const upcs = [...new Set(list.map(t => String((t.publisher_metadata || {}).upc_or_ean || '').trim()).filter(Boolean))]; const [y, m, d] = String(pl.release_date || pl.published_at || pl.created_at || '').slice(0, 10).split('-').map(n => parseInt(n, 10)); const url = pl.permalink_url || page; return { source: this.id, annotation: notesText(pl.description), url, title: af.title, credit, types: this.TYPES[String(pl.set_type || '').toLowerCase()] || [], status: 'official', packaging: 'None', date: { year: y || null, month: m || null, day: d || null }, country: 'XW', barcode: upcs.length === 1 ? upcs[0] : null, labels: pl.label_name ? [{ name: pl.label_name, catno: '' }] : [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Amazon Music (#650, majkinetor: "implement AM"): the API its own web player uses // (na.mesk.skill.music.a2z.com), as a guest — music.amazon.com/config.json hands out a session (CSRF // token, device and session id, no login), carried as a JSON "headers" string in each request's body. // Platform Check (#644) and ISRC Scout read it the same way. The guest session is amazon.com's (US) // catalogue: an album from another country's store is looked up by the same id there. The album // page is one flat tracklist (no disc numbers), with each track's length, link and, when it isn't // the album artist, an artist line like "A, B & C" whose first name alone is linked. No barcode or // ISRCs (Amazon Music shows neither); the label is read from the page's ℗ line. const AMAZON = { id: 'amazonmusic', name: 'Amazon Music', host: /^music\.amazon\.(?:com|co\.uk|de|fr|it|es|ca|co\.jp|com\.au|com\.br|com\.mx|in)$/, albumId(loc) { const m = loc.pathname.match(/^\/albums\/([A-Z0-9]{10})\/?$/i); return m ? m[1].toUpperCase() : null; }, API: 'https://na.mesk.skill.music.a2z.com/api/', TYPES: { album: ['Album'], ep: ['EP'], single: ['Single'] }, MONTHS: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'], session: null, post(url, data, headers) { const t0 = Date.now(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data, timeout: 20000, anonymous: true, headers: headers || {}, onload: r => { Log.debug(`POST ${url} → ${r.status}, ${(r.responseText || '').length} b in ${Date.now() - t0} ms`); if (r.status < 200 || r.status >= 300) return reject(Object.assign(new Error(`Amazon Music: HTTP ${r.status} for ${url}`), { status: r.status })); try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error(`Amazon Music: bad JSON from ${url}`)); } }, onerror: () => reject(new Error('Amazon Music: network error')), ontimeout: () => reject(new Error('Amazon Music: timeout')), }); }); }, async guest() { if (this.session) return this.session; const j = await this.post('https://music.amazon.com/config.json', ''); if (!j || !j.csrf || !j.deviceId) throw new Error('Amazon Music: no guest session; its web player may have changed'); Log.info(`Amazon Music: guest session (marketplace ${j.marketplaceId || '?'}, player ${j.version || '?'})`); return (this.session = j); }, async call(endpoint, body) { const s = await this.guest(); const headers = JSON.stringify({ 'x-amzn-authentication': JSON.stringify({ interface: 'ClientAuthenticationInterface.v1_0.ClientTokenElement', accessToken: s.accessToken || '' }), 'x-amzn-device-model': 'WEBPLAYER', 'x-amzn-device-width': '1920', 'x-amzn-device-family': 'WebPlayer', 'x-amzn-device-id': s.deviceId, 'x-amzn-user-agent': navigator.userAgent, 'x-amzn-session-id': s.sessionId, 'x-amzn-device-height': '1080', 'x-amzn-request-id': (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)), 'x-amzn-device-language': 'en_US', 'x-amzn-currency-of-preference': 'USD', 'x-amzn-os-version': '1.0', 'x-amzn-application-version': s.version, 'x-amzn-device-time-zone': 'UTC', 'x-amzn-timestamp': String(Date.now()), 'x-amzn-csrf': JSON.stringify({ interface: 'CSRFInterface.v1_0.CSRFHeaderElement', token: s.csrf.token, timestamp: s.csrf.ts, rndNonce: s.csrf.rnd }), 'x-amzn-music-domain': 'music.amazon.com', 'x-amzn-referer': '', 'x-amzn-affiliate-tags': '', 'x-amzn-ref-marker': '', 'x-amzn-page-url': 'https://music.amazon.com/', 'x-amzn-weblab-id-overrides': '', 'x-amzn-video-player-token': '', 'x-amzn-feature-flags': '', 'x-amzn-has-profile-id': '', 'x-amzn-age-band': '', }); let j; try { j = await this.post(this.API + endpoint, JSON.stringify(Object.assign({}, body, { headers })), { 'Content-Type': 'text/plain;charset=UTF-8' }); } catch (e) { if (e.status === 401 || e.status === 403) this.session = null; throw e; } const tpl = j && j.methods && (j.methods.find(m => m.template) || {}).template; if (!tpl) throw new Error(`Amazon Music: ${endpoint} gave no page; its API may have changed`); return tpl; }, text: t => String((t && typeof t === 'object' ? t.text : t) || '').trim(), slug: s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/&/g, ' ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), // "A, B & C" with a link to one artist's page (/artists//) → [{ name, url }]. The link // names who it is: when its slug is the whole line ("Simon & Garfunkel"), the line is one artist; // otherwise it's a list, and the link belongs to the name its slug matches (the first, so far). artists(line, link) { const m = String(link || '').match(/^\/artists\/([A-Z0-9]{10})(?:\/([^/?#]+))?/i); const url = m ? `https://music.amazon.com/artists/${m[1].toUpperCase()}` : null; const slug = m && m[2] ? m[2].toLowerCase() : ''; const names = /,\s|\s&\s/.test(line) && !(slug && slug === this.slug(line)) ? line.split(/\s*,\s+|\s+&\s+/).map(x => x.trim()).filter(Boolean) : [line]; let at = slug ? names.findIndex(n => this.slug(n) === slug) : -1; if (at < 0 && url) { at = 0; if (slug) Log.debug(`Amazon Music: the link /${slug} is none of ${JSON.stringify(names)}; it goes to the first`); } return names.map((name, i) => (i === at ? { name, url } : { name })); }, // "MAY 17 2013" → { year, month, day } date(s) { const m = String(s || '').match(/\b([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})\b/); if (m) return { year: +m[3], month: this.MONTHS.indexOf(m[1].toLowerCase()) + 1 || null, day: +m[2] }; const y = String(s || '').match(/\b(\d{4})\b/); return { year: y ? +y[1] : null, month: null, day: null }; }, secs(x) { const p = String(x || '').split(':').map(Number); return p.length > 1 && p.every(n => !isNaN(n)) ? p.reduce((a, n) => a * 60 + n, 0) : null; }, async fetchRelease(id, progress) { const tpl = await this.call('showCatalogAlbum', { id, userHash: JSON.stringify({ level: 'LIBRARY_MEMBER' }) }); const title = this.text(tpl.headerText); if (!title) throw new Error(`Amazon Music: no album ${id}`); const sub = String(tpl.headerTertiaryText || '').split(/\s*•\s*/); const artistLine = this.text(tpl.headerPrimaryText); const artistLink = (tpl.headerPrimaryTextLink || {}).deeplink; const rows = []; for (const w of tpl.widgets || []) for (const it of w.items || []) { const tid = (String((it.primaryTextLink || {}).deeplink || '').match(/^\/tracks\/([A-Z0-9]{10})/) || String((it.primaryLink || {}).deeplink || '').match(/[?&]trackAsin=([A-Z0-9]{10})/) || [])[1]; if (tid && !rows.some(r => r.id === tid)) rows.push({ id: tid, it }); } const said = parseInt(((sub[0] || '').match(/^([\d,.]+)\s+(?:song|track)s?\b/i) || [])[1] || '', 10); Log.info(`Amazon Music album ${id}: "${title}" by ${artistLine} (${artistLink || 'no link'}) · ${this.text(tpl.headerLabel) || 'no kind'} · ${rows.length} track(s)${said && said !== rows.length ? ` (the header says ${said})` : ''} · ${sub.slice(1).join(' · ')} · ${tpl.footer || 'no ℗ line'}`); if (!rows.length) throw new Error(`Amazon Music: album ${id} has no tracks in its answer; its page may have changed`); const va = /^various artists$/i.test(artistLine); const albumArtists = va ? null : this.artists(artistLine, artistLink); const tracks = rows.map(({ id: tid, it }, i) => { const tf = splitFeat(this.text(it.primaryText)); const line = this.text(it.secondaryText2); const listed = line ? this.artists(line, (it.secondaryText2Link || {}).deeplink) : (albumArtists || [{ name: 'Various Artists' }]); const credit = creditFromTitle(listed, tf.feat); const secs = this.secs(it.secondaryText3); progress && progress(i + 1, rows.length); Log.debug(`track ${i + 1}: "${tf.title}" ${it.secondaryText3 || '?'} — ${credit.map(c => c.name + c.join).join('')}${line ? ` (line "${line}")` : ' (album artist)'}`); return { title: tf.title, lengthMs: secs == null ? null : secs * 1000, isrc: null, url: `https://music.amazon.com/tracks/${tid}`, credit }; }); const af = splitFeat(title); const credit = va ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(albumArtists, af.feat); const date = this.date(sub.find(x => /\b\d{4}$/.test(x)) || ''); const lbl = labelFromCopyright(tpl.footer); const url = `https://music.amazon.com/albums/${id}`; return { source: this.id, url, title: af.title, credit, types: this.TYPES[this.text(tpl.headerLabel).toLowerCase()] || [], status: 'official', packaging: 'None', date, country: 'XW', barcode: null, labels: lbl ? [{ name: lbl, catno: '' }] : [], urls: [{ url, linkType: 980 }], // 980 = streaming page (paid) // #650 (majkinetor, of "℗© VIDLIK": "this is not label", "could probably be added in // annotation"): the page's ℗ line is its only note; it goes there whole, read as a label or not annotation: notesText(tpl.footer), mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; // Audiomack (#664): its API (api.audiomack.com/v1), as ISRC Scout and Platform Check read it. Every // call is OAuth 1.0a-signed with the web player's own public key and secret, so no login. An album // (//album/) has its tracks in order, each with its ISRC; a song (//song/) // is a one-track release. The barcode is the album's `upc`, the label comes from its ℗ line. const AUDIOMACK = { id: 'audiomack', name: 'Audiomack', host: /^(www\.)?audiomack\.com$/, albumId(loc) { const m = loc.pathname.match(/^\/([^/]+)\/(album|song)\/([^/]+)\/?$/); return m ? `${m[1]}/${m[2]}/${m[3]}` : null; }, API: 'https://api.audiomack.com/v1/', KEY: 'audiomack-web', SECRET: 'bd8a07e9f23fbe9d808646b730f89b8e', async api(endpoint) { const enc = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); const url = this.API + endpoint; const p = { oauth_consumer_key: this.KEY, oauth_nonce: Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: String(Math.floor(Date.now() / 1000)), oauth_version: '1.0' }; const qs = Object.keys(p).sort().map(k => `${enc(k)}=${enc(p[k])}`).join('&'); const te = new TextEncoder(); const key = await crypto.subtle.importKey('raw', te.encode(enc(this.SECRET) + '&'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); const sig = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC', key, te.encode(['GET', enc(url), enc(qs)].join('&')))))); const j = await gmJson(`${url}?${qs}&oauth_signature=${enc(sig)}`); if (!j || !j.results) throw new Error(`Audiomack: nothing at ${endpoint}`); return j.results; }, // "℗ Spaceship/ Bad Habit/ Atlantic Records, 2025 Spaceship …" → "Spaceship/ Bad Habit/ Atlantic Records" label: pline => String(pline || '').replace(/^[\s©℗]*(?:\(P\)\s*)?(?:\d{4}\s+)?/i, '').split(/,\s+\d{4}\b/)[0].trim(), async fetchRelease(id, progress) { const [artistSlug, kind, slug] = id.split('/'); const a = await this.api(`music/${kind}/${artistSlug}/${slug}`); const list = kind === 'album' ? (a.tracks || []) : [a]; const ts = Number(a.original_release_date || a.released || 0); Log.info(`Audiomack ${kind} ${id}: "${a.title}" by ${a.artist} · ${list.length} track(s) · ${ts ? new Date(ts * 1000).toISOString().slice(0, 10) : 'no date'} · UPC ${a.upc || '—'} · ℗ "${a.pline || ''}"`); const uploader = a.uploader || {}; const linkFor = name => (uploader.name && normName(name) === normName(uploader.name) ? `https://audiomack.com/${uploader.url_slug}` : null); // only the uploader has a page we know const names = text => String(text || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean).map(name => ({ name, url: linkFor(name) })); const tracks = list.map((t, i) => { const tf = splitFeat(t.title || ''); const feat = tf.feat.length ? tf.feat : String(t.featuring || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean); const credit = creditFromTitle(names(t.artist || a.artist), feat); progress && progress(i + 1, list.length); Log.debug(`track ${i + 1}: "${tf.title}" — ${credit.map(c => c.name + c.join).join('')} (${t.isrc || 'no ISRC'})`); const songSlug = t.url_slug; return { title: tf.title, lengthMs: Number(t.duration) > 0 ? Number(t.duration) * 1000 : null, isrc: t.isrc || null, url: songSlug ? `https://audiomack.com/${t.uploader_url_slug || uploader.url_slug || artistSlug}/song/${songSlug}` : null, credit }; }); const af = splitFeat(a.title || ''); const credit = /^various artists$/i.test(a.artist || '') ? [{ name: 'Various Artists', mbid: VARIOUS_ARTISTS_MBID, join: '' }] : creditFromTitle(names(a.artist), af.feat.length ? af.feat : String(a.featuring || '').split(/\s*,\s*|\s+&\s+/).filter(Boolean)); const d = ts ? new Date(ts * 1000) : null; const url = `https://audiomack.com/${id}`; const lbl = this.label(a.pline); return { source: this.id, annotation: notesText(a.description), url, title: af.title, credit, types: kind === 'song' ? ['Single'] : [], // an album: its title's EP / Single, or its track count, decide it (importCurrent) status: 'official', packaging: 'None', date: { year: d ? d.getUTCFullYear() : null, month: d ? d.getUTCMonth() + 1 : null, day: d ? d.getUTCDate() : null }, country: 'XW', barcode: String(a.upc || '').replace(/\D/g, '') || null, labels: lbl ? [{ name: lbl, catno: '' }] : [], urls: [{ url, linkType: 85 }], // 85 = stream for free mediums: [{ format: 'Digital Media', name: '', tracks }], }; }, }; const PROVIDERS = [DEEZER, BANDCAMP, DISCOGS, APPLE, TIDAL, QOBUZ, BEATPORT, SPOTIFY, YTMUSIC, VOLUMO, HDTRACKS, SOUNDCLOUD, AMAZON, AUDIOMACK]; /* ── the seed: model → the release editor's POST parameters ──────────────── */ // https://musicbrainz.org/doc/Development/Release_Editor_Seeding function seedParams(rel, editNote) { const p = []; const add = (k, v) => { if (v !== null && v !== undefined && v !== '') p.push([k, String(v)]); }; const credit = (prefix, c) => (c || []).forEach((a, i) => { add(`${prefix}artist_credit.names.${i}.name`, a.name); add(`${prefix}artist_credit.names.${i}.artist.name`, a.artistName || a.name); add(`${prefix}artist_credit.names.${i}.mbid`, a.mbid); add(`${prefix}artist_credit.names.${i}.join_phrase`, a.join); }); add('name', rel.title); credit('', rel.credit); (rel.types || []).forEach(t => add('type', t)); add('status', rel.status); add('packaging', rel.packaging); add('script', rel.script); add('barcode', rel.barcode); if (rel.date && (rel.date.year || rel.country)) { add('events.0.date.year', rel.date.year); add('events.0.date.month', rel.date.month); add('events.0.date.day', rel.date.day); add('events.0.country', rel.country); } (rel.labels || []).forEach((l, i) => { add(`labels.${i}.name`, l.name); add(`labels.${i}.mbid`, l.mbid); add(`labels.${i}.catalog_number`, l.catno); }); (rel.urls || []).forEach((u, i) => { add(`urls.${i}.url`, u.url); add(`urls.${i}.link_type`, u.linkType); }); rel.mediums.forEach((m, i) => { add(`mediums.${i}.format`, m.format); add(`mediums.${i}.name`, m.name); m.tracks.forEach((t, j) => { const pre = `mediums.${i}.track.${j}.`; add(pre + 'name', t.title); add(pre + 'number', t.number || j + 1); add(pre + 'length', t.lengthMs); credit(pre, t.credit); }); }); add('annotation', rel.annotation); add('edit_note', editNote); return p; } function editNoteFor(rel, provider, archive) { const lines = [`Imported from ${provider.name}: ${rel.url}`]; if (archive) { // the snapshot is made after the seed, so its exact time isn't known yet: the import's time // is, and Wayback sends /web/