NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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); }