您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds robust song deletion (Selective & Bulk) and a Download Queue tool with multi-format selection and delay. Features: Main Menu, Independent Lists, Keyword Filters, Liked Filters/Selectors, Draggable & Minimizable UI. USE WITH CAUTION.
当前为
// ==UserScript== // @name Riffusion Multitool // @namespace http://tampermonkey.net/ // @version 1.51 // @description Adds robust song deletion (Selective & Bulk) and a Download Queue tool with multi-format selection and delay. Features: Main Menu, Independent Lists, Keyword Filters, Liked Filters/Selectors, Draggable & Minimizable UI. USE WITH CAUTION. // @author Graph1ks (assisted by GoogleAI & ClaudeAI) // @match https://www.riffusion.com/library/my-songs // @grant GM_addStyle // @grant GM_info // ==/UserScript== (function() { 'use strict'; // --- Configuration --- const INITIAL_VIEW = 'menu'; const DELETION_DELAY = 500; const DOWNLOAD_MENU_DELAY = 450; const DOWNLOAD_ACTION_DELAY = 500; const DEFAULT_INTRA_FORMAT_DELAY_SECONDS = 6; const DROPDOWN_DELAY = 350; const DEFAULT_INTER_SONG_DELAY_SECONDS = 6; const MAX_RETRIES = 3; const MAX_EMPTY_CHECKS = 3; const EMPTY_RETRY_DELAY = 6000; const KEYWORD_FILTER_DEBOUNCE = 500; const UI_INITIAL_TOP = '60px'; const UI_INITIAL_RIGHT = '20px'; const INITIAL_IGNORE_LIKED_DELETE = true; const MINIMIZED_ICON_SIZE = '40px'; const MINIMIZED_ICON_TOP = '15px'; const MINIMIZED_ICON_RIGHT = '15px'; // --- State Variables --- let debugMode = false; let isDeleting = false; let isDownloading = false; let currentView = INITIAL_VIEW; let ignoreLikedSongsDeleteState = INITIAL_IGNORE_LIKED_DELETE; let downloadInterSongDelaySeconds = DEFAULT_INTER_SONG_DELAY_SECONDS; let downloadIntraFormatDelaySeconds = DEFAULT_INTRA_FORMAT_DELAY_SECONDS; let keywordFilterDebounceTimer = null; // --- State for Minimize/Restore --- let isMinimized = true; let lastUiTop = UI_INITIAL_TOP; let lastUiLeft = null; let uiElement = null; let minimizedIconElement = null; // --- Styling (Compact Adjustments) --- GM_addStyle(` #riffControlUI { position: fixed; background: linear-gradient(145deg, #2a2a2a, #1e1e1e); border: 1px solid #444; border-radius: 10px; padding: 0; z-index: 10000; width: 300px; /* Slightly narrower */ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; user-select: none; overflow: hidden; display: ${isMinimized ? 'none' : 'block'}; } #riffControlHeader { background: linear-gradient(90deg, #3a3a3a, #2c2c2c); padding: 8px 12px; /* Reduced padding */ cursor: move; border-bottom: 1px solid #444; border-radius: 10px 10px 0 0; position: relative; } #riffControlHeader h3 { margin: 0; font-size: 15px; /* Slightly smaller */ font-weight: 600; color: #ffffff; text-align: center; text-shadow: 0 1px 1px rgba(0,0,0,0.2); padding-right: 25px; } #minimizeButton { position: absolute; top: 4px; /* Adjusted */ right: 6px; /* Adjusted */ background: none; border: none; color: #aaa; font-size: 18px; /* Slightly smaller */ font-weight: bold; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px; transition: color 0.2s, background-color 0.2s; } #minimizeButton:hover { color: #fff; background-color: rgba(255, 255, 255, 0.1); } #riffControlMinimizedIcon { position: fixed; top: ${MINIMIZED_ICON_TOP}; right: ${MINIMIZED_ICON_RIGHT}; width: ${MINIMIZED_ICON_SIZE}; height: ${MINIMIZED_ICON_SIZE}; background: linear-gradient(145deg, #3a3a3a, #2c2c2c); border: 1px solid #555; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: bold; display: ${isMinimized ? 'flex' : 'none'}; align-items: center; justify-content: center; cursor: pointer; z-index: 10001; transition: background 0.2s; user-select: none; } #riffControlMinimizedIcon:hover { background: linear-gradient(145deg, #4a4a4a, #3c3c3c); } #riffControlContent { padding: 12px; /* Reduced padding */ } .riffControlButton { display: block; border: none; border-radius: 6px; /* Slightly smaller radius */ padding: 8px; /* Reduced padding */ font-size: 13px; /* Slightly smaller */ font-weight: 500; text-align: center; cursor: pointer; transition: transform 0.15s, background 0.15s; width: 100%; margin-bottom: 8px; /* Reduced margin */ } .riffControlButton:hover:not(:disabled) { transform: translateY(-1px); } /* Less hover effect */ .riffControlButton:disabled { background: #555 !important; cursor: not-allowed; transform: none; opacity: 0.7; } .riffMenuButton { background: linear-gradient(90deg, #4d94ff, #3385ff); color: #fff; } .riffMenuButton:hover:not(:disabled) { background: linear-gradient(90deg, #3385ff, #1a75ff); } .riffBackButton { background: linear-gradient(90deg, #888, #666); color: #fff; margin-top: 12px; /* Reduced */ margin-bottom: 0; } /* No bottom margin on back */ .riffBackButton:hover:not(:disabled) { background: linear-gradient(90deg, #666, #444); } #deleteAllButton, #deleteButton { background: linear-gradient(90deg, #ff4d4d, #e63939); color: #fff; } #deleteAllButton:hover:not(:disabled), #deleteButton:hover:not(:disabled) { background: linear-gradient(90deg, #e63939, #cc3333); } #startDownloadQueueButton { background: linear-gradient(90deg, #1db954, #17a34a); color: #fff; } #startDownloadQueueButton:hover:not(:disabled) { background: linear-gradient(90deg, #17a34a, #158a3f); } #reloadDeleteButton, #reloadDownloadButton { background: linear-gradient(90deg, #ff9800, #e68a00); color: #fff; } #reloadDeleteButton:hover:not(:disabled), #reloadDownloadButton:hover:not(:disabled) { background: linear-gradient(90deg, #e68a00, #cc7a00); } #debugToggle { background: linear-gradient(90deg, #6666ff, #4d4dff); color: #fff; margin-top: 10px; /* Space before debug */ } #debugToggle:hover:not(:disabled) { background: linear-gradient(90deg, #4d4dff, #3333cc); } #statusMessage { margin-top: 8px; /* Reduced margin */ font-size: 12px; /* Slightly smaller */ color: #1db954; text-align: center; min-height: 1.1em; word-wrap: break-word; } .section-controls { display: none; } .songListContainer { margin-bottom: 10px; /* Reduced margin */ max-height: 22vh; /* Reduced height */ overflow-y: auto; padding-right: 5px; border: 1px solid #444; border-radius: 5px; background-color: rgba(0,0,0,0.1); padding: 6px; /* Reduced padding */ } .songListContainer label { display: flex; align-items: center; margin: 6px 0; /* Reduced margin */ color: #d0d0d0; font-size: 13px; /* Slightly smaller */ transition: color 0.2s; } .songListContainer label:hover:not(.ignored) { color: #ffffff; } .songListContainer input[type="checkbox"] { margin-right: 8px; accent-color: #1db954; width: 15px; height: 15px; /* Slightly smaller */ cursor: pointer; flex-shrink: 0; } .songListContainer input[type="checkbox"]:disabled { cursor: not-allowed; accent-color: #555; } .songListContainer label.ignored { color: #777; cursor: not-allowed; font-style: italic; } .songListContainer label.liked { font-weight: bold; color: #8c8cff; } /* This color might need adjustment if it's based on text-logo class */ .songListContainer label.liked:hover { color: #a0a0ff; } .selectAllContainer { margin-bottom: 8px; /* Reduced margin */ display: flex; align-items: center; color: #d0d0d0; font-size: 13px; /* Slightly smaller */ font-weight: 500; cursor: pointer; } .selectAllContainer input[type="checkbox"] { margin-right: 8px; accent-color: #1db954; width: 15px; height: 15px; /* Slightly smaller */ } .selectAllContainer:hover { color: #ffffff; } .counterDisplay { margin-bottom: 8px; /* Reduced margin */ font-size: 13px; /* Slightly smaller */ color: #1db954; text-align: center; } .songListContainer::-webkit-scrollbar { width: 6px; /* Narrower scrollbar */ } .songListContainer::-webkit-scrollbar-track { background: #333; border-radius: 3px; } .songListContainer::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; } .songListContainer::-webkit-scrollbar-thumb:hover { background: #777; } .filterSettings { margin-top: 8px; margin-bottom: 8px; padding-top: 8px; border-top: 1px solid #444; } .filterSettings label, .downloadFormatContainer label, .downloadDelayContainer label { display: flex; align-items: center; font-size: 12px; /* Slightly smaller */ color: #ccc; cursor: pointer; margin-bottom: 6px; /* Reduced margin */ } .filterSettings label:hover, .downloadFormatContainer label:hover, .downloadDelayContainer label:hover { color: #fff; } .filterSettings input[type="checkbox"], .downloadFormatContainer input[type="checkbox"] { margin-right: 6px; accent-color: #1db954; width: 14px; height: 14px; cursor: pointer; } .filterSettings input[type="text"], .filterSettings input[type="number"] { width: 100%; background-color: #333; border: 1px solid #555; color: #ddd; padding: 5px 8px; /* Reduced padding */ border-radius: 5px; font-size: 12px; /* Slightly smaller */ box-sizing: border-box; margin-top: 4px; /* Reduced margin */ } .filterSettings input[type="text"]:focus, .filterSettings input[type="number"]:focus { outline: none; border-color: #777; } #downloadSelectLiked { background: linear-gradient(90deg, #6666ff, #4d4dff); color: #fff; } #downloadSelectLiked:hover:not(:disabled) { background: linear-gradient(90deg, #4d4dff, #3333cc); } .downloadButtonRow { display: flex; align-items: center; gap: 6px; /* Reduced gap */ margin-bottom: 8px; /* Reduced margin */ } #downloadSelectLiked { flex-grow: 1; } #downloadClearSelection { background: linear-gradient(90deg, #ff4d4d, #e63939); color: #fff; width: 28px; height: 28px; /* Slightly smaller */ padding: 0; font-size: 15px; font-weight: bold; line-height: 1; border: none; border-radius: 5px; cursor: pointer; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 0; transition: transform 0.15s, background 0.15s; } #downloadClearSelection:hover:not(:disabled) { background: linear-gradient(90deg, #e63939, #cc3333); transform: translateY(-1px); } #downloadClearSelection:disabled { background: #555 !important; cursor: not-allowed; transform: none; opacity: 0.7; } .downloadFormatContainer { margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; } .downloadFormatContainer > label { margin-bottom: 4px; /* Reduced margin */ justify-content: center; display: block; text-align: center;} .downloadFormatContainer div { display: flex; justify-content: space-around; margin-top: 4px; } .downloadFormatContainer label { margin-bottom: 0; } /* Ensure format labels themselves have no extra bottom margin */ .downloadDelayContainer { margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; display: flex; justify-content: space-between; gap: 10px; /* Reduced gap */ } .downloadDelayContainer > div { flex: 1; } .downloadDelayContainer label { margin-bottom: 2px; display: block; } .downloadDelayContainer input[type="number"] { margin-top: 0; } /* Bulk delete description */ #bulkModeControls p { font-size: 11px; color:#aaa; text-align:center; margin-top:4px; margin-bottom: 8px; } `); // --- Helper Functions --- function debounce(func, wait) { let t; return function(...a) { const l=()=> { clearTimeout(t); func.apply(this,a); }; clearTimeout(t); t=setTimeout(l, wait); }; } function log(m, l='info') { const p="[RiffTool]"; if(l==='error') console.error(`${p} ${m}`); else if(l==='warn') console.warn(`${p} ${m}`); else console.log(`${p} ${m}`); updateStatusMessage(m); } function logDebug(m, e=null) { if(!debugMode) return; console.log(`[RiffTool DEBUG] ${m}`, e instanceof Element ? e.outerHTML.substring(0,250)+'...' : e !== null ? e : ''); } function logWarn(m, e=null) { console.warn(`[RiffTool WARN] ${m}`, e instanceof Element ? e.outerHTML.substring(0,250)+'...' : e !== null ? e : ''); } function updateStatusMessage(m) { const s=document.getElementById('statusMessage'); if(s) s.textContent = m.length > 100 ? `... ${m.substring(m.length - 100)}` : m; } function simulateClick(e) { if (!e) { logDebug('Element null for click'); return false; } try { ['pointerdown','mousedown','pointerup','mouseup','click'].forEach(t => e.dispatchEvent(new MouseEvent(t,{bubbles:true,cancelable:true,composed: true}))); if(typeof e.click==='function') e.click(); logDebug('Sim Click:', e); return true; } catch (err) { log(`Click fail: ${err.message}`, 'error'); console.error('[RiffTool] Click details:', err, e); return false; } } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // --- UI Functions --- function createMainUI() { uiElement = document.createElement('div'); uiElement.id = 'riffControlUI'; if (UI_INITIAL_RIGHT) { const rightPx = parseInt(UI_INITIAL_RIGHT, 10); const widthPx = 300; lastUiLeft = `${Math.max(0, window.innerWidth - rightPx - widthPx)}px`; uiElement.style.left = lastUiLeft; uiElement.style.right = 'auto'; } else { lastUiLeft = '20px'; uiElement.style.left = lastUiLeft; } lastUiTop = UI_INITIAL_TOP; uiElement.style.top = lastUiTop; uiElement.style.display = isMinimized ? 'none' : 'block'; uiElement.innerHTML = ` <div id="riffControlHeader"> <h3>Riffusion Multitool v${GM_info.script.version}</h3> <button id="minimizeButton" title="Minimize UI">_</button> </div> <div id="riffControlContent"> <div id="mainMenuControls" class="section-controls"> <button id="goToSelectiveDelete" class="riffMenuButton riffControlButton">Selective Deletion</button> <button id="goToBulkDelete" class="riffMenuButton riffControlButton">Bulk Deletion</button> <button id="goToDownloadQueue" class="riffMenuButton riffControlButton">Download Queue</button> </div> <div id="selectiveModeControls" class="section-controls"> <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button> <label class="selectAllContainer"><input type="checkbox" id="deleteSelectAll"> Select All Visible</label> <div id="deleteSongList" class="songListContainer">Loading...</div> <div id="deleteCounter" class="counterDisplay">Deleted: 0 / 0</div> <button id="deleteButton" class="riffControlButton">Delete Selected</button> <button id="reloadDeleteButton" class="riffControlButton">Reload List</button> <div class="filterSettings"> <label><input type="checkbox" id="ignoreLikedToggleDelete"> Ignore Liked</label> <input type="text" id="deleteKeywordFilterInput" placeholder="Keywords to ignore (comma-sep)..."> </div> </div> <div id="bulkModeControls" class="section-controls"> <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button> <button id="deleteAllButton" class="riffControlButton">Delete Entire Library</button> <p>Deletes all songs without scrolling. Retries if needed.</p> </div> <div id="downloadQueueControls" class="section-controls"> <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button> <label class="selectAllContainer"><input type="checkbox" id="downloadSelectAll"> Select All</label> <div class="downloadButtonRow"> <button id="downloadSelectLiked" class="riffControlButton">Select/Deselect Liked</button> <button id="downloadClearSelection" title="Clear Selection" class="riffControlButton">C</button> </div> <div id="downloadSongList" class="songListContainer">Loading...</div> <div id="downloadCounter" class="counterDisplay">Downloaded: 0 / 0</div> <button id="startDownloadQueueButton" class="riffControlButton">Start Download Queue</button> <button id="reloadDownloadButton" class="riffControlButton">Reload List</button> <div class="filterSettings"> <label for="downloadKeywordFilterInput">Filter list by keywords:</label> <input type="text" id="downloadKeywordFilterInput" placeholder="Keywords to show (comma-sep)..."> <div class="downloadFormatContainer"> <label>Download Formats:</label> <div> <label><input type="checkbox" id="formatMP3" value="MP3" checked> MP3</label> <label><input type="checkbox" id="formatM4A" value="M4A"> M4A</label> <label><input type="checkbox" id="formatWAV" value="WAV"> WAV</label> </div> </div> <div class="downloadDelayContainer"> <div> <label for="downloadIntraFormatDelayInput">Format Delay (s):</label> <input type="number" id="downloadIntraFormatDelayInput" min="0" step="0.1" value="${DEFAULT_INTRA_FORMAT_DELAY_SECONDS}"> </div> <div> <label for="downloadInterSongDelayInput">Song Delay (s):</label> <input type="number" id="downloadInterSongDelayInput" min="1" value="${DEFAULT_INTER_SONG_DELAY_SECONDS}"> </div> </div> </div> </div> <button id="debugToggle" class="riffControlButton">${debugMode?'Disable Debug':'Enable Debug'}</button> <div id="statusMessage">Ready.</div> </div>`; document.body.appendChild(uiElement); minimizedIconElement = document.createElement('div'); minimizedIconElement.id = 'riffControlMinimizedIcon'; minimizedIconElement.textContent = 'RM'; minimizedIconElement.title = 'Restore Riffusion Multitool'; minimizedIconElement.style.display = isMinimized ? 'flex' : 'none'; document.body.appendChild(minimizedIconElement); const header = uiElement.querySelector('#riffControlHeader'); enableDrag(uiElement, header); document.getElementById('minimizeButton')?.addEventListener('click', minimizeUI); minimizedIconElement?.addEventListener('click', restoreUI); document.getElementById('goToSelectiveDelete')?.addEventListener('click', () => navigateToView('selective')); document.getElementById('goToBulkDelete')?.addEventListener('click', () => navigateToView('bulk')); document.getElementById('goToDownloadQueue')?.addEventListener('click', () => navigateToView('download')); uiElement.querySelectorAll('.backToMenuButton').forEach(btn => btn.addEventListener('click', () => navigateToView('menu'))); document.getElementById('deleteSelectAll')?.addEventListener('change', (e) => toggleSelectAll(e, '#deleteSongList')); document.getElementById('deleteButton')?.addEventListener('click', deleteSelectedSongs); document.getElementById('reloadDeleteButton')?.addEventListener('click', () => { if (currentView === 'selective') populateDeleteSongList(); }); const ignoreLikedToggle = document.getElementById('ignoreLikedToggleDelete'); if (ignoreLikedToggle) { ignoreLikedToggle.checked = ignoreLikedSongsDeleteState; ignoreLikedToggle.addEventListener('change', (e) => { ignoreLikedSongsDeleteState = e.target.checked; log(`Ignore Liked Songs (Delete): ${ignoreLikedSongsDeleteState}`); populateDeleteSongList(); });} const deleteKeywordInput = document.getElementById('deleteKeywordFilterInput'); if (deleteKeywordInput) { deleteKeywordInput.addEventListener('input', debounce(() => { log('Delete keywords changed, refreshing list...'); populateDeleteSongList(); }, KEYWORD_FILTER_DEBOUNCE)); } document.getElementById('deleteAllButton')?.addEventListener('click', () => {if (isDeleting || isDownloading) { log("Operation already in progress.", "warn"); return; } if (confirm("ARE YOU SURE? This will attempt to delete ALL songs in your library without scrolling. NO UNDO.")) { deleteAllSongsInLibrary(); }}); document.getElementById('downloadSelectAll')?.addEventListener('change', (e) => toggleSelectAll(e, '#downloadSongList')); document.getElementById('downloadSelectLiked')?.addEventListener('click', toggleSelectLiked); document.getElementById('downloadClearSelection')?.addEventListener('click', clearDownloadSelection); document.getElementById('startDownloadQueueButton')?.addEventListener('click', startDownloadQueue); document.getElementById('reloadDownloadButton')?.addEventListener('click', () => { if (currentView === 'download') populateDownloadSongList(); }); const downloadKeywordInput = document.getElementById('downloadKeywordFilterInput'); if (downloadKeywordInput) { downloadKeywordInput.addEventListener('input', debounce(() => { log('Download filter changed, refreshing list...'); populateDownloadSongList(); }, KEYWORD_FILTER_DEBOUNCE)); } const interSongDelayInput = document.getElementById('downloadInterSongDelayInput'); if (interSongDelayInput) { interSongDelayInput.value = downloadInterSongDelaySeconds; interSongDelayInput.addEventListener('input', (e) => { const val = parseInt(e.target.value, 10); if (!isNaN(val) && val >= 0) { downloadInterSongDelaySeconds = val; log(`Inter-Song delay set to: ${downloadInterSongDelaySeconds}s`); } }); } const intraFormatDelayInput = document.getElementById('downloadIntraFormatDelayInput'); if (intraFormatDelayInput) { intraFormatDelayInput.value = downloadIntraFormatDelaySeconds; intraFormatDelayInput.addEventListener('input', (e) => { const val = parseFloat(e.target.value); if (!isNaN(val) && val >= 0) { downloadIntraFormatDelaySeconds = val; log(`Intra-Format delay set to: ${downloadIntraFormatDelaySeconds}s`); } }); } document.getElementById('debugToggle').addEventListener('click', toggleDebug); updateUIVisibility(); } function minimizeUI() { if (!uiElement || !minimizedIconElement) return; if (!isMinimized) { lastUiTop = uiElement.style.top || UI_INITIAL_TOP; lastUiLeft = uiElement.style.left || lastUiLeft; } uiElement.style.display = 'none'; minimizedIconElement.style.display = 'flex'; isMinimized = true; logDebug("UI Minimized"); } function restoreUI() { if (!uiElement || !minimizedIconElement) return; minimizedIconElement.style.display = 'none'; uiElement.style.display = 'block'; uiElement.style.top = lastUiTop; uiElement.style.left = lastUiLeft; uiElement.style.right = 'auto'; isMinimized = false; logDebug("UI Restored to:", { top: lastUiTop, left: lastUiLeft }); updateUIVisibility(); } function navigateToView(view) { if (isDeleting || isDownloading) { log("Cannot switch views while an operation is in progress.", "warn"); return; } logDebug(`Navigating to view: ${view}`); currentView = view; updateUIVisibility(); } function updateUIVisibility() { if (isMinimized || !uiElement) return; const sections = { menu: document.getElementById('mainMenuControls'), selective: document.getElementById('selectiveModeControls'), bulk: document.getElementById('bulkModeControls'), download: document.getElementById('downloadQueueControls') }; const headerTitle = uiElement.querySelector('#riffControlHeader h3'); const statusMsg = document.getElementById('statusMessage'); let title = `Riffusion Multitool v${GM_info.script.version}`; Object.values(sections).forEach(section => { if (section) section.style.display = 'none'; }); if (sections[currentView]) { sections[currentView].style.display = 'block'; switch (currentView) { case 'menu': title += " - Menu"; updateStatusMessage("Select a tool."); break; case 'selective': title += " - Selective Deletion"; populateDeleteSongListIfNeeded(); updateStatusMessage("Select songs to delete."); break; case 'bulk': title += " - Bulk Deletion"; updateStatusMessage("Warning: Deletes entire library."); break; case 'download': title += " - Download Queue"; populateDownloadSongListIfNeeded(); updateStatusMessage("Select songs to download."); break; } } else { log(`View '${currentView}' not found, showing menu.`, 'warn'); sections.menu.style.display = 'block'; currentView = 'menu'; title += " - Menu"; updateStatusMessage("Select a tool."); } if (headerTitle) headerTitle.textContent = title; if (statusMsg) statusMsg.style.display = 'block'; logDebug(`UI Visibility Updated. Current View: ${currentView}`); } function toggleDebug() { debugMode = !debugMode; const btn = document.getElementById('debugToggle'); if (btn) btn.textContent = debugMode ? 'Disable Debug' : 'Enable Debug'; log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}.`); } function enableDrag(element, handle) { let isDragging = false, offsetX, offsetY; handle.addEventListener('mousedown', (e) => { if (e.button !== 0 || e.target.closest('button')) return; if (isMinimized) return; isDragging = true; const rect = element.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; element.style.cursor = 'grabbing'; handle.style.cursor = 'grabbing'; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp, { once: true }); e.preventDefault(); }); function onMouseMove(e) { if (!isDragging) return; let newX = e.clientX - offsetX; let newY = e.clientY - offsetY; const winWidth = window.innerWidth; const winHeight = window.innerHeight; const elWidth = element.offsetWidth; const elHeight = element.offsetHeight; if (newX < 0) newX = 0; if (newY < 0) newY = 0; if (newX + elWidth > winWidth) newX = winWidth - elWidth; if (newY + elHeight > winHeight) newY = winHeight - elHeight; element.style.left = `${newX}px`; element.style.top = `${newY}px`; element.style.right = 'auto'; } function onMouseUp(e) { if (e.button !== 0 || !isDragging) return; isDragging = false; element.style.cursor = 'default'; handle.style.cursor = 'move'; document.removeEventListener('mousemove', onMouseMove); if (!isMinimized) { lastUiTop = element.style.top; lastUiLeft = element.style.left; logDebug("Stored new position after drag:", { top: lastUiTop, left: lastUiLeft }); } } } // --- Song List Population & Filtering --- // THIS FUNCTION IS UPDATED FOR LIKED STATUS function getSongDataFromPage() { const songElements = getCurrentSongElements(); logDebug(`Found ${songElements.length} song elements on page.`); const songs = []; songElements.forEach((songElement, index) => { const titleLink = songElement.querySelector('a[href^="/song/"]'); // Try to find title in a more robust way let titleElement = titleLink ? titleLink.querySelector('h4.text-primary') : null; // Original attempt if (!titleElement) titleElement = songElement.querySelector('[data-sentry-element="RiffTitle"]'); // Sentry attribute if (!titleElement && titleLink) titleElement = titleLink.querySelector('div[class*="truncate"], h4'); // More generic text holder const title = titleElement ? titleElement.textContent.trim() : `Untitled Song ${index + 1}`; let songId = null; if (titleLink) { const match = titleLink.href.match(/\/song\/([a-f0-9-]+)/); if (match && match[1]) songId = match[1]; } if (!songId) { log(`Could not extract song ID for element at index ${index} ('${title}'). Skipping.`, "warn"); return; } // UPDATED LIKED STATUS DETECTION const unfavoriteButton = songElement.querySelector('button[aria-label^="Unfavorite"]'); const solidHeartIcon = songElement.querySelector('button svg[data-prefix="fas"][data-icon="heart"]'); let isLiked = false; if (unfavoriteButton) { isLiked = true; // logDebug(`Song '${title}' marked as liked based on "Unfavorite" button.`); // Too verbose for normal operation } else if (solidHeartIcon && !songElement.querySelector('button[aria-label^="Favorite"]')) { isLiked = true; // logDebug(`Song '${title}' marked as liked based on solid heart icon and no "Favorite" button.`); } // No need for an else logDebug here, default is false. songs.push({ id: songId, title: title, titleLower: title.toLowerCase(), isLiked: isLiked, element: songElement }); }); return songs; } function populateDeleteSongListIfNeeded() { const songListDiv = document.getElementById('deleteSongList'); if (!songListDiv) return; if (songListDiv.innerHTML === '' || songListDiv.innerHTML === 'Loading...' || songListDiv.children.length === 0) { populateDeleteSongList(); } } function populateDeleteSongList() { if (currentView !== 'selective' || isMinimized) return; logDebug('Populating DELETE song list with filters...'); const songListDiv = document.getElementById('deleteSongList'); const deleteCounter = document.getElementById('deleteCounter'); if (!songListDiv) return; songListDiv.innerHTML = 'Loading...'; if(deleteCounter) deleteCounter.textContent = 'Deleted: 0 / 0'; const selectAllCheckbox = document.getElementById('deleteSelectAll'); if (selectAllCheckbox) selectAllCheckbox.checked = false; const ignoreLikedCheckbox = document.getElementById('ignoreLikedToggleDelete'); if(ignoreLikedCheckbox) ignoreLikedCheckbox.checked = ignoreLikedSongsDeleteState; const keywordInput = document.getElementById('deleteKeywordFilterInput'); const keywordString = keywordInput ? keywordInput.value : ''; const dynamicIgnoreKeywords = keywordString.split(',').map(k => k.trim().toLowerCase()).filter(k => k !== ''); logDebug(`Keywords to ignore for delete: [${dynamicIgnoreKeywords.join(', ')}]`); setTimeout(() => { const songs = getSongDataFromPage(); songListDiv.innerHTML = ''; if (songs.length === 0) { songListDiv.innerHTML = '<p style="color:#d0d0d0;text-align:center;font-size:12px;">No songs found on page.</p>'; updateStatusMessage("No songs found."); return; } let ignoredCount = 0; let visibleCount = 0; songs.forEach(song => { const keywordMatch = dynamicIgnoreKeywords.length > 0 && dynamicIgnoreKeywords.some(keyword => song.titleLower.includes(keyword)); const likedMatch = ignoreLikedSongsDeleteState && song.isLiked; const shouldIgnore = keywordMatch || likedMatch; let ignoreReason = ''; if (keywordMatch) ignoreReason += 'Keyword'; if (likedMatch) ignoreReason += (keywordMatch ? ' & Liked' : 'Liked'); const label = document.createElement('label'); label.innerHTML = `<input type="checkbox" data-song-id="${song.id}" ${shouldIgnore ? 'disabled' : ''}> ${song.title}`; if (song.isLiked) label.classList.add('liked'); if (shouldIgnore) { label.classList.add('ignored'); label.title = `Ignoring for delete: ${ignoreReason}`; ignoredCount++; } else { visibleCount++; } songListDiv.appendChild(label); }); logDebug(`Populated DELETE list: ${songs.length} total, ${visibleCount} selectable, ${ignoredCount} ignored.`); updateStatusMessage(`Loaded ${songs.length} songs (${ignoredCount} ignored).`); }, 100); } function populateDownloadSongListIfNeeded() { const songListDiv = document.getElementById('downloadSongList'); if (!songListDiv) return; if (songListDiv.innerHTML === '' || songListDiv.innerHTML === 'Loading...' || songListDiv.children.length === 0) { populateDownloadSongList(); } } function populateDownloadSongList() { if (currentView !== 'download' || isMinimized) return; logDebug('Populating DOWNLOAD song list with filters...'); const songListDiv = document.getElementById('downloadSongList'); const downloadCounter = document.getElementById('downloadCounter'); if (!songListDiv) return; songListDiv.innerHTML = 'Loading...'; if(downloadCounter) downloadCounter.textContent = 'Downloaded: 0 / 0'; const selectAllCheckbox = document.getElementById('downloadSelectAll'); if (selectAllCheckbox) selectAllCheckbox.checked = false; const keywordInput = document.getElementById('downloadKeywordFilterInput'); const keywordString = keywordInput ? keywordInput.value : ''; const filterKeywords = keywordString.split(',').map(k => k.trim().toLowerCase()).filter(k => k !== ''); logDebug(`Keywords to filter for download: [${filterKeywords.join(', ')}]`); setTimeout(() => { const songs = getSongDataFromPage(); songListDiv.innerHTML = ''; if (songs.length === 0) { songListDiv.innerHTML = '<p style="color:#d0d0d0;text-align:center;font-size:12px;">No songs found on page.</p>'; updateStatusMessage("No songs found."); updateSelectLikedButtonText(); return; } let displayedCount = 0; songs.forEach(song => { const keywordMatch = filterKeywords.length === 0 || filterKeywords.some(keyword => song.titleLower.includes(keyword)); if (keywordMatch) { const label = document.createElement('label'); // Store isLiked on the checkbox dataset for toggleSelectLiked label.innerHTML = `<input type="checkbox" data-song-id="${song.id}" data-is-liked="${song.isLiked}"> ${song.title}`; if (song.isLiked) { label.classList.add('liked'); } songListDiv.appendChild(label); displayedCount++; } else { logDebug(`Filtering out song for download view ${song.id} ('${song.title}') due to keywords.`); } }); logDebug(`Populated DOWNLOAD list: ${songs.length} total, ${displayedCount} displayed after filtering.`); updateStatusMessage(`Showing ${displayedCount} of ${songs.length} songs.`); updateSelectLikedButtonText(); }, 100); } function toggleSelectAll(event, listSelector) { if (isMinimized) return; const isChecked = event.target.checked; const checkboxes = document.querySelectorAll(`${listSelector} input[type="checkbox"]:not(:disabled)`); checkboxes.forEach(cb => cb.checked = isChecked); logDebug(`Select All Toggled in ${listSelector}: ${isChecked} (${checkboxes.length} items affected)`); if(listSelector === '#downloadSongList') { updateSelectLikedButtonText(); } } function updateSelectLikedButtonText() { if (currentView !== 'download' || isMinimized) return; const button = document.getElementById('downloadSelectLiked'); if (!button) return; const checkboxes = document.querySelectorAll('#downloadSongList input[type="checkbox"]:not(:disabled)'); if (checkboxes.length === 0) { button.textContent = 'Select Liked'; return; } let shouldSelect = false; // Determine if the button should say "Select Liked" // If there's any visible, unignored, liked song that is NOT checked, then "Select Liked" is appropriate. checkboxes.forEach(cb => { if (cb.dataset.isLiked === 'true' && !cb.checked) { shouldSelect = true; } }); button.textContent = shouldSelect ? 'Select Liked' : 'Deselect Liked'; } function toggleSelectLiked() { if (currentView !== 'download' || isMinimized) return; const checkboxes = document.querySelectorAll('#downloadSongList input[type="checkbox"]:not(:disabled)'); if (checkboxes.length === 0) { log("No songs available to select.", "warn"); return; } // Determine if we are selecting or deselecting based on current state let shouldSelect = false; for (const cb of checkboxes) { if (cb.dataset.isLiked === 'true' && !cb.checked) { shouldSelect = true; // Found a liked song that's not selected, so we should select liked songs break; } } let changedCount = 0; checkboxes.forEach(cb => { if (cb.dataset.isLiked === 'true') { // Only operate on liked songs if (cb.checked !== shouldSelect) { // If its current state is different from target state cb.checked = shouldSelect; changedCount++; } } }); log(`Toggled selection for ${changedCount} liked songs. Action: ${shouldSelect ? 'Select' : 'Deselect'}`); updateStatusMessage(`${shouldSelect ? 'Selected' : 'Deselected'} ${changedCount} liked songs.`); const selectAllCheckbox = document.getElementById('downloadSelectAll'); if (selectAllCheckbox) { const allVisibleCheckboxes = document.querySelectorAll('#downloadSongList input[type="checkbox"]:not(:disabled)'); const allVisibleChecked = document.querySelectorAll('#downloadSongList input[type="checkbox"]:not(:disabled):checked'); selectAllCheckbox.checked = allVisibleCheckboxes.length > 0 && allVisibleCheckboxes.length === allVisibleChecked.length; } updateSelectLikedButtonText(); // Update button text after action } function clearDownloadSelection() { if (currentView !== 'download' || isMinimized) return; const checkboxes = document.querySelectorAll('#downloadSongList input[type="checkbox"]:checked'); if (checkboxes.length === 0) { log("No songs currently selected.", "info"); return; } checkboxes.forEach(cb => cb.checked = false); const selectAllCheckbox = document.getElementById('downloadSelectAll'); if (selectAllCheckbox) selectAllCheckbox.checked = false; log(`Cleared selection for ${checkboxes.length} songs.`); updateStatusMessage("Selection cleared."); updateSelectLikedButtonText(); } function updateCounter(type, count, total) { if (isMinimized) return; let counterElementId = ''; if (type === 'delete') counterElementId = 'deleteCounter'; else if (type === 'download') counterElementId = 'downloadCounter'; else return; const counterElement = document.getElementById(counterElementId); if (counterElement) { const prefix = type === 'delete' ? 'Deleted' : 'Downloaded'; counterElement.textContent = `${prefix}: ${count} / ${total}`; } logDebug(`${type} Counter Updated: ${count}/${total}`); } // --- Deletion Logic --- function getCurrentSongElements() { let listContainer = document.querySelector('div[data-sentry-component="InfiniteScroll"] > div.grow'); if (!listContainer || listContainer.children.length === 0) { const allRiffRows = document.querySelectorAll('div[data-sentry-component="DraggableRiffRow"]'); if (allRiffRows.length > 0 && allRiffRows[0].parentElement.childElementCount > 1) { if (Array.from(allRiffRows[0].parentElement.children).every(child => child.getAttribute('data-sentry-component') === 'DraggableRiffRow' || child.tagName === 'HR')) { listContainer = allRiffRows[0].parentElement; } } } if(listContainer) { logDebug("Using list container:", listContainer); return listContainer.querySelectorAll(':scope > div[data-sentry-component="DraggableRiffRow"]'); } log("Warning: Specific list container not found, using global fallback selector for song elements.", "warn"); return document.querySelectorAll('div[data-sentry-component="DraggableRiffRow"]'); } async function deleteSelectedSongs() { if (isMinimized) { log("Please restore the UI to delete.", "warn"); return; } if (currentView !== 'selective') { log("Selective delete only available in Selective View.", "warn"); return; } if (isDeleting || isDownloading) { log("Another operation is already in progress.", "warn"); return; } const checkboxes = document.querySelectorAll('#deleteSongList input[type="checkbox"]:checked:not(:disabled)'); const totalToDelete = checkboxes.length; if (totalToDelete === 0) { updateCounter('delete', 0, 0); log('No valid songs selected for deletion.'); updateStatusMessage('No songs selected or all selected are ignored.'); return; } isDeleting = true; setAllButtonsDisabled(true); const songIdsToDelete = Array.from(checkboxes).map(cb => cb.dataset.songId); log(`Starting deletion for ${totalToDelete} selected song IDs: [${songIdsToDelete.join(', ')}]`); updateCounter('delete', 0, totalToDelete); updateStatusMessage(`Deleting ${totalToDelete} selected...`); let deletedCount = 0; let criticalErrorOccurred = false; for (const songId of songIdsToDelete) { logDebug(`Processing Song ID for delete: ${songId}`); if (criticalErrorOccurred || !isDeleting) { log(`Stopping deletion loop. CritErr: ${criticalErrorOccurred}, IsDeleting: ${isDeleting}`, "warn"); break; } const songElement = document.querySelector(`div[data-sentry-component="DraggableRiffRow"] a[href="/song/${songId}"]`)?.closest('div[data-sentry-component="DraggableRiffRow"]'); if (!songElement) { log(`Song row for ID ${songId} not found on page (already deleted?). Skipping.`, "warn"); const checkboxToRemove = document.querySelector(`#deleteSongList input[data-song-id="${songId}"]`); checkboxToRemove?.closest('label')?.remove(); continue; } const titleData = getSongDataFromPage().find(s => s.id === songId); const title = titleData ? titleData.title : `song ID ${songId}`; logDebug(`Found element for ${title} (ID: ${songId}). Attempting delete...`); const success = await processSingleAction(songElement, 'delete', songId); logDebug(`processSingleAction(delete) result for ID ${songId}: ${success}`); if (success) { deletedCount++; updateCounter('delete', deletedCount, totalToDelete); updateStatusMessage(`Deleted ${deletedCount}/${totalToDelete}...`); logDebug(`Successfully processed deletion for ID ${songId}. Count: ${deletedCount}`); const checkboxToRemove = document.querySelector(`#deleteSongList input[data-song-id="${songId}"]`); checkboxToRemove?.closest('label')?.remove(); } else { log(`Failed to delete ${title} (ID: ${songId}). Stopping selective delete process.`, "error"); updateStatusMessage(`Error deleting ${title}. Stopped.`); criticalErrorOccurred = true; } logDebug(`Delete loop iteration end for ID: ${songId}. Critical Error: ${criticalErrorOccurred}`); await delay(50); } log(`Selective deletion loop finished. ${deletedCount} of ${totalToDelete} songs attempted.`); updateStatusMessage(criticalErrorOccurred ? `Deletion stopped due to error. ${deletedCount} deleted.` : `Selected deletion complete. ${deletedCount} deleted.`); isDeleting = false; setAllButtonsDisabled(false); } async function deleteAllSongsInLibrary() { if (isMinimized) { log("Please restore the UI to delete.", "warn"); return; } if (currentView !== 'bulk') { log("Bulk delete only available in Bulk View.", "warn"); return; } if (isDeleting || isDownloading) { log("Another operation is already in progress.", "warn"); return; } isDeleting = true; setAllButtonsDisabled(true); log("--- STARTING LIBRARY DELETION (Bulk Mode / No-Scroll) ---"); updateStatusMessage("Starting full library deletion..."); let totalDeleted = 0; let emptyChecks = 0; while (isDeleting) { await delay(500); if (!isDeleting) { log("Deletion stopped externally.", "warn"); break; } let currentElements = getCurrentSongElements(); let currentSize = currentElements.length; log(`Checking for songs... Found ${currentSize}.`); if (currentSize === 0) { log(`No songs found. Waiting ${EMPTY_RETRY_DELAY / 1000}s (Check ${emptyChecks + 1}/${MAX_EMPTY_CHECKS})...`); updateStatusMessage(`No songs. Re-checking in ${EMPTY_RETRY_DELAY / 1000}s...`); await delay(EMPTY_RETRY_DELAY); if (!isDeleting) { log("Deletion stopped during empty wait.", "warn"); break; } currentElements = getCurrentSongElements(); currentSize = currentElements.length; log(`Re-checking after delay... Found ${currentSize} songs.`); if (currentSize > 0) { log("Songs found after wait. Continuing deletion."); updateStatusMessage(`Found ${currentSize} songs after wait. Resuming...`); emptyChecks = 0; } else { emptyChecks++; log(`Still empty (Check ${emptyChecks}/${MAX_EMPTY_CHECKS}).`); updateStatusMessage(`Still empty (Check ${emptyChecks}/${MAX_EMPTY_CHECKS}).`); if (emptyChecks >= MAX_EMPTY_CHECKS) { log("No songs found after multiple retries. Assuming library is empty or cannot load more."); updateStatusMessage("Library appears empty after retries."); isDeleting = false; break; } continue; } } emptyChecks = 0; log(`Processing batch of ${currentSize} songs...`); let batchDeleted = 0; while (currentSize > 0 && isDeleting) { if (!isDeleting) { log("Deletion stopped during batch processing.", "warn"); break; } const firstElement = getCurrentSongElements()[0]; if (!firstElement || !firstElement.parentNode) { log(`Top song element disappeared unexpectedly. Re-evaluating list...`, "warn"); await delay(100); currentSize = getCurrentSongElements().length; continue; } // Attempt to get title for logging const titleLink = firstElement.querySelector('a[href^="/song/"]'); let titleElement = titleLink ? titleLink.querySelector('h4.text-primary, [data-sentry-element="RiffTitle"], div[class*="truncate"]') : firstElement.querySelector('[data-sentry-element="RiffTitle"], div[class*="truncate"], h4'); const title = titleElement ? titleElement.textContent.trim() : `Top song`; const deletionIdentifier = `Bulk ${totalDeleted + batchDeleted + 1}`; log(`Deleting: ${title} (${deletionIdentifier})...`); updateStatusMessage(`Deleting ${title} (${totalDeleted + batchDeleted + 1} total...)`); const success = await processSingleAction(firstElement, 'delete', deletionIdentifier); if (success) { batchDeleted++; await delay(DELETION_DELAY / 2); currentSize = getCurrentSongElements().length; } else { log(`Failed to delete ${title}. Stopping bulk delete.`, "error"); updateStatusMessage(`Error deleting ${title}. Stopped.`); isDeleting = false; break; } await delay(50); } totalDeleted += batchDeleted; if (isDeleting) { log(`Batch attempt complete. Deleted ${batchDeleted} this round. Total: ${totalDeleted}. Checking for more...`); updateStatusMessage(`Batch complete. Total: ${totalDeleted}. Checking for more...`); } } const finalReason = !isDeleting && emptyChecks < MAX_EMPTY_CHECKS ? 'INTERRUPTED' : 'COMPLETE'; log(`--- LIBRARY DELETION ${finalReason} (Bulk Mode) --- Total deleted: ${totalDeleted}`); updateStatusMessage(finalReason === 'INTERRUPTED' ? `Deletion stopped. Total: ${totalDeleted}` : `Deletion complete! Total: ${totalDeleted}`); isDeleting = false; setAllButtonsDisabled(false); } // --- Download Logic --- async function startDownloadQueue() { if (isMinimized) { log("Please restore the UI to download.", "warn"); return; } if (currentView !== 'download') { log("Download only available in Download View.", "warn"); return; } if (isDeleting || isDownloading) { log("Another operation is already in progress.", "warn"); return; } const checkboxes = document.querySelectorAll('#downloadSongList input[type="checkbox"]:checked:not(:disabled)'); const totalSongsToDownload = checkboxes.length; if (totalSongsToDownload === 0) { updateCounter('download', 0, 0); log('No valid songs selected for download.'); updateStatusMessage('No songs selected for download.'); return; } const selectedFormats = []; if (document.getElementById('formatMP3')?.checked) selectedFormats.push('MP3'); if (document.getElementById('formatM4A')?.checked) selectedFormats.push('M4A'); if (document.getElementById('formatWAV')?.checked) selectedFormats.push('WAV'); if (selectedFormats.length === 0) { log('No download formats selected.', 'error'); updateStatusMessage('Please select at least one download format.'); return; } isDownloading = true; setAllButtonsDisabled(true); const songIdsToDownload = Array.from(checkboxes).map(cb => cb.dataset.songId); const interSongDelayMs = downloadInterSongDelaySeconds * 1000; const intraFormatDelayMs = downloadIntraFormatDelaySeconds * 1000; log(`Starting download queue for ${totalSongsToDownload} songs. Formats: [${selectedFormats.join(', ')}], Inter-Song Delay: ${downloadInterSongDelaySeconds}s, Intra-Format Delay: ${downloadIntraFormatDelaySeconds}s.`); updateCounter('download', 0, totalSongsToDownload); updateStatusMessage(`Downloading ${totalSongsToDownload} songs (${selectedFormats.join('/')})...`); let songsProcessedCount = 0; let criticalErrorOccurred = false; for (const songId of songIdsToDownload) { logDebug(`Processing Song ID for download: ${songId}`); if (criticalErrorOccurred || !isDownloading) { log(`Stopping download loop. CritErr: ${criticalErrorOccurred}, IsDownloading: ${isDownloading}`, "warn"); break; } const songElement = document.querySelector(`div[data-sentry-component="DraggableRiffRow"] a[href="/song/${songId}"]`)?.closest('div[data-sentry-component="DraggableRiffRow"]'); if (!songElement) { log(`Song row for ID ${songId} not found on page. Skipping download for this song.`, "warn"); continue; } const titleData = getSongDataFromPage().find(s => s.id === songId); // Get fresh title data const title = titleData ? titleData.title : `song ID ${songId}`; let songDownloadAttempted = false; let songDownloadSuccess = false; let formatIndex = 0; for (const format of selectedFormats) { const currentSongElementCheck = document.querySelector(`div[data-sentry-component="DraggableRiffRow"] a[href="/song/${songId}"]`)?.closest('div[data-sentry-component="DraggableRiffRow"]'); if (!currentSongElementCheck) { logWarn(`${title} (ID: ${songId}) element disappeared before downloading format ${format}. Skipping remaining formats for this song.`); criticalErrorOccurred = true; break; } logDebug(`Attempting download for ${title} (ID: ${songId}) - Format: ${format}`); updateStatusMessage(`Downloading ${songsProcessedCount + 1}/${totalSongsToDownload}: ${title} (${format})...`); songDownloadAttempted = true; const success = await processSingleAction(currentSongElementCheck, 'download', `${songId}-${format}`, format); logDebug(`processSingleAction(download) result for ID ${songId}, Format ${format}: ${success}`); if (success) { songDownloadSuccess = true; log(`Successfully initiated download for ${title} (${format}).`); formatIndex++; if (formatIndex < selectedFormats.length && isDownloading && intraFormatDelayMs > 0) { logDebug(`Waiting ${downloadIntraFormatDelaySeconds}s before next format...`); await delay(intraFormatDelayMs); } else if (isDownloading && intraFormatDelayMs <= 0 && formatIndex < selectedFormats.length) { await delay(50); } } else { log(`Failed to download ${title} (ID: ${songId}) - Format: ${format}. Stopping queue.`, "error"); updateStatusMessage(`Error downloading ${title} (${format}). Stopped.`); criticalErrorOccurred = true; break; } if (!isDownloading) { log(`Download stopped externally during format loop for ${songId}.`, "warn"); break; } } if (songDownloadAttempted) { if (songDownloadSuccess) { songsProcessedCount++; updateCounter('download', songsProcessedCount, totalSongsToDownload); } } if (criticalErrorOccurred || !isDownloading) { break; } if (songsProcessedCount < totalSongsToDownload && isDownloading && (songIdsToDownload.indexOf(songId) < songIdsToDownload.length -1) ) { log(`Waiting ${downloadInterSongDelaySeconds}s before next song...`); updateStatusMessage(`Waiting ${downloadInterSongDelaySeconds}s before next song...`); await delay(interSongDelayMs); } } log(`Download queue finished. Attempted ${totalSongsToDownload} songs. Successfully processed (at least one format for) ${songsProcessedCount} songs.`); updateStatusMessage(criticalErrorOccurred ? `Download stopped due to error. ${songsProcessedCount} songs fully/partially downloaded.` : `Download queue complete. ${songsProcessedCount} songs fully/partially downloaded.`); isDownloading = false; setAllButtonsDisabled(false); } // --- Generic Action Processor (Handles Delete or Download) --- async function processSingleAction(songElement, actionType, identifier, format = null, retryCount = 0) { const logPrefix = `(${actionType} - ID/Index: ${identifier}) -`; if (!songElement || !songElement.parentNode) { log(`${logPrefix} Song element is already gone. Assuming success for ${actionType}.`, "warn"); return true; } const menuButton = songElement.querySelector('button[data-sentry-element="MenuTrigger"]'); if (!menuButton) { log(`${logPrefix} 'More options' button (using data-sentry-element="MenuTrigger") not found. Cannot proceed.`, "error"); logDebug(`${logPrefix} Searched within element:`, songElement); return false; } logDebug(`${logPrefix} Clicking 'More options' button:`, menuButton); if (!simulateClick(menuButton)) { log(`${logPrefix} Failed to simulate click on 'More options'.`, "error"); return false; } await delay(DROPDOWN_DELAY); let primaryActionText = actionType === 'delete' ? 'delete' : 'download'; let primaryActionItem = null; let downloadMenuItemId = null; const popperWrapper = document.querySelector(`div[data-radix-popper-content-wrapper][style*="transform: translate"]`); let potentialItems = []; let menuContentElement = null; if (popperWrapper) { menuContentElement = popperWrapper.querySelector('div[data-radix-menu-content][data-state="open"]'); if (menuContentElement) { potentialItems = menuContentElement.querySelectorAll('[role="menuitem"]'); logDebug(`${logPrefix} Found ${potentialItems.length} potential menu items in active popper's open menu.`); } else { logDebug(`${logPrefix} No open menu content in active popper.`); } } else { logDebug(`${logPrefix} No active popper wrapper found. Searching globally for open menus.`); const openMenus = document.querySelectorAll('div[data-radix-menu-content][data-state="open"]'); if (openMenus.length > 0) { menuContentElement = openMenus[openMenus.length - 1]; potentialItems = menuContentElement.querySelectorAll('[role="menuitem"]'); logDebug(`${logPrefix} Found ${potentialItems.length} potential menu items in the last globally open menu.`); } else { logDebug(`${logPrefix} No globally open menus found.`); } } if (!menuContentElement) { log(`${logPrefix} Could not find any open menu content.`, "error"); try { document.body.click(); await delay(50); } catch(e){} return false; } primaryActionItem = Array.from(potentialItems).find(el => { const textDiv = el.querySelector('.line-clamp-2'); const itemText = textDiv ? textDiv.textContent.trim().toLowerCase() : ''; const isVisible = el.offsetParent !== null; // logDebug(`${logPrefix} Checking main menu item: '${itemText}', visible: ${isVisible}`); // Can be too verbose if (isVisible && itemText === 'download') { downloadMenuItemId = el.getAttribute('aria-controls'); logDebug(`${logPrefix} Found 'Download' item, controls submenu ID: ${downloadMenuItemId || 'Not found'}`); } return isVisible && itemText === primaryActionText; }); if (!primaryActionItem && retryCount < MAX_RETRIES) { log(`${logPrefix} '${primaryActionText}' option not found in menu (Attempt ${retryCount + 1}/${MAX_RETRIES}). Retrying click sequence...`, "warn"); try { document.body.click(); await delay(150); } catch(e){} if (!songElement || !songElement.parentNode) { log(`${logPrefix} Song element disappeared before retry could occur.`, "warn"); return true; } const checkMenuButtonAgain = songElement.querySelector('button[data-sentry-element="MenuTrigger"]'); if (!checkMenuButtonAgain) { log(`${logPrefix} Song element menu button disappeared before retry could occur.`, "warn"); return false; } return processSingleAction(songElement, actionType, identifier, format, retryCount + 1); } if (!primaryActionItem) { log(`${logPrefix} '${primaryActionText}' option not found after ${MAX_RETRIES} retries. Aborting action. Menu HTML:`, menuContentElement.innerHTML.substring(0, 500)); try { document.body.click(); } catch(e){} return false; } logDebug(`${logPrefix} Clicking '${primaryActionText}' option (controls submenu ID: ${downloadMenuItemId || 'N/A'}):`, primaryActionItem); if (!simulateClick(primaryActionItem)) { log(`${logPrefix} Failed to simulate click on '${primaryActionText}' option.`, "error"); try { document.body.click(); } catch(e){} return false; } if (actionType === 'delete') { await delay(DELETION_DELAY); logDebug(`--- Finished processing ${logPrefix} (Assumed Success after Delete Click) ---`); try { document.body.click(); await delay(50); } catch(e){} return true; } else if (actionType === 'download') { if (!downloadMenuItemId) { log(`${logPrefix} Submenu ID for 'Download' was not captured or is missing. Aborting download for format ${format}.`, "error"); try { document.body.click(); } catch(e){} return false; } await delay(DOWNLOAD_MENU_DELAY); let formatItem = null; const formatTextUpper = format.toUpperCase(); let subMenuPopperWrapper = null; let subMenuContent = null; subMenuContent = document.getElementById(downloadMenuItemId); if (!subMenuContent || subMenuContent.getAttribute('data-state') !== 'open') { logDebug(`${logPrefix} Submenu (ID: ${downloadMenuItemId}) not immediately found or not open. Looking for latest popper...`); const allPoppers = Array.from(document.querySelectorAll(`div[data-radix-popper-content-wrapper][style*="transform: translate"]`)); if (allPoppers.length > 0) { subMenuPopperWrapper = allPoppers[allPoppers.length -1]; if (subMenuPopperWrapper) { subMenuContent = subMenuPopperWrapper.querySelector(`div[data-radix-menu-content][data-state="open"][id="${downloadMenuItemId}"]`); if (!subMenuContent) { subMenuContent = subMenuPopperWrapper.querySelector(`div[data-radix-menu-content][data-state="open"]`); logDebug(`${logPrefix} Found submenu content by latest popper, ID ${subMenuContent ? subMenuContent.id : 'unknown'}. Expecting ${downloadMenuItemId}`); if(subMenuContent && subMenuContent.id !== downloadMenuItemId) { logWarn(`${logPrefix} Submenu ID mismatch. Expected ${downloadMenuItemId}, found ${subMenuContent.id}. Proceeding with caution.`); } } } } } if (subMenuContent && subMenuContent.getAttribute('data-state') === 'open') { logDebug(`${logPrefix} Found download sub-menu container (ID: ${subMenuContent.id}). Searching for format '${formatTextUpper}'.`); const potentialFormatItems = subMenuContent.querySelectorAll('[role="menuitem"]'); formatItem = Array.from(potentialFormatItems).find(el => { const textDiv = el.querySelector('.line-clamp-2'); const itemText = textDiv ? textDiv.textContent.trim().toUpperCase() : ''; // logDebug(`${logPrefix} Checking format item in sub-menu: text='${itemText}', visible=${el.offsetParent !== null}`); // Can be too verbose return itemText === formatTextUpper && el.offsetParent !== null && !el.querySelector('svg[data-icon="angle-right"]'); }); } else { log(`${logPrefix} Download sub-menu content (ID: ${downloadMenuItemId}) not found or not open. Aborting format ${format}.`, "error"); try { document.body.click(); } catch(e){} return false; } if (!formatItem && retryCount < MAX_RETRIES) { // Re-using retryCount for this sub-step for simplicity log(`${logPrefix} Format '${formatTextUpper}' not in sub-menu (Attempt ${retryCount +1}/${MAX_RETRIES}). Re-checking sub-menu...`, "warn"); await delay(250); if (subMenuContent && subMenuContent.getAttribute('data-state') === 'open') { const potentialFormatItemsAgain = subMenuContent.querySelectorAll('[role="menuitem"]'); formatItem = Array.from(potentialFormatItemsAgain).find(el => { const textDiv = el.querySelector('.line-clamp-2'); const itemText = textDiv ? textDiv.textContent.trim().toUpperCase() : ''; return itemText === formatTextUpper && el.offsetParent !== null && !el.querySelector('svg[data-icon="angle-right"]'); }); if (formatItem) logDebug(`${logPrefix} Found format '${formatTextUpper}' in sub-menu after delay.`); } } if (!formatItem) { log(`${logPrefix} Format option '${formatTextUpper}' not found in sub-menu after checks. Aborting download format ${format}.`, "error"); logDebug(`${logPrefix} Submenu (ID: ${downloadMenuItemId}) content checked:`, subMenuContent ? subMenuContent.innerHTML.substring(0, 500) + '...' : 'Not Found/Open'); try { document.body.click(); } catch(e){} return false; } logDebug(`${logPrefix} Clicking format '${formatTextUpper}' option:`, formatItem); if (!simulateClick(formatItem)) { log(`${logPrefix} Failed to simulate click on format '${formatTextUpper}' option. Aborting format ${format}.`, "error"); try { document.body.click(); } catch(e){} return false; } await delay(DOWNLOAD_ACTION_DELAY); logDebug(`--- Finished processing ${logPrefix} (Assumed Success after Format Click) ---`); try { document.body.click(); await delay(50); } catch(e){} return true; } log(`${logPrefix} Reached unexpected end of function. Action type: ${actionType}`, "error"); return false; } // --- Utility --- function setAllButtonsDisabled(disabled) { if (!uiElement || (isMinimized && disabled)) return; const buttons = uiElement.querySelectorAll('#riffControlContent button'); buttons.forEach(btn => { if (btn.id !== 'minimizeButton') { btn.disabled = disabled; } }); const inputs = uiElement.querySelectorAll('#riffControlContent input, #riffControlContent select'); inputs.forEach(input => { if (input.id.startsWith('format') || input.id.includes('DelayInput')) { input.disabled = false; } else { input.disabled = disabled; } }); const labels = uiElement.querySelectorAll('#riffControlContent label'); labels.forEach(label => { const isFormatLabel = label.closest('.downloadFormatContainer') !== null; const isDelayLabel = label.closest('.downloadDelayContainer') !== null; label.style.cursor = (disabled && !isFormatLabel && !isDelayLabel) ? 'not-allowed' : 'pointer'; label.style.opacity = (disabled && !isFormatLabel && !isDelayLabel) ? '0.7' : '1'; }); if(!disabled) { if(currentView === 'selective') populateDeleteSongListIfNeeded(); if(currentView === 'download') populateDownloadSongListIfNeeded(); } else { uiElement.querySelectorAll('.songListContainer input[type="checkbox"]').forEach(cb => cb.disabled = true); } const status = document.getElementById('statusMessage'); if (status) status.style.pointerEvents = disabled ? 'none' : 'auto'; logDebug(`Controls ${disabled ? 'mostly disabled' : 'enabled'} (formats/delays always interactive)`); } // --- Initialization --- function waitForPageLoad(callback) { if (document.readyState === "complete" || document.readyState === "interactive") { setTimeout(callback, 1000); } else { window.addEventListener('load', () => { setTimeout(callback, 1000); }, { once: true }); } } function init() { if (window.location.pathname.includes('/library/my-songs')) { try { log(`Riffusion Multitool Script Loaded (v${GM_info.script.version}).`); createMainUI(); log(`Initialized. Current View: ${currentView}. UI is ${isMinimized ? 'minimized (Top-Right)' : 'visible'}.`); } catch (e) { console.error("[RiffTool] Initialization failed:", e); alert("[RiffTool] Failed to initialize script. See console for errors."); } } else { logDebug("Not on the target /library/my-songs page."); } } waitForPageLoad(init); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址