// ==UserScript== // @name Import Deezer releases into MusicBrainz // @description One-click importing of releases from deezer.com into MusicBrainz. Also allows to submit their ISRCs to MusicBrainz releases. // @version 2026.09.16.1 // @author atj // @namespace https://github.com/murdos/musicbrainz-userscripts/ // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/deezer_importer.user.js // @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/deezer_importer.user.js // @match https://www.deezer.com/* // @connect api.deezer.com // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @icon https://metabrainz.org/static/img/projects/musicbrainz.svg // ==/UserScript== (function () { 'use strict'; let LogLevel = /*#__PURE__*/function (LogLevel) { LogLevel["DEBUG"] = "debug"; LogLevel["INFO"] = "info"; LogLevel["ERROR"] = "error"; return LogLevel; }({}); class Logger { LOG_LEVEL = LogLevel.INFO; scriptName; constructor(scriptName, level = LogLevel.ERROR) { this.scriptName = scriptName; this.LOG_LEVEL = level; } debug(...args) { this._log(LogLevel.DEBUG, args); } info(...args) { this._log(LogLevel.INFO, args); } error(...args) { this._log(LogLevel.ERROR, args); } setLevel(level) { this.LOG_LEVEL = level; } _log(level, args) { if (level < this.LOG_LEVEL) { return; } let logMethod = console.log; switch (level) { case LogLevel.DEBUG: logMethod = console.debug; break; case LogLevel.INFO: logMethod = console.info; break; case LogLevel.ERROR: logMethod = console.error; break; } try { logMethod.apply(this, [`[${this.scriptName}]`, ...args]); } catch { // do nothing } } } // convert HH:MM:SS or MM:SS to milliseconds function hmsToMilliSeconds(str) { if (typeof str == 'undefined' || str === null || str === '') return NaN; if (typeof str == 'number') return str; const t = str.split(':'); let s = 0; let m = 1; while (t.length > 0) { s += m * parseInt(t.pop(), 10); m *= 60; } return s * 1000; } // convert ISO8601 duration (limited to hours/minutes/seconds) to milliseconds // format looks like PT1H45M5.789S (note: floats can be used) // https://en.wikipedia.org/wiki/ISO_8601#Durations function ISO8601toMilliSeconds(str) { const regex = /^PT(?:(\d*\.?\d*)H)?(?:(\d*\.?\d*)M)?(?:(\d*\.?\d*)S)?$/; const m = str.replace(',', '.').match(regex); if (!m) return NaN; return (3600 * parseFloat(m[1] || '0') + 60 * parseFloat(m[2] || '0') + parseFloat(m[3] || '0')) * 1000; } // compute HTML of import form function buildFormHTML(parameters) { // Build form let innerHTML = `
`; parameters.forEach(function (parameter) { const value = parameter.value.toString(); innerHTML += ``; }); innerHTML += ''; innerHTML += '
'; return innerHTML; } function luceneEscape(text) { let newText = text.replace(/[-[\]{}()*+?~:\\^!"/]/g, '\\$&'); newText = newText.replace('&&', '&&').replace('||', '||'); return newText; } function appendParameter(parameters, paramName, paramValue) { if (!paramValue) return; parameters.push({ name: paramName, value: paramValue }); } function searchParams(release) { const params = []; const totaltracks = release.discs.reduce((acc, { tracks }) => acc + tracks.length, 0); let release_artist = ''; for (let i = 0; i < release.artist_credit.length; i++) { const ac = release.artist_credit[i]; if (ac) { release_artist += ac.artist_name; if (typeof ac.joinphrase != 'undefined' && ac.joinphrase != '') { release_artist += ac.joinphrase; } else { if (i != release.artist_credit.length - 1) release_artist += ', '; } } } const query = `artist:(${luceneEscape(release_artist)})` + ` release:(${luceneEscape(release.title)})` + ` tracks:(${totaltracks})${release.country ? ` country:${release.country}` : ''}`; appendParameter(params, 'query', query); appendParameter(params, 'type', 'release'); appendParameter(params, 'advanced', '1'); return params; } const VERSION_MARKER = /\b(?:acoustic|clean|club|demo|dub|edit|explicit|extended|instrumental|karaoke|live|mix|mono|radio|remaster(?:ed)?|remix|stereo|version|vocal)\b/i; /** Remove version information while retaining the actual work title. */ function normalizeTrackTitle(title) { let normalized = title.normalize('NFKC').toLocaleLowerCase(); // Remove bracketed qualifiers such as "(Jane Doe Remix)" or "[Live]". normalized = normalized.replace(/\s*[([{]([^\])}]*?)[\])}]/g, (match, contents) => VERSION_MARKER.test(contents) ? '' : match); // Also support unbracketed suffixes such as " - Radio Edit". normalized = normalized.replace(/\s*[-–—:]\s*([^\n]*)$/, (match, suffix) => VERSION_MARKER.test(suffix) ? '' : match); return normalized.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/\s+/g, ' '); } function isMultiTrackSingle(numTracks, trackTitles) { if (!Array.isArray(trackTitles) || numTracks < 2 || trackTitles.length !== numTracks || trackTitles.some(title => typeof title !== 'string')) { return false; } const normalizedTitles = trackTitles.map(normalizeTrackTitle); return normalizedTitles.every(title => title.length > 0 && title === normalizedTitles[0]); } /** * Guess a primary release type in descending order of confidence: * * 1. Reject invalid track counts. * 2. Honor an explicit "EP" or "E.P." token in the release title. It takes precedence over every other signal, including "Single" and version-title deduplication. * 3. Honor an explicit "Single" token when the release remains within broad track count and duration guards. Unlike "EP", "single" is common English text and therefore needs basic false-positive protection. * 4. Normalize track titles by removing technical version qualifiers such as "Remix", "Instrumental", "Edit", "Live", and "Version". If every track then has the same non-empty title, classify the release as a multi-track Single. * 5. If duration is missing, use track count only where it is reasonably decisive: one track is a Single, three to six tracks is an EP, and seven or more tracks is an album. Leave two tracks unclassified because both Singles and electronic EPs commonly have two tracks. * 6. With duration available, seven or more tracks or more than 30 minutes is an album. For releases with fewer than seven tracks, one to seven minutes is a Single; more than seven and up to 30 minutes with at least two tracks is an EP. Leave sub-minute releases and one-track releases between seven and 30 minutes unclassified rather than making a weak guess. * * `durationMs` is the complete release duration. Pass NaN when one or more track durations are unavailable. `trackTitles` must contain every track title for the multi-track Single check to apply. */ function guessReleaseType(title, numTracks, durationMs, trackTitles = []) { if (!Number.isInteger(numTracks) || numTracks < 1) return ''; const releaseTitle = typeof title === 'string' ? title : ''; const hasSingle = /\bsingle\b/i.test(releaseTitle); const hasEP = /\bE\.?P\b\.?/i.test(releaseTitle); const hasDuration = Number.isFinite(durationMs) && durationMs > 0; const durationMinutes = hasDuration ? durationMs / 60_000 : Number.NaN; // "EP" is a comparatively unambiguous marketing token and takes precedence, including over track-title deduplication and a simultaneous "Single" token. if (hasEP) return 'EP'; // "Single" is a common English word, so retain broad sanity limits. A missing duration is not evidence against an otherwise plausible explicit token. if (hasSingle && numTracks <= 8 && (!hasDuration || durationMinutes <= 50)) return 'single'; // Remix/version bundles of one work are normally marketed as singles. Do this before count/duration heuristics so large remix bundles can still be detected. if (isMultiTrackSingle(numTracks, trackTitles)) return 'single'; if (!hasDuration) { if (numTracks === 1) return 'single'; if (numTracks >= 3 && numTracks <= 6) return 'EP'; if (numTracks >= 7) return 'album'; // A two-track release without duration can plausibly be a Single or an EP. return ''; } // Track count is strong evidence for albums even when individual tracks are short. if (numTracks >= 7) return 'album'; if (durationMinutes > 30) return 'album'; if (durationMinutes < 1) return ''; if (durationMinutes <= 7) return 'single'; if (numTracks >= 2) return 'EP'; // A long one-track release is album-like; 7..30 minutes remains too ambiguous. return ''; } function buildArtistCreditsFormParameters(parameters, paramPrefix, artist_credit) { for (let i = 0; i < artist_credit.length; i++) { const ac = artist_credit[i]; if (ac) { appendParameter(parameters, `${paramPrefix}artist_credit.names.${i}.name`, ac.credited_name || ''); appendParameter(parameters, `${paramPrefix}artist_credit.names.${i}.artist.name`, ac.artist_name); if (ac.mbid) appendParameter(parameters, `${paramPrefix}artist_credit.names.${i}.mbid`, ac.mbid); if (typeof ac.joinphrase != 'undefined' && ac.joinphrase != '') { appendParameter(parameters, `${paramPrefix}artist_credit.names.${i}.join_phrase`, ac.joinphrase); } } } } // build form POST parameters that MB is expecting function buildFormParameters(release, edit_note) { // Form parameters const parameters = []; appendParameter(parameters, 'name', release.title); // Release Artist credits buildArtistCreditsFormParameters(parameters, '', release.artist_credit); if (release['secondary_types']) { for (let i = 0; i < release.secondary_types.length; i++) { const secondaryType = release.secondary_types[i]; if (secondaryType) { appendParameter(parameters, 'type', secondaryType); } } } if (release.status) appendParameter(parameters, 'status', release.status); if (release.language) appendParameter(parameters, 'language', release.language); if (release.script) appendParameter(parameters, 'script', release.script); if (release.packaging) appendParameter(parameters, 'packaging', release.packaging); // ReleaseGroup if (release.release_group_mbid) appendParameter(parameters, 'release_group', release.release_group_mbid); // Date + country if (release.country) appendParameter(parameters, 'country', release.country); if (!isNaN(release.year || 0) && release.year != 0) { appendParameter(parameters, 'date.year', release.year); } if (!isNaN(release.month || 0) && release.month != 0) { appendParameter(parameters, 'date.month', release.month); } if (!isNaN(release.day || 0) && release.day != 0) { appendParameter(parameters, 'date.day', release.day); } // Barcode if (release.barcode) appendParameter(parameters, 'barcode', release.barcode); // Disambiguation comment if (release.comment) appendParameter(parameters, 'comment', release.comment); // Annotation if (release.annotation) appendParameter(parameters, 'annotation', release.annotation); // Label + catnos if (Array.isArray(release.labels)) { for (let i = 0; i < release.labels.length; i++) { const label = release.labels[i]; if (label) { appendParameter(parameters, `labels.${i}.name`, label.name); if (label.mbid) appendParameter(parameters, `labels.${i}.mbid`, label.mbid); if (label.catno && label.catno != 'none') { appendParameter(parameters, `labels.${i}.catalog_number`, label.catno); } } } } // URLs if (Array.isArray(release.urls)) { for (let i = 0; i < release.urls.length; i++) { const url = release.urls[i]; if (url) { appendParameter(parameters, `urls.${i}.url`, url.url); appendParameter(parameters, `urls.${i}.link_type`, url.link_type); } } } // Mediums let total_tracks = 0; let total_tracks_with_duration = 0; let total_duration = 0; const track_titles = []; for (let i = 0; i < release.discs.length; i++) { const disc = release.discs[i]; if (disc) { appendParameter(parameters, `mediums.${i}.format`, disc.format); if (disc.title) appendParameter(parameters, `mediums.${i}.name`, disc.title); // Tracks for (let j = 0; j < disc.tracks.length; j++) { const track = disc.tracks[j]; if (track) { total_tracks++; track_titles.push(track.title); if (track.number) appendParameter(parameters, `mediums.${i}.track.${j}.number`, track.number); appendParameter(parameters, `mediums.${i}.track.${j}.name`, track.title); let tracklength = '?:??'; const duration_ms = hmsToMilliSeconds(track.duration); if (!isNaN(duration_ms)) { tracklength = duration_ms.toString(); total_tracks_with_duration++; total_duration += duration_ms; } appendParameter(parameters, `mediums.${i}.track.${j}.length`, tracklength); // @ts-expect-error TODO: recording is not a property of Track and in no importer scripts a recording is found in a track. Once all scripts are migrated, we need to see if we can remove this line entirely. if (track.recording) appendParameter(parameters, `mediums.${i}.track.${j}.recording`, track.recording); // oxlint-disable-line typescript/no-unsafe-argument buildArtistCreditsFormParameters(parameters, `mediums.${i}.track.${j}.`, track.artist_credit); } } } } // Guess release type if not given if (!release.type && release.title) { const allTracksHaveDuration = total_tracks === total_tracks_with_duration; const complete_duration = allTracksHaveDuration ? total_duration : Number.NaN; release.type = guessReleaseType(release.title, total_tracks, complete_duration, track_titles); } if (release.type) appendParameter(parameters, 'type', release.type); // Add Edit note parameter if (edit_note) appendParameter(parameters, 'edit_note', edit_note); return parameters; } const styleBlockIconButton = ` `; const styleBlockFullButton = ` `; function buildHarmonyButton({ barcode, release_url, variant }) { const searchParams = new URLSearchParams(); if (barcode) { searchParams.set('gtin', barcode); } if (release_url) { searchParams.set('url', encodeURI(release_url)); } searchParams.set('category', 'preferred'); // take Harmony user preferences into account searchParams.set('musicbrainz', ''); // enforce lookup by barcode in MusicBrainz const harmonyURL = `https://harmony.pulsewidth.org.uk/release?${searchParams.toString()}`; return ` ${variant === 'full' ? styleBlockFullButton : styleBlockIconButton} Harmony icon ${variant === 'full' ? 'Import with Harmony' : ''} `; } // compute HTML of search button function buildSearchButton(release) { const parameters = searchParams(release); let html = `'; return html; } function buildSearchLink(release) { const parameters = searchParams(release); const url_params = []; parameters.forEach(function (parameter) { const value = `${parameter.value}`; url_params.push(encodeURI(`${parameter.name}=${value}`)); }); return `Search in MusicBrainz`; } function searchUrlFor(type, what) { type = type.replace('-', '_'); const params = [`query=${luceneEscape(what)}`, `type=${type}`, 'indexed=1']; return `https://musicbrainz.org/search?${params.join('&')}`; } function exactSearchUrlFor(type, what, limit = 25) { type = type.replace('-', '_'); const query = `"${luceneEscape(what)}"`; const params = [`query=${encodeURIComponent(query)}`, `type=${type}`, `limit=${limit}`, 'method=advanced']; return `https://musicbrainz.org/search?${params.join('&')}`; } const MB_SEARCH_MARKS = { artist: 'A', recording: 'T', release: 'R', 'release-group': 'G', place: 'P', label: 'L', series: 'S' }; /** * Create the compact entity search indicator used next to external entity links. * Placement and replacement with resolved MusicBrainz links are left to the caller. */ function createEntitySearchLink(mbType, entityName, { searchMode = 'indexed' } = {}) { const normalizedType = mbType.replaceAll('_', '-'); const mark = MB_SEARCH_MARKS[normalizedType] || ''; const displayType = normalizedType in MB_SEARCH_MARKS ? normalizedType.replaceAll('-', ' ') : 'entity'; const href = searchMode === 'exact' ? exactSearchUrlFor(mbType, entityName) : searchUrlFor(mbType, entityName); const indicator = document.createElement('span'); indicator.className = 'mb_valign mb_searchit'; const searchLink = document.createElement('a'); searchLink.className = 'mb_search_link'; searchLink.target = '_blank'; searchLink.title = `Search this ${displayType} on MusicBrainz (open in a new tab)`; searchLink.href = href; searchLink.innerHTML = `${mark}?`; indicator.append(searchLink); return indicator; } function setEntityLookupState(indicator, state) { indicator.classList.remove('mb_lookup_error', 'mb_lookup_loading'); indicator.removeAttribute('aria-label'); indicator.removeAttribute('role'); indicator.removeAttribute('title'); if (state === 'matched') { indicator.classList.remove('mb_searchit'); return; } indicator.classList.add('mb_searchit'); if (state === 'loading') { indicator.classList.add('mb_lookup_loading'); indicator.setAttribute('aria-label', 'Looking up this entity on MusicBrainz'); indicator.setAttribute('role', 'status'); indicator.title = 'Looking up this entity on MusicBrainz'; } else if (state === 'error') { indicator.classList.add('mb_lookup_error'); indicator.setAttribute('aria-label', 'MusicBrainz lookup failed'); indicator.setAttribute('role', 'img'); indicator.title = 'MusicBrainz lookup failed'; } } function createEntityLookupIndicator(mbType, entityName, options) { const indicator = createEntitySearchLink(mbType, entityName, options); setEntityLookupState(indicator, 'loading'); return indicator; } // Convert a list of artists to a list of artist credits with joinphrases function makeArtistCredits(artists_list) { const artists = artists_list.map(function (item) { return { artist_name: item }; }); if (artists.length > 2) { const last = artists.pop(); if (last) { last.joinphrase = ''; const prev = artists.pop(); if (prev) { prev.joinphrase = ' & '; for (let i = 0; i < artists.length; i++) { const artist = artists[i]; if (artist) { artist.joinphrase = ', '; } } artists.push(prev); artists.push(last); } } } else if (artists.length == 2) { const first = artists[0]; if (first) { first.joinphrase = ' & '; } } const credits = []; // re-split artists if featuring or vs artists.map(function (item) { let c = item.artist_name.replace(/\s*\b(?:feat\.?|ft\.?|featuring)\s+/gi, ' feat. '); c = c.replace(/\s*\(( feat. )([^)]+)\)/g, '$1$2'); c = c.replace(/\s*\b(?:versus|vs\.?)\s+/gi, ' vs. '); c = c.replace(/\s+/g, ' '); const splitted = c.split(/( feat\. | vs\. )/); if (splitted.length === 1) { credits.push(item); // nothing to split } else { const new_items = []; let n = 0; for (const element of splitted) { if (n && (element === ' feat. ' || element === ' vs. ')) { const prevItem = new_items[n - 1]; if (prevItem) { prevItem.joinphrase = element; } } else { new_items[n++] = { artist_name: element.trim(), joinphrase: '' }; } } const lastItem = new_items[n - 1]; if (lastItem && item.joinphrase) { lastItem.joinphrase = item.joinphrase; } new_items.forEach(newit => credits.push(newit)); } }); return credits; } function makeEditNote(release_url, importer_name, format, home = 'https://github.com/murdos/musicbrainz-userscripts') { return `Imported from ${release_url}${format ? ` (${format})` : ''} using ${importer_name} import script from ${home}`; } const special_artists = { various_artists: { name: 'Various Artists', mbid: '89ad4ac3-39f7-470e-963a-56509c546377' }, unknown: { name: '[unknown]', mbid: '125ec42a-7229-4250-afc5-e057484327fe' } }; function specialArtist(key, ac) { let joinphrase = ''; if (typeof ac !== 'undefined') { joinphrase = ac.joinphrase || ''; } const specialArtist = special_artists[key]; if (!specialArtist) { throw new Error(`Unknown special artist: ${key}`); } return { artist_name: specialArtist.name, credited_name: '', joinphrase: joinphrase, mbid: specialArtist.mbid }; } const URL_TYPES = { purchase_for_download: 74, download_for_free: 75, discogs: 76, purchase_for_mail_order: 79, other_databases: 82, stream_for_free: 85, license: 301 }; const MBImport = { buildHarmonyButton, buildSearchLink, buildSearchButton, createEntitySearchLink, createEntityLookupIndicator, setEntityLookupState, buildFormHTML, buildFormParameters, makeArtistCredits, guessReleaseType, hmsToMilliSeconds, ISO8601toMilliSeconds, makeEditNote, searchUrlFor, exactSearchUrlFor, URL_TYPES, SPECIAL_ARTISTS: special_artists, specialArtist }; function _add_css(css) { document.head.insertAdjacentHTML('beforeend', ``); } function MBImportStyle() { const css_import_button = ` #mb_buttons { display: flex; gap: 5px; } .musicbrainz_import button { margin: 0 !important; border-radius:5px; display: flex; justify-content: center; align-items: center; cursor:pointer; font-family:Arial; font-size:12px !important; padding:3px 6px; text-decoration:none; border: 1px solid rgba(180,180,180,0.8) !important; background-color: rgba(240,240,240,0.8) !important; color: #334 !important; height: 26px ; } .musicbrainz_import button:hover { background-color: rgba(250,250,250,0.9) !important; } .musicbrainz_import button:active { background-color: rgba(170,170,170,0.8) !important; } .musicbrainz_import button img { vertical-align: middle !important; margin-right: 4px !important; height: 16px; } img[src*="musicbrainz.org"] { display: inline-block; } `; _add_css(css_import_button); } /** * Subscribe to Single Page Application (SPA) navigation events. * Uses pushState/replaceState interception when possible; falls back to URL polling in sandboxed environments * (e.g. Firefox/Greasemonkey) where the page uses a different history object. * * @param onNavigate - Callback function to execute when navigation occurs * @param delay - Delay in milliseconds before calling onNavigate (default: 200ms) * @param pollInterval - If set, polls location.href for changes; use when pushState interception doesn't work (default: 400ms, 0 to disable) * @returns Cleanup function to unsubscribe from navigation events */ function subscribeToSPANavigation({ onNavigate, delay = 200, pollInterval = 400 }) { let currentUrl = window.location.href; const originalPushState = history.pushState.bind(history); const originalReplaceState = history.replaceState.bind(history); const scheduleOnNavigate = () => { const newUrl = window.location.href; if (newUrl !== currentUrl) { currentUrl = newUrl; setTimeout(() => { void onNavigate(); }, delay); } }; let pushStatePatched = false; let replaceStatePatched = false; try { history.pushState = function (...args) { originalPushState.apply(history, args); scheduleOnNavigate(); }; pushStatePatched = true; } catch { // pushState is read-only in some sandboxed environments } try { history.replaceState = function (...args) { originalReplaceState.apply(history, args); scheduleOnNavigate(); }; replaceStatePatched = true; } catch { // replaceState is read-only in some sandboxed environments } let pollTimer; if (pollInterval > 0) { pollTimer = setInterval(scheduleOnNavigate, pollInterval); } const popstateHandler = () => { currentUrl = window.location.href; setTimeout(() => { void onNavigate(); }, delay); }; window.addEventListener('popstate', popstateHandler); return () => { if (pollTimer) clearInterval(pollTimer); if (pushStatePatched) history.pushState = originalPushState; if (replaceStatePatched) history.replaceState = originalReplaceState; window.removeEventListener('popstate', popstateHandler); }; } function getGmApi(name) { // Tampermonkey may only inject granted APIs when the script references them directly. const apis = {}; if (typeof GM !== 'undefined') { apis.getValue = GM.getValue; apis.setValue = GM.setValue; apis.xmlHttpRequest = GM.xmlHttpRequest; } if (!apis.getValue && typeof GM_getValue !== 'undefined') { apis.getValue = GM_getValue; } if (!apis.setValue && typeof GM_setValue !== 'undefined') { apis.setValue = GM_setValue; } if (!apis.xmlHttpRequest && typeof GM_xmlhttpRequest !== 'undefined') { apis.xmlHttpRequest = GM_xmlhttpRequest; } return apis[name]; } const releaseCache = new Map(); function httpGetJson(url, logger) { const request = getGmApi('xmlHttpRequest'); if (!request) { logger.error('Userscript requires GM_xmlHttpRequest or GM.xmlHttpRequest'); return Promise.resolve(null); } return new Promise(resolve => { request({ method: 'GET', url, onload: res => { if (res.status >= 200 && res.status < 300) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (err) { logger.error(`Failed to parse JSON from ${url}:`, err); resolve(null); } } else { logger.error(`HTTP request to ${url} failed with status ${res.status}`); resolve(null); } }, onerror: res => { logger.error(`Network error requesting ${url} (status: ${res.status})`); resolve(null); } }); }); } /** * Fetches complete Deezer album data and all paginated tracks. * If any track pagination request fails, returns null without caching partial data. */ async function getDeezerReleaseData(releaseId, logger) { const cached = releaseCache.get(releaseId); if (cached) { return cached; } const albumApiUrl = `https://api.deezer.com/album/${releaseId}?limit=1`; const album = await httpGetJson(albumApiUrl, logger); if (!album || !album.title) { logger.error(`Could not retrieve Deezer album info for release ID ${releaseId}`); return null; } album.tracks = { data: [] }; let nextTracksUrl = `https://api.deezer.com/album/${releaseId}/tracks?limit=100`; while (nextTracksUrl) { const tracksResponse = await httpGetJson(nextTracksUrl, logger); if (!tracksResponse || !tracksResponse.data) { logger.error(`Failed to fetch complete track list for album ${releaseId}`); return null; } album.tracks.data.push(...tracksResponse.data); nextTracksUrl = tracksResponse.next; } releaseCache.set(releaseId, album); return album; } function parseDeezerRelease(releaseUrl, data) { const releaseDate = (data.release_date || '').split('-'); const year = parseInt(releaseDate[0] || '', 10); const month = parseInt(releaseDate[1] || '', 10); const day = parseInt(releaseDate[2] || '', 10); const artist_credit = []; const urls = [{ link_type: URL_TYPES.stream_for_free, url: releaseUrl }]; const labels = data.label ? [{ name: data.label }] : []; const discs = []; const release = { artist_credit, title: data.title, packaging: 'None', country: 'XW', status: 'official', language: 'eng', script: 'Latn', type: data.record_type, urls, labels, discs }; if (!Number.isNaN(year)) { release.year = year; } if (!Number.isNaN(month)) { release.month = month; } if (!Number.isNaN(day)) { release.day = day; } if (data.upc) { release.barcode = data.upc; } const isrcs = []; const contributors = data.contributors || []; contributors.forEach((contributor, index) => { if (contributor.role !== 'Main') return; let ac = { artist_name: contributor.name, joinphrase: index === contributors.length - 1 ? '' : ', ' }; if (contributor.name === 'Various Artists') { ac = specialArtist('various_artists', ac); } artist_credit.push(ac); }); for (const track of data.tracks.data) { const mbTrack = { number: track.track_position, title: track.title_short, duration: track.duration * 1000, artist_credit: [{ artist_name: track.artist.name }] }; if (track.isrc) isrcs.push(track.isrc);else isrcs.push(null); // ignore pointless "(Original Mix)" in title version if (track.title_version && !/^\s*\(Original Mix\)\s*$/i.test(track.title_version)) { mbTrack.title += ` ${track.title_version}`; } const diskNumber = track.disk_number || 1; while (discs.length < diskNumber) { discs.push({ format: 'Digital Media', title: '', tracks: [] }); } const currentDisc = discs[diskNumber - 1]; if (currentDisc) { currentDisc.tracks.push(mbTrack); } } return { release, isrcs }; } const LOGGER = new Logger('deezer_importer', LogLevel.INFO); let currentRunId = 0; let mountedElements = []; function cleanup() { mountedElements.forEach(el => { el.remove(); }); mountedElements = []; } function waitForEl(selector, runId, callback) { if (runId !== currentRunId) { return; } const el = document.querySelector(selector); if (el) { callback(el); } else { setTimeout(() => { waitForEl(selector, runId, callback); }, 100); } } function insertLink(release, releaseUrl, isrcs, runId) { const editNote = MBImport.makeEditNote(releaseUrl, 'Deezer'); const parameters = MBImport.buildFormParameters(release, editNote); const importItem = document.createElement('div'); importItem.className = 'toolbar-item'; importItem.innerHTML = MBImport.buildFormHTML(parameters); const searchItem = document.createElement('div'); searchItem.className = 'toolbar-item'; searchItem.innerHTML = MBImport.buildSearchButton(release); const isrcItem = document.createElement('div'); isrcItem.className = 'toolbar-item'; const isrcForm = document.createElement('form'); isrcForm.className = 'musicbrainz_import'; const isrcButton = document.createElement('button'); isrcButton.type = 'submit'; isrcButton.title = "Submit ISRCs to MusicBrainz with kepstin's MagicISRC"; isrcButton.innerHTML = 'Submit ISRCs'; isrcForm.appendChild(isrcButton); isrcForm.addEventListener('click', event => { event.preventDefault(); const query = [`edit-note=${encodeURIComponent(editNote)}`, ...isrcs.map((isrc, index) => isrc == null ? `isrc${index + 1}=` : `isrc${index + 1}=${isrc}`)].join('&'); window.open(`https://magicisrc.kepstin.ca?${query}`); }); isrcItem.appendChild(isrcForm); const toolbarItems = [importItem, searchItem, isrcItem]; waitForEl('[data-testid="toolbar"]', runId, toolbar => { if (runId === currentRunId) { toolbar.style.alignItems = 'center'; toolbar.append(...toolbarItems); mountedElements.push(...toolbarItems); } }); // Deezer Mobile is a completely different App, so we need to mount differently waitForEl('[data-tracking-label="main-CTA"]', runId, cta => { if (runId === currentRunId) { const mbUIContainer = document.createElement('div'); mbUIContainer.style.cssText = 'display: flex; flex-direction: row; flex-wrap: wrap; justify-content: center; width: 100%; gap: 4px;'; mbUIContainer.append(...toolbarItems); cta.insertAdjacentElement('afterend', mbUIContainer); mountedElements.push(mbUIContainer); } }); } function processPage() { const runId = ++currentRunId; cleanup(); const releaseUrl = window.location.href.replace(/\?.*$/, '').replace(/#.*$/, ''); const releaseId = releaseUrl.replace(/^https?:\/\/www\.deezer\.com\/[^/]+\/album\//i, ''); if (!releaseId || !/^\d+$/.test(releaseId)) { return Promise.resolve(); } return getDeezerReleaseData(releaseId, LOGGER).then(data => { if (runId !== currentRunId) { return; } if (data) { const { release, isrcs } = parseDeezerRelease(releaseUrl, data); insertLink(release, releaseUrl, isrcs, runId); } }).catch(err => { LOGGER.error('Failed to parse release: ', err); }); } function init() { MBImportStyle(); // allow 1 second for Deezer SPA to initialize setTimeout(() => { void processPage(); }, 1000); subscribeToSPANavigation({ onNavigate: () => processPage() }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();