// ==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">×</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);
})();