// ==UserScript== // @name Import Mastermix releases to MusicBrainz // @description Import Mastermix releases and show links to matching MusicBrainz releases // @version 2026.09.01.4 // @author Raman Sinclair // @namespace https://github.com/murdos/musicbrainz-userscripts/ // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/mastermix_importer.user.js // @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/mastermix_importer.user.js // @match https://mastermixdj.com/* // @match https://www.mastermixdj.com/* // @icon https://metabrainz.org/static/img/projects/musicbrainz.svg // ==/UserScript== (function () { 'use strict'; let LogLevel = /*#__PURE__*/function (LogLevel) { LogLevel["DEBUG"] = "debug"; LogLevel["INFO"] = "info"; LogLevel["ERROR"] = "error"; return LogLevel; }({}); class Logger { LOG_LEVEL = LogLevel.INFO; scriptName; constructor(scriptName, level = LogLevel.ERROR) { this.scriptName = scriptName; this.LOG_LEVEL = level; } debug(...args) { this._log(LogLevel.DEBUG, args); } info(...args) { this._log(LogLevel.INFO, args); } error(...args) { this._log(LogLevel.ERROR, args); } setLevel(level) { this.LOG_LEVEL = level; } _log(level, args) { if (level < this.LOG_LEVEL) { return; } let logMethod = console.log; switch (level) { case LogLevel.DEBUG: logMethod = console.debug; break; case LogLevel.INFO: logMethod = console.info; break; case LogLevel.ERROR: logMethod = console.error; break; } try { logMethod.apply(this, [`[${this.scriptName}]`, ...args]); } catch { // do nothing } } } // convert HH:MM:SS or MM:SS to milliseconds function hmsToMilliSeconds(str) { if (typeof str == 'undefined' || str === null || str === '') return NaN; if (typeof str == 'number') return str; const t = str.split(':'); let s = 0; let m = 1; while (t.length > 0) { s += m * parseInt(t.pop(), 10); m *= 60; } return s * 1000; } // convert ISO8601 duration (limited to hours/minutes/seconds) to milliseconds // format looks like PT1H45M5.789S (note: floats can be used) // https://en.wikipedia.org/wiki/ISO_8601#Durations function ISO8601toMilliSeconds(str) { const regex = /^PT(?:(\d*\.?\d*)H)?(?:(\d*\.?\d*)M)?(?:(\d*\.?\d*)S)?$/; const m = str.replace(',', '.').match(regex); if (!m) return NaN; return (3600 * parseFloat(m[1] || '0') + 60 * parseFloat(m[2] || '0') + parseFloat(m[3] || '0')) * 1000; } // compute HTML of import form function buildFormHTML(parameters) { // Build form let innerHTML = `
`; parameters.forEach(function (parameter) { const value = parameter.value.toString(); innerHTML += ``; }); innerHTML += ''; innerHTML += '
'; return innerHTML; } function luceneEscape(text) { let newText = text.replace(/[-[\]{}()*+?~:\\^!"/]/g, '\\$&'); newText = newText.replace('&&', '&&').replace('||', '||'); return newText; } function appendParameter(parameters, paramName, paramValue) { if (!paramValue) return; parameters.push({ name: paramName, value: paramValue }); } function searchParams(release) { const params = []; const totaltracks = release.discs.reduce((acc, { tracks }) => acc + tracks.length, 0); let release_artist = ''; for (let i = 0; i < release.artist_credit.length; i++) { const ac = release.artist_credit[i]; if (ac) { release_artist += ac.artist_name; if (typeof ac.joinphrase != 'undefined' && ac.joinphrase != '') { release_artist += ac.joinphrase; } else { if (i != release.artist_credit.length - 1) release_artist += ', '; } } } const query = `artist:(${luceneEscape(release_artist)})` + ` release:(${luceneEscape(release.title)})` + ` tracks:(${totaltracks})${release.country ? ` country:${release.country}` : ''}`; appendParameter(params, 'query', query); appendParameter(params, 'type', 'release'); appendParameter(params, 'advanced', '1'); return params; } // Try to guess release type using number of tracks, title and total duration (in millisecs) function guessReleaseType(title, num_tracks, duration_ms) { if (num_tracks < 1) return ''; let has_single = !!title.match(/\bsingle\b/i); let has_EP = !!title.match(/\bEP\b/i); if (has_single && has_EP) { has_single = false; has_EP = false; } const perhaps_single = has_single && num_tracks <= 4 || num_tracks <= 2; const perhaps_EP = has_EP || num_tracks > 2 && num_tracks <= 6; const perhaps_album = num_tracks > 8; if (isNaN(duration_ms)) { // no duration, try to guess with title and number of tracks if (perhaps_single && !perhaps_EP && !perhaps_album) return 'single'; if (!perhaps_single && perhaps_EP && !perhaps_album) return 'EP'; if (!perhaps_single && !perhaps_EP && perhaps_album) return 'album'; return ''; } const duration_mn = duration_ms / (60 * 1000); if (perhaps_single && duration_mn >= 1 && duration_mn < 7) return 'single'; if (perhaps_EP && duration_mn > 7 && duration_mn <= 30) return 'EP'; if (perhaps_album && duration_mn > 30) return 'album'; 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; 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++; 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); // eslint-disable-line @typescript-eslint/no-unsafe-argument buildArtistCreditsFormParameters(parameters, `mediums.${i}.track.${j}.`, track.artist_credit); } } } } // Guess release type if not given if (!release.type && release.title && total_tracks == total_tracks_with_duration) { release.type = guessReleaseType(release.title, total_tracks, total_duration); } if (release.type) appendParameter(parameters, 'type', release.type); // Add Edit note parameter if (edit_note) appendParameter(parameters, 'edit_note', edit_note); return parameters; } const styleBlockIconButton = ` `; const styleBlockFullButton = ` `; function buildHarmonyButton({ barcode, release_url, variant }) { const searchParams = new URLSearchParams(); if (barcode) { searchParams.set('gtin', barcode); } if (release_url) { searchParams.set('url', encodeURI(release_url)); } searchParams.set('category', 'preferred'); // take Harmony user preferences into account searchParams.set('musicbrainz', ''); // enforce lookup by barcode in MusicBrainz const harmonyURL = `https://harmony.pulsewidth.org.uk/release?${searchParams.toString()}`; return ` ${variant === 'full' ? styleBlockFullButton : styleBlockIconButton} Harmony icon ${variant === 'full' ? 'Import with Harmony' : ''} `; } // compute HTML of search button function buildSearchButton(release) { const parameters = searchParams(release); let html = `'; return html; } function buildSearchLink(release) { const parameters = searchParams(release); const url_params = []; parameters.forEach(function (parameter) { const value = `${parameter.value}`; url_params.push(encodeURI(`${parameter.name}=${value}`)); }); return `Search in MusicBrainz`; } function searchUrlFor(type, what) { type = type.replace('-', '_'); const params = [`query=${luceneEscape(what)}`, `type=${type}`, 'indexed=1']; return `https://musicbrainz.org/search?${params.join('&')}`; } function exactSearchUrlFor(type, what, limit = 25) { type = type.replace('-', '_'); const query = `"${luceneEscape(what)}"`; const params = [`query=${encodeURIComponent(query)}`, `type=${type}`, `limit=${limit}`, 'method=advanced']; return `https://musicbrainz.org/search?${params.join('&')}`; } const MB_SEARCH_MARKS = { artist: 'A', 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; } // 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, 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; } 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_wrapper { display: inline-block; } `; _add_css(css_search_it); } // Class MBLinks : query MusicBrainz for urls and display links for matching urls // The main method is searchAndDisplayMbLinks() // Example: // document.addEventListener('DOMContentLoaded', function () { // // const mblinks = new MBLinks('EXAMPLE_MBLINKS_CACHE', undefined, 7*24*60); // force refresh of cached links once a week // // const album_link = 'http://' + window.location.href.match( /^https?:\/\/(.*\/album\/.+)$/i)[1]; // mblinks.searchAndDisplayMbLinks([{ url: album_link, mb_type: 'release', insert_func: function (link) { // document.querySelector('div#there').insertAdjacentHTML('afterend', link); // } }]); // }); 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; // eslint-disable-next-line @typescript-eslint/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 }) { const matching_urls_data = batch.filter(query => { if (!query.url_regex) return query.url === resource; try { return new RegExp(`^(?:${query.url_regex})$`).test(resource); } catch { return false; } }); 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 => { 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 searchRelations(url) { if (url.relations) return url.relations; const relationLists = url['relation-list']; if (!relationLists) return undefined; return relationLists.flatMap(relationList => relationList.relations ?? []); } // 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(); nextRequestAt = 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 errors (e.g. 503), using 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; let attempt = 0; const doRequest = () => { attempt += 1; fetch(url, { headers: { Accept: 'application/json' } }).then(function (response) { if (!response.ok) { const error = new Error(`HTTP ${response.status}`); error.status = response.status; throw error; } return response.json(); }).then(function (data) { successCallback(data); if (typeof alwaysCallback === 'function') { alwaysCallback(); } }).catch(error => { const status = isErrorWithStatus(error) ? error.status : 0; const is5xx = status >= 500 && status < 600; if (is5xx) { const retryDelayMs = Math.min(30_000, 1000 * 2 ** (attempt - 1)); if (Date.now() + retryDelayMs <= retryDeadline) { setTimeout(() => { this.scheduleRequest(doRequest); }, retryDelayMs); } else if (typeof alwaysCallback === 'function') { alwaysCallback(); } } else if (typeof alwaysCallback === 'function') { alwaysCallback(); } }); }; this.scheduleRequest(doRequest); } scheduleRequest(request) { const now = Date.now(); const runAt = Math.max(now, this.nextRequestAt); this.nextRequestAt = runAt + 1000; const delay = runAt - now; if (delay === 0) request();else setTimeout(request, delay); } 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) { // eslint-disable-next-line @typescript-eslint/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)); }); } 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 = []; const request = mblinks.ajax_requests[query]; if (typeof request === 'object') { handlers = request.context.handlers; } handlers.push(function (data) { 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 }); }); } 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 }); } mblinks.saveCache(); }); mblinks.ajax_requests.push(query, function () { // eslint-disable-next-line @typescript-eslint/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); }); }); }, { handlers: handlers, query: query, mblinks: mblinks }); } } /** * Search MusicBrainz's indexed URL field with Lucene regular expressions. */ searchAndDisplayMbLinksByRegex(urls_data) { // eslint-disable-next-line @typescript-eslint/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 } : {})); }); } 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('|'); this.enqueueRegexSearchPage(batch, regex, 0); } } enqueueRegexSearchPage(batch, regex, offset) { // eslint-disable-next-line @typescript-eslint/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 = []; const request = mblinks.ajax_requests[query]; if (typeof request === 'object') handlers = request.context.handlers; handlers.push(function (data) { const urls = data.urls ?? []; urls.forEach(urlData => { processUrlMatch({ mblinks, batch, resource: urlData.resource, relations: searchRelations(urlData) }); }); mblinks.saveCache(); 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); } }); mblinks.ajax_requests.push(query, function () { // eslint-disable-next-line @typescript-eslint/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); }); }); }, { handlers, query, mblinks }); } } function isErrorWithStatus(error) { return typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number'; } function normalizeProductUrl(url) { const parsed = new URL(url, window.location.origin); parsed.search = ''; parsed.hash = ''; return parsed.href; } function createReleaseSearchLink(title) { const indicator = MBImport.createEntitySearchLink('release', title); indicator.classList.add('mastermix-mb-indicator'); indicator.addEventListener('click', event => { event.stopPropagation(); }); return indicator; } function addReleaseLookup(queries, { url, title, target }) { const indicator = createReleaseSearchLink(title); target.prepend(indicator); let foundMatch = false; queries.push({ url, mb_type: 'release', key: `release:${url}`, insert_func: link => { if (!foundMatch) { indicator.replaceChildren(); indicator.classList.remove('mb_searchit'); foundMatch = true; } indicator.insertAdjacentHTML('beforeend', link.trim()); } }); } function collectElements(root, selector) { const elements = Array.from(root.querySelectorAll(selector)); if (root instanceof Element && root.matches(selector)) elements.unshift(root); return elements; } function addSingleResultLookups(context, roots, queries) { roots.flatMap(root => collectElements(root, 'td:nth-child(3)')).forEach(albumCell => { if (!(albumCell instanceof HTMLTableCellElement) || !albumCell.closest('#singles tbody') || context.processedTargets.has(albumCell)) return; const albumLink = albumCell.querySelector(':scope > a[href*="/product/"]'); const title = albumLink?.textContent.trim(); if (!albumLink || !title) return; context.processedTargets.add(albumCell); albumCell.classList.add('mastermix-search-single-album'); addReleaseLookup(queries, { url: normalizeProductUrl(albumLink.href), title, target: albumCell }); }); } function addAlbumResultLookups(context, roots, queries) { roots.flatMap(root => collectElements(root, '.text-container > h3')).forEach(titleElement => { if (!(titleElement instanceof HTMLElement) || !titleElement.closest('#js-list-albums') || context.processedTargets.has(titleElement)) return; const productLink = titleElement.closest('a[href*="/product/"]'); const title = titleElement.textContent.trim(); if (!productLink || !title) return; context.processedTargets.add(titleElement); titleElement.classList.add('mastermix-search-album-title'); addReleaseLookup(queries, { url: normalizeProductUrl(productLink.href), title, target: titleElement }); }); } function addSearchResultLookups(context, roots) { const queries = []; addSingleResultLookups(context, roots, queries); addAlbumResultLookups(context, roots, queries); if (queries.length > 0) context.mblinks.searchAndDisplayMbLinks(queries); } function handleResultMutations(context, mutations) { const addedElements = mutations.flatMap(mutation => Array.from(mutation.addedNodes).filter(node => node instanceof Element)); if (addedElements.length > 0) addSearchResultLookups(context, addedElements); } function observeDynamicResults(context) { const resultContainers = document.querySelectorAll('#singles tbody, #js-list-singles, #js-list-albums'); if (resultContainers.length === 0) return; const observer = new MutationObserver(handleResultMutations.bind(undefined, context)); resultContainers.forEach(container => { observer.observe(container, { childList: true, subtree: true }); }); } function initSearchResultLookups(mblinks) { const context = { mblinks, processedTargets: new WeakSet() }; addSearchResultLookups(context, [document]); observeDynamicResults(context); } const LOGGER = new Logger('MusicBrainz mastermix_importer', LogLevel.INFO); const MASTERMIX_MBID = '8e0090e8-9081-4797-a386-990040f0accf'; // Music Factory label const MASTERMIX_LABEL = 'Music Factory'; const PRODUCT_URL_PATTERN = /^\/product\/[^/]+\/?$/; function getCurrentProductUrl() { const canonicalUrl = document.querySelector('link[rel="canonical"]')?.href; return normalizeProductUrl(canonicalUrl || window.location.href); } function addReleaseLookups(mblinks) { MBSearchItStyle(); const queries = []; document.querySelectorAll('article.article--album').forEach(article => { const productLink = article.querySelector(':scope > a[href*="/product/"]'); const titleElement = productLink?.querySelector('h2'); const title = titleElement?.textContent.trim(); if (!productLink || !titleElement || !title) return; const titleText = document.createElement('span'); titleText.className = 'mastermix-card-title-text'; titleText.textContent = title; titleElement.replaceChildren(titleText); titleElement.classList.add('mastermix-card-title'); addReleaseLookup(queries, { url: normalizeProductUrl(productLink.href), title, target: titleElement }); }); if (PRODUCT_URL_PATTERN.test(window.location.pathname)) { const title = document.querySelector('h1.product_title'); if (title?.textContent.trim()) { addReleaseLookup(queries, { url: getCurrentProductUrl(), title: title.textContent.trim(), target: title }); } } if (queries.length > 0) { mblinks.searchAndDisplayMbLinks(queries); } } function getProductId() { const product = document.querySelector('.product[id^="product-"]'); return product?.id.match(/^product-(\d+)$/)?.[1]; } async function getPublicationDate() { const productId = getProductId(); if (!productId) return undefined; try { const response = await fetch(`/wp-json/wp/v2/product/${productId}`, { headers: { Accept: 'application/json' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); const match = data.date?.match(/^(\d{4})-(\d{2})-(\d{2})T/); if (!match) return undefined; return { year: parseInt(match[1], 10), month: parseInt(match[2], 10), day: parseInt(match[3], 10) }; } catch (error) { LOGGER.error('Could not retrieve the product publication date', error); return undefined; } } function getReleaseArtistCredit(tracks) { const artists = tracks.map(track => track.artist_credit[0]?.artist_name).filter(artist => typeof artist === 'string'); const uniqueArtists = [...new Set(artists)]; return uniqueArtists.length === 1 && uniqueArtists[0] !== 'Various Artists' ? MBImport.makeArtistCredits(uniqueArtists) : [MBImport.specialArtist('various_artists')]; } function getTrackArtistCredit(artist) { return artist === 'Mastermix' ? [MBImport.specialArtist('various_artists')] : MBImport.makeArtistCredits([artist]); } function getAnnotation() { const description = document.querySelector('.woocommerce-product-details__short-description .wysiwyg'); if (!description) return undefined; const paragraphs = Array.from(description.querySelectorAll('p')).map(paragraph => paragraph.textContent.trim()).filter(Boolean); let annotation = paragraphs.length > 0 ? paragraphs.join('\n\n') : description.textContent.trim(); if (annotation) { annotation = `=== Description from Mastermix ===\n\n${annotation}`; } return annotation || undefined; } function parseTracks() { return Array.from(document.querySelectorAll('#mfeg-single-list tbody tr.single-item')).flatMap(row => { const title = row.querySelector('.single-item__title')?.textContent.trim(); const artist = row.querySelector('.single-item__artist')?.textContent.trim(); const duration = row.querySelector('.single-item__runtime')?.textContent.trim(); const number = row.querySelector('.track-number')?.textContent.trim(); if (!title || !artist) return []; const durationMs = duration ? MBImport.hmsToMilliSeconds(duration) : undefined; return [{ ...(number ? { number } : {}), title, ...(durationMs === undefined ? {} : { duration: durationMs }), artist_credit: getTrackArtistCredit(artist) }]; }); } async function parseRelease() { const titleElement = document.querySelector('h1.product_title'); const title = Array.from(titleElement?.childNodes ?? []).filter(node => !(node instanceof HTMLElement && node.classList.contains('mastermix-mb-indicator'))).map(node => node.textContent).join('').trim(); const tracks = parseTracks(); if (!title || tracks.length === 0) return undefined; // Publication date is not found in the DOM, so we need to call their API const publicationDate = await getPublicationDate(); const sku = document.querySelector('.product_meta .sku')?.textContent.trim(); const releaseUrl = getCurrentProductUrl(); const annotation = getAnnotation(); return { title, artist_credit: getReleaseArtistCredit(tracks), ...(annotation ? { annotation } : {}), type: 'album', secondary_types: ['compilation', 'dj-mix'], status: 'official', language: 'eng', script: 'Latn', packaging: 'None', country: 'XW', ...(publicationDate ?? {}), labels: [{ mbid: MASTERMIX_MBID, name: MASTERMIX_LABEL, ...(sku ? { catno: sku } : {}) }], barcode: '', urls: [{ url: releaseUrl, link_type: MBImport.URL_TYPES.purchase_for_download }], discs: [{ format: 'Digital Media', tracks }] }; } async function addImportButtons() { if (!PRODUCT_URL_PATTERN.test(window.location.pathname)) return; const release = await parseRelease(); if (!release) { LOGGER.error('Could not parse release data from the product page'); return; } const releaseUrl = getCurrentProductUrl(); const editNote = MBImport.makeEditNote(releaseUrl, 'Mastermix'); const parameters = MBImport.buildFormParameters(release, editNote); const buttons = document.createElement('div'); buttons.id = 'mb_buttons'; buttons.className = 'mastermix-import-buttons'; buttons.innerHTML = MBImport.buildFormHTML(parameters) + MBImport.buildSearchButton(release); const productMeta = document.querySelector('.product_meta'); if (!productMeta) { LOGGER.error('Could not find the product metadata container'); return; } productMeta.insertAdjacentElement('afterend', buttons); } function addStyles() { MBImportStyle(); document.head.insertAdjacentHTML('beforeend', ``); } function init() { addStyles(); const mblinks = new MBLinks('MASTERMIX_MBLINKS_CACHE', 1); addReleaseLookups(mblinks); initSearchResultLookups(mblinks); void addImportButtons(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();