MusicBrainz 批量添加收藏

从条目页面、搜索结果或现有收藏页面批量复制MBID或添加项目到MusicBrainz收藏。

当前为 2024-03-11 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name              MusicBrainz Batch Add to Collection
// @namespace         https://github.com/y-young/userscripts
// @version           2024.3.8
// @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+)?$/
// @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)/
// @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/2023.6.28(https://github.com/y-young)";
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>
`;

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 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((header) => {
                header.prepend(createToggleSelectionCheckbox());
            });
            break;
        case "work":
            table.querySelectorAll("tr.subh th[colspan]").forEach((header) => {
                header.setAttribute("colspan", "5");
            });
            table
                .querySelector("thead tr")
                .prepend(createToggleSelectionCheckbox());
            break;
        case "search":
        case "series":
            table
                .querySelector("thead tr")
                .prepend(createToggleSelectionCheckbox());
            break;
        case "collection":
            if (!table.querySelector("thead th input[type='checkbox']")) {
                table
                    .querySelector("thead tr")
                    .prepend(createToggleSelectionCheckbox());
            }
            break;
    }
}

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

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);
}