您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds some convenience features, various UI and behavior settings, as well as an improved language detection to Harmony.
当前为
// ==UserScript== // @name Harmony: Enhancements // @namespace https://musicbrainz.org/user/chaban // @version 1.2.1 // @tag ai-created // @description Adds some convenience features, various UI and behavior settings, as well as an improved language detection to Harmony. // @author chaban // @license MIT // @match https://harmony.pulsewidth.org.uk/* // @connect none // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_info // ==/UserScript== (function() { 'use strict'; const SCRIPT_NAME = GM_info.script.name; const TOOLTIP_DISPLAY_DURATION = 2000; // --- CONFIGURATION --- const SETTINGS_CONFIG = { // Seeder Behavior skipConfirmation: { key: 'enhancements.seeder.skipConfirmation', label: 'Skip MusicBrainz confirmation page when adding a new release', description: 'Automatically skips the interstitial page when seeding data from external pages.', defaultValue: false, section: 'Seeder Behavior', type: 'checkbox' }, updateProperties: { key: 'enhancements.seeder.updateProperties', label: 'Include GTIN and packaging when updating an existing release', description: 'When using the "Update external links in MusicBrainz" button, also include the GTIN (barcode) and set packaging to "None".', defaultValue: false, section: 'Seeder Behavior', type: 'checkbox' }, // UI Settings hideDebugMessages: { key: 'enhancements.ui.hideDebugMessages', label: 'Hide debug messages on release pages', description: 'Hides the boxes containing debug information from Harmony, such as guessed languages and scripts.', defaultValue: false, section: 'UI Settings', type: 'checkbox' }, hideReleaseInfo: { key: 'enhancements.ui.hideReleaseInfo', label: 'Hide Availability, Sources, and External Links sections', description: 'Hides the verbose and redundant release info sections.', defaultValue: false, section: 'UI Settings', type: 'checkbox' }, // Convenience Features clipboardRelookup: { key: 'enhancements.ui.clipboardRelookup', label: `Add 'Re-Lookup with MBID from Clipboard' button`, description: 'Adds a button to the "Release Lookup" page to redo the lookup using a MusicBrainz Release ID found in the clipboard.', defaultValue: true, section: 'Convenience Features', type: 'checkbox' }, actionsRelookup: { key: 'enhancements.ui.actionsRelookup', label: 'Add "Re-Lookup" link on Release Actions page', description: 'Adds a link to re-lookup a release from the Harmony release actions page.', defaultValue: true, section: 'Convenience Features', type: 'checkbox' }, copyTracklist: { key: 'enhancements.ui.copyTracklist', label: `Enable copying tracklist by clicking "Track" header`, description: 'Copies the tracklist in MusicBrainz track parser compatible format to clipboard when clicking on track header', defaultValue: true, section: 'Convenience Features', type: 'checkbox' }, // Language Detection languageDetection: { key: 'enhancements.lang.enabled', label: 'Enable browser-based language detection', description: 'Uses your browser\'s built-in API for a secondary language analysis, which can be more accurate than Harmony\'s default.<br>Only works in <a href="https://developer.mozilla.org/en-US/docs/Web/API/LanguageDetector#browser_compatibility" rel="noopener noreferrer">Chrome >138</a> as of July 2025', defaultValue: true, section: 'Language Detection', type: 'checkbox' }, confidenceThreshold: { key: 'enhancements.lang.confidenceThreshold', label: 'Confidence Threshold', description: 'The minimum confidence level (in percent) required for the browser-detected language to be applied.', defaultValue: 50, section: 'Language Detection', type: 'range' }, conflictThreshold: { key: 'enhancements.lang.conflictThreshold', label: 'Harmony Conflict Threshold', description: 'If Harmony\'s confidence is below this level, this script will overwrite its guess. Otherwise, it will not.', defaultValue: 90, section: 'Language Detection', type: 'range' }, detectSingles: { key: 'enhancements.lang.detectSingles', label: 'Analyze single-track releases', description: 'By default, language detection is skipped for releases with only one track. Enable this to analyze them.', defaultValue: false, section: 'Language Detection', type: 'checkbox' }, ignoreHarmony: { key: 'enhancements.lang.ignoreHarmony', label: `Force overwrite Harmony's guess`, description: 'Always replace Harmony\'s language guess result with the browser-detected result, regardless of confidence scores.', defaultValue: false, section: 'Language Detection', type: 'checkbox' }, stopWords: { key: 'enhancements.lang.stopWords', label: 'Stop Words (one per line)', description: 'These common words will be ignored during language analysis to improve accuracy. Add or remove words as needed.', defaultValue: ['a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'bye', 'for', 'from', 'is', 'it', 'of', 'off', 'on', 'the', 'to', 'was', 'with'], section: 'Language Detection', type: 'textarea' }, techTerms: { key: 'enhancements.lang.techTerms', label: 'Technical Terms (one per line, regex supported)', description: 'Terms that are not specific to any language (like "remix" or "live") will be removed from titles before analysis.', defaultValue: ['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'], section: 'Language Detection', type: 'textarea' }, }; 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':'slv','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'}; const getISO639_3_Code = (code) => ISO_639_1_TO_3_MAP[code] || null; const langDetectState = { code: null, detector: null, apiFailed: false, result: null, }; // --- UTILITY FUNCTIONS --- function log(message, ...args) { console.log(`%c[${SCRIPT_NAME}] %c${message}`, 'color: #337ab7; font-weight: bold;', 'color: unset;', ...args); } function warn(message, ...args) { console.warn(`%c[${SCRIPT_NAME}] %c${message}`, 'color: #f0ad4e; font-weight: bold;', 'color: unset;', ...args); } function error(message, ...args) { console.error(`%c[${SCRIPT_NAME}] %c${message}`, 'color: #d9534f; font-weight: bold;', 'color: unset;', ...args); } async function getSettings() { const settings = {}; for (const config of Object.values(SETTINGS_CONFIG)) { settings[config.key] = await GM_getValue(config.key, config.defaultValue); } return settings; } function showTooltip(message, type, event) { const tooltip = document.createElement('div'); tooltip.textContent = message; tooltip.style.cssText = ` position: fixed; background-color: ${type === 'success' ? '#4CAF50' : '#f44336'}; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; z-index: 10002; opacity: 0; transition: opacity 0.3s; pointer-events: none; white-space: nowrap; `; document.body.appendChild(tooltip); tooltip.style.left = `${event.clientX - (tooltip.offsetWidth / 2)}px`; tooltip.style.top = `${event.clientY - tooltip.offsetHeight - 10}px`; setTimeout(() => { tooltip.style.opacity = '1'; }, 10); setTimeout(() => { tooltip.style.opacity = '0'; tooltip.addEventListener('transitionend', () => tooltip.remove()); }, TOOLTIP_DISPLAY_DURATION); } function showConfirmationModal({ title, message, confirmText = 'Confirm', cancelText = 'Cancel' }) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'harmony-enhancements-modal-overlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; `; const modal = document.createElement('div'); modal.className = 'harmony-enhancements-modal-content'; modal.style.cssText = ` background-color: var(--theme-fill); padding: 20px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); max-width: 400px; text-align: center; `; modal.innerHTML = ` <h3 style="margin-top: 0;">${title}</h3> <p>${message}</p> <div class="harmony-enhancements-modal-actions" style="margin-top: 20px;"> <button class="cancel-button" style="margin-right: 10px; padding: 8px 16px; border-radius: 4px; border: 1px solid #ccc; background-color: #f0f0f0; cursor: pointer;">${cancelText}</button> <button class="confirm-button" style="padding: 8px 16px; border-radius: 4px; border: none; background-color: #f44336; color: white; cursor: pointer;">${confirmText}</button> </div> `; overlay.appendChild(modal); document.body.appendChild(overlay); const close = (value) => { overlay.remove(); resolve(value); }; modal.querySelector('.confirm-button').onclick = () => close(true); modal.querySelector('.cancel-button').onclick = () => close(false); overlay.onclick = (e) => { if (e.target === overlay) close(false); }; }); } // --- SETTINGS PAGE --- function initSettingsPage(settings) { const main = document.querySelector('main'); if (!main || main.querySelector('.harmony-enhancements-settings-container')) return; const sections = Object.values(SETTINGS_CONFIG).reduce((acc, config) => { (acc[config.section] = acc[config.section] || []).push(config); return acc; }, {}); const container = document.createElement('div'); container.className = 'harmony-enhancements-settings-container'; container.style.marginTop = '2em'; for (const [name, configs] of Object.entries(sections)) { const header = document.createElement('div'); header.style.cssText = 'display: flex; justify-content: space-between; align-items: center;'; const h3 = document.createElement('h3'); h3.textContent = name; header.appendChild(h3); if (name === 'Language Detection') { const resetButton = document.createElement('button'); resetButton.textContent = 'Reset Language Settings'; resetButton.className = 'reset-button'; resetButton.style.cssText = ` padding: 4px 8px; font-size: 12px; border-radius: 4px; border: 1px solid #ccc; background-color: #f0f0f0; cursor: pointer; `; resetButton.onclick = async (e) => { e.preventDefault(); const confirmed = await showConfirmationModal({ title: 'Reset Language Settings', message: 'Are you sure you want to reset all language detection settings to their defaults? This cannot be undone.', confirmText: 'Reset' }); if (confirmed) { await resetLanguageSettings(); showTooltip('Language settings have been reset.', 'success', e); } }; header.appendChild(resetButton); } container.appendChild(header); configs.forEach(config => { const wrap = document.createElement('div'); wrap.className = 'row'; wrap.style.alignItems = 'flex-start'; const textContainer = document.createElement('div'); textContainer.style.flex = '1'; const lbl = document.createElement('label'); lbl.htmlFor = config.key; lbl.textContent = config.label; lbl.style.cursor = 'pointer'; lbl.style.display = 'inline-block'; textContainer.appendChild(lbl); let input; let descriptionEl; if (config.description) { descriptionEl = document.createElement('small'); descriptionEl.id = `${config.key}-desc`; descriptionEl.innerHTML = config.description; descriptionEl.style.cssText = `display: block; color: #666; margin-top: 4px;`; textContainer.appendChild(descriptionEl); } switch (config.type) { case 'checkbox': input = document.createElement('input'); input.type = 'checkbox'; input.checked = settings[config.key]; input.style.marginTop = '4px'; wrap.append(input, textContainer); break; case 'range': wrap.style.flexDirection = 'column'; input = document.createElement('input'); input.type = 'range'; input.min = 0; input.max = 100; input.value = settings[config.key]; const val = document.createElement('span'); val.textContent = ` ${settings[config.key]}%`; input.addEventListener('input', () => val.textContent = ` ${input.value}%`); const rangeWrap = document.createElement('div'); rangeWrap.style.display = 'flex'; rangeWrap.style.alignItems = 'center'; rangeWrap.append(input, val); wrap.append(textContainer, rangeWrap); break; case 'textarea': wrap.style.flexDirection = 'column'; input = document.createElement('textarea'); input.rows = 5; input.value = Array.isArray(settings[config.key]) ? settings[config.key].join('\n') : ''; input.style.width = '100%'; input.style.marginTop = '4px'; input.style.color = 'var(--text)'; input.style.backgroundColor = 'var(--input-fill)'; wrap.append(textContainer, input); break; } if (input) { input.id = config.key; if (descriptionEl) { input.setAttribute('aria-describedby', descriptionEl.id); } const save = () => { let value; if (config.type === 'checkbox') value = input.checked; else if (config.type === 'range') value = parseInt(input.value, 10); else if (config.type === 'textarea') value = input.value.split('\n').map(s => s.trim()).filter(Boolean); GM_setValue(config.key, value); }; input.addEventListener('change', save); if (config.type === 'range' || config.type === 'textarea') { input.addEventListener('input', save); } } container.appendChild(wrap); }); } main.appendChild(container); } async function resetLanguageSettings() { const langConfigs = Object.values(SETTINGS_CONFIG).filter(c => c.section === 'Language Detection'); for (const config of langConfigs) { await GM_setValue(config.key, config.defaultValue); const input = document.getElementById(config.key); if (!input) continue; switch (config.type) { case 'checkbox': input.checked = config.defaultValue; break; case 'range': input.value = config.defaultValue; input.nextElementSibling.textContent = ` ${config.defaultValue}%`; break; case 'textarea': input.value = config.defaultValue.join('\n'); break; } } } // --- ENHANCEMENT MODULES --- const enhancements = { removeHardcodedBy: (node) => { if (node && node.firstChild && node.firstChild.nodeType === Node.TEXT_NODE && node.firstChild.textContent.trim().startsWith('by')) { node.firstChild.textContent = node.firstChild.textContent.replace(/^by\s+/, ''); } }, toggleReleaseInfo: (node, enabled) => { if (!enabled) return; const terms = ['Availability', 'Sources', 'External links']; node.querySelectorAll('.release-info > tbody > tr').forEach(row => { const header = row.querySelector('th'); if (header && terms.includes(header.innerText.trim())) { row.style.display = 'none'; } }); }, addClipboardButton: (enabled) => { if (!enabled || document.getElementById('redo-lookup-mbid-button')) return; const lookupBtn = document.querySelector('input[type="submit"][value="Lookup"]'); const container = lookupBtn?.closest('.input-with-overlay'); if (!container) return; const newBtn = document.createElement('input'); newBtn.type = 'submit'; newBtn.value = 'Re-Lookup with MBID from Clipboard'; newBtn.id = 'redo-lookup-mbid-button'; newBtn.className = lookupBtn.className; container.parentElement.insertBefore(newBtn, container.nextSibling); newBtn.addEventListener('click', async (e) => { e.preventDefault(); try { const text = await navigator.clipboard.readText(); const mbid = (text.match(/musicbrainz\.org\/release\/([a-f0-9\-]{36})/i) || [])[1]; if (mbid) { const url = new URL(window.location.href); url.searchParams.set('musicbrainz', mbid); window.location.href = url.toString(); } else { showTooltip('No MBID Found in Clipboard!', 'error', e); } } catch (err) { const message = err.name === 'NotAllowedError' ? 'Clipboard permission denied!' : 'Could not read clipboard!'; showTooltip(message, 'error', e); } }); }, addActionsRelookupLink: (enabled) => { if (!enabled || document.getElementById('relookup-link-container')) return; const h2 = Array.from(document.querySelectorAll('h2')).find(h => h.textContent.includes('Release Actions')); if (!h2) return; const mbid = new URLSearchParams(window.location.search).get('release_mbid'); if (!mbid) return; const params = new URLSearchParams({ musicbrainz: mbid }); document.querySelectorAll('.provider-list li').forEach(item => { const pName = item.getAttribute('data-provider')?.toLowerCase(); const pId = item.querySelector('.provider-id')?.textContent.trim(); if (pName && pId) params.set(pName, pId); }); const url = `/release?${params.toString()}`; const container = document.createElement('div'); container.id = 'relookup-link-container'; container.className = 'message'; container.innerHTML = `<svg class="icon" width="24" height="24" stroke-width="2"><use xlink:href="/icon-sprite.svg#brand-metabrainz"></use></svg><p><a href="${url}">Re-Lookup with Harmony</a></p>`; h2.parentNode.insertBefore(container, h2.nextSibling); }, makeTracklistCopyable: (header, enabled) => { if (!enabled || !header || header.dataset.copyApplied) return; header.dataset.copyApplied = 'true'; header.title = 'Click to copy this tracklist'; header.addEventListener('click', async (e) => { const table = header.closest('table.tracklist'); if (!table) return; const getCleanText = (element, selectorsToRemove) => { if (!element) return ''; const clone = element.cloneNode(true); clone.querySelectorAll(selectorsToRemove.join(',')).forEach(el => el.remove()); return clone.textContent.trim(); }; const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim()); const requiredCols = ['Track', 'Title', 'Artists', 'Length']; const colIndices = Object.fromEntries(requiredCols.map(name => [name, headers.indexOf(name)])); if (Object.values(colIndices).some(i => i === -1)) { showTooltip(`Required columns missing: ${requiredCols.join(', ')}`, 'error', e); return; } const lines = Array.from(table.querySelectorAll('tbody tr')).map(row => { const cells = row.children; const num = cells[colIndices.Track]?.textContent.trim() || ''; const title = getCleanText(cells[colIndices.Title], ['ul.alt-values']); const artist = getCleanText(cells[colIndices.Artists]?.querySelector('.artist-credit'), ['ul.alt-values']); const len = (getCleanText(cells[colIndices.Length], ['ul.alt-values'])).split('.')[0]; return `${num}. ${title}${artist ? ` - ${artist}` : ''} (${len})`; }); if (lines.length > 0) { try { await navigator.clipboard.writeText(lines.join('\n')); showTooltip('Tracklist copied!', 'success', e); header.style.color = 'green'; setTimeout(() => header.style.color = '', TOOLTIP_DISPLAY_DURATION); } catch (err) { showTooltip('Failed to copy tracklist!', 'error', e); header.style.color = 'red'; setTimeout(() => header.style.color = '', TOOLTIP_DISPLAY_DURATION); } } }); }, runLanguageDetection: async (settings) => { if (!settings[SETTINGS_CONFIG.languageDetection.key]) { log('Language detection disabled in settings, skipping.'); return; } if (langDetectState.result !== null) { enhancements.applyLanguageDetectionResult(settings, langDetectState.result); return; } if (!langDetectState.detector && !langDetectState.apiFailed) { if ('LanguageDetector' in window) { try { const nativeDetector = await window.LanguageDetector.create(); langDetectState.detector = (text) => nativeDetector.detect(text); } catch (error) { error('LanguageDetector API failed to initialize.', error); langDetectState.apiFailed = true; } } else { warn('LanguageDetector API not available in this browser.'); langDetectState.apiFailed = true; } } if (langDetectState.apiFailed) { langDetectState.result = { skipped: true, debugInfo: { analyzedText: 'LanguageDetector API not available or failed to load.' } }; enhancements.applyLanguageDetectionResult(settings, langDetectState.result); return; } const scriptElement = document.querySelector('script[id^="__FRSH_STATE_"]'); if (!scriptElement?.textContent) return; const data = JSON.parse(scriptElement.textContent); const releaseData = data.v?.flat().find(prop => prop?.release)?.release; if (!releaseData) return; const { title: releaseTitle, media } = releaseData; const trackTitles = media?.flatMap(m => m.tracklist.map(t => t.title)) || []; const trackCount = media?.reduce((sum, m) => sum + (m.tracklist?.length || 0), 0) || 0; if (trackCount === 1 && !settings[SETTINGS_CONFIG.detectSingles.key]) { langDetectState.result = { skipped: true, debugInfo: { analyzedText: 'Skipped: Single track release detection is disabled.' } }; enhancements.applyLanguageDetectionResult(settings, langDetectState.result); return; } const allTitles = [releaseTitle, ...trackTitles].filter(Boolean); if (allTitles.length === 0) return; const techTerms = settings[SETTINGS_CONFIG.techTerms.key]; const stopWords = new Set(settings[SETTINGS_CONFIG.stopWords.key]); const enclosedRegex = new RegExp(`\\s*(?:\\([^)]*\\b(${techTerms.join('|')})\\b[^)]*\\)|\\[[^\\]]*\\b(${techTerms.join('|')})\\b[^\\]]*\\])`, 'ig'); const trailingRegex = new RegExp(`\\s+[-–]\\s+.*(?:${techTerms.map(t => `\\b${t}\\b`).join('|')}).*`, 'ig'); // --- Multi-stage Filtering Algorithm --- // Stage 1: Initial Cleaning (remove bracketed/hyphenated technical terms) let cleanedTitles = allTitles.map(title => title.replace(enclosedRegex, '').replace(trailingRegex, '').trim() ).filter(Boolean); // Stage 2: Contextual "Core Title" Cleaning (find a common base title) 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) { cleanedTitles = cleanedTitles.map(title => (title.startsWith(coreTitle) && title !== coreTitle) ? coreTitle : title); } // Stage 3: Stop Word Removal (from within titles) const stopWordsRegex = new RegExp(`\\b(${Array.from(stopWords).join('|')})\\b`, 'gi'); let surgicallyCleanedTitles = cleanedTitles.map(title => title.replace(stopWordsRegex, '').replace(/\s{2,}/g, ' ').trim() ); // Stage 4: Whole Title Filtering (remove titles that are now just stop words) const finalFilteredTitles = surgicallyCleanedTitles.filter(title => { if (!title) return false; const normalizedTitle = title.toLowerCase().replace(/[\s.]+/g, ''); return !stopWords.has(normalizedTitle); }); // Stage 5: De-duplication & Analysis const uniqueTitles = [...new Set(finalFilteredTitles)]; const titlesToAnalyze = uniqueTitles.length > 0 ? uniqueTitles : [...new Set(allTitles)]; let textToAnalyze = titlesToAnalyze.join(' . '); if (titlesToAnalyze.length <= 3) { textToAnalyze += ' .'; } if (!textToAnalyze.replaceAll(/\P{Letter}/gu, '')) { langDetectState.result = { languageName: '[No linguistic content]', confidence: 100, languageCode3: 'zxx', isZxx: true, debugInfo: { allResults: [], analyzedText: textToAnalyze } }; } else { const results = await langDetectState.detector(textToAnalyze); if (results.length === 0) return; const final = results[0]; langDetectState.result = { languageName: new Intl.DisplayNames(['en'], { type: 'language' }).of(final.detectedLanguage), confidence: Math.round(final.confidence * 100), languageCode3: getISO639_3_Code(final.detectedLanguage), isZxx: false, skipped: false, debugInfo: { allResults: results, analyzedText: textToAnalyze } }; } enhancements.applyLanguageDetectionResult(settings, langDetectState.result); }, applyLanguageDetectionResult: (settings, result) => { if (!result) return; const container = document.querySelector('div.release'); if (!container) return; document.getElementById('harmony-enhancements-language-analysis')?.remove(); const { languageName, confidence, languageCode3, isZxx, skipped, debugInfo } = result; const confidenceThreshold = settings[SETTINGS_CONFIG.confidenceThreshold.key]; const messageDiv = document.createElement('div'); messageDiv.id = 'harmony-enhancements-language-analysis'; messageDiv.className = 'message debug'; messageDiv.innerHTML = `<svg class="icon" width="24" height="24" stroke-width="2"><use xlink:href="/icon-sprite.svg#bug"></use></svg><div><p></p></div>`; const p = messageDiv.querySelector('p'); if (skipped) { p.textContent = debugInfo.analyzedText; } else { const b = document.createElement('b'); b.textContent = languageName; p.append('Guessed language (LanguageDetector API): ', b, ` (${confidence}% confidence)`); const langRow = Array.from(document.querySelectorAll('.release-info th')).find(th => th.textContent.trim() === 'Language')?.parentElement; let harmonyConfidence = 0; let originalLang = ''; if (langRow) { const originalText = langRow.querySelector('td').textContent.trim(); originalLang = originalText.replace(/\s*\(.*\)/, '').trim(); harmonyConfidence = parseInt((originalText.match(/\((\d+)%\sconfidence\)/) || [])[1] || '0', 10); } const shouldOverwrite = settings[SETTINGS_CONFIG.ignoreHarmony.key] || originalLang.toLowerCase() === languageName.toLowerCase() || harmonyConfidence < settings[SETTINGS_CONFIG.conflictThreshold.key]; if (!shouldOverwrite && originalLang.toLowerCase() !== languageName.toLowerCase() && harmonyConfidence >= settings[SETTINGS_CONFIG.conflictThreshold.key]) { const i = document.createElement('i'); i.textContent = ` - Harmony's confidence (${harmonyConfidence}%) is high and force overwrite is off, no changes applied.`; p.append(i); } else if (confidence < confidenceThreshold) { const i = document.createElement('i'); i.textContent = ` - below ${confidenceThreshold}% threshold, no changes applied.`; p.append(i); } p.append(document.createElement('br'), `Analyzed block: "${debugInfo.analyzedText}"`); } const findInsertionAnchor = () => { 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; if (scriptGuessMsg) return scriptGuessMsg.nextSibling; return container.querySelector('.message'); }; const insertionAnchor = findInsertionAnchor(); const parent = (insertionAnchor?.parentElement || container.querySelector('.message')?.parentElement) || container; parent.insertBefore(messageDiv, insertionAnchor); const updateSeeder = (code) => { langDetectState.code = code; }; const langRow = Array.from(document.querySelectorAll('.release-info th')).find(th => th.textContent.trim() === 'Language')?.parentElement; if (isZxx) { if (langRow) langRow.querySelector('td').textContent = '[No linguistic content]'; updateSeeder('zxx'); return; } if (skipped || confidence < confidenceThreshold) return; const newContent = `${languageName} (${confidence}% confidence)`; if (langRow) { const cell = langRow.querySelector('td'); const originalText = cell.textContent.trim(); const originalLang = originalText.replace(/\s*\(.*\)/, '').trim(); const harmonyConfidence = parseInt((originalText.match(/\((\d+)%\sconfidence\)/) || [])[1] || '0', 10); const shouldOverwrite = settings[SETTINGS_CONFIG.ignoreHarmony.key] || originalLang.toLowerCase() === languageName.toLowerCase() || harmonyConfidence < settings[SETTINGS_CONFIG.conflictThreshold.key]; if (shouldOverwrite && cell.textContent !== newContent) { cell.textContent = newContent; updateSeeder(languageCode3); } } else if (!document.getElementById('harmony-enhancements-language-row')) { const table = document.querySelector('.release-info tbody'); const scriptRow = Array.from(table.querySelectorAll('th')).find(th => th.textContent.trim() === 'Script')?.parentElement; const newRow = table.insertRow(scriptRow ? scriptRow.rowIndex + 1 : -1); newRow.id = 'harmony-enhancements-language-row'; newRow.innerHTML = `<th>Language</th><td>${newContent}</td>`; updateSeeder(languageCode3); } } }; // --- FORM SUBMISSION HANDLER --- function handleSeederFormSubmit(event, settings) { const form = event.target.closest('form'); if (!form) { warn('Event target has no parent form, ignoring.'); return; } let modificationsMade = false; const formName = form.getAttribute('name'); if (formName === 'release-seeder') { const skipConfirmationSetting = settings[SETTINGS_CONFIG.skipConfirmation.key]; if (skipConfirmationSetting) { const actionAlreadyModified = form.action.includes('skip_confirmation=1'); if (!actionAlreadyModified) { try { const url = new URL(form.action); url.searchParams.set('skip_confirmation', '1'); form.action = url.toString(); modificationsMade = true; } catch (e) { error(`Could not parse form action URL.`, e); } } } if (langDetectState.code) { let input = form.querySelector('input[name="language"]'); if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'language'; form.appendChild(input); modificationsMade = true; } if (input.value !== langDetectState.code) { input.value = langDetectState.code; modificationsMade = true; } } } if (formName === 'release-update-seeder') { const updatePropertiesSetting = settings[SETTINGS_CONFIG.updateProperties.key]; if (updatePropertiesSetting) { const append = (name, value) => { if (!value) { return; } let input = form.querySelector(`input[type="hidden"][name="${name}"]`); if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = name; form.appendChild(input); modificationsMade = true; } if (input.value !== value) { input.value = value; modificationsMade = true; } }; const gtin = Array.from(document.querySelectorAll('th')).find(th => th.textContent?.trim() === 'GTIN')?.nextElementSibling?.textContent?.trim(); append('barcode', gtin); append('packaging', 'None'); } } if (modificationsMade) { event.preventDefault(); event.stopPropagation(); setTimeout(() => form.submit(), 10); console.groupEnd(); return false; } } // --- INITIALIZATION AND ROUTING --- function applyEnhancements(node, settings) { if (node.nodeType !== Node.ELEMENT_NODE) return; const applyToNodeAndDescendants = (selector, action) => { if (node.matches(selector)) action(node); node.querySelectorAll(selector).forEach(action); }; applyToNodeAndDescendants('.release-artist', enhancements.removeHardcodedBy); applyToNodeAndDescendants('table.tracklist th', header => { if (header.textContent.trim() === 'Track') { enhancements.makeTracklistCopyable(header, settings[SETTINGS_CONFIG.copyTracklist.key]); } }); if (node === document.body) { enhancements.addClipboardButton(settings[SETTINGS_CONFIG.clipboardRelookup.key]); enhancements.addActionsRelookupLink(settings[SETTINGS_CONFIG.actionsRelookup.key]); enhancements.toggleReleaseInfo(document.body, settings[SETTINGS_CONFIG.hideReleaseInfo.key]); } } function applyGlobalStyles(settings) { const css = ` .release-artist::before { content: "by "; } .release-artist > :first-child { margin-left: 0.25em; } ${settings[SETTINGS_CONFIG.hideDebugMessages.key] ? '.message.debug { display: none !important; }' : ''} th[data-copy-applied] { cursor: pointer; text-decoration: underline; color: #3B82F6; -webkit-user-select: none; user-select: none; transition: color 0.2s; } th[data-copy-applied]:hover { color: #1D4ED8; } `; GM_addStyle(css); } async function main() { const settings = await getSettings(); const path = window.location.pathname; applyGlobalStyles(settings); if (path.startsWith('/settings')) { initSettingsPage(settings); } else if (path.startsWith('/release')) { document.body.addEventListener('submit', (e) => { const form = e.target.closest('form'); if (form && (form.getAttribute('name') === 'release-seeder' || form.getAttribute('name') === 'release-update-seeder')) { handleSeederFormSubmit(e, settings); } }); applyEnhancements(document.body, settings); enhancements.runLanguageDetection(settings); } } main().catch(e => error(`An unhandled error occurred in main execution:`, e)); })();