MusicBrainz Artist Credits Helper

Split and fill artist credits, append character voice actor credit, and guess artists from track titles.

当前为 2023-05-12 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MusicBrainz Artist Credits Helper
// @namespace    https://github.com/y-young/userscripts
// @version      2023.5.12
// @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
// @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        none
// ==/UserScript==

'use strict';

const CLIENT = "Artist Credits Helper/2023.5.12(https://github.com/y-young)";
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;
    }
    getVoiceActor(getCharacterMBID(characterName))
        .then(mbid => {
            if (!mbid) {
                return;
            }
            editor.update(-2, (credit) => ({
                ...credit,
                joinPhrase: credit.joinPhrase + SEPARATOR + " "
            }));
            editor.update(-1, (credit) => ({
                ...credit,
                joinPhrase: CV_JOIN_PHRASES[0]
            }));
            editor.append({ artist: mbid, joinPhrase: CV_JOIN_PHRASES[1] });
        });
}

/**
 * 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();