Harmony: Title Language Detector

Analyzes release/track titles on Harmony, displays the language, and updates the seeder form.

// ==UserScript==
// @name         Harmony: Title Language Detector
// @namespace    https://musicbrainz.org/user/chaban
// @version      1.6.2
// @tag          ai-created
// @description  Analyzes release/track titles on Harmony, displays the language, and updates the seeder form.
// @author       chaban
// @license      MIT
// @match        https://harmony.pulsewidth.org.uk/release*
// @exclude      https://harmony.pulsewidth.org.uk/release/actions*
// @icon         https://harmony.pulsewidth.org.uk/harmony-logo.svg
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_NAME = '[Harmony: Title Language Detector]';
    const RESULT_ID = 'userscript-language-analysis';
    const TABLE_ROW_ID = 'userscript-language-row';

    // --- Configuration Keys ---
    const CONFIG_KEY_CONFIDENCE = 'languageDetector_confidenceThreshold';
    const CONFIG_KEY_CONFLICT = 'languageDetector_conflictThreshold';
    const CONFIG_KEY_DETECT_SINGLES = 'languageDetector_detectSingles';
    const CONFIG_KEY_IGNORE_HARMONY = 'languageDetector_ignoreHarmony';
    const CONFIG_KEY_STOP_WORDS = 'languageDetector_stopWords';
    const CONFIG_KEY_TECH_TERMS = 'languageDetector_techTerms';

    // --- Default Settings ---
    const DEFAULTS = {
        [CONFIG_KEY_CONFIDENCE]: 50,
        [CONFIG_KEY_CONFLICT]: 90,
        [CONFIG_KEY_DETECT_SINGLES]: false,
        [CONFIG_KEY_IGNORE_HARMONY]: false,
        [CONFIG_KEY_STOP_WORDS]: [
            'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'bye', 'for', 'from', 'is', 'it',
            'of', 'off', 'on', 'the', 'to', 'was', 'with'
        ],
        [CONFIG_KEY_TECH_TERMS]: [
            'live', 'remix(es)?', 'edit(ion)?', 'medley', 'mix', 'version(s)?',
            'instrumental', 'album', 'radio', 'single',
            'vocal', 'dub', 'club', 'extended', 'original',
            'acoustic', 'unplugged', 'mono', 'stereo',
            'demo', 'remaster(ed)?', 'f(ea)?t\\.?',
            'sped up', 'slowed', 'chopped', 'screwed', '8d'
        ],
    };

    let detectFunction = null;
    let isApiFailed = false;
    let detectionResult = null;

    /**
     * Maps 2-letter ISO 639-1 language codes to their 3-letter
     * ISO 639-3 equivalent as required by MusicBrainz.
     */
    const ISO_639_1_TO_3_MAP = {
        'aa': 'aar', 'ab': 'abk', 'ae': 'ave', 'af': 'afr', 'ak': 'aka', 'am': 'amh',
        'an': 'arg', 'ar': 'ara', 'as': 'asm', 'av': 'ava', 'ay': 'aym', 'az': 'aze',
        'ba': 'bak', 'be': 'bel', 'bg': 'bul', 'bi': 'bis', 'bm': 'bam', 'bn': 'ben',
        'bo': 'bod', 'br': 'bre', 'bs': 'bos', 'ca': 'cat', 'ce': 'che', 'ch': 'cha',
        'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'cu': 'chu', 'cv': 'chv', 'cy': 'cym',
        'da': 'dan', 'de': 'deu', 'dv': 'div', 'dz': 'dzo', 'ee': 'ewe', 'el': 'ell',
        'en': 'eng', 'eo': 'epo', 'es': 'spa', 'et': 'est', 'eu': 'eus', 'fa': 'fas',
        'ff': 'ful', 'fi': 'fin', 'fj': 'fij', 'fo': 'fao', 'fr': 'fra', 'fy': 'fry',
        'ga': 'gle', 'gd': 'gla', 'gl': 'glg', 'gn': 'grn', 'gu': 'guj', 'gv': 'glv',
        'ha': 'hau', 'he': 'heb', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'ht': 'hat',
        'hu': 'hun', 'hy': 'hye', 'hz': 'her', 'ia': 'ina', 'id': 'ind', 'ie': 'ile',
        'ig': 'ibo', 'ii': 'iii', 'ik': 'ipk', 'io': 'ido', 'is': 'isl', 'it': 'ita',
        'iu': 'iku', 'ja': 'jpn', 'jv': 'jav', 'ka': 'kat', 'kg': 'kon', 'ki': 'kik',
        'kj': 'kua', 'kk': 'kaz', 'kl': 'kal', 'km': 'khm', 'kn': 'kan', 'ko': 'kor',
        'kr': 'kau', 'ks': 'kas', 'ku': 'kur', 'kv': 'kom', 'kw': 'cor', 'ky': 'kir',
        'la': 'lat', 'lb': 'ltz', 'lg': 'lug', 'li': 'lim', 'ln': 'lin', 'lo': 'lao',
        'lt': 'lit', 'lu': 'lub', 'lv': 'lav', 'mg': 'mlg', 'mh': 'mah', 'mi': 'mri',
        'mk': 'mkd', 'ml': 'mal', 'mn': 'mon', 'mr': 'mar', 'ms': 'msa', 'mt': 'mlt',
        'my': 'mya', 'na': 'nau', 'nb': 'nob', 'nd': 'nde', 'ne': 'nep', 'ng': 'ndo',
        'nl': 'nld', 'nn': 'nno', 'no': 'nor', 'nr': 'nbl', 'nv': 'nav', 'ny': 'nya',
        'oc': 'oci', 'oj': 'oji', 'om': 'orm', 'or': 'ori', 'os': 'oss', 'pa': 'pan',
        'pi': 'pli', 'pl': 'pol', 'ps': 'pus', 'pt': 'por', 'qu': 'que', 'rm': 'roh',
        'rn': 'run', 'ro': 'ron', 'ru': 'rus', 'rw': 'kin', 'sa': 'san', 'sc': 'srd',
        'sd': 'snd', 'se': 'sme', 'sg': 'sag', 'si': 'sin', 'sk': 'slk', 'sl': 'slv',
        'sm': 'smo', 'sn': 'sna', 'so': 'som', 'sq': 'sqi', 'sr': 'srp', 'ss': 'ssw',
        'st': 'sot', 'su': 'sun', 'sv': 'swe', 'sw': 'swa', 'ta': 'tam', 'te': 'tel',
        'tg': 'tgk', 'th': 'tha', 'ti': 'tir', 'tk': 'tuk', 'tl': 'tgl', 'tn': 'tsn',
        'to': 'ton', 'tr': 'tur', 'ts': 'tso', 'tt': 'tat', 'tw': 'twi', 'ty': 'tah',
        'ug': 'uig', 'uk': 'ukr', 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie',
        'vo': 'vol', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid', 'yo': 'yor',
        'za': 'zha', 'zh': 'zho', 'zu': 'zul'
    };

    /**
     * Converts a 2-letter ISO 639-1 code to its 3-letter ISO 639-3 counterpart.
     * @param {string} code The 2-letter language code (e.g., "en").
     * @returns {string|null} The corresponding 3-letter code (e.g., "eng") or null.
     */
    function getISO639_3_Code(code) {
        return ISO_639_1_TO_3_MAP[code] || null;
    }

    /**
     * Attempts to initialize the native LanguageDetector API.
     * @returns {Promise<boolean>} True if the detector is ready, false otherwise.
     */
    async function initializeDetector() {
        if (detectFunction || isApiFailed) return !isApiFailed;
        if (!('LanguageDetector' in window)) {
            isApiFailed = true;
            return false;
        }
        try {
            const nativeDetector = await LanguageDetector.create();
            detectFunction = (text) => nativeDetector.detect(text);
            return true;
        } catch (error) {
            console.error(`${SCRIPT_NAME} The LanguageDetector API is available but could not be initialized.`);
            isApiFailed = true;
            return false;
        }
    }

    /**
     * Extracts release and track titles from the page's Fresh state JSON.
     * @param {object} data The parsed JSON data.
     * @returns {{releaseTitle: string, trackTitles: string[], trackCount: number}}
     */
    function extractReleaseInfo(data) {
        const result = { releaseTitle: '', trackTitles: [], trackCount: 0 };
        if (!data?.v || !Array.isArray(data.v)) return result;
        let releaseData = null;
        for (const island of data.v) {
            if (Array.isArray(island)) {
                for (const prop of island) {
                    if (prop && typeof prop === 'object' && prop.release) {
                        releaseData = prop.release;
                        break;
                    }
                }
            }
            if (releaseData) break;
        }
        if (!releaseData) return result;
        result.releaseTitle = releaseData.title || '';
        const allTrackTitles = releaseData.media?.flatMap(m => m.tracklist.map(t => t.title)) || [];
        result.trackTitles = [...new Set(allTrackTitles)].filter(Boolean);
        result.trackCount = releaseData.media?.reduce((sum, medium) => sum + (medium.tracklist?.length || 0), 0) || 0;
        return result;
    }

    /**
     * Finds the best position to insert the script's debug message.
     * It prefers to insert after Harmony's language or script guess messages.
     * @param {HTMLElement} container The main release container element.
     * @returns {Node|null} The node to insert before, or null to append.
     */
    function findInsertionAnchor(container) {
        const messages = container.querySelectorAll('.message.debug');
        let langGuessMsg = null;
        let scriptGuessMsg = null;

        for (const msg of messages) {
            const text = msg.textContent;
            if (text.includes('Guessed language of the titles:')) {
                langGuessMsg = msg;
            } else if (text.includes('Detected scripts of the titles:')) {
                scriptGuessMsg = msg;
            }
        }

        if (langGuessMsg) {
            return langGuessMsg.nextSibling; // Insert after the language guess
        }
        if (scriptGuessMsg) {
            return scriptGuessMsg.nextSibling; // Insert after the script guess
        }

        // Fallback to inserting before the first debug message
        return container.querySelector('.message.debug');
    }

    /**
     * Creates a styled debug message element using safe DOM methods.
     * @param {(string|Node)[]} contentNodes An array of strings or DOM nodes to append.
     * @returns {HTMLElement} The complete message element.
     */
    function createDebugMessage(contentNodes) {
        const messageDiv = document.createElement('div');
        messageDiv.id = RESULT_ID;
        messageDiv.className = 'message debug';
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('class', 'icon');
        svg.setAttribute('width', '24');
        svg.setAttribute('height', '24');
        svg.setAttribute('stroke-width', '2');
        const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
        use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '/icon-sprite.svg#bug');
        svg.appendChild(use);
        const contentDiv = document.createElement('div');
        const p = document.createElement('p');
        p.append(...contentNodes);
        contentDiv.appendChild(p);
        messageDiv.append(svg, contentDiv);
        return messageDiv;
    }

    /**
     * Updates the language value in the release info table and handles conflicts.
     * @param {object} result The full detection result object.
     * @param {HTMLElement} container The main release container element.
     * @returns {boolean} True if a change was made or no change was needed.
     */
    function updateReleaseInfoTable(result, container) {
        const { languageName, confidence } = result;
        const table = document.querySelector('.release-info');
        if (!table) return false;
        let languageRow;
        const headers = table.querySelectorAll('th');
        for (const th of headers) {
            if (th.textContent.trim() === 'Language') {
                languageRow = th.parentElement;
                break;
            }
        }
        const newContent = confidence ? `${languageName} (${confidence}% confidence)` : languageName;
        if (languageRow) {
            const valueCell = languageRow.querySelector('td');
            if (!valueCell) return false;
            const fullOriginalText = valueCell.textContent.trim();
            const baseOriginalLanguage = fullOriginalText.replace(/\s*\(.*\)/, '').trim();
            const harmonyConfidenceMatch = fullOriginalText.match(/\((\d+)%\sconfidence\)/);
            const harmonyConfidence = harmonyConfidenceMatch ? parseInt(harmonyConfidenceMatch[1], 10) : 0;
            const languagesMatch = baseOriginalLanguage.toLowerCase() === languageName.toLowerCase();
            const ignoreHarmony = GM_getValue(CONFIG_KEY_IGNORE_HARMONY, DEFAULTS[CONFIG_KEY_IGNORE_HARMONY]);
            const CONFLICT_THRESHOLD = GM_getValue(CONFIG_KEY_CONFLICT, DEFAULTS[CONFIG_KEY_CONFLICT]);

            if (!ignoreHarmony && !languagesMatch && harmonyConfidence >= CONFLICT_THRESHOLD) {
                const bHarmony = document.createElement('b');
                bHarmony.textContent = baseOriginalLanguage;
                const bUserscript = document.createElement('b');
                bUserscript.textContent = languageName;
                const conflictMessage = createDebugMessage([
                    `High confidence language conflict detected (threshold: ${CONFLICT_THRESHOLD}%). Harmony guessed `,
                    bHarmony, ` (${harmonyConfidence}%), but userscript guessed `,
                    bUserscript, ` (${confidence}%). No changes were applied.`
                ]);
                const insertionAnchor = findInsertionAnchor(container);
                container.insertBefore(conflictMessage, insertionAnchor || container.firstChild);
                return false;
            }
            if (languagesMatch && !valueCell.hasAttribute('data-userscript-overwritten')) {
                valueCell.textContent = newContent;
            } else if (!languagesMatch) {
                const overwrittenSpan = document.createElement('span');
                overwrittenSpan.className = 'label';
                overwrittenSpan.title = `Original value: ${fullOriginalText}`;
                overwrittenSpan.textContent = '(overwritten)';
                valueCell.textContent = '';
                valueCell.append(newContent, ' ', overwrittenSpan);
                valueCell.setAttribute('data-userscript-overwritten', 'true');

                // Also modify Harmony's debug message
                const harmonyMessages = document.querySelectorAll('.message.debug');
                for (const msg of harmonyMessages) {
                    const p = msg.querySelector('p');
                    if (p && p.textContent.includes('Guessed language of the titles:') && !msg.hasAttribute('data-hld-modified')) {
                        const harmonyOverwriteSpan = document.createElement('span');
                        harmonyOverwriteSpan.className = 'label';
                        harmonyOverwriteSpan.style.marginLeft = '0.5rem';
                        harmonyOverwriteSpan.textContent = `(overwritten by userscript)`;
                        p.appendChild(harmonyOverwriteSpan);
                        msg.setAttribute('data-hld-modified', 'true');
                        break;
                    }
                }
            }
        } else {
            if (document.getElementById(TABLE_ROW_ID)) return true;
            const newRow = document.createElement('tr');
            newRow.id = TABLE_ROW_ID;
            const th = document.createElement('th');
            th.textContent = 'Language';
            const td = document.createElement('td');
            td.textContent = newContent;
            newRow.append(th, td);
            let anchorRow;
            for (const th of headers) {
                if (th.textContent.trim() === 'Script') {
                    anchorRow = th.parentElement;
                    break;
                }
            }
            if (anchorRow) {
                table.querySelector('tbody').insertBefore(newRow, anchorRow.nextSibling);
            } else {
                table.querySelector('tbody').appendChild(newRow);
            }
        }
        return true;
    }

    /**
     * Updates the hidden language input in the MusicBrainz seeder form.
     * @param {string} langCode The 3-letter ISO 639-3 language code.
     */
    function updateSeederForm(langCode) {
        const seederForm = document.querySelector('form[name="release-seeder"]');
        if (!seederForm) return;
        let langInput = seederForm.querySelector('input[name="language"]');
        if (!langInput) {
            langInput = document.createElement('input');
            langInput.type = 'hidden';
            langInput.name = 'language';
            seederForm.appendChild(langInput);
        }
        langInput.value = langCode;
    }

    /**
     * Renders the script's output message on the page.
     */
    function updateUIWithResult() {
        if (!detectionResult) return;
        const container = document.querySelector('div.release');
        if (!container) return;
        document.getElementById(RESULT_ID)?.remove();
        const { languageName, confidence, languageCode3, isZxx, skipped, debugInfo } = detectionResult;
        const CONFIDENCE_THRESHOLD = GM_getValue(CONFIG_KEY_CONFIDENCE, DEFAULTS[CONFIG_KEY_CONFIDENCE]);

        let resultElement;
        if (skipped) {
            resultElement = createDebugMessage(['Language detection for single track releases is disabled.']);
        } else {
            // Always show the debug log for non-skipped releases
            const b = document.createElement('b');
            b.textContent = languageName;
            const allGuesses = debugInfo.allResults.map(r => `${new Intl.DisplayNames(['en'], { type: 'language' }).of(r.detectedLanguage)} (${Math.round(r.confidence * 100)}%)`).join(', ');
            const content = ['Guessed language (LanguageDetector API): ', b, ` (${confidence}% confidence)`];

            if (confidence < CONFIDENCE_THRESHOLD) {
                 const i = document.createElement('i');
                 i.textContent = ` - below ${CONFIDENCE_THRESHOLD}% threshold, no changes applied.`;
                 content.push(i);
            }

            content.push(document.createElement('br'), `Analyzed block: "${debugInfo.analyzedText}"`);
            resultElement = createDebugMessage(content);
        }

        const insertionAnchor = findInsertionAnchor(container);
        const messageContainer = container.querySelector('.message')?.parentNode || container.querySelector('div');
        if (messageContainer) {
            messageContainer.insertBefore(resultElement, insertionAnchor);
        } else {
            container.prepend(resultElement);
        }


        // Only act on the result if it meets the threshold
        if (isZxx) {
            updateReleaseInfoTable({ languageName: '[No linguistic content]' }, container);
            updateSeederForm('zxx');
        } else if (!skipped && confidence >= CONFIDENCE_THRESHOLD) {
            const wasUpdated = updateReleaseInfoTable(detectionResult, container);
            if (wasUpdated && languageCode3) {
                updateSeederForm(languageCode3);
            }
        }
    }

    /**
     * A lightweight function that re-applies the script's changes to the DOM.
     * This is designed to be called by the MutationObserver to counter framework re-renders.
     */
    function reapplyChanges() {
        if (detectionResult) {
            const CONFIDENCE_THRESHOLD = GM_getValue(CONFIG_KEY_CONFIDENCE, DEFAULTS[CONFIG_KEY_CONFIDENCE]);
            if (!detectionResult.skipped && detectionResult.confidence >= CONFIDENCE_THRESHOLD) {
                const container = document.querySelector('div.release');
                if (container) {
                    updateReleaseInfoTable(detectionResult, container);
                    if (detectionResult.languageCode3) {
                        updateSeederForm(detectionResult.languageCode3);
                    }
                }
            }
        }
    }

    /**
     * Main processing function. It extracts titles, cleans them using a multi-stage
     * filtering algorithm, and then passes them to the language detector.
     * * --- The Algorithm ---
     * 1.  **Initial Cleaning:** Removes technical terms found inside brackets `[]` or
     * parentheses `()`, as well as terms that follow a hyphen `-`.
     * 2.  **Contextual Cleaning:** Identifies the most common "core" title in a release.
     * If a clear core title is found, it's used to more aggressively clean variations
     * (e.g., "Song Title Remix" becomes "Song Title"). This handles cases where
     * technical terms are appended without standard separators.
     * 3.  **Surgical Stop Word Removal:** Goes through each title and removes common,
     * language-ambiguous English words (like "the", "a", "bye") from *within* them.
     * 4.  **Whole Title Filtering:** A final pass removes any titles that, after all the
     * previous cleaning, are now just a stop word (e.g., "O F F" becomes "off").
     * 5.  **De-duplication & Analysis:** Creates a unique list of the fully cleaned titles
     * and joins them into a single block for the final, high-accuracy analysis.
     */
    async function runAnalysisOnce() {
        const isReady = await initializeDetector();
        if (!isReady) return;

        const scriptElement = document.querySelector('script[id^="__FRSH_STATE_"]');
        if (!scriptElement?.textContent) return;

        try {
            // --- Load settings and build dynamic regexes ---
            const TECHNICAL_TERM_LIST = GM_getValue(CONFIG_KEY_TECH_TERMS, DEFAULTS[CONFIG_KEY_TECH_TERMS]);
            const ENGLISH_STOP_WORDS = new Set(GM_getValue(CONFIG_KEY_STOP_WORDS, DEFAULTS[CONFIG_KEY_STOP_WORDS]));

            const ENCLOSED_TERMS_REGEX = new RegExp(
                '\\s*' + '(?:' + '\\([^)]*\\b(' + TECHNICAL_TERM_LIST.join('|').replace(/\s/g, '\\s+') + ')\\b[^)]*\\)' + '|' + '\\[[^\\]]*\\b(' + TECHNICAL_TERM_LIST.join('|').replace(/\s/g, '\\s+') + ')\\b[^\\]]*\\]' + ')', 'ig'
            );
            const TRAILING_TERMS_REGEX = new RegExp(
                '\\s+[-–]\\s+.*(?:' + TECHNICAL_TERM_LIST.map(term => '\\b' + term.replace(' ', '\\s+') + '\\b').join('|') + ').*', 'ig'
            );

            const data = JSON.parse(scriptElement.textContent);
            const { releaseTitle, trackTitles, trackCount } = extractReleaseInfo(data);

            const isSingle = trackCount === 1;
            const detectSingles = GM_getValue(CONFIG_KEY_DETECT_SINGLES, DEFAULTS[CONFIG_KEY_DETECT_SINGLES]);

            if (isSingle && !detectSingles) {
                detectionResult = { skipped: true, debugInfo: { allResults: [], analyzedText: 'N/A' } };
                updateUIWithResult();
                return;
            }

            const allTitles = [releaseTitle, ...trackTitles].filter(Boolean);
            if (allTitles.length === 0) return;

            // --- Multi-stage Filtering ---
            let cleanedTitles = allTitles.map(title => {
                return title
                    .replace(ENCLOSED_TERMS_REGEX, '')
                    .replace(TRAILING_TERMS_REGEX, '')
                    .trim();
            }).filter(Boolean);

            const titleCounts = new Map();
            cleanedTitles.forEach(title => titleCounts.set(title, (titleCounts.get(title) || 0) + 1));
            let coreTitle = null;
            let maxCount = 0;
            if (titleCounts.size > 1) {
                for (const [title, count] of titleCounts.entries()) {
                    if (count > maxCount) {
                        maxCount = count;
                        coreTitle = title;
                    }
                }
            }
            if (maxCount > 1) {
                const allTermsRegex = new RegExp('\\s*\\b(' + TECHNICAL_TERM_LIST.join('|') + ')\\b', 'ig');
                cleanedTitles = cleanedTitles.map(title => {
                    if (title.startsWith(coreTitle) && title !== coreTitle) {
                        return coreTitle;
                    }
                    return title;
                });
            }

            const filteredTitles = cleanedTitles.map(title => {
                const words = title.split(/\s+/);
                const filteredWords = words.filter(word => !ENGLISH_STOP_WORDS.has(word.toLowerCase()));
                return filteredWords.join(' ');
            }).filter(title => {
                if (!title) return false;
                const normalizedTitle = title.toLowerCase().replace(/[\s.]+/g, '');
                return !ENGLISH_STOP_WORDS.has(normalizedTitle);
            });

            const uniqueTitles = [...new Set(filteredTitles)];
            const titlesToAnalyze = uniqueTitles.length > 0 ? uniqueTitles : [...new Set(allTitles)];

            let textToAnalyze = titlesToAnalyze.join(' . ');
            if (titlesToAnalyze.length <= 3) {
                textToAnalyze += ' .';
            }

            if (!textToAnalyze) return;

            const letters = textToAnalyze.replaceAll(/\P{Letter}/gu, '');
            if (!letters.length) {
                detectionResult = { languageName: '[No linguistic content]', confidence: 100, languageCode3: 'zxx', isZxx: true, debugInfo: { allResults: [], analyzedText: textToAnalyze } };
            } else {
                const detectionResults = await detectFunction(textToAnalyze);
                if (detectionResults.length === 0) return;

                const finalResult = detectionResults[0];

                detectionResult = {
                    languageName: new Intl.DisplayNames(['en'], { type: 'language' }).of(finalResult.detectedLanguage),
                    confidence: Math.round(finalResult.confidence * 100),
                    languageCode3: getISO639_3_Code(finalResult.detectedLanguage),
                    isZxx: false,
                    skipped: false,
                    debugInfo: {
                        allResults: detectionResults,
                        analyzedText: textToAnalyze,
                    }
                };
            }

            if (detectionResult) {
                updateUIWithResult();
            }
        } catch (e) {
            console.error(`${SCRIPT_NAME} Failed to parse page JSON data.`, e);
        }
    }

    // --- Settings Pane ---
    function createSettingsPane() {
        const css = `
            #hld-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 9999; display: flex; align-items: center; justify-content: center; font-family: sans-serif; }
            #hld-settings-modal { background: #fff; padding: 2rem; border-radius: 8px; width: 90%; max-width: 500px; position: relative; color: #333; display: flex; flex-direction: column; max-height: 90vh; }
            #hld-settings-modal h2 { margin-top: 0; border-bottom: 1px solid #ddd; padding-bottom: 0.5rem; flex-shrink: 0; }
            #hld-settings-modal form { overflow-y: auto; padding-right: 1rem; }
            #hld-settings-modal .hld-setting { margin-bottom: 1.5rem; }
            #hld-settings-modal label { display: block; font-weight: bold; margin-bottom: 0.5rem; }
            #hld-settings-modal input[type="range"] { width: 100%; }
            #hld-settings-modal input[type="checkbox"] { margin-right: 0.5rem; }
            #hld-settings-modal textarea { width: 100%; box-sizing: border-box; resize: vertical; font-family: monospace; }
            #hld-settings-modal p { font-size: 0.8rem; color: #666; margin-top: 0.25rem; }
            #hld-settings-buttons { text-align: right; border-top: 1px solid #ddd; padding-top: 1rem; margin-top: 1rem; flex-shrink: 0; }
            #hld-settings-buttons button { padding: 0.5rem 1rem; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; margin-left: 0.5rem; }
            #hld-save-button { background: #4a90e2; color: white; border-color: #4a90e2; }
            #hld-reset-button { background: #e24a4a; color: white; border-color: #e24a4a; }
            #hld-close-button { position: absolute; top: 1rem; right: 1rem; background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #999; }
            .hld-hidden { display: none !important; }
        `;
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);

        const html = `
            <div id="hld-settings-overlay" class="hld-hidden">
                <div id="hld-settings-modal">
                    <button id="hld-close-button">&times;</button>
                    <h2>Language Detector Settings</h2>
                    <form id="hld-settings-form">
                        <div class="hld-setting">
                            <label for="hld-confidence-threshold">Confidence Threshold: <span id="hld-confidence-value">50</span>%</label>
                            <input type="range" id="hld-confidence-threshold" min="0" max="100" value="50">
                            <p>Only apply changes if the confidence is above this value.</p>
                        </div>
                         <div class="hld-setting">
                            <label for="hld-conflict-threshold">Harmony Conflict Threshold: <span id="hld-conflict-value">90</span>%</label>
                            <input type="range" id="hld-conflict-threshold" min="0" max="100" value="90">
                            <p>Block overwriting Harmony's guess if its confidence is above this value (unless forced).</p>
                        </div>
                        <div class="hld-setting">
                            <input type="checkbox" id="hld-detect-singles">
                            <label for="hld-detect-singles" style="display: inline;">Analyze single-track releases</label>
                        </div>
                        <div class="hld-setting">
                            <input type="checkbox" id="hld-ignore-harmony">
                            <label for="hld-ignore-harmony" style="display: inline;">Force overwrite Harmony's guess</label>
                            <p>Always use the script's guess, even if Harmony is highly confident in a different language.</p>
                        </div>
                        <div class="hld-setting">
                            <label for="hld-stop-words">English Stop Words (one per line)</label>
                            <textarea id="hld-stop-words" rows="5"></textarea>
                            <p>Words that will be surgically removed from titles before analysis.</p>
                        </div>
                        <div class="hld-setting">
                            <label for="hld-tech-terms">Technical Terms (one per line, regex supported)</label>
                            <textarea id="hld-tech-terms" rows="5"></textarea>
                            <p>These terms (and their containers like parentheses) will be removed from titles before analysis.</p>
                        </div>
                    </form>
                    <div id="hld-settings-buttons">
                        <button id="hld-reset-button">Reset to Defaults</button>
                        <button id="hld-cancel-button">Cancel</button>
                        <button id="hld-save-button">Save & Reload</button>
                    </div>
                </div>
            </div>
        `;
        document.body.insertAdjacentHTML('beforeend', html);

        // --- Event Listeners ---
        const overlay = document.getElementById('hld-settings-overlay');
        const closeButton = document.getElementById('hld-close-button');
        const cancelButton = document.getElementById('hld-cancel-button');
        const saveButton = document.getElementById('hld-save-button');
        const resetButton = document.getElementById('hld-reset-button');

        const confidenceSlider = document.getElementById('hld-confidence-threshold');
        const confidenceValue = document.getElementById('hld-confidence-value');
        const conflictSlider = document.getElementById('hld-conflict-threshold');
        const conflictValue = document.getElementById('hld-conflict-value');

        const closeSettings = () => overlay.classList.add('hld-hidden');

        closeButton.addEventListener('click', closeSettings);
        cancelButton.addEventListener('click', closeSettings);
        overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSettings(); });

        confidenceSlider.addEventListener('input', () => { confidenceValue.textContent = confidenceSlider.value; });
        conflictSlider.addEventListener('input', () => { conflictValue.textContent = conflictSlider.value; });

        saveButton.addEventListener('click', () => {
            GM_setValue(CONFIG_KEY_CONFIDENCE, parseInt(confidenceSlider.value, 10));
            GM_setValue(CONFIG_KEY_CONFLICT, parseInt(conflictSlider.value, 10));
            GM_setValue(CONFIG_KEY_DETECT_SINGLES, document.getElementById('hld-detect-singles').checked);
            GM_setValue(CONFIG_KEY_IGNORE_HARMONY, document.getElementById('hld-ignore-harmony').checked);

            const stopWords = document.getElementById('hld-stop-words').value.split('\n').map(s => s.trim()).filter(Boolean);
            GM_setValue(CONFIG_KEY_STOP_WORDS, stopWords);

            const techTerms = document.getElementById('hld-tech-terms').value.split('\n').map(s => s.trim()).filter(Boolean);
            GM_setValue(CONFIG_KEY_TECH_TERMS, techTerms);

            closeSettings();
            location.reload();
        });

        resetButton.addEventListener('click', () => {
            if (confirm('Are you sure you want to reset all settings to their defaults?')) {
                Object.keys(DEFAULTS).forEach(key => GM_deleteValue(key));
                closeSettings();
                location.reload();
            }
        });
    }

    function openSettings() {
        // Load current settings into the form
        const confidence = GM_getValue(CONFIG_KEY_CONFIDENCE, DEFAULTS[CONFIG_KEY_CONFIDENCE]);
        const conflict = GM_getValue(CONFIG_KEY_CONFLICT, DEFAULTS[CONFIG_KEY_CONFLICT]);
        const detectSingles = GM_getValue(CONFIG_KEY_DETECT_SINGLES, DEFAULTS[CONFIG_KEY_DETECT_SINGLES]);
        const ignoreHarmony = GM_getValue(CONFIG_KEY_IGNORE_HARMONY, DEFAULTS[CONFIG_KEY_IGNORE_HARMONY]);
        const stopWords = GM_getValue(CONFIG_KEY_STOP_WORDS, DEFAULTS[CONFIG_KEY_STOP_WORDS]);
        const techTerms = GM_getValue(CONFIG_KEY_TECH_TERMS, DEFAULTS[CONFIG_KEY_TECH_TERMS]);

        const confidenceSlider = document.getElementById('hld-confidence-threshold');
        const confidenceValue = document.getElementById('hld-confidence-value');
        const conflictSlider = document.getElementById('hld-conflict-threshold');
        const conflictValue = document.getElementById('hld-conflict-value');

        confidenceSlider.value = confidence;
        confidenceValue.textContent = confidence;
        conflictSlider.value = conflict;
        conflictValue.textContent = conflict;
        document.getElementById('hld-detect-singles').checked = detectSingles;
        document.getElementById('hld-ignore-harmony').checked = ignoreHarmony;
        document.getElementById('hld-stop-words').value = stopWords.join('\n');
        document.getElementById('hld-tech-terms').value = techTerms.join('\n');

        document.getElementById('hld-settings-overlay').classList.remove('hld-hidden');
    }

    // --- Main Execution ---
    function main() {
        const observer = new MutationObserver((mutations, obs) => {
            reapplyChanges();
            obs.disconnect(); // Disconnect after the first re-application
        });

        createSettingsPane();
        runAnalysisOnce().then(() => {
            const seederForm = document.querySelector('form[name="release-seeder"]');
            if (seederForm) {
                observer.observe(seederForm, {
                    childList: true, // Watch for added/removed nodes (like our input)
                });
            }
        });

        GM_registerMenuCommand('Language Detector Settings…', openSettings);
    }

    function onDOMLoaded(callback) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', callback);
        } else {
            callback();
        }
    }

    onDOMLoaded(main);

})();