Raw Source
yyoung / MusicBrainz Artist Credits Helper

// ==UserScript==
// @name         MusicBrainz Artist Credits Helper
// @namespace    https://github.com/y-young/userscripts
// @version      2023.10.7
// @description  Split and fill artist credits, append character voice actor credit, and guess artists from track titles.
// @author       y-young
// @license      MIT; https://opensource.org/licenses/MIT
// @supportURL   https://github.com/y-young/userscripts/labels/mb-artist-credits-helper
// @downloadURL  https://github.com/y-young/userscripts/raw/master/musicbrainz-artist-credits-helper.user.js
// @match        https://*.musicbrainz.org/release/*/edit
// @match        https://*.musicbrainz.org/release/add*
// @icon         https://musicbrainz.org/static/images/favicons/apple-touch-icon-72x72.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

"use strict";

const CLIENT = "Artist Credits Helper/2023.10.7(https://github.com/y-young)";
// Default values
const CV_JOIN_PHRASES = [" (CV ", ")"];
const SEPARATOR = ",";

const TRACK_ARTIST_PATTERN =
    /(?<=\s\(?)([^\w\s\(]{1,3} ?\S{1,3})\s?(?=Ver|Remix|ソロ)/i;
const JOIN_PHRASE_PATTERN =
    /\s*(?:[\((]CV[\.:: ]?|[\))]\s*[,,、・]?|\s(?:featuring|feat|ft|vs)[\.\s]|,|,|、|&|・)\s*/gi;

const ENABLE_GUESS_TRACK_ARTISTS = true;
const ENABLE_APPEND_CHARACTER_CV = true;

/**
 * Fetch API wrapper with user agent and headers
 * @param {string} url
 * @param {RequestInit} options
 * @returns {Promise<Response>}
 */
function request(url, options = {}) {
    return fetch(origin + url, {
        ...options,
        headers: {
            "user-agent": CLIENT,
            accept: "application/json",
        },
    });
}

/**
 * Set the value of an input element and trigger React events
 * @param {HTMLInputElement} input
 * @param {string} value
 */
function setInputValue(input, value) {
    if (!input || input.disabled) {
        return;
    }
    // https://stackoverflow.com/questions/23892547/what-is-the-best-way-to-trigger-onchange-event-in-react-js
    const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
        window.HTMLInputElement.prototype,
        "value"
    ).set;
    nativeInputValueSetter.call(input, value);
    input.dispatchEvent(new Event("input", { bubbles: true }));
}

/**
 * @typedef {object} ArtistCredit
 * @property {string} artist Artist name in database
 * @property {string} [creditedAs] Artist as credited
 * @property {string} [joinPhrase] Join phrase
 */

/**
 * @typedef {object} ArtistCreditInputs
 * @property {HTMLInputElement} artist
 * @property {HTMLInputElement} creditedAs
 * @property {HTMLInputElement} joinPhrase
 */

class ArtistCreditsEditor {
    #bubble;
    #addButton;

    init(bubble) {
        this.#bubble = bubble;
        this.#addButton = bubble.querySelector("button.add-item.with-label");
    }

    /**
     * Get input boxes of some artist credits
     * @param {number} [sliceIndex=0] Index at which to start slicing
     * @returns {ArtistCreditInputs[]}
     */
    getInputs(sliceIndex = 0) {
        const inputs = Array.from(
            this.#bubble.querySelectorAll("input[type=text]")
        );
        const SIZE = 3;
        return Array.from(new Array(Math.ceil(inputs.length / SIZE)), (_, i) =>
            inputs.slice(i * SIZE, i * SIZE + SIZE)
        )
            .slice(sliceIndex)
            .map((input) => ({
                artist: input[0],
                creditedAs: input[1],
                joinPhrase: input[2],
            }));
    }

    /**
     * Fill in the given artist credits, replacing existing ones
     * @param {ArtistCredit[]} credits
     */
    fill(credits) {
        let inputs = this.getInputs();
        // Add new artist credits if necessary
        if (inputs.length < credits.length) {
            for (let i = 1; i <= credits.length - inputs.length; ++i) {
                setTimeout(() => this.#addButton.click(), 10);
            }
        }
        setTimeout(() => {
            inputs = this.getInputs();
            credits.forEach((credit, index) =>
                this.updateInputs(inputs[index], credit)
            );
        }, 30);
    }

    /**
     * Append a new artist credit
     * @param {ArtistCredit} credit
     */
    append(credit) {
        this.#addButton.click();
        setTimeout(() => {
            const newInput = this.getInputs(-1)[0];
            this.updateInputs(newInput, credit);
        }, 10);
    }

    /**
     * Update an existing artist credit at given index
     * @param {number} index
     * @param {(oldCredit: ArtistCredit) => ArtistCredit} updater
     */
    update(index, updater) {
        const inputs = this.getInputs().at(index);
        if (!inputs) {
            return;
        }
        const oldCredit = Object.fromEntries(
            Object.entries(inputs).map(([key, value]) => [key, value.value])
        );
        const newCredit = updater(oldCredit);
        this.updateInputs(inputs, newCredit);
    }

    /**
     * Update a group of artist credit input boxes
     * @param {ArtistCreditInputs} inputs
     * @param {ArtistCredit} newCredit
     */
    updateInputs(inputs, newCredit) {
        for (const key in newCredit) {
            const value = newCredit[key];
            if (value) {
                setInputValue(inputs[key], value);
            }
        }
    }
}

const editor = new ArtistCreditsEditor();

/**
 * Query API for the voice actor of an character
 * @param {string} characterMBID
 * @returns {Promise<string?>} MBID of voice actor
 */
async function getVoiceActor(characterMBID) {
    if (!characterMBID) {
        return Promise.resolve(null);
    }
    const RELATIONSHIP_ID = "e259a3f5-ce8e-45c1-9ef7-90ff7d0c7589";
    return request(`/ws/2/artist/${characterMBID}?inc=artist-rels&fmt=json`)
        .then((response) => response.json())
        .then(
            (data) =>
                data.relations.find(
                    (relation) =>
                        relation["type-id"] === RELATIONSHIP_ID &&
                        relation.direction === "backward" &&
                        !relation.ended
                )?.artist.id ?? alert("No voice actor relationship found.")
        );
}

/**
 * Get the MBID of a given character in preview text
 * @param {string} characterName
 * @returns {string|undefined} MBID of the character
 */
function getCharacterMBID(characterName) {
    const bubble = document.getElementById("artist-credit-bubble");
    const previewText = bubble.querySelectorAll("tr")[1];
    const artists = Array.from(previewText.querySelectorAll("a")).map(
        (link) => {
            let name;
            if (link.parentNode.classList.contains("name-variation")) {
                // Credit name differs from artist name
                name = link.title.split(" – ")[0].trim();
            } else {
                name = link.querySelector("bdi").innerText.trim();
            }
            const gid = link.href.split("/artist/")[1];
            return { name, gid };
        }
    );
    return (
        artists?.find((artist) => artist.name === characterName)?.gid ??
        alert("Character not found in preview text.")
    );
}

/**
 * Append the voice actor credit of the character
 * corresponding to the last artist credits
 */
function appendCharacterCV() {
    const characterName = editor.getInputs(-1)[0]?.artist?.value;
    if (!characterName) {
        alert("Please enter a character first.");
        return;
    }
    const { joinPhrases, separator } = getCVJoinPhrases();
    getVoiceActor(getCharacterMBID(characterName)).then((mbid) => {
        if (!mbid) {
            return;
        }
        editor.update(-2, (credit) => ({
            ...credit,
            joinPhrase: credit.joinPhrase + separator + " ",
        }));
        editor.update(-1, (credit) => ({
            ...credit,
            joinPhrase: joinPhrases[0],
        }));
        editor.append({ artist: mbid, joinPhrase: joinPhrases[1] });
    });
}

function getCVJoinPhrases() {
    const config = GM_getValue("cv_join_phrases");
    return {
        joinPhrases: CV_JOIN_PHRASES,
        separator: SEPARATOR,
        ...config,
    };
}

function setCVJoinPhrases() {
    const config = getCVJoinPhrases();
    const phrase1 = prompt(
        `Enter the first part of join phrases:`,
        config.joinPhrases[0]
    );
    const phrase2 = prompt(
        `Enter the second part of join phrases:`,
        config.joinPhrases[1]
    );
    const separator = prompt(`Enter the separator:`, config.separator);
    GM_setValue("cv_join_phrases", {
        joinPhrases: [phrase1, phrase2],
        separator: separator,
    });
}

/**
 * Guess the solo artist of from track titles and fill the artist credits in tracklist
 * @param {MouseEvent} event
 */
function guessTrackArtists(event) {
    const index = event.target.dataset.index;
    const trackList = document.querySelectorAll("table.medium").item(index);
    const tracks = trackList.querySelectorAll("tr.track");
    tracks.forEach((track) => {
        const title = track.querySelector("td.title > input").value;
        const artist = title.match(TRACK_ARTIST_PATTERN);
        if (!artist) {
            console.log("No artist found:", title);
            return;
        }
        const input = track.querySelector("td.artist input.name");
        setInputValue(input, artist[1]);
    });
}

/**
 * Split a string into multiple artist credits
 * @param {string} str
 * @returns {ArtistCredit[]} Parsed artist credits
 * @example
 * // returns [
 * //   { artist: "A", joinPhrase: " vs. " },
 * //   { artist: "B", joinPhrase: " feat. " },
 * //   { artist: "C" }
 * // ]
 * parseArtistCreditsString("A vs. B feat. C")
 * @example
 * // returns [
 * //   { artist: "A", joinPhrase: "(CV." },
 * //   { artist: "B", joinPhrase: "), " },
 * //   { artist: "C", joinPhrase: "(CV." },
 * //   { artist: "D", joinPhrase: ")" },
 * // ]
 * parseArtistCreditsString("A(CV.B), C(CV.D)")
 */
function parseArtistCreditsString(str) {
    if (!str) {
        return [];
    }
    const matches = str.matchAll(JOIN_PHRASE_PATTERN);
    const artists = str.split(JOIN_PHRASE_PATTERN);
    const credits = [];
    let pos = 0;
    for (const artist of artists) {
        const credit = { artist };
        pos += artist.length;
        const next = matches.next();
        if (!next.done) {
            const match = next.value;
            if (match.index === pos) {
                credit.joinPhrase = match[0];
                pos += match[0].length;
            }
        }
        credits.push(credit);
    }
    /*
        If the string is pasted outside the bubble
        we need to overwrite the first credited name exclusively.
    */
    if (credits[0]) {
        credits[0].creditedAs = credits[0].artist;
    }
    return credits;
}

/**
 * Parse the string in the first artist name input box and fill the artist credits
 */
function parseArtistCredits() {
    const acString = editor.getInputs()[0]?.artist?.value;
    if (!acString) {
        alert(
            "Please enter the artist credits to parse in the first input box."
        );
        return;
    }
    editor.fill(parseArtistCreditsString(acString));
}

function createButton(title, onClick) {
    const button = document.createElement("button");
    button.setAttribute("type", "button");
    button.style.float = "left";
    button.innerText = title;
    button.addEventListener("click", onClick);
    return button;
}

function initBubbleTools() {
    let bubble = document.getElementById("artist-credit-bubble");
    /*
        If all tracks have artist credits entered,
        there's no bubble container until user interaction.
        To correctly listen and inject buttons, we have to create one in advance.
    */
    if (!bubble) {
        bubble = document.createElement("div");
        bubble.setAttribute("id", "artist-credit-bubble");
        document.body.appendChild(bubble);
    }

    const initButtons = () => {
        const container = bubble.querySelector("div.buttons");
        if (ENABLE_APPEND_CHARACTER_CV) {
            const appendButton = createButton(
                "Append Character CV",
                appendCharacterCV
            );
            container.appendChild(appendButton);
        }
        const parseButton = createButton(
            "Parse Artist Credits",
            parseArtistCredits
        );
        container.appendChild(parseButton);
    };

    const observerCallback = (_, observer) => {
        initButtons();
        editor.init(bubble);
        observer.disconnect();
    };

    const observer = new MutationObserver(observerCallback);
    observer.observe(bubble, { childList: true });
}

function initTrackTools() {
    if (!ENABLE_GUESS_TRACK_ARTISTS) {
        return;
    }
    document
        .querySelectorAll("#tracklist-tools")
        .forEach((trackList, index) => {
            const button = createButton(
                "Guess artist from track titles",
                guessTrackArtists
            );
            button.dataset.index = index;
            trackList.querySelector("div.buttons").appendChild(button);
        });
}

initBubbleTools();
initTrackTools();
GM_registerMenuCommand("Config CV Join Phrases", setCVJoinPhrases);