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.1
// @description  Imports YouTube videos to MusicBrainz as a new standalone recording
// @tag          ai-created
// @author       nikki, RustyNova, chaban
// @license      MIT
// @match        *://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=256&domain=youtube.com
// @grant        GM.xmlHttpRequest
// @grant        GM.info
// @run-at       document-end
// ==/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';

    /**
     * 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',
        },

        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',
        },

        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,
                ...(details.headers || {})
            };

            return new Promise((resolve, reject) => {
                GM.xmlHttpRequest({
                    method: details.method || 'GET',
                    url: details.url,
                    headers: headers,
                    data: details.data || '',
                    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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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) {
            var 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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_NAME}: YouTube API returned no items for video ID: ${videoId}.`);
                    this._videoDataCache.set(videoId, false);
                    return null;
                }
            } catch (error) {
                console.error(`${Config.SHORT_APP_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(`${Config.SHORT_APP_NAME}: MusicBrainz URL entity found in cache for ${url}.`);
                    } else {
                        console.log(`${Config.SHORT_APP_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(`${Config.SHORT_APP_NAME}: Checking MB for existing URL entities:`, 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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_NAME}: Found button dock:`, dock);
                return dock;
            } catch (e) {
                console.error(`${Config.SHORT_APP_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 = "Add to MusicBrainz as recording";
            this._submitButton.setAttribute("class", Config.CLASS_NAMES.BUTTON);
            this._textElement = document.createElement("span");
            this._textElement.innerText = "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 = "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(`${Config.SHORT_APP_NAME}: Button UI appended to dock.`);
            } else {
                console.warn(`${Config.SHORT_APP_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;
            const 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);
            }

            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 = "Add Recording";
            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(`${Config.SHORT_APP_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.
         * @param {Array} allRelevantRecordingRelations - An array of recording relations.
         * @param {string} urlEntityId - The MusicBrainz URL entity ID.
         */
        displayExistingButton: function(allRelevantRecordingRelations, urlEntityId) {
            this._form.style.display = 'none';
            const link = document.createElement('a');
            link.style.textDecoration = 'none';
            link.target = '_blank';

            const button = document.createElement('button');
            button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ADDED}`;
            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";
                link.href = `//musicbrainz.org/recording/${recordingMBID}`;
                link.title = `This YouTube video is linked to MusicBrainz recording: ${recordingTitle}`;
                span.textContent = 'On MB ✓';
            } else {
                console.log(`${Config.SHORT_APP_NAME}: Multiple recording relations found. Linking to URL entity page.`);
                link.href = `//musicbrainz.org/url/${urlEntityId}`;
                link.title = `This YouTube video is linked to multiple recordings on MusicBrainz.
Click to view URL entity page.`;
                span.textContent = 'On MB (Multi) ✓';
            }
            this._containerDiv.appendChild(link);
            console.log(`${Config.SHORT_APP_NAME}: Displaying 'On MB ✓' 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,

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

            this.runUpdate(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_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() {
            const self = this;
            document.addEventListener('yt-navigate-finish', (event) => {
                console.log(`${Config.SHORT_APP_NAME}: 'yt-navigate-finish' event detected.`);

                setTimeout(() => {
                    const currentVideoId = DOMScanner.getVideoId();
                    if (currentVideoId && currentVideoId !== self._processingVideoId) {
                        self.runUpdate(currentVideoId);
                    } else if (!currentVideoId) {
                        ButtonManager.resetState();
                        ButtonManager._containerDiv.style.display = 'none';
                        self._processingVideoId = null;
                    }
                }, 500);
            });
        },

        /**
         * Main function to execute the process of fetching data and updating UI.
         * @param {string|null} videoId - The YouTube video ID to process.
         */
        runUpdate: async function(videoId) {
            let ytData = null;

            if (this._processingVideoId === videoId) {
                console.log(`${Config.SHORT_APP_NAME}: Already processing video ID: ${videoId}. Skipping.`);
                return;
            }

            this._processingVideoId = videoId;
            if (!videoId) {
                console.log(`${Config.SHORT_APP_NAME}: Not a YouTube video page. Hiding button.`);
                ButtonManager._containerDiv.style.display = 'none';
                this._processingVideoId = null;
                return;
            }

            console.log(`${Config.SHORT_APP_NAME}: Starting update for video ID: ${videoId}`);
            ButtonManager.resetState();
            const dockElement = await DOMScanner.getButtonAnchorElement();
            ButtonManager.appendToDock(dockElement);

            try {
                ytData = await YouTubeAPI.fetchVideoData(videoId);
                if (!ytData) {
                    ButtonManager.displayInfo("Video Not Found / YT API Error");
                    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);
                }

                const 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(`${Config.SHORT_APP_NAME}: Video already linked on MusicBrainz.`);
                        ButtonManager.displayExistingButton(allRelevantRecordingRelations, mbVideoUrlEntity.id);
                    } else {
                        console.log(`${Config.SHORT_APP_NAME}: URL entity found, but no relevant recording relations. Proceeding to add button.`);
                        ButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, videoId);
                    }
                } else {
                    console.log(`${Config.SHORT_APP_NAME}: YouTube URL not found as a URL entity on MusicBrainz. Preparing add button.`);
                    ButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, videoId);
                }
            } catch (error) {
                console.error(`${Config.SHORT_APP_NAME}: Unhandled error during update for video ID: ${videoId}:`, error);

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

                if (error.status === 503) {
                    ButtonManager.displayError(`${apiName} Rate Limit / Server Error`);
                } else if (error.status === 0) {
                    ButtonManager.displayError(`${apiName} Network Error`);
                }
                else {
                    ButtonManager.displayError("Processing Error");
                }
            } finally {
                this._processingVideoId = null;
            }
        },
    };


    YouTubeMusicBrainzImporter.init();

})();