Raw Source
yyoung / MusicBrainz Batch Add to Collection

// ==UserScript==
// @name              MusicBrainz Batch Add to Collection
// @namespace         https://github.com/y-young/userscripts
// @version           2024.4.20
// @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
// @downloadURL       https://github.com/y-young/userscripts/raw/master/musicbrainz-batch-add-to-collection.user.js
// @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)/
// @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/2024.4.20(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>
`;

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