YouTube: MusicBrainz Importer

Imports YouTube videos to MusicBrainz as a new standalone recording

// ==UserScript==
// @name         YouTube: MusicBrainz Importer
// @namespace    https://musicbrainz.org/user/chaban
// @version      2.5.0
// @description  Imports YouTube videos to MusicBrainz as a new standalone recording
// @tag          ai-created
// @author       nikki, RustyNova, chaban
// @license      MIT
// @match        *://www.youtube.com/*
// @match        *://musicbrainz.org/recording/create*
// @connect      googleapis.com
// @connect      musicbrainz.org
// @icon         https://www.google.com/s2/favicons?sz=256&domain=youtube.com
// @grant        GM.xmlHttpRequest
// @grant        GM.info
// @run-at       document-end
// @noframes
// ==/UserScript==

//**************************************************************************//
// Based on the "Import videos from YouTube as release" script by RustyNova
// and the original "Import videos from YouTube as recording" script by nikki et al.
//**************************************************************************//

(function() {
    'use strict';

    /**
     * Localization module to handle translations.
     */
    const L10n = {
        _language: (document.documentElement.lang || navigator.language || navigator.userLanguage).split('-')[0],
        _strings: {
            en: {
                loading: 'Loading...',
                addRecording: 'Add Recording',
                updateLength: 'Update Length',
                onMB: 'On MB ✓',
                onMBMulti: 'On MB (Multi) ✓',
                addRecordingTitle: 'Add to MusicBrainz as recording',
                updateLengthTitle: 'The linked MusicBrainz recording is missing its length. Click to update it to {length}s.',
                linkedToRecordingTitle: 'This YouTube video is linked to MusicBrainz recording: {title}',
                linkedToMultiTitle: 'This YouTube video is linked to multiple recordings on MusicBrainz.\nClick to view URL entity page.',
                errorVideoNotFound: 'Video Not Found / YT API Error',
                errorApiRateLimit: '{apiName} Rate Limit / Server Error',
                errorApiNetwork: '{apiName} Network Error',
                errorProcessing: 'Processing Error',
            },
            de: {
                loading: 'Wird geladen...',
                addRecording: 'Aufnahme hinzufügen',
                updateLength: 'Länge aktualisieren',
                onMB: 'Auf MB ✓',
                onMBMulti: 'Auf MB (Multi) ✓',
                addRecordingTitle: 'Als Aufnahme zu MusicBrainz hinzufügen',
                updateLengthTitle: 'Bei der verknüpften MusicBrainz-Aufnahme fehlt die Länge. Klicken, um sie auf {length}s zu aktualisieren.',
                linkedToRecordingTitle: 'Dieses YouTube-Video ist mit der MusicBrainz-Aufnahme verknüpft: {title}',
                linkedToMultiTitle: 'Dieses YouTube-Video ist mit mehreren Aufnahmen auf MusicBrainz verknüpft.\nKlicken, um die URL-Entitätsseite anzuzeigen.',
                errorVideoNotFound: 'Video nicht gefunden / YT API-Fehler',
                errorApiRateLimit: '{apiName} Ratenlimit / Serverfehler',
                errorApiNetwork: '{apiName} Netzwerkfehler',
                errorProcessing: 'Verarbeitungsfehler',
            }
        },
        getString: function(key, substitutions) {
            const langStrings = this._strings[this._language] || this._strings.en;
            let str = langStrings[key] || this._strings.en[key] || `L10N_ERROR: ${key}`;
            if (substitutions) {
                for (const subKey in substitutions) {
                    str = str.replace(`{${subKey}}`, substitutions[subKey]);
                }
            }
            return str;
        }
    };

    /**
     * Configuration object to centralize all constants and selectors.
     */
    const Config = {
        SHORT_APP_NAME: 'UserJS.YoutubeImport',
        GOOGLE_API_KEY: 'AIzaSyC5syukuFyCSoRvMr42Geu_d_1c_cRYouU',
        MUSICBRAINZ_API_ROOT: 'https://musicbrainz.org/ws/2/',
        YOUTUBE_API_ROOT: 'https://www.googleapis.com/youtube/v3/',
        YOUTUBE_API_VIDEO_PARTS: 'snippet,id,contentDetails',

        MAX_RETRIES: 5,
        INITIAL_RETRY_DELAY_MS: 1000,
        RETRY_BACKOFF_FACTOR: 2,

        SELECTORS: {
            BUTTON_DOCK: '#top-row.ytd-watch-metadata #owner.ytd-watch-metadata',
            MUSICBRAINZ_MAIN_VIDEO_CHECKBOX: '[name="edit-recording.video"]',
            MUSICBRAINZ_EXTERNAL_LINKS_EDITOR: '#external-links-editor',
            MUSICBRAINZ_INDIVIDUAL_VIDEO_CHECKBOX: '.relationship-item input[type="checkbox"]',
        },

        CLASS_NAMES: {
            CONTAINER: 'musicbrainz-userscript-container',
            BUTTON: 'search-button',
            BUTTON_READY: 'mb-ready',
            BUTTON_ADDED: 'mb-added',
            BUTTON_ERROR: 'mb-error',
            BUTTON_INFO: 'mb-info',
            BUTTON_UPDATE: 'mb-update', // Class for the update button
        },

        MUSICBRAINZ_FREE_STREAMING_LINK_TYPE_ID: '268',
        MUSICBRAINZ_FREE_STREAMING_RELATION_TYPE_ID: '7e41ef12-a124-4324-afdb-fdbae687a89c',
    };

    const USER_AGENT = `${Config.SHORT_APP_NAME}/${GM_info.script.version} ( ${GM_info.script.namespace} )`;

    /**
     * General utility functions.
     */
    const Utils = {
        /**
         * Waits for an element matching the given CSS selector to appear in the DOM.
         * @param {string} selector - The CSS selector of the element to wait for.
         * @param {number} timeout - The maximum time (in milliseconds) to wait for the element.
         * @returns {Promise<Element>} A promise that resolves with the element once found, or rejects on timeout.
         */
        waitForElement: function(selector, timeout = 7000) {
            return new Promise((resolve, reject) => {
                const element = document.querySelector(selector);
                if (element) {
                    resolve(element);
                    return;
                }

                let observer;
                const timer = setTimeout(() => {
                    if (observer) observer.disconnect();
                    reject(new Error(`Timeout waiting for element with selector: ${selector}`));
                }, timeout);

                observer = new MutationObserver((mutations, obs) => {
                    const targetElement = document.querySelector(selector);
                    if (targetElement) {
                        clearTimeout(timer);
                        obs.disconnect();
                        resolve(targetElement);
                    }
                });
                observer.observe(document.documentElement, {
                    childList: true,
                    subtree: true
                });
            });
        },

        /**
         * Performs an asynchronous HTTP request using GM.xmlHttpRequest with retry logic and exponential backoff.
         * @param {Object} details - The GM.xmlHttpRequest details object (method, url, headers, data).
         * @param {string} apiName - Name of the API for logging (e.g., "YouTube API", "MusicBrainz API").
         * @param {number} [currentRetry=0] - The current retry attempt.
         * @returns {Promise<Object>} A promise that resolves with the response object or rejects on error/exhausted retries.
         */
        gmXmlHttpRequest: function(details, apiName, currentRetry = 0) {
            const headers = {
                "Referer": location.origin,
                "Origin": location.origin,
                ...(details.headers || {})
            };

            return new Promise((resolve, reject) => {
                GM.xmlHttpRequest({
                    method: details.method || 'GET',
                    url: details.url,
                    headers: headers,
                    data: details.data || '',
                    anonymous: details.anonymous || false,
                    onload: (response) => {
                        if (response.status >= 200 && response.status < 300) {
                            resolve(response);
                        } else if (response.status === 503 && currentRetry < Config.MAX_RETRIES) {
                            const delay = Config.INITIAL_RETRY_DELAY_MS * Math.pow(Config.RETRY_BACKOFF_FACTOR, currentRetry);
                            console.warn(`[${GM.info.script.name}] ${apiName} returned 503. Retrying in ${delay}ms (attempt ${currentRetry + 1}/${Config.MAX_RETRIES}).`);
                            setTimeout(() => {
                                Utils.gmXmlHttpRequest(details, apiName, currentRetry + 1)
                                    .then(resolve)
                                    .catch(reject);
                            }, delay);
                        } else {
                            if (!(response.status === 404 && apiName === 'MusicBrainz API')) {
                                console.error(`[${GM.info.script.name}] ${apiName} request failed with status ${response.status}.`);
                            }
                            const error = new Error(`Request to ${apiName} failed with status ${response.status}: ${response.responseText}`);
                            error.status = response.status;
                            error.apiName = apiName;
                            reject(error);
                        }
                    },
                    onerror: (response) => {
                        console.error(`[${GM.info.script.name}] ${apiName} network error:`, response);
                        const error = new Error(`Network error for ${apiName}: ${response.statusText}`);
                        error.status = response.status;
                        error.apiName = apiName;
                        reject(error);
                    },
                    ontimeout: () => {
                        console.error(`[${GM.info.script.name}] ${apiName} request timed out.`);
                        const error = new Error(`Request to ${apiName} timed out`);
                        error.status = 408;
                        error.apiName = apiName;
                        reject(error);
                    }
                });
            });
        },

        /**
         * Converts ISO8601 duration (limited to hours/minutes/seconds) to milliseconds.
         * Format looks like PT1H45M5.789S (note: floats can be used)
         * https://en.wikipedia.org/wiki/ISO_8601#Durations
         * @param {string} str - The ISO8601 duration string.
         * @returns {number} The duration in milliseconds, or NaN if invalid.
         */
        ISO8601toMilliSeconds: function(str) {
            const regex = /^PT(?:(\d*\.?\d*)H)?(?:(\d*\.?\d*)M)?(?:(\d*\.?\d*)S)?$/,
                m = str.replace(',', '.').match(regex);
            if (!m) return NaN;
            return (3600 * parseFloat(m[1] || 0) + 60 * parseFloat(m[2] || 0) + parseFloat(m[3] || 0)) * 1000;
        }
    };

    /**
     * Handles all interactions with the YouTube Data API.
     */
    const YouTubeAPI = {
        _videoDataCache: new Map(),

        /**
         * Fetches minimalist video data from the YouTube Data API.
         * @param {string} videoId - The YouTube video ID.
         * @returns {Promise<Object|null>} A promise that resolves with the video data, or null if not found/error.
         */
        fetchVideoData: async function(videoId) {
            if (this._videoDataCache.has(videoId)) {
                const cachedData = this._videoDataCache.get(videoId);
                console.log(`[${GM.info.script.name}] YouTube API response found in cache for video ID: ${videoId}.`);
                return cachedData !== false ? cachedData : null;
            }

            const url = new URL('videos', Config.YOUTUBE_API_ROOT);
            url.searchParams.append('part', Config.YOUTUBE_API_VIDEO_PARTS);
            url.searchParams.append('id', videoId);
            url.searchParams.append('key', Config.GOOGLE_API_KEY);

            console.log(`[${GM.info.script.name}] Calling YouTube API for video ID:`, videoId);
            try {
                const response = await Utils.gmXmlHttpRequest({
                    method: 'GET',
                    url: url.toString(),
                }, 'YouTube API');

                const parsedFullResponse = JSON.parse(response.responseText);
                if (parsedFullResponse.items && parsedFullResponse.items.length > 0) {
                    const videoData = parsedFullResponse.items[0];
                    const minimalStructuredData = {
                        id: videoData.id,
                        snippet: {
                            title: videoData.snippet.title,
                            channelTitle: videoData.snippet.channelTitle,
                            channelId: videoData.snippet.channelId,
                        },
                        contentDetails: {
                            duration: videoData.contentDetails.duration
                        }
                    };
                    this._videoDataCache.set(videoId, minimalStructuredData);
                    return minimalStructuredData;
                } else {
                    console.log(`[${GM.info.script.name}] YouTube API returned no items for video ID: ${videoId}.`);
                    this._videoDataCache.set(videoId, false);
                    return null;
                }
            } catch (error) {
                console.error(`[${GM.info.script.name}] Error fetching YouTube video data for ${videoId}:`, error);
                this._videoDataCache.set(videoId, false);
                throw error;
            }
        },
    };

    /**
     * Handles all interactions with the MusicBrainz API.
     */
    const MusicBrainzAPI = {
        _urlCache: new Map(),
        _requestQueue: [],
        _isProcessingQueue: false,
        _lastRequestTime: 0,

        /**
         * Throttles GM.xmlHttpRequest calls to respect MusicBrainz API rate limits.
         * @param {Object} options - Request options.
         * @returns {Promise<Object>} A promise that resolves with the response object.
         */
        _throttledGmXmlHttpRequest: function(options) {
            return new Promise((resolve, reject) => {
                const request = {
                    options,
                    resolve,
                    reject
                };
                this._requestQueue.push(request);
                this._processQueue();
            });
        },

        /**
         * Processes the request queue, respecting the rate limit.
         */
        _processQueue: function() {
            if (this._isProcessingQueue || this._requestQueue.length === 0) {
                return;
            }

            this._isProcessingQueue = true;
            const now = Date.now();

            const timeSinceLastRequest = now - this._lastRequestTime;
            const delay = Math.max(0, Config.INITIAL_RETRY_DELAY_MS - timeSinceLastRequest);

            setTimeout(async () => {
                const request = this._requestQueue.shift();
                if (request) {
                    try {
                        const response = await Utils.gmXmlHttpRequest(request.options, 'MusicBrainz API');
                        request.resolve(response);
                    } catch (error) {
                        request.reject(error);
                    } finally {
                        this._lastRequestTime = Date.now();
                        this._isProcessingQueue = false;
                        this._processQueue();
                    }
                } else {
                    this._isProcessingQueue = false;
                }
            }, delay);
        },

        /**
         * Looks up multiple URLs on MusicBrainz to find existing relations.
         * @param {string[]} canonicalUrls - An array of canonical URLs to look up.
         * @returns {Promise<Map<string, Object|null>>} A promise that resolves with a Map where keys are URLs and values are MusicBrainz URL entity data (including relations), or null if not found/error.
         */
        lookupUrls: async function(canonicalUrls) {
            const resultsMap = new Map();
            const urlsToFetch = [];

            for (const url of canonicalUrls) {
                if (this._urlCache.has(url)) {
                    const cachedData = this._urlCache.get(url);
                    if (cachedData !== false && cachedData !== null) {
                        console.log(`[${GM.info.script.name}] MusicBrainz URL entity found in cache for ${url}.`);
                    } else {
                        console.log(`[${GM.info.script.name}] MusicBrainz URL not found in cache for ${url}.`);
                    }
                    resultsMap.set(url, cachedData !== false ? cachedData : null);
                } else {
                    urlsToFetch.push(url);
                }
            }

            if (urlsToFetch.length === 0) {
                return resultsMap;
            }

            const url = new URL('url', Config.MUSICBRAINZ_API_ROOT);
            urlsToFetch.forEach(resUrl => url.searchParams.append('resource', resUrl));
            url.searchParams.append('inc', 'recording-rels+artist-rels');
            url.searchParams.append('fmt', 'json');

            console.log(`[${GM.info.script.name}] :`, url.toString());
            try {
                const response = await this._throttledGmXmlHttpRequest({
                    method: 'GET',
                    url: url.toString(),
                    headers: {
                        "User-Agent": USER_AGENT,
                    },
                    anonymous: true,
                });

                const data = JSON.parse(response.responseText);

                if (urlsToFetch.length === 1) {
                    if (data && data.resource === urlsToFetch[0]) {
                        this._urlCache.set(urlsToFetch[0], data);
                        resultsMap.set(urlsToFetch[0], data);
                    } else {
                        this._urlCache.set(urlsToFetch[0], false);
                        resultsMap.set(urlsToFetch[0], null);
                    }
                } else {
                    if (data.urls && data.urls.length > 0) {
                        for (const urlEntity of data.urls) {
                            const originalUrl = urlsToFetch.find(u => u === urlEntity.resource);
                            if (originalUrl) {
                                this._urlCache.set(originalUrl, urlEntity);
                                resultsMap.set(originalUrl, urlEntity);
                            }
                        }
                    }
                }

                for (const url of urlsToFetch) {
                    if (!resultsMap.has(url)) {
                        this._urlCache.set(url, false);
                        resultsMap.set(url, null);
                    }
                }
                return resultsMap;

            } catch (error) {
                if (error.status === 404 && urlsToFetch.length === 1) {
                    console.info(`[${GM.info.script.name}] MusicBrainz URL not found (404) for single URL: ${urlsToFetch[0]}. This is expected and handled.`);
                    this._urlCache.set(urlsToFetch[0], false);
                    resultsMap.set(urlsToFetch[0], null);
                    return resultsMap;
                } else {
                    console.error(`[${GM.info.script.name}] Error looking up MusicBrainz URLs:`, error);
                    throw error;
                }
            }
        },

        /**
         * Extracts the Artist MBID from a MusicBrainz URL entity if it contains artist relations.
         * @param {Object|null} channelUrlEntity - The MusicBrainz URL entity object for a channel.
         * @returns {string|null} The Artist MBID if found, otherwise null.
         */
        _extractArtistMbid: function(channelUrlEntity) {
            if (!channelUrlEntity || !channelUrlEntity.relations) return null;
            for (const relation of channelUrlEntity.relations) {
                if (relation['target-type'] === 'artist' && relation.artist && relation.artist.id) {
                    return relation.artist.id;
                }
            }
            return null;
        }
    };

    /**
     * Scans the DOM for relevant elements and extracts information.
     */
    const DOMScanner = {
        /**
         * Checks if the current page is a YouTube video watch page.
         * @returns {string|null} The video ID if it's a video page, otherwise null.
         */
        getVideoId: function() {
            const videoIdMatch = location.href.match(/[?&]v=([A-Za-z0-9_-]{11})/);
            return videoIdMatch ? videoIdMatch[1] : null;
        },

        /**
         * Finds the DOM element where the import button should be appended.
         * @returns {Promise<HTMLElement|null>} A promise that resolves with the dock element, or null if not found.
         */
        getButtonAnchorElement: async function() {
            try {
                const dock = await Utils.waitForElement(Config.SELECTORS.BUTTON_DOCK, 5000);
                console.log(`[${GM.info.script.name}] Found button dock:`, dock);
                return dock;
            } catch (e) {
                console.error(`[${GM.info.script.name}] Could not find button dock element:`, e);
                return null;
            }
        },
    };

    /**
     * Manages the creation, display, and state of the MusicBrainz import button.
     */
    const ButtonManager = {
        _form: null,
        _submitButton: null,
        _textElement: null,
        _containerDiv: null,

        /**
         * Initializes the button elements and their basic structure.
         */
        init: function() {
            this._containerDiv = document.createElement("div");
            this._containerDiv.setAttribute("class", `holder ${Config.CLASS_NAMES.CONTAINER}`);
            this._containerDiv.style.display = 'none';

            this._form = document.createElement("form");
            this._form.method = "get";
            this._form.action = "//musicbrainz.org/recording/create";
            this._form.acceptCharset = "UTF-8";
            this._form.target = "_blank";

            this._submitButton = document.createElement("button");
            this._submitButton.type = "submit";
            this._submitButton.title = L10n.getString('addRecordingTitle');
            this._submitButton.setAttribute("class", Config.CLASS_NAMES.BUTTON);
            this._textElement = document.createElement("span");
            this._textElement.innerText = L10n.getString('loading');

            const buttonContent = document.createElement('div');
            buttonContent.style.display = 'flex';
            buttonContent.style.alignItems = 'center';
            buttonContent.appendChild(this._textElement);
            this._submitButton.appendChild(buttonContent);

            this._form.appendChild(this._submitButton);
            this._containerDiv.appendChild(this._form);
        },

        /**
         * Resets the button state, clearing previous form fields and setting to loading.
         */
        resetState: function() {
            Array.from(this._form.querySelectorAll('input[type="hidden"]')).forEach(input => this._form.removeChild(input));
            while (this._containerDiv.firstChild) {
                this._containerDiv.removeChild(this._containerDiv.firstChild);
            }
            this._containerDiv.appendChild(this._form);

            this._textElement.innerText = L10n.getString('loading');
            this._submitButton.className = Config.CLASS_NAMES.BUTTON;
            this._submitButton.disabled = true;
            this._form.style.display = 'flex';
            this._containerDiv.style.display = 'flex';
        },

        /**
         * Appends a hidden input field to the form.
         * @param {string} name - The name attribute of the input field.
         * @param {string} value - The value attribute of the input field.
         */
        _addField: function(name, value) {
            if (!this._form) return;
            const field = document.createElement("input");
            field.type = "hidden";
            field.name = name;
            field.value = value;
            this._form.insertBefore(field, this._submitButton);
        },

        /**
         * Appends the button container to the specified dock element.
         * If dock is null, it appends to body as a fallback.
         * @param {HTMLElement|null} dockElement - The element to append the button to.
         */
        appendToDock: function(dockElement) {
            if (document.body.contains(this._containerDiv)) {
                return;
            }

            if (dockElement) {
                dockElement.appendChild(this._containerDiv);
                console.log(`[${GM.info.script.name}] Button UI appended to dock.`);
            } else {
                console.warn(`[${GM.info.script.name}] Could not find a suitable dock element. Appending to body as last resort.`);
                document.body.appendChild(this._containerDiv);
                this._containerDiv.style.position = 'fixed';
                this._containerDiv.style.top = '10px';
                this._containerDiv.style.right = '10px';
                this._containerDiv.style.zIndex = '9999';
                this._containerDiv.style.background = 'rgba(0,0,0,0.7)';
                this._containerDiv.style.padding = '5px';
                this._containerDiv.style.borderRadius = '5px';
            }
        },

        /**
         * Prepares the form with YouTube video data and displays the "Add Recording" button.
         * @param {Object} youtubeVideoData - The minimalist YouTube video data.
         * @param {string} canonicalYtUrl - The canonical YouTube URL.
         * @param {string|null} artistMbid - The MusicBrainz Artist MBID if found.
         * @param {string} videoId - The YouTube video ID.
         */
        prepareAddButton: function(youtubeVideoData, canonicalYtUrl, artistMbid, videoId) {
            const title = youtubeVideoData.snippet.title;
            const artist = youtubeVideoData.snippet.channelTitle;

            let length = 0;
            if (youtubeVideoData.contentDetails && typeof youtubeVideoData.contentDetails.duration === 'string') {
                length = Utils.ISO8601toMilliSeconds(youtubeVideoData.contentDetails.duration);
            }

            this._addField('edit-recording.name', title);
            if (artistMbid) {
                this._addField('artist', artistMbid);
                this._addField('edit-recording.artist_credit.names.0.artist.name', artist);
            } else {
                this._addField('edit-recording.artist_credit.names.0.name', artist);
            }

            if (typeof length === 'number' && !isNaN(length) && length > 0) {
                this._addField('edit-recording.length', length);
            }

            this._addField('edit-recording.video', '1');
            this._addField('edit-recording.url.0.text', canonicalYtUrl);
            this._addField('edit-recording.url.0.link_type_id', Config.MUSICBRAINZ_FREE_STREAMING_LINK_TYPE_ID);
            const scriptInfo = GM_info.script;
            const editNote = `${document.location.href}\n—\n${scriptInfo.name} (v${scriptInfo.version})`;
            this._addField('edit-recording.edit_note', editNote);

            this._textElement.innerText = L10n.getString('addRecording');
            this._submitButton.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_READY}`;
            this._submitButton.disabled = false;
            this._form.style.display = 'flex';

            this._submitButton.onclick = () => {
                console.log(`[${GM.info.script.name}] Import button clicked. Clearing cache for video ID: ${videoId}`);
                MusicBrainzAPI._urlCache.delete(canonicalYtUrl);

                if (youtubeVideoData.snippet.channelId) {
                    const youtubeChannelUrl = new URL(`https://www.youtube.com/channel/${youtubeVideoData.snippet.channelId}`).toString();
                    MusicBrainzAPI._urlCache.delete(youtubeChannelUrl);
                }
            };
        },

        /**
         * Displays the "On MB ✓" button, linking to the existing MusicBrainz entity.
         * If the recording has no length, it provides a button to update it.
         * @param {Array} allRelevantRecordingRelations - An array of recording relations.
         * @param {string} urlEntityId - The MusicBrainz URL entity ID.
         * @param {Object} youtubeVideoData - The minimalist YouTube video data.
         */
        displayExistingButton: function(allRelevantRecordingRelations, urlEntityId, youtubeVideoData) {
            this._form.style.display = 'none';
            const link = document.createElement('a');
            link.style.textDecoration = 'none';
            link.target = '_blank';

            const button = document.createElement('button');
            const span = document.createElement('span');
            button.appendChild(span);
            link.appendChild(button);

            if (allRelevantRecordingRelations.length === 1) {
                const existingRecordingRelation = allRelevantRecordingRelations[0];
                const recordingMBID = existingRecordingRelation.recording.id;
                const recordingTitle = existingRecordingRelation.recording.title || "View Recording";
                const hasLength = existingRecordingRelation.recording.length != null;
                const ytHasLength = youtubeVideoData && youtubeVideoData.contentDetails && youtubeVideoData.contentDetails.duration;

                // Check if the recording is missing the length and we have a length from YouTube
                if (!hasLength && ytHasLength) {
                    const lengthInMs = Utils.ISO8601toMilliSeconds(youtubeVideoData.contentDetails.duration);
                    const scriptInfo = GM_info.script;
                    const editNote = `${document.location.href}\n—\n${scriptInfo.name} (v${scriptInfo.version})`;
                    const encodedEditNote = encodeURIComponent(editNote);
                    link.href = `//musicbrainz.org/recording/${recordingMBID}/edit?edit-recording.length=${lengthInMs}&edit-recording.edit_note=${encodedEditNote}`;
                    link.title = L10n.getString('updateLengthTitle', {
                        length: Math.round(lengthInMs / 1000)
                    });
                    span.textContent = L10n.getString('updateLength');
                    button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_UPDATE}`;
                    console.log(`[${GM.info.script.name}] Displaying 'Update Length' button for recording ${recordingMBID}.`);
                } else {
                    // Default behavior: link to the recording page
                    link.href = `//musicbrainz.org/recording/${recordingMBID}`;
                    link.title = L10n.getString('linkedToRecordingTitle', {
                        title: recordingTitle
                    });
                    span.textContent = L10n.getString('onMB');
                    button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ADDED}`;
                }
            } else {
                console.log(`[${GM.info.script.name}] Multiple recording relations found. Linking to URL entity page.`);
                link.href = `//musicbrainz.org/url/${urlEntityId}`;
                link.title = L10n.getString('linkedToMultiTitle');
                span.textContent = L10n.getString('onMBMulti');
                button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ADDED}`;
            }
            this._containerDiv.appendChild(link);
            console.log(`[${GM.info.script.name}] Displaying existing link button.`);
        },

        /**
         * Displays an error button with a given message.
         * @param {string} message - The error message to display.
         */
        displayError: function(message) {
            this.resetState();
            this._textElement.innerText = message;
            this._submitButton.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ERROR}`;
            this._submitButton.disabled = true;
            this._containerDiv.style.display = 'flex';
        },

        /**
         * Displays an informational button with a given message.
         * @param {string} message - The info message to display.
         */
        displayInfo: function(message) {
            this.resetState();
            this._textElement.innerText = message;
            this._submitButton.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_INFO}`;
            this._submitButton.disabled = true;
            this._containerDiv.style.display = 'flex';
        }
    };

    /**
     * Main application logic for the userscript.
     */
    const YouTubeMusicBrainzImporter = {
        _previousUrl: '',
        _processingVideoId: null,
        _currentProcessingPromise: null,
        _navigationTimeoutId: null,
        _prefetchedDataPromise: null,
        _prefetchedVideoId: null,

        /**
         * Initializes the application: injects CSS and sets up observers.
         */
        init: function() {
            this._injectCSS();
            ButtonManager.init();
            this._setupObservers();
            this._setupUrlChangeListeners();
            this._previousUrl = window.location.href;

            this.triggerUpdate(DOMScanner.getVideoId());
        },

        /**
         * Injects custom CSS rules into the document head for button styling.
         */
        _injectCSS: function() {
            const head = document.head || document.getElementsByTagName('head')[0];
            if (head) {
                const style = document.createElement('style');
                style.setAttribute('type', 'text/css');
                style.textContent = `
                    .${Config.CLASS_NAMES.CONTAINER} {
                        /* Add any container specific styles here if needed */
                    }
                    .dashbox {
                        padding-bottom: 4px;
                    }
                    .button-area {
                        display: flex;
                        padding: 5px;
                    }
                    .button-favicon {
                        height: 1.25em;
                        margin-left: 5px;
                    }
                    .holder {
                        height: 100%;
                        display: flex;
                        align-items: center;
                    }
                    .${Config.CLASS_NAMES.BUTTON} {
                        border-radius: 18px;
                        border: none;
                        padding: 0px 10px;
                        font-size: 14px;
                        height: 36px;
                        color: white;
                        cursor: pointer;
                        display: flex;
                        align-items: center;
                        text-decoration: none;
                        margin: 0px 0 0 8px;
                        background-color: #f8f8f8;
                        color: #0f0f0f;
                        transition: background-color .3s;
                    }
                    .${Config.CLASS_NAMES.BUTTON}:hover {
                        background-color: #e0e0e0;
                    }
                    .${Config.CLASS_NAMES.BUTTON}[disabled] {
                        opacity: 0.7;
                        cursor: not-allowed;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_READY} {
                        background-color: #BA478F;
                        color: white;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_READY}:hover {
                        background-color: #a53f7c;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_ADDED} {
                        background-color: #a4a4a4;
                        color: white;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_ADDED}:hover {
                        background-color: #8c8c8c;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_UPDATE} {
                        background-color: #3ea6ff; /* A different color to stand out */
                        color: white;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_UPDATE}:hover {
                        background-color: #3593e0;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_ERROR} {
                        background-color: #cc0000;
                        color: white;
                    }
                    .${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_INFO} {
                        background-color: #3ea6ff;
                        color: white;
                    }
                `;
                head.appendChild(style);
            }
        },

        /**
         * Sets up observers for YouTube's SPA navigation.
         */
        _setupObservers: function() {
            document.addEventListener('yt-navigate-finish', (event) => {
                console.log(`[${GM.info.script.name}] 'yt-navigate-finish' event detected.`);

                if (this._navigationTimeoutId) {
                    clearTimeout(this._navigationTimeoutId);
                    this._navigationTimeoutId = null;
                    console.log(`[${GM.info.script.name}] Cleared previous navigation timeout.`);
                }

                this._navigationTimeoutId = setTimeout(() => {
                    const currentVideoId = DOMScanner.getVideoId();
                    this.triggerUpdate(currentVideoId);
                }, 500);
            });
        },

        /**
         * Sets up event listeners for URL changes to initiate pre-fetching of data.
         */
        _setupUrlChangeListeners: function() {
            document.addEventListener('yt-navigate-start', () => {
                const currentVideoId = DOMScanner.getVideoId();
                if (currentVideoId && currentVideoId !== this._prefetchedVideoId) {
                    console.log(`[${GM.info.script.name}] 'yt-navigate-start' detected for video ID: ${currentVideoId}. Initiating pre-fetch.`);
                    this._prefetchedVideoId = currentVideoId;
                    this._prefetchedDataPromise = this._startPrefetching(currentVideoId);
                } else if (!currentVideoId && this._prefetchedVideoId) {
                    console.log(`[${GM.info.script.name}] Navigated away from video page. Clearing pre-fetch state.`);
                    this._prefetchedVideoId = null;
                    this._prefetchedDataPromise = null;
                }
            });

            window.addEventListener('popstate', () => {
                const currentVideoId = DOMScanner.getVideoId();
                if (currentVideoId && currentVideoId !== this._prefetchedVideoId) {
                    console.log(`[${GM.info.script.name}] 'popstate' detected for video ID: ${currentVideoId}. Initiating pre-fetch.`);
                    this._prefetchedVideoId = currentVideoId;
                    this._prefetchedDataPromise = this._startPrefetching(currentVideoId);
                } else if (!currentVideoId && this._prefetchedVideoId) {
                    console.log(`[${GM.info.script.name}] Navigated away from video page. Clearing pre-fetch state.`);
                    this._prefetchedVideoId = null;
                    this._prefetchedDataPromise = null;
                }
            });

            console.log(`[${GM.info.script.name}] URL change listeners (yt-navigate-start, popstate) set up.`);
        },


        /**
         * Initiates the pre-fetching of YouTube and MusicBrainz data for a given video ID.
         * @param {string} videoId - The YouTube video ID.
         * @returns {Promise<[Object|null, Map<string, Object|null>]>} A promise that resolves with an array
         * containing [youtubeVideoData, musicBrainzUrlResultsMap] or [null, null] on error.
         */
        _startPrefetching: async function(videoId) {
            try {
                const ytDataPromise = YouTubeAPI.fetchVideoData(videoId);
                const canonicalYtUrl = new URL(`https://www.youtube.com/watch?v=${videoId}`).toString();

                const ytData = await ytDataPromise;

                if (!ytData) {
                    console.warn(`[${GM.info.script.name}] YT data not available for pre-fetching ${videoId}. Skipping MB pre-fetch.`);
                    return [null, null];
                }

                const youtubeChannelUrl = ytData.snippet.channelId ? new URL(`https://www.youtube.com/channel/${ytData.snippet.channelId}`).toString() : null;
                const urlsToQuery = [canonicalYtUrl];
                if (youtubeChannelUrl) {
                    urlsToQuery.push(youtubeChannelUrl);
                }
                const mbResultsPromise = MusicBrainzAPI.lookupUrls(urlsToQuery);

                const [finalYtData, finalMbResults] = await Promise.all([Promise.resolve(ytData), mbResultsPromise]);
                console.log(`[${GM.info.script.name}] Pre-fetching completed for video ID: ${videoId}.`);
                return [finalYtData, finalMbResults];
            } catch (error) {
                console.error(`[${GM.info.script.name}] Error during pre-fetching for video ID: ${videoId}:`, error);
                return [null, null];
            }
        },

        /**
         * Triggers the update process for a given video ID.
         * This function acts as a gatekeeper to ensure only one update runs at a time.
         * @param {string|null} videoId - The YouTube video ID to process.
         */
        triggerUpdate: function(videoId) {
            if (this._processingVideoId === videoId && this._currentProcessingPromise) {
                console.log(`[${GM.info.script.name}] Already processing video ID: ${videoId}. Skipping trigger.`);
                return;
            }

            ButtonManager.resetState();
            if (!videoId) {
                ButtonManager._containerDiv.style.display = 'none';
                this._processingVideoId = null;
                this._currentProcessingPromise = null;
                console.log(`[${GM.info.script.name}] Not a YouTube video page. Hiding button.`);
                return;
            }

            this._processingVideoId = videoId;
            console.log(`[${GM.info.script.name}] Triggering update for video ID: ${videoId}`);

            if (videoId === this._prefetchedVideoId && this._prefetchedDataPromise) {
                console.log(`[${GM.info.script.name}] Using pre-fetched data for video ID: ${videoId}.`);
                this._currentProcessingPromise = this._prefetchedDataPromise
                    .then(([ytData, mbResults]) => this._performUpdate(videoId, ytData, mbResults))
                    .finally(() => {
                        if (this._processingVideoId === videoId) {
                            this._processingVideoId = null;
                            this._currentProcessingPromise = null;
                            this._prefetchedDataPromise = null;
                            this._prefetchedVideoId = null;
                        }
                    });
            } else {
                console.log(`[${GM.info.script.name}] No pre-fetched data or different video. Performing full update for video ID: ${videoId}.`);
                this._currentProcessingPromise = this._performUpdate(videoId)
                    .finally(() => {
                        if (this._processingVideoId === videoId) {
                            this._processingVideoId = null;
                            this._currentProcessingPromise = null;
                        }
                    });
            }
        },

        /**
         * The actual function that performs the API calls and UI updates.
         * @param {string} videoId - The YouTube video ID to process.
         * @param {Object|null} [prefetchedYtData=null] - Optional pre-fetched YouTube video data.
         * @param {Map<string, Object|null>|null} [prefetchedMbResults=null] - Optional pre-fetched MusicBrainz URL lookup results.
         * @returns {Promise<void>} A promise that resolves when the update is complete.
         */
        _performUpdate: async function(videoId, prefetchedYtData = null, prefetchedMbResults = null) {
            let ytData = prefetchedYtData;
            let mbResults = prefetchedMbResults;

            try {
                const dockElement = await DOMScanner.getButtonAnchorElement();
                ButtonManager.appendToDock(dockElement);

                if (!ytData) {
                    ytData = await YouTubeAPI.fetchVideoData(videoId);
                }
                if (!ytData) {
                    ButtonManager.displayInfo(L10n.getString('errorVideoNotFound'));
                    return;
                }

                const canonicalYtUrl = new URL(`https://www.youtube.com/watch?v=${videoId}`).toString();
                const youtubeChannelUrl = ytData.snippet.channelId ? new URL(`https://www.youtube.com/channel/${ytData.snippet.channelId}`).toString() : null;

                const urlsToQuery = [canonicalYtUrl];
                if (youtubeChannelUrl) {
                    urlsToQuery.push(youtubeChannelUrl);
                }

                if (!mbResults) {
                    mbResults = await MusicBrainzAPI.lookupUrls(urlsToQuery);
                }

                const mbVideoUrlEntity = mbResults.get(canonicalYtUrl);
                const artistMbid = youtubeChannelUrl ? MusicBrainzAPI._extractArtistMbid(mbResults.get(youtubeChannelUrl)) : null;

                if (mbVideoUrlEntity) {
                    const allRelevantRecordingRelations = (mbVideoUrlEntity.relations || []).filter(
                        rel => rel['type-id'] === Config.MUSICBRAINZ_FREE_STREAMING_RELATION_TYPE_ID &&
                        rel['target-type'] === "recording" &&
                        rel.recording && rel.recording.id
                    );

                    if (allRelevantRecordingRelations.length > 0) {
                        console.log(`[${GM.info.script.name}] Video already linked on MusicBrainz.`);
                        ButtonManager.displayExistingButton(allRelevantRecordingRelations, mbVideoUrlEntity.id, ytData);
                    } else {
                        console.log(`[${GM.info.script.name}] URL entity found, but no relevant recording relations. Proceeding to add button.`);
                        ButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, videoId);
                    }
                } else {
                    console.log(`[${GM.info.script.name}] YouTube URL not found as a URL entity on MusicBrainz. Preparing add button.`);
                    ButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, videoId);
                }
            } catch (error) {
                console.error(`[${GM.info.script.name}] Unhandled error during update for video ID: ${videoId}:`, error);

                const apiName = error.apiName || 'API';

                if (error.status === 503) {
                    ButtonManager.displayError(L10n.getString('errorApiRateLimit', {
                        apiName
                    }));
                } else if (error.status === 0) {
                    ButtonManager.displayError(L10n.getString('errorApiNetwork', {
                        apiName
                    }));
                } else {
                    ButtonManager.displayError(L10n.getString('errorProcessing'));
                }
            }
        },
    };

    /**
     * Helper function to set the checked state of a checkbox by simulating a click.
     * @param {HTMLInputElement} checkbox - The checkbox element.
     * @param {boolean} isChecked - The desired checked state.
     */
    function setCheckboxState(checkbox, isChecked) {
        if (!checkbox || checkbox.disabled) {
            return;
        }
        if (checkbox.checked !== isChecked) {
            checkbox.click();
        }
    }

    /**
     * Handles logic specific to the MusicBrainz recording creation page.
     */
    const MusicBrainzRecordingCreatePage = {
        _mainVideoCheckbox: null,
        _externalLinksEditor: null,
        _mutationObserver: null,
        _isInternalSync: false,

        init: async function() {
            try {
                this._externalLinksEditor = await Utils.waitForElement(Config.SELECTORS.MUSICBRAINZ_EXTERNAL_LINKS_EDITOR, 10000);
                this._mainVideoCheckbox = await Utils.waitForElement(Config.SELECTORS.MUSICBRAINZ_MAIN_VIDEO_CHECKBOX, 10000);

                console.log(`[${GM.info.script.name}] Initializing for MusicBrainz recording create page.`);
                this._setupListeners();
                this._setupMutationObserver();
                this._initialSync();
            } catch (error) {
                console.log(`[${GM.info.script.name}] Not on MusicBrainz recording create page or elements not found:`, error.message);
            }
        },

        /**
         * Gets all 'video' checkboxes associated with external links.
         * @returns {NodeList<HTMLInputElement>} A NodeList of the checkbox elements.
         */
        _getIndividualVideoCheckboxes: function() {
            return this._externalLinksEditor.querySelectorAll(Config.SELECTORS.MUSICBRAINZ_INDIVIDUAL_VIDEO_CHECKBOX);
        },

        /**
         * Synchronizes the state of the main video checkbox to all individual video checkboxes.
         * @param {boolean} isChecked - The desired checked state.
         */
        _syncMainToIndividual: function(isChecked) {
            this._getIndividualVideoCheckboxes().forEach(checkbox => {
                setCheckboxState(checkbox, isChecked);
            });
            if (!this._isInternalSync) {
                console.log(`[${GM.info.script.name}] Main video checkbox synced to individual checkboxes: ${isChecked}`);
            }
        },

        /**
         * Synchronizes the state of individual video checkboxes to the main video checkbox.
         */
        _syncIndividualToMain: function() {
            const anyIndividualChecked = Array.from(this._getIndividualVideoCheckboxes()).some(checkbox => checkbox.checked);
            setCheckboxState(this._mainVideoCheckbox, anyIndividualChecked);
            if (!this._isInternalSync) {
                console.log(`[${GM.info.script.name}] Individual video checkboxes synced to main checkbox: ${anyIndividualChecked}`);
            }
        },

        /**
         * Sets up event listeners for the main and existing individual video checkboxes.
         */
        _setupListeners: function() {
            this._mainVideoCheckbox.addEventListener('change', () => {
                if (this._isInternalSync) return;

                this._isInternalSync = true;
                this._syncMainToIndividual(this._mainVideoCheckbox.checked);
                this._isInternalSync = false;
                console.log(`[${GM.info.script.name}] Main video checkbox toggled by user. Synced to individual checkboxes: ${this._mainVideoCheckbox.checked}`);
            });

            this._getIndividualVideoCheckboxes().forEach(checkbox => {
                checkbox.addEventListener('change', () => {
                    if (this._isInternalSync) return;

                    this._isInternalSync = true;
                    this._syncIndividualToMain();
                    this._isInternalSync = false;
                    console.log(`[${GM.info.script.name}] Individual video checkbox toggled by user. Synced to main checkbox.`);
                });
            });
            console.log(`[${GM.info.script.name}] Initial listeners set up.`);
        },

        /**
         * Sets up a MutationObserver to detect dynamically added external link rows and attach listeners.
         */
        _setupMutationObserver: function() {
            this._mutationObserver = new MutationObserver(mutations => {
                mutations.forEach(mutation => {
                    if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                        mutation.addedNodes.forEach(node => {
                            if (node.nodeType === 1) {
                                const relationshipItems = node.matches('.relationship-item') ? [node] : node.querySelectorAll('.relationship-item');
                                relationshipItems.forEach(item => {
                                    const checkbox = item.querySelector(Config.SELECTORS.MUSICBRAINZ_INDIVIDUAL_VIDEO_CHECKBOX);
                                    if (checkbox) {
                                        if (!checkbox.dataset.mbSyncListenerAdded) {
                                            checkbox.addEventListener('change', () => {
                                                if (this._isInternalSync) return;
                                                this._isInternalSync = true;
                                                this._syncIndividualToMain();
                                                this._isInternalSync = false;
                                                console.log(`[${GM.info.script.name}] New individual video checkbox toggled. Synced to main checkbox.`);
                                            });
                                            checkbox.dataset.mbSyncListenerAdded = 'true';
                                            console.log(`[${GM.info.script.name}] Listener attached to new individual video checkbox.`);

                                            if (this._mainVideoCheckbox && this._mainVideoCheckbox.checked) {
                                                this._isInternalSync = true;
                                                setCheckboxState(checkbox, true);
                                                this._isInternalSync = false;
                                                console.log(`[${GM.info.script.name}] Newly added individual video checkbox automatically set to checked.`);
                                            }
                                        }
                                    }
                                });
                            }
                        });
                        this._isInternalSync = true;
                        this._syncIndividualToMain();
                        this._isInternalSync = false;
                    }
                });
            });

            this._mutationObserver.observe(this._externalLinksEditor, {
                childList: true,
                subtree: true
            });
            console.log(`[${GM.info.script.name}] MutationObserver set up for external links editor.`);
        },

        /**
         * Performs an initial synchronization of checkbox states when the script loads.
         */
        _initialSync: function() {
            this._isInternalSync = true;

            const initialMainCheckboxStateFromURL = this._mainVideoCheckbox.checked;

            if (initialMainCheckboxStateFromURL) {
                setCheckboxState(this._mainVideoCheckbox, true);
                this._getIndividualVideoCheckboxes().forEach(checkbox => {
                    setCheckboxState(checkbox, true);
                });
                console.log(`[${GM.info.script.name}] Main video checkbox was pre-checked by URL. Synced all individual checkboxes to true.`);
            } else {
                this._syncIndividualToMain();
                console.log(`[${GM.info.script.name}] Main video checkbox not pre-checked by URL. Synced main checkbox based on individual links.`);
            }

            this._isInternalSync = false;
            console.log(`[${GM.info.script.name}] Initial sync completed.`);
        }
    };


    if (window.location.href.includes('musicbrainz.org/recording/create')) {
        MusicBrainzRecordingCreatePage.init();
    } else if (window.location.hostname.includes('youtube.com')) {
        YouTubeMusicBrainzImporter.init();
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址