Discogs Edit Helper

Extracts durations, artists, featuring artists and remixers from track titles and assigns them to the appropriate fields

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Discogs Edit Helper
// @namespace    https://github.com/chr1sx/Discogs-Edit-Helper
// @version      1.2
// @description  Extracts durations, artists, featuring artists and remixers from track titles and assigns them to the appropriate fields
// @author       chr1sx
// @match        https://www.discogs.com/release/edit/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @icon         https://www.google.com/s2/favicons?domain=discogs.com&sz=64
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        INACTIVITY_TIMEOUT_MS: 45 * 1000,
        INFO_TEXT_COLOR: '#28a745',
        THEME_KEY: 'discogs_helper_theme_v2',
        FEAT_REMOVE_KEY: 'discogs_helper_removeFeat',
        REMIX_OPTIONAL_KEY: 'discogs_helper_remix_optional',
        MAX_LOG_MESSAGES: 200,
        RETRY_ATTEMPTS: 4,
        RETRY_DELAY_MS: 140,
        FEATURING_PATTERNS: ['featuring', 'feat', 'ft', 'f/', 'w/'],
        REMIX_PATTERNS: ['remix', 'rmx'],
        REMIX_PATTERNS_OPTIONAL: ['edit', 'mix', 'rework', 'version'],
        REMIX_BY_PATTERNS: ['remixed by', 'remix by', 'rmx by', 'reworked by', 'rework by', 'edited by', 'edit by', 'mixed by', 'mix by', 'version by'],
        ARTIST_SPLITTER_PATTERNS: ['vs', '&', '+', '/']
    };

    const state = {
        logMessages: [],
        hideTimeout: null,
        actionHistory: [],
        isCollapsed: false,
        removeFeatFromTitle: false,
        remixOptionalEnabled: false
    };

    function log(message, type = 'info') {
        const timestamp = new Date().toLocaleTimeString();
        state.logMessages.push({ timestamp, message, type });
        if (state.logMessages.length > CONFIG.MAX_LOG_MESSAGES) {
            state.logMessages = state.logMessages.slice(-CONFIG.MAX_LOG_MESSAGES);
        }
        updatePanelLog();
    }

    function escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    function escapeRegExp(str) {
        return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function setReactValue(element, value) {
        if (!element) return;
        try {
            const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
            nativeInputValueSetter.call(element, value);
            element.dispatchEvent(new Event('input', { bubbles: true }));
            element.dispatchEvent(new Event('change', { bubbles: true }));
            element.focus();
            element.blur();
        } catch (e) {
            log(`Error setting value: ${e.message}`, 'error');
        }
    }

    function updatePanelLog() {
        const logContainer = document.getElementById('log-container');
        if (!logContainer) return;
        const colors = { info: '#9aa0a6', success: '#28a745', warning: '#ffc107', error: '#dc3545' };
        logContainer.innerHTML = state.logMessages
            .slice(-CONFIG.MAX_LOG_MESSAGES)
            .map(entry => `<div style="color: ${colors[entry.type]}; margin: 2px 0;">[${entry.timestamp}] ${escapeHtml(entry.message)}</div>`)
            .join('');
        logContainer.scrollTop = logContainer.scrollHeight;
    }

    function setInfoSingleLine(text, success = true) {
        const infoDiv = document.getElementById('track-info');
        if (!infoDiv) return;
        infoDiv.style.display = 'block';
        infoDiv.style.whiteSpace = 'nowrap';
        infoDiv.style.overflow = 'hidden';
        infoDiv.style.textOverflow = 'ellipsis';
        infoDiv.style.padding = '8px';
        infoDiv.style.borderRadius = '4px';
        infoDiv.style.fontSize = '12px';
        infoDiv.style.textAlign = 'center';
        infoDiv.style.color = CONFIG.INFO_TEXT_COLOR;
        infoDiv.textContent = text;
    }

    function initializeState() {
        try {
            const storedFeat = localStorage.getItem(CONFIG.FEAT_REMOVE_KEY);
            if (storedFeat === '0' || storedFeat === '1') {
                state.removeFeatFromTitle = (storedFeat === '1');
            }
        } catch (e) { }
        try {
            const storedRemixOpt = localStorage.getItem(CONFIG.REMIX_OPTIONAL_KEY);
            if (storedRemixOpt === '0' || storedRemixOpt === '1') {
                state.remixOptionalEnabled = (storedRemixOpt === '1');
            }
        } catch (e) { }
    }

    function cleanupArtistName(str) {
        if (!str) return '';
        let s = String(str).trim();
        s = s.replace(/^[\s\(\[\-:\.]+/, '');
        s = s.replace(/[\s\-\:;,]+$/g, '');
        if ((s.startsWith('(') && s.endsWith(')')) || (s.startsWith('[') && s.endsWith(']'))) {
            s = s.slice(1, -1).trim();
        }
        return s;
    }

    function isAlphaToken(tok) {
        return /^[A-Za-z]+$/.test(tok);
    }

    function buildFeaturingPattern() {
        const alphaAlts = CONFIG.FEATURING_PATTERNS
            .filter(isAlphaToken)
            .map(t => escapeRegExp(t) + '\\.?');
        const nonAlphaAlts = CONFIG.FEATURING_PATTERNS
            .filter(t => !isAlphaToken(t))
            .map(t => escapeRegExp(t));
        const parts = [];
        if (alphaAlts.length) parts.push(`(?<!\\w)(?:${alphaAlts.join('|')})(?!\\w)`);
        if (nonAlphaAlts.length) parts.push(`(?:${nonAlphaAlts.join('|')})`);
        return parts.join('|');
    }

    function buildSplitterCaptureRegex(includeFeaturing = false) {
        const parts = [];
        if (includeFeaturing) parts.push(buildFeaturingPattern());
        for (const s of CONFIG.ARTIST_SPLITTER_PATTERNS) {
            if (isAlphaToken(s)) {
                parts.push(`(?<!\\w)(?:${escapeRegExp(s)}\\.?)(?!\\w)`);
            } else {
                parts.push(`(?:${escapeRegExp(s)})`);
            }
        }
        const pattern = parts.join('|');
        return new RegExp(`\\s*(${pattern})\\s*`, 'i');
    }

    function buildSplitterRegex() {
        const parts = CONFIG.ARTIST_SPLITTER_PATTERNS.map(s => {
            if (isAlphaToken(s)) {
                return `(?<!\\w)(?:${escapeRegExp(s)}\\.?)(?!\\w)`;
            }
            return `(?:${escapeRegExp(s)})`;
        });
        const pattern = parts.join('|');
        return new RegExp(`\\s*(?:${pattern})\\s*`, 'i');
    }

    function findRemoveButtonIn(container) {
        if (!container) return null;
        const selectors = ['button.editable_input_remove', 'button[aria-label="Remove"]', 'button[title="Remove"]'];
        for (const selector of selectors) {
            const button = container.querySelector(selector);
            if (button) return button;
        }
        const icon = container.querySelector('i.icon.icon-times, svg.icon-times');
        if (icon) return icon.closest('button') || icon;
        return null;
    }

    function findRemoveNear(node) {
        if (!node) return null;
        const row = node.closest('tr');
        if (!row) return null;
        const selectors = ['button.editable_input_remove', 'button[aria-label="Remove"]', 'i.icon.icon-times'];
        for (const selector of selectors) {
            const el = row.querySelector(selector);
            if (el) return el.closest('button') || el;
        }
        return null;
    }

    async function clickAddArtistButton(row) {
        return new Promise((resolve) => {
            const artistTd = row.querySelector('td.subform_track_artists');
            const addButton = artistTd?.querySelector('button.add-credit-button');
            if (!addButton) { resolve({ success: false }); return; }
            const before = Array.from(artistTd.querySelectorAll('input[data-type="artist-name"], input.credit-artist-name-input'));
            addButton.click();
            let attempts = 0;
            const maxAttempts = 40;
            const interval = setInterval(() => {
                attempts++;
                const inputs = Array.from(artistTd.querySelectorAll('input[data-type="artist-name"], input.credit-artist-name-input'));
                const newInput = inputs.find(input => !before.includes(input));
                if (newInput) {
                    clearInterval(interval);
                    const artistContainer = newInput.closest('li.editable_item') || newInput.closest('li') || newInput.closest('fieldset') || newInput.parentElement;
                    const removeButton = findRemoveButtonIn(artistContainer) || findRemoveNear(newInput);
                    setTimeout(() => resolve({ success: true, artistInput: newInput, artistContainer, removeButton }), 30);
                    return;
                }
                if (attempts >= maxAttempts) {
                    clearInterval(interval);
                    resolve({ success: false });
                }
            }, 100);
        });
    }

    async function createArtistInputs(row, count) {
        const artistTd = row.querySelector('td.subform_track_artists');
        const addButton = artistTd?.querySelector('button.add-credit-button');
        if (!addButton || count <= 0) return [];
        const before = Array.from(artistTd.querySelectorAll('input[data-type="artist-name"], input.credit-artist-name-input'));
        const beforeCount = before.length;
        for (let i = 0; i < count; i++) try { addButton.click(); } catch (e) { }
        const timeout = 1200;
        const pollInterval = 40;
        const start = Date.now();
        let nowInputs = Array.from(artistTd.querySelectorAll('input[data-type="artist-name"], input.credit-artist-name-input'));
        while (nowInputs.length < beforeCount + count && (Date.now() - start) < timeout) {
            await new Promise(r => setTimeout(r, pollInterval));
            nowInputs = Array.from(artistTd.querySelectorAll('input[data-type="artist-name"], input.credit-artist-name-input'));
        }
        return nowInputs.slice(beforeCount).map(inp => {
            const container = inp.closest('li.editable_item') || inp.closest('li') || inp.closest('fieldset') || inp.parentElement;
            const removeButton = findRemoveButtonIn(container) || findRemoveNear(inp);
            return { artistInput: inp, artistContainer: container, removeButton };
        });
    }

    async function clickAddCreditButton(row) {
        return new Promise((resolve) => {
            const titleTd = row.querySelector('td.subform_track_title');
            if (!titleTd) { log('Title TD not found for track.', 'error'); resolve(false); return; }
            let creditsEditableList = Array.from(titleTd.querySelectorAll('.editable_list')).find(list => {
                const span = list.querySelector('span:not([data-reactid*="track-number"])');
                return span && span.textContent && span.textContent.includes('Credits');
            });
            if (!creditsEditableList) creditsEditableList = Array.from(titleTd.querySelectorAll('.editable_list')).find(list => list.querySelector('button.add-credit-button'));
            let addButton = creditsEditableList?.querySelector('button.add-credit-button');
            if (!addButton) addButton = titleTd.querySelector('button.add-credit-button') || row.querySelector('button.add-credit-button');
            if (!addButton) { log('Could not find Credits editable list.', 'error'); resolve(false); return; }
            const artistInputsInRow = Array.from(row.querySelectorAll('input.credit-artist-name-input'));
            let beforeMax = -1;
            artistInputsInRow.forEach(input => {
                const id = input.id || '';
                const match = id.match(/artist-name-credits-input-(\d+)/);
                if (match) beforeMax = Math.max(beforeMax, parseInt(match[1], 10));
            });
            addButton.click();
            let attempts = 0;
            const maxAttempts = 60;
            const interval = setInterval(() => {
                attempts++;
                const nowArtistInputs = Array.from(row.querySelectorAll('input.credit-artist-name-input'));
                let newMax = beforeMax;
                nowArtistInputs.forEach(input => {
                    const id = input.id || '';
                    const match = id.match(/artist-name-credits-input-(\d+)/);
                    if (match) newMax = Math.max(newMax, parseInt(match[1], 10));
                });
                if (newMax > beforeMax) {
                    const roleSel = `#add-role-input-${newMax}`;
                    const artistSel = `#artist-name-credits-input-${newMax}`;
                    let roleInput = row.querySelector(roleSel);
                    let artistInput = row.querySelector(artistSel);
                    if (!roleInput || !artistInput) {
                        const creditItemsList = titleTd.querySelector('ul.editable_items_list') || row.querySelector('ul.editable_items_list');
                        if (creditItemsList) {
                            const items = Array.from(creditItemsList.querySelectorAll('li.editable_item, li, fieldset'));
                            const candidate = items[items.length - 1];
                            if (candidate) {
                                const li = candidate.closest('li.editable_item') || candidate.closest('li') || candidate;
                                roleInput = roleInput || li.querySelector('input.add-credit-role-input');
                                artistInput = artistInput || li.querySelector('input.credit-artist-name-input');
                                const removeButton = li.querySelector('button.editable_input_remove') || findRemoveNear(li);
                                clearInterval(interval);
                                setTimeout(() => resolve({ roleInput, artistInput, newCreditItem: li, removeButton }), 20);
                                return;
                            }
                        }
                    }
                    if (!roleInput || !artistInput) {
                        const allRoles = Array.from(document.querySelectorAll('input.add-credit-role-input'));
                        const allArtists = Array.from(document.querySelectorAll('input.credit-artist-name-input'));
                        if (allRoles.length && allArtists.length) {
                            roleInput = allRoles[allRoles.length - 1];
                            artistInput = allArtists[allArtists.length - 1];
                        }
                    }
                    const newCreditItem = (roleInput && roleInput.closest('li.editable_item')) || (artistInput && artistInput.closest('li.editable_item')) || (roleInput && roleInput.closest('li')) || (artistInput && artistInput.closest('li')) || null;
                    const removeButton = newCreditItem ? (newCreditItem.querySelector('button.editable_input_remove') || findRemoveNear(newCreditItem)) : (titleTd.querySelector('button.editable_input_remove') || findRemoveNear(row));
                    clearInterval(interval);
                    setTimeout(() => resolve({ roleInput, artistInput, newCreditItem, removeButton }), 20);
                    return;
                }
                const creditItemsList = titleTd.querySelector('ul.editable_items_list') || row.querySelector('ul.editable_items_list');
                if (creditItemsList) {
                    const items = Array.from(creditItemsList.querySelectorAll('li.editable_item, li, fieldset'));
                    if (items.length > 0) {
                        const last = items[items.length - 1];
                        const li = last.closest('li.editable_item') || last.closest('li') || last;
                        const roleInput = li.querySelector('input.add-credit-role-input[aria-label="Add Artist Role"], input.add-credit-role-input');
                        const artistInput = li.querySelector('input.credit-artist-name-input[aria-label="Add Artist"], input.credit-artist-name-input');
                        if (roleInput && artistInput) {
                            clearInterval(interval);
                            const removeButton = li.querySelector('button.editable_input_remove') || findRemoveNear(li);
                            setTimeout(() => resolve({ roleInput, artistInput, newCreditItem: li, removeButton }), 20);
                            return;
                        }
                    }
                }
                const allRoles = Array.from(document.querySelectorAll('input.add-credit-role-input'));
                const allArtists = Array.from(document.querySelectorAll('input.credit-artist-name-input'));
                if (allRoles.length > 0 && allArtists.length > 0) {
                    const roleInput = allRoles[allRoles.length - 1];
                    const artistInput = allArtists[allArtists.length - 1];
                    const newCreditItem = (roleInput && roleInput.closest('li.editable_item')) || (artistInput && artistInput.closest('li.editable_item')) || null;
                    clearInterval(interval);
                    const removeButton = newCreditItem ? (newCreditItem.querySelector('button.editable_input_remove') || findRemoveNear(newCreditItem)) : findRemoveNear(row);
                    setTimeout(() => resolve({ roleInput, artistInput, newCreditItem, removeButton }), 20);
                    return;
                }
                if (attempts >= maxAttempts) {
                    clearInterval(interval);
                    log('Timeout waiting for credit inputs to appear', 'error');
                    resolve(false);
                }
            }, 100);
        });
    }

    async function createCreditItems(row, count) {
        const titleTd = row.querySelector('td.subform_track_title');
        if (!titleTd || count <= 0) return [];
        let addButton = titleTd.querySelector('button.add-credit-button') || row.querySelector('button.add-credit-button');
        if (!addButton) {
            const editableLists = Array.from(titleTd.querySelectorAll('.editable_list'));
            for (const list of editableLists) {
                const btn = list.querySelector('button.add-credit-button');
                if (btn) { addButton = btn; break; }
            }
        }
        if (!addButton) return [];
        const beforeRoles = Array.from(row.querySelectorAll('input.add-credit-role-input'));
        const beforeArtists = Array.from(row.querySelectorAll('input.credit-artist-name-input'));
        const beforeCount = Math.max(beforeRoles.length, beforeArtists.length);
        for (let i = 0; i < count; i++) try { addButton.click(); } catch (e) { }
        const timeout = 1200;
        const pollInterval = 40;
        const start = Date.now();
        let nowRoles = Array.from(row.querySelectorAll('input.add-credit-role-input'));
        let nowArtists = Array.from(row.querySelectorAll('input.credit-artist-name-input'));
        while ((Math.max(nowRoles.length, nowArtists.length) < beforeCount + count) && (Date.now() - start) < timeout) {
            await new Promise(r => setTimeout(r, pollInterval));
            nowRoles = Array.from(row.querySelectorAll('input.add-credit-role-input'));
            nowArtists = Array.from(row.querySelectorAll('input.credit-artist-name-input'));
        }
        const result = [];
        for (let i = 0; i < count; i++) {
            const role = nowRoles[beforeCount + i] || null;
            const artist = nowArtists[beforeCount + i] || null;
            let container = null;
            if (artist) container = artist.closest('li.editable_item') || artist.closest('li') || artist.closest('fieldset');
            if (!container && role) container = role.closest('li.editable_item') || role.closest('li') || role.closest('fieldset');
            const removeButton = container ? (findRemoveButtonIn(container) || findRemoveNear(container)) : null;
            result.push({ roleInput: role, artistInput: artist, newCreditItem: container, removeButton });
        }
        return result;
    }

    async function scanAndExtract() {
        setInfoSingleLine('Processing...');
        await new Promise(r => setTimeout(r, 0));
        log('Starting duration scan...', 'info');
        let trackRows = document.querySelectorAll('tr.track_row');
        if (trackRows.length === 0) trackRows = document.querySelectorAll('tr[class*="track"]');
        if (trackRows.length === 0) {
            log('No track rows found', 'error');
            setInfoSingleLine('No tracks found', false);
            return;
        }
        let processed = 0;
        const changes = [];
        trackRows.forEach((row, index) => {
            const titleInput = row.querySelector('input[data-type="track-title"], input[id*="track-title"]');
            const durationInput = row.querySelector('td.subform_track_duration input, input[aria-label*="duration" i]');
            if (!titleInput || !durationInput) return;
            const title = titleInput.value.trim();
            const match = title.match(/(\d+:\d+)\s*$/);
            if (match) {
                const duration = match[1];
                const newTitle = title.replace(/\s*\d+:\d+\s*$/, '').trim();
                changes.push({
                    titleInput,
                    oldTitle: title,
                    newTitle,
                    durationInput,
                    oldDuration: durationInput.value.trim(),
                    newDuration: duration
                });
                setReactValue(titleInput, newTitle);
                setReactValue(durationInput, duration);
                processed++;
                log(`Track ${index + 1}: Extracted duration "${duration}" and updated title to "${newTitle}"`, 'success');
            }
        });
        if (changes.length > 0) {
            state.actionHistory.push({ type: 'durations', changes });
            updateRevertButton();
        }
        if (processed > 0) {
            const plural = processed > 1 ? 's' : '';
            setInfoSingleLine(`Done! Extracted ${processed} duration${plural}`, true);
            log(`Done! Extracted ${processed} duration${plural}`, 'success');
        } else {
            setInfoSingleLine('No durations found', false);
        }
    }

    async function extractArtists() {
        setInfoSingleLine('Processing...');
        await new Promise(r => setTimeout(r, 0));
        log('Starting artist extraction...', 'info');
        let trackRows = document.querySelectorAll('tr.track_row');
        if (trackRows.length === 0) trackRows = document.querySelectorAll('tr[class*="track"]');
        let processed = 0;
        const changes = [];
        for (let i = 0; i < trackRows.length; i++) {
            const row = trackRows[i];
            const titleInput = row.querySelector('input[data-type="track-title"], input[id*="track-title"]');
            if (!titleInput) continue;
            const title = titleInput.value.trim();

            let match = title.match(/^(.+?)\s+[-–—]\s+(.+)$/);
            if (!match) match = title.match(/^(.+?)\s*[-—]\s*(.+)$/);

            if (!match) continue;
            const artistText = match[1].trim();
            const newTitle = match[2].trim();
            const existingArtistInput = row.querySelector('td.subform_track_artists input[data-type="artist-name"]');
            if (existingArtistInput && existingArtistInput.value.trim()) continue;
            const splitterWithCapture = buildSplitterCaptureRegex(true);
            const rawTokens = artistText.split(splitterWithCapture).map(s => s.trim()).filter(s => s !== '');
            let artistParts = [];
            let separators = [];
            if (rawTokens.length === 1) {
                artistParts = artistText.split(buildSplitterRegex()).map(p => cleanupArtistName(p)).filter(Boolean);
                separators = [];
            } else {
                for (let t = 0; t < rawTokens.length; t++) {
                    if (t % 2 === 0) artistParts.push(cleanupArtistName(rawTokens[t]));
                    else separators.push(rawTokens[t]);
                }
            }
            if (artistParts.length === 0) continue;
            const created = await createArtistInputs(row, artistParts.length);
            if (created.length < artistParts.length) {
                for (let m = created.length; m < artistParts.length; m++) {
                    const res = await clickAddArtistButton(row);
                    if (res.success) created.push({ artistInput: res.artistInput, artistContainer: res.artistContainer, removeButton: res.removeButton });
                }
            }
            let joinInputs = Array.from(row.querySelectorAll('input[placeholder="Join"], input[aria-label="Join"]'));
            for (let idx = 0; idx < artistParts.length; idx++) {
                const part = artistParts[idx] || '';
                const added = created[idx];
                if (!added) { log(`Track ${i + 1}: missing input for "${part}"`, 'warning'); continue; }
                const artistInput = added.artistInput;
                const artistContainer = added.artistContainer;
                const removeButton = added.removeButton;
                const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                setReactValue(artistInput, part);
                if (idx < separators.length) {
                    const sepRaw = separators[idx] || '';
                    const joinValue = sepRaw.trim();
                    let joinInput = joinInputs[idx] || getJoinInputForArtistRow(row, artistInput, artistContainer, idx);
                    if (joinInput) setReactValue(joinInput, joinValue);
                }
                changes.push({
                    titleInput,
                    oldTitle: title,
                    newTitle,
                    artistInput,
                    artistContainer,
                    removeButton,
                    oldArtist: oldArtistValue,
                    newArtist: part
                });
                processed++;
                log(`Track ${i + 1}: Extracted main artist "${part}"`, 'success');
            }
            setReactValue(titleInput, newTitle);
        }
        if (changes.length > 0) {
            state.actionHistory.push({ type: 'artists', changes });
            updateRevertButton();
        }
        if (processed > 0) {
            const plural = processed > 1 ? 's' : '';
            setInfoSingleLine(`Done! Extracted ${processed} artist${plural}`, true);
            log(`Done! Extracted ${processed} artist${plural}`, 'success');
        } else {
            setInfoSingleLine('No artists found', false);
        }
    }

    async function extractFeaturing() {
        setInfoSingleLine('Processing...');
        await new Promise(r => setTimeout(r, 0));
        log('Starting featuring artist extraction...', 'info');
        let trackRows = document.querySelectorAll('tr.track_row');
        if (trackRows.length === 0) trackRows = document.querySelectorAll('tr[class*="track"]');
        let processed = 0;
        const changes = [];
        const featPattern = buildFeaturingPattern();
        const splitterRegex = buildSplitterRegex();
        const containerRegex = /([\(\[\uFF08\uFF3B]\s*(.*?)\s*[\)\]\uFF09\uFF3D])/g;

        const remixByPatternWords = CONFIG.REMIX_BY_PATTERNS.map(p => escapeRegExp(p)).join('|');
        const remixByRegex = new RegExp(`\\b(?:${remixByPatternWords})\\b`, 'i');

        for (let i = 0; i < trackRows.length; i++) {
            const row = trackRows[i];
            const titleInput = row.querySelector('input[data-type="track-title"], input[id*="track-title"]');
            if (!titleInput) continue;
            let originalTitle = titleInput.value.trim();
            let title = originalTitle;
            let found = false;
            let matchedFeatTextForRemoval = '';
            let match;

            while ((match = containerRegex.exec(title)) !== null) {
                const fullBracketedText = match[1];
                const innerText = match[2];

                const featTokenRegex = new RegExp(`(?:${featPattern})\\b`, 'i');
                const featTokenMatch = featTokenRegex.exec(innerText);
                if (!featTokenMatch) continue;

                const featTokenIndex = featTokenMatch.index;
                const rawStart = featTokenIndex + featTokenMatch[0].length;
                let raw = innerText.substring(rawStart).trim();
                if (!raw) continue;

                found = true;
                let adjustedFullBracketReplacement = null;

                const remByBeforeIndex = innerText.search(remixByRegex);
                if (remByBeforeIndex !== -1 && remByBeforeIndex < featTokenIndex) {
                    const remCaptureRegex = new RegExp(`(?:${remixByPatternWords})\\s+(.+?)(?=(?:${featPattern})\\b|$)`, 'i');
                    const remCapture = remCaptureRegex.exec(innerText);
                    if (remCapture && remCapture[0]) {
                        const remPhrase = remCapture[0].trim();
                        adjustedFullBracketReplacement = fullBracketedText.replace(innerText, remPhrase);
                    }
                } else {
                    const remIndexInRaw = raw.search(remixByRegex);
                    if (remIndexInRaw !== -1) {
                        const truncated = raw.substring(0, remIndexInRaw).trim();
                        if (truncated) {
                            raw = truncated;
                            const featRemoveRegex = new RegExp(`(?:${featPattern})\\s*${escapeRegExp(truncated)}`, 'i');
                            let newInner = innerText.replace(featRemoveRegex, '').trim();
                            newInner = newInner.replace(/^[,;:\-\s]+/, '').replace(/[,;:\-\s]+$/, '').replace(/\s{2,}/g, ' ').trim();
                            adjustedFullBracketReplacement = fullBracketedText.replace(innerText, newInner);
                        }
                    }
                }

                if (!raw) {
                    if (adjustedFullBracketReplacement !== null) {
                        const idx = title.indexOf(fullBracketedText);
                        if (idx !== -1) {
                            const newTitleCandidate = title.slice(0, idx) + adjustedFullBracketReplacement + title.slice(idx + fullBracketedText.length);
                            title = newTitleCandidate.replace(/\s{2,}/g, ' ').trim();
                        }
                    }
                    break;
                }

                const parts = raw.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                if (parts.length === 0) {
                    if (adjustedFullBracketReplacement !== null) {
                        const idx = title.indexOf(fullBracketedText);
                        if (idx !== -1) {
                            const newTitleCandidate = title.slice(0, idx) + adjustedFullBracketReplacement + title.slice(idx + fullBracketedText.length);
                            title = newTitleCandidate.replace(/\s{2,}/g, ' ').trim();
                        }
                    }
                    break;
                }

                const createdCredits = await createCreditItems(row, parts.length);
                if (createdCredits.length < parts.length) {
                    for (let k = createdCredits.length; k < parts.length; k++) {
                        const res = await clickAddCreditButton(row);
                        if (res) createdCredits.push({ roleInput: res.roleInput, artistInput: res.artistInput, newCreditItem: res.newCreditItem, removeButton: res.removeButton });
                    }
                }
                for (let k = 0; k < parts.length; k++) {
                    const part = parts[k];
                    const credit = createdCredits[k] || (await clickAddCreditButton(row)) || {};
                    const roleInput = credit.roleInput || null;
                    const artistInput = credit.artistInput || null;
                    const newCreditItem = credit.newCreditItem || null;
                    const removeButton = credit.removeButton || null;
                    const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                    if (roleInput) setReactValue(roleInput, 'Featuring');
                    if (artistInput) setReactValue(artistInput, part);
                    changes.push({
                        titleInput,
                        oldTitle: originalTitle,
                        newTitle: state.removeFeatFromTitle ? title : originalTitle,
                        roleInput,
                        artistInput,
                        role: 'Featuring',
                        artist: part,
                        oldArtist: oldArtistValue,
                        creditItem: newCreditItem,
                        removeButton
                    });
                    processed++;
                    log(`Track ${i + 1}: Extracted featuring artist "${part}"`, 'success');
                }

                if (adjustedFullBracketReplacement !== null) {
                    const idx = title.indexOf(fullBracketedText);
                    if (idx !== -1) {
                        const newTitleCandidate = title.slice(0, idx) + adjustedFullBracketReplacement + title.slice(idx + fullBracketedText.length);
                        title = newTitleCandidate.replace(/\s{2,}/g, ' ').trim();
                    }
                } else {
                    matchedFeatTextForRemoval = fullBracketedText;
                }
                break;
            }

            if (!found) {
                const outsideRegex = new RegExp(`(?:${featPattern})\\s+([^\\(\\)\\[\\]\\-–—,;]+)`, 'i');
                const outsideFeat = title.match(outsideRegex);
                if (outsideFeat) {
                    found = true;
                    let raw = outsideFeat[1].trim();
                    let truncated = raw;
                    const remIndex = raw.search(remixByRegex);
                    if (remIndex !== -1) {
                        truncated = raw.substring(0, remIndex).trim();
                    }
                    if (!truncated) {
                        continue;
                    }
                    const parts = truncated.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                    const createdCredits = await createCreditItems(row, parts.length);
                    if (createdCredits.length < parts.length) {
                        for (let k = createdCredits.length; k < parts.length; k++) {
                            const res = await clickAddCreditButton(row);
                            if (res) createdCredits.push({ roleInput: res.roleInput, artistInput: res.artistInput, newCreditItem: res.newCreditItem, removeButton: res.removeButton });
                        }
                    }
                    for (let k = 0; k < parts.length; k++) {
                        const part = parts[k];
                        const credit = createdCredits[k] || (await clickAddCreditButton(row)) || {};
                        const roleInput = credit.roleInput || null;
                        const artistInput = credit.artistInput || null;
                        const newCreditItem = credit.newCreditItem || null;
                        const removeButton = credit.removeButton || null;
                        const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                        if (roleInput) setReactValue(roleInput, 'Featuring');
                        if (artistInput) setReactValue(artistInput, part);
                        changes.push({
                            titleInput,
                            oldTitle: originalTitle,
                            newTitle: state.removeFeatFromTitle ? title : originalTitle,
                            roleInput,
                            artistInput,
                            role: 'Featuring',
                            artist: part,
                            oldArtist: oldArtistValue,
                            creditItem: newCreditItem,
                            removeButton
                        });
                        processed++;
                        log(`Track ${i + 1}: Extracted featuring artist "${part}"`, 'success');
                    }

                    const fullMatch = outsideFeat[0];
                    if (truncated && fullMatch) {
                        const idx = fullMatch.toLowerCase().indexOf(truncated.toLowerCase());
                        if (idx !== -1) {
                            matchedFeatTextForRemoval = fullMatch.substring(0, idx + truncated.length);
                        } else {
                            matchedFeatTextForRemoval = fullMatch;
                        }
                    } else {
                        matchedFeatTextForRemoval = fullMatch;
                    }
                }
            }

            if (found && state.removeFeatFromTitle) {
                if (matchedFeatTextForRemoval) {
                    const removalRegex = new RegExp(`\\s*${escapeRegExp(matchedFeatTextForRemoval)}\\s*$|\\s*${escapeRegExp(matchedFeatTextForRemoval)}`, 'i');
                    let newTitleCandidate = title.replace(removalRegex, (m, suffixMatch) => suffixMatch ? '' : ' ');
                    newTitleCandidate = newTitleCandidate
                        .replace(/\s{2,}/g, ' ')
                        .replace(/\s*([,;:])\s*/g, '$1 ')
                        .replace(/\s*([—\-])\s*/g, ' $1 ')
                        .replace(/\s*([\)\]])\s*/g, '$1')
                        .replace(/([\(\[])\s*/g, '$1')
                        .replace(/[\(\[]\s*[\)\]]/g, '')
                        .trim();
                    newTitleCandidate = newTitleCandidate.replace(/\s+([.,!?;:—\-])/g, '$1');
                    setReactValue(titleInput, newTitleCandidate);
                }
            }
        }

        if (changes.length > 0) {
            state.actionHistory.push({ type: 'featuring', changes });
            updateRevertButton();
        }
        if (processed > 0) {
            const plural = processed > 1 ? 's' : '';
            setInfoSingleLine(`Done! Extracted ${processed} feat. artist${plural}`, true);
            log(`Done! Extracted ${processed} feat. artist${plural}`, 'success');
        } else {
            setInfoSingleLine('No featuring artists found', false);
        }
    }

    function getActiveRemixTokens() {
        if (state.remixOptionalEnabled) {
            return CONFIG.REMIX_PATTERNS.concat(CONFIG.REMIX_PATTERNS_OPTIONAL);
        }
        return CONFIG.REMIX_PATTERNS.slice();
    }

    function updateRemixToggleUI() {
        const toggle = document.getElementById('toggle-remix-optional');
        if (!toggle) return;
        toggle.textContent = state.remixOptionalEnabled ? '✓' : '';
        toggle.title = `Optional Keywords: ${CONFIG.REMIX_PATTERNS_OPTIONAL.join(', ')}`;
        updateRemixButtonTitle();
    }

    function updateRemixButtonTitle() {
        const remixBtn = document.getElementById('extract-remixers');
        if (!remixBtn) return;
        let remixKeywords = `Keywords: ${CONFIG.REMIX_PATTERNS.join(', ')}\nKeywords: ${CONFIG.REMIX_BY_PATTERNS.join(', ')}`;
        if (state.remixOptionalEnabled) {
            remixKeywords += `\nOptional Keywords: ${CONFIG.REMIX_PATTERNS_OPTIONAL.join(', ')}`;
        }
        remixBtn.title = remixKeywords;
    }

    function hasSplitterToken(str) {
        if (!str) return false;
        for (const s of CONFIG.ARTIST_SPLITTER_PATTERNS) {
            const re = new RegExp(escapeRegExp(s), 'i');
            if (re.test(str)) return true;
        }
        return false;
    }

    function lastWordsCandidate(str) {
        if (!str) return '';
        const words = str.trim().split(/\s+/);
        if (words.length === 0) return '';
        if (words.length === 1) return words[0];
        return words.slice(-2).join(' ');
    }

    async function extractRemixers() {
        setInfoSingleLine('Processing...');
        await new Promise(r => setTimeout(r, 0));
        log('Starting remixer extraction...', 'info');

        const remixPatternWords = getActiveRemixTokens().map(p => escapeRegExp(p)).join('|');
        const remixByPatternWords = CONFIG.REMIX_BY_PATTERNS.map(p => escapeRegExp(p)).join('|');
        const splitterRegex = buildSplitterRegex();
        const remixAnyPattern = [remixPatternWords, remixByPatternWords].filter(Boolean).join('|');
        const remixAnyRegex = remixAnyPattern ? new RegExp(`\\b(?:${remixAnyPattern})\\b`, 'i') : null;

        let trackRows = document.querySelectorAll('tr.track_row');
        if (trackRows.length === 0) trackRows = document.querySelectorAll('tr[class*="track"]');

        let processed = 0;
        const changes = [];

        for (let i = 0; i < trackRows.length; i++) {
            const row = trackRows[i];
            const titleInput = row.querySelector('input[data-type="track-title"], input[id*="track-title"]');
            if (!titleInput) continue;
            const title = titleInput.value.trim();

            const containerRegex = /[\(\[]([^\])]+)[\)\]]/g;
            let matchInContainer;
            let handled = false;

            while ((matchInContainer = containerRegex.exec(title)) !== null) {
                const inner = matchInContainer[1].trim();

                if (remixByPatternWords) {
                    const remByRegex = new RegExp(`(?:${remixByPatternWords})\\s+(.+)$`, 'i');
                    const remByMatch = inner.match(remByRegex);
                    if (remByMatch && remByMatch[1]) {
                        let raw = remByMatch[1].trim();

                        const featTokens = CONFIG.FEATURING_PATTERNS.map(escapeRegExp).join('|');
                        const featRegex = new RegExp(`(?:${featTokens})`, 'i');
                        const featMatch = featRegex.exec(raw);

                        let remixes = [];
                        if (featMatch) {
                            const featIndex = featMatch.index;
                            const beforeFeat = raw.substring(0, featIndex).trim();
                            if (hasSplitterToken(beforeFeat)) {
                                remixes = beforeFeat.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                            } else {
                                const first = cleanupArtistName(beforeFeat);
                                if (first) remixes = [first];
                            }
                        } else {
                            remixes = raw.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                        }

                        if (remixes.length === 0) break;

                        const createdCredits = await createCreditItems(row, remixes.length);
                        if (createdCredits.length < remixes.length) {
                            for (let k = createdCredits.length; k < remixes.length; k++) {
                                const res = await clickAddCreditButton(row);
                                if (res) createdCredits.push({ roleInput: res.roleInput, artistInput: res.artistInput, newCreditItem: res.newCreditItem, removeButton: res.removeButton });
                            }
                        }

                        for (let k = 0; k < remixes.length; k++) {
                            const part = remixes[k];
                            const credit = createdCredits[k] || (await clickAddCreditButton(row)) || {};
                            const roleInput = credit.roleInput || null;
                            const artistInput = credit.artistInput || null;
                            const newCreditItem = credit.newCreditItem || null;
                            const removeButton = credit.removeButton || null;
                            const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                            if (roleInput) setReactValue(roleInput, 'Remix');
                            if (artistInput) setReactValue(artistInput, part);
                            changes.push({
                                titleInput,
                                oldTitle: title,
                                newTitle: title,
                                roleInput,
                                artistInput,
                                role: 'Remix',
                                artist: part,
                                oldArtist: oldArtistValue,
                                creditItem: newCreditItem,
                                removeButton
                            });
                            processed++;
                            log(`Track ${i + 1}: Extracted remixer "${part}" (Remix)`, 'success');
                        }

                        handled = true;
                        break;
                    }
                }

                if (remixAnyRegex && remixAnyRegex.test(inner)) {
                    const remMatch = inner.match(remixAnyRegex);
                    if (!remMatch) continue;
                    const remIndex = remMatch.index;
                    const beforeRemix = inner.substring(0, remIndex).trim();
                    if (!beforeRemix) continue;

                    const featTokens = CONFIG.FEATURING_PATTERNS.map(escapeRegExp).join('|');
                    const featRegexGlobal = new RegExp(`(?:${featTokens})`, 'ig');
                    let lastFeatMatch = null;
                    let fm;
                    while ((fm = featRegexGlobal.exec(beforeRemix)) !== null) { lastFeatMatch = fm; }

                    let remixes = [];

                    if (lastFeatMatch) {
                        const lastFeatIndex = lastFeatMatch.index;
                        const featToken = lastFeatMatch[0];
                        const afterFeat = beforeRemix.substring(lastFeatIndex + featToken.length).trim();
                        if (afterFeat) {
                            if (hasSplitterToken(afterFeat)) {
                                const parts = afterFeat.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                                if (parts.length) remixes = [parts[parts.length - 1]];
                            } else {
                                const cand = lastWordsCandidate(afterFeat);
                                if (cand) remixes = [cleanupArtistName(cand)];
                            }
                        } else {
                            const beforeFeatOnly = beforeRemix.substring(0, lastFeatIndex).trim();
                            if (hasSplitterToken(beforeFeatOnly)) {
                                const parts = beforeFeatOnly.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                                if (parts.length) remixes = [parts[0]];
                            } else {
                                const lastCand = lastWordsCandidate(beforeFeatOnly);
                                if (lastCand) remixes = [cleanupArtistName(lastCand)];
                            }
                        }
                    } else {
                        if (hasSplitterToken(beforeRemix)) {
                            remixes = beforeRemix.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                        } else {
                            remixes = [cleanupArtistName(beforeRemix)];
                        }
                    }

                    if (remixes.length === 0) continue;

                    const createdCredits = await createCreditItems(row, remixes.length);
                    if (createdCredits.length < remixes.length) {
                        for (let k = createdCredits.length; k < remixes.length; k++) {
                            const res = await clickAddCreditButton(row);
                            if (res) createdCredits.push({ roleInput: res.roleInput, artistInput: res.artistInput, newCreditItem: res.newCreditItem, removeButton: res.removeButton });
                        }
                    }

                    for (let k = 0; k < remixes.length; k++) {
                        const part = remixes[k];
                        const credit = createdCredits[k] || (await clickAddCreditButton(row)) || {};
                        const roleInput = credit.roleInput || null;
                        const artistInput = credit.artistInput || null;
                        const newCreditItem = credit.newCreditItem || null;
                        const removeButton = credit.removeButton || null;
                        const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                        if (roleInput) setReactValue(roleInput, 'Remix');
                        if (artistInput) setReactValue(artistInput, part);
                        changes.push({
                            titleInput,
                            oldTitle: title,
                            newTitle: title,
                            roleInput,
                            artistInput,
                            role: 'Remix',
                            artist: part,
                            oldArtist: oldArtistValue,
                            creditItem: newCreditItem,
                            removeButton
                        });
                        processed++;
                        log(`Track ${i + 1}: Extracted remixer "${part}" (Remix)`, 'success');
                    }

                    handled = true;
                    break;
                }
            }

            if (handled) continue;

            if (remixByPatternWords) {
                const remByOutRegex = new RegExp(`(?:${remixByPatternWords})\\s+([^\\(\\)\\[\\]\\-–—,;]+)`, 'i');
                const remByOutMatch = title.match(remByOutRegex);
                if (remByOutMatch && remByOutMatch[1]) {
                    let raw = remByOutMatch[1].trim();
                    const featTokens = CONFIG.FEATURING_PATTERNS.map(escapeRegExp).join('|');
                    const featRegex = new RegExp(`(?:${featTokens})`, 'i');
                    const featMatch = featRegex.exec(raw);

                    let remixes = [];
                    if (featMatch) {
                        const featIndex = featMatch.index;
                        const beforeFeat = raw.substring(0, featIndex).trim();
                        if (hasSplitterToken(beforeFeat)) remixes = beforeFeat.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                        else {
                            const first = cleanupArtistName(beforeFeat);
                            if (first) remixes = [first];
                        }
                    } else {
                        remixes = raw.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                    }

                    if (remixes.length === 0) continue;
                    const createdCredits = await createCreditItems(row, remixes.length);
                    if (createdCredits.length < remixes.length) {
                        for (let k = createdCredits.length; k < remixes.length; k++) {
                            const res = await clickAddCreditButton(row);
                            if (res) createdCredits.push({ roleInput: res.roleInput, artistInput: res.artistInput, newCreditItem: res.newCreditItem, removeButton: res.removeButton });
                        }
                    }
                    for (let k = 0; k < remixes.length; k++) {
                        const part = remixes[k];
                        const credit = createdCredits[k] || (await clickAddCreditButton(row)) || {};
                        const roleInput = credit.roleInput || null;
                        const artistInput = credit.artistInput || null;
                        const newCreditItem = credit.newCreditItem || null;
                        const removeButton = credit.removeButton || null;
                        const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                        if (roleInput) setReactValue(roleInput, 'Remix');
                        if (artistInput) setReactValue(artistInput, part);
                        changes.push({
                            titleInput,
                            oldTitle: title,
                            newTitle: title,
                            roleInput,
                            artistInput,
                            role: 'Remix',
                            artist: part,
                            oldArtist: oldArtistValue,
                            creditItem: newCreditItem,
                            removeButton
                        });
                        processed++;
                        log(`Track ${i + 1}: Extracted remixer "${part}" (Remix)`, 'success');
                    }
                    continue;
                }
            }

            if (remixAnyRegex) {
                const remEndRegex = new RegExp(`([^\\(\\)\\[\\]\\-–—,;]+?)\\s+(?:${remixPatternWords})\\b\\s*$`, 'i');
                const remEndMatch = title.match(remEndRegex);
                if (remEndMatch && remEndMatch[1]) {
                    let raw = remEndMatch[1].trim();
                    const featTokens = CONFIG.FEATURING_PATTERNS.map(escapeRegExp).join('|');
                    const featRegex = new RegExp(`(?:${featTokens})`, 'i');
                    const featMatch = featRegex.exec(raw);

                    let remixes = [];
                    if (featMatch) {
                        const featIndex = featMatch.index;
                        const afterFeat = raw.substring(featIndex + featMatch[0].length).trim();
                        if (afterFeat) {
                            if (hasSplitterToken(afterFeat)) {
                                const parts = afterFeat.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                                const last = parts.length ? parts[parts.length - 1] : cleanupArtistName(afterFeat);
                                if (last) remixes = [last];
                            } else {
                                const cand = lastWordsCandidate(afterFeat);
                                if (cand) remixes = [cleanupArtistName(cand)];
                            }
                        }
                    } else {
                        remixes = raw.split(splitterRegex).map(p => cleanupArtistName(p)).filter(Boolean);
                    }

                    if (remixes.length === 0) continue;
                    const createdCredits = await createCreditItems(row, remixes.length);
                    if (createdCredits.length < remixes.length) {
                        for (let k = createdCredits.length; k < remixes.length; k++) {
                            const res = await clickAddCreditButton(row);
                            if (res) createdCredits.push({ roleInput: res.roleInput, artistInput: res.artistInput, newCreditItem: res.newCreditItem, removeButton: res.removeButton });
                        }
                    }
                    for (let k = 0; k < remixes.length; k++) {
                        const part = remixes[k];
                        const credit = createdCredits[k] || (await clickAddCreditButton(row)) || {};
                        const roleInput = credit.roleInput || null;
                        const artistInput = credit.artistInput || null;
                        const newCreditItem = credit.newCreditItem || null;
                        const removeButton = credit.removeButton || null;
                        const oldArtistValue = artistInput ? (artistInput.value || '').trim() : '';
                        if (roleInput) setReactValue(roleInput, 'Remix');
                        if (artistInput) setReactValue(artistInput, part);
                        changes.push({
                            titleInput,
                            oldTitle: title,
                            newTitle: title,
                            roleInput,
                            artistInput,
                            role: 'Remix',
                            artist: part,
                            oldArtist: oldArtistValue,
                            creditItem: newCreditItem,
                            removeButton
                        });
                        processed++;
                        log(`Track ${i + 1}: Extracted remixer "${part}" (Remix)`, 'success');
                    }
                    continue;
                }
            }
        }

        if (changes.length > 0) {
            state.actionHistory.push({ type: 'remixers', changes });
            updateRevertButton();
        }
        if (processed > 0) {
            const plural = processed > 1 ? 's' : '';
            setInfoSingleLine(`Done! Extracted ${processed} remixer${plural}`, true);
            log(`Done! Extracted ${processed} remixer${plural}`, 'success');
        } else {
            setInfoSingleLine('No remixers found', false);
        }
    }

    async function tryClickAndWait(removeEl, targetNode, attempts = CONFIG.RETRY_ATTEMPTS, delayMs = CONFIG.RETRY_DELAY_MS) {
        if (!removeEl) return false;
        for (let i = 0; i < attempts; i++) {
            try { dispatchMouseClick(removeEl); } catch (e) { log(`Error clicking remove button: ${e.message}`, 'warning'); }
            await new Promise(resolve => setTimeout(resolve, delayMs));
            if (!targetNode || !targetNode.isConnected) return true;
        }
        return (!targetNode || !targetNode.isConnected);
    }

    function dispatchMouseClick(el) {
        if (!el) return false;
        try {
            el.click();
            ['mousedown', 'mouseup', 'click'].forEach(eventType => {
                el.dispatchEvent(new MouseEvent(eventType, {
                    bubbles: true,
                    cancelable: true,
                    view: window
                }));
            });
            return true;
        } catch (e) {
            return false;
        }
    }

    async function clickRemoveCandidateAndVerify(change) {
        const creditItem = change.creditItem || change.artistContainer || null;
        const artistInput = change.artistInput || null;
        const storedRemove = change.removeButton || null;
        if (creditItem) {
            const li = creditItem.tagName && creditItem.tagName.toLowerCase() === 'li' ?
                creditItem :
                (creditItem.closest ? creditItem.closest('li.editable_item') || creditItem.closest('li') : creditItem);
            if (li && li.isConnected) {
                const rb = findRemoveButtonIn(li);
                if (rb) {
                    const success = await tryClickAndWait(rb, li);
                    if (success) return true;
                }
            }
        }
        if (storedRemove && storedRemove.isConnected) {
            const success = await tryClickAndWait(storedRemove, creditItem || artistInput);
            if (success) return true;
        }
        if (artistInput && artistInput.isConnected) {
            const li2 = artistInput.closest('li.editable_item') || artistInput.closest('li') || artistInput.closest('fieldset');
            if (li2 && li2.isConnected) {
                const rb = findRemoveButtonIn(li2);
                if (rb) {
                    const success = await tryClickAndWait(rb, li2);
                    if (success) return true;
                }
            }
        }
        const near = (artistInput && findRemoveNear(artistInput)) || (creditItem && findRemoveNear(creditItem));
        if (near) {
            const success = await tryClickAndWait(near, creditItem || artistInput);
            if (success) return true;
        }
        if (creditItem && creditItem.isConnected) {
            const icon = creditItem.querySelector('i.icon.icon-times, svg.icon-times');
            if (icon) {
                const success = await tryClickAndWait(icon, creditItem);
                if (success) return true;
            }
        }
        return false;
    }

    async function revertLastAction() {
        if (state.actionHistory.length === 0) {
            log('No action to revert', 'warning');
            setInfoSingleLine('No changes to revert', false);
            return;
        }
        setInfoSingleLine('Processing...');
        await new Promise(r => setTimeout(r, 0));
        const lastAction = state.actionHistory.pop();
        log(`Reverting: ${lastAction.type}`, 'info');
        if (lastAction.type === 'durations') {
            let restored = 0;
            for (const change of lastAction.changes) {
                if (change.titleInput) setReactValue(change.titleInput, change.oldTitle);
                if (change.durationInput) setReactValue(change.durationInput, change.oldDuration || '');
                restored++;
            }
            updateRevertButton();
            const plural = restored > 1 ? 's' : '';
            setInfoSingleLine(`Done! Reverted ${restored} duration${plural}`, true);
            log(`Done! Reverted ${restored} duration${plural}`, 'success');
            return;
        }
        if (lastAction.type === 'artists' || lastAction.type === 'featuring' || lastAction.type === 'remixers') {
            for (const change of lastAction.changes) {
                if (change.titleInput && change.oldTitle !== undefined) {
                    setReactValue(change.titleInput, change.oldTitle);
                }
            }
            const removeActions = [];
            for (const change of lastAction.changes) {
                const creditItem = change.creditItem || change.artistContainer || null;
                const artistInput = change.artistInput || null;
                const storedRemove = change.removeButton || null;
                let removeEl = null;
                let targetNode = creditItem || artistInput;
                if (creditItem) {
                    const li = (creditItem.tagName && creditItem.tagName.toLowerCase() === 'li') ?
                        creditItem :
                        (creditItem.closest ? (creditItem.closest('li.editable_item') || creditItem.closest('li')) : creditItem);
                    if (li) {
                        removeEl = findRemoveButtonIn(li);
                        targetNode = li;
                    }
                }
                if (!removeEl && storedRemove && storedRemove.isConnected) removeEl = storedRemove;
                if (!removeEl && artistInput && artistInput.isConnected) {
                    const li2 = artistInput.closest('li.editable_item') || artistInput.closest('li') || artistInput.closest('fieldset');
                    if (li2) removeEl = findRemoveButtonIn(li2);
                    if (!removeEl) removeEl = findRemoveNear(artistInput);
                }
                if (!removeEl && (creditItem || artistInput)) {
                    removeEl = (creditItem && findRemoveNear(creditItem)) || (artistInput && findRemoveNear(artistInput));
                }
                removeActions.push({ removeEl, targetNode, change });
            }
            for (const act of removeActions) {
                if (act.removeEl && act.removeEl.isConnected) {
                    try { dispatchMouseClick(act.removeEl); } catch (e) { }
                }
            }
            const timeout = 1200;
            const pollInterval = 60;
            const start = Date.now();
            let unresolved = removeActions.filter(a => a.targetNode && a.targetNode.isConnected);
            while (unresolved.length > 0 && (Date.now() - start) < timeout) {
                await new Promise(r => setTimeout(r, pollInterval));
                unresolved = removeActions.filter(a => a.targetNode && a.targetNode.isConnected);
            }
            let removed = 0;
            let failed = 0;
            for (const act of removeActions) {
                const change = act.change;
                if (!act.targetNode || !act.targetNode.isConnected) {
                    removed++;
                    continue;
                }
                const success = await clickRemoveCandidateAndVerify(change);
                if (success) removed++; else {
                    failed++;
                    if (change.artistInput && change.oldArtist !== undefined) setReactValue(change.artistInput, change.oldArtist || '');
                    if (change.roleInput) setReactValue(change.roleInput, '');
                }
            }
            updateRevertButton();
            const word = lastAction.type === 'artists' ? 'artist' : (lastAction.type === 'featuring' ? 'feat. artist' : 'remixer');
            const plural = removed !== 1 ? 's' : '';
            const summary = `Reverted ${removed} ${word}${plural}`;
            if (removed > 0) { setInfoSingleLine(`Done! ${summary}`, true); log(`Done! ${summary}`, 'success'); }
            if (failed > 0) { log(`${failed} removal(s) failed`, 'warning'); if (removed === 0) setInfoSingleLine(`${failed} removal(s) failed`, false); }
            return;
        }
        updateRevertButton();
        setInfoSingleLine('Done! Reverted', true);
        log('Done! Reverted', 'success');
    }

    function updateRevertButton() {
        const btn = document.getElementById('revert-last');
        if (!btn) return;
        if (state.actionHistory.length > 0) {
            btn.disabled = false;
            btn.style.opacity = '1';
            btn.style.cursor = 'pointer';
            btn.textContent = `↩️ Revert Actions (${state.actionHistory.length})`;
        } else {
            btn.disabled = true;
            btn.style.opacity = '0.6';
            btn.style.cursor = 'default';
            btn.textContent = '↩️ Revert Actions';
        }
    }

    function applyTheme(theme) {
        const panel = document.getElementById('durations-helper-panel');
        if (!panel) return;
        const panelContent = panel.querySelector('#panel-content');
        const styleButtons = panel.querySelectorAll('.dh-btn');
        const themeBtn = panel.querySelector('#theme-toggle');
        const collapseBtn = panel.querySelector('#collapse-panel');
        const closeBtn = panel.querySelector('#close-panel');
        const logContainer = panel.querySelector('#log-container');
        const infoDiv = panel.querySelector('#track-info');
        const headerTitle = panel.querySelector('.panel-header strong');
        const featToggle = document.getElementById('toggle-feat-remove');
        const remixToggle = document.getElementById('toggle-remix-optional');
        const activeBlueLight = '#1e66d6';
        const activeBlueDark = '#0b5fd6';
        const inactiveBgLight = '#e6e6e6';
        const inactiveBgDark = '#2b2b2b';
        if (theme === 'dark') {
            panel.style.background = '#0f1112';
            panel.style.color = '#ddd';
            if (panelContent) panelContent.style.background = '#111216';
            styleButtons.forEach(btn => { btn.style.background = '#1f2224'; btn.style.color = '#ddd'; btn.style.border = '1px solid #262626'; });
            if (infoDiv) { infoDiv.style.background = '#161718'; infoDiv.style.color = CONFIG.INFO_TEXT_COLOR; }
            if (logContainer) { logContainer.style.background = '#0e0f10'; logContainer.style.color = '#cfcfcf'; }
            if (themeBtn) { themeBtn.textContent = '☀'; themeBtn.style.color = '#fff'; }
            if (collapseBtn) collapseBtn.style.color = '#fff';
            if (closeBtn) closeBtn.style.color = '#fff';
            if (headerTitle) { headerTitle.style.color = '#fff'; headerTitle.style.whiteSpace = 'nowrap'; headerTitle.style.overflow = 'hidden'; headerTitle.style.textOverflow = 'ellipsis'; }
            if (featToggle) { featToggle.style.background = state.removeFeatFromTitle ? activeBlueDark : inactiveBgDark; featToggle.style.color = '#fff'; featToggle.style.border = '1px solid #1b446f'; }
            if (remixToggle) { remixToggle.style.background = state.remixOptionalEnabled ? activeBlueDark : inactiveBgDark; remixToggle.style.color = '#fff'; remixToggle.style.border = '1px solid #1b446f'; }
        } else {
            panel.style.background = '#fff';
            panel.style.color = '#111';
            if (panelContent) panelContent.style.background = '#fff';
            styleButtons.forEach(btn => { btn.style.background = '#f1f3f5'; btn.style.color = '#111'; btn.style.border = '1px solid #e4e6e8'; });
            if (infoDiv) { infoDiv.style.background = '#f8f9fa'; infoDiv.style.color = CONFIG.INFO_TEXT_COLOR; }
            if (logContainer) { logContainer.style.background = '#f8f9fa'; logContainer.style.color = '#6b6b6b'; }
            if (themeBtn) { themeBtn.textContent = '☾'; themeBtn.style.color = '#111'; }
            if (collapseBtn) collapseBtn.style.color = '#111';
            if (closeBtn) closeBtn.style.color = '#111';
            if (headerTitle) { headerTitle.style.color = '#111'; headerTitle.style.whiteSpace = 'nowrap'; headerTitle.style.overflow = 'hidden'; headerTitle.style.textOverflow = 'ellipsis'; }
            if (featToggle) { featToggle.style.background = state.removeFeatFromTitle ? activeBlueLight : inactiveBgLight; featToggle.style.color = state.removeFeatFromTitle ? '#fff' : '#111'; featToggle.style.border = '1px solid #bfcfe8'; }
            if (remixToggle) { remixToggle.style.background = state.remixOptionalEnabled ? activeBlueLight : inactiveBgLight; remixToggle.style.color = state.remixOptionalEnabled ? '#fff' : '#111'; remixToggle.style.border = '1px solid #bfcfe8'; }
        }
        if (featToggle) { featToggle.title = `Remove feat text from title`; featToggle.textContent = state.removeFeatFromTitle ? '✓' : ''; }
        if (remixToggle) updateRemixToggleUI();
    }

    function initThemeFromStorage() {
        let theme = 'light';
        try {
            const stored = localStorage.getItem(CONFIG.THEME_KEY);
            if (stored === 'dark' || stored === 'light') theme = stored;
        } catch (e) { log('Could not load theme preference', 'warning'); }
        applyTheme(theme);
    }

    function addPanelStyles() {
        if (document.getElementById('discogs-helper-panel-styles')) return;
        const css = `
            #durations-helper-panel { border-radius: 8px !important; overflow: hidden !important; box-sizing: border-box !important; }
            #durations-helper-panel .panel-header strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: middle; }
            #durations-helper-panel #panel-content { box-sizing: border-box; background: transparent; }
            #durations-helper-panel #log-container { border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; box-sizing: border-box; }
            #durations-helper-panel, #durations-helper-panel * { box-sizing: border-box; }
            #toggle-feat-remove, #toggle-remix-optional { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 4px; font-size: 12px; line-height: 1; cursor: pointer; user-select: none; }
            #toggle-feat-remove:focus, #toggle-remix-optional:focus { outline: 2px solid rgba(30, 102, 214, 0.3); outline-offset: 2px; }
        `;
        const style = document.createElement('style');
        style.id = 'discogs-helper-panel-styles';
        style.appendChild(document.createTextNode(css));
        document.head.appendChild(style);
    }

    function createPanel() {
        const existing = document.getElementById('durations-helper-panel');
        if (existing) existing.remove();
        const panel = document.createElement('div');
        panel.id = 'durations-helper-panel';
        panel.style.cssText = `
            position: fixed;
            right: 20px;
            top: 165px;
            width: 255px;
            background: #fff;
            border: 1px solid #ccc;
            border-radius: 8px;
            box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
            z-index: 10000;
            font-family: Arial, sans-serif;
            box-sizing: border-box;
        `;
        panel.innerHTML = `
            <div class="panel-header" style="padding: 8px 10px; display: flex; align-items: center; gap: 8px; box-sizing: border-box;">
                <strong style="font-size: 14px; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Discogs Edit Helper</strong>
                <div style="display: flex; gap: 6px; align-items: center;">
                    <button id="theme-toggle" title="Toggle theme" style="background: none; border: none; cursor: pointer; font-size: 14px; padding: 2px 4px;">☾</button>
                    <button id="collapse-panel" title="Collapse" style="background: none; border: none; cursor: pointer; font-size: 16px; padding: 2px 4px;">▲</button>
                    <button id="close-panel" title="Close" style="background: none; border: none; cursor: pointer; font-size: 18px; padding: 2px 4px;">✕</button>
                </div>
            </div>
            <div id="panel-content" style="padding: 12px; box-sizing: border-box;">
                <button id="scan-and-extract" class="dh-btn" title="Extracts durations from the end of track titles">🕛 Extract Durations</button>
                <button id="extract-artists" class="dh-btn" title="Splitter Keywords: ${CONFIG.ARTIST_SPLITTER_PATTERNS.join(', ')}">👤 Extract Artists</button>
                <button id="extract-featuring" class="dh-btn" title="Keywords: ${CONFIG.FEATURING_PATTERNS.join(', ')}">👥 Extract Feat. Artists</button>
                <div style="display:flex; gap:8px; align-items:center;">
                    <button id="extract-remixers" class="dh-btn" style="flex:1;">🎶 Extract Remixers</button>
                </div>
                <button id="revert-last" class="dh-btn" style="margin-top: 8px;">↩️ Revert Actions</button>
                <div id="track-info" style="background: #f8f9fa; padding: 8px; border-radius: 4px; margin-top: 8px; font-size: 12px; display: block; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Ready</div>
                <div id="log-section" style="margin-top: 10px; display: block;">
                    <div id="log-toggle" style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; cursor: pointer;">
                        <strong style="font-size: 11px; color: #666;">Activity Log</strong>
                        <span id="log-arrow" style="font-size: 12px; color: #666;">▼</span>
                    </div>
                    <div id="log-container" style="max-height: 160px; overflow-y: auto; font-size: 10px; font-family: monospace; background: #f8f9fa; padding: 6px; border-radius: 4px; display: none;"></div>
                </div>
            </div>
        `;
        document.body.appendChild(panel);
        addPanelStyles();
        const styleButtons = panel.querySelectorAll('.dh-btn');
        styleButtons.forEach(btn => {
            btn.style.cssText = `
                display: block;
                width: 100%;
                box-sizing: border-box;
                padding: 10px 12px;
                margin-bottom: 8px;
                background: #f1f3f5;
                color: #111;
                border: 1px solid #e4e6e8;
                border-radius: 6px;
                text-align: left;
                cursor: pointer;
                font-weight: 500;
            `;
        });

        const remixBtn = document.getElementById('extract-remixers');
        if (remixBtn) {
            remixBtn.style.display = 'flex';
            remixBtn.style.alignItems = 'center';
            remixBtn.style.justifyContent = 'space-between';
            remixBtn.style.gap = '8px';
            const remixToggle = document.createElement('span');
            remixToggle.id = 'toggle-remix-optional';
            remixToggle.setAttribute('role', 'button');
            remixToggle.setAttribute('tabindex', '0');
            remixToggle.textContent = state.remixOptionalEnabled ? '✓' : '';
            remixToggle.title = `Optional Keywords: ${CONFIG.REMIX_PATTERNS_OPTIONAL.join(', ')}`;
            remixToggle.style.cssText = `
                flex: 0 0 auto;
                margin: 0;
                padding: 0;
                width: 18px;
                height: 18px;
                font-size: 12px;
                border-radius: 4px;
                cursor: pointer;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                user-select: none;
            `;
            remixToggle.addEventListener('click', (e) => {
                if (e && e.stopPropagation) e.stopPropagation();
                if (e && e.preventDefault) e.preventDefault();
                state.remixOptionalEnabled = !state.remixOptionalEnabled;
                try { localStorage.setItem(CONFIG.REMIX_OPTIONAL_KEY, state.remixOptionalEnabled ? '1' : '0'); } catch (err) { log('Could not save remix optional setting', 'warning'); }
                updateRemixToggleUI();
                const current = localStorage.getItem(CONFIG.THEME_KEY) === 'dark' ? 'dark' : 'light';
                applyTheme(current);
            });
            remixToggle.addEventListener('keydown', (ev) => { if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); remixToggle.click(); } });
            remixBtn.appendChild(remixToggle);
        }

        const featBtn = document.getElementById('extract-featuring');
        if (featBtn) {
            featBtn.style.display = 'flex';
            featBtn.style.alignItems = 'center';
            featBtn.style.justifyContent = 'space-between';
            featBtn.style.gap = '8px';
            const featToggle = document.createElement('span');
            featToggle.id = 'toggle-feat-remove';
            featToggle.setAttribute('role', 'button');
            featToggle.setAttribute('tabindex', '0');
            featToggle.textContent = state.removeFeatFromTitle ? '✓' : '';
            featToggle.title = `Remove feat text from title`;
            featToggle.style.cssText = `
                flex: 0 0 auto;
                margin: 0;
                padding: 0;
                width: 18px;
                height: 18px;
                font-size: 12px;
                border-radius: 4px;
                cursor: pointer;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                user-select: none;
            `;

            function toggleFeatHandler(e) {
                if (e && e.stopPropagation) e.stopPropagation();
                if (e && e.preventDefault) e.preventDefault();
                state.removeFeatFromTitle = !state.removeFeatFromTitle;
                featToggle.textContent = state.removeFeatFromTitle ? '✓' : '';
                featToggle.title = `Remove feat text from title`;
                try { localStorage.setItem(CONFIG.FEAT_REMOVE_KEY, state.removeFeatFromTitle ? '1' : '0'); } catch (err) { log('Could not save feat setting', 'warning'); }
                const current = localStorage.getItem(CONFIG.THEME_KEY) === 'dark' ? 'dark' : 'light';
                applyTheme(current);
            }
            featToggle.addEventListener('click', toggleFeatHandler);
            featToggle.addEventListener('keydown', (ev) => { if (ev.key === ' ' || ev.key === 'Enter') toggleFeatHandler(ev); });
            featBtn.appendChild(featToggle);
        }

        const collapseBtn = document.getElementById('collapse-panel');
        const closeBtn = document.getElementById('close-panel');
        const themeBtn = document.getElementById('theme-toggle');
        const logToggle = document.getElementById('log-toggle');
        const logContainer = document.getElementById('log-container');
        closeBtn.onclick = () => { panel.style.display = 'none'; if (state.hideTimeout) clearTimeout(state.hideTimeout); };
        collapseBtn.onclick = () => {
            const content = document.getElementById('panel-content');
            if (content.style.display === 'none') {
                content.style.display = 'block';
                collapseBtn.textContent = '▲';
                collapseBtn.title = 'Collapse';
                state.isCollapsed = false;
                resetHideTimer();
            } else {
                content.style.display = 'none';
                collapseBtn.textContent = '▼';
                collapseBtn.title = 'Expand';
                state.isCollapsed = true;
            }
        };
        document.getElementById('scan-and-extract').onclick = scanAndExtract;
        document.getElementById('extract-artists').onclick = extractArtists;
        document.getElementById('extract-featuring').onclick = extractFeaturing;
        document.getElementById('extract-remixers').onclick = extractRemixers;
        document.getElementById('revert-last').onclick = revertLastAction;
        logToggle.onclick = () => {
            if (!logContainer) return;
            if (logContainer.style.display === 'none' || logContainer.style.display === '') {
                logContainer.style.display = 'block';
                document.getElementById('log-arrow').textContent = '▲';
            } else {
                logContainer.style.display = 'none';
                document.getElementById('log-arrow').textContent = '▼';
            }
        };
        themeBtn.onclick = () => {
            const current = localStorage.getItem(CONFIG.THEME_KEY) === 'dark' ? 'dark' : 'light';
            const next = current === 'dark' ? 'light' : 'dark';
            try { localStorage.setItem(CONFIG.THEME_KEY, next); } catch (e) { log('Could not save theme preference', 'warning'); }
            applyTheme(next);
        };
        initThemeFromStorage();
        updateRemixToggleUI();
        updateRemixButtonTitle();
        log('Panel initialized');
        resetHideTimer();
        updateRevertButton();
    }

    function resetHideTimer() {
        if (state.hideTimeout) clearTimeout(state.hideTimeout);
        state.hideTimeout = setTimeout(() => { if (!state.isCollapsed) collapsePanel(); }, CONFIG.INACTIVITY_TIMEOUT_MS);
    }

    function collapsePanel() {
        const content = document.getElementById('panel-content');
        const collapseBtn = document.getElementById('collapse-panel');
        if (content && collapseBtn && content.style.display !== 'none') {
            content.style.display = 'none';
            collapseBtn.textContent = '▼';
            collapseBtn.title = 'Expand';
            state.isCollapsed = true;
        }
    }

    setTimeout(() => {
        initializeState();
        createPanel();
        updateRevertButton();
        log('Discogs Edit Helper ready');
        document.body.addEventListener('mousemove', resetHideTimer);
        document.body.addEventListener('keydown', resetHideTimer);
        document.body.addEventListener('click', resetHideTimer);
        const panel = document.getElementById('durations-helper-panel');
        if (panel) {
            panel.addEventListener('mousemove', resetHideTimer);
            panel.addEventListener('keydown', resetHideTimer);
            panel.addEventListener('click', resetHideTimer);
        }
    }, 900);

})();