// ==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 = `
';
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}
${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();
}
})();