YouTube: MusicBrainz Importer

Imports YouTube videos to MusicBrainz as a new standalone recording

当前为 2025-10-07 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube: MusicBrainz Importer
// @namespace    https://musicbrainz.org/user/chaban
// @version      2.6.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
// @connect      listenbrainz.org
// @icon         https://www.google.com/s2/favicons?sz=256&domain=youtube.com
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @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',
                // Playlist specific strings
                createPlaylist: 'Create LB Playlist',
                syncPlaylist: 'Sync LB Playlist',
                onLB: 'On LB (Playlist) ✓',
                createPlaylistTitle: 'Create a new ListenBrainz playlist from this video\'s tracklist.',
                syncPlaylistTitle: 'This playlist is marked as [INCOMPLETE] on ListenBrainz. Click to sync with the current tracklist.',
                linkedToPlaylistTitle: 'This video is linked to a ListenBrainz playlist: {title}',
                playlistInProgress: 'Processing...',
                tokenMissing: 'Set LB Token!',
                tokenMissingTitle: 'Click to set your ListenBrainz token',
                tokenMissing: 'Set LB Token!',
                tokenMissingTitle: 'Click to set your ListenBrainz token',
                viewReport: 'View Report',
                viewReportTitle: 'View list of unmatched/unparsed tracks from the video description.',
            },
            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',
                // Playlist specific strings
                createPlaylist: 'LB-Playlist erstellen',
                syncPlaylist: 'LB-Playlist synchronisieren',
                onLB: 'Auf LB (Playlist) ✓',
                createPlaylistTitle: 'Eine neue ListenBrainz-Playlist aus der Trackliste dieses Videos erstellen.',
                syncPlaylistTitle: 'Diese Playlist ist auf ListenBrainz als [INCOMPLETE] markiert. Klicken, um mit der aktuellen Trackliste zu synchronisieren.',
                linkedToPlaylistTitle: 'Dieses Video ist mit einer ListenBrainz-Playlist verknüpft: {title}',
                playlistInProgress: 'Verarbeite...',
                tokenMissing: 'LB-Token setzen!',
                tokenMissingTitle: 'Klicken, um Ihr ListenBrainz-Token festzulegen',
                viewReport: 'Bericht anzeigen',
                viewReportTitle: 'Liste der nicht zugeordneten und nicht verarbeiteten Titel aus der Videobeschreibung anzeigen.',
            }
        },
        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/',
        LISTENBRAINZ_API_ROOT: 'https://api.listenbrainz.org/1/',
        TOKEN_STORAGE_KEY: 'listenbrainz_user_token',
        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
            PLAYLIST_BUTTON: 'playlist-button',
            PLAYLIST_BUTTON_SYNC: 'lb-sync',
        },

        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} )`;

    /**
     * Manages the ListenBrainz user token.
     */
    const TokenManager = {
        _token: null,
        async init() {
            this._token = await GM.getValue(Config.TOKEN_STORAGE_KEY, null);
            GM.registerMenuCommand('Set ListenBrainz Token', () => this.getToken(true));
        },
        getTokenValue() {
            return this._token;
        },
        async getToken(forcePrompt = false) {
            if (!this._token || forcePrompt) {
                const success = await this.setToken();
                if (!success) {
                    return null;
                }
            }
            return this._token;
        },
        async setToken() {
            const token = prompt('Please enter your ListenBrainz User Token:', this._token || '');
            if (token && token.trim()) {
                this._token = token.trim();
                await GM.setValue(Config.TOKEN_STORAGE_KEY, this._token);
                alert('ListenBrainz token saved!');
                return true;
            }
            return false; 
        }
    };

    /**
     * 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 = 15000) {
            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 || null,
                    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 to milliseconds using a single regular expression.
         * Handles durations with date and time parts (e.g., P1DT12H30M5.5S).
         * 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) {
            // This single regex captures the optional Day part, and the optional Time part (which must be preceded by 'T').
            // Groups: 1=Days, 2=Hours, 3=Minutes, 4=Seconds
            const regex = /^P(?:(\d*\.?\d*)D)?(?:T(?:(\d*\.?\d*)H)?(?:(\d*\.?\d*)M)?(?:(\d*\.?\d*)S)?)?$/;

            const matches = str.replace(',', '.').match(regex);

            // If the regex doesn't match, or if it matches but finds no duration components (e.g., input is "P" or "PT"), return NaN.
            if (!matches || matches.slice(1).every(part => part === undefined)) {
                return NaN;
            }

            const days = parseFloat(matches[1] || 0);
            const hours = parseFloat(matches[2] || 0);
            const minutes = parseFloat(matches[3] || 0);
            const seconds = parseFloat(matches[4] || 0);

            const totalSeconds = (days * 86400) + (hours * 3600) + (minutes * 60) + seconds;

            return totalSeconds * 1000;
        },
        /**
         * Parses a block of text for track information using multiple regex patterns.
         * @param {string} text The raw text (e.g., YouTube description).
         * @returns {{parsedTracks: Array<Object>, unparsedLines: Array<string>}}
         */
        parseTracklist: function(text) {
            if (!text) {
                return { parsedTracks: [], unparsedLines: [] };
            }
            const tracklistPatterns = [
                { // Format: StartTime - EndTime Title - Artist
                    regex: /^((?:\d+:)?\d+:\d+)\s*[-–—]\s*(?:\d+:)?\d+\s+(.+?)\s*[-–—]\s*(.+)$/,
                    map: (match) => ({ timestampStr: match[1], title: match[2], artist: match[3] })
                },
                { // Format: Timestamp - Artist - Title
                    regex: /^((?:\d+:)?\d+:\d+)\s*[-–—]\s*(.+?)\s*[-–—]\s*(.+)$/,
                    map: (match) => ({ timestampStr: match[1], artist: match[2], title: match[3] })
                },
                { // Format: Timestamp [Artist] - Title or Timestamp Artist - Title
                    regex: /^((?:\d+:)?\d+:\d+)\s+(?:\[(.+?)\]|(.+?))\s*[-–—]\s*(.+)$/,
                    map: (match) => ({ timestampStr: match[1], artist: match[2] || match[3], title: match[4] })
                },
                { // Format: Artist - Title (Timestamp)
                    regex: /^(.+?)\s*[-–—]\s*(.+?)\s+\(?((\d+:)?\d+:\d+)\)?$/,
                    map: (match) => ({ artist: match[1], title: match[2], timestampStr: match[3] })
                }
            ];

            const lines = text.split('\n').map(line => line.trim()).filter(Boolean);
            const parsedTracks = [];
            const unparsedLines = [];

            for (const line of lines) {
                let matched = false;
                for (const pattern of tracklistPatterns) {
                    const match = line.match(pattern.regex);
                    if (match) {
                        const { timestampStr, artist, title } = pattern.map(match);
                        const timeParts = timestampStr.split(':').map(Number);
                        let timestampSeconds = 0;
                        if (timeParts.length === 2) { // MM:SS
                            timestampSeconds = timeParts[0] * 60 + timeParts[1];
                        } else if (timeParts.length === 3) { // HH:MM:SS
                            timestampSeconds = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
                        }

                        parsedTracks.push({
                            artist: artist.trim(),
                            title: title.trim(),
                            timestamp: timestampStr.trim(),
                            timestampSeconds,
                            originalLine: line
                        });

                        matched = true;
                        break; // Pattern matched, move to the next line
                    }
                }
                if (!matched) {
                    unparsedLines.push(line);
                }
            }
            return { parsedTracks, unparsedLines };
        },

        /**
         * Finds the Longest Common Subsequence (LCS) of two arrays.
         * @param {Array<any>} arr1
         * @param {Array<any>} arr2
         * @returns {Array<any>}
         */
        findLCS: function(arr1, arr2) {
            const m = arr1.length;
            const n = arr2.length;
            const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));

            for (let i = 1; i <= m; i++) {
                for (let j = 1; j <= n; j++) {
                    if (arr1[i - 1] === arr2[j - 1]) {
                        dp[i][j] = 1 + dp[i - 1][j - 1];
                    } else {
                        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                    }
                }
            }

            // Backtrack from dp[m][n] to reconstruct the LCS
            const lcs = [];
            let i = m, j = n;
            while (i > 0 && j > 0) {
                if (arr1[i - 1] === arr2[j - 1]) {
                    lcs.unshift(arr1[i - 1]);
                    i--; j--;
                } else if (dp[i - 1][j] > dp[i][j - 1]) {
                    i--;
                } else {
                    j--;
                }
            }
            return lcs;
        },

        /**
         * Groups a sorted list of deletion indices into consecutive chunks for batching.
         * @param {number[]} indices - A list of indices to delete, sorted in descending order.
         * @returns {Array<{index: number, count: number}>} An array of chunks to delete.
         */
        groupDeletions: function(indices) {
            if (indices.length === 0) {
                return [];
            }

            const groups = [];
            let currentGroup = { index: indices[0], count: 1 };

            for (let i = 1; i < indices.length; i++) {
                if (indices[i] === currentGroup.index - 1) {
                    currentGroup.index = indices[i];
                    currentGroup.count++;
                } else {
                    groups.push(currentGroup);
                    currentGroup = { index: indices[i], count: 1 };
                }
            }
            groups.push(currentGroup);
            return groups;
        }
    };

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

        /**
         * Fetches 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];
                    this._videoDataCache.set(videoId, videoData);
                    return videoData;
                } 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;
        }
    };

    /**
     * Handles all interactions with the ListenBrainz API.
     */
    const rateLimitState = {
        isBlocked: false,
        resetTime: 0,
    };

    const ListenBrainzAPI = {
        _searchCache: new Map(),
        /**
         * Generic helper for making requests to the ListenBrainz API.
         * @param {string} endpoint - The API endpoint path.
         * @param {Object} options - Configuration for the request.
         * @param {string} options.token - The user's ListenBrainz token.
         * @param {string} [options.method='GET'] - The HTTP method.
         * @param {Object|null} [options.body=null] - The JSON body for POST requests.
         * @returns {Promise<Object>} The parsed JSON response.
         */
        async apiRequest(endpoint, { token, method = 'GET', body = null }) {
            if (rateLimitState.isBlocked && Date.now() < rateLimitState.resetTime) {
                const secondsRemaining = Math.ceil((rateLimitState.resetTime - Date.now()) / 1000);
                const errorMessage = `Rate limited. Wait ${secondsRemaining}s.`;
                console.error(`[${GM.info.script.name}] ${errorMessage}`);
                throw new Error(errorMessage);
            }
            rateLimitState.isBlocked = false;

            const url = Config.LISTENBRAINZ_API_ROOT + endpoint;
            const headers = new Headers();
            if (token) headers.append('Authorization', `Token ${token}`);
            if (body) headers.append('Content-Type', 'application/json');

            try {
                const response = await Utils.gmXmlHttpRequest({
                    method,
                    url,
                    headers: Object.fromEntries(headers.entries()),
                    data: body ? JSON.stringify(body) : null,
                }, 'ListenBrainz API');

                const remaining = response.responseHeaders.match(/x-ratelimit-remaining:\s*(\d+)/i);
                const resetIn = response.responseHeaders.match(/x-ratelimit-reset-in:\s*(\d+)/i);

                if (remaining && resetIn && parseInt(remaining[1], 10) === 0) {
                    const resetInMs = parseInt(resetIn[1], 10) * 1000;
                    rateLimitState.isBlocked = true;
                    rateLimitState.resetTime = Date.now() + resetInMs;
                }

                if (response.status === 429) {
                    const retryAfter = response.responseHeaders.match(/retry-after:\s*(\d+)/i) || resetIn;
                    const retryAfterMs = parseInt(retryAfter ? retryAfter[1] : '10', 10) * 1000;
                    rateLimitState.isBlocked = true;
                    rateLimitState.resetTime = Date.now() + retryAfterMs;
                    throw new Error(`Rate limit exceeded. Wait ${retryAfterMs/1000}s.`);
                }

                return response.responseText ? JSON.parse(response.responseText) : {};
            } catch (error) {
                console.error(`[${GM.info.script.name}] ListenBrainz API Error:`, error);
                throw error;
            }
        },

        async searchPlaylists(query) {
            if (this._searchCache.has(query)) {
                return this._searchCache.get(query);
            }
            const token = await TokenManager.getToken();
            if (!token) throw new Error("ListenBrainz token not set.");

            const endpoint = `playlist/search?query=${encodeURIComponent(query)}&count=100`;
            const data = await this.apiRequest(endpoint, { token });
            this._searchCache.set(query, data);
            return data;
        },

        async lookupTrack(artist, title) {
            const endpoint = `metadata/lookup/?artist_name=${encodeURIComponent(artist)}&recording_name=${encodeURIComponent(title)}&metadata=false&inc=artist`;
            const data = await this.apiRequest(endpoint, {});
            return data.recording_mbid ? { title, creator: artist, identifier: `https://musicbrainz.org/recording/${data.recording_mbid}` } : null;
        },

        async createPlaylist(token, title, annotation, tracks, isPublic) {
            const jspf = { playlist: { title, track: tracks, annotation, extension: { "https://musicbrainz.org/doc/jspf#playlist": { public: isPublic } } } };
            return this.apiRequest('playlist/create', { method: 'POST', token, body: jspf });
        },

        async fetchPlaylist(token, mbid) {
            const data = await this.apiRequest(`playlist/${mbid}`, { token });
            return data.playlist;
        },

        async addMetadataToPlaylist(token, mbid, existingPlaylist, description) {
            const jspf = {
                playlist: {
                    title: existingPlaylist.title,
                    annotation: existingPlaylist.annotation || '',
                    extension: {
                        "https://musicbrainz.org/doc/jspf#playlist": {
                            public: existingPlaylist.extension["https://musicbrainz.org/doc/jspf#playlist"].public,
                            additional_metadata: { "youtube_description": description }
                        }
                    }
                }
            };
            return this.apiRequest(`playlist/edit/${mbid}`, { method: 'POST', token, body: jspf });
        },

        async deletePlaylistItems(token, mbid, index, count) {
            if (count === 0) return;
            return this.apiRequest(`playlist/${mbid}/item/delete`, { method: 'POST', token, body: { index, count } });
        },

        async addPlaylistItemAtOffset(token, mbid, offset, tracks) {
            const jspf = { playlist: { track: tracks } };
            return this.apiRequest(`playlist/${mbid}/item/add/${offset}`, { method: 'POST', token, body: jspf });
        },

        /**
         * Edits a playlist's core metadata, including title, annotation, visibility, and description.
         * This function replaces the previous addMetadataToPlaylist.
         * @param {string} token - The user's ListenBrainz token.
         * @param {string} mbid - The MBID of the playlist to edit.
         * @param {Object} details - The metadata to update.
         * @param {string} details.title - The new title.
         * @param {string} details.annotation - The new annotation.
         * @param {boolean} details.isPublic - The public status.
         * @param {string} details.description - The full description to store in additional_metadata.
         */
        async editPlaylistMetadata(token, mbid, { title, annotation, isPublic, description }) {
            const jspf = {
                playlist: {
                    title,
                    annotation: annotation || '',
                    extension: {
                        "https://musicbrainz.org/doc/jspf#playlist": {
                            public: isPublic,
                            additional_metadata: { "youtube_description": description }
                        }
                    }
                }
            };
            return this.apiRequest(`playlist/edit/${mbid}`, { method: 'POST', token, body: jspf });
        },
    };

    /**
     * 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);
                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 RecordingButtonManager = {
        _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';
        }
    };

    /**
     * Manages the UI for the ListenBrainz playlist button.
     */
    const PlaylistButtonManager = {
        _containerDiv: null,
        _currentButton: null,

        init: function () {
            this._containerDiv = document.createElement("div");
            this._containerDiv.setAttribute("class", `holder ${Config.CLASS_NAMES.CONTAINER}`);
            this._containerDiv.style.display = 'none';
        },

        appendToDock: function (dockElement) {
            if (document.body.contains(this._containerDiv)) {
                return;
            }
            if (dockElement) {
                dockElement.appendChild(this._containerDiv);
            }
        },

        _createButton(text, title, className, onClick) {
            const button = document.createElement("button");
            button.type = "button";
            button.title = title;
            button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.PLAYLIST_BUTTON} ${className || ''}`;

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

            if (onClick) {
                button.addEventListener('click', onClick);
            }
            return button;
        },

        _replaceButton(newButton) {
            this._containerDiv.innerHTML = '';
            this._currentButton = newButton;
            this._containerDiv.appendChild(this._currentButton);
            this._containerDiv.style.display = 'flex';
        },

        hide: function() {
            this._containerDiv.style.display = 'none';
        },

        resetState: function () {
            this._containerDiv.innerHTML = '';
            const loadingButton = this._createButton(L10n.getString('loading'), '', '', null);
            loadingButton.disabled = true;
            this._replaceButton(loadingButton);
        },

        setStateTokenNeeded: function(onSuccessCallback) {
            const button = this._createButton(
                L10n.getString('tokenMissing'),
                L10n.getString('tokenMissingTitle'),
                Config.CLASS_NAMES.BUTTON_ERROR,
                async () => {
                    const token = await TokenManager.getToken(true);
                    if (token) {
                        onSuccessCallback();
                    }
                }
            );
            this._replaceButton(button);
        },

        setStateCreate: function (onClick) {
            const button = this._createButton(L10n.getString('createPlaylist'), L10n.getString('createPlaylistTitle'), '', onClick);
            this._replaceButton(button);
        },

        setStateSync: function (title, mbid, onClick) {
            const link = document.createElement('a');
            link.href = `//listenbrainz.org/playlist/${mbid}`;
            link.title = L10n.getString('linkedToPlaylistTitle', { title });
            link.target = '_blank';
            link.style.textDecoration = 'none';
            const buttonExists = this._createButton(L10n.getString('onLB'), L10n.getString('linkedToPlaylistTitle', { title }), Config.CLASS_NAMES.BUTTON_ADDED, null);
            link.appendChild(buttonExists);

            const syncButton = this._createButton(L10n.getString('syncPlaylist'), L10n.getString('syncPlaylistTitle'), Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC, onClick);

            this._containerDiv.innerHTML = '';
            this._containerDiv.appendChild(link);
            this._containerDiv.appendChild(syncButton);
            this._containerDiv.style.display = 'flex';
        },

        setStateExists: function (title, targetUrl) {
            const link = document.createElement('a');
            const uuidRegex = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
            if (uuidRegex.test(targetUrl)) {
                link.href = `//listenbrainz.org/playlist/${targetUrl}`;
            } else {
                link.href = targetUrl.startsWith('http') ? targetUrl : `//${targetUrl}`;
            }

            link.title = L10n.getString('linkedToPlaylistTitle', { title });
            link.target = '_blank';
            link.style.textDecoration = 'none';

            const text = title === 'On LB (Multi)' ? 'On LB (Multi) ✓' : L10n.getString('onLB');
            const button = this._createButton(text, link.title, Config.CLASS_NAMES.BUTTON_ADDED, null);
            link.appendChild(button);
            this._replaceButton(link);
        },

        setStateReport: function(title, mbid, openReportCallback) {
            const link = document.createElement('a');
            link.href = `//listenbrainz.org/playlist/${mbid}`;
            link.title = L10n.getString('linkedToPlaylistTitle', { title });
            link.target = '_blank';
            link.style.textDecoration = 'none';
            const buttonExists = this._createButton(L10n.getString('onLB'), L10n.getString('linkedToPlaylistTitle', { title }), Config.CLASS_NAMES.BUTTON_ADDED, null);
            link.appendChild(buttonExists);

            const reportButton = this._createButton(L10n.getString('viewReport'), L10n.getString('viewReportTitle'), 'lb-report-button', openReportCallback);

            this._containerDiv.innerHTML = '';
            this._containerDiv.appendChild(link);
            this._containerDiv.appendChild(reportButton);
            this._containerDiv.style.display = 'flex';
        },

        setStateInProgress: function(message) {
            const button = this._createButton(message, '', '', null);
            button.disabled = true;
            this._replaceButton(button);
        },

        displayError: function(message) {
            const button = this._createButton(message, '', Config.CLASS_NAMES.BUTTON_ERROR, null);
            button.disabled = true;
            this._replaceButton(button);
        }
    };

    /**
     * High-level logic for creating and syncing ListenBrainz playlists.
     */
    const PlaylistLogic = {
        _generateReportHTML: function(notFoundTracks, unparsedLines, videoTitle) {
            let html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Playlist Import Report: ${videoTitle}</title>
            <style>body{font-family:sans-serif;padding:1em 2em;background-color:#f9f9f9;} h1,h2{border-bottom:1px solid #ccc;padding-bottom:5px;} ul{list-style:none;padding-left:0;} li{margin-bottom:0.8em;padding:0.5em;background-color:white;border:1px solid #ddd;border-radius:4px;} a{text-decoration:none;color:#007bff;font-weight:bold;margin-left:1em;}</style>
            </head><body><h1>Playlist Import Report</h1><h2>${videoTitle}</h2>`;

            if (notFoundTracks.length > 0) {
                html += '<h2>Unmatched Tracks</h2><p>These lines were parsed as tracks but could not be found on MusicBrainz.</p><ul>';
                notFoundTracks.forEach(track => {
                    const mbQuery = `artist:"${track.artist}" AND recording:"${track.title}"`;
                    const mbSearchUrl = `https://musicbrainz.org/search?query=${encodeURIComponent(mbQuery)}&type=recording&method=advanced`;
                    const googleQuery = `"${track.artist}" "${track.title}"`;
                    const googleSearchUrl = `https://www.google.com/search?q=${encodeURIComponent(googleQuery)}&nfpr=1`;
                    html += `<li>${track.originalLine} <a href="${mbSearchUrl}" target="_blank">[Search MB]</a> <a href="${googleSearchUrl}" target="_blank">[Search Google]</a></li>`;
                });
                html += '</ul>';
            }

            if (unparsedLines.length > 0) {
                html += '<h2>Unparsed Lines</h2><p>These lines from the description did not match any track format.</p><ul>';
                unparsedLines.forEach(line => {
                    const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(line)}`;
                    html += `<li>${line} <a href="${searchUrl}" target="_blank">[Search Google]</a></li>`;
                });
                html += '</ul>';
            }

            html += '</body></html>';
            return html;
        },
        async _processTracklist(description, progressCallback) {
            const { parsedTracks, unparsedLines } = Utils.parseTracklist(description);
            parsedTracks.sort((a, b) => a.timestampSeconds - b.timestampSeconds);

            const foundTracks = [];
            const notFoundTracks = [];
            let i = 0;
            for (const track of parsedTracks) {
                if (progressCallback) progressCallback(i, parsedTracks.length);
                try {
                    const result = await ListenBrainzAPI.lookupTrack(track.artist, track.title);
                    if (result) {
                        foundTracks.push(result);
                    } else {
                        notFoundTracks.push(track);
                    }
                } catch (error) {
                    console.error("Error looking up track:", track, error);
                    notFoundTracks.push(track);
                }
                i++;
            }
            return { foundTracks, notFoundTracks, unparsedLines };
        },

        async createPlaylist(ytData, canonicalYtUrl) {
            const token = await TokenManager.getToken();
            if (!token) {
                PlaylistButtonManager.setStateTokenNeeded(() => this.createPlaylist(ytData, canonicalYtUrl));
                return;
            }

            PlaylistButtonManager.setStateInProgress('Processing...');
            try {
                const { foundTracks, notFoundTracks, unparsedLines } = await this._processTracklist(ytData.snippet.description, (current, total) => {
                    PlaylistButtonManager.setStateInProgress(`Looking up: ${current}/${total}`);
                });

                if (foundTracks.length === 0) {
                    PlaylistButtonManager.displayError('No tracks found');
                    return;
                }

                let playlistTitle = ytData.snippet.title;
                if (notFoundTracks.length > 0) {
                    playlistTitle = `[INCOMPLETE] ${playlistTitle}`;
                }

                PlaylistButtonManager.setStateInProgress('Creating...');
                const createResponse = await ListenBrainzAPI.createPlaylist(token, playlistTitle, canonicalYtUrl, foundTracks, true);
                const newMbid = createResponse.playlist_mbid;

                PlaylistButtonManager.setStateInProgress('Storing metadata...');
                await ListenBrainzAPI.editPlaylistMetadata(token, newMbid, {
                    title: playlistTitle,
                    annotation: canonicalYtUrl,
                    isPublic: true,
                    description: ytData.snippet.description
                });

                if (notFoundTracks.length > 0 || unparsedLines.length > 0) {
                    const reportHtml = this._generateReportHTML(notFoundTracks, unparsedLines, ytData.snippet.title);
                    const openReport = () => {
                        const blob = new Blob([reportHtml], { type: 'text/html' });
                        const url = URL.createObjectURL(blob);
                        const reportWindow = window.open(url);
                        if (reportWindow) {
                            reportWindow.addEventListener('unload', () => {
                                URL.revokeObjectURL(url);
                            });
                        } else {
                            alert('Popup blocked! Please allow popups for this site to view the report.');
                        }
                    };
                    PlaylistButtonManager.setStateReport(playlistTitle, newMbid, openReport);
                } else {
                    PlaylistButtonManager.setStateExists(playlistTitle, newMbid);
                }

            } catch (error) {
                PlaylistButtonManager.displayError('Creation Failed');
                console.error("Error creating playlist:", error);
            }
        },

        async syncPlaylist(ytData, canonicalYtUrl, playlistMbid) {
            const token = await TokenManager.getToken();
            if (!token) {
                PlaylistButtonManager.setStateTokenNeeded(() => this.syncPlaylist(ytData, canonicalYtUrl, playlistMbid));
                return;
            }

            PlaylistButtonManager.setStateInProgress('Syncing...');
            try {
                // Step 1: Fetch existing playlist and process new tracklist
                PlaylistButtonManager.setStateInProgress('Fetching data...');
                const existingPlaylist = await ListenBrainzAPI.fetchPlaylist(token, playlistMbid);
                const oldTracks = existingPlaylist.track || [];
                const oldMbids = oldTracks.map(t => t.identifier[0].split('/').pop());

                const { foundTracks: newTracks, notFoundTracks, unparsedLines } = await this._processTracklist(ytData.snippet.description, (current, total) => {
                    PlaylistButtonManager.setStateInProgress(`Looking up: ${current}/${total}`);
                });
                const newMbids = newTracks.map(t => t.identifier.split('/').pop());

                // Steps 2 & 3: Calculate and perform deletions and additions
                PlaylistButtonManager.setStateInProgress('Updating tracks...');
                const lcsMbids = Utils.findLCS(oldMbids, newMbids);
                const lcsMbidsSet = new Set(lcsMbids);

                const indicesToDelete = oldMbids.map((mbid, index) => lcsMbidsSet.has(mbid) ? -1 : index).filter(index => index !== -1);
                indicesToDelete.sort((a, b) => b - a);

                const deleteGroups = Utils.groupDeletions(indicesToDelete);
                for (const group of deleteGroups) {
                    await ListenBrainzAPI.deletePlaylistItems(token, playlistMbid, group.index, group.count);
                }

                const currentServerMbids = oldMbids.filter(mbid => lcsMbidsSet.has(mbid));
                let serverIndex = 0;
                for (let i = 0; i < newMbids.length; i++) {
                    const newMbid = newMbids[i];
                    if (serverIndex < currentServerMbids.length && currentServerMbids[serverIndex] === newMbid) {
                        serverIndex++;
                    } else {
                        const chunkToAdd = [];
                        let lookaheadIndex = i;
                        while (lookaheadIndex < newMbids.length && (serverIndex >= currentServerMbids.length || currentServerMbids[serverIndex] !== newMbids[lookaheadIndex])) {
                            const trackToAdd = newTracks.find(t => t.identifier.endsWith(newMbids[lookaheadIndex]));
                            chunkToAdd.push(trackToAdd);
                            lookaheadIndex++;
                        }
                        if (chunkToAdd.length > 0) {
                            await ListenBrainzAPI.addPlaylistItemAtOffset(token, playlistMbid, i, chunkToAdd);
                            i = lookaheadIndex - 1;
                        }
                    }
                }

                // Step 4: Update Playlist Metadata on the server
                PlaylistButtonManager.setStateInProgress('Updating title...');
                let finalTitle = existingPlaylist.title;
                if (notFoundTracks.length === 0) {
                    finalTitle = existingPlaylist.title.replace(/\[INCOMPLETE\]\s*/, '');
                } else if (!existingPlaylist.title.startsWith('[INCOMPLETE]')) {
                    finalTitle = `[INCOMPLETE] ${existingPlaylist.title}`;
                }

                const isPublic = existingPlaylist.extension["https://musicbrainz.org/doc/jspf#playlist"].public;
                await ListenBrainzAPI.editPlaylistMetadata(token, playlistMbid, {
                    title: finalTitle,
                    annotation: existingPlaylist.annotation,
                    isPublic: isPublic,
                    description: ytData.snippet.description
                });

                if (notFoundTracks.length > 0 || unparsedLines.length > 0) {
                    const reportHtml = this._generateReportHTML(notFoundTracks, unparsedLines, ytData.snippet.title);
                    const openReport = () => {
                        const blob = new Blob([reportHtml], { type: 'text/html' });
                        const url = URL.createObjectURL(blob);
                        const reportWindow = window.open(url);
                        if (reportWindow) {
                            reportWindow.addEventListener('unload', () => {
                                URL.revokeObjectURL(url);
                            });
                        } else {
                            alert('Popup blocked! Please allow popups for this site to view the report.');
                        }
                    };
                    PlaylistButtonManager.setStateReport(finalTitle, playlistMbid, openReport);
                } else {
                    PlaylistButtonManager.setStateExists(finalTitle, playlistMbid);
                }

            } catch (error) {
                PlaylistButtonManager.displayError('Sync Failed');
                console.error("Error syncing playlist:", error);
            }
        },
    };

    /**
     * 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();
            TokenManager.init(); // Initialize token manager
            RecordingButtonManager.init();
            PlaylistButtonManager.init(); // Initialize playlist button manager
            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;
                    }
                    .${Config.CLASS_NAMES.PLAYLIST_BUTTON} {
                        background-color: #eb743b;
                        color: white;
                    }
                    .${Config.CLASS_NAMES.PLAYLIST_BUTTON}:hover {
                        background-color: #d16631;
                    }
                    .${Config.CLASS_NAMES.PLAYLIST_BUTTON}.${Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC} {
                        background-color: #007bff;
                    }
                    .${Config.CLASS_NAMES.PLAYLIST_BUTTON}.${Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC}:hover {
                        background-color: #0069d9;
                    }
                    .${Config.CLASS_NAMES.PLAYLIST_BUTTON}.${Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC}:hover {
                        background-color: #0069d9;
                    }
                    .lb-report-button {
                        background-color: #ffc107 !important;
                        color: black !important;
                    }
                    .lb-report-button:hover {
                        background-color: #e0a800 !important;
                    }
                `;
                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;
            }

            RecordingButtonManager.resetState();
            if (!videoId) {
                RecordingButtonManager._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) {
            const dockElement = await DOMScanner.getButtonAnchorElement();
            RecordingButtonManager.appendToDock(dockElement);
            PlaylistButtonManager.appendToDock(dockElement);

            let ytData = prefetchedYtData;
            if (!ytData) {
                try {
                    ytData = await YouTubeAPI.fetchVideoData(videoId);
                } catch (error) {
                    const apiName = error.apiName || 'API';
                    const errorMessage = error.status === 503 ?
                        L10n.getString('errorApiRateLimit', { apiName }) :
                        L10n.getString('errorApiNetwork', { apiName });
                    RecordingButtonManager.displayError(errorMessage);
                    PlaylistButtonManager.displayError(errorMessage);
                    return;
                }
            }

            if (!ytData) {
                RecordingButtonManager.displayInfo(L10n.getString('errorVideoNotFound'));
                PlaylistButtonManager.hide();
                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;

            // ===== Run Recording Importer Logic and Playlist Logic in Parallel =====
            const recordingPromise = this._handleRecordingImport(ytData, canonicalYtUrl, youtubeChannelUrl, prefetchedMbResults);
            const playlistPromise = this._handlePlaylistLogic(ytData, canonicalYtUrl);

            await Promise.all([recordingPromise, playlistPromise]);
        },

        _handleRecordingImport: async function (ytData, canonicalYtUrl, youtubeChannelUrl, prefetchedMbResults) {
            RecordingButtonManager.resetState();
            let mbResults = prefetchedMbResults;

            try {
                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) {
                        RecordingButtonManager.displayExistingButton(allRelevantRecordingRelations, mbVideoUrlEntity.id, ytData);
                    } else {
                        RecordingButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, ytData.id);
                    }
                } else {
                    RecordingButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, ytData.id);
                }
            } catch (error) {
                console.error(`[${GM.info.script.name}] Error in recording import logic:`, error);
                const apiName = error.apiName || 'API';
                const errorMessage = error.status === 503 ? L10n.getString('errorApiRateLimit', { apiName }) : L10n.getString('errorProcessing');
                RecordingButtonManager.displayError(errorMessage);
            }
        },

        _handlePlaylistLogic: async function (ytData, canonicalYtUrl) {
            PlaylistButtonManager.resetState();

            const { parsedTracks } = Utils.parseTracklist(ytData.snippet.description);
            if (parsedTracks.length === 0) {
                PlaylistButtonManager.hide();
                return;
            }

            if (!TokenManager.getTokenValue()) {
                // Pass a function that re-runs this logic after token is set
                PlaylistButtonManager.setStateTokenNeeded(() => this._handlePlaylistLogic(ytData, canonicalYtUrl));
                return;
            }

            try {
                const searchResults = await ListenBrainzAPI.searchPlaylists(canonicalYtUrl);
                const perfectMatches = (searchResults.playlists || []).filter(p => p.playlist.annotation && p.playlist.annotation.includes(canonicalYtUrl));

                if (perfectMatches.length === 1) {
                    const playlist = perfectMatches[0].playlist;
                    const playlistMbid = playlist.identifier.split('/').pop();
                    const isINCOMPLETE = playlist.title.startsWith('[INCOMPLETE]');

                    if (isINCOMPLETE) {
                        PlaylistButtonManager.setStateSync(playlist.title, playlistMbid, () => {
                            PlaylistLogic.syncPlaylist(ytData, canonicalYtUrl, playlistMbid);
                        });
                    } else {
                        PlaylistButtonManager.setStateExists(playlist.title, playlistMbid);
                    }
                } else if (perfectMatches.length > 1) {
                    // Handle multiple matches case if necessary, for now link to search
                    const searchUrl = `https://listenbrainz.org/search/?search_type=playlist&search_term=${encodeURIComponent(canonicalYtUrl)}`;
                    PlaylistButtonManager.setStateExists('On LB (Multi)', searchUrl);
                } else {
                    PlaylistButtonManager.setStateCreate(() => {
                        PlaylistLogic.createPlaylist(ytData, canonicalYtUrl);
                    });
                }
            } catch (error) {
                console.error(`[${GM.info.script.name}] Error in playlist logic:`, error);
                const apiName = error.apiName || 'API';
                const errorMessage = error.status === 503 ? L10n.getString('errorApiRateLimit', { apiName }) : L10n.getString('errorProcessing');
                PlaylistButtonManager.displayError(errorMessage);
            }
        },
    };


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

        /**
         * A wrapper function to prevent event loops during checkbox synchronization.
         * It ensures the sync flag is always reset, even if an error occurs.
         * @param {Function} action - The function to execute while the guard is active.
         */
        _withSyncGuard: function (action) {
            if (this._isInternalSync) return;

            this._isInternalSync = true;
            try {
                action();
            } finally {
                this._isInternalSync = false;
            }
        },

        /**
         * Gets all 'video' checkboxes associated with external links.
         * @returns {NodeListOf<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);
            });
        },

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

        /**
         * Sets up event listeners for the main and existing individual video checkboxes.
         */
        _setupListeners: function () {
            this._mainVideoCheckbox.addEventListener('change', () => {
                this._withSyncGuard(() => {
                    this._syncMainToIndividual(this._mainVideoCheckbox.checked);
                    console.log(`[${GM.info.script.name}] Main video checkbox toggled by user. Synced to individual checkboxes.`);
                });
            });

            this._getIndividualVideoCheckboxes().forEach(checkbox => {
                checkbox.addEventListener('change', () => {
                    this._withSyncGuard(() => {
                        this._syncIndividualToMain();
                        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 && !checkbox.dataset.mbSyncListenerAdded) {
                                        checkbox.addEventListener('change', () => {
                                            this._withSyncGuard(() => {
                                                this._syncIndividualToMain();
                                                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) {
                                            setCheckboxState(checkbox, true);
                                        }
                                    }
                                });
                            }
                        });
                    }
                });
            });

            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._withSyncGuard(() => {
                if (this._mainVideoCheckbox.checked) {
                    this._syncMainToIndividual(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.`);
                }
            });
            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();
    }

})();