MB: Inline per-recording streaming & download links

Displays per-recording streaming/download links on MusicBrainz with toggle buttons (collapsed by default).

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MB: Inline per-recording streaming & download links
// @namespace    https://chat.openai.com/
// @version      1.4
// @description  Displays per-recording streaming/download links on MusicBrainz with toggle buttons (collapsed by default).
// @match        *://*.musicbrainz.org/release/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    function getServiceIcon(domain) {
        domain = domain.toLowerCase().replace(/^www\./, '');
        if (domain.includes('bandcamp.com')) return '🎵';
        if (domain.includes('spotify.com')) return '🟢';
        if (domain.includes('apple.com')) return '🍎';
        if (domain.includes('deezer.com')) return '🟣';
        if (domain.includes('tidal.com')) return '🌊';
        if (domain.includes('youtube.com') || domain.includes('youtu.be')) return '▶️';
        if (domain.includes('soundcloud.com')) return '☁️';
        if (domain.includes('archive.org')) return '🗄️';
        if (domain.includes('qobuz.com')) return '🎼';
        if (domain.includes('beatport.com')) return '🧩';
        if (domain.includes('amazon.com')) return '🅰️';
        return '🌐';
    }

    function mergeRelations(relations) {
        const merged = {};
        for (const rel of relations) {
            const url = rel.url?.resource;
            if (!url) continue;
            if (!merged[url]) merged[url] = { href: url, types: new Set() };
            merged[url].types.add(rel.type);
        }
        return Object.values(merged).map(entry => ({
            href: entry.href,
            typeText: Array.from(entry.types).join(', ')
        }));
    }

    function groupByDomain(mergedLinks) {
        const grouped = {};
        for (const item of mergedLinks) {
            try {
                const domain = new URL(item.href).hostname.replace(/^www\./, '');
                const icon = getServiceIcon(domain);
                const isKnown = icon !== '🌐';
                if (!grouped[domain]) grouped[domain] = { links: [], icon, known: isKnown };
                grouped[domain].links.push(item);
            } catch (e) {
                console.warn('Invalid URL:', item.href);
            }
        }
        return grouped;
    }

    function injectLinks(row, relations) {
        const merged = mergeRelations(relations);
        const grouped = groupByDomain(merged);

        const td = row.querySelectorAll('td')[1];
        if (!td) return;

        const dl = td.querySelector('dl.ar') || Object.assign(document.createElement('dl'), { className: 'ar' });
        if (!td.contains(dl)) td.appendChild(dl);

        const dt = document.createElement('dt');
        const toggle = document.createElement('button');
        toggle.textContent = '►';
        toggle.className = 'recording-toggle';
        toggle.style.marginRight = '0.5em';
        toggle.style.border = 'none';
        toggle.style.background = 'none';
        toggle.style.cursor = 'pointer';
        toggle.style.fontSize = '0.9em';

        const dd = document.createElement('dd');
        dd.classList.add('recording-url-links');
        dd.style.display = 'none';

        toggle.addEventListener('click', () => {
            const isHidden = dd.style.display === 'none';
            dd.style.display = isHidden ? 'block' : 'none';
            toggle.textContent = isHidden ? '▼' : '►';
        });

        dt.appendChild(toggle);
        dt.appendChild(document.createTextNode('Streaming/Downloads:'));

        if (merged.length === 0) {
            dd.textContent = 'No streaming or download links found.';
        } else {
            for (const [domain, { links, icon }] of Object.entries(grouped).sort(([, a], [, b]) => Number(b.known) - Number(a.known))) {
                const label = document.createElement('strong');
                label.textContent = `${icon} ${domain}: `;
                dd.appendChild(label);
                for (const link of links) {
                    const a = document.createElement('a');
                    a.href = link.href;
                    a.target = '_blank';
                    a.rel = 'noopener';
                    a.textContent = `[${link.typeText}]`;
                    a.style.marginRight = '0.5em';
                    dd.appendChild(a);
                }
                dd.appendChild(document.createElement('br'));
            }
        }

        dl.appendChild(dt);
        dl.appendChild(dd);
    }

    async function fetchReleaseRecordingData() {
        const releaseMBID = window.location.pathname.match(/release\/([a-f0-9-]{36})/)?.[1];
        if (!releaseMBID) return null;
        const url = `https://musicbrainz.org/ws/2/release/${releaseMBID}?inc=recordings+recording-level-rels+url-rels&fmt=json`;
        const res = await fetch(url);
        return await res.json();
    }

    function getTrackRows() {
        return Array.from(document.querySelectorAll('.tracklist-and-credits table.tbl.medium tbody tr'))
            .filter(tr => !tr.classList.contains('subh'));
    }

    function onReactHydrated(element, callback) {
        const alreadyHydrated = Object.keys(element).some(key => key.startsWith('_reactListening') && element[key]);
        if (alreadyHydrated) callback();
        else element.addEventListener('mb-hydration', callback);
    }

    onReactHydrated(document.querySelector('.tracklist-and-credits'), async () => {
        const toolbox = document.querySelector('#medium-toolbox');
        if (!toolbox) return;

        const status = document.createElement('span');
        status.style.marginLeft = '1em';

        const toggleAllBtn = document.createElement('button');
        toggleAllBtn.classList.add('btn-link');
        toggleAllBtn.textContent = 'Expand all streaming/download links';

        let allExpanded = false;
        toggleAllBtn.addEventListener('click', () => {
            const dds = document.querySelectorAll('.tracklist-and-credits td dd.recording-url-links');
            const toggles = document.querySelectorAll('.tracklist-and-credits td button.recording-toggle');
            for (const dd of dds) {
                dd.style.display = allExpanded ? 'none' : 'block';
            }
            for (const toggle of toggles) {
                toggle.textContent = allExpanded ? '►' : '▼';
            }
            allExpanded = !allExpanded;
            toggleAllBtn.textContent = allExpanded ? 'Collapse all streaming/download links' : 'Expand all streaming/download links';
        });

        toolbox.firstChild?.before(toggleAllBtn, ' | ');
        toolbox.firstChild?.before(status);

        status.textContent = 'Fetching recording links…';
        const releaseData = await fetchReleaseRecordingData();
        const trackRows = getTrackRows();
        const tracks = releaseData?.media?.flatMap(m => m.tracks) || [];

        for (let i = 0; i < trackRows.length; i++) {
            const row = trackRows[i];
            const recording = tracks[i]?.recording;
            const relations = recording?.relations || [];
            injectLinks(row, relations);
        }

        status.textContent = '';
    });
})();