Spotify: MusicBrainz importer

Adds buttons for MusicBrainz, Harmony, ISRC Hunt and SAMBL to Spotify.

// ==UserScript==
// @name         Spotify: MusicBrainz importer
// @namespace    https://musicbrainz.org/user/chaban
// @version      1.0.2
// @tag          ai-created
// @description  Adds buttons for MusicBrainz, Harmony, ISRC Hunt and SAMBL to Spotify.
// @author       chaban, garylaski, RustyNova
// @license      MIT
// @icon         https://open.spotify.com/favicon.ico
// @match        *://*.spotify.com/*
// @connect      musicbrainz.org
// @grant        GM.xmlHttpRequest
// @grant        GM.addStyle
// ==/UserScript==

(function () {
    'use strict';

    class main {
        static SCRIPT_NAME = GM.info.script.name;
        static SELECTORS = {
            ACTION_BAR: '[data-testid="action-bar-row"]',
            SORT_BUTTON: 'button[role="combobox"]',
            ARTIST_LINK: '[data-testid="creator-link"]',
            PAGE_TITLE: '.encore-text-headline-large',
        };
        static URLS = {
            MUSICBRAINZ_API_BASE: 'https://musicbrainz.org/ws/2/url',
            MUSICBRAINZ_BASE: 'https://musicbrainz.org',
            HARMONY_BASE: 'https://harmony.pulsewidth.org.uk/release',
            LISTENBRAINZ_BASE: 'https://listenbrainz.org',
            SAMBL_BASE: 'https://sambl.lioncat6.com',
            ISRCHUNT_BASE: 'https://isrchunt.com',
        };

        static BUTTON_CONFIG = {
            MUSICBRAINZ: {
                id: 'mb-import-lookup-button', text: 'MusicBrainz', className: 'import-button-open', color: '#BA478F',
                pages: ['album', 'artist'],
                getText: ({ mbInfo }) => mbInfo ? 'Open in MusicBrainz' : 'Search in MusicBrainz',
                getUrl: ({ mbInfo, pageInfo }) => {
                    if (mbInfo) {
                        return new URL(`${mbInfo.targetType}/${mbInfo.mbid}`, main.URLS.MUSICBRAINZ_BASE);
                    }
                    const { title, artist } = main.getReleaseInfo();
                    if (!title) return null;
                    return pageInfo.type === 'artist'
                        ? main.constructUrl(`${main.URLS.MUSICBRAINZ_BASE}/search`, { query: title, type: 'artist' })
                        : main.constructUrl(`${main.URLS.MUSICBRAINZ_BASE}/taglookup/index`, { 'tag-lookup.release': title, 'tag-lookup.artist': artist });
                },
            },
            HARMONY: {
                id: 'mb-import-harmony-button', text: 'Import with harmony', className: 'import-button-harmony', color: '#c45555',
                pages: ['album'],
                getUrl: ({ normalizedUrl }) => main.constructUrl(main.URLS.HARMONY_BASE, {
                    gtin: '', category: 'preferred', url: normalizedUrl,
                }),
            },
            LISTENBRAINZ: {
                id: 'mb-listenbrainz-button', text: 'Open in ListenBrainz', className: 'import-button-listenbrainz', color: '#5555c4',
                pages: ['artist'],
                requiresMbInfo: true,
                getUrl: ({ mbInfo }) => mbInfo?.mbid ? new URL(`artist/${mbInfo.mbid}/`, main.URLS.LISTENBRAINZ_BASE) : null,
            },
            SAMBL: {
                id: 'sambl-button', text: 'Open in SAMBL', className: 'import-button-sambl', color: '#1DB954',
                pages: ['artist'],
                getUrl: ({ mbInfo, pageInfo }) => {
                    if (!pageInfo.id) return null;
                    const isMbidFound = mbInfo?.targetType === 'artist';
                    return isMbidFound
                        ? main.constructUrl(`${main.URLS.SAMBL_BASE}/artist`, { provider_id: pageInfo.id, provider: 'spotify', artist_mbid: mbInfo.mbid })
                        : main.constructUrl(`${main.URLS.SAMBL_BASE}/newartist`, { provider_id: pageInfo.id, provider: 'spotify' });
                },
            },
            ISRCHUNT: {
                id: 'isrc-hunt-button', text: 'Open in ISRC Hunt', className: 'import-button-isrc-hunt', color: '#3B82F6',
                pages: ['playlist'],
                getUrl: ({ normalizedUrl }) => main.constructUrl(main.URLS.ISRCHUNT_BASE, {
                    spotifyPlaylist: normalizedUrl,
                }),
            },
        };

        #urlCache = new Map();
        #currentUrl = '';
        #observer = null;
        #debounceTimer = null;
        #buttonContainer = null;

        constructor() {
            this.#addStyles();
            this.#currentUrl = location.href;
            this.#initializeObserver();
            this.#run();
        }

        #initializeObserver() {
            this.#observer = new MutationObserver(() => {
                if (location.href !== this.#currentUrl) {
                    this.#currentUrl = location.href;
                    clearTimeout(this.#debounceTimer);
                    this.#debounceTimer = setTimeout(() => this.#run(), 250);
                }
            });
            this.#observer.observe(document.body, { childList: true, subtree: true });
        }

        async #run() {
            console.debug(`${main.SCRIPT_NAME}: Running on ${this.#currentUrl}`);
            this.#cleanup();

            const pageInfo = main.extractInfoFromUrl(location.href);
            const supportedPages = [...new Set(Object.values(main.BUTTON_CONFIG).flatMap(config => config.pages))];

            if (!supportedPages.includes(pageInfo.type)) {
                console.debug(`${main.SCRIPT_NAME}: Not a supported page (${pageInfo.type}). Aborting.`);
                return;
            }

            try {
                const actionBar = await main.waitForElement(main.SELECTORS.ACTION_BAR, 5000);
                this.#createButtonContainer(actionBar);
                const normalizedUrl = main.normalizeUrl(location.href);

                const needsMbInfo = Object.values(main.BUTTON_CONFIG).some(config =>
                    config.pages.includes(pageInfo.type) && (config.requiresMbInfo || config.id === 'mb-import-lookup-button')
                );

                const mbInfo = needsMbInfo ? await this.#fetchMusicBrainzInfo(location.href, pageInfo) : null;

                if (location.href !== this.#currentUrl) {
                    console.debug(`${main.SCRIPT_NAME}: URL changed during async operation. Aborting update.`);
                    return;
                }

                this.#setupButtons({ pageInfo, mbInfo, normalizedUrl });

            } catch (error) {
                console.error(`${main.SCRIPT_NAME}: Failed to initialize buttons.`, error);
            }
        }


        #setupButtons(context) {
            for (const config of Object.values(main.BUTTON_CONFIG)) {
                this.#setupButtonFromConfig(config, context);
            }
        }

        #setupButtonFromConfig(config, context) {
            const { pageInfo } = context;

            if (config.pages && !config.pages.includes(pageInfo.type)) return;
            if (config.requiresMbInfo && !context.mbInfo) return;

            const button = this.#createOrUpdateButton(config);
            if (!button) return;

            const url = config.getUrl(context);
            if (config.getText) {
                main.setButtonText(button, config.getText(context));
            }
            if (url) {
                button.onclick = () => main.openInNewTab(url);
            }

            main.setButtonLoading(button, false);

            if (!url) {
                button.disabled = true;
                if (config.id === 'mb-import-lookup-button') {
                    main.setButtonText(button, 'Info N/A');
                }
            }
        }

        #createButtonContainer(actionBar) {
            this.#buttonContainer = document.createElement('div');
            this.#buttonContainer.id = 'mb-script-button-container';
            const sortButton = actionBar.querySelector(main.SELECTORS.SORT_BUTTON);
            if (sortButton) {
                sortButton.parentElement.before(this.#buttonContainer);
            } else {
                actionBar.appendChild(this.#buttonContainer);
            }
        }

        #createOrUpdateButton(config) {
            if (!this.#buttonContainer) return null;
            let button = document.getElementById(config.id);
            if (!button) {
                button = document.createElement("button");
                button.id = config.id;
                this.#buttonContainer.appendChild(button);
            }
            button.className = `import-button ${config.className}`;
            button.textContent = '';
            const textSpan = document.createElement('span');
            textSpan.textContent = config.text;
            button.appendChild(textSpan);

            const needsLoading = config.id === 'mb-import-lookup-button' || config.requiresMbInfo;
            if (needsLoading) {
                main.setButtonLoading(button, true);
            }

            return button;
        }

        #cleanup() {
            document.getElementById('mb-script-button-container')?.remove();
            this.#buttonContainer = null;
        }

        #addStyles() {
            const staticStyles = `
                #mb-script-button-container { display: flex; align-items: center; margin-left: 8px; }
                .import-button {
                    border-radius: 4px; border: none; padding: 8px 12px; font-size: 0.9em; font-weight: 700; color: white;
                    cursor: pointer; margin: 0 4px; transition: all 0.2s ease; position: relative;
                }
                .import-button:hover:not(:disabled) { filter: brightness(1.1); transform: scale(1.05); }
                .import-button:disabled { opacity: 0.7; cursor: not-allowed; }
                .import-button.loading span { visibility: hidden; }
                .import-button.loading::after {
                    content: ''; position: absolute; top: 50%; left: 50%;
                    width: 16px; height: 16px; transform: translate(-50%, -50%);
                    border: 2px solid rgba(255, 255, 255, 0.5); border-top-color: white;
                    border-radius: 50%; animation: spin 0.8s linear infinite;
                }
                @keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
            `;
            const dynamicStyles = main.generateDynamicStyles();
            GM.addStyle(staticStyles + dynamicStyles);
        }


        async #fetchMusicBrainzInfo(url, pageInfo) {
            const normalizedUrl = main.normalizeUrl(url);
            if (this.#urlCache.has(normalizedUrl)) {
                return this.#urlCache.get(normalizedUrl);
            }
            const inc = pageInfo.type === 'album' ? 'release-rels' : 'artist-rels';
            try {
                const res = await main.gmXmlHttpRequest({
                    url: main.constructUrl(main.URLS.MUSICBRAINZ_API_BASE, { limit: 1, fmt: 'json', inc: inc, resource: normalizedUrl }),
                    method: 'GET',
                    responseType: 'json',
                });
                if (res.status !== 200 || !res.response?.relations?.length) {
                    this.#urlCache.set(normalizedUrl, null); return null;
                }
                const relation = res.response.relations[0];
                const result = { targetType: relation['target-type'], mbid: relation[relation['target-type']].id };
                this.#urlCache.set(normalizedUrl, result);
                return result;
            } catch (error) {
                console.error(`${main.SCRIPT_NAME}: MB API request failed for ${normalizedUrl}`, error);
                this.#urlCache.set(normalizedUrl, null);
                return null;
            }
        }


        static openInNewTab(url) {
            if (url) window.open(url.toString(), '_blank').focus();
        }

        static setButtonLoading(button, isLoading) {
            if (!button) return;
            button.classList.toggle('loading', isLoading);
            button.disabled = isLoading;
        }

        static setButtonText(button, text) {
            const span = button.querySelector('span');
            if (span) span.textContent = text;
        }

        static getReleaseInfo() {
            const titleEl = document.querySelector(this.SELECTORS.PAGE_TITLE);
            const artistEl = document.querySelector(this.SELECTORS.ARTIST_LINK);
            const title = titleEl?.textContent.trim() || '';
            const artist = this.extractInfoFromUrl(location.href).type !== 'artist' ? (artistEl?.textContent.trim() || '') : '';
            return { title, artist };
        }

        static constructUrl(base, params) {
            const url = new URL(base);
            for (const key in params) {
                if (params[key]) url.searchParams.set(key, params[key]);
            }
            return url;
        }

        static normalizeUrl(url) {
            const { type, id } = this.extractInfoFromUrl(url);
            return (type !== 'unknown' && id) ? `https://open.spotify.com/${type}/${id}` : url;
        }

        static extractInfoFromUrl(url) {
            const match = url.match(/(?:https?:\/\/)?(?:play|open)\.spotify.com\/(?:intl-[a-z]{2,}(?:-[A-Z]{2,})?\/)?(\w+)\/([a-zA-Z0-9]+)/);
            return { type: match?.[1] || 'unknown', id: match?.[2] || null };
        }

        static gmXmlHttpRequest(options) {
            return new Promise((resolve, reject) => GM.xmlHttpRequest({ ...options, onload: resolve, onerror: reject, onabort: reject }));
        }

        static generateDynamicStyles() {
            return Object.values(this.BUTTON_CONFIG).map(config =>
                `.${config.className} { background-color: ${config.color}; }`
            ).join('\n');
        }

        static waitForElement(selector, timeout = 10000) {
            return new Promise((resolve, reject) => {
                const element = document.querySelector(selector);
                if (element) return resolve(element);
                const observer = new MutationObserver(() => {
                    const el = document.querySelector(selector);
                    if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
                });
                const timer = setTimeout(() => { observer.disconnect(); reject(new Error(`Timeout waiting for: ${selector}`)); }, timeout);
                observer.observe(document.body, { childList: true, subtree: true });
            });
        }
    }

    new main();
})();