MusicBrainz批量添加收藏

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

目前为 2021-08-25 提交的版本。查看 最新版本

// ==UserScript==
// @name              MusicBrainz Batch Add to Collection
// @namespace         https://github.com/y-young/userscripts
// @version           2021.8.25.1
// @description       Batch add entities to MusicBrainz collection and copy MBIDs from entity pages, search result or existing collections.
// @author            y-young
// @licence           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-]+(\/disc\/.*)?\/?(\?page=\d+)?$/
// @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\?query=.*&type=(artist|event|label|instrument|place|recording|release_group|release|series|work)/
// @exclude           /^https?:\/\/(.*\.)?musicbrainz.org\/collection/create/
// @grant             GM_setClipboard
// @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/2021.8.25.1(https://github.com/y-young)";
const ENTITY_TYPE_MAPPING = {
    artist: "release-group",
    label: "release",
    place: "event",
    "release-group": "release",
    release: "recording",
    work: "recording"
};
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.substr(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";
        }
        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;
            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 addToCollection(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;
    }
    if (ids.length > 400) {
        alert("Too many items, you can select at most 400 items.");
        dialog.dialog("close");
        return;
    }
    const loadingNotice = document.querySelector("#" + IDENTIFIER + "-dialog div.loading-message");
    loadingNotice.style.display = "block";
    request(
        `/ws/2/collection/${collectionId}/${getCollectionTypePlural()}/${ids.join(';')}?client=${encodeURIComponent(CLIENT)}`,
        {method: "PUT"}
    ).then(response => {
        loadingNotice.style.display = "none";
        if (response.status === 200) {
            alert(`Successfully added ${ids.length} item(s) to collection.`);
            if (CLOSE_DIALOG_AFTER_SUBMIT) {
                dialog.dialog("close");
            }
        } else {
            alert("An error occurred.");
            console.error(response);
        }
    });
}

function loadCollections() {
    if (collections) {
        return collections;
    }
    return request("/ws/2/collection")
        .then(response => response.json())
        .then(data =>
            data.collections.filter(
                collection =>
                    collection["entity-type"] === (collectionType === "release-group" ? "release_group" : collectionType)
            )
        )
        .then(result => {
            collections = result;
            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>
                    ${result.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>
                        <td>
                            <a href="/collection/create" target="_blank" rel="noreferrer">
                                Create a new collection
                            </a>
                        </td>
                    </tr>
                </tbody>
            </table>`;
            document.querySelectorAll("#" + IDENTIFIER + "-dialog a[name='add']")
                .forEach(element => element.addEventListener("click", addToCollection));
        });
}

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"));
    alert(`Copied ${entityIds.length} MBIDs to clipboard.`);
}

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

    const copyButton = document.createElement("button");
    copyButton.setAttribute("type", "button");
    copyButton.innerText = "Copy MBIDs";
    copyButton.addEventListener("click", copyMBIDs);

    let container = document.querySelector("form div.row span.buttons");
    if (container) {
        container.appendChild(button);
        if (SHOW_COPY_BUTTON) {
            container.appendChild(copyButton);
        }
    } 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
        const precedent = tables[tables.length - 1];
        if (!precedent) {
            // Empty collection
            return;
        }
        container.querySelector("span.buttons").appendChild(button);
        if (SHOW_COPY_BUTTON) {
            container.querySelector("span.buttons").appendChild(copyButton);
        }
        precedent.parentNode.insertBefore(container, precedent.nextSibling);
    }
}

console.log("[Batch add to collection]", entityType, collectionType);
if (SUPPORTED_TYPES.includes(collectionType)) {
    initCheckboxes();
    initButton();
}