MusicBrainz Batch Add to Collection

Batch add entities to MusicBrainz collection and copy MBIDs from entity pages, search result or existing collections.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name              MusicBrainz Batch Add to Collection
// @namespace         https://github.com/y-young/userscripts
// @version           2025.5.11
// @description       Batch add entities to MusicBrainz collection and copy MBIDs from entity pages, search result or existing collections.
// @author            y-young
// @license           MIT; https://opensource.org/licenses/MIT
// @supportURL        https://github.com/y-young/userscripts/labels/mb-batch-add-to-collection
// @include           /^https?:\/\/(.*\.)?musicbrainz.org\/(artist|collection|label|release|release-group|series|work)\/[\w-]{32,}(\/disc\/.*)?\/?(\?page=\d+|\?order=\w+)?$/
// @include           /^https?:\/\/(.*\.)?musicbrainz.org\/area\/[\w-]+\/(artists|events|labels|releases|recordings|places|works)\/?(\?page=\d+)?$/
// @include           /^https?:\/\/(.*\.)?musicbrainz.org\/artist\/[\w-]+\/(events|releases|recordings|works)\/?(\?page=\d+|\?filter.+)?$/
// @include           /^https?:\/\/(.*\.)?musicbrainz.org\/place\/[\w-]+\/events\/?(\?page=\d+)?$/
// @include           /^https?:\/\/(.*\.)?musicbrainz.org\/search\?.*type=(artist|event|label|instrument|place|recording|release_group|release|series|work)/
// @require           https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js
// @require           https://ajax.googleapis.com/ajax/libs/jqueryui/1.10.3/jquery-ui.min.js
// @grant             GM_setClipboard
// @grant             GM_getValue
// @grant             GM_setValue
// @grant             GM_deleteValue
// @run-at            document-idle
// @name:zh-CN        MusicBrainz 批量添加收藏
// @description:zh-CN 从条目页面、搜索结果或现有收藏页面批量复制MBID或添加项目到MusicBrainz收藏。
// ==/UserScript==

"use strict";

// To enable "Copy MBIDs" button, set this option to true
const SHOW_COPY_BUTTON = false;
// Whether to close dialog when successfully submitted
const CLOSE_DIALOG_AFTER_SUBMIT = true;

const IDENTIFIER = "batch-add-to-collection";
const CLIENT =
    "BatchAddToCollection/2025.5.11(https://github.com/y-young/userscripts)";
const ENTITY_TYPE_MAPPING = {
    artist: "release-group",
    label: "release",
    place: "event",
    "release-group": "release",
    release: "recording",
    work: "recording",
};
// prettier-ignore
const SUPPORTED_TYPES = [
    "artist", "event", "label", "instrument", "place",
    "recording", "release", "release-group",
    "series", "work"
];
const DIALOG_LOADING_NOTICE = `
    <div class="banner loading-message" style="background-position: right;">
        Loading your collections...
    </div>
`;

let $ = jQuery.noConflict(true);

const url = new URL(location.href);
const origin = url.origin;
const path = url.pathname.split("/");

// Determine entity type of current page and target collection type
const entityType = path[1];
let collectionType = ENTITY_TYPE_MAPPING[entityType];
switch (entityType) {
    case "area":
    case "artist": {
        const subType = path[3];
        if (subType) {
            // Convert plural form to singular form
            collectionType = subType.substring(0, subType.length - 1);
        } else if (entityType === "artist") {
            // Artist index pages without release groups fall back to list recordings
            if (!document.querySelector("table.release-group-list")) {
                collectionType = "recording";
            }
        }
        break;
    }
    case "search":
        collectionType = url.searchParams.get("type");
        if (collectionType === "release_group") {
            collectionType = "release-group";
        }
        break;
    case "collection": {
        const type = document.querySelector("dd[class='type']").innerText;
        collectionType = type.toLowerCase().replaceAll(" ", "-");
        if (collectionType === "owned-music" || collectionType === "wishlist") {
            collectionType = "release";
        } else if (
            collectionType === "attending" ||
            collectionType === "maybe-attending"
        ) {
            collectionType = "event";
        } else {
            collectionType = collectionType.replaceAll("-collection", "");
        }
        break;
    }
    case "series": {
        const type = document.querySelector("dd[class='type']").innerText;
        collectionType = type
            .toLowerCase()
            .replaceAll(" ", "-")
            .replaceAll("-series", "");
        break;
    }
}
let collections = undefined;

// Initialize "Batch add to collection" dialog
const dialogElement = document.createElement("div");
dialogElement.id = IDENTIFIER + "-dialog";
dialogElement.title = "Batch add to collection";
dialogElement.innerHTML = DIALOG_LOADING_NOTICE;
dialogElement.style.overflow = "auto";
document.querySelector("body").appendChild(dialogElement);
const dialog = $("#" + IDENTIFIER + "-dialog").dialog({
    autoOpen: false,
    height: 400,
    width: 350,
    open: function () {
        loadCollections();
    },
    buttons: {
        Refresh: function () {
            collections = undefined;
            GM_deleteValue("collections");
            dialogElement.innerHTML = DIALOG_LOADING_NOTICE;
            loadCollections();
        },
        Cancel: function () {
            $(this).dialog("close");
        },
    },
});

function request(url, options = {}) {
    return fetch(origin + url, {
        ...options,
        headers: {
            "user-agent": CLIENT,
            accept: "application/json",
        },
    });
}

function getGidFromUrl(url) {
    const path = new URL(url).pathname.split("/");
    return path[2];
}

function getCollectionTypePlural() {
    if (collectionType === "series") {
        return "series";
    }
    return collectionType + "s";
}

function createCheckbox(recordingId) {
    const checkbox = document.createElement("input");
    checkbox.setAttribute("type", "checkbox");
    checkbox.classList.add(IDENTIFIER);
    checkbox.dataset.id = recordingId;
    const cell = document.createElement("td");
    cell.prepend(checkbox);
    return cell;
}

function getSelectedIds() {
    const entityIds = Array.from(
        document.querySelectorAll(
            "input:checked[type='checkbox']." + IDENTIFIER
        )
    ).map((checkbox) => checkbox.dataset.id);
    return entityIds;
}

function chunked(array, size) {
    const result = [];
    for (let i = 0; i < array.length; i += size) {
        result.push(array.slice(i, i + size));
    }
    return result;
}

function addToCollection(collectionId, ids) {
    const CHUNK_SIZE = 400;
    const tasks = chunked(ids, CHUNK_SIZE).map((chunk) =>
        request(
            `/ws/2/collection/${collectionId}/${getCollectionTypePlural()}/${chunk.join(
                ";"
            )}?client=${encodeURIComponent(CLIENT)}`,
            { method: "PUT" }
        )
    );
    return Promise.all(tasks).then((responses) => {
        const error = responses.find((response) => response.status !== 200);
        if (error) {
            throw error;
        }
        alert(`Successfully added ${ids.length} item(s) to collection.`);
    });
}

function addSelectedToCollection(event) {
    let target = event.target;
    // Compatibility with katakana-terminator
    if (target.nodeName === "RUBY") {
        target = target.parentElement;
    } else if (target.nodeName === "RT") {
        target = target.parentElement.parentElement;
    }

    const collectionId = target.dataset.id;
    const ids = getSelectedIds();
    if (ids.length === 0) {
        alert("No item is selected.");
        dialog.dialog("close");
        return;
    }
    const loadingNotice = document.querySelector(
        "#" + IDENTIFIER + "-dialog div.loading-message"
    );
    loadingNotice.style.display = "block";
    addToCollection(collectionId, ids)
        .then(() => {
            loadingNotice.style.display = "none";
            if (CLOSE_DIALOG_AFTER_SUBMIT) {
                dialog.dialog("close");
            }
        })
        .catch((error) => {
            console.error(error);
            alert("An error occurred, please see console output.");
        });
}

function renderCollections() {
    document.querySelector("#" + IDENTIFIER + "-dialog").innerHTML = `
        <div>You have the following ${collectionType} collection(s), click to add selected items:</div>
        <div class="banner loading-message" style="background-position: right; display: none;">
            Adding to collection...
        </div>
        <table class="tbl">
            <thead>
                <th>Name</th>
                <th>Action</th>
            </thead>
            <tbody>
                ${collections
                    .map(
                        (collection, index) => `
                <tr class="${index % 2 ? "odd" : "even"}">
                    <td>
                        <a href="/collection/${
                            collection.id
                        }" target="_blank" rel="noreferrer">
                            ${collection.name}
                        </a>
                    </td>
                    <td>
                        <a name="add" data-id="${
                            collection.id
                        }" href="javascript:void(0)">
                            Add
                        </a>
                    </td>
                </tr>`
                    )
                    .join("")}
                <tr class="${collections.length % 2 ? "odd" : "even"}">
                    <td>
                        <a href="/collection/create" target="_blank" rel="noreferrer">
                            Create a new collection
                        </a>
                    </td>
                    <td />
                </tr>
            </tbody>
        </table>
        <p style="color: gray">
            The collections are cached in local storage.
            Click "Refresh" to get latest data from server.
        </p>`;
    document
        .querySelectorAll("#" + IDENTIFIER + "-dialog a[name='add']")
        .forEach((element) =>
            element.addEventListener("click", addSelectedToCollection)
        );
}

// Filter and sort collections according to current entity type
function filterCollections(data) {
    return data
        .filter(
            (collection) =>
                collection["entity-type"] ===
                (collectionType === "release-group"
                    ? "release_group"
                    : collectionType)
        )
        .sort((a, b) => {
            if (a.name < b.name) {
                return -1;
            }
            if (a.name > b.name) {
                return 1;
            }
            return 0;
        });
}

function loadCollections() {
    if (collections) {
        // Collections already rendered
        return collections;
    }
    // Try to get cached collections
    const cachedCollections = GM_getValue("collections");
    if (cachedCollections) {
        collections = filterCollections(cachedCollections);
        renderCollections();
        return collections;
    }
    // Fetch collections from server
    return request("/ws/2/collection")
        .then((response) => response.json())
        .then((data) => data.collections)
        .then((collections) => {
            GM_setValue("collections", collections);
            return filterCollections(collections);
        })
        .then((result) => {
            collections = result;
            renderCollections();
            return collections;
        });
}

function toggleSelection(event) {
    const target = event.target;
    let context = target;
    while (context.nodeName !== "TABLE") {
        context = context.parentNode;
    }
    const checked = target.checked;
    context
        .querySelectorAll("input[type='checkbox']." + IDENTIFIER)
        .forEach((checkbox) => {
            checkbox.checked = checked;
        });
}

function createToggleSelectionCheckbox() {
    const headCell = document.createElement("th");
    const checkbox = document.createElement("input");
    checkbox.setAttribute("type", "checkbox");
    checkbox.addEventListener("click", toggleSelection);
    headCell.appendChild(checkbox);
    headCell.className = "checkbox-cell";
    return headCell;
}

function initHeaderCheckbox(header) {
    if (header.querySelector("th.checkbox-cell")) {
        // Already initialized
        return;
    }
    header.prepend(createToggleSelectionCheckbox());
}

function initTableCheckboxes(table) {
    // Get rows
    const rows = Array.from(table.querySelectorAll("tr.odd, tr.even"));
    rows.forEach((row) => {
        const entityLink = row.querySelector(
            `td a[href^='/${collectionType}']`
        );
        if (!entityLink) {
            if (entityType === "search") {
                // Some rows in search result are grouped together
                row.prepend(document.createElement("td"));
            }
            return;
        }
        const gid = getGidFromUrl(entityLink.href);
        // Use existing checkboxes if possible
        const checkbox = row.querySelector("td input[type='checkbox']");
        if (checkbox) {
            checkbox.classList.add(IDENTIFIER);
            checkbox.dataset.id = gid;
        } else {
            row.prepend(createCheckbox(gid));
        }
    });
    // Update table headers
    switch (entityType) {
        case "release":
            table.querySelectorAll("thead th[colspan]").forEach((header) => {
                header.setAttribute(
                    "colspan",
                    Number(header.getAttribute("colspan")) + 1
                );
            });
            table.querySelectorAll("tr.subh").forEach(initHeaderCheckbox);
            break;
        case "work":
            table.querySelectorAll("tr.subh th[colspan]").forEach((header) => {
                header.setAttribute("colspan", "5");
            });
            initHeaderCheckbox(table.querySelector("thead tr"));
            break;
        case "search":
        case "series":
            initHeaderCheckbox(table.querySelector("thead tr"));
            break;
        case "collection":
            if (!table.querySelector("thead th input[type='checkbox']")) {
                initHeaderCheckbox(table.querySelector("thead tr"));
            }
            break;
    }
}

function initCheckboxes() {
    document.querySelectorAll("table.tbl").forEach((table) => {
        initTableCheckboxes(table);

        // Handle dynamically loaded rows,
        // e.g. more than 100 tracks in a medium, or more than 7 media
        const observerOptions = {
            childList: true,
            subtree: true,
        };
        const observer = new MutationObserver((records) => {
            if (
                records.findIndex(
                    (record) => record.target.tagName === "TBODY"
                ) != -1
            ) {
                initTableCheckboxes(table);
            }
        });
        observer.observe(table, observerOptions);
    });
}

function openDialog() {
    dialog.dialog("open");
}

function copyMBIDs() {
    const entityIds = getSelectedIds();
    GM_setClipboard(entityIds.join("\n"));
    // temporarily replace the button text with a status message
    const previousText = this.innerText;
    this.innerText = `Copied ${entityIds.length} MBIDs`;
    setTimeout(() => (this.innerText = previousText), 1000);
}

function addClipboardToCollection() {
    const input = prompt("Paste MBIDs of entities to add to this collection:");
    if (!input) {
        return;
    }
    const entityIds = Array.from(input.matchAll(/\b[a-fA-F0-9\-]{36}\b/gm)).map(
        (match) => match[0]
    );
    if (!entityIds.length) {
        alert("No MBIDs found in input.");
        return;
    }
    const collectionId = path[2];

    const previousText = this.innerText;
    this.innerText = `Adding ${entityIds.length} entities...`;
    this.disabled = true;

    addToCollection(collectionId, entityIds)
        .then(() => {
            location.reload();
        })
        .catch((error) => {
            console.error(error);
            alert("An error occurred, please see console output.");
        })
        .finally(() => {
            this.innerText = previousText;
            this.disabled = false;
        });
}

function initButtons() {
    const buttons = [];

    const button = document.createElement("button");
    button.setAttribute("type", "button");
    button.innerText = "Batch add to collection";
    button.addEventListener("click", openDialog);
    buttons.push(button);

    if (SHOW_COPY_BUTTON) {
        const copyButton = document.createElement("button");
        copyButton.setAttribute("type", "button");
        copyButton.innerText = "Copy MBIDs";
        copyButton.title = "Copies MBIDs to clipboard.";
        copyButton.addEventListener("click", copyMBIDs);
        buttons.push(copyButton);
    }

    if (entityType === "collection") {
        const fromClipboardButton = document.createElement("button");
        fromClipboardButton.setAttribute("type", "button");
        fromClipboardButton.innerText = "Add from clipboard";
        fromClipboardButton.title =
            "Adds entities from clipboard to collection.";
        fromClipboardButton.addEventListener("click", addClipboardToCollection);
        buttons.push(fromClipboardButton);
    }

    let container = document.querySelector("form div.row span.buttons");
    if (container) {
        buttons.forEach((button) => container.appendChild(button));
    } else {
        container = document.createElement("form");
        container.innerHTML = `<div class="row"><span class="buttons"></span></div>`;
        const tables = document.querySelectorAll("table.tbl");
        // Insert after the last table if there're multiple ones
        let precedent = tables[tables.length - 1];
        if (!precedent) {
            // Empty collection
            precedent = document.querySelector("#content > p");
        }
        buttons.forEach((button) =>
            container.querySelector("span.buttons").appendChild(button)
        );
        precedent.parentNode.insertBefore(container, precedent.nextSibling);
    }
}

function isLoggedOut() {
    const loginLink = document.querySelector("ul.menu > li > a");
    return loginLink && loginLink.href.split("/")[3].startsWith("login");
}

console.log("[Batch add to collection]", entityType, collectionType);
if (SUPPORTED_TYPES.includes(collectionType) && !isLoggedOut()) {
    setTimeout(function () {
        initCheckboxes();
        initButtons();
    }, 500);
}