您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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(); })();