Manage Riffusion songs: selective/bulk delete, download queue (MP3/M4A/WAV), privacy settings. Draggable/minimizable UI, filters, auto-reload & more. USE WITH CAUTION.
Size
111.9 KB
Version
1.70.1
Created
Oct 31, 2025
Updated
about 1 month ago
1// ==UserScript==
2// @name Riffusion Multitool
3// @namespace http://tampermonkey.net/
4// @version 1.70.1
5// @description Manage Riffusion songs: selective/bulk delete, download queue (MP3/M4A/WAV), privacy settings. Draggable/minimizable UI, filters, auto-reload & more. USE WITH CAUTION.
6// @author Graph1ks (assisted by GoogleAI)
7// @match https://www.classic.riffusion.com/*
8// @grant GM_addStyle
9// @grant GM_info
10// @downloadURL https://update.greasyfork.org/scripts/534612/Riffusion%20Multitool.user.js
11// @updateURL https://update.greasyfork.org/scripts/534612/Riffusion%20Multitool.meta.js
12// ==/UserScript==
13(function() {
14 'use strict';
15
16 // --- Configuration ---
17 const SCRIPT_PREFIX = 'Graph1ksRiffTool_'; // Unique prefix for all IDs
18 const MAIN_UI_ID = `${SCRIPT_PREFIX}MainUI`;
19 const MINIMIZED_ICON_ID = `${SCRIPT_PREFIX}MinimizedIcon`;
20
21 const INITIAL_VIEW = 'menu';
22 const DELETION_DELAY = 500;
23 const DOWNLOAD_MENU_DELAY = 550;
24 const DOWNLOAD_ACTION_DELAY = 500;
25 const DEFAULT_INTRA_FORMAT_DELAY_SECONDS = 6;
26 const DROPDOWN_DELAY = 400; // General delay for main dropdowns to open
27 const DEFAULT_INTER_SONG_DELAY_SECONDS = 6;
28
29 // New Privacy Delays
30 const PRIVACY_MENU_OPEN_DELAY = 400; // Delay to open the "More Options" menu
31 const PRIVACY_SUBMENU_TRIGGER_CLICK_DELAY = 150; // Delay after clicking "Privacy" for submenu to register
32 const PRIVACY_SUBMENU_OPEN_DELAY = 450; // Delay for the privacy submenu itself to open
33 const PRIVACY_ACTION_CLICK_DELAY = 500; // Delay after clicking the specific privacy option
34 const DEFAULT_PRIVACY_INTER_SONG_DELAY_SECONDS = 6;
35
36
37 const MAX_RETRIES = 3;
38 const MAX_SUB_MENU_OPEN_RETRIES = 4; // Applies to download and privacy submenus
39 const MAX_EMPTY_CHECKS = 3;
40 const EMPTY_RETRY_DELAY = 6000;
41 const KEYWORD_FILTER_DEBOUNCE = 500;
42 const UI_INITIAL_TOP = '60px';
43 const UI_INITIAL_RIGHT = '20px';
44 const INITIAL_IGNORE_LIKED_DELETE = true;
45 const MINIMIZED_ICON_SIZE = '40px';
46 const MINIMIZED_ICON_TOP = '15px';
47 const MINIMIZED_ICON_RIGHT = '15px';
48 const AUTO_RELOAD_INTERVAL = 3000;
49 const DEFAULT_SONGLIST_HEIGHT = '22vh'; // Unified default height
50
51 // --- State Variables ---
52 let debugMode = false;
53 let isDeleting = false;
54 let isDownloading = false;
55 let isChangingPrivacy = false; // New state
56 let currentView = INITIAL_VIEW;
57 let ignoreLikedSongsDeleteState = INITIAL_IGNORE_LIKED_DELETE;
58
59 let downloadInterSongDelaySeconds = DEFAULT_INTER_SONG_DELAY_SECONDS;
60 let downloadIntraFormatDelaySeconds = DEFAULT_INTRA_FORMAT_DELAY_SECONDS;
61
62 // New Privacy State Variables
63 let privacyInterSongDelaySeconds = DEFAULT_PRIVACY_INTER_SONG_DELAY_SECONDS;
64
65 let stopBulkDeletionSignal = false;
66 let stopPrivacyChangeSignal = false; // New signal
67
68 let isMinimized = true;
69 let lastUiTop = UI_INITIAL_TOP;
70 let lastUiLeft = null;
71 let uiElement = null;
72 let minimizedIconElement = null;
73
74 let autoReloadEnabled = true;
75 let autoReloadTimer = null;
76 let lastKnownSongIdsDelete = [];
77 let lastKnownSongIdsDownload = [];
78 let lastKnownSongIdsPrivacy = []; // New
79 let selectedSongIdsDelete = new Set();
80 let selectedSongIdsDownload = new Set();
81 let selectedSongIdsPrivacy = new Set(); // New
82
83 let currentDeleteListHeight = DEFAULT_SONGLIST_HEIGHT;
84 let currentDownloadListHeight = DEFAULT_SONGLIST_HEIGHT;
85 let currentPrivacyListHeight = DEFAULT_SONGLIST_HEIGHT; // New
86
87 // Privacy button text map
88 const privacyButtonTextMap = {
89 'Only Me': 'Set to Only Me',
90 'Link Only': 'Set to Link Only',
91 'Publish': 'Publish Selected'
92 };
93 const privacySubmenuTextMap = { // Maps UI dropdown value to the exact text in the submenu
94 'Only Me': 'Only me',
95 'Link Only': 'Anyone with the link',
96 'Publish': 'Publish'
97 };
98
99
100 GM_addStyle(`
101 #${MAIN_UI_ID} {
102 position: fixed; background: linear-gradient(145deg, #2a2a2a, #1e1e1e); border: 1px solid #444; border-radius: 10px; padding: 0; z-index: 10000; width: 300px; 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;
103 display: ${isMinimized ? 'none' : 'block'};
104 }
105 #${MAIN_UI_ID} #riffControlHeader { /* Scoped to main UI */
106 background: linear-gradient(90deg, #3a3a3a, #2c2c2c); padding: 8px 12px; cursor: move; border-bottom: 1px solid #444; border-radius: 10px 10px 0 0; position: relative;
107 }
108 #${MAIN_UI_ID} #riffControlHeader h3 { margin: 0; font-size: 15px; font-weight: 600; color: #ffffff; text-align: center; text-shadow: 0 1px 1px rgba(0,0,0,0.2); padding-right: 25px; /* Space for minimize button */ }
109
110 #${MAIN_UI_ID} #minimizeButton { /* Scoped to main UI */
111 position: absolute; top: 4px; right: 6px; background: none; border: none; color: #aaa; font-size: 18px; font-weight: bold; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px; transition: color 0.2s, background-color 0.2s;
112 }
113 #${MAIN_UI_ID} #minimizeButton:hover { color: #fff; background-color: rgba(255, 255, 255, 0.1); }
114
115 #${MINIMIZED_ICON_ID} {
116 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;
117 }
118 #${MINIMIZED_ICON_ID}:hover { background: linear-gradient(145deg, #4a4a4a, #3c3c3c); }
119
120 /* Scoping general rules to within the main UI container */
121 #${MAIN_UI_ID} #riffControlContent { padding: 12px; }
122 #${MAIN_UI_ID} .riffControlButton { display: block; border: none; border-radius: 6px; padding: 8px; font-size: 13px; font-weight: 500; text-align: center; cursor: pointer; transition: transform 0.15s, background 0.15s; width: 100%; margin-bottom: 8px; }
123 #${MAIN_UI_ID} .riffControlButton:hover:not(:disabled) { transform: translateY(-1px); }
124 #${MAIN_UI_ID} .riffControlButton:disabled { background: #555 !important; cursor: not-allowed; transform: none; opacity: 0.7; }
125 #${MAIN_UI_ID} .riffMenuButton { background: linear-gradient(90deg, #4d94ff, #3385ff); color: #fff; }
126 #${MAIN_UI_ID} .riffMenuButton:hover:not(:disabled) { background: linear-gradient(90deg, #3385ff, #1a75ff); }
127 #${MAIN_UI_ID} .riffBackButton { background: linear-gradient(90deg, #888, #666); color: #fff; margin-top: 12px; margin-bottom: 0; }
128 #${MAIN_UI_ID} .riffBackButton:hover:not(:disabled) { background: linear-gradient(90deg, #666, #444); }
129 #${MAIN_UI_ID} #deleteAllButton, #${MAIN_UI_ID} #deleteButton { background: linear-gradient(90deg, #ff4d4d, #e63939); color: #fff; }
130 #${MAIN_UI_ID} #deleteAllButton:hover:not(:disabled), #${MAIN_UI_ID} #deleteButton:hover:not(:disabled) { background: linear-gradient(90deg, #e63939, #cc3333); }
131 #${MAIN_UI_ID} #startDownloadQueueButton { background: linear-gradient(90deg, #1db954, #17a34a); color: #fff; }
132 #${MAIN_UI_ID} #startDownloadQueueButton:hover:not(:disabled) { background: linear-gradient(90deg, #17a34a, #158a3f); }
133 #${MAIN_UI_ID} #changePrivacyStatusButton { background: linear-gradient(90deg, #8A2BE2, #6A1B9A); color: #fff; } /* Purple for privacy */
134 #${MAIN_UI_ID} #changePrivacyStatusButton:hover:not(:disabled) { background: linear-gradient(90deg, #6A1B9A, #4A148C); }
135 #${MAIN_UI_ID} #reloadDeleteButton, #${MAIN_UI_ID} #reloadDownloadButton, #${MAIN_UI_ID} #reloadPrivacyButton { background: linear-gradient(90deg, #ff9800, #e68a00); color: #fff; }
136 #${MAIN_UI_ID} #reloadDeleteButton:hover:not(:disabled), #${MAIN_UI_ID} #reloadDownloadButton:hover:not(:disabled), #${MAIN_UI_ID} #reloadPrivacyButton:hover:not(:disabled) { background: linear-gradient(90deg, #e68a00, #cc7a00); }
137
138
139 #${MAIN_UI_ID} #statusMessage { margin-top: 8px; font-size: 12px; color: #1db954; text-align: center; min-height: 1.1em; word-wrap: break-word; }
140 #${MAIN_UI_ID} .section-controls { display: none; }
141 #${MAIN_UI_ID} .songListContainer { margin-bottom: 0px; overflow-y: auto; padding-right: 5px; /* For scrollbar */ border: 1px solid #444; border-radius: 5px; background-color: rgba(0,0,0,0.1); padding: 6px; }
142 #${MAIN_UI_ID} .songListContainer label { display: flex; align-items: center; margin: 6px 0; color: #d0d0d0; font-size: 13px; transition: color 0.2s; }
143 #${MAIN_UI_ID} .songListContainer label:hover:not(.ignored) { color: #ffffff; }
144 #${MAIN_UI_ID} .songListContainer input[type="checkbox"] { margin-right: 8px; accent-color: #1db954; width: 15px; height: 15px; cursor: pointer; flex-shrink: 0; }
145 #${MAIN_UI_ID} .songListContainer input[type="checkbox"]:disabled { cursor: not-allowed; accent-color: #555; }
146 #${MAIN_UI_ID} .songListContainer label.ignored { color: #777; cursor: not-allowed; font-style: italic; }
147 #${MAIN_UI_ID} .songListContainer label.liked { font-weight: bold; color: #8c8cff; /* Light purple/blue for liked */ }
148 #${MAIN_UI_ID} .songListContainer label.liked:hover { color: #a0a0ff; }
149
150 #${MAIN_UI_ID} .listResizer {
151 width: 100%; height: 8px; background-color: #4a4a4a; cursor: ns-resize;
152 border-radius: 3px; margin-top: 2px; margin-bottom: 10px; display: block;
153 transition: background-color 0.2s;
154 }
155 #${MAIN_UI_ID} .listResizer:hover { background-color: #5c5c5c; }
156
157 #${MAIN_UI_ID} .selectAllContainer { margin-bottom: 8px; display: flex; align-items: center; color: #d0d0d0; font-size: 13px; font-weight: 500; cursor: pointer; }
158 #${MAIN_UI_ID} .selectAllContainer input[type="checkbox"] { margin-right: 8px; accent-color: #1db954; width: 15px; height: 15px; }
159 #${MAIN_UI_ID} .selectAllContainer:hover { color: #ffffff; }
160 #${MAIN_UI_ID} .counterDisplay { margin-bottom: 8px; font-size: 13px; color: #1db954; text-align: center; }
161 #${MAIN_UI_ID} .songListContainer::-webkit-scrollbar { width: 6px; }
162 #${MAIN_UI_ID} .songListContainer::-webkit-scrollbar-track { background: #333; border-radius: 3px; }
163 #${MAIN_UI_ID} .songListContainer::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
164 #${MAIN_UI_ID} .songListContainer::-webkit-scrollbar-thumb:hover { background: #777; }
165
166 #${MAIN_UI_ID} .filterSettings { margin-top: 8px; margin-bottom: 8px; padding-top: 8px; border-top: 1px solid #444; }
167 #${MAIN_UI_ID} .settings-checkbox-label { display: flex; align-items: center; font-size: 12px; color: #ccc; cursor: pointer; margin-bottom: 6px; }
168 #${MAIN_UI_ID} .settings-checkbox-label:hover { color: #fff; }
169 #${MAIN_UI_ID} .settings-checkbox-label input[type="checkbox"] { margin-right: 6px; accent-color: #1db954; width: 14px; height: 14px; cursor: pointer; }
170
171 #${MAIN_UI_ID} .filterSettings input[type="text"], #${MAIN_UI_ID} .filterSettings input[type="number"] { width: 100%; background-color: #333; border: 1px solid #555; color: #ddd; padding: 5px 8px; border-radius: 5px; font-size: 12px; box-sizing: border-box; margin-top: 4px; }
172 #${MAIN_UI_ID} .filterSettings input[type="text"]:focus, #${MAIN_UI_ID} .filterSettings input[type="number"]:focus { outline: none; border-color: #777; }
173
174 #${MAIN_UI_ID} #downloadSelectLiked, #${MAIN_UI_ID} #privacySelectLiked { background: linear-gradient(90deg, #6666ff, #4d4dff); color: #fff; }
175 #${MAIN_UI_ID} #downloadSelectLiked:hover:not(:disabled), #${MAIN_UI_ID} #privacySelectLiked:hover:not(:disabled) { background: linear-gradient(90deg, #4d4dff, #3333cc); }
176 #${MAIN_UI_ID} .downloadButtonRow, #${MAIN_UI_ID} .privacyButtonRow { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
177 #${MAIN_UI_ID} #downloadSelectLiked, #${MAIN_UI_ID} #privacySelectLiked { flex-grow: 1; }
178 #${MAIN_UI_ID} #downloadClearSelection, #${MAIN_UI_ID} #privacyClearSelection {
179 background: linear-gradient(90deg, #ff4d4d, #e63939); color: #fff;
180 width: 28px; height: 28px; 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;
181 }
182 #${MAIN_UI_ID} #downloadClearSelection:hover:not(:disabled), #${MAIN_UI_ID} #privacyClearSelection:hover:not(:disabled) { background: linear-gradient(90deg, #e63939, #cc3333); transform: translateY(-1px); }
183 #${MAIN_UI_ID} #downloadClearSelection:disabled, #${MAIN_UI_ID} #privacyClearSelection:disabled { background: #555 !important; cursor: not-allowed; transform: none; opacity: 0.7; }
184
185 #${MAIN_UI_ID} .downloadFormatContainer { margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; }
186 #${MAIN_UI_ID} .downloadFormatContainer > label.settings-checkbox-label { margin-bottom: 4px; justify-content: center; display: block; text-align: center;}
187 #${MAIN_UI_ID} .downloadFormatContainer div { display: flex; justify-content: space-around; margin-top: 4px; }
188
189 #${MAIN_UI_ID} .downloadDelayContainer { margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; display: flex; justify-content: space-between; gap: 10px; }
190 #${MAIN_UI_ID} .downloadDelayContainer > div { flex: 1; }
191 #${MAIN_UI_ID} .downloadDelayContainer label.settings-checkbox-label { margin-bottom: 2px; display: block; }
192 #${MAIN_UI_ID} .downloadDelayContainer input[type="number"] { margin-top: 0; }
193
194 #${MAIN_UI_ID} #bulkModeControls p { font-size: 11px; color:#aaa; text-align:center; margin-top:4px; margin-bottom: 8px; }
195 #${MAIN_UI_ID} #commonSettingsFooter { margin-top: 10px; padding-top: 8px; border-top: 1px solid #444; }
196
197 /* Privacy Specific Settings */
198 #${MAIN_UI_ID} .privacySettingsContainer { margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; }
199 #${MAIN_UI_ID} .privacySettingsContainer > label.settings-checkbox-label { display: block; margin-bottom: 4px; text-align: left; }
200 #${MAIN_UI_ID} .privacySettingsContainer select,
201 #${MAIN_UI_ID} .privacySettingsContainer input[type="number"] {
202 width: 100%; background-color: #333; border: 1px solid #555; color: #ddd;
203 padding: 5px 8px; border-radius: 5px; font-size: 12px;
204 box-sizing: border-box; margin-bottom: 8px;
205 }
206 #${MAIN_UI_ID} .privacySettingsContainer select:focus,
207 #${MAIN_UI_ID} .privacySettingsContainer input[type="number"]:focus { outline: none; border-color: #777; }
208 #${MAIN_UI_ID} .privacyDelayContainer { display: flex; justify-content: space-between; gap: 10px; } /* This class might be unused if only one delay input */
209 #${MAIN_UI_ID} .privacyDelayContainer > div { flex: 1; }
210 `);
211
212 // --- Helper Functions ---
213 function debounce(func, wait) { let t; return function(...a) { const l=()=> { clearTimeout(t); func.apply(this,a); }; clearTimeout(t); t=setTimeout(l, wait); }; }
214 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); }
215 function logDebug(m, e=null) { if(!debugMode) return; console.log(`[RiffTool DEBUG] ${m}`, e instanceof Element ? e.outerHTML.substring(0,250)+'...' : e !== null ? e : ''); }
216 function logWarn(m, e=null) { console.warn(`[RiffTool WARN] ${m}`, e instanceof Element ? e.outerHTML.substring(0,250)+'...' : e !== null ? e : ''); }
217 function updateStatusMessage(m) {
218 if (!uiElement) return;
219 const s = uiElement.querySelector('#statusMessage'); // Scoped query
220 if(s) s.textContent = m.length > 100 ? `... ${m.substring(m.length - 100)}` : m;
221 }
222
223 function simulateClick(e) {
224 if (!e) { logDebug('Element null for click'); return false; }
225 try {
226 ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(t =>
227 e.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, composed: true }))
228 );
229 logDebug('Sim Click (full event sequence):', e);
230 return true;
231 } catch (err) {
232 log(`Click simulation failed: ${err.message}`, 'error');
233 console.error('[RiffTool] Click details:', err, e);
234 return false;
235 }
236 }
237 function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
238
239
240 // --- UI Functions ---
241 function createMainUI() {
242 uiElement = document.createElement('div');
243 uiElement.id = MAIN_UI_ID; // Use new unique ID
244 if (UI_INITIAL_RIGHT) {
245 const rightPx = parseInt(UI_INITIAL_RIGHT, 10);
246 const widthPx = parseInt(uiElement.style.width, 10) || 300; // Default width if not set
247 lastUiLeft = `${Math.max(0, window.innerWidth - rightPx - widthPx)}px`;
248 uiElement.style.left = lastUiLeft;
249 uiElement.style.right = 'auto';
250 } else {
251 lastUiLeft = '20px'; // Default left if right is not specified
252 uiElement.style.left = lastUiLeft;
253 }
254 lastUiTop = UI_INITIAL_TOP;
255 uiElement.style.top = lastUiTop;
256 uiElement.style.display = isMinimized ? 'none' : 'block';
257
258 uiElement.innerHTML = `
259 <div id="riffControlHeader">
260 <h3>Riffusion Multitool v${GM_info.script.version}</h3>
261 <button id="minimizeButton" title="Minimize UI">_</button>
262 </div>
263 <div id="riffControlContent">
264 <div id="mainMenuControls" class="section-controls">
265 <button id="goToSelectiveDelete" class="riffMenuButton riffControlButton">Selective Deletion</button>
266 <button id="goToBulkDelete" class="riffMenuButton riffControlButton">Bulk Deletion</button>
267 <button id="goToDownloadQueue" class="riffMenuButton riffControlButton">Download Queue</button>
268 <button id="goToPrivacyStatus" class="riffMenuButton riffControlButton">Privacy Status</button>
269 </div>
270 <div id="selectiveModeControls" class="section-controls">
271 <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button>
272 <label class="selectAllContainer"><input type="checkbox" id="deleteSelectAll"> Select All Visible</label>
273 <div id="deleteSongList" class="songListContainer" style="max-height: ${currentDeleteListHeight};">Loading...</div>
274 <div class="listResizer" data-list-id="deleteSongList" data-height-var-name="currentDeleteListHeight"></div>
275 <div id="deleteCounter" class="counterDisplay">Deleted: 0 / 0</div>
276 <button id="deleteButton" class="riffControlButton">Delete Selected</button>
277 <button id="reloadDeleteButton" class="riffControlButton" style="display: ${autoReloadEnabled ? 'none' : 'block'};">Reload List</button>
278 <div class="filterSettings">
279 <label class="settings-checkbox-label"><input type="checkbox" id="ignoreLikedToggleDelete"> Ignore Liked</label>
280 <input type="text" id="deleteKeywordFilterInput" placeholder="Keywords to ignore (comma-sep)...">
281 </div>
282 </div>
283 <div id="bulkModeControls" class="section-controls">
284 <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button>
285 <button id="deleteAllButton" class="riffControlButton">Delete Entire Library</button>
286 <p>Deletes all songs without scrolling. Retries if needed. Click "Stop Deletion" to halt.</p>
287 </div>
288 <div id="downloadQueueControls" class="section-controls">
289 <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button>
290 <label class="selectAllContainer"><input type="checkbox" id="downloadSelectAll"> Select All</label>
291 <div class="downloadButtonRow">
292 <button id="downloadSelectLiked" class="riffControlButton">Select/Deselect Liked</button>
293 <button id="downloadClearSelection" title="Clear Selection" class="riffControlButton">C</button>
294 </div>
295 <div id="downloadSongList" class="songListContainer" style="max-height: ${currentDownloadListHeight};">Loading...</div>
296 <div class="listResizer" data-list-id="downloadSongList" data-height-var-name="currentDownloadListHeight"></div>
297 <div id="downloadCounter" class="counterDisplay">Downloaded: 0 / 0</div>
298 <button id="startDownloadQueueButton" class="riffControlButton">Start Download Queue</button>
299 <button id="reloadDownloadButton" class="riffControlButton" style="display: ${autoReloadEnabled ? 'none' : 'block'};">Reload List</button>
300 <div class="filterSettings">
301 <label class="settings-checkbox-label" for="downloadKeywordFilterInput">Filter list by keywords:</label>
302 <input type="text" id="downloadKeywordFilterInput" placeholder="Keywords to show (comma-sep)...">
303 <div class="downloadFormatContainer">
304 <label class="settings-checkbox-label">Download Formats:</label>
305 <div>
306 <label class="settings-checkbox-label"><input type="checkbox" id="formatMP3" value="MP3" checked> MP3</label>
307 <label class="settings-checkbox-label"><input type="checkbox" id="formatM4A" value="M4A"> M4A</label>
308 <label class="settings-checkbox-label"><input type="checkbox" id="formatWAV" value="WAV"> WAV</label>
309 </div>
310 </div>
311 <div class="downloadDelayContainer">
312 <div>
313 <label class="settings-checkbox-label" for="downloadIntraFormatDelayInput">Format Delay (s):</label>
314 <input type="number" id="downloadIntraFormatDelayInput" min="0" step="0.1" value="${DEFAULT_INTRA_FORMAT_DELAY_SECONDS}">
315 </div>
316 <div>
317 <label class="settings-checkbox-label" for="downloadInterSongDelayInput">Song Delay (s):</label>
318 <input type="number" id="downloadInterSongDelayInput" min="1" value="${DEFAULT_INTER_SONG_DELAY_SECONDS}">
319 </div>
320 </div>
321 </div>
322 </div>
323
324 <div id="privacyStatusControls" class="section-controls">
325 <button class="riffBackButton riffControlButton backToMenuButton">Back to Menu</button>
326 <label class="selectAllContainer"><input type="checkbox" id="privacySelectAll"> Select All Visible</label>
327 <div class="privacyButtonRow">
328 <button id="privacySelectLiked" class="riffControlButton">Select/Deselect Liked</button>
329 <button id="privacyClearSelection" title="Clear Selection" class="riffControlButton">C</button>
330 </div>
331 <div id="privacySongList" class="songListContainer" style="max-height: ${currentPrivacyListHeight};">Loading...</div>
332 <div class="listResizer" data-list-id="privacySongList" data-height-var-name="currentPrivacyListHeight"></div>
333 <div id="privacyCounter" class="counterDisplay">Updated: 0 / 0</div>
334 <button id="changePrivacyStatusButton" class="riffControlButton">Change Status</button>
335 <button id="reloadPrivacyButton" class="riffControlButton" style="display: ${autoReloadEnabled ? 'none' : 'block'};">Reload List</button>
336 <div class="filterSettings privacySettingsContainer">
337 <label class="settings-checkbox-label" for="privacyKeywordFilterInput">Filter list by keywords:</label>
338 <input type="text" id="privacyKeywordFilterInput" placeholder="Keywords to show (comma-sep)...">
339
340 <label class="settings-checkbox-label" for="privacyLevelSelect">Set selected songs to:</label>
341 <select id="privacyLevelSelect">
342 <option value="Only Me">Only Me</option>
343 <option value="Link Only">Link Only</option>
344 <option value="Publish">Publish</option>
345 </select>
346
347 <div> <!-- Removed privacyDelayContainer class for single input -->
348 <label class="settings-checkbox-label" for="privacyInterSongDelayInput">Song Delay (s):</label>
349 <input type="number" id="privacyInterSongDelayInput" min="1" value="${DEFAULT_PRIVACY_INTER_SONG_DELAY_SECONDS}">
350 </div>
351 </div>
352 </div>
353
354 <div id="commonSettingsFooter">
355 <label id="autoReloadToggleContainer" class="settings-checkbox-label" style="display: none;"><input type="checkbox" id="autoReloadToggle"> Auto-Update Lists</label>
356 <label id="debugToggleContainer" class="settings-checkbox-label" style="display: none;"><input type="checkbox" id="debugToggleCheckbox"> Enable Debug</label>
357 </div>
358 <div id="statusMessage">Ready.</div>
359 </div>`;
360 document.body.appendChild(uiElement);
361
362 minimizedIconElement = document.createElement('div');
363 minimizedIconElement.id = MINIMIZED_ICON_ID; // Use new unique ID
364 minimizedIconElement.textContent = 'RM';
365 minimizedIconElement.title = 'Restore Riffusion Multitool';
366 minimizedIconElement.style.display = isMinimized ? 'flex' : 'none';
367 document.body.appendChild(minimizedIconElement);
368
369 const header = uiElement.querySelector('#riffControlHeader');
370 enableDrag(uiElement, header);
371 uiElement.querySelector('#minimizeButton')?.addEventListener('click', minimizeUI); // Scoped query
372 minimizedIconElement?.addEventListener('click', restoreUI);
373
374 uiElement.querySelector('#goToSelectiveDelete')?.addEventListener('click', () => navigateToView('selective'));
375 uiElement.querySelector('#goToBulkDelete')?.addEventListener('click', () => navigateToView('bulk'));
376 uiElement.querySelector('#goToDownloadQueue')?.addEventListener('click', () => navigateToView('download'));
377 uiElement.querySelector('#goToPrivacyStatus')?.addEventListener('click', () => navigateToView('privacy'));
378 uiElement.querySelectorAll('.backToMenuButton').forEach(btn => btn.addEventListener('click', () => navigateToView('menu')));
379
380 // Selective Delete Listeners
381 uiElement.querySelector('#deleteSelectAll')?.addEventListener('change', (e) => toggleSelectAll(e, '#deleteSongList', selectedSongIdsDelete, 'delete'));
382 uiElement.querySelector('#deleteButton')?.addEventListener('click', deleteSelectedSongs);
383 uiElement.querySelector('#reloadDeleteButton')?.addEventListener('click', () => { if (currentView === 'selective') populateDeleteSongList(); });
384 const ignoreLikedToggle = uiElement.querySelector('#ignoreLikedToggleDelete');
385 if (ignoreLikedToggle) { ignoreLikedToggle.checked = ignoreLikedSongsDeleteState; ignoreLikedToggle.addEventListener('change', (e) => { ignoreLikedSongsDeleteState = e.target.checked; log(`Ignore Liked Songs (Delete): ${ignoreLikedSongsDeleteState}`); populateDeleteSongList(); });}
386 const deleteKeywordInput = uiElement.querySelector('#deleteKeywordFilterInput');
387 if (deleteKeywordInput) { deleteKeywordInput.addEventListener('input', debounce(() => { log('Delete keywords changed, refreshing list...'); populateDeleteSongList(); }, KEYWORD_FILTER_DEBOUNCE)); }
388 initListResizer(uiElement.querySelector('.listResizer[data-list-id="deleteSongList"]'), uiElement.querySelector('#deleteSongList'), 'currentDeleteListHeight');
389
390 // Bulk Delete Listeners
391 uiElement.querySelector('#deleteAllButton')?.addEventListener('click', () => {
392 if (currentView === 'bulk') {
393 if (isDeleting) { stopBulkDeletionByUser(); }
394 else { if (isDownloading || isChangingPrivacy) { log('Another operation in progress. Cannot start deletion.', 'warn'); return; }
395 if (confirm('ARE YOU SURE? This will attempt to delete ALL songs in your library without scrolling. NO UNDO.')) { deleteAllSongsInLibrary(); }
396 }
397 }
398 });
399
400 // Download Queue Listeners
401 uiElement.querySelector('#downloadSelectAll')?.addEventListener('change', (e) => toggleSelectAll(e, '#downloadSongList', selectedSongIdsDownload, 'download'));
402 uiElement.querySelector('#downloadSelectLiked')?.addEventListener('click', () => toggleSelectLikedGeneric('#downloadSongList', selectedSongIdsDownload, '#downloadSelectAll', updateDownloadSelectLikedButtonText));
403 uiElement.querySelector('#downloadClearSelection')?.addEventListener('click', () => clearSelectionGeneric('#downloadSongList', selectedSongIdsDownload, '#downloadSelectAll', updateDownloadSelectLikedButtonText));
404 uiElement.querySelector('#startDownloadQueueButton')?.addEventListener('click', startDownloadQueue);
405 uiElement.querySelector('#reloadDownloadButton')?.addEventListener('click', () => { if (currentView === 'download') populateDownloadSongList(); });
406 const downloadKeywordInput = uiElement.querySelector('#downloadKeywordFilterInput');
407 if (downloadKeywordInput) { downloadKeywordInput.addEventListener('input', debounce(() => { log('Download filter changed, refreshing list...'); populateDownloadSongList(); }, KEYWORD_FILTER_DEBOUNCE)); }
408 const interSongDelayInput = uiElement.querySelector('#downloadInterSongDelayInput');
409 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 (Download) set to: ${downloadInterSongDelaySeconds}s`); } }); }
410 const intraFormatDelayInput = uiElement.querySelector('#downloadIntraFormatDelayInput');
411 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`); } }); }
412 initListResizer(uiElement.querySelector('.listResizer[data-list-id="downloadSongList"]'), uiElement.querySelector('#downloadSongList'), 'currentDownloadListHeight');
413
414 // Privacy Status Listeners
415 uiElement.querySelector('#privacySelectAll')?.addEventListener('change', (e) => toggleSelectAll(e, '#privacySongList', selectedSongIdsPrivacy, 'privacy'));
416 uiElement.querySelector('#privacySelectLiked')?.addEventListener('click', () => toggleSelectLikedGeneric('#privacySongList', selectedSongIdsPrivacy, '#privacySelectAll', updatePrivacySelectLikedButtonText));
417 uiElement.querySelector('#privacyClearSelection')?.addEventListener('click', () => clearSelectionGeneric('#privacySongList', selectedSongIdsPrivacy, '#privacySelectAll', updatePrivacySelectLikedButtonText));
418 uiElement.querySelector('#changePrivacyStatusButton')?.addEventListener('click', changeSelectedSongsPrivacy);
419 uiElement.querySelector('#reloadPrivacyButton')?.addEventListener('click', () => { if (currentView === 'privacy') populatePrivacySongList(); });
420 const privacyKeywordInput = uiElement.querySelector('#privacyKeywordFilterInput');
421 if (privacyKeywordInput) { privacyKeywordInput.addEventListener('input', debounce(() => { log('Privacy filter changed, refreshing list...'); populatePrivacySongList(); }, KEYWORD_FILTER_DEBOUNCE)); }
422 const privacyLevelSelect = uiElement.querySelector('#privacyLevelSelect');
423 if (privacyLevelSelect) { privacyLevelSelect.addEventListener('change', updateChangePrivacyButtonText); }
424 const privacyInterSongDelayInput = uiElement.querySelector('#privacyInterSongDelayInput');
425 if (privacyInterSongDelayInput) {
426 privacyInterSongDelayInput.value = privacyInterSongDelaySeconds;
427 privacyInterSongDelayInput.addEventListener('input', (e) => {
428 const val = parseInt(e.target.value, 10);
429 if (!isNaN(val) && val >= 0) {
430 privacyInterSongDelaySeconds = val;
431 log(`Inter-Song delay (Privacy) set to: ${privacyInterSongDelaySeconds}s`);
432 }
433 });
434 }
435 initListResizer(uiElement.querySelector('.listResizer[data-list-id="privacySongList"]'), uiElement.querySelector('#privacySongList'), 'currentPrivacyListHeight');
436 updateChangePrivacyButtonText();
437
438
439 // Common Settings Listeners
440 const autoReloadCheckbox = uiElement.querySelector('#autoReloadToggle');
441 if (autoReloadCheckbox) { autoReloadCheckbox.checked = autoReloadEnabled; autoReloadCheckbox.addEventListener('change', handleAutoReloadToggle); }
442 const debugCheckbox = uiElement.querySelector('#debugToggleCheckbox');
443 if (debugCheckbox) { debugCheckbox.checked = debugMode; debugCheckbox.addEventListener('change', handleDebugToggle); }
444
445 updateUIVisibility();
446 startAutoReloadPolling();
447 }
448
449 function minimizeUI() {
450 if (!uiElement || !minimizedIconElement) return;
451 if (!isMinimized) {
452 lastUiTop = uiElement.style.top || UI_INITIAL_TOP;
453 lastUiLeft = uiElement.style.left || lastUiLeft;
454 }
455 uiElement.style.display = 'none';
456 minimizedIconElement.style.display = 'flex';
457 isMinimized = true;
458 logDebug('UI Minimized');
459 }
460
461 function restoreUI() {
462 if (!uiElement || !minimizedIconElement) return;
463 minimizedIconElement.style.display = 'none';
464 uiElement.style.display = 'block';
465 uiElement.style.top = lastUiTop;
466 uiElement.style.left = lastUiLeft;
467 uiElement.style.right = 'auto';
468 isMinimized = false;
469 logDebug('UI Restored to:', { top: lastUiTop, left: lastUiLeft });
470 updateUIVisibility();
471 }
472
473
474 function navigateToView(view) {
475 if (isDeleting || isDownloading || isChangingPrivacy) {
476 if (!(currentView === 'bulk' && isDeleting)) {
477 log('Cannot switch views while an operation is in progress.', 'warn');
478 return;
479 }
480 }
481 logDebug(`Navigating to view: ${view}`);
482 const oldView = currentView;
483 currentView = view;
484 updateUIVisibility();
485
486 const noAutoReloadOrEmptyList = !autoReloadEnabled ||
487 (view === 'selective' && lastKnownSongIdsDelete.length === 0) ||
488 (view === 'download' && lastKnownSongIdsDownload.length === 0) ||
489 (view === 'privacy' && lastKnownSongIdsPrivacy.length === 0);
490
491 if (noAutoReloadOrEmptyList) {
492 if (view === 'selective') populateDeleteSongListIfNeeded();
493 else if (view === 'download') populateDownloadSongListIfNeeded();
494 else if (view === 'privacy') populatePrivacySongListIfNeeded();
495 }
496 if (autoReloadEnabled && oldView !== currentView) {
497 checkAndReloadLists(true); // Force check current view's list if auto-reload is on
498 }
499 }
500
501 function updateUIVisibility() {
502 if (!uiElement) return; // Guard against calls before uiElement is ready
503
504 if (isMinimized) {
505 uiElement.style.display = 'none';
506 if (minimizedIconElement) minimizedIconElement.style.display = 'flex';
507 return;
508 }
509 if (minimizedIconElement) minimizedIconElement.style.display = 'none';
510 uiElement.style.display = 'block';
511
512 const sections = {
513 menu: uiElement.querySelector('#mainMenuControls'),
514 selective: uiElement.querySelector('#selectiveModeControls'),
515 bulk: uiElement.querySelector('#bulkModeControls'),
516 download: uiElement.querySelector('#downloadQueueControls'),
517 privacy: uiElement.querySelector('#privacyStatusControls')
518 };
519 const headerTitle = uiElement.querySelector('#riffControlHeader h3');
520 const statusMsg = uiElement.querySelector('#statusMessage');
521 let title = `Riffusion Multitool v${GM_info.script.version}`;
522
523 Object.values(sections).forEach(section => {
524 if (section) section.style.display = 'none';
525 });
526
527 const autoReloadContainer = uiElement.querySelector('#autoReloadToggleContainer');
528 const debugContainer = uiElement.querySelector('#debugToggleContainer');
529
530 if (autoReloadContainer) autoReloadContainer.style.display = 'none';
531 if (debugContainer) debugContainer.style.display = 'none';
532
533 const opInProgress = isDeleting || isDownloading || isChangingPrivacy;
534
535 if (sections[currentView]) {
536 sections[currentView].style.display = 'block';
537 switch (currentView) {
538 case 'menu':
539 title += ' - Menu';
540 if (!opInProgress) updateStatusMessage('Select a tool.');
541 if (debugContainer) debugContainer.style.display = 'flex';
542 break;
543 case 'selective':
544 title += ' - Selective Deletion';
545 populateDeleteSongListIfNeeded();
546 if (!opInProgress) updateStatusMessage('Select songs to delete.');
547 if (autoReloadContainer) autoReloadContainer.style.display = 'flex';
548 break;
549 case 'bulk':
550 title += ' - Bulk Deletion';
551 const deleteAllBtn = uiElement.querySelector('#deleteAllButton');
552 if (deleteAllBtn) {
553 deleteAllBtn.textContent = isDeleting ? 'Stop Deletion' : 'Delete Entire Library';
554 }
555 if (!opInProgress) updateStatusMessage('Warning: Deletes entire library.');
556 break;
557 case 'download':
558 title += ' - Download Queue';
559 populateDownloadSongListIfNeeded();
560 if (!opInProgress) updateStatusMessage('Select songs to download.');
561 if (autoReloadContainer) autoReloadContainer.style.display = 'flex';
562 break;
563 case 'privacy':
564 title += ' - Privacy Status';
565 populatePrivacySongListIfNeeded();
566 if (!opInProgress) updateStatusMessage('Select songs and privacy level.');
567 if (autoReloadContainer) autoReloadContainer.style.display = 'flex';
568 updateChangePrivacyButtonText(); // Ensure button text is correct on view switch
569 break;
570 }
571 } else {
572 log(`View '${currentView}' not found, showing menu.`, 'warn');
573 if (sections.menu) sections.menu.style.display = 'block'; // Fallback check
574 currentView = 'menu'; // Default to menu if view is invalid
575 title += ' - Menu';
576 if (!opInProgress) updateStatusMessage('Select a tool.');
577 if (debugContainer) debugContainer.style.display = 'flex';
578 }
579 if (headerTitle) headerTitle.textContent = title;
580 if (statusMsg) statusMsg.style.display = 'block'; // Always show status message area
581
582 // Toggle reload buttons based on autoReloadEnabled state
583 const reloadDelBtn = uiElement.querySelector('#reloadDeleteButton');
584 if (reloadDelBtn) reloadDelBtn.style.display = autoReloadEnabled ? 'none' : 'block';
585 const reloadDownBtn = uiElement.querySelector('#reloadDownloadButton');
586 if (reloadDownBtn) reloadDownBtn.style.display = autoReloadEnabled ? 'none' : 'block';
587 const reloadPrivBtn = uiElement.querySelector('#reloadPrivacyButton');
588 if (reloadPrivBtn) reloadPrivBtn.style.display = autoReloadEnabled ? 'none' : 'block';
589
590 logDebug(`UI Visibility Updated. Current View: ${currentView}`);
591 }
592
593
594 function handleDebugToggle(event) {
595 debugMode = event.target.checked;
596 log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}.`);
597 }
598
599 function enableDrag(element, handle) {
600 let isDragging = false, offsetX, offsetY;
601 handle.addEventListener('mousedown', (e) => {
602 if (e.button !== 0 || e.target.closest('button')) return; // Ignore clicks on buttons within the header
603 if (isMinimized) return; // Don't drag if minimized
604 isDragging = true;
605 const rect = element.getBoundingClientRect();
606 offsetX = e.clientX - rect.left;
607 offsetY = e.clientY - rect.top;
608 element.style.cursor = 'grabbing';
609 handle.style.cursor = 'grabbing'; // Change handle cursor too
610 document.addEventListener('mousemove', onMouseMove);
611 document.addEventListener('mouseup', onMouseUp, { once: true });
612 e.preventDefault(); // Prevent text selection or other default actions
613 });
614 function onMouseMove(e) {
615 if (!isDragging) return;
616 let newX = e.clientX - offsetX;
617 let newY = e.clientY - offsetY;
618 const winWidth = window.innerWidth;
619 const winHeight = window.innerHeight;
620 const elWidth = element.offsetWidth;
621 const elHeight = element.offsetHeight;
622 // Constrain to viewport
623 if (newX < 0) newX = 0;
624 if (newY < 0) newY = 0;
625 if (newX + elWidth > winWidth) newX = winWidth - elWidth;
626 if (newY + elHeight > winHeight) newY = winHeight - elHeight;
627 element.style.left = `${newX}px`;
628 element.style.top = `${newY}px`;
629 element.style.right = 'auto'; // Important: override fixed right if it was set
630 }
631 function onMouseUp(e) {
632 if (e.button !== 0 || !isDragging) return; // Ensure it's the left mouse button releasing
633 isDragging = false;
634 element.style.cursor = 'default';
635 handle.style.cursor = 'move';
636 document.removeEventListener('mousemove', onMouseMove);
637 if (!isMinimized) { // Store position only if not minimized
638 lastUiTop = element.style.top;
639 lastUiLeft = element.style.left;
640 logDebug('Stored new position after drag:', { top: lastUiTop, left: lastUiLeft });
641 }
642 }
643 }
644
645 function initListResizer(resizerElem, listContentElem, heightVarName) {
646 if (!resizerElem || !listContentElem) return;
647 let startY, startHeight;
648
649 resizerElem.addEventListener('mousedown', function(e) {
650 if (e.button !== 0) return; // Only left click
651 e.preventDefault();
652 startY = e.clientY;
653 // Use current style.maxHeight if set, otherwise current offsetHeight
654 startHeight = parseInt(window.getComputedStyle(listContentElem).maxHeight, 10);
655 if (isNaN(startHeight) || startHeight === 0) { // Fallback if maxHeight is not set or 'none'
656 startHeight = listContentElem.offsetHeight;
657 }
658
659 document.addEventListener('mousemove', onMouseMove);
660 document.addEventListener('mouseup', onMouseUp, { once: true });
661 });
662
663 function onMouseMove(e) {
664 if (e.buttons === 0) { // If mouse button was released outside window
665 onMouseUp();
666 return;
667 }
668 const dy = e.clientY - startY;
669 let newHeight = startHeight + dy;
670 newHeight = Math.max(50, newHeight); // Min height
671 newHeight = Math.min(window.innerHeight * 0.8, newHeight); // Max height (80% of viewport)
672
673 listContentElem.style.maxHeight = newHeight + 'px';
674 }
675
676 function onMouseUp() {
677 document.removeEventListener('mousemove', onMouseMove);
678 const finalHeight = listContentElem.style.maxHeight;
679 // Update the corresponding global state variable for height
680 if (heightVarName === 'currentDeleteListHeight') currentDeleteListHeight = finalHeight;
681 else if (heightVarName === 'currentDownloadListHeight') currentDownloadListHeight = finalHeight;
682 else if (heightVarName === 'currentPrivacyListHeight') currentPrivacyListHeight = finalHeight;
683 logDebug(`List ${listContentElem.id} height set to ${finalHeight}`);
684 }
685 }
686
687
688 // --- Song List Population & Filtering ---
689 function getSongDataFromPage() {
690 let listContainer = document.querySelector('div[data-sentry-component="InfiniteScroll"] > div.grow');
691 if (!listContainer || listContainer.children.length === 0) {
692 const allRiffRows = document.querySelectorAll('div[data-sentry-component="DraggableRiffRow"]');
693 if (allRiffRows.length > 0 && allRiffRows[0].parentElement.childElementCount > 1) {
694 if (Array.from(allRiffRows[0].parentElement.children).every(child => child.getAttribute('data-sentry-component') === 'DraggableRiffRow' || child.tagName === 'HR')) {
695 listContainer = allRiffRows[0].parentElement;
696 }
697 }
698 }
699 const songElements = listContainer
700 ? listContainer.querySelectorAll(':scope > div[data-sentry-component="DraggableRiffRow"]')
701 : document.querySelectorAll('div[data-sentry-component="DraggableRiffRow"]');
702
703 const songs = [];
704 songElements.forEach((songElement, index) => {
705 const titleLink = songElement.querySelector('a[href^="/song/"]');
706 let titleElement = titleLink ? titleLink.querySelector('h4.text-primary') : null;
707 if (!titleElement) titleElement = songElement.querySelector('[data-sentry-element="RiffTitle"]'); // Generic title element
708 if (!titleElement && titleLink) titleElement = titleLink.querySelector('div[class*="truncate"], h4'); // Fallback for different structures under link
709 const title = titleElement ? titleElement.textContent.trim() : `Untitled Song ${index + 1}`;
710
711 let songId = null;
712 if (titleLink) {
713 const match = titleLink.href.match(/\/song\/([a-f0-9-]+)/);
714 if (match && match[1]) songId = match[1];
715 }
716 if (!songId) songId = songElement.dataset.songId; // Fallback to data attribute on row
717 if (!songId) { // Try to extract from menu trigger if available and ID follows a pattern
718 const menuTrigger = songElement.querySelector('button[data-sentry-element="MenuTrigger"]');
719 if (menuTrigger && menuTrigger.id) {
720 // Example id: radix-:r2gH:-trigger-songshare:song:bff4d67c-51e1-487e-9bca-a641d5c2df55
721 const idParts = menuTrigger.id.split(':');
722 if (idParts.length > 2 && idParts[idParts.length-2] === 'song') { // Check if 'song' is the second to last part
723 songId = idParts[idParts.length-1];
724 }
725 }
726 }
727 if (!songId) { logDebug(`Could not determine songId for element at index ${index}:`, songElement); return; } // Skip if no ID can be found
728
729 const unfavoriteButton = songElement.querySelector('button[aria-label^="Unfavorite"]');
730 const solidHeartIcon = songElement.querySelector('button svg[data-prefix="fas"][data-icon="heart"]'); // Solid heart for liked
731 let isLiked = !!unfavoriteButton || (!!solidHeartIcon && !songElement.querySelector('button[aria-label^="Favorite"]')); // Liked if unfav exists, or solid heart without fav button
732
733 songs.push({ id: songId, title: title, titleLower: title.toLowerCase(), isLiked: isLiked, element: songElement });
734 });
735 return songs;
736 }
737
738 function populateDeleteSongListIfNeeded() {
739 if (!uiElement) return;
740 const songListDiv = uiElement.querySelector('#deleteSongList');
741 if (!songListDiv) return;
742 if (isDeleting || isDownloading || isChangingPrivacy) { logDebug('Skipping delete list population during active operation.'); return; }
743 if (songListDiv.innerHTML === '' || songListDiv.innerHTML === 'Loading...' || songListDiv.children.length === 0 || (songListDiv.children.length === 1 && songListDiv.firstElementChild.tagName === 'P')) {
744 populateDeleteSongList();
745 }
746 }
747 function populateDeleteSongList() {
748 if (!uiElement || currentView !== 'selective' || isMinimized) return;
749 if (isDeleting || isDownloading || isChangingPrivacy) { logDebug('Skipping delete list population during active operation.'); return; }
750 logDebug('Populating DELETE song list...');
751 const songListDiv = uiElement.querySelector('#deleteSongList');
752 const deleteCounter = uiElement.querySelector('#deleteCounter');
753 if (!songListDiv || !deleteCounter) return;
754
755 songListDiv.innerHTML = 'Loading...';
756 deleteCounter.textContent = 'Deleted: 0 / 0';
757
758 const selectAllCheckbox = uiElement.querySelector('#deleteSelectAll');
759 if (selectAllCheckbox) selectAllCheckbox.checked = false;
760 const ignoreLikedCheckbox = uiElement.querySelector('#ignoreLikedToggleDelete');
761 if(ignoreLikedCheckbox) ignoreLikedCheckbox.checked = ignoreLikedSongsDeleteState;
762
763 const keywordInput = uiElement.querySelector('#deleteKeywordFilterInput');
764 const keywordString = keywordInput ? keywordInput.value : '';
765 const dynamicIgnoreKeywords = keywordString.split(',').map(k => k.trim().toLowerCase()).filter(k => k !== '');
766
767 setTimeout(() => { // Delay to allow DOM to update if called rapidly
768 const songsFromPage = getSongDataFromPage();
769 songListDiv.innerHTML = ''; // Clear loading message
770 songListDiv.style.maxHeight = currentDeleteListHeight; // Apply current height
771
772 if (songsFromPage.length === 0) {
773 songListDiv.innerHTML = '<p style="color:#d0d0d0;text-align:center;font-size:12px;">No songs found on page.</p>';
774 if(!(isDeleting || isDownloading || isChangingPrivacy)) updateStatusMessage('No songs found.');
775 lastKnownSongIdsDelete = [];
776 return;
777 }
778
779 let ignoredCount = 0;
780 let visibleCount = 0;
781
782 songsFromPage.forEach(song => {
783 const keywordMatch = dynamicIgnoreKeywords.length > 0 && dynamicIgnoreKeywords.some(keyword => song.titleLower.includes(keyword));
784 const likedMatch = ignoreLikedSongsDeleteState && song.isLiked;
785 const shouldIgnore = keywordMatch || likedMatch;
786
787 let ignoreReason = '';
788 if (keywordMatch) ignoreReason += 'Keyword';
789 if (likedMatch) ignoreReason += (keywordMatch ? ' & Liked' : 'Liked');
790
791 const label = document.createElement('label');
792 const checkbox = document.createElement('input');
793 checkbox.type = 'checkbox';
794 checkbox.dataset.songId = song.id;
795 checkbox.disabled = shouldIgnore;
796 checkbox.checked = selectedSongIdsDelete.has(song.id) && !shouldIgnore;
797
798 checkbox.addEventListener('change', (event) => {
799 if (event.target.checked) selectedSongIdsDelete.add(song.id);
800 else selectedSongIdsDelete.delete(song.id);
801 updateSelectAllCheckboxState('#deleteSelectAll', '#deleteSongList');
802 });
803
804 label.appendChild(checkbox);
805 label.appendChild(document.createTextNode(` ${song.title}`));
806 if (song.isLiked) label.classList.add('liked');
807
808 if (shouldIgnore) {
809 label.classList.add('ignored');
810 label.title = `Ignoring for delete: ${ignoreReason}`;
811 ignoredCount++;
812 } else {
813 visibleCount++;
814 }
815 songListDiv.appendChild(label);
816 });
817 updateSelectAllCheckboxState('#deleteSelectAll', '#deleteSongList');
818
819 logDebug(`Populated DELETE list: ${songsFromPage.length} total, ${visibleCount} selectable, ${ignoredCount} ignored. ${selectedSongIdsDelete.size} selected.`);
820 if(!(isDeleting || isDownloading || isChangingPrivacy)) updateStatusMessage(`Loaded ${songsFromPage.length} songs (${ignoredCount} ignored).`);
821 lastKnownSongIdsDelete = songsFromPage.map(s => s.id);
822 }, 100);
823 }
824
825 function populateDownloadSongListIfNeeded() {
826 if (!uiElement) return;
827 const songListDiv = uiElement.querySelector('#downloadSongList');
828 if (!songListDiv) return;
829 if (isDeleting || isDownloading || isChangingPrivacy) { logDebug('Skipping download list population during active operation.'); return; }
830 if (songListDiv.innerHTML === '' || songListDiv.innerHTML === 'Loading...' || songListDiv.children.length === 0 || (songListDiv.children.length === 1 && songListDiv.firstElementChild.tagName === 'P')) {
831 populateDownloadSongList();
832 }
833 }
834 function populateDownloadSongList() {
835 if (!uiElement || currentView !== 'download' || isMinimized) return;
836 if (isDeleting || isDownloading || isChangingPrivacy) { logDebug('Skipping download list population during active operation.'); return; }
837 logDebug('Populating DOWNLOAD song list...');
838 const songListDiv = uiElement.querySelector('#downloadSongList');
839 const downloadCounter = uiElement.querySelector('#downloadCounter');
840 if (!songListDiv || !downloadCounter) return;
841
842 songListDiv.innerHTML = 'Loading...';
843 downloadCounter.textContent = 'Downloaded: 0 / 0';
844
845 const selectAllCheckbox = uiElement.querySelector('#downloadSelectAll');
846 if (selectAllCheckbox) selectAllCheckbox.checked = false;
847
848 const keywordInput = uiElement.querySelector('#downloadKeywordFilterInput');
849 const keywordString = keywordInput ? keywordInput.value : '';
850 const filterKeywords = keywordString.split(',').map(k => k.trim().toLowerCase()).filter(k => k !== '');
851
852 setTimeout(() => {
853 const songsFromPage = getSongDataFromPage();
854 songListDiv.innerHTML = '';
855 songListDiv.style.maxHeight = currentDownloadListHeight;
856
857 if (songsFromPage.length === 0) {
858 songListDiv.innerHTML = '<p style="color:#d0d0d0;text-align:center;font-size:12px;">No songs found on page.</p>';
859 if(!(isDeleting || isDownloading || isChangingPrivacy)) updateStatusMessage('No songs found.');
860 updateDownloadSelectLikedButtonText();
861 lastKnownSongIdsDownload = [];
862 return;
863 }
864
865 let displayedCount = 0;
866
867 songsFromPage.forEach(song => {
868 const keywordMatch = filterKeywords.length === 0 || filterKeywords.some(keyword => song.titleLower.includes(keyword));
869
870 if (keywordMatch) {
871 const label = document.createElement('label');
872 const checkbox = document.createElement('input');
873 checkbox.type = 'checkbox';
874 checkbox.dataset.songId = song.id;
875 checkbox.dataset.isLiked = song.isLiked.toString(); // Store liked status
876 checkbox.checked = selectedSongIdsDownload.has(song.id);
877
878 checkbox.addEventListener('change', (event) => {
879 if (event.target.checked) selectedSongIdsDownload.add(song.id);
880 else selectedSongIdsDownload.delete(song.id);
881 updateSelectAllCheckboxState('#downloadSelectAll', '#downloadSongList');
882 updateDownloadSelectLikedButtonText();
883 });
884
885 label.appendChild(checkbox);
886 label.appendChild(document.createTextNode(` ${song.title}`));
887 if (song.isLiked) label.classList.add('liked');
888
889 songListDiv.appendChild(label);
890 displayedCount++;
891 }
892 });
893 updateSelectAllCheckboxState('#downloadSelectAll', '#downloadSongList');
894
895 logDebug(`Populated DOWNLOAD list: ${songsFromPage.length} total, ${displayedCount} displayed. ${selectedSongIdsDownload.size} selected.`);
896 if(!(isDeleting || isDownloading || isChangingPrivacy)) updateStatusMessage(`Showing ${displayedCount} of ${songsFromPage.length} songs.`);
897 updateDownloadSelectLikedButtonText();
898 lastKnownSongIdsDownload = songsFromPage.map(s => s.id);
899 }, 100);
900 }
901
902 function populatePrivacySongListIfNeeded() {
903 if (!uiElement) return;
904 const songListDiv = uiElement.querySelector('#privacySongList');
905 if (!songListDiv) return;
906 if (isDeleting || isDownloading || isChangingPrivacy) { logDebug('Skipping privacy list population during active operation.'); return; }
907 if (songListDiv.innerHTML === '' || songListDiv.innerHTML === 'Loading...' || songListDiv.children.length === 0 || (songListDiv.children.length === 1 && songListDiv.firstElementChild.tagName === 'P')) {
908 populatePrivacySongList();
909 }
910 }
911
912 function populatePrivacySongList() {
913 if (!uiElement || currentView !== 'privacy' || isMinimized) return;
914 if (isDeleting || isDownloading || isChangingPrivacy) { logDebug('Skipping privacy list population during active operation.'); return; }
915 logDebug('Populating PRIVACY song list...');
916 const songListDiv = uiElement.querySelector('#privacySongList');
917 const privacyCounter = uiElement.querySelector('#privacyCounter');
918 if (!songListDiv || !privacyCounter) return;
919
920 songListDiv.innerHTML = 'Loading...';
921 privacyCounter.textContent = 'Updated: 0 / 0';
922
923 const selectAllCheckbox = uiElement.querySelector('#privacySelectAll');
924 if (selectAllCheckbox) selectAllCheckbox.checked = false;
925
926 const keywordInput = uiElement.querySelector('#privacyKeywordFilterInput');
927 const keywordString = keywordInput ? keywordInput.value : '';
928 const filterKeywords = keywordString.split(',').map(k => k.trim().toLowerCase()).filter(k => k !== '');
929
930 setTimeout(() => {
931 const songsFromPage = getSongDataFromPage();
932 songListDiv.innerHTML = '';
933 songListDiv.style.maxHeight = currentPrivacyListHeight;
934
935 if (songsFromPage.length === 0) {
936 songListDiv.innerHTML = '<p style="color:#d0d0d0;text-align:center;font-size:12px;">No songs found on page.</p>';
937 if(!(isDeleting || isDownloading || isChangingPrivacy)) updateStatusMessage('No songs found.');
938 updatePrivacySelectLikedButtonText();
939 lastKnownSongIdsPrivacy = [];
940 return;
941 }
942
943 let displayedCount = 0;
944 songsFromPage.forEach(song => {
945 const keywordMatch = filterKeywords.length === 0 || filterKeywords.some(keyword => song.titleLower.includes(keyword));
946
947 if (keywordMatch) {
948 const label = document.createElement('label');
949 const checkbox = document.createElement('input');
950 checkbox.type = 'checkbox';
951 checkbox.dataset.songId = song.id;
952 checkbox.dataset.isLiked = song.isLiked.toString();
953 checkbox.checked = selectedSongIdsPrivacy.has(song.id);
954
955 checkbox.addEventListener('change', (event) => {
956 if (event.target.checked) selectedSongIdsPrivacy.add(song.id);
957 else selectedSongIdsPrivacy.delete(song.id);
958 updateSelectAllCheckboxState('#privacySelectAll', '#privacySongList');
959 updatePrivacySelectLikedButtonText();
960 });
961
962 label.appendChild(checkbox);
963 label.appendChild(document.createTextNode(` ${song.title}`));
964 if (song.isLiked) label.classList.add('liked');
965
966 songListDiv.appendChild(label);
967 displayedCount++;
968 }
969 });
970 updateSelectAllCheckboxState('#privacySelectAll', '#privacySongList');
971
972 logDebug(`Populated PRIVACY list: ${songsFromPage.length} total, ${displayedCount} displayed. ${selectedSongIdsPrivacy.size} selected.`);
973 if(!(isDeleting || isDownloading || isChangingPrivacy)) updateStatusMessage(`Showing ${displayedCount} of ${songsFromPage.length} songs.`);
974 updatePrivacySelectLikedButtonText();
975 lastKnownSongIdsPrivacy = songsFromPage.map(s => s.id);
976 }, 100);
977 }
978
979
980 function updateSelectAllCheckboxState(selectAllSelector, listSelector) {
981 if (!uiElement) return;
982 const selectAllCb = uiElement.querySelector(selectAllSelector);
983 if (!selectAllCb) return;
984 const visibleCheckboxes = uiElement.querySelectorAll(`${listSelector} input[type="checkbox"]:not(:disabled)`);
985 const checkedVisibleCheckboxes = uiElement.querySelectorAll(`${listSelector} input[type="checkbox"]:not(:disabled):checked`);
986 selectAllCb.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.length === checkedVisibleCheckboxes.length;
987 }
988
989 function toggleSelectAll(event, listSelector, selectionSet, type) {
990 if (!uiElement || isMinimized || isDeleting || isDownloading || isChangingPrivacy) return;
991 const isChecked = event.target.checked;
992 const checkboxes = uiElement.querySelectorAll(`${listSelector} input[type="checkbox"]:not(:disabled)`);
993 checkboxes.forEach(cb => {
994 cb.checked = isChecked;
995 const songId = cb.dataset.songId;
996 if (isChecked) selectionSet.add(songId);
997 else selectionSet.delete(songId);
998 });
999 logDebug(`Select All Toggled in ${listSelector}: ${isChecked} (${checkboxes.length} items). Selection Set: ${selectionSet.size}`);
1000 if (type === 'download') updateDownloadSelectLikedButtonText();
1001 else if (type === 'privacy') updatePrivacySelectLikedButtonText();
1002 }
1003
1004 function toggleSelectLikedGeneric(listSelector, selectionSet, selectAllSelector, updateButtonTextFn) {
1005 if (!uiElement || isMinimized || isDeleting || isDownloading || isChangingPrivacy) return;
1006 const checkboxes = uiElement.querySelectorAll(`${listSelector} input[type="checkbox"]:not(:disabled)`);
1007 if (checkboxes.length === 0) { logWarn('No songs available in list for liked toggle.'); return; }
1008
1009 let shouldSelect = false; // Determine if we should select or deselect liked songs
1010 for (const cb of checkboxes) {
1011 if (cb.dataset.isLiked === 'true' && !cb.checked) { shouldSelect = true; break; }
1012 }
1013
1014 let changedCount = 0;
1015 checkboxes.forEach(cb => {
1016 if (cb.dataset.isLiked === 'true') {
1017 if (cb.checked !== shouldSelect) { // Only change if current state is different from target state
1018 cb.checked = shouldSelect;
1019 const songId = cb.dataset.songId;
1020 if (shouldSelect) selectionSet.add(songId); else selectionSet.delete(songId);
1021 changedCount++;
1022 }
1023 }
1024 });
1025 log(`Toggled selection for ${changedCount} liked songs. Action: ${shouldSelect ? 'Select' : 'Deselect'}`);
1026 updateStatusMessage(`${shouldSelect ? 'Selected' : 'Deselected'} ${changedCount} liked songs.`);
1027 updateSelectAllCheckboxState(selectAllSelector, listSelector);
1028 if (updateButtonTextFn) updateButtonTextFn();
1029 }
1030
1031 function clearSelectionGeneric(listSelector, selectionSet, selectAllSelector, updateButtonTextFn) {
1032 if (!uiElement || isMinimized || isDeleting || isDownloading || isChangingPrivacy) return;
1033 const checkboxes = uiElement.querySelectorAll(`${listSelector} input[type="checkbox"]:checked`);
1034 if (checkboxes.length === 0) { log('No songs selected to clear.', 'info'); return; }
1035
1036 checkboxes.forEach(cb => cb.checked = false);
1037 selectionSet.clear();
1038
1039 const selectAllCheckbox = uiElement.querySelector(selectAllSelector);
1040 if (selectAllCheckbox) selectAllCheckbox.checked = false;
1041 log(`Cleared selection for ${checkboxes.length} songs in ${listSelector}.`);
1042 updateStatusMessage('Selection cleared.');
1043 if (updateButtonTextFn) updateButtonTextFn();
1044 }
1045
1046
1047 function updateDownloadSelectLikedButtonText() {
1048 if (!uiElement || currentView !== 'download' || isMinimized) return;
1049 const button = uiElement.querySelector('#downloadSelectLiked');
1050 if (!button) return;
1051
1052 const checkboxes = uiElement.querySelectorAll('#downloadSongList input[type="checkbox"]:not(:disabled)');
1053 if (checkboxes.length === 0) {
1054 button.textContent = 'Select Liked';
1055 button.disabled = true; return;
1056 }
1057 button.disabled = false;
1058 let shouldOfferSelect = false;
1059 checkboxes.forEach(cb => {
1060 if (cb.dataset.isLiked === 'true' && !cb.checked) {
1061 shouldOfferSelect = true;
1062 }
1063 });
1064 button.textContent = shouldOfferSelect ? 'Select Liked' : 'Deselect Liked';
1065 }
1066
1067 function updatePrivacySelectLikedButtonText() {
1068 if (!uiElement || currentView !== 'privacy' || isMinimized) return;
1069 const button = uiElement.querySelector('#privacySelectLiked');
1070 if (!button) return;
1071
1072 const checkboxes = uiElement.querySelectorAll('#privacySongList input[type="checkbox"]:not(:disabled)');
1073 if (checkboxes.length === 0) {
1074 button.textContent = 'Select Liked';
1075 button.disabled = true; return;
1076 }
1077 button.disabled = false;
1078 let shouldOfferSelect = false;
1079 checkboxes.forEach(cb => {
1080 if (cb.dataset.isLiked === 'true' && !cb.checked) {
1081 shouldOfferSelect = true;
1082 }
1083 });
1084 button.textContent = shouldOfferSelect ? 'Select Liked' : 'Deselect Liked';
1085 }
1086
1087 function updateChangePrivacyButtonText() {
1088 if (!uiElement || currentView !== 'privacy' || isMinimized) return;
1089 const button = uiElement.querySelector('#changePrivacyStatusButton');
1090 const select = uiElement.querySelector('#privacyLevelSelect');
1091 if (button && select) {
1092 const selectedOption = select.value;
1093 button.textContent = privacyButtonTextMap[selectedOption] || 'Change Status';
1094 }
1095 }
1096
1097 function updateCounter(type, count, total) {
1098 if (!uiElement || isMinimized) return;
1099 let counterElementId = '';
1100 if (type === 'delete') counterElementId = 'deleteCounter';
1101 else if (type === 'download') counterElementId = 'downloadCounter';
1102 else if (type === 'privacy') counterElementId = 'privacyCounter';
1103 else return;
1104
1105 const counterElement = uiElement.querySelector(`#${counterElementId}`); // Scoped query
1106 if (counterElement) {
1107 const prefix = type === 'delete' ? 'Deleted' : (type === 'download' ? 'Downloaded' : 'Updated');
1108 counterElement.textContent = `${prefix}: ${count} / ${total}`;
1109 }
1110 }
1111
1112 // --- Auto Reload Logic ---
1113 function handleAutoReloadToggle(event) {
1114 autoReloadEnabled = event.target.checked;
1115 log(`Automatic list reload ${autoReloadEnabled ? 'enabled' : 'disabled'}.`);
1116 if (!uiElement) return;
1117 const reloadDelBtn = uiElement.querySelector('#reloadDeleteButton');
1118 if (reloadDelBtn) reloadDelBtn.style.display = autoReloadEnabled ? 'none' : 'block';
1119 const reloadDownBtn = uiElement.querySelector('#reloadDownloadButton');
1120 if (reloadDownBtn) reloadDownBtn.style.display = autoReloadEnabled ? 'none' : 'block';
1121 const reloadPrivBtn = uiElement.querySelector('#reloadPrivacyButton');
1122 if (reloadPrivBtn) reloadPrivBtn.style.display = autoReloadEnabled ? 'none' : 'block';
1123
1124
1125 if (autoReloadEnabled) {
1126 startAutoReloadPolling();
1127 checkAndReloadLists(true); // Force a check on toggle to immediately reflect state
1128 } else {
1129 stopAutoReloadPolling();
1130 }
1131 }
1132
1133 function startAutoReloadPolling() {
1134 if (autoReloadTimer) clearInterval(autoReloadTimer);
1135 if (autoReloadEnabled) {
1136 autoReloadTimer = setInterval(() => checkAndReloadLists(false), AUTO_RELOAD_INTERVAL);
1137 logDebug('Auto-reload polling started.');
1138 }
1139 }
1140
1141 function stopAutoReloadPolling() {
1142 if (autoReloadTimer) {
1143 clearInterval(autoReloadTimer);
1144 autoReloadTimer = null;
1145 logDebug('Auto-reload polling stopped.');
1146 }
1147 }
1148
1149
1150 function checkAndReloadLists(forceCheckCurrentView = false) {
1151 if ((!autoReloadEnabled && !forceCheckCurrentView) || !uiElement || isMinimized || isDeleting || isDownloading || isChangingPrivacy || uiElement.style.display === 'none') {
1152 return;
1153 }
1154
1155 const songsOnPage = getSongDataFromPage();
1156 const currentPageSongIds = songsOnPage.map(s => s.id);
1157 const currentPageSongIdsString = [...currentPageSongIds].sort().join(','); // Create a comparable string
1158
1159 let listNeedsRefresh = false;
1160 let currentListIsEmpty = false;
1161
1162 if (currentView === 'selective' || (forceCheckCurrentView && currentView === 'selective')) {
1163 const knownIdsString = [...lastKnownSongIdsDelete].sort().join(',');
1164 listNeedsRefresh = currentPageSongIdsString !== knownIdsString;
1165 currentListIsEmpty = uiElement.querySelector('#deleteSongList')?.children.length === 0;
1166 if (listNeedsRefresh || (forceCheckCurrentView && currentListIsEmpty && songsOnPage.length > 0)) {
1167 logDebug('Page song list changed for Selective Deletion. Reloading UI list.');
1168 populateDeleteSongList();
1169 }
1170 } else if (currentView === 'download' || (forceCheckCurrentView && currentView === 'download')) {
1171 const knownIdsString = [...lastKnownSongIdsDownload].sort().join(',');
1172 listNeedsRefresh = currentPageSongIdsString !== knownIdsString;
1173 currentListIsEmpty = uiElement.querySelector('#downloadSongList')?.children.length === 0;
1174 if (listNeedsRefresh || (forceCheckCurrentView && currentListIsEmpty && songsOnPage.length > 0)) {
1175 logDebug('Page song list changed for Download Queue. Reloading UI list.');
1176 populateDownloadSongList();
1177 }
1178 } else if (currentView === 'privacy' || (forceCheckCurrentView && currentView === 'privacy')) {
1179 const knownIdsString = [...lastKnownSongIdsPrivacy].sort().join(',');
1180 listNeedsRefresh = currentPageSongIdsString !== knownIdsString;
1181 currentListIsEmpty = uiElement.querySelector('#privacySongList')?.children.length === 0;
1182 if (listNeedsRefresh || (forceCheckCurrentView && currentListIsEmpty && songsOnPage.length > 0)) {
1183 logDebug('Page song list changed for Privacy Status. Reloading UI list.');
1184 populatePrivacySongList();
1185 }
1186 }
1187 }
1188
1189
1190 // --- Deletion Logic ---
1191 function getCurrentSongElements() { // Helper to get current song row elements on the page
1192 let listContainer = document.querySelector('div[data-sentry-component="InfiniteScroll"] > div.grow');
1193 if (!listContainer || listContainer.children.length === 0) {
1194 const allRiffRows = document.querySelectorAll('div[data-sentry-component="DraggableRiffRow"]');
1195 if (allRiffRows.length > 0 && allRiffRows[0].parentElement.childElementCount > 1) {
1196 if (Array.from(allRiffRows[0].parentElement.children).every(child => child.getAttribute('data-sentry-component') === 'DraggableRiffRow' || child.tagName === 'HR')) {
1197 listContainer = allRiffRows[0].parentElement;
1198 }
1199 }
1200 }
1201 if(listContainer) {
1202 return listContainer.querySelectorAll(':scope > div[data-sentry-component="DraggableRiffRow"]');
1203 }
1204 return document.querySelectorAll('div[data-sentry-component="DraggableRiffRow"]');
1205 }
1206
1207
1208 async function deleteSelectedSongs() {
1209 if (isMinimized) { log('Restore UI to delete.', 'warn'); return; }
1210 if (currentView !== 'selective') { log('Selective delete only in Selective View.', 'warn'); return; }
1211 if (isDeleting || isDownloading || isChangingPrivacy) { log('Operation in progress.', 'warn'); return; }
1212
1213 const songIdsToDeleteArray = Array.from(selectedSongIdsDelete);
1214 const totalToDelete = songIdsToDeleteArray.length;
1215
1216 if (totalToDelete === 0) {
1217 updateCounter('delete', 0, 0); log('No songs selected for deletion.');
1218 updateStatusMessage('No songs selected or all selected are ignored.'); return;
1219 }
1220
1221 isDeleting = true; setAllButtonsDisabled(true);
1222 log(`Starting deletion for ${totalToDelete} selected: [${songIdsToDeleteArray.join(', ')}]`);
1223 updateCounter('delete', 0, totalToDelete); updateStatusMessage(`Deleting ${totalToDelete} selected...`);
1224
1225 let deletedCount = 0, criticalErrorOccurred = false;
1226 for (const songId of songIdsToDeleteArray) {
1227 if (criticalErrorOccurred || !isDeleting) { break; }
1228 const songElement = Array.from(getCurrentSongElements()).find(el => {
1229 const link = el.querySelector(`a[href="/song/${songId}"]`);
1230 return !!link;
1231 });
1232 if (!songElement) {
1233 log(`Song ID ${songId} not found (deleted?). Removing from selection.`, 'warn');
1234 selectedSongIdsDelete.delete(songId); continue;
1235 }
1236 const title = getSongDataFromPage().find(s => s.id === songId)?.title || `song ID ${songId}`;
1237 const success = await processSingleAction(songElement, 'delete', songId);
1238 if (success) {
1239 deletedCount++; selectedSongIdsDelete.delete(songId);
1240 updateCounter('delete', deletedCount, totalToDelete); updateStatusMessage(`Deleted ${deletedCount}/${totalToDelete}...`);
1241 } else {
1242 log(`Failed to delete ${title}. Stopping.`, 'error');
1243 updateStatusMessage(`Error deleting ${title}. Stopped.`); criticalErrorOccurred = true;
1244 }
1245 await delay(50);
1246 }
1247 log(`Selective deletion: ${deletedCount}/${totalToDelete} attempted.`);
1248 updateStatusMessage(criticalErrorOccurred ? `Deletion stopped. ${deletedCount} deleted.` : `Selected deletion complete. ${deletedCount} deleted.`);
1249 isDeleting = false; setAllButtonsDisabled(false); populateDeleteSongList();
1250 }
1251
1252 function stopBulkDeletionByUser() {
1253 if (!isDeleting || currentView !== 'bulk') { return; }
1254 log('Bulk deletion stop by user.'); updateStatusMessage('Stopping bulk deletion...');
1255 stopBulkDeletionSignal = true;
1256 }
1257
1258 async function deleteAllSongsInLibrary() {
1259 if (isMinimized) { log('Restore UI to delete.', 'warn'); return; }
1260 if (currentView !== 'bulk') { log('Bulk delete only in Bulk View.', 'warn'); return; }
1261 if (isDownloading || isChangingPrivacy) { log('Another operation is in progress.', 'warn'); return; }
1262
1263 stopBulkDeletionSignal = false; isDeleting = true; setAllButtonsDisabled(true);
1264 const deleteAllBtn = uiElement.querySelector('#deleteAllButton'); // Scoped query
1265 if (deleteAllBtn) deleteAllBtn.textContent = 'Stop Deletion';
1266 log('--- STARTING BULK LIBRARY DELETION ---'); updateStatusMessage('Starting full library deletion...');
1267 let totalDeleted = 0, emptyChecks = 0;
1268 while (isDeleting && !stopBulkDeletionSignal) {
1269 await delay(500);
1270 if (stopBulkDeletionSignal) { isDeleting = false; break; }
1271
1272 const currentElements = getCurrentSongElements();
1273 let currentSize = currentElements.length;
1274 if (currentSize === 0) {
1275 emptyChecks++;
1276 if (emptyChecks >= MAX_EMPTY_CHECKS) { log('No songs after retries. Assuming empty.'); isDeleting = false; break; }
1277 updateStatusMessage(`No songs. Re-checking (${emptyChecks}/${MAX_EMPTY_CHECKS})...`); await delay(EMPTY_RETRY_DELAY); continue;
1278 }
1279 emptyChecks = 0; let batchDeleted = 0;
1280
1281 for (const firstElement of currentElements) { // Iterate over a static list for this batch
1282 if (!isDeleting || stopBulkDeletionSignal) { isDeleting = false; break; }
1283
1284 if (!firstElement || !firstElement.parentNode) { continue; }
1285 const title = (firstElement.querySelector('a[href^="/song/"] h4, [data-sentry-element="RiffTitle"], div[class*="truncate"]') || {textContent: 'Top song'}).textContent.trim();
1286 updateStatusMessage(`Deleting ${title} (${totalDeleted + batchDeleted + 1} total...)`);
1287 const success = await processSingleAction(firstElement, 'delete', `Bulk ${totalDeleted + batchDeleted + 1}`);
1288 if (success) {
1289 batchDeleted++;
1290 await delay(DELETION_DELAY / 2);
1291 } else {
1292 log(`Failed to delete ${title}. Stopping.`, 'error'); updateStatusMessage(`Error deleting ${title}.`); isDeleting = false; break;
1293 }
1294 await delay(50);
1295 }
1296 totalDeleted += batchDeleted;
1297 if (isDeleting && !stopBulkDeletionSignal) updateStatusMessage(`Batch done. Total: ${totalDeleted}. Checking more...`);
1298 }
1299 if (deleteAllBtn) deleteAllBtn.textContent = 'Delete Entire Library';
1300 let finalMsg = stopBulkDeletionSignal ? `Stopped by user. Total: ${totalDeleted}.`
1301 : (emptyChecks >= MAX_EMPTY_CHECKS ? `Complete. No more songs. Total: ${totalDeleted}.`
1302 : `Finished. Total: ${totalDeleted}.`);
1303 log(`--- BULK DELETION FINISHED --- ${finalMsg}`); updateStatusMessage(finalMsg);
1304 isDeleting = false; stopBulkDeletionSignal = false; setAllButtonsDisabled(false);
1305 }
1306
1307 // --- Download Logic ---
1308 async function startDownloadQueue() {
1309 if (!uiElement || isMinimized) { log('Restore UI to download.', 'warn'); return; }
1310 if (currentView !== 'download') { log('Download only in Download View.', 'warn'); return; }
1311 if (isDeleting || isDownloading || isChangingPrivacy) { log('Operation in progress.', 'warn'); return; }
1312
1313 const songIdsToDownloadArray = Array.from(selectedSongIdsDownload);
1314 const totalSongsToDownload = songIdsToDownloadArray.length;
1315 if (totalSongsToDownload === 0) { updateCounter('download', 0, 0); log('No songs selected.'); updateStatusMessage('No songs selected.'); return; }
1316 const selectedFormats = [];
1317 if (uiElement.querySelector('#formatMP3')?.checked) selectedFormats.push('MP3'); // Scoped query
1318 if (uiElement.querySelector('#formatM4A')?.checked) selectedFormats.push('M4A'); // Scoped query
1319 if (uiElement.querySelector('#formatWAV')?.checked) selectedFormats.push('WAV'); // Scoped query
1320 if (selectedFormats.length === 0) { log('No formats selected.', 'error'); updateStatusMessage('Select download format(s).'); return; }
1321
1322 isDownloading = true; setAllButtonsDisabled(true);
1323 const interSongDelayMs = downloadInterSongDelaySeconds * 1000, intraFormatDelayMs = downloadIntraFormatDelaySeconds * 1000;
1324 log(`Starting download: ${totalSongsToDownload} songs. Formats: [${selectedFormats.join(', ')}]`);
1325 updateCounter('download', 0, totalSongsToDownload); updateStatusMessage(`Downloading ${totalSongsToDownload} (${selectedFormats.join('/')})...`);
1326 let songsProcessedCount = 0, criticalErrorOccurred = false;
1327
1328 for (const songId of songIdsToDownloadArray) {
1329 if (criticalErrorOccurred || !isDownloading) { break; }
1330 const songElement = Array.from(getCurrentSongElements()).find(el => {
1331 const link = el.querySelector(`a[href="/song/${songId}"]`);
1332 return !!link;
1333 });
1334 if (!songElement) { log(`Song ID ${songId} not found. Skipping.`, 'warn'); selectedSongIdsDownload.delete(songId); continue; }
1335 const title = getSongDataFromPage().find(s => s.id === songId)?.title || `song ID ${songId}`;
1336 let songDownloadSuccessThisSong = false, formatIndex = 0;
1337 for (const format of selectedFormats) {
1338 if (!isDownloading) { criticalErrorOccurred = true; break; }
1339 const currentSongElCheck = Array.from(getCurrentSongElements()).find(el => {
1340 const link = el.querySelector(`a[href="/song/${songId}"]`);
1341 return !!link;
1342 });
1343 if (!currentSongElCheck) { logWarn(`${title} disappeared. Skipping formats.`); criticalErrorOccurred = true; break; }
1344 updateStatusMessage(`DL ${songsProcessedCount + 1}/${totalSongsToDownload}: ${title} (${format})...`);
1345 const success = await processSingleAction(currentSongElCheck, 'download', `${songId}-${format}`, format);
1346 if (success) {
1347 songDownloadSuccessThisSong = true; formatIndex++;
1348 if (formatIndex < selectedFormats.length && isDownloading && intraFormatDelayMs > 0) await delay(intraFormatDelayMs);
1349 else if (isDownloading && intraFormatDelayMs <= 0 && formatIndex < selectedFormats.length) await delay(50);
1350 } else {
1351 log(`Failed DL ${title} (${format}). Stopping.`, 'error'); updateStatusMessage(`Error DL ${title} (${format}).`); criticalErrorOccurred = true; break;
1352 }
1353 }
1354 if (songDownloadSuccessThisSong) songsProcessedCount++;
1355 if (criticalErrorOccurred || !isDownloading) break;
1356 if (songsProcessedCount < totalSongsToDownload && isDownloading && (songIdsToDownloadArray.indexOf(songId) < songIdsToDownloadArray.length -1) ) {
1357 updateStatusMessage(`Wait ${downloadInterSongDelaySeconds}s for next song...`); await delay(interSongDelayMs);
1358 }
1359 }
1360 log(`DL queue finished. Processed ${songsProcessedCount}/${totalSongsToDownload}.`);
1361 updateStatusMessage(criticalErrorOccurred ? `DL stopped. ${songsProcessedCount} processed.` : `DL queue complete. ${songsProcessedCount} processed.`);
1362 isDownloading = false; setAllButtonsDisabled(false); populateDownloadSongList();
1363 }
1364
1365 async function changeSelectedSongsPrivacy() {
1366 if (!uiElement || isMinimized) { log('Restore UI to change privacy.', 'warn'); return; }
1367 if (currentView !== 'privacy') { log('Privacy change only in Privacy View.', 'warn'); return; }
1368 if (isDeleting || isDownloading || isChangingPrivacy) { log('Operation in progress.', 'warn'); return; }
1369
1370 const songIdsToChangeArray = Array.from(selectedSongIdsPrivacy);
1371 const totalToChange = songIdsToChangeArray.length;
1372 if (totalToChange === 0) {
1373 updateCounter('privacy', 0, 0); log('No songs selected for privacy change.');
1374 updateStatusMessage('No songs selected.'); return;
1375 }
1376
1377 const privacyLevelSelect = uiElement.querySelector('#privacyLevelSelect'); // Scoped query
1378 const targetPrivacyKey = privacyLevelSelect ? privacyLevelSelect.value : 'Only Me';
1379 const targetSubMenuText = privacySubmenuTextMap[targetPrivacyKey]; // Get exact submenu text
1380
1381 isChangingPrivacy = true; stopPrivacyChangeSignal = false; setAllButtonsDisabled(true);
1382 const interSongDelayMs = privacyInterSongDelaySeconds * 1000;
1383 log(`Starting privacy change for ${totalToChange} songs to '${targetPrivacyKey}'.`);
1384 updateCounter('privacy', 0, totalToChange); updateStatusMessage(`Updating ${totalToChange} songs to ${targetPrivacyKey}...`);
1385
1386 let changedCount = 0, criticalErrorOccurred = false;
1387 for (const songId of songIdsToChangeArray) {
1388 if (criticalErrorOccurred || !isChangingPrivacy || stopPrivacyChangeSignal) { break; }
1389 const songElement = Array.from(getCurrentSongElements()).find(el => {
1390 const link = el.querySelector(`a[href="/song/${songId}"]`);
1391 return !!link;
1392 });
1393 if (!songElement) {
1394 log(`Song ID ${songId} not found (possibly removed?). Skipping.`, 'warn');
1395 selectedSongIdsPrivacy.delete(songId);
1396 continue;
1397 }
1398 const title = getSongDataFromPage().find(s => s.id === songId)?.title || `song ID ${songId}`;
1399 updateStatusMessage(`Updating ${changedCount + 1}/${totalToChange}: ${title} to ${targetPrivacyKey}...`);
1400
1401 const success = await processSinglePrivacyChange(songElement, targetSubMenuText, songId);
1402 if (success) {
1403 changedCount++;
1404 updateCounter('privacy', changedCount, totalToChange);
1405 } else {
1406 log(`Failed to change privacy for ${title}. Stopping.`, 'error');
1407 updateStatusMessage(`Error for ${title}. Stopped.`);
1408 criticalErrorOccurred = true;
1409 }
1410 if (isChangingPrivacy && !stopPrivacyChangeSignal && !criticalErrorOccurred && (songIdsToChangeArray.indexOf(songId) < songIdsToChangeArray.length - 1)) {
1411 if (interSongDelayMs > 0) {
1412 updateStatusMessage(`Waiting ${privacyInterSongDelaySeconds}s for next song...`);
1413 await delay(interSongDelayMs);
1414 } else {
1415 await delay(50);
1416 }
1417 }
1418 }
1419 const finalMessage = criticalErrorOccurred ? `Privacy change stopped. ${changedCount} updated.`
1420 : stopPrivacyChangeSignal ? `Privacy change stopped by user. ${changedCount} updated.`
1421 : `Privacy change complete. ${changedCount} updated.`;
1422 log(finalMessage);
1423 updateStatusMessage(finalMessage);
1424 isChangingPrivacy = false; stopPrivacyChangeSignal = false; setAllButtonsDisabled(false);
1425 populatePrivacySongList();
1426 }
1427
1428
1429 async function processSinglePrivacyChange(songElement, targetSubMenuText, identifier, retryCount = 0) {
1430 const logPrefix = `(Privacy - ${identifier}) -`;
1431 if (!isChangingPrivacy || stopPrivacyChangeSignal) { log(`${logPrefix} Op cancelled.`, 'warn'); return false; }
1432 if (!songElement || !songElement.parentNode) { log(`${logPrefix} Element gone.`, 'warn'); return false; }
1433
1434 const menuButton = songElement.querySelector('button[data-sentry-element="MenuTrigger"]');
1435 if (!menuButton) { log(`${logPrefix} No MenuTrigger.`, 'error'); return false; }
1436
1437 logDebug(`${logPrefix} Clicking 'More options' button:`, menuButton);
1438 if (!simulateClick(menuButton)) { log(`${logPrefix} Fail click MenuTrigger.`, 'error'); return false; }
1439 await delay(PRIVACY_MENU_OPEN_DELAY);
1440
1441 let menuContentElement = null;
1442 let popperWrapper = document.querySelector('div[data-radix-popper-content-wrapper][style*="transform: translate"]');
1443 if (popperWrapper) menuContentElement = popperWrapper.querySelector('div[data-radix-menu-content][data-state="open"]');
1444 if (!menuContentElement) {
1445 const openMenus = document.querySelectorAll('div[data-radix-menu-content][data-state="open"]');
1446 if (openMenus.length > 0) menuContentElement = openMenus[openMenus.length - 1];
1447 }
1448 if (!menuContentElement) {
1449 if (retryCount < MAX_RETRIES) {
1450 logWarn(`${logPrefix} No open menu content. Retrying (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
1451 try { document.body.click(); await delay(150); } catch(_e){}
1452 return processSinglePrivacyChange(songElement, targetSubMenuText, identifier, retryCount + 1);
1453 }
1454 log(`${logPrefix} No open menu content after retries.`, 'error');
1455 try { document.body.click(); await delay(50); } catch(_e){} return false;
1456 }
1457
1458 let privacyMainMenuItem = Array.from(menuContentElement.querySelectorAll('div[role="menuitem"]')).find(el => {
1459 const mainDiv = el.querySelector('div.flex.items-center.gap-4');
1460 if (!mainDiv) return false;
1461 const hasEyeIcon = mainDiv.querySelector('svg[data-icon="eye"]');
1462 // Extract text directly from mainDiv, excluding sub-texts like current privacy status
1463 let mainText = '';
1464 mainDiv.childNodes.forEach(node => {
1465 if (node.nodeType === Node.TEXT_NODE) mainText += node.textContent.trim() + ' ';
1466 else if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('overflow-hidden')) { // Common container for main text
1467 mainText += node.textContent.trim() + ' ';
1468 }
1469 });
1470 mainText = mainText.trim();
1471 return hasEyeIcon && mainText === 'Privacy';
1472 });
1473 if (!privacyMainMenuItem) { // Fallback using data-sentry-source-file as it was specific
1474 privacyMainMenuItem = menuContentElement.querySelector('div[role="menuitem"][data-sentry-source-file="PrivacySubMenu.tsx"]');
1475 if (privacyMainMenuItem) logDebug(`${logPrefix} Found Privacy item by data-sentry-source-file`);
1476 }
1477
1478 if (!privacyMainMenuItem) {
1479 if (retryCount < MAX_RETRIES) {
1480 logWarn(`${logPrefix} 'Privacy' option not found in menu (Attempt ${retryCount + 1}/${MAX_RETRIES}). Retrying...`);
1481 try { document.body.click(); await delay(150); } catch(_e){}
1482 if (!songElement?.parentNode) { logWarn(`${logPrefix} Song element disappeared before retry for 'Privacy'.`); return false; }
1483 return processSinglePrivacyChange(songElement, targetSubMenuText, identifier, retryCount + 1);
1484 }
1485 log(`${logPrefix} 'Privacy' option not found after ${MAX_RETRIES} retries. Menu HTML:`, menuContentElement.innerHTML.substring(0, 700) + '...');
1486 try { document.body.click(); await delay(50); } catch(_e){} return false;
1487 }
1488
1489 const privacySubMenuId = privacyMainMenuItem.getAttribute('aria-controls');
1490 logDebug(`${logPrefix} Clicking 'Privacy' menu item (controls: ${privacySubMenuId || 'N/A'}):`, privacyMainMenuItem);
1491 if (!simulateClick(privacyMainMenuItem)) {
1492 log(`${logPrefix} Failed to simulate click on 'Privacy' option.`, 'error');
1493 try { document.body.click(); await delay(50); } catch(_e){} return false;
1494 }
1495 await delay(PRIVACY_SUBMENU_TRIGGER_CLICK_DELAY);
1496
1497 let subMenuContent = null, subMenuOpenRetries = 0;
1498 while ((isChangingPrivacy && !stopPrivacyChangeSignal) && subMenuOpenRetries < MAX_SUB_MENU_OPEN_RETRIES) {
1499 await delay(PRIVACY_SUBMENU_OPEN_DELAY);
1500 if (privacySubMenuId) subMenuContent = document.getElementById(privacySubMenuId);
1501
1502 if (subMenuContent?.getAttribute('data-state') === 'open') {
1503 logDebug(`${logPrefix} Privacy sub-menu (ID: ${privacySubMenuId}) found by ID and open.`);
1504 break;
1505 } else {
1506 const allPoppers = Array.from(document.querySelectorAll('div[data-radix-popper-content-wrapper][style*="transform: translate"]'));
1507 let foundSubMenu = null;
1508 allPoppers.some(p => {
1509 const sm = p.querySelector('div[data-radix-menu-content][data-state="open"][data-sentry-component="MenuSubContent"]');
1510 if (sm && (!privacySubMenuId || sm.id === privacySubMenuId)) {
1511 foundSubMenu = sm;
1512 logDebug(`${logPrefix} Found privacy sub-menu in popper (ID: ${sm.id}).`);
1513 return true;
1514 }
1515 return false;
1516 });
1517 if (foundSubMenu) { subMenuContent = foundSubMenu; break; }
1518 }
1519 subMenuOpenRetries++;
1520 if (subMenuOpenRetries > 1 && privacyMainMenuItem?.offsetParent !== null && privacyMainMenuItem.getAttribute('data-state') === 'closed' && privacyMainMenuItem.getAttribute('aria-expanded') === 'false') {
1521 logWarn(`${logPrefix} Submenu not opening. Re-clicking 'Privacy'. Attempt ${subMenuOpenRetries}.`);
1522 if(!simulateClick(privacyMainMenuItem)) { logWarn('Re-click \'Privacy\' for submenu failed.'); break;}
1523 await delay(PRIVACY_SUBMENU_TRIGGER_CLICK_DELAY);
1524 }
1525 }
1526
1527 if (!isChangingPrivacy || stopPrivacyChangeSignal) { log(`${logPrefix} Op cancelled while waiting for sub-menu.`, 'warn'); return false; }
1528 if (!subMenuContent || subMenuContent.getAttribute('data-state') !== 'open') {
1529 log(`${logPrefix} Privacy sub-menu (ID: ${privacySubMenuId || 'unknown'}) did not open. Aborting.`, 'error');
1530 try { document.body.click(); await delay(50); } catch(_e){} return false;
1531 }
1532
1533 logDebug(`${logPrefix} Open sub-menu. Searching for option '${targetSubMenuText}'.`);
1534 const potentialItems = subMenuContent.querySelectorAll(':scope > div[role="menuitem"]');
1535 let targetItem = Array.from(potentialItems).find(el => {
1536 const textDiv = el.querySelector('.line-clamp-2');
1537 const itemText = textDiv ? textDiv.textContent.trim() : el.textContent.trim();
1538 let iconMatch = false;
1539 if (targetSubMenuText === 'Only me' && el.querySelector('svg[data-icon="lock"]')) iconMatch = true;
1540 else if (targetSubMenuText === 'Anyone with the link' && el.querySelector('svg[data-icon="link-simple"]')) iconMatch = true;
1541 else if (targetSubMenuText === 'Publish' && el.querySelector('svg[data-icon="earth-americas"]')) iconMatch = true;
1542 return itemText === targetSubMenuText && iconMatch && el.offsetParent !== null;
1543 });
1544
1545 if (!targetItem && retryCount < MAX_RETRIES) {
1546 logWarn(`${logPrefix} Target privacy option '${targetSubMenuText}' not in sub-menu (Attempt ${retryCount + 1}/${MAX_RETRIES}). Retrying sub-menu check...`);
1547 // This is a soft retry, just re-evaluating the existing submenu content after a small delay
1548 await delay(PRIVACY_SUBMENU_OPEN_DELAY / 2);
1549 if (subMenuContent?.getAttribute('data-state') === 'open') {
1550 const potentialItemsAgain = subMenuContent.querySelectorAll(':scope > div[role="menuitem"]');
1551 targetItem = Array.from(potentialItemsAgain).find(el => {
1552 const textDiv = el.querySelector('.line-clamp-2');
1553 const itemText = textDiv ? textDiv.textContent.trim() : el.textContent.trim();
1554 let iconMatch = false;
1555 if (targetSubMenuText === 'Only me' && el.querySelector('svg[data-icon="lock"]')) iconMatch = true;
1556 else if (targetSubMenuText === 'Anyone with the link' && el.querySelector('svg[data-icon="link-simple"]')) iconMatch = true;
1557 else if (targetSubMenuText === 'Publish' && el.querySelector('svg[data-icon="earth-americas"]')) iconMatch = true;
1558 return itemText === targetSubMenuText && iconMatch && el.offsetParent !== null;
1559 });
1560 if (targetItem) logDebug(`${logPrefix} Found target privacy option '${targetSubMenuText}' after re-check.`);
1561 } else { log(`${logPrefix} Sub-menu closed unexpectedly during re-check.`, 'error'); return false; }
1562 }
1563
1564 if (!targetItem) {
1565 log(`${logPrefix} Target privacy option '${targetSubMenuText}' not found. Submenu HTML:`, subMenuContent.innerHTML.substring(0, 700) + '...');
1566 try { document.body.click(); await delay(50); } catch(_e){} return false;
1567 }
1568
1569 logDebug(`${logPrefix} Clicking target privacy option '${targetSubMenuText}':`, targetItem);
1570 if (!simulateClick(targetItem)) {
1571 log(`${logPrefix} Failed to click target privacy option '${targetSubMenuText}'.`, 'error');
1572 try { document.body.click(); await delay(50); } catch(_e){} return false;
1573 }
1574 await delay(PRIVACY_ACTION_CLICK_DELAY);
1575 logDebug(`--- Finished processing ${logPrefix} (Privacy change to '${targetSubMenuText}' assumed initiated) ---`);
1576 return true;
1577 }
1578
1579 async function processSingleAction(songElement, actionType, identifier, format = null, retryCount = 0) {
1580 const logPrefix = `(${actionType} - ${identifier}) -`;
1581 if ((actionType === 'delete' && (!isDeleting || stopBulkDeletionSignal)) ||
1582 (actionType === 'download' && !isDownloading)) {
1583 log(`${logPrefix} Op cancelled.`, 'warn'); return false;
1584 }
1585 if (isChangingPrivacy) { log(`${logPrefix} Privacy change in progress, ${actionType} op paused.`, 'warn'); return false;}
1586
1587
1588 if (!songElement || !songElement.parentNode) { log(`${logPrefix} Element gone.`, 'warn'); return actionType === 'delete'; }
1589
1590 const menuButton = songElement.querySelector('button[data-sentry-element="MenuTrigger"]');
1591 if (!menuButton) { log(`${logPrefix} No MenuTrigger.`, 'error'); return false; }
1592
1593 logDebug(`${logPrefix} Clicking 'More options' button:`, menuButton);
1594 if (!simulateClick(menuButton)) { log(`${logPrefix} Fail click MenuTrigger.`, 'error'); return false; }
1595 await delay(DROPDOWN_DELAY);
1596
1597 let primaryActionText = actionType === 'delete' ? 'delete' : 'download';
1598 let primaryActionItem = null, downloadMenuItemId = null, menuContentElement = null;
1599
1600 let popperWrapper = document.querySelector('div[data-radix-popper-content-wrapper][style*="transform: translate"]');
1601 if (popperWrapper) menuContentElement = popperWrapper.querySelector('div[data-radix-menu-content][data-state="open"]');
1602
1603 if (!menuContentElement) {
1604 const openMenus = document.querySelectorAll('div[data-radix-menu-content][data-state="open"]');
1605 if (openMenus.length > 0) menuContentElement = openMenus[openMenus.length - 1];
1606 }
1607
1608 if (!menuContentElement) {
1609 if (retryCount < MAX_RETRIES) {
1610 logWarn(`${logPrefix} No open menu content. Retrying (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
1611 try { document.body.click(); await delay(150); } catch(_e){}
1612 return processSingleAction(songElement, actionType, identifier, format, retryCount + 1);
1613 }
1614 log(`${logPrefix} No open menu content after retries.`, 'error');
1615 try { document.body.click(); await delay(50); } catch(_e){} return false;
1616 }
1617
1618 primaryActionItem = Array.from(menuContentElement.querySelectorAll(':scope > [role="menuitem"], :scope > [data-radix-collection-item]')).find(el => {
1619 const isVisible = el.offsetParent !== null;
1620 if (!isVisible) return false;
1621 let itemText = '';
1622 const lineClampDiv = el.querySelector('.line-clamp-2'); // Common for primary text
1623 if (lineClampDiv) {
1624 itemText = lineClampDiv.textContent.trim().toLowerCase();
1625 } else { // Fallback if no .line-clamp-2
1626 const mainContentContainer = el.querySelector('.flex.items-center.gap-4 > .overflow-hidden:not(:has(svg))'); // Div that holds text, not icon
1627 if (mainContentContainer) {
1628 itemText = mainContentContainer.textContent.trim().toLowerCase();
1629 } else { // Broader fallback
1630 itemText = el.textContent.trim().toLowerCase();
1631 // Clean up potential extra text for items like Privacy that show current status
1632 if (itemText.startsWith('privacy ')) itemText = 'privacy';
1633 if (itemText.startsWith('download ')) itemText = 'download';
1634 }
1635 }
1636 logDebug(`${logPrefix} Checking menu item, text: '${itemText}' vs target: '${primaryActionText}'`);
1637 if (itemText === primaryActionText) {
1638 if (primaryActionText === 'download') {
1639 downloadMenuItemId = el.getAttribute('aria-controls');
1640 logDebug(`${logPrefix} Found '${primaryActionText}' item, controls submenu: ${downloadMenuItemId || 'N/A'}`);
1641 } else {
1642 logDebug(`${logPrefix} Found '${primaryActionText}' item`);
1643 }
1644 return true;
1645 }
1646 return false;
1647 });
1648
1649
1650 if (!primaryActionItem && retryCount < MAX_RETRIES) {
1651 logWarn(`${logPrefix} '${primaryActionText}' option not found in menu (Attempt ${retryCount + 1}/${MAX_RETRIES}). Retrying...`);
1652 try { document.body.click(); await delay(150); } catch(_e){}
1653 if (!songElement?.parentNode) {
1654 logWarn(`${logPrefix} Song element disappeared before retry for '${primaryActionText}'.`);
1655 return (actionType === 'delete');
1656 }
1657 return processSingleAction(songElement, actionType, identifier, format, retryCount + 1);
1658 }
1659
1660 if (!primaryActionItem) {
1661 log(`${logPrefix} '${primaryActionText}' option not found after ${MAX_RETRIES} retries. Aborting. Menu HTML:`, menuContentElement.innerHTML.substring(0, 700) + '...');
1662 try { document.body.click(); await delay(50); } catch(_e){} return false;
1663 }
1664
1665 logDebug(`${logPrefix} Clicking primary action '${primaryActionText}' (controls: ${primaryActionItem.getAttribute('aria-controls') || 'N/A'}):`, primaryActionItem);
1666 if (!simulateClick(primaryActionItem)) {
1667 log(`${logPrefix} Failed to simulate click on '${primaryActionText}' option.`, 'error');
1668 try { document.body.click(); await delay(50); } catch(_e){} return false;
1669 }
1670
1671 if (actionType === 'delete') {
1672 await delay(DELETION_DELAY);
1673 logDebug(`--- Finished processing ${logPrefix} (Delete action assumed successful) ---`);
1674 return true;
1675 }
1676 else if (actionType === 'download') {
1677 if (!downloadMenuItemId) {
1678 log(`${logPrefix} No submenu ID for Download. Aborting format ${format}.`, 'error');
1679 try { document.body.click(); await delay(50); } catch(_e){} return false;
1680 }
1681 let subMenuContent = null, subMenuOpenRetries = 0;
1682 while (isDownloading && subMenuOpenRetries < MAX_SUB_MENU_OPEN_RETRIES) {
1683 await delay(DOWNLOAD_MENU_DELAY);
1684 if (downloadMenuItemId) subMenuContent = document.getElementById(downloadMenuItemId);
1685
1686 if (subMenuContent?.getAttribute('data-state') === 'open') {
1687 logDebug(`${logPrefix} Download sub-menu (ID: ${downloadMenuItemId}) found by ID and open.`);
1688 break;
1689 } else {
1690 const allPoppers = Array.from(document.querySelectorAll('div[data-radix-popper-content-wrapper][style*="transform: translate"]'));
1691 let foundInPopper = false;
1692 allPoppers.some(p => {
1693 const sm = p.querySelector('div[data-radix-menu-content][data-state="open"]' + (downloadMenuItemId ? `[id="${downloadMenuItemId}"]` : '[data-sentry-component="MenuSubContent"]'));
1694 if (sm) {
1695 subMenuContent = sm;
1696 logDebug(`${logPrefix} Found download sub-menu in popper (ID: ${sm.id}).`);
1697 foundInPopper = true;
1698 }
1699 return foundInPopper;
1700 });
1701 if (foundInPopper) break;
1702 }
1703 subMenuOpenRetries++;
1704 if (subMenuOpenRetries > 1 && primaryActionItem?.offsetParent !== null && primaryActionItem.getAttribute('data-state') === 'closed' && primaryActionItem.getAttribute('aria-expanded') === 'false') {
1705 logWarn(`${logPrefix} Submenu not opening. Re-clicking 'Download'. Attempt ${subMenuOpenRetries}.`);
1706 if(!simulateClick(primaryActionItem)) { logWarn('Re-click download for submenu failed.'); break;}
1707 await delay(DOWNLOAD_MENU_DELAY / 2);
1708 }
1709 }
1710
1711 if (!isDownloading) { log(`${logPrefix} Download cancelled while waiting for sub-menu.`, 'warn'); return false; }
1712 if (!subMenuContent || subMenuContent.getAttribute('data-state') !== 'open') {
1713 log(`${logPrefix} Download sub-menu (ID: ${downloadMenuItemId || 'unknown'}) did not open. Aborting format ${format}.`, 'error');
1714 try { document.body.click(); await delay(50); } catch(_e){} return false;
1715 }
1716
1717 let formatItem = null; const formatTextUpper = format.toUpperCase();
1718 logDebug(`${logPrefix} Open sub-menu. Searching for format '${formatTextUpper}'.`);
1719 const potentialFormatItems = subMenuContent.querySelectorAll(':scope > [role="menuitem"], :scope > [data-radix-collection-item]');
1720 formatItem = Array.from(potentialFormatItems).find(el => {
1721 const textDiv = el.querySelector('.line-clamp-2');
1722 const itemText = textDiv ? textDiv.textContent.trim().toUpperCase() : el.textContent.trim().toUpperCase();
1723 return itemText === formatTextUpper && el.offsetParent !== null && !el.querySelector('svg[data-icon="angle-right"]');
1724 });
1725
1726 if (!formatItem && retryCount < MAX_RETRIES) { // Soft retry for format item
1727 logWarn(`${logPrefix} Format '${formatTextUpper}' not in sub-menu (Attempt ${retryCount +1}/${MAX_RETRIES}). Re-checking...`);
1728 await delay(DOWNLOAD_MENU_DELAY / 2);
1729 if (subMenuContent?.getAttribute('data-state') === 'open') {
1730 const potentialFormatItemsAgain = subMenuContent.querySelectorAll(':scope > [role="menuitem"], :scope > [data-radix-collection-item]');
1731 formatItem = Array.from(potentialFormatItemsAgain).find(el => {
1732 const textDiv = el.querySelector('.line-clamp-2');
1733 const itemText = textDiv ? textDiv.textContent.trim().toUpperCase() : el.textContent.trim().toUpperCase();
1734 return itemText === formatTextUpper && el.offsetParent !== null && !el.querySelector('svg[data-icon="angle-right"]');
1735 });
1736 if (formatItem) logDebug(`${logPrefix} Found format '${formatTextUpper}' after re-check.`);
1737 } else { log(`${logPrefix} Sub-menu closed unexpectedly.`, 'error'); return false; }
1738 }
1739 if (!formatItem) { log(`${logPrefix} Format '${formatTextUpper}' not found. Submenu:`, subMenuContent.innerHTML.substring(0,700)); return false; }
1740
1741 logDebug(`${logPrefix} Clicking format '${formatTextUpper}' option:`, formatItem);
1742 if (!simulateClick(formatItem)) { log(`${logPrefix} Failed click format '${formatTextUpper}'.`, 'error'); return false; }
1743 await delay(DOWNLOAD_ACTION_DELAY);
1744 logDebug(`--- Finished processing ${logPrefix} (Format ${format} assumed initiated) ---`);
1745 return true;
1746 }
1747 return false;
1748 }
1749
1750 function setAllButtonsDisabled(disabled) {
1751 if (!uiElement) return;
1752 if (isMinimized && disabled) { return; }
1753
1754 const buttons = uiElement.querySelectorAll('#riffControlContent button');
1755 buttons.forEach(btn => {
1756 if (btn.id === 'minimizeButton') return;
1757 if (btn.id === 'deleteAllButton' && currentView === 'bulk' && isDeleting && disabled) {
1758 btn.disabled = false; // Keep stop button enabled
1759 }
1760 // Add similar logic for a stopPrivacyChangeButton if it exists
1761 else {
1762 btn.disabled = disabled;
1763 }
1764 });
1765 const inputs = uiElement.querySelectorAll('#riffControlContent input, #riffControlContent select');
1766 inputs.forEach(input => {
1767 if (input.type === 'checkbox' && (input.id === 'autoReloadToggle' || input.id === 'debugToggleCheckbox' || input.id.startsWith('format') || input.id === 'ignoreLikedToggleDelete')) {
1768 input.disabled = false; // Always enable these specific checkboxes
1769 } else if (input.id.includes('DelayInput') || input.id === 'privacyLevelSelect') {
1770 input.disabled = false; // Always enable delay inputs and privacy select
1771 }
1772 else input.disabled = disabled;
1773 });
1774
1775 const labels = uiElement.querySelectorAll('#riffControlContent label.settings-checkbox-label, #riffControlContent label.selectAllContainer, #riffControlContent .downloadFormatContainer > label.settings-checkbox-label, #riffControlContent .downloadDelayContainer label.settings-checkbox-label, #riffControlContent .privacySettingsContainer label.settings-checkbox-label');
1776 labels.forEach(label => {
1777 const forActiveStop = label.htmlFor === 'deleteAllButton' && currentView === 'bulk' && isDeleting && disabled;
1778 const isAlwaysInteractive = label.closest('#commonSettingsFooter') ||
1779 label.closest('.downloadFormatContainer') ||
1780 label.closest('.downloadDelayContainer') ||
1781 label.closest('.privacySettingsContainer') ||
1782 label.htmlFor === 'ignoreLikedToggleDelete';
1783
1784 if (forActiveStop || isAlwaysInteractive) {
1785 label.style.cursor = 'pointer'; label.style.opacity = '1';
1786 }
1787 else { label.style.cursor = disabled ? 'not-allowed' : 'pointer'; label.style.opacity = disabled ? '0.7' : '1'; }
1788 });
1789
1790 const songListCheckboxes = uiElement.querySelectorAll('.songListContainer input[type="checkbox"]');
1791 songListCheckboxes.forEach(cb => {
1792 if (cb.closest('label.ignored')) { // Ignored checkboxes in delete list
1793 cb.disabled = true;
1794 } else {
1795 cb.disabled = disabled;
1796 }
1797 });
1798
1799
1800 if (!disabled) {
1801 if(currentView === 'selective' && uiElement.querySelector('#selectiveModeControls')?.style.display === 'block') populateDeleteSongListIfNeeded();
1802 if(currentView === 'download' && uiElement.querySelector('#downloadQueueControls')?.style.display === 'block') populateDownloadSongListIfNeeded();
1803 if(currentView === 'privacy' && uiElement.querySelector('#privacyStatusControls')?.style.display === 'block') populatePrivacySongListIfNeeded();
1804 }
1805 const status = uiElement.querySelector('#statusMessage');
1806 if (status) {
1807 const allowPointerEvents = (!disabled) || (currentView === 'bulk' && isDeleting && disabled);
1808 status.style.pointerEvents = allowPointerEvents ? 'auto' : 'none';
1809 }
1810 logDebug(`Controls ${disabled ? 'mostly disabled' : 'enabled'}.`);
1811 }
1812
1813
1814 // --- Initialization ---
1815 function waitForAppReady(callback) {
1816 const checkInterval = 500, maxWaitTime = 20000; let elapsedTime = 0;
1817 const intervalId = setInterval(() => {
1818 elapsedTime += checkInterval;
1819 const appReadyElement = document.querySelector('div#__next main, div#__next nav, div#__next header'); // Check for main app structure
1820 if (appReadyElement) {
1821 clearInterval(intervalId); setTimeout(callback, 300); // Wait a bit more for full render
1822 } else if (elapsedTime >= maxWaitTime) {
1823 clearInterval(intervalId); logWarn('Riffusion app not fully detected. Initializing anyway.'); setTimeout(callback, 300);
1824 }
1825 }, checkInterval);
1826 }
1827
1828 function init() {
1829 try {
1830 log(`Riffusion Multitool Script Loaded (v${GM_info.script.version}).`);
1831 createMainUI();
1832 log(`Initialized. UI is ${isMinimized ? 'minimized' : 'visible'}. Auto-reload: ${autoReloadEnabled}. Debug: ${debugMode}.`);
1833 if (isMinimized) updateStatusMessage('Ready. Click icon to expand.');
1834 logDebug('Initial debug log test post-UI creation.');
1835 } catch (e) {
1836 console.error('[RiffTool] Initialization failed:', e); alert('RiffTool Init Error: ' + e.message);
1837 }
1838 }
1839
1840 waitForAppReady(init);
1841
1842})();