// ==UserScript==
// @name Import Mirlo releases to MusicBrainz
// @description One-click importing of releases from mirlo.space into MusicBrainz
// @version 2026.09.16.1
// @author Raman Sinclair
// @namespace https://github.com/murdos/musicbrainz-userscripts/
// @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/mirlo_importer.user.js
// @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/mirlo_importer.user.js
// @match https://mirlo.space/*
// @connect musicbrainz.org
// @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);
}
function MBSearchItStyle() {
const css_search_it = `
.mb_valign {
display: inline-block;
vertical-align: top;
}
.mb_searchit {
width: 16px;
height: 16px;
margin: 0;
padding: 0;
background-color: #FFF7BE;
border: 0px;
vertical-align: top;
font-size: 11px;
text-align: center;
}
.mb_valign.mb_searchit {
font-weight: bold;
line-height: 16px;
}
a.mb_search_link {
color: #888;
text-decoration: none;
}
a.mb_search_link small {
font-size: 8px;
}
.mb_searchit a.mb_search_link:hover {
color: darkblue;
}
.mb_lookup_loading > *,
.mb_lookup_error > * {
display: none !important;
}
.mb_lookup_loading,
.mb_lookup_error {
display: inline-flex;
align-items: center;
justify-content: center;
}
.mb_lookup_loading::before {
content: '';
display: block;
width: 11px;
height: 11px;
box-sizing: border-box;
border: 2px solid #d7ca75;
border-top-color: #ba478f;
border-radius: 50%;
animation: mb_lookup_spin 1.2s linear infinite;
}
.mb_lookup_error::before {
content: '⚠';
color: #c62828;
font-size: 13px;
line-height: 16px;
}
@keyframes mb_lookup_spin {
to { transform: rotate(360deg); }
}
.mb_wrapper {
display: inline-block;
}
`;
_add_css(css_search_it);
}
/**
* 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];
}
// Class MBLinks : query MusicBrainz for urls and display links for matching urls
// The main method is searchAndDisplayMbLinks()
function getRawHeader(rawHeaders, name) {
const expectedName = name.toLowerCase();
for (const line of rawHeaders.split(/\r?\n/)) {
const separator = line.indexOf(':');
if (separator >= 0 && line.slice(0, separator).trim().toLowerCase() === expectedName) {
return line.slice(separator + 1).trim();
}
}
return null;
}
function requestJSON(url) {
const gmRequest = getGmApi('xmlHttpRequest');
if (!gmRequest) {
return fetch(url, {
headers: {
Accept: 'application/json'
}
}).then(response => ({
ok: response.ok,
status: response.status,
getHeader: name => response.headers?.get(name) ?? null,
json: () => response.json()
}));
}
return new Promise((resolve, reject) => {
gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json'
},
responseType: 'json',
onload: response => {
resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
getHeader: name => getRawHeader(response.responseHeaders, name),
json: () => Promise.resolve(response.response ?? JSON.parse(response.responseText))
});
},
onerror: () => reject(new Error('Network request failed'))
});
});
}
function getRetryDelayMs(response) {
const retryAfter = response.getHeader('retry-after');
if (retryAfter) {
const seconds = Number(retryAfter);
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(retryAfter) - Date.now();
if (Number.isFinite(delay) && delay >= 0) return delay;
}
const resetSeconds = Number(response.getHeader('x-ratelimit-reset'));
if (!Number.isFinite(resetSeconds) || resetSeconds <= 0) return undefined;
const serverDate = Date.parse(response.getHeader('date') ?? '');
const referenceTime = Number.isFinite(serverDate) ? serverDate : Date.now();
return Math.max(0, resetSeconds * 1000 - referenceTime);
}
class AjaxRequests {
// properties: "key": {handler: function, next: property, context: {}}
first = '';
last = '';
empty() {
return this.first == '';
}
push(key, handler, context) {
const request = this[key];
if (typeof request === 'object') {
request.handler = handler;
request.context = context;
} else {
this[key] = {
handler: handler,
next: '',
context: context
};
if (this.first == '') {
this.first = this.last = key;
} else {
const lastRequest = this[this.last];
if (typeof lastRequest === 'object') {
lastRequest.next = key;
}
this.last = key;
}
}
}
shift() {
if (this.empty()) {
return;
}
const key = this.first;
const request = this[key];
if (typeof request !== 'object') {
return;
}
const handler = request.handler;
const context = request.context;
this.first = request.next;
// oxlint-disable-next-line typescript/no-dynamic-delete -- Kept in line with the original request queue.
delete this[key]; // delete this property
return handler.bind(context);
}
}
/**
* Processes a URL match from the MusicBrainz API response: updates cache and inserts
* MusicBrainz links into all batch entries that reference this resource.
*
* @private
* @param options - Options for processing the URL match.
* @param options.mblinks - The MBLinks instance (for cache and link creation).
* @param options.batch - Batch of URL data entries for this request.
* @param options.resource - The resource URL from the API response.
* @param options.relations - Relations array from the API response.
*/
function processUrlMatch({
mblinks,
batch,
resource,
relations,
foundQueries
}) {
const matching_urls_data = batch.filter(query => queryMatchesResource(query, resource));
if (matching_urls_data.length === 0) return;
if (!relations) return;
matching_urls_data.forEach(reference => {
const key = reference.key || reference.url;
const _type = reference.mb_type.replace('-', '_');
if (!mblinks.cache[key]) {
mblinks.cache[key] = {
timestamp: new Date().getTime(),
urls: []
};
}
// Build map of mb_url -> ended (true only if every relation for that URL+entity is ended).
const urlData = {};
relations.forEach(relation => {
if (_type in relation) {
const entity = relation[_type];
const mb_url = `${mblinks.mb_server}/${reference.mb_type}/${entity.id}`;
if (!(mb_url in urlData)) urlData[mb_url] = {
ended: true
};
if (!relation.ended) urlData[mb_url].ended = false;
}
});
const cacheUrls = mblinks.cache[key].urls;
const getUrl = entry => typeof entry === 'string' ? entry : entry.url;
Object.keys(urlData).forEach(mb_url => {
foundQueries?.add(reference);
const ended = urlData[mb_url].ended;
const alreadyCached = cacheUrls.some(e => getUrl(e) === mb_url);
if (!alreadyCached) {
cacheUrls.push({
url: mb_url,
ended: _type === 'release' ? ended : false
});
}
const link = mblinks.createMusicBrainzLink(mb_url, _type, _type === 'release' ? {
ended
} : {});
reference.insert_func(link);
});
});
}
function queryMatchesResource(query, resource) {
if (!query.url_regex) return query.url === resource;
try {
return new RegExp(`^(?:${query.url_regex})$`).test(resource);
} catch {
return false;
}
}
// user_cache_key = textual key used to store cached data in local storage
// version = optionnal version, to force creation of a cache (ie. when format of keys changes)
// expiration = time in minutes before an entry is refreshed, value <= 0 disables cache reads, if undefined or false, use defaults
class MBLinks {
supports_local_storage;
ajax_requests = new AjaxRequests();
pendingRequests = [];
requestTimer;
requestInFlight = false;
nextRequestAt = 0;
rateLimitResetAt = 0;
consecutiveRequestFailures = 0;
cache = {};
expirationMinutes;
user_cache_key;
cache_key;
mb_server = 'https://musicbrainz.org';
type_link_info;
constructor(user_cache_key, version, expiration) {
this.supports_local_storage = (() => {
try {
return !!localStorage.getItem;
} catch {
return false;
}
})();
this.expirationMinutes = typeof expiration != 'undefined' && expiration !== false ? parseInt(String(expiration), 10) : 90 * 24 * 60; // default to 90 days
const cache_version = 3;
this.user_cache_key = user_cache_key;
this.cache_key = `${this.user_cache_key}-v${cache_version}${typeof version != 'undefined' ? `.${version}` : ''}`;
// overrides link title and img src url (per type), see createMusicBrainzLink()
this.type_link_info = {
release_group: {
title: 'See this release group on MusicBrainz'
},
place: {
img_src: `
`
}
};
this.initCache();
this.initAjaxEngine();
}
initAjaxEngine() {
const ajax_requests = this.ajax_requests;
setInterval(function () {
if (!ajax_requests.empty()) {
const request = ajax_requests.shift();
if (typeof request === 'function') {
request();
}
}
}, 1000);
}
/**
* GET JSON with retry on 5xx and status-less network errors, using server-provided rate-limit headers when available, with queue-wide exponential backoff capped at 30 seconds and a five-minute retry budget.
* @param url - The URL to request.
* @param successCallback - Called with response data on success.
* @param alwaysCallback - Called when the request is finally done (success or after giving up retries).
*/
getJSONWithRetry(url, successCallback, alwaysCallback) {
const retryDeadline = Date.now() + 5 * 60 * 1000;
const retry = (serverDelayMs = 0) => {
const retryDelayMs = this.recordRequestFailure(serverDelayMs);
if (Date.now() + retryDelayMs <= retryDeadline) {
setTimeout(() => this.scheduleRequest(doRequest, true), retryDelayMs);
} else {
alwaysCallback?.(false);
}
};
const doRequest = async () => {
let response;
try {
response = await requestJSON(url);
} catch {
// Extension throttling and ordinary network failures both arrive without an HTTP status.
retry();
return;
}
const serverDelayMs = getRetryDelayMs(response) ?? 0;
if (!response.ok) {
if (response.status >= 500 && response.status < 600) {
retry(serverDelayMs);
} else if (response.status === 404) {
// The URL endpoint uses 404 to report a resource with no relationships.
this.consecutiveRequestFailures = 0;
try {
successCallback({});
alwaysCallback?.(true);
} catch {
alwaysCallback?.(false);
}
} else {
this.consecutiveRequestFailures = 0;
alwaysCallback?.(false);
}
return;
}
this.consecutiveRequestFailures = 0;
if (response.getHeader('x-ratelimit-remaining') === '0') this.pauseRequests(serverDelayMs);
try {
successCallback(await response.json());
alwaysCallback?.(true);
} catch {
alwaysCallback?.(false);
}
};
this.scheduleRequest(doRequest);
}
scheduleRequest(request, priority = false) {
if (priority) this.pendingRequests.unshift(request);else this.pendingRequests.push(request);
this.runNextRequest();
}
recordRequestFailure(serverDelayMs) {
this.consecutiveRequestFailures += 1;
const retryDelayMs = Math.max(serverDelayMs, Math.min(30_000, 1000 * 2 ** (this.consecutiveRequestFailures - 1)));
this.pauseRequests(retryDelayMs);
return retryDelayMs;
}
pauseRequests(delayMs) {
this.rateLimitResetAt = Math.max(this.rateLimitResetAt, Date.now() + delayMs);
}
runNextRequest() {
if (this.requestTimer || this.requestInFlight || this.pendingRequests.length === 0) return;
const now = Date.now();
const runAt = Math.max(now, this.nextRequestAt, this.rateLimitResetAt);
const delay = runAt - now;
if (delay > 0) {
this.requestTimer = setTimeout(() => {
this.requestTimer = undefined;
this.runNextRequest();
}, delay);
return;
}
const request = this.pendingRequests.shift();
this.nextRequestAt = now + 1000;
this.requestInFlight = true;
void Promise.resolve().then(request).catch(() => undefined).finally(() => {
this.requestInFlight = false;
this.runNextRequest();
});
}
initCache() {
if (!this.supports_local_storage) return;
// Check if we already added links for this content
this.cache = JSON.parse(localStorage.getItem(this.cache_key) || '{}');
// remove old entries
this.clearCacheExpired();
// remove old cache versions
this.removeOldCacheVersions();
}
saveCache() {
if (!this.supports_local_storage) return;
try {
localStorage.setItem(this.cache_key, JSON.stringify(this.cache));
} catch (e) {
alert(e);
}
}
removeOldCacheVersions() {
const to_remove = [];
for (let i = 0, len = localStorage.length; i < len; ++i) {
const key = localStorage.key(i);
if (key.startsWith(this.user_cache_key)) {
if (key !== this.cache_key) {
// we don't want to remove current cache
to_remove.push(key);
}
}
}
// remove old cache keys
for (const element of to_remove) {
localStorage.removeItem(element);
}
}
clearCacheExpired() {
const new_cache = {};
Object.keys(this.cache).forEach(key => {
if (this.is_cached(key)) {
new_cache[key] = this.cache[key];
}
});
this.cache = new_cache;
}
is_cached(key) {
const entry = this.cache[key];
return Boolean(entry && entry.urls && entry.urls.length > 0 && this.expirationMinutes > 0 && new Date().getTime() < entry.timestamp + this.expirationMinutes * 60 * 1000);
}
// Search for ressource 'url' in local cache, and return the matching MBID if there's only matching MB entity.
// If the url is not known by the cache, no attempt will be made to request the MusicBrainz webservice, in order to keep this method synchronous.
resolveMBID(key) {
if (this.is_cached(key) && this.cache[key].urls.length == 1) {
const entry = this.cache[key].urls[0];
const mb_url = typeof entry === 'string' ? entry : entry.url;
return mb_url.slice(-36);
}
return undefined;
}
/**
* Create an HTML element for a MusicBrainz link with the given type and URL.
* @param mb_url - The URL of the MusicBrainz entity.
* @param _type - The type of the MusicBrainz entity.
* @param options - Optional options.
* @param options.ended - When true and type is release, applies grayscale to the icon.
* @returns The HTML for the MusicBrainz link.
*/
createMusicBrainzLink(mb_url, _type, options) {
let title = `See this ${_type} on MusicBrainz`;
let img_url = `${this.mb_server}/static/images/entity/${_type}.svg`;
let img_src = `
`;
// handle overrides
const ti = this.type_link_info[_type];
if (ti) {
if (ti.title) title = ti.title;
if (ti.img_url) img_url = ti.img_url;
if (ti.img_src) img_src = ti.img_src;
}
if (_type === 'release' && options?.ended) {
img_src = img_src.replace('/>', ' style="filter: grayscale(1)" />');
}
return `${img_src} `;
}
// Batch process multiple URLs in a single request
// urls_data should be an array of objects with the following structure:
// { url: string, mb_type: string, insert_func: function, key: string }
searchAndDisplayMbLinks(urls_data) {
// oxlint-disable-next-line typescript/no-this-alias -- Kept in line with the original callback contexts.
const mblinks = this;
// Filter out URLs that are already cached
const uncached_urls = [];
urls_data.forEach(data => {
const key = data.key || data.url;
if (this.is_cached(key)) {
// Handle cached results immediately
const data_type = data.mb_type.replace('-', '_');
mblinks.cache[key].urls.forEach(cacheEntry => {
const mb_url = typeof cacheEntry === 'string' ? cacheEntry : cacheEntry.url;
const ended = typeof cacheEntry === 'string' ? false : cacheEntry.ended;
const options = data_type === 'release' ? {
ended
} : {};
data.insert_func(mblinks.createMusicBrainzLink(mb_url, data_type, options));
});
data.complete_func?.({
found: true,
status: 'success'
});
} else {
uncached_urls.push(data);
}
});
if (uncached_urls.length === 0) {
return; // All URLs were cached
}
// Process URLs in batches
const BATCH_SIZE = 75;
for (let i = 0; i < uncached_urls.length; i += BATCH_SIZE) {
const batch = uncached_urls.slice(i, i + BATCH_SIZE);
const resources = batch.map(data => encodeURIComponent(data.url)).join('&resource=');
const mb_type = batch[0].mb_type;
const query = `${mblinks.mb_server}/ws/2/url?resource=${resources}&inc=${mb_type}-rels`;
// Merge with previous context if there's already a pending ajax request
let handlers = [];
let failureHandlers = [];
const request = mblinks.ajax_requests[query];
if (typeof request === 'object') {
handlers = request.context.handlers;
failureHandlers = request.context.failureHandlers;
}
handlers.push(function (data) {
const foundQueries = new Set();
if ('urls' in data) {
const processedResources = {};
data.urls.forEach(url_data => {
if (processedResources[url_data.resource]) return;
processedResources[url_data.resource] = true;
processUrlMatch({
mblinks,
batch,
resource: url_data.resource,
relations: url_data.relations,
foundQueries
});
});
} else if ('relations' in data && 'resource' in data) {
/**
* For some reason, for a single entity request the API response has a different shape.
*/
processUrlMatch({
mblinks,
batch,
resource: data.resource,
relations: data.relations,
foundQueries
});
}
mblinks.saveCache();
batch.forEach(queryData => {
queryData.complete_func?.({
found: foundQueries.has(queryData),
status: 'success'
});
});
});
failureHandlers.push(() => {
batch.forEach(queryData => queryData.complete_func?.({
found: false,
status: 'error'
}));
});
mblinks.ajax_requests.push(query, function () {
// oxlint-disable-next-line typescript/no-this-alias -- Kept in line with the original callback context.
const ctx = this;
ctx.mblinks.getJSONWithRetry(ctx.query, function (data) {
ctx.handlers.forEach(handler => {
handler(data);
});
}, function (succeeded) {
if (!succeeded) ctx.failureHandlers.forEach(handler => handler());
});
}, {
failureHandlers,
handlers: handlers,
query: query,
mblinks: mblinks
});
}
}
/**
* Search MusicBrainz's indexed URL field with Lucene regular expressions,
* then resolve the discovered resources to load their relationships.
*/
searchAndDisplayMbLinksByRegex(urls_data) {
// oxlint-disable-next-line typescript/no-this-alias -- Kept in line with the callback contexts above.
const mblinks = this;
const uncachedQueries = [];
urls_data.forEach(data => {
const key = data.key || data.url;
if (this.is_cached(key)) {
const dataType = data.mb_type.replace('-', '_');
mblinks.cache[key].urls.forEach(cacheEntry => {
const mbUrl = typeof cacheEntry === 'string' ? cacheEntry : cacheEntry.url;
const ended = typeof cacheEntry === 'string' ? false : cacheEntry.ended;
data.insert_func(mblinks.createMusicBrainzLink(mbUrl, dataType, dataType === 'release' ? {
ended
} : {}));
});
data.complete_func?.({
found: true,
status: 'success'
});
} else if (data.url_regex) {
uncachedQueries.push(data);
}
});
const batchSize = 20;
for (let i = 0; i < uncachedQueries.length; i += batchSize) {
const batch = uncachedQueries.slice(i, i + batchSize);
const regex = batch.map(data => `(${data.url_regex})`).join('|');
const lookup = {
batch,
discoveredResources: new Set(),
outcomes: new Map(batch.map(query => [query, {
failed: false,
found: false
}])),
pending: 0
};
this.enqueueRegexSearchPage(batch, regex, 0, lookup);
}
}
enqueueRegexSearchPage(batch, regex, offset, lookup) {
lookup.pending += 1;
// oxlint-disable-next-line typescript/no-this-alias -- Kept in line with the callback contexts above.
const mblinks = this;
const search = `url:/(${regex})/`;
const query = `${mblinks.mb_server}/ws/2/url?query=${encodeURIComponent(search)}&fmt=json&limit=100&offset=${offset}`;
let handlers = [];
let failureHandlers = [];
const request = mblinks.ajax_requests[query];
if (typeof request === 'object') {
handlers = request.context.handlers;
failureHandlers = request.context.failureHandlers;
}
handlers.push(function (data) {
const urls = data.urls ?? [];
const discoveredQueries = [];
const discoveredResources = new Set();
urls.forEach(urlData => {
if (discoveredResources.has(urlData.resource)) return;
discoveredResources.add(urlData.resource);
batch.forEach(queryData => {
if (!queryMatchesResource(queryData, urlData.resource)) return;
const resourceKey = `${queryData.key ?? queryData.url}\0${urlData.resource}`;
if (lookup.discoveredResources.has(resourceKey)) return;
lookup.discoveredResources.add(resourceKey);
lookup.pending += 1;
discoveredQueries.push({
url: urlData.resource,
mb_type: queryData.mb_type,
insert_func: queryData.insert_func,
key: queryData.key || queryData.url,
complete_func: result => {
const outcome = lookup.outcomes.get(queryData);
outcome.found ||= result.found;
outcome.failed ||= result.status === 'error';
mblinks.finishRegexOperation(lookup);
}
});
});
});
mblinks.searchAndDisplayMbLinks(discoveredQueries);
const responseOffset = data.offset ?? offset;
const nextOffset = responseOffset + urls.length;
if (typeof data.count === 'number' && urls.length > 0 && nextOffset < data.count) {
mblinks.enqueueRegexSearchPage(batch, regex, nextOffset, lookup);
}
mblinks.finishRegexOperation(lookup);
});
failureHandlers.push(() => {
lookup.outcomes.forEach(outcome => {
outcome.failed = true;
});
mblinks.finishRegexOperation(lookup);
});
mblinks.ajax_requests.push(query, function () {
// oxlint-disable-next-line typescript/no-this-alias -- Kept in line with the original callback context.
const ctx = this;
ctx.mblinks.getJSONWithRetry(ctx.query, function (data) {
ctx.handlers.forEach(handler => {
handler(data);
});
}, function (succeeded) {
if (!succeeded) ctx.failureHandlers.forEach(handler => handler());
});
}, {
failureHandlers,
handlers,
query,
mblinks
});
}
finishRegexOperation(lookup) {
lookup.pending -= 1;
if (lookup.pending !== 0) return;
lookup.batch.forEach(query => {
const outcome = lookup.outcomes.get(query);
query.complete_func?.({
found: outcome.found,
status: outcome.failed ? 'error' : 'success'
});
});
}
}
const LOOKUP_ATTRIBUTE = 'data-mb-mirlo-lookup';
const RELEASE_PATH = /^\/([^/]+)\/release\/([^/]+)\/?$/;
const TRACK_PATH = /^\/([^/]+)\/release\/([^/]+)\/tracks\/(\d+)\/?$/;
const HOMEPAGE_SECTION_HEADINGS = new Set(['Recent releases', 'Recent purchases']);
const RELEASE_CARD_LINK_SELECTOR = ':is(h2, h3, h4) > a[href]';
let entityMatchHandler;
let scheduleLookups;
const labelRosterTypes = new Map();
const loadingLabelRosters = new Set();
function canonicalUrl(pathname) {
return `${window.location.origin}${pathname.replace(/\/$/, '')}`;
}
function pathnameFor(link) {
try {
const url = new URL(link.href, window.location.origin);
return url.origin === window.location.origin ? url.pathname : undefined;
} catch {
return undefined;
}
}
function createLookup(queries, type, url, name, target, placement = 'prepend') {
if (target.hasAttribute(LOOKUP_ATTRIBUTE)) return;
target.setAttribute(LOOKUP_ATTRIBUTE, type);
const indicator = MBImport.createEntityLookupIndicator(type, name);
indicator.classList.add('mb-mirlo-link');
indicator.addEventListener('click', event => event.stopPropagation());
if (placement === 'before') target.before(indicator);else target.prepend(indicator);
let foundMatch = false;
const matchedMbids = new Set();
let matchNotificationScheduled = false;
queries[type].push({
url,
mb_type: type,
key: `${type}:${url}`,
insert_func: link => {
if (!indicator.isConnected) return;
if (!foundMatch) {
indicator.replaceChildren();
MBImport.setEntityLookupState(indicator, 'matched');
foundMatch = true;
}
indicator.insertAdjacentHTML('beforeend', link.trim());
const mbid = link.match(new RegExp(`/${type}/([0-9a-f-]{36})`, 'i'))?.[1];
if (!mbid) return;
matchedMbids.add(mbid);
if (matchNotificationScheduled) return;
matchNotificationScheduled = true;
queueMicrotask(() => {
matchNotificationScheduled = false;
if (matchedMbids.size === 1) entityMatchHandler?.(type, url, mbid);
});
},
complete_func: result => {
if (!foundMatch) MBImport.setEntityLookupState(indicator, result.status === 'error' ? 'error' : 'search');
}
});
}
function addArtistLookup(queries, artistSlug, artistLink) {
const artistName = artistLink.textContent.trim() || artistLink.title;
if (!artistName) return;
createLookup(queries, 'artist', canonicalUrl(`/${artistSlug}`), artistName, artistLink, 'before');
}
function addLabelLookups(queries, artistSlug, title) {
const header = title?.parentElement?.parentElement;
if (!header) return;
header.querySelectorAll('a[href]').forEach(link => {
const pathname = pathnameFor(link)?.replace(/\/$/, '');
if (!pathname || pathname === `/${artistSlug}` || !/^\/[^/]+$/.test(pathname)) return;
const labelName = link.textContent.trim() || link.title;
if (labelName) createLookup(queries, 'label', canonicalUrl(pathname), labelName, link, 'before');
});
}
function addReleasePageLookups(queries, artistSlug, releaseSlug) {
const releaseUrl = canonicalUrl(`/${artistSlug}/release/${releaseSlug}`);
const title = document.querySelector('#main-content h1');
if (title?.textContent.trim()) createLookup(queries, 'release', releaseUrl, title.textContent.trim(), title);
addLabelLookups(queries, artistSlug, title);
document.querySelectorAll('#main-content a[href]').forEach(link => {
if (pathnameFor(link)?.replace(/\/$/, '') === `/${artistSlug}`) addArtistLookup(queries, artistSlug, link);
});
document.querySelectorAll('#main-content li[id]').forEach(row => {
if (!/^\d+$/.test(row.id) || !row.querySelector('button[aria-label="Track options"]')) return;
const details = row.children.item(1);
const trackTitle = details?.children.item(0);
const name = trackTitle?.textContent.trim();
if (!trackTitle || !name) return;
createLookup(queries, 'recording', `${releaseUrl}/tracks/${row.id}`, name, trackTitle);
});
}
function addTrackPageLookups(queries, artistSlug, releaseSlug, trackId) {
const releasePath = `/${artistSlug}/release/${releaseSlug}`;
const releaseUrl = canonicalUrl(releasePath);
const title = document.querySelector('#main-content h1');
if (title?.textContent.trim()) {
createLookup(queries, 'recording', `${releaseUrl}/tracks/${trackId}`, title.textContent.trim(), title);
}
addLabelLookups(queries, artistSlug, title);
document.querySelectorAll('#main-content a[href]').forEach(link => {
const pathname = pathnameFor(link)?.replace(/\/$/, '');
if (pathname === `/${artistSlug}`) addArtistLookup(queries, artistSlug, link);
if (pathname === releasePath) {
const releaseName = link.textContent.trim() || link.title;
if (releaseName) createLookup(queries, 'release', releaseUrl, releaseName, link, 'before');
}
});
}
function addArtistPageLookups(queries, artistSlug) {
const releasePathPrefix = `/${artistSlug}/release/`;
const releases = [...document.querySelectorAll('#main-content h2 > a[href]')].filter(link => pathnameFor(link)?.startsWith(releasePathPrefix));
if (releases.length === 0) return;
const artistTitle = document.querySelector('#main-content h1');
if (artistTitle?.textContent.trim()) {
createLookup(queries, 'artist', canonicalUrl(`/${artistSlug}`), artistTitle.textContent.trim(), artistTitle);
}
releases.forEach(link => {
const pathname = pathnameFor(link);
const name = link.textContent.trim() || link.title;
if (pathname && name) createLookup(queries, 'release', canonicalUrl(pathname), name, link.parentElement ?? link);
});
}
function homepageSectionFor(heading) {
let section = heading.parentElement;
while (section && section !== document.body) {
if (section.querySelector(`li ${RELEASE_CARD_LINK_SELECTOR}`)) return section;
section = section.parentElement;
}
return undefined;
}
function addReleaseCardLookups(queries, container) {
container.querySelectorAll('li').forEach(card => {
const releaseLink = card.querySelector(RELEASE_CARD_LINK_SELECTOR);
const releasePath = releaseLink ? pathnameFor(releaseLink) : undefined;
const releaseMatch = releasePath ? RELEASE_PATH.exec(releasePath) : null;
const releaseName = releaseLink?.textContent.trim() || releaseLink?.title;
if (!releaseLink || !releasePath || !releaseMatch?.[1] || !releaseName) return;
createLookup(queries, 'release', canonicalUrl(releasePath), releaseName, releaseLink.parentElement ?? releaseLink);
const artistPath = `/${releaseMatch[1]}`;
const artistLink = [...card.querySelectorAll('a[href]')].find(link => pathnameFor(link)?.replace(/\/$/, '') === artistPath);
if (artistLink) {
artistLink.parentElement?.classList.add('mb-mirlo-card-entity');
addArtistLookup(queries, releaseMatch[1], artistLink);
}
});
}
function addTrackCardLookups(queries, container) {
container.querySelectorAll('li').forEach(card => {
const trackLink = card.querySelector(RELEASE_CARD_LINK_SELECTOR);
const trackPath = trackLink ? pathnameFor(trackLink) : undefined;
const trackMatch = trackPath ? TRACK_PATH.exec(trackPath) : null;
const trackName = trackLink?.textContent.trim() || trackLink?.title;
if (!trackLink || !trackPath || !trackMatch?.[1] || !trackName) return;
createLookup(queries, 'recording', canonicalUrl(trackPath), trackName, trackLink.parentElement ?? trackLink);
const artistPath = `/${trackMatch[1]}`;
const artistLink = [...card.querySelectorAll('a[href]')].find(link => pathnameFor(link)?.replace(/\/$/, '') === artistPath);
if (artistLink) {
artistLink.parentElement?.classList.add('mb-mirlo-card-entity');
addArtistLookup(queries, trackMatch[1], artistLink);
}
});
}
function addHomepageLookups(queries) {
document.querySelectorAll('#main-content h3').forEach(heading => {
if (!HOMEPAGE_SECTION_HEADINGS.has(heading.textContent.trim())) return;
const section = homepageSectionFor(heading);
if (section) addReleaseCardLookups(queries, section);
});
}
function addReleasesPageLookups(queries) {
const mainContent = document.querySelector('#main-content');
if (mainContent) addReleaseCardLookups(queries, mainContent);
}
function resultContainerAfter(heading, resultSelector) {
let current = heading;
while (current && current.parentElement !== document.body) {
const resultContainer = current.nextElementSibling;
if (resultContainer?.querySelector(resultSelector)) return resultContainer;
current = current.parentElement;
}
return undefined;
}
function addProfileCardLookups(queries, typeForPath, container) {
const links = [...container.querySelectorAll('a[href]')];
links.forEach(link => {
const pathname = pathnameFor(link)?.replace(/\/$/, '');
const name = link.textContent.trim();
if (!pathname || !name || !/^\/[^/]+$/.test(pathname)) return;
const hasMatchingImageLink = links.some(candidate => candidate !== link && pathnameFor(candidate)?.replace(/\/$/, '') === pathname && candidate.querySelector('img'));
if (!hasMatchingImageLink) return;
const type = typeof typeForPath === 'function' ? typeForPath(pathname) : typeForPath;
if (!type) return;
link.parentElement?.classList.add('mb-mirlo-card-entity');
createLookup(queries, type, canonicalUrl(pathname), name, link, 'before');
});
}
function addSearchPageLookups(queries) {
document.querySelectorAll('#main-content h2').forEach(heading => {
const headingText = heading.textContent.trim();
if (/^Releases(?: for\b|$)/.test(headingText)) {
const container = resultContainerAfter(heading, `li ${RELEASE_CARD_LINK_SELECTOR}`);
if (container) addReleaseCardLookups(queries, container);
} else if (/^Tracks(?: for\b|$)/.test(headingText)) {
const container = resultContainerAfter(heading, `li ${RELEASE_CARD_LINK_SELECTOR}`);
if (container) addTrackCardLookups(queries, container);
} else if (/^Artists(?: for\b|$)/.test(headingText)) {
const container = resultContainerAfter(heading, 'a[href] img');
if (container) addProfileCardLookups(queries, 'artist', container);
} else if (/^Labels(?: for\b|$)/.test(headingText)) {
const container = resultContainerAfter(heading, 'a[href] img');
if (container) addProfileCardLookups(queries, 'label', container);
}
});
}
function addArtistsPageLookups(queries) {
const mainContent = document.querySelector('#main-content');
if (!mainContent) return;
const type = new URLSearchParams(window.location.search).get('isLabel') === 'true' ? 'label' : 'artist';
addProfileCardLookups(queries, type, mainContent);
}
function labelPageRoute() {
const match = /^\/([^/]+)(?:\/([^/]+))?\/?$/.exec(window.location.pathname);
if (!match?.[1]) return undefined;
const title = document.querySelector('#main-content h1');
const isLabel = [...(title?.parentElement?.querySelectorAll('span') ?? [])].some(span => span.textContent.trim() === 'Label');
if (!title || !isLabel) return undefined;
return {
labelSlug: match[1],
...(match[2] ? {
tab: match[2]
} : {})
};
}
function loadLabelRoster(labelSlug) {
if (labelRosterTypes.has(labelSlug) || loadingLabelRosters.has(labelSlug)) return;
loadingLabelRosters.add(labelSlug);
const endpoint = new URL(`/v1/labels/${encodeURIComponent(labelSlug)}`, window.location.origin);
void fetch(endpoint, {
headers: {
Accept: 'application/json'
}
}).then(response => {
if (!response.ok) throw new Error(`Mirlo API returned HTTP ${response.status}`);
return response.json();
}).then(data => {
const result = data && typeof data === 'object' ? data['result'] : undefined;
const roster = result && typeof result === 'object' ? result['artistLabels'] : undefined;
const types = new Map();
if (Array.isArray(roster)) {
roster.forEach(membership => {
if (!membership || typeof membership !== 'object') return;
const artist = membership['artist'];
if (!artist || typeof artist !== 'object') return;
const profile = artist;
if (typeof profile['urlSlug'] !== 'string') return;
types.set(`/${profile['urlSlug']}`, profile['isLabelProfile'] === true ? 'label' : 'artist');
});
}
labelRosterTypes.set(labelSlug, types);
scheduleLookups?.();
}).catch(() => {}).finally(() => loadingLabelRosters.delete(labelSlug));
}
function addLabelPageLookups(queries, {
labelSlug,
tab
}) {
const title = document.querySelector('#main-content h1');
const labelName = title?.textContent.trim();
if (title && labelName) createLookup(queries, 'label', canonicalUrl(`/${labelSlug}`), labelName, title);
const mainContent = document.querySelector('#main-content');
if (!mainContent) return;
if (tab === 'releases') {
addReleaseCardLookups(queries, mainContent);
} else if (tab === 'roster') {
const rosterTypes = labelRosterTypes.get(labelSlug);
if (rosterTypes) addProfileCardLookups(queries, pathname => rosterTypes.get(pathname), mainContent);else loadLabelRoster(labelSlug);
}
}
function addMirloLookups(mblinks) {
const queries = {
artist: [],
label: [],
recording: [],
release: []
};
const track = TRACK_PATH.exec(window.location.pathname);
const release = RELEASE_PATH.exec(window.location.pathname);
const labelPage = labelPageRoute();
if (window.location.pathname === '/') addHomepageLookups(queries);else if (window.location.pathname.replace(/\/$/, '') === '/releases') addReleasesPageLookups(queries);else if (window.location.pathname.replace(/\/$/, '') === '/search') addSearchPageLookups(queries);else if (window.location.pathname.replace(/\/$/, '') === '/artists') addArtistsPageLookups(queries);else if (track?.[1] && track[2] && track[3]) addTrackPageLookups(queries, track[1], track[2], track[3]);else if (release?.[1] && release[2]) addReleasePageLookups(queries, release[1], release[2]);else if (labelPage) addLabelPageLookups(queries, labelPage);else {
const artist = /^\/([^/]+)\/?$/.exec(window.location.pathname);
if (artist?.[1]) addArtistPageLookups(queries, artist[1]);
}
Object.values(queries).forEach(typeQueries => {
if (typeQueries.length > 0) mblinks.searchAndDisplayMbLinks(typeQueries);
});
}
function initMirloLinking(onEntityMatch) {
entityMatchHandler = onEntityMatch;
MBSearchItStyle();
const mblinks = new MBLinks('MIRLO_MBLINKS_CACHE', 1);
let scheduled = false;
scheduleLookups = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
addMirloLookups(mblinks);
});
};
scheduleLookups();
new MutationObserver(scheduleLookups).observe(document.body, {
childList: true,
subtree: true
});
return mblinks;
}
function parseDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value ?? '');
if (!match) return {};
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3])
};
}
function mapReleaseType(type) {
switch (type?.trim().toLocaleLowerCase()) {
case 'album':
case 'lp':
return 'album';
case 'ep':
case 'e.p.':
return 'EP';
case 'single':
return 'single';
default:
return '';
}
}
function trackArtistNames(track, fallbackArtist) {
const artists = [...(track.trackArtists ?? [])].sort((left, right) => (left.order ?? 0) - (right.order ?? 0)).filter(artist => artist.artistName?.trim()).map(artist => ({
name: artist.artistName.trim(),
isCoAuthor: artist.isCoAuthor
}));
const coAuthors = artists.filter(artist => artist.isCoAuthor);
return (coAuthors.length > 0 ? coAuthors : artists).map(artist => artist.name).concat(artists.length === 0 ? [fallbackArtist] : []);
}
function durationInMilliseconds(track) {
const seconds = track.audio?.duration;
return typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : undefined;
}
function commonLicenseUrl(tracks) {
if (tracks.length === 0) return undefined;
const links = tracks.map(track => track.license?.link?.trim()).filter(link => Boolean(link));
if (links.length !== tracks.length || new Set(links).size !== 1) return undefined;
return links[0];
}
function buildAnnotation(trackGroup) {
const genres = [...new Set((trackGroup.tags ?? []).map(tag => tag.trim()).filter(Boolean))].join(', ');
const sections = [['Credits', trackGroup.credits], ['About', trackGroup.about], ['Genres', genres]].flatMap(([heading, content]) => {
const trimmedContent = content?.trim().replaceAll('\r', '') ?? '';
return trimmedContent ? [`=== ${heading} from Mirlo ===`, trimmedContent] : [];
});
if (sections.length === 0) return undefined;
return sections.join('\n\n').replaceAll('[', '[').replaceAll(']', ']');
}
function parseMirloRelease(releaseUrl, trackGroup) {
const sourceTracks = [...trackGroup.tracks].sort((left, right) => left.order - right.order);
const tracks = sourceTracks.map(track => {
const mbTrack = {
artist_credit: makeArtistCredits(trackArtistNames(track, trackGroup.artist.name)),
title: track.title,
number: track.order
};
const duration = durationInMilliseconds(track);
if (duration !== undefined) mbTrack.duration = duration;
return mbTrack;
});
const durations = tracks.map(track => typeof track.duration === 'number' ? track.duration : Number.NaN);
const completeDuration = durations.every(Number.isFinite) ? durations.reduce((total, duration) => total + duration, 0) : Number.NaN;
const urls = trackGroup.isGettable ? [{
url: releaseUrl,
link_type: URL_TYPES.purchase_for_download
}] : [];
const licenseUrl = commonLicenseUrl(sourceTracks);
if (licenseUrl) urls.push({
url: licenseUrl,
link_type: URL_TYPES.license
});
const explicitType = mapReleaseType(trackGroup.type);
const annotation = buildAnnotation(trackGroup);
const release = {
artist_credit: makeArtistCredits([trackGroup.artist.name]),
title: trackGroup.title,
...parseDate(trackGroup.releaseDate ?? trackGroup.publishedAt),
...(annotation ? {
annotation
} : {}),
packaging: 'None',
country: 'XW',
status: 'official',
type: explicitType || guessReleaseType(trackGroup.title, tracks.length, completeDuration, tracks.map(track => track.title)),
urls,
discs: [{
format: 'Digital Media',
tracks
}]
};
return {
release,
isrcs: sourceTracks.map(track => track.isrc?.trim() || null)
};
}
const LOGGER = new Logger('mirlo_importer', LogLevel.INFO);
const CONTAINER_ID = 'musicbrainz-mirlo-import';
const STYLE_ID = 'musicbrainz-mirlo-import-style';
let currentRunId = 0;
let mirloLinks;
function releaseRoute() {
const match = /^\/([^/]+)\/release\/([^/]+)\/?$/.exec(window.location.pathname);
if (!match?.[1] || !match[2]) return undefined;
return {
artistSlug: decodeURIComponent(match[1]),
releaseSlug: decodeURIComponent(match[2])
};
}
function canonicalReleaseUrl() {
return `${window.location.origin}${window.location.pathname.replace(/\/$/, '')}`;
}
function canonicalArtistUrl() {
const artistSlug = /^\/([^/]+)/.exec(window.location.pathname)?.[1];
return artistSlug ? `${window.location.origin}/${artistSlug}` : undefined;
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${CONTAINER_ID} {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
max-width: 100%;
box-sizing: border-box;
margin-block-start: 10px;
color: inherit;
font: 12px Arial, sans-serif;
}
#${CONTAINER_ID} .mb-mirlo-title { font-weight: bold; }
#${CONTAINER_ID} .mb-mirlo-status,
#${CONTAINER_ID} .mb-mirlo-meta { color: var(--mi-secondary-text-color, #888); }
#${CONTAINER_ID} .mb-mirlo-status.mb-mirlo-error { color: #a33; }
#${CONTAINER_ID} .mb-mirlo-buttons { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
#${CONTAINER_ID} .mb-mirlo-buttons form { margin: 0; }
.mb-mirlo-link.mb_valign {
margin-inline-end: 4px;
vertical-align: middle;
}
.mb-mirlo-link a.mb_search_link { color: #888; }
.mb-mirlo-link.mb_searchit a.mb_search_link:hover { color: darkblue; }
.mb-mirlo-card-entity {
display: flex;
flex-direction: row;
align-items: center;
min-width: 0;
}
.mb-mirlo-card-entity > .mb-mirlo-link { flex: none; }
.mb-mirlo-card-entity > a { min-width: 0; }
#${CONTAINER_ID}.mb-mirlo-floating {
position: absolute;
top: 76px;
right: 16px;
z-index: 2147483646;
max-width: min(360px, calc(100vw - 32px));
padding: 10px 12px;
border: 1px solid rgba(120, 120, 120, 0.6);
border-radius: 8px;
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
color: #222;
}
`;
document.head.appendChild(style);
}
function findMountPoint(releaseTitle) {
const headings = document.querySelectorAll('#main-content h1');
const heading = [...headings].find(candidate => {
if (!releaseTitle) return true;
const copy = candidate.cloneNode(true);
copy.querySelectorAll('.mb-mirlo-link').forEach(link => link.remove());
return copy.textContent.trim() === releaseTitle;
});
return heading?.parentElement?.parentElement ?? undefined;
}
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
async function waitForMountPoint(releaseTitle, runId) {
for (let attempt = 0; attempt < 40 && runId === currentRunId; attempt++) {
const mountPoint = findMountPoint(releaseTitle);
if (mountPoint) return mountPoint;
await wait(100);
}
return undefined;
}
function createContainer(mountPoint) {
document.getElementById(CONTAINER_ID)?.remove();
const container = document.createElement('aside');
container.id = CONTAINER_ID;
container.setAttribute('aria-live', 'polite');
container.innerHTML = 'MusicBrainz
Loading Mirlo release data…
';
if (mountPoint) {
mountPoint.appendChild(container);
} else {
container.classList.add('mb-mirlo-floating');
document.body.appendChild(container);
}
return container;
}
function renderError(container, message) {
const status = container.querySelector('.mb-mirlo-status');
if (status) {
status.classList.add('mb-mirlo-error');
status.textContent = message;
}
}
function isTrackGroupResponse(value) {
if (!value || typeof value !== 'object') return false;
const result = value['result'];
if (!result || typeof result !== 'object') return false;
const release = result;
const artist = release['artist'];
return typeof release['title'] === 'string' && Array.isArray(release['tracks']) && typeof artist === 'object' && artist !== null && typeof artist['name'] === 'string';
}
async function fetchTrackGroup(artistSlug, releaseSlug) {
const endpoint = new URL(`/v1/trackGroups/${encodeURIComponent(releaseSlug)}/`, window.location.origin);
endpoint.searchParams.set('artistId', artistSlug);
const response = await fetch(endpoint, {
headers: {
Accept: 'application/json'
}
});
if (!response.ok) throw new Error(`Mirlo API returned HTTP ${response.status}`);
const data = await response.json();
if (!isTrackGroupResponse(data)) throw new Error('Mirlo API returned an unexpected response');
return data;
}
function magicISRCForm(isrcs, editNote) {
if (!isrcs.some(Boolean)) return undefined;
const form = document.createElement('form');
form.className = 'musicbrainz_import';
form.innerHTML = '';
form.addEventListener('submit', event => {
event.preventDefault();
const query = new URLSearchParams({
'edit-note': editNote
});
isrcs.forEach((isrc, index) => query.set(`isrc${index + 1}`, isrc ?? ''));
window.open(`https://magicisrc.kepstin.ca?${query.toString()}`, '_blank', 'noopener');
});
return form;
}
function populateArtistMbid(mbid) {
const form = document.querySelector(`#${CONTAINER_ID} form.musicbrainz_import_add`);
const releaseArtist = form?.querySelector('input[name="artist_credit.names.0.artist.name"]');
if (!form || !releaseArtist) return;
form.querySelectorAll('input[name$=".artist.name"]').forEach(artistNameInput => {
if (artistNameInput.value !== releaseArtist.value) return;
const mbidParameterName = artistNameInput.name.replace(/\.artist\.name$/, '.mbid');
const existingInput = [...form.elements].find(element => element instanceof HTMLInputElement && element.name === mbidParameterName);
const mbidInput = existingInput ?? document.createElement('input');
mbidInput.type = 'hidden';
mbidInput.name = mbidParameterName;
mbidInput.value = mbid;
if (!existingInput) form.appendChild(mbidInput);
});
}
function handleEntityMatch(type, url, mbid) {
if (type === 'artist' && url === canonicalArtistUrl()) populateArtistMbid(mbid);
}
async function processPage() {
const runId = ++currentRunId;
document.getElementById(CONTAINER_ID)?.remove();
const route = releaseRoute();
if (!route) return;
try {
const data = await fetchTrackGroup(route.artistSlug, route.releaseSlug);
if (runId !== currentRunId) return;
const mountPoint = await waitForMountPoint(data.result.title, runId);
if (runId !== currentRunId) return;
if (!mountPoint) LOGGER.error('Could not find the Mirlo release heading; using the floating fallback');
const container = createContainer(mountPoint);
const releaseUrl = canonicalReleaseUrl();
const {
release,
isrcs
} = parseMirloRelease(releaseUrl, data.result);
const editNote = MBImport.makeEditNote(releaseUrl, 'Mirlo');
const buttons = document.createElement('div');
buttons.className = 'mb-mirlo-buttons';
buttons.innerHTML = MBImport.buildFormHTML(MBImport.buildFormParameters(release, editNote)) + MBImport.buildSearchButton(release);
const isrcForm = magicISRCForm(isrcs, editNote);
if (isrcForm) buttons.appendChild(isrcForm);
container.replaceChildren();
const title = document.createElement('div');
title.className = 'mb-mirlo-title';
title.textContent = 'MusicBrainz';
const meta = document.createElement('div');
meta.className = 'mb-mirlo-meta';
meta.textContent = `${release.discs[0]?.tracks.length ?? 0} tracks · Digital Media`;
container.append(title, meta, buttons);
const artistUrl = canonicalArtistUrl();
const artistMbid = artistUrl ? mirloLinks?.resolveMBID(`artist:${artistUrl}`) : undefined;
if (artistMbid) populateArtistMbid(artistMbid);
} catch (error) {
if (runId !== currentRunId) return;
LOGGER.error('Failed to import Mirlo release:', error);
const mountPoint = findMountPoint();
renderError(createContainer(mountPoint), error instanceof Error ? error.message : 'Could not load this Mirlo release.');
}
}
function init() {
MBImportStyle();
ensureStyles();
mirloLinks = initMirloLinking(handleEntityMatch);
void processPage();
subscribeToSPANavigation({
onNavigate: processPage
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();