MusicBrainz Batch Add to Collection

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

目前為 2023-06-28 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name              MusicBrainz Batch Add to Collection
// @namespace         https://github.com/y-young/userscripts
// @version           2023.6.28
// @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);
        }
        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) {
            // 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);
}