// ── Import File Tab ────────────────────────────────────────────────── let _importFileState = { rawText: '', fileName: '', fileType: '', // 'csv' or 'text' headers: [], // CSV column headers rows: [], // raw parsed rows (arrays for csv, strings for text) columnMap: {}, // { columnIndex: 'track_name' | 'artist_name' | 'album_name' | 'duration' | 'skip' } parsedTracks: [] // final [{track_name, artist_name, album_name, duration_ms}] }; function _initImportFileTab() { const dropzone = document.getElementById('import-file-dropzone'); const fileInput = document.getElementById('import-file-input'); if (!dropzone || !fileInput) return; dropzone.addEventListener('click', () => fileInput.click()); dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }); dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.classList.remove('drag-over'); const file = e.dataTransfer.files[0]; if (file) _importFileRead(file); }); fileInput.addEventListener('change', () => { if (fileInput.files[0]) _importFileRead(fileInput.files[0]); fileInput.value = ''; }); // Enable/disable import button based on playlist name const nameInput = document.getElementById('import-file-playlist-name'); if (nameInput) { nameInput.addEventListener('input', () => { const btn = document.getElementById('import-file-import-btn'); if (btn) btn.disabled = !nameInput.value.trim(); }); } } function _importFileRead(file) { const ext = file.name.split('.').pop().toLowerCase(); if (!['csv', 'tsv', 'txt', 'm3u', 'm3u8'].includes(ext)) { showToast('Unsupported file type. Use CSV, TSV, TXT, M3U, or M3U8.', 'error'); return; } const reader = new FileReader(); reader.onload = (e) => { _importFileState.rawText = e.target.result; _importFileState.fileName = file.name; _importFileState.fileType = (ext === 'm3u' || ext === 'm3u8') ? 'm3u' : (ext === 'txt') ? 'text' : 'csv'; _importFileParseAndPreview(); }; reader.readAsText(file); } function _importFileDetectDelimiter(firstLine) { const tab = (firstLine.match(/\t/g) || []).length; const semi = (firstLine.match(/;/g) || []).length; const comma = (firstLine.match(/,/g) || []).length; if (tab >= comma && tab >= semi && tab > 0) return '\t'; if (semi >= comma && semi > 0) return ';'; return ','; } function _importFileParseCsv(text, delimiter) { const lines = text.split(/\r?\n/).filter(l => l.trim()); if (lines.length < 2) return { headers: [], rows: [] }; // Parse CSV with basic quote handling function parseLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (ch === delimiter && !inQuotes) { result.push(current.trim()); current = ''; } else { current += ch; } } result.push(current.trim()); return result; } const headers = parseLine(lines[0]); const rows = []; for (let i = 1; i < lines.length; i++) { const row = parseLine(lines[i]); if (row.some(cell => cell)) rows.push(row); } return { headers, rows }; } // Parse an M3U / M3U8 playlist into track objects. Handles both the simple form // (one media path per line) and the extended form (#EXTINF:, - // followed by the path). SoulSync's own export is extended M3U and replaces the path // of an un-located track with a "# MISSING: ..." comment — those are exactly the // tracks a user imports to go match/download, so a pending #EXTINF is flushed even // when no path line follows it. Returns { tracks, playlistName }. function _importFileParseM3u(text) { const lines = (text || '').split(/\r?\n/); const tracks = []; let playlistName = ''; let pending = null; // { duration_ms, artist, title } from the last #EXTINF function splitArtistTitle(s) { const i = s.indexOf(' - '); return i !== -1 ? { artist: s.slice(0, i).trim(), title: s.slice(i + 3).trim() } : { artist: '', title: s.trim() }; } function pushTrack(artist, title, duration_ms) { if (!title && !artist) return; tracks.push({ track_name: title || '', artist_name: artist || '', album_name: '', duration_ms: duration_ms || 0 }); } function flushPending() { if (pending) { pushTrack(pending.artist, pending.title, pending.duration_ms); pending = null; } } for (const raw of lines) { const line = raw.trim(); if (!line) continue; if (line.charAt(0) === '#') { if (/^#EXTINF:/i.test(line)) { flushPending(); // a prior #EXTINF whose path was missing still counts const rest = line.slice(8); // after "#EXTINF:" const comma = rest.indexOf(','); const secs = parseFloat(comma === -1 ? rest : rest.slice(0, comma)); const meta = comma === -1 ? '' : rest.slice(comma + 1).trim(); const { artist, title } = splitArtistTitle(meta); pending = { duration_ms: (!isNaN(secs) && secs > 0) ? Math.round(secs * 1000) : 0, artist, title }; } else if (/^#PLAYLIST:/i.test(line)) { playlistName = line.slice(line.indexOf(':') + 1).trim(); } // ignore #EXTM3U, #GENERATED, "# MISSING:", #EXTALB, and other directives continue; } // A non-# line is a media path/URL — the entry for the pending #EXTINF, if any. if (pending && (pending.title || pending.artist)) { pushTrack(pending.artist, pending.title, pending.duration_ms); pending = null; } else { // Simple M3U with no #EXTINF: derive artist/title from the file name. pending = null; const base = (line.split(/[\\/]/).pop() || line).replace(/\.[^.]+$/, ''); const { artist, title } = splitArtistTitle(base); pushTrack(artist, title, 0); } } flushPending(); // trailing #EXTINF with no following path return { tracks, playlistName }; } function _importFileAutoMapColumns(headers) { const map = {}; const lowerHeaders = headers.map(h => h.toLowerCase().trim()); const trackPatterns = ['track_name', 'track name', 'track', 'title', 'song', 'song_name', 'song name', 'name']; const artistPatterns = ['artist_name', 'artist name', 'artist', 'artists', 'performer']; const albumPatterns = ['album_name', 'album name', 'album']; const durationPatterns = ['duration', 'duration_ms', 'length', 'time']; function findMatch(patterns) { for (const p of patterns) { const idx = lowerHeaders.indexOf(p); if (idx !== -1 && !(idx in map)) return idx; } return -1; } const trackIdx = findMatch(trackPatterns); if (trackIdx !== -1) map[trackIdx] = 'track_name'; const artistIdx = findMatch(artistPatterns); if (artistIdx !== -1) map[artistIdx] = 'artist_name'; const albumIdx = findMatch(albumPatterns); if (albumIdx !== -1) map[albumIdx] = 'album_name'; const durIdx = findMatch(durationPatterns); if (durIdx !== -1) map[durIdx] = 'duration'; return map; } function _importFileParseAndPreview() { const state = _importFileState; const text = state.rawText; if (state.fileType === 'm3u') { // M3U/M3U8 is self-describing — no column mapping or order/separator needed. const { tracks, playlistName } = _importFileParseM3u(text); state.rows = tracks; // already track objects state.headers = []; state.columnMap = {}; state.m3uPlaylistName = playlistName || ''; } else if (state.fileType === 'text') { // Plain text: one track per line const lines = text.split(/\r?\n/).filter(l => l.trim()); state.rows = lines; state.headers = []; state.columnMap = {}; } else { // CSV/TSV const firstLine = text.split(/\r?\n/)[0] || ''; const delimiter = _importFileDetectDelimiter(firstLine); const { headers, rows } = _importFileParseCsv(text, delimiter); state.headers = headers; state.rows = rows; state.columnMap = _importFileAutoMapColumns(headers); } _importFileBuildTracks(); _importFileRenderPreview(); } function _importFileBuildTracks() { const state = _importFileState; state.parsedTracks = []; if (state.fileType === 'm3u') { // Tracks were already parsed by _importFileParseM3u; just copy them through. state.parsedTracks = state.rows.map(t => ({ track_name: t.track_name || '', artist_name: t.artist_name || '', album_name: t.album_name || '', duration_ms: t.duration_ms || 0 })); } else if (state.fileType === 'text') { const orderEl = document.getElementById('import-file-text-order'); const sepEl = document.getElementById('import-file-text-separator'); const order = orderEl ? orderEl.value : 'artist-title'; const sep = sepEl ? sepEl.value : ' - '; for (const line of state.rows) { const parts = line.split(sep); if (parts.length >= 2) { const a = parts[0].trim(); const b = parts.slice(1).join(sep).trim(); state.parsedTracks.push({ track_name: order === 'artist-title' ? b : a, artist_name: order === 'artist-title' ? a : b, album_name: '', duration_ms: 0 }); } else { // Can't split — treat whole line as track name state.parsedTracks.push({ track_name: line.trim(), artist_name: '', album_name: '', duration_ms: 0 }); } } } else { // CSV mapped const map = state.columnMap; const trackCol = Object.keys(map).find(k => map[k] === 'track_name'); const artistCol = Object.keys(map).find(k => map[k] === 'artist_name'); const albumCol = Object.keys(map).find(k => map[k] === 'album_name'); const durCol = Object.keys(map).find(k => map[k] === 'duration'); for (const row of state.rows) { const track = trackCol !== undefined ? (row[trackCol] || '') : ''; const artist = artistCol !== undefined ? (row[artistCol] || '') : ''; const album = albumCol !== undefined ? (row[albumCol] || '') : ''; let dur = durCol !== undefined ? (row[durCol] || '') : ''; // Parse duration: could be ms, seconds, or mm:ss let durationMs = 0; if (dur) { dur = dur.trim(); if (dur.includes(':')) { const parts = dur.split(':'); durationMs = (parseInt(parts[0]) * 60 + parseInt(parts[1] || 0)) * 1000; } else { const num = parseFloat(dur); durationMs = num > 10000 ? num : num * 1000; // assume ms if > 10000, else seconds } if (isNaN(durationMs)) durationMs = 0; } state.parsedTracks.push({ track_name: track, artist_name: artist, album_name: album, duration_ms: durationMs }); } } } function _importFileRenderPreview() { const state = _importFileState; const validTracks = state.parsedTracks.filter(t => t.track_name || t.artist_name); // Show/hide sections document.getElementById('import-file-upload-zone').style.display = 'none'; document.getElementById('import-file-preview-section').style.display = ''; // File info document.getElementById('import-file-name-label').textContent = state.fileName; document.getElementById('import-file-track-count').textContent = `${validTracks.length} track${validTracks.length !== 1 ? 's' : ''} parsed`; // Show format controls based on file type document.getElementById('import-file-text-format').style.display = state.fileType === 'text' ? '' : 'none'; document.getElementById('import-file-column-mapping').style.display = state.fileType === 'csv' ? '' : 'none'; // Render column mapping for CSV if (state.fileType === 'csv') { _importFileRenderColumnMapping(); } // Pre-fill playlist name: prefer the M3U's own #PLAYLIST: directive, else the filename. const nameInput = document.getElementById('import-file-playlist-name'); if (nameInput && !nameInput.value) { nameInput.value = (state.fileType === 'm3u' && state.m3uPlaylistName) ? state.m3uPlaylistName : state.fileName.replace(/\.[^.]+$/, ''); } // Update button state const btn = document.getElementById('import-file-import-btn'); if (btn) btn.disabled = !nameInput.value.trim(); // Render preview table const tbody = document.getElementById('import-file-preview-tbody'); tbody.innerHTML = ''; state.parsedTracks.forEach((t, i) => { const valid = !!(t.track_name || t.artist_name); const tr = document.createElement('tr'); if (!valid) tr.classList.add('invalid-row'); tr.innerHTML = ` <td>${i + 1}</td> <td>${_esc(t.track_name)}</td> <td>${_esc(t.artist_name)}</td> <td>${_esc(t.album_name)}</td> `; tbody.appendChild(tr); }); } function _importFileRenderColumnMapping() { const state = _importFileState; const container = document.getElementById('import-file-mapping-selects'); container.innerHTML = ''; const options = ['skip', 'track_name', 'artist_name', 'album_name', 'duration']; const optLabels = { skip: 'Skip', track_name: 'Track', artist_name: 'Artist', album_name: 'Album', duration: 'Duration' }; state.headers.forEach((header, idx) => { const mapped = state.columnMap[idx] || 'skip'; const wrap = document.createElement('div'); wrap.className = 'import-file-col-map'; if (mapped === 'track_name') wrap.classList.add('mapped-track'); else if (mapped === 'artist_name') wrap.classList.add('mapped-artist'); else if (mapped === 'album_name') wrap.classList.add('mapped-album'); const label = document.createElement('span'); label.className = 'import-file-col-label'; label.textContent = header; label.title = header; const sel = document.createElement('select'); sel.className = 'import-file-select'; options.forEach(o => { const opt = document.createElement('option'); opt.value = o; opt.textContent = optLabels[o]; if (o === mapped) opt.selected = true; sel.appendChild(opt); }); sel.addEventListener('change', () => { if (sel.value === 'skip') { delete state.columnMap[idx]; } else { // Remove this mapping from any other column for (const k of Object.keys(state.columnMap)) { if (state.columnMap[k] === sel.value) delete state.columnMap[k]; } state.columnMap[idx] = sel.value; } _importFileBuildTracks(); _importFileRenderPreview(); }); wrap.appendChild(label); wrap.appendChild(sel); container.appendChild(wrap); }); } function importFileReparse() { _importFileBuildTracks(); _importFileRenderPreview(); } function importFileClear() { _importFileState = { rawText: '', fileName: '', fileType: '', headers: [], rows: [], columnMap: {}, parsedTracks: [] }; document.getElementById('import-file-upload-zone').style.display = ''; document.getElementById('import-file-preview-section').style.display = 'none'; document.getElementById('import-file-playlist-name').value = ''; document.getElementById('import-file-preview-tbody').innerHTML = ''; } function importFileSubmit() { const nameInput = document.getElementById('import-file-playlist-name'); const name = nameInput ? nameInput.value.trim() : ''; if (!name) { showToast('Please enter a playlist name.', 'error'); nameInput && nameInput.focus(); return; } const tracks = _importFileState.parsedTracks.filter(t => t.track_name || t.artist_name); if (!tracks.length) { showToast('No valid tracks to import.', 'error'); return; } // Use a unique ID based on timestamp so multiple imports don't collide const sourceId = `file_${Date.now()}`; mirrorPlaylist('file', sourceId, name, tracks, { description: `Imported from ${_importFileState.fileName}`, owner: 'local' }); showToast(`Imported "${name}" with ${tracks.length} tracks`, 'success'); importFileClear(); // Switch to mirrored tab so user sees the result const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); if (mirroredBtn) { mirroredBtn.click(); // Reload mirrored playlists to show the new one setTimeout(() => loadMirroredPlaylists(), 500); } } // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; // Auto-Sync state + functions moved to webui/static/auto-sync.js; declared // as globals there so the older mirrored-playlist render path below can // still reach `mirroredPipelinePollers`, `runMirroredPlaylistPipeline`, // `editMirroredSourceRef`, `getMirroredSourceRef`, and `pollMirroredPipelineStatus`. /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. */ function mirrorPlaylist(source, sourceId, name, tracks, metadata = {}) { const normalizedTracks = tracks.map(t => ({ track_name: t.track_name || t.name || '', artist_name: t.artist_name || (Array.isArray(t.artists) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : t.artists || ''), album_name: t.album_name || (typeof t.album === 'object' ? (t.album && t.album.name) : t.album) || '', duration_ms: t.duration_ms || 0, image_url: t.image_url || (t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null), source_track_id: t.source_track_id || t.id || t.spotify_track_id || '', extra_data: t.extra_data || null })); fetch('/api/mirror-playlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, source_playlist_id: String(sourceId), name, tracks: normalizedTracks, description: metadata.description || '', owner: metadata.owner || '', image_url: metadata.image_url || '' }) }).then(r => r.json()).then(data => { if (data.success) console.log(`Mirrored ${source} playlist: ${name} (${normalizedTracks.length} tracks)`); }).catch(err => console.warn('Mirror save failed:', err)); } /** * Load and render all mirrored playlists into the Mirrored tab. */ async function loadMirroredPlaylists() { const container = document.getElementById('mirrored-playlist-container'); if (!container) return; container.innerHTML = `<div class="playlist-placeholder">Loading mirrored playlists...</div>`; try { const res = await fetch('/api/mirrored-playlists'); const playlists = await res.json(); if (playlists.error) throw new Error(playlists.error); if (!playlists.length) { container.innerHTML = `<div class="playlist-placeholder">Playlists you parse from any service will appear here as persistent backups.</div>`; return; } container.innerHTML = ''; playlists.forEach(p => renderMirroredCard(p, container)); mirroredPlaylistsLoaded = true; // Hydrate discovery states from backend (survives page refresh) await hydrateMirroredDiscoveryStates(); } catch (err) { container.innerHTML = `<div class="playlist-placeholder">Error loading mirrored playlists: ${err.message}</div>`; } } function renderMirroredCard(p, container) { const ago = timeAgo(p.updated_at || p.mirrored_at); const hash = `mirrored_${p.id}`; const pipelineState = p.pipeline_state || null; const pipelinePhase = pipelineState && pipelineState.status === 'running' ? 'pipeline_running' : pipelineState && pipelineState.status === 'finished' ? 'pipeline_complete' : pipelineState && (pipelineState.status === 'error' || pipelineState.status === 'skipped') ? 'pipeline_error' : null; const state = youtubePlaylistStates[hash] || (pipelinePhase ? { phase: pipelinePhase, pipeline_status: pipelineState.status, pipeline_progress: pipelineState.progress || 0, pipeline_phase: pipelineState.phase || '', pipeline_error: pipelineState.error || '', pipeline_log: pipelineState.log || [], pipeline_result: pipelineState.result || null, } : null); const phase = state ? state.phase : null; const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; if (phase === 'pipeline_running') { const pct = state.pipeline_progress || state.progress || 0; const label = state.pipeline_phase || state.phase_label || 'Pipeline running'; phaseHtml = `<span style="color:#38bdf8;">${_esc(label)} ${pct}%</span>`; } else if (phase === 'pipeline_complete') { phaseHtml = `<span style="color:#22c55e;">Pipeline complete</span>`; } else if (phase === 'pipeline_error') { phaseHtml = `<span style="color:#ef4444;">Pipeline error</span>`; } else if (phase === 'discovering') { const pct = state.discoveryProgress || state.discovery_progress || 0; phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`; } else if (phase === 'discovered') { const matches = state.spotifyMatches || state.spotify_matches || 0; const total = state.spotify_total || p.track_count; phaseHtml = `<span style="color:#22c55e;">Discovered ${matches}/${total}</span>`; } else if (phase === 'syncing' || phase === 'sync_complete') { phaseHtml = `<span style="color:#3b82f6;">${phase === 'syncing' ? 'Syncing...' : 'Synced'}</span>`; } else if (phase === 'downloading') { phaseHtml = `<span style="color:#f59e0b;">Downloading...</span>`; } else if (phase === 'download_complete') { phaseHtml = `<span style="color:#22c55e;">Downloaded</span>`; } const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', file: '📄' }; const srcIcon = sourceIcons[p.source] || '📋'; // Discovery ratio const disc = p.discovered_count || 0; const tot = p.total_count || p.track_count || 0; let ratioHtml = ''; if (disc > 0) { const complete = disc >= tot; const srcName = typeof currentMusicSourceName !== 'undefined' ? currentMusicSourceName : 'metadata'; ratioHtml = `<span class="discovery-ratio${complete ? ' complete' : ''}">${disc}/${tot} discovered on ${srcName}</span>`; } const card = document.createElement('div'); card.className = 'mirrored-playlist-card'; card.id = `mirrored-card-${p.id}`; card.innerHTML = ` <div class="source-icon ${_escAttr(p.source)}">${srcIcon}</div> <div class="mirrored-card-info"> <div class="card-name">${_esc(p.display_name || p.name)}</div> ${p.custom_name ? `<div class="card-original-name" title="Original (upstream) playlist name — still tracked">↳ ${_esc(p.name)}</div>` : ''} <div class="card-meta"> <span class="source-badge ${_escAttr(p.source)}">${_esc(p.source)}</span> <span>${p.track_count} tracks</span> <span>Mirrored ${ago}</span> ${ratioHtml} ${phaseHtml} </div> </div> ${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escJs(p.name)}')" title="Clear discovery data">↺</button>` : ''} <button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escJs(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button> <button class="mirrored-card-rename" onclick="event.stopPropagation(); editMirroredCustomName(${p.id}, '${_escJs(p.name)}', '${_escJs(p.custom_name || '')}')" title="Rename (changes the name shown here and used when syncing)">✏️</button> <button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escJs(p.name)}', '${_escJs(p.source)}', '${_escJs(sourceRef)}')" title="Edit original playlist link">🔗</button> <button class="mirrored-card-export" onclick="event.stopPropagation(); exportMirroredPlaylist(${p.id}, '${_escJs(p.display_name || p.name)}')" title="Export to ListenBrainz / JSPF">📤</button> <button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror">✕</button> `; card.addEventListener('click', () => { const st = youtubePlaylistStates[hash]; // Treat as non-fresh if phase is set, or if a poller/discovery modal exists const hasActiveDiscovery = activeYouTubePollers[hash] || document.getElementById(`youtube-discovery-modal-${hash}`); if (st && ((st.phase && st.phase !== 'fresh') || hasActiveDiscovery)) { if (st.phase === 'downloading' || st.phase === 'download_complete') { // Open download modal directly (follows Tidal/YouTube card click pattern) const spotifyPlaylistId = st.convertedSpotifyPlaylistId; if (spotifyPlaylistId && activeDownloadProcesses[spotifyPlaylistId]) { // Modal already exists — just show it const process = activeDownloadProcesses[spotifyPlaylistId]; if (process.modalElement) { if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; } } else if (spotifyPlaylistId) { // Need to rehydrate the download modal rehydrateMirroredDownloadModal(hash, st); } else { // No converted playlist ID yet, fall back to discovery modal openYouTubeDiscoveryModal(hash); } } else { openYouTubeDiscoveryModal(hash); if (st.phase === 'discovering' && !activeYouTubePollers[hash]) { startYouTubeDiscoveryPolling(hash); } } } else { openMirroredPlaylistModal(p.id); } }); container.appendChild(card); if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) { pollMirroredPipelineStatus(p.id, p.name); } } // ── Playlist export to ListenBrainz / JSPF (#903) ─────────────────────────── // Export button on the mirrored-playlist card -> pick a destination -> background job // resolves each track to a MusicBrainz recording MBID (cache/DB/file/MusicBrainz) and either // downloads the .jspf or pushes the playlist straight to ListenBrainz, with live status on the card. function exportMirroredPlaylist(playlistId, name) { const existing = document.getElementById('pl-export-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'pl-export-modal'; overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);'; overlay.innerHTML = ` <div style="width:420px;max-width:92vw;background:linear-gradient(135deg,rgba(26,26,30,0.98),rgba(18,18,22,0.99));border:1px solid rgba(var(--accent-rgb),0.25);border-radius:18px;padding:22px;box-shadow:0 18px 50px rgba(0,0,0,0.55);"> <div style="font-size:16px;font-weight:700;color:#fff;margin-bottom:4px;">Export playlist</div> <div style="font-size:13px;color:rgba(255,255,255,0.55);margin-bottom:18px;">${_esc(name)} → ListenBrainz</div> <button class="pl-export-choice" data-mode="push" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(var(--accent-rgb),0.35);background:rgba(var(--accent-rgb),0.12);color:#fff;cursor:pointer;"> <div style="font-weight:600;">Sync to ListenBrainz</div> <div style="font-size:12px;color:rgba(255,255,255,0.55);">Create the playlist directly on your ListenBrainz account (needs your LB token).</div> </button> <button class="pl-export-choice" data-mode="download" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;"> <div style="font-weight:600;">Download .jspf file</div> <div style="font-size:12px;color:rgba(255,255,255,0.55);">Save a JSPF playlist you can upload to ListenBrainz manually.</div> </button> <button class="pl-export-choice" data-mode="spotify" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;"> <div style="font-weight:600;">Sync to Spotify</div> <div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Spotify playlist in your account (the first time, you'll grant permission to create playlists).</div> </button> <button class="pl-export-choice" data-mode="deezer" style="width:100%;text-align:left;margin-bottom:16px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;"> <div style="font-weight:600;">Sync to Deezer</div> <div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Deezer playlist from this list (uses your Deezer login).</div> </button> <div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Tracks are matched by ID (MusicBrainz for ListenBrainz/JSPF; the stored Spotify/Deezer ID for those). Tracks without a match can't be included — you'll see how many made it. Renaming/re-syncing can reset play counts on the destination.</div> <label style="display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:12px;color:rgba(255,255,255,0.6);cursor:pointer;"> <input type="checkbox" id="pl-export-backfill" style="margin-top:2px;flex-shrink:0;accent-color:rgb(var(--accent-rgb));"> <span><b style="color:rgba(255,255,255,0.75);">Match missing tracks</b> (Spotify/Deezer) — search the service for tracks with no known ID. Slower, and only confident matches are added.</span> </label> <div style="text-align:right;margin-top:14px;"><button onclick="document.getElementById('pl-export-modal').remove()" style="background:none;border:none;color:rgba(255,255,255,0.5);cursor:pointer;font-size:13px;">Cancel</button></div> </div>`; overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); overlay.querySelectorAll('.pl-export-choice').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; // Gated service button (not connected) → nudge to Settings instead of a doomed export. if (btn.dataset.disconnected) { const dest = mode[0].toUpperCase() + mode.slice(1); overlay.remove(); _setExportStatus(playlistId, `<span style="color:#f59e0b;">Connect ${dest} in Settings → Connections to export here</span>`, 9000); return; } const bfEl = overlay.querySelector('#pl-export-backfill'); const backfill = !!(bfEl && bfEl.checked); overlay.remove(); _startPlaylistExport(playlistId, mode, name, backfill); }); }); document.body.appendChild(overlay); // Gate Spotify/Deezer on connection (cheap token/ARL check — no live verify). Disconnected // services grey out + nudge to Settings rather than letting the export fail with "not connected". fetch('/api/discover/your-albums/sources').then(r => r.json()).then(data => { const connected = (data && data.connected) || []; overlay.querySelectorAll('.pl-export-choice[data-mode]').forEach(btn => { const m = btn.dataset.mode; if ((m === 'spotify' || m === 'deezer') && !connected.includes(m)) { btn.dataset.disconnected = '1'; btn.style.opacity = '0.5'; const hint = btn.querySelector('div:last-child'); if (hint) hint.innerHTML = `<span style="color:#f59e0b;">Not connected</span> — set up ${m[0].toUpperCase() + m.slice(1)} in Settings → Connections first.`; } }); }).catch(() => {}); } async function _startPlaylistExport(playlistId, mode, name, backfill) { _setExportStatus(playlistId, `<span style="color:#a78bfa;">Starting export…</span>`); try { // Spotify/Deezer go to the service endpoint; ListenBrainz/JSPF keep the LB one. const isService = (mode === 'spotify' || mode === 'deezer'); const url = isService ? `/api/playlists/${playlistId}/export/service/${mode}` : `/api/playlists/${playlistId}/export/listenbrainz`; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: isService ? JSON.stringify({ backfill: !!backfill }) : JSON.stringify({ mode }), }); const data = await resp.json(); // Spotify export needs a one-time write-permission grant. Surface a clickable link (a // direct user click avoids popup-blocking; window.open after this await would be blocked) // and tell the user to retry once they've authorized. if (data.needs_auth && data.auth_url) { _setExportStatus(playlistId, `<span style="color:#f59e0b;">Spotify needs permission to create playlists — <a href="${data.auth_url}" target="_blank" rel="noopener" style="color:#38bdf8;text-decoration:underline;">authorize</a>, then click Export again.</span>`, 20000); return; } if (!data.success || !data.job_id) { _setExportStatus(playlistId, `<span style="color:#ef4444;">${_esc(data.error || 'Export failed to start')}</span>`); return; } _pollPlaylistExport(data.job_id, playlistId, mode, name); } catch (e) { _setExportStatus(playlistId, `<span style="color:#ef4444;">Export error</span>`); } } async function _pollPlaylistExport(jobId, playlistId, mode, name) { try { const resp = await fetch(`/api/playlists/export/status/${jobId}`); const data = await resp.json(); const job = data.job || {}; const st = job.stats || {}; if (job.phase === 'resolving') { const pct = job.total ? Math.round(100 * (job.done || 0) / job.total) : 0; _setExportStatus(playlistId, `<span style="color:#38bdf8;">Matching ${job.done || 0}/${job.total || 0} (${pct}%)${st.resolved != null ? ` · ${st.resolved} matched` : ''}</span>`); } else if (job.phase === 'pushing') { const dest = (mode === 'spotify' || mode === 'deezer') ? (mode[0].toUpperCase() + mode.slice(1)) : 'ListenBrainz'; _setExportStatus(playlistId, `<span style="color:#a78bfa;">Pushing to ${dest}…</span>`); } else if (job.phase === 'done') { // Spotify/Deezer export: report added + unmatched and link the new playlist. if (mode === 'spotify' || mode === 'deezer') { const dest = mode[0].toUpperCase() + mode.slice(1); const push = job.push || {}; const cov = `${st.resolved || 0} added${st.from_search ? ` (${st.from_search} matched live)` : ''}${st.unmatched ? ` · ${st.unmatched} not on ${dest}` : ''}`; const link = push.url ? ` <a href="${push.url}" target="_blank" style="color:#38bdf8;">open</a>` : ''; _setExportStatus(playlistId, `<span style="color:#22c55e;">Exported to ${dest} · ${cov}</span>${link}`, 12000); if (typeof showToast === 'function') showToast(`Playlist exported to ${dest} (${cov})`, 'success'); return; } const sum = job.summary || {}; const cov = `${sum.included || 0}/${sum.total || 0} matched${sum.skipped ? ` · ${sum.skipped} unmatched` : ''}`; if (mode === 'download') { window.location = `/api/playlists/export/download/${jobId}`; _setExportStatus(playlistId, `<span style="color:#22c55e;">Downloaded · ${cov}</span>`, 8000); } else { const url = (job.push && job.push.playlist_url) || ''; _setExportStatus(playlistId, `<span style="color:#22c55e;">Synced to ListenBrainz · ${cov}</span>` + (url ? ` <a href="${url}" target="_blank" style="color:#38bdf8;">view</a>` : ''), 12000); if (typeof showToast === 'function') showToast(`Playlist synced to ListenBrainz (${cov})`, 'success'); } return; } else if (job.phase === 'error') { _setExportStatus(playlistId, `<span style="color:#ef4444;">${_esc(job.error || 'Export failed')}</span>`, 10000); return; } setTimeout(() => _pollPlaylistExport(jobId, playlistId, mode, name), 1000); } catch (e) { setTimeout(() => _pollPlaylistExport(jobId, playlistId, mode, name), 2000); } } function _setExportStatus(playlistId, html, autoHideMs) { // Inject into the card's existing .card-meta line (same approach as the pipeline phase // indicator) so the status sits inline and never disrupts the card's flex row. const card = document.getElementById(`mirrored-card-${playlistId}`); if (!card) return; const meta = card.querySelector('.card-meta'); if (!meta) return; let span = meta.querySelector('.export-status-span'); if (!span) { span = document.createElement('span'); span.className = 'export-status-span'; meta.appendChild(span); } span.innerHTML = html; if (autoHideMs) setTimeout(() => { if (span && span.parentNode) span.remove(); }, autoHideMs); } function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; if (state) state.phase = phase; // Extract the numeric ID from urlHash (e.g., 'mirrored_3' → '3') const mirroredId = urlHash.replace('mirrored_', ''); const card = document.getElementById(`mirrored-card-${mirroredId}`); if (!card) return; const metaEl = card.querySelector('.card-meta'); if (!metaEl) return; // Remove old phase indicator const oldPhase = metaEl.querySelector('span[style]'); if (oldPhase) oldPhase.remove(); // Add new phase indicator let phaseHtml = ''; switch (phase) { case 'pipeline_running': const pipelineProgress = state?.pipeline_progress || 0; const pipelinePhase = state?.pipeline_phase || 'Pipeline running'; phaseHtml = `<span style="color:#38bdf8;">${_esc(pipelinePhase)} ${pipelineProgress}%</span>`; break; case 'pipeline_complete': phaseHtml = `<span style="color:#22c55e;">Pipeline complete</span>`; break; case 'pipeline_error': phaseHtml = `<span style="color:#ef4444;">Pipeline error</span>`; break; case 'discovering': phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`; break; case 'discovered': const matches = state?.spotifyMatches || state?.spotify_matches || 0; const total = state?.spotify_total || 0; phaseHtml = `<span style="color:#22c55e;">Discovered ${matches}/${total}</span>`; break; case 'syncing': phaseHtml = `<span style="color:#3b82f6;">Syncing...</span>`; break; case 'sync_complete': phaseHtml = `<span style="color:#3b82f6;">Synced</span>`; break; case 'downloading': phaseHtml = `<span style="color:#f59e0b;">Downloading...</span>`; break; case 'download_complete': phaseHtml = `<span style="color:#22c55e;">Downloaded</span>`; break; } if (phaseHtml) { metaEl.insertAdjacentHTML('beforeend', phaseHtml); } } async function rehydrateMirroredDownloadModal(urlHash, state) { try { if (!state || !state.playlist) { showToast('Cannot open download modal - invalid playlist data', 'error'); return; } console.log(`💧 [Rehydration] Rehydrating mirrored download modal for: ${state.playlist.name}`); // Get discovery results from backend if not already loaded let discoveryRes = state.discoveryResults || state.discovery_results; if (!discoveryRes || discoveryRes.length === 0) { console.log(`🔍 Fetching discovery results from backend for mirrored playlist: ${urlHash}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { const fullState = await stateResponse.json(); state.discovery_results = fullState.discovery_results; state.discoveryResults = fullState.discovery_results; state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; state.download_process_id = fullState.download_process_id; discoveryRes = fullState.discovery_results; console.log(`✅ Loaded ${discoveryRes?.length || 0} discovery results from backend`); } else { showToast('Error loading playlist data', 'error'); return; } } // Extract Spotify tracks from discovery results const spotifyTracks = (discoveryRes || []) .filter(r => r.spotify_data || (r.spotify_track && r.status_class === 'found')) .map(r => { if (r.spotify_data) return r.spotify_data; const albumData = r.spotify_album || 'Unknown Album'; return { id: r.spotify_id || 'unknown', name: r.spotify_track || 'Unknown Track', artists: r.spotify_artist ? [r.spotify_artist] : ['Unknown Artist'], album: typeof albumData === 'object' ? albumData : { name: albumData, album_type: 'album', images: [] }, duration_ms: 0 }; }); if (spotifyTracks.length === 0) { showToast('No Spotify matches found for download', 'error'); return; } const virtualPlaylistId = state.convertedSpotifyPlaylistId; const playlistName = state.playlist.name; // Create the download modal await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); // If we have a download process ID, set up the modal for the running/complete state if (state.download_process_id) { const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = state.phase === 'download_complete' ? 'complete' : 'running'; process.batchId = state.download_process_id; const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); if (state.phase === 'downloading') { if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Start polling for live updates startModalDownloadPolling(virtualPlaylistId); console.log(`🔄 Started polling for active mirrored download: ${state.download_process_id}`); } else if (state.phase === 'download_complete') { if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; console.log(`✅ Showing completed mirrored download results: ${state.download_process_id}`); // Fetch final results to populate the modal try { const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`); if (response.ok) { const data = await response.json(); if (data.phase === 'complete' && data.tasks) { updateCompletedModalResults(virtualPlaylistId, data); } } } catch (err) { console.warn('Could not load completed download results:', err); } } } } console.log(`✅ Successfully rehydrated mirrored download modal for: ${state.playlist.name}`); } catch (error) { console.error('❌ Error rehydrating mirrored download modal:', error); showToast('Error opening download modal', 'error'); } } async function hydrateMirroredDiscoveryStates() { try { const res = await fetch('/api/mirrored-playlists/discovery-states'); const data = await res.json(); if (data.error || !data.states || data.states.length === 0) return; console.log(`Hydrating ${data.states.length} mirrored discovery states`); for (const s of data.states) { const hash = s.url_hash; youtubePlaylistStates[hash] = { playlist: s.playlist, phase: s.phase, discovery_results: s.discovery_results || [], discoveryResults: s.discovery_results || [], discovery_progress: s.discovery_progress || 0, discoveryProgress: s.discovery_progress || 0, spotify_matches: s.spotify_matches || 0, spotifyMatches: s.spotify_matches || 0, spotify_total: s.spotify_total || 0, status: s.status || '', url: s.playlist?.url || '', sync_playlist_id: null, converted_spotify_playlist_id: s.converted_spotify_playlist_id, convertedSpotifyPlaylistId: s.converted_spotify_playlist_id, download_process_id: s.download_process_id, created_at: Date.now() / 1000, last_accessed: Date.now() / 1000, discovery_future: null, sync_progress: {}, is_mirrored_playlist: true, mirrored_source: s.playlist?.source || '' }; // Update the card to reflect the current phase const card = document.getElementById(`mirrored-card-${s.playlist_id}`); if (card) { const metaEl = card.querySelector('.card-meta'); if (metaEl) { // Remove old phase span and add new one const oldPhase = metaEl.querySelector('span[style]'); if (oldPhase) oldPhase.remove(); if (s.phase === 'discovering') { metaEl.insertAdjacentHTML('beforeend', `<span style="color:#a78bfa;">Discovering ${s.discovery_progress || 0}%</span>`); } else if (s.phase === 'discovered') { metaEl.insertAdjacentHTML('beforeend', `<span style="color:#22c55e;">Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0}</span>`); } else if (s.phase === 'syncing' || s.phase === 'sync_complete') { metaEl.insertAdjacentHTML('beforeend', `<span style="color:#3b82f6;">${s.phase === 'syncing' ? 'Syncing...' : 'Synced'}</span>`); } else if (s.phase === 'downloading') { metaEl.insertAdjacentHTML('beforeend', `<span style="color:#f59e0b;">Downloading...</span>`); } else if (s.phase === 'download_complete') { metaEl.insertAdjacentHTML('beforeend', `<span style="color:#22c55e;">Downloaded</span>`); } } } // Resume polling if discovery is in progress if (s.phase === 'discovering' && !activeYouTubePollers[hash]) { startYouTubeDiscoveryPolling(hash); } } } catch (err) { console.warn('Failed to hydrate mirrored discovery states:', err); } } function timeAgo(dateStr) { if (!dateStr) return ''; // Handle ISO formats: "Z" suffix, "+00:00" offset, or bare (assume UTC) let ts = dateStr; if (!ts.includes('Z') && !ts.includes('+') && !ts.includes('-', 10)) ts += 'Z'; const diff = Date.now() - new Date(ts).getTime(); const secs = Math.floor(diff / 1000); if (secs < 5) return 'just now'; if (secs < 60) return `${secs}s ago`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}d ago`; return `${Math.floor(days / 30)}mo ago`; } /** * Open modal showing all tracks in a mirrored playlist. */ async function openMirroredPlaylistModal(playlistId) { showLoadingOverlay('Loading mirrored playlist...'); try { const res = await fetch(`/api/mirrored-playlists/${playlistId}`); const data = await res.json(); if (data.error) throw new Error(data.error); hideLoadingOverlay(); // Remove any existing modal const old = document.getElementById('mirrored-track-modal'); if (old) old.remove(); const overlay = document.createElement('div'); overlay.id = 'mirrored-track-modal'; overlay.className = 'mirrored-modal-overlay'; const tracks = data.tracks || []; const source = data.source || 'unknown'; const sourceRef = getMirroredSourceRef(data); const sourceIcons = { spotify: '🎵', spotify_public: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', deezer: '🎧', qobuz: '♫' }; const sourceLabels = { spotify: 'Spotify', spotify_public: 'Spotify', tidal: 'Tidal', youtube: 'YouTube', beatport: 'Beatport', deezer: 'Deezer', qobuz: 'Qobuz' }; const sourceIcon = sourceIcons[source] || '📋'; const srcLabel = sourceLabels[source] || source; // Hero artwork: playlist cover → first track with art → gradient fallback. const heroArt = data.image_url || (tracks.find(t => t.image_url) || {}).image_url || ''; // Total runtime for the meta line. const totalMs = tracks.reduce((sum, t) => sum + (t.duration_ms || 0), 0); const totalMin = Math.round(totalMs / 60000); const totalLabel = totalMin >= 60 ? `${Math.floor(totalMin / 60)} hr ${totalMin % 60} min` : `${totalMin} min`; const trackRows = tracks.map(t => { const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; const art = t.image_url ? `<img class="mm-row-art" src="${_escAttr(t.image_url)}" loading="lazy" onerror="this.onerror=null;this.classList.add('mm-art-empty');this.removeAttribute('src');">` : `<span class="mm-row-art mm-art-empty"></span>`; return `<div class="mm-row"> <span class="mm-row-pos">${t.position}</span> ${art} <div class="mm-row-main"> <span class="mm-row-title">${_esc(t.track_name)}</span> <span class="mm-row-artist">${_esc(t.artist_name)}</span> </div> <span class="mm-row-album">${_esc(t.album_name || '')}</span> <span class="mm-row-dur">${dur}</span> </div>`; }).join(''); const heroCover = heroArt ? `<div class="mm-cover" style="background-image:url('${_escAttr(heroArt)}')"></div>` : `<div class="mm-cover mm-cover-empty ${_escAttr(source)}">${sourceIcon}</div>`; overlay.innerHTML = ` <div class="mirrored-modal"> <div class="mm-hero"> ${heroArt ? `<div class="mm-hero-bg" style="background-image:url('${_escAttr(heroArt)}')"></div>` : ''} <div class="mm-hero-content"> ${heroCover} <div class="mm-hero-info"> <span class="mm-eyebrow">Mirrored Playlist</span> <h2 class="mm-title">${_esc(data.name)}</h2> <div class="mm-meta"> <span class="mm-source-pill ${_escAttr(source)}">${_esc(srcLabel)}</span> ${data.owner ? `<span class="mm-meta-item">${_esc(data.owner)}</span><span class="mm-dot">·</span>` : ''} <span class="mm-meta-item">${tracks.length} tracks</span> ${totalMs ? `<span class="mm-dot">·</span><span class="mm-meta-item">${totalLabel}</span>` : ''} <span class="mm-dot">·</span> <span class="mm-meta-item">Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span> </div> </div> </div> <button class="mm-close" onclick="closeMirroredModal()" aria-label="Close">×</button> </div> <div class="mm-list"> <div class="mm-list-head"> <span>#</span><span></span><span>Title</span><span>Album</span><span class="mm-col-dur">Time</span> </div> ${trackRows || '<div class="mm-empty">No tracks in this mirror yet.</div>'} </div> <div class="mm-actions"> <button class="mm-btn mm-btn-danger" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escJs(data.name)}')">Delete Mirror</button> <div class="mm-actions-right"> <button class="mm-btn mm-btn-ghost" onclick="editMirroredSourceRef(${playlistId}, '${_escJs(data.name)}', '${_escJs(source)}', '${_escJs(sourceRef)}')">Edit Source</button> <button class="mm-btn mm-btn-secondary" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escJs(data.name)}')">Auto-Sync</button> <button class="mm-btn mm-btn-ghost" onclick="closeMirroredModal()">Close</button> <button class="mm-btn mm-btn-primary" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button> </div> </div> </div> `; overlay.addEventListener('click', e => { if (e.target === overlay) closeMirroredModal(); }); document.body.appendChild(overlay); } catch (err) { hideLoadingOverlay(); showToast(`Error: ${err.message}`, 'error'); } } function closeMirroredModal() { const m = document.getElementById('mirrored-track-modal'); if (m) m.remove(); } /** * Delete a mirrored playlist after confirmation. */ async function clearMirroredDiscovery(playlistId, name) { if (!await showConfirmDialog({ title: 'Clear Discovery Data', message: `Clear discovery data for "${name}"? You can re-discover afterwards to get updated cover art.` })) return; try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/clear-discovery`, { method: 'POST' }); const data = await res.json(); if (data.success) { showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success'); // Signal cancellation to any running worker, then clear state const hash = `mirrored_${playlistId}`; if (youtubePlaylistStates[hash]) { youtubePlaylistStates[hash].phase = 'cancelled'; } delete youtubePlaylistStates[hash]; const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`); if (staleModal) staleModal.remove(); loadMirroredPlaylists(); } else { showToast(data.error || 'Failed to clear discovery', 'error'); } } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } // ==================== Discovery Pool Modal ==================== let _discoveryPoolOverlay = null; let _discoveryPoolData = null; let _discoveryPoolView = 'categories'; // 'categories' | 'failed' | 'matched' let _discoveryPoolPlaylistFilter = null; async function loadDiscoveryPoolStats() { try { const res = await fetch('/api/discovery-pool'); const data = await res.json(); const matchedEl = document.getElementById('discovery-pool-matched-count'); const failedEl = document.getElementById('discovery-pool-failed-count'); if (matchedEl) matchedEl.textContent = data.stats.matched || 0; if (failedEl) failedEl.textContent = data.stats.failed || 0; } catch (e) { } } async function openDiscoveryPoolModal(playlistId = null) { _discoveryPoolPlaylistFilter = playlistId; _discoveryPoolView = 'categories'; // Fetch pool data let url = '/api/discovery-pool'; if (playlistId) url += `?playlist_id=${playlistId}`; try { const res = await fetch(url); _discoveryPoolData = await res.json(); } catch (err) { showToast('Failed to load discovery pool', 'error'); return; } // Remove existing overlay if present if (_discoveryPoolOverlay) _discoveryPoolOverlay.remove(); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'discovery-pool-overlay'; overlay.onclick = (e) => { if (e.target === overlay) closeDiscoveryPoolModal(); }; const playlistOptions = (_discoveryPoolData.playlists || []) .map(p => `<option value="${p.id}" ${playlistId == p.id ? 'selected' : ''}>${_esc(p.name)}</option>`) .join(''); const failedCount = _discoveryPoolData.stats.failed || 0; const matchedCount = _discoveryPoolData.stats.matched || 0; overlay.innerHTML = ` <div class="modal-container playlist-modal"> <div class="playlist-modal-header"> <div class="playlist-header-content"> <h2>Discovery Pool</h2> <div class="playlist-quick-info"> <span class="playlist-track-count" id="pool-header-matched">${matchedCount} Matched</span> <span class="playlist-owner ${failedCount > 0 ? 'pool-header-failed-highlight' : ''}" id="pool-header-failed">${failedCount} Failed</span> <select class="pool-playlist-filter" onchange="filterDiscoveryPool(this.value)"> <option value="">All Playlists</option> ${playlistOptions} </select> </div> </div> <span class="playlist-modal-close" onclick="closeDiscoveryPoolModal()">×</span> </div> <div class="playlist-modal-body"> <div class="pool-category-grid" id="pool-category-grid"> <div class="pool-category-card failed" onclick="showPoolList('failed')"> <div class="pool-category-fallback failed"></div> <div class="pool-category-overlay"></div> <div class="pool-category-content"> <div class="pool-category-icon">⚠</div> <div class="pool-category-count failed" id="pool-cat-failed-count">${failedCount}</div> <div class="pool-category-label">tracks need attention</div> </div> <div class="pool-category-top-bar failed"></div> </div> <div class="pool-category-card matched" onclick="showPoolList('matched')"> <div class="pool-category-fallback matched" id="pool-matched-bg"></div> <div class="pool-category-overlay"></div> <div class="pool-category-content"> <div class="pool-category-icon">✓</div> <div class="pool-category-count matched" id="pool-cat-matched-count">${matchedCount}</div> <div class="pool-category-label">cached matches</div> </div> <div class="pool-category-top-bar matched"></div> </div> </div> <div class="pool-list-view" id="pool-list-view" style="display: none;"> <div class="pool-list-header"> <button class="pool-back-btn" onclick="showPoolCategories()">← Back</button> <span class="pool-list-title" id="pool-list-title"></span> <input type="text" class="pool-list-search" id="pool-list-search" placeholder="Filter tracks..." oninput="renderPoolList()"> </div> <div class="pool-list-content" id="pool-list-content"></div> </div> </div> <div class="playlist-modal-footer"> <div class="playlist-modal-footer-left"></div> <div class="playlist-modal-footer-right"> <button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDiscoveryPoolModal()">Close</button> </div> </div> </div> `; document.body.appendChild(overlay); overlay.style.display = 'flex'; _discoveryPoolOverlay = overlay; // Build matched mosaic if images available _buildPoolMatchedMosaic(); } function _buildPoolMatchedMosaic() { const entries = _discoveryPoolData.matched || []; const images = []; for (const e of entries) { const md = e.matched_data || {}; if (md.image_url && images.indexOf(md.image_url) === -1) { images.push(md.image_url); if (images.length >= 20) break; } } const bgEl = document.getElementById('pool-matched-bg'); if (!bgEl || images.length < 4) return; // keep fallback gradient // Build mosaic rows similar to wishlist bgEl.innerHTML = ''; bgEl.className = 'wishlist-mosaic-background'; const rows = 4; const imgPerRow = Math.ceil(images.length / rows) * 2; // duplicate for seamless loop for (let r = 0; r < rows; r++) { const wrapper = document.createElement('div'); wrapper.className = 'wishlist-mosaic-row-wrapper'; const row = document.createElement('div'); row.className = 'wishlist-mosaic-row' + (r % 2 === 1 ? ' scroll-right' : ''); row.style.setProperty('--speed', (25 + r * 5) + 's'); row.style.animationDelay = (r * 0.15) + 's'; for (let i = 0; i < imgPerRow; i++) { const img = images[(i + r * 3) % images.length]; const tile = document.createElement('div'); tile.className = 'wishlist-mosaic-tile'; tile.innerHTML = `<div class="wishlist-mosaic-image" style="background-image: url('${img}')"></div>`; row.appendChild(tile); } wrapper.appendChild(row); bgEl.appendChild(wrapper); } } function closeDiscoveryPoolModal() { if (_discoveryPoolOverlay) { _discoveryPoolOverlay.remove(); _discoveryPoolOverlay = null; } _discoveryPoolData = null; // Refresh dashboard stats loadDiscoveryPoolStats(); } // ── Wing It Pool ─────────────────────────────────────────────────────────── // Lists tracks Wing It auto-matched on a best-effort guess (extra_data // wing_it_fallback flag). They count as 'discovered' so the Discovery Pool hides // them — this is the only place to review + re-match the guesses. Re-match reuses // the Discovery Pool's openPoolFixModal / /api/discovery-pool/fix (same track id); // a manual match drops the row from here on the next refresh. let _wingItPoolOverlay = null; let _wingItPoolData = null; let _wingItPoolView = 'categories'; // 'categories' | 'attention' | 'matched' let _wingItPoolPlaylistFilter = null; async function openWingItPoolModal(playlistId = null) { _wingItPoolPlaylistFilter = playlistId; _wingItPoolView = 'categories'; let url = '/api/wing-it-pool'; if (playlistId) url += `?playlist_id=${playlistId}`; try { const res = await fetch(url); _wingItPoolData = await res.json(); } catch (err) { showToast('Failed to load Wing It pool', 'error'); return; } if (_wingItPoolOverlay) _wingItPoolOverlay.remove(); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'wing-it-pool-overlay'; overlay.onclick = (e) => { if (e.target === overlay) closeWingItPoolModal(); }; const playlistOptions = (_wingItPoolData.playlists || []) .map(p => `<option value="${p.id}" ${playlistId == p.id ? 'selected' : ''}>${_esc(p.name)}</option>`) .join(''); const attentionCount = (_wingItPoolData.tracks || []).length; const matchedCount = (_wingItPoolData.matched || []).length; overlay.innerHTML = ` <div class="modal-container playlist-modal"> <div class="playlist-modal-header"> <div class="playlist-header-content"> <h2>Wing It Pool</h2> <div class="playlist-quick-info"> <span class="playlist-owner ${attentionCount > 0 ? 'pool-header-failed-highlight' : ''}" id="wing-it-header-attention">${attentionCount} to review</span> <span class="playlist-track-count" id="wing-it-header-matched">${matchedCount} resolved</span> <select class="pool-playlist-filter" onchange="filterWingItPool(this.value)"> <option value="">All Playlists</option> ${playlistOptions} </select> </div> </div> <span class="playlist-modal-close" onclick="closeWingItPoolModal()">×</span> </div> <div class="playlist-modal-body"> <div class="pool-category-grid" id="wing-it-category-grid"> <div class="pool-category-card failed" onclick="showWingItList('attention')"> <div class="pool-category-fallback failed"></div> <div class="pool-category-overlay"></div> <div class="pool-category-content"> <div class="pool-category-icon">⚡</div> <div class="pool-category-count failed" id="wing-it-cat-attention-count">${attentionCount}</div> <div class="pool-category-label">guesses to review</div> </div> <div class="pool-category-top-bar failed"></div> </div> <div class="pool-category-card matched" onclick="showWingItList('matched')"> <div class="pool-category-fallback matched"></div> <div class="pool-category-overlay"></div> <div class="pool-category-content"> <div class="pool-category-icon">✓</div> <div class="pool-category-count matched" id="wing-it-cat-matched-count">${matchedCount}</div> <div class="pool-category-label">resolved manually</div> </div> <div class="pool-category-top-bar matched"></div> </div> </div> <div class="pool-list-view" id="wing-it-list-view" style="display: none;"> <div class="pool-list-header"> <button class="pool-back-btn" onclick="showWingItCategories()">← Back</button> <span class="pool-list-title" id="wing-it-list-title"></span> <input type="text" class="pool-list-search" id="wing-it-list-search" placeholder="Filter tracks..." oninput="renderWingItPoolList()"> </div> <div class="pool-list-content" id="wing-it-list-content"></div> </div> </div> <div class="playlist-modal-footer"> <div class="playlist-modal-footer-left"></div> <div class="playlist-modal-footer-right"> <button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWingItPoolModal()">Close</button> </div> </div> </div> `; document.body.appendChild(overlay); overlay.style.display = 'flex'; _wingItPoolOverlay = overlay; } function showWingItList(view) { _wingItPoolView = view; const grid = document.getElementById('wing-it-category-grid'); const listView = document.getElementById('wing-it-list-view'); const title = document.getElementById('wing-it-list-title'); const search = document.getElementById('wing-it-list-search'); if (grid) grid.style.display = 'none'; if (listView) listView.style.display = 'block'; if (title) title.textContent = view === 'matched' ? '✓ Resolved Wing It guesses' : '⚡ Guesses to review'; if (search) search.value = ''; renderWingItPoolList(); } function showWingItCategories() { _wingItPoolView = 'categories'; const grid = document.getElementById('wing-it-category-grid'); const listView = document.getElementById('wing-it-list-view'); if (grid) grid.style.display = 'grid'; if (listView) listView.style.display = 'none'; } function _wingItMatchedName(t) { try { const md = JSON.parse(t.extra_data || '{}').matched_data || {}; return md.name || ''; } catch (e) { return ''; } } function renderWingItPoolList() { const container = document.getElementById('wing-it-list-content'); if (!container || !_wingItPoolData) return; const searchEl = document.getElementById('wing-it-list-search'); const query = (searchEl ? searchEl.value : '').toLowerCase().trim(); const isMatched = _wingItPoolView === 'matched'; let tracks = (isMatched ? _wingItPoolData.matched : _wingItPoolData.tracks) || []; if (query) { tracks = tracks.filter(t => (t.track_name || '').toLowerCase().includes(query) || (t.artist_name || '').toLowerCase().includes(query) || (t.playlist_name || '').toLowerCase().includes(query) ); } if (tracks.length === 0) { const emptyMsg = isMatched ? 'No resolved Wing It tracks yet — ones you Fix here will land in this list.' : 'No Wing It guesses to review.'; container.innerHTML = query ? '<div class="pool-empty">No tracks match your filter.</div>' : `<div class="pool-empty">${emptyMsg}</div>`; return; } container.innerHTML = tracks.map(t => { if (isMatched) { const matchedName = _wingItMatchedName(t); return ` <div class="pool-track-row pool-matched"> <div class="pool-track-info"> <div class="pool-track-name">${_esc(t.track_name)}</div> <div class="pool-track-meta"> <span class="pool-track-artist">${_esc(t.artist_name)}</span> ${matchedName ? `<span class="pool-track-arrow">→</span><span class="pool-match-name">${_esc(matchedName)}</span>` : ''} <span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span> </div> </div> <button class="pool-rematch-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')" title="Change this match">Re-match</button> </div> `; } return ` <div class="pool-track-row pool-failed"> <div class="pool-track-info"> <div class="pool-track-name">${_esc(t.track_name)}</div> <div class="pool-track-meta"> <span class="pool-track-artist">${_esc(t.artist_name)}</span> <span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span> </div> </div> <button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')">Fix Match</button> </div> `; }).join(''); } async function filterWingItPool(playlistId) { _wingItPoolPlaylistFilter = playlistId || null; let url = '/api/wing-it-pool'; if (playlistId) url += `?playlist_id=${playlistId}`; try { const res = await fetch(url); _wingItPoolData = await res.json(); } catch (e) { showToast('Failed to filter Wing It pool', 'error'); return; } _updateWingItHeaderCounts(); if (_wingItPoolView === 'categories') return; renderWingItPoolList(); } function _updateWingItHeaderCounts() { if (!_wingItPoolData) return; const a = (_wingItPoolData.tracks || []).length; const m = (_wingItPoolData.matched || []).length; const aEl = document.getElementById('wing-it-header-attention'); const mEl = document.getElementById('wing-it-header-matched'); const aCat = document.getElementById('wing-it-cat-attention-count'); const mCat = document.getElementById('wing-it-cat-matched-count'); if (aEl) { aEl.textContent = `${a} to review`; aEl.classList.toggle('pool-header-failed-highlight', a > 0); } if (mEl) mEl.textContent = `${m} resolved`; if (aCat) aCat.textContent = a; if (mCat) mCat.textContent = m; } // Re-fetch + re-render the open Wing It pool (used after a Fix Match resolves a track). function refreshWingItPool() { filterWingItPool(_wingItPoolPlaylistFilter || ''); } function closeWingItPoolModal() { if (_wingItPoolOverlay) { _wingItPoolOverlay.remove(); _wingItPoolOverlay = null; } _wingItPoolData = null; } function showPoolCategories() { _discoveryPoolView = 'categories'; const grid = document.getElementById('pool-category-grid'); const list = document.getElementById('pool-list-view'); if (grid) grid.style.display = ''; if (list) list.style.display = 'none'; } function showPoolList(category) { _discoveryPoolView = category; const grid = document.getElementById('pool-category-grid'); const list = document.getElementById('pool-list-view'); if (grid) grid.style.display = 'none'; if (list) list.style.display = ''; const titleEl = document.getElementById('pool-list-title'); if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks'; // Clear search filter when switching views const searchEl = document.getElementById('pool-list-search'); if (searchEl) searchEl.value = ''; renderPoolList(); } async function filterDiscoveryPool(playlistId) { _discoveryPoolPlaylistFilter = playlistId || null; let url = '/api/discovery-pool'; if (playlistId) url += `?playlist_id=${playlistId}`; try { const res = await fetch(url); _discoveryPoolData = await res.json(); // Update header counts _updatePoolHeaderCounts(); // Update category card counts const failedCountEl = document.getElementById('pool-cat-failed-count'); const matchedCountEl = document.getElementById('pool-cat-matched-count'); if (failedCountEl) failedCountEl.textContent = _discoveryPoolData.stats.failed || 0; if (matchedCountEl) matchedCountEl.textContent = _discoveryPoolData.stats.matched || 0; // If viewing a list, refresh it if (_discoveryPoolView === 'failed' || _discoveryPoolView === 'matched') { renderPoolList(); } } catch (err) { showToast('Failed to filter discovery pool', 'error'); } } function _updatePoolHeaderCounts() { if (!_discoveryPoolData) return; const failedCount = _discoveryPoolData.stats.failed || 0; const matchedCount = _discoveryPoolData.stats.matched || 0; const matchedEl = document.getElementById('pool-header-matched'); const failedEl = document.getElementById('pool-header-failed'); if (matchedEl) matchedEl.textContent = `${matchedCount} Matched`; if (failedEl) { failedEl.textContent = `${failedCount} Failed`; failedEl.classList.toggle('pool-header-failed-highlight', failedCount > 0); } } function renderPoolList() { const container = document.getElementById('pool-list-content'); if (!container || !_discoveryPoolData) return; // Client-side search filter const searchEl = document.getElementById('pool-list-search'); const query = (searchEl ? searchEl.value : '').toLowerCase().trim(); if (_discoveryPoolView === 'failed') { let tracks = _discoveryPoolData.failed || []; if (query) { tracks = tracks.filter(t => (t.track_name || '').toLowerCase().includes(query) || (t.artist_name || '').toLowerCase().includes(query) || (t.playlist_name || '').toLowerCase().includes(query) ); } if (tracks.length === 0) { container.innerHTML = query ? '<div class="pool-empty">No failed tracks match your filter.</div>' : '<div class="pool-empty">No failed discoveries. All tracks matched successfully.</div>'; return; } container.innerHTML = tracks.map(t => ` <div class="pool-track-row pool-failed"> <div class="pool-track-info"> <div class="pool-track-name">${_esc(t.track_name)}</div> <div class="pool-track-meta"> <span class="pool-track-artist">${_esc(t.artist_name)}</span> <span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span> </div> </div> <button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')">Fix Match</button> </div> `).join(''); } else { let entries = _discoveryPoolData.matched || []; if (query) { entries = entries.filter(e => { const md = e.matched_data || {}; const matchedName = md.name || ''; return (e.original_title || '').toLowerCase().includes(query) || (e.original_artist || '').toLowerCase().includes(query) || matchedName.toLowerCase().includes(query); }); } if (entries.length === 0) { container.innerHTML = query ? '<div class="pool-empty">No matched tracks match your filter.</div>' : '<div class="pool-empty">No cached discovery matches yet.</div>'; return; } container.innerHTML = entries.map(e => { const md = e.matched_data || {}; const matchedArtists = (md.artists || []).map(a => typeof a === 'string' ? a : (a.name || '')).join(', '); const conf = Math.round((e.confidence || 0) * 100); const confClass = conf >= 80 ? 'high' : (conf >= 70 ? 'mid' : 'low'); const album = md.album || {}; const albumImages = (typeof album === 'object' && album.images) ? album.images : []; const imgUrl = md.image_url || (albumImages.length > 0 ? albumImages[0].url || '' : ''); return ` <div class="pool-track-row pool-matched"> ${imgUrl ? `<img class="pool-match-image" src="${_esc(imgUrl)}" alt="" onerror="this.style.display='none'" />` : '<div class="pool-match-image-placeholder"></div>'} <div class="pool-track-info"> <div class="pool-track-name">${_esc(e.original_title)}</div> <div class="pool-track-meta"> <span class="pool-track-artist">${_esc(e.original_artist)}</span> <span class="pool-track-arrow">→</span> <span class="pool-match-name">${_esc(md.name || '?')}</span> <span class="pool-match-provider">${_esc(e.provider)}</span> </div> </div> <span class="pool-confidence-badge ${confClass}">${conf}%</span> <span class="pool-use-count">${e.use_count}×</span> <button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escJs(e.original_title)}', '${_escJs(e.original_artist)}')" title="Rematch this track">Rematch</button> <button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">×</button> </div> `; }).join(''); } } function rematchPoolCacheEntry(cacheId, originalTitle, originalArtist) { // Open the fix modal in "rematch" mode — saves to cache instead of mirrored tracks openPoolRematchModal(cacheId, originalTitle, originalArtist); } function openPoolRematchModal(cacheId, trackName, artistName) { // Reuses the fix modal UI but saves via the rematch endpoint let fixOverlay = document.getElementById('pool-fix-overlay'); if (fixOverlay) fixOverlay.remove(); fixOverlay = document.createElement('div'); fixOverlay.className = 'pool-fix-overlay'; fixOverlay.id = 'pool-fix-overlay'; fixOverlay.addEventListener('mousedown', (e) => { if (e.target === fixOverlay) { e.preventDefault(); closePoolFixModal(); } }); fixOverlay.innerHTML = ` <div class="pool-fix-modal" onmousedown="event.stopPropagation()"> <div class="pool-fix-header"> <h2>Rematch Track</h2> <button class="pool-fix-close" onclick="closePoolFixModal()" title="Close">✕</button> </div> <div class="pool-fix-body"> <div class="pool-fix-source"> <div class="pool-fix-source-label">Current Match</div> <div class="pool-fix-source-row"> <span class="pool-fix-source-title">${_esc(trackName)}</span> <span class="pool-fix-source-sep">—</span> <span class="pool-fix-source-artist">${_esc(artistName)}</span> </div> </div> <div class="pool-fix-search"> <div class="pool-fix-input-row"> <div class="pool-fix-input-wrap"> <label for="pool-fix-track-input">Track</label> <input type="text" id="pool-fix-track-input" placeholder="Track name" value="${_escAttr(trackName)}"> </div> <div class="pool-fix-input-wrap"> <label for="pool-fix-artist-input">Artist</label> <input type="text" id="pool-fix-artist-input" placeholder="Artist name" value="${_escAttr(artistName)}"> </div> <button class="pool-fix-search-btn" onclick="searchPoolFix()">Search</button> </div> </div> <div class="pool-fix-results-area"> <div id="pool-fix-results" class="pool-fix-results-list"> <div class="pool-fix-empty">Searching...</div> </div> </div> </div> <div class="pool-fix-footer"> <button class="pool-fix-cancel" onclick="closePoolFixModal()">Cancel</button> </div> </div> `; // Store rematch context fixOverlay.dataset.mode = 'rematch'; fixOverlay.dataset.cacheId = cacheId; fixOverlay.dataset.originalTitle = trackName; fixOverlay.dataset.originalArtist = artistName; document.body.appendChild(fixOverlay); const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; trackInput.addEventListener('keypress', enterHandler); artistInput.addEventListener('keypress', enterHandler); trackInput.focus(); trackInput.select(); setTimeout(() => searchPoolFix(), 500); } async function removePoolCacheEntry(entryId) { if (!await showConfirmDialog({ title: 'Remove Cache Entry', message: 'Remove this cached match? The track will be re-discovered fresh next time.' })) return; try { const res = await fetch(`/api/discovery-pool/cache/${entryId}`, { method: 'DELETE' }); const data = await res.json(); if (data.success) { showToast('Cache entry removed', 'success'); filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); } else { showToast(data.error || 'Failed to remove', 'error'); } } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } // --- Pool Fix Sub-Modal --- function openPoolFixModal(trackId, trackName, artistName) { // Create sub-modal overlay inside the pool modal let fixOverlay = document.getElementById('pool-fix-overlay'); if (fixOverlay) fixOverlay.remove(); fixOverlay = document.createElement('div'); fixOverlay.className = 'pool-fix-overlay'; fixOverlay.id = 'pool-fix-overlay'; // Only close on click to the overlay itself — use a dedicated close zone // to prevent accidental dismissal when clicking near inputs fixOverlay.addEventListener('mousedown', (e) => { if (e.target === fixOverlay) { e.preventDefault(); // Prevent stealing focus from inputs closePoolFixModal(); } }); fixOverlay.innerHTML = ` <div class="pool-fix-modal" onmousedown="event.stopPropagation()"> <div class="pool-fix-header"> <h2>Fix Track Match</h2> <button class="pool-fix-close" onclick="closePoolFixModal()" title="Close">✕</button> </div> <div class="pool-fix-body"> <div class="pool-fix-source"> <div class="pool-fix-source-label">Original Track</div> <div class="pool-fix-source-row"> <span class="pool-fix-source-title">${_esc(trackName)}</span> <span class="pool-fix-source-sep">—</span> <span class="pool-fix-source-artist">${_esc(artistName)}</span> </div> </div> <div class="pool-fix-search"> <div class="pool-fix-input-row"> <div class="pool-fix-input-wrap"> <label for="pool-fix-track-input">Track</label> <input type="text" id="pool-fix-track-input" placeholder="Track name" value="${_escAttr(trackName)}"> </div> <div class="pool-fix-input-wrap"> <label for="pool-fix-artist-input">Artist</label> <input type="text" id="pool-fix-artist-input" placeholder="Artist name" value="${_escAttr(artistName)}"> </div> <button class="pool-fix-search-btn" onclick="searchPoolFix()">Search</button> </div> </div> <div class="pool-fix-results-area"> <div id="pool-fix-results" class="pool-fix-results-list"> <div class="pool-fix-empty">Searching...</div> </div> </div> </div> <div class="pool-fix-footer"> <button class="pool-fix-cancel" onclick="closePoolFixModal()">Cancel</button> </div> </div> `; fixOverlay.dataset.trackId = trackId; document.body.appendChild(fixOverlay); // Add enter key support const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; trackInput.addEventListener('keypress', enterHandler); artistInput.addEventListener('keypress', enterHandler); // Focus the track input trackInput.focus(); trackInput.select(); // Auto-search after a delay setTimeout(() => searchPoolFix(), 500); } function closePoolFixModal() { const fixOverlay = document.getElementById('pool-fix-overlay'); if (fixOverlay) fixOverlay.remove(); } async function searchPoolFix() { const trackInput = document.getElementById('pool-fix-track-input'); const artistInput = document.getElementById('pool-fix-artist-input'); const resultsContainer = document.getElementById('pool-fix-results'); if (!trackInput || !resultsContainer) return; const trackVal = trackInput.value.trim(); const artistVal = artistInput.value.trim(); if (!trackVal && !artistVal) { resultsContainer.innerHTML = '<div class="pool-fix-empty">Enter a search term</div>'; return; } resultsContainer.innerHTML = '<div class="pool-fix-empty"><div class="pool-fix-spinner"></div>Searching...</div>'; try { const params = new URLSearchParams(); if (trackVal) params.set('track', trackVal); if (artistVal) params.set('artist', artistVal); params.set('limit', '20'); const res = await fetch(`/api/spotify/search_tracks?${params.toString()}`); const data = await res.json().catch(() => ({})); // Surface the real failure instead of masking every error (auth, 500, an // upstream connection abort) as a bland "No results found". if (!res.ok || data.error) { const msg = data.error || res.statusText || `request failed (${res.status})`; resultsContainer.innerHTML = `<div class="pool-fix-empty">Search error: ${_esc(msg)}</div>`; return; } const tracks = data.tracks || []; if (tracks.length === 0) { resultsContainer.innerHTML = '<div class="pool-fix-empty">No results found</div>'; return; } resultsContainer.innerHTML = tracks.map((track) => { const artists = (track.artists || []).join(', '); const duration = track.duration_ms ? formatDuration(track.duration_ms) : ''; const albumText = track.album ? ` · ${_esc(track.album)}` : ''; return ` <div class="pool-fix-result" onclick='selectPoolFixTrack(${JSON.stringify(track).replace(/'/g, "'")})'> <div class="pool-fix-result-main"> <div class="pool-fix-result-title">${_esc(track.name || 'Unknown')}</div> <div class="pool-fix-result-meta">${_esc(artists)}${albumText}</div> </div> ${duration ? `<div class="pool-fix-result-dur">${duration}</div>` : ''} </div> `; }).join(''); } catch (err) { resultsContainer.innerHTML = `<div class="pool-fix-empty">Search failed: ${_esc(err.message)}</div>`; } } async function selectPoolFixTrack(track) { const fixOverlay = document.getElementById('pool-fix-overlay'); if (!fixOverlay) return; // Confirm selection const artists = (track.artists || []).join(', '); if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return; const isRematch = fixOverlay.dataset.mode === 'rematch'; try { let res, data; if (isRematch) { // Rematch mode: save new match to discovery cache res = await fetch('/api/discovery-pool/rematch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cache_id: parseInt(fixOverlay.dataset.cacheId), original_title: fixOverlay.dataset.originalTitle, original_artist: fixOverlay.dataset.originalArtist, spotify_track: track, }), }); data = await res.json(); } else { // Normal fix mode: save to mirrored track const trackId = parseInt(fixOverlay.dataset.trackId); res = await fetch('/api/discovery-pool/fix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_id: trackId, spotify_track: track, }), }); data = await res.json(); } if (data.success) { showToast(`Matched: ${track.name}`, 'success'); closePoolFixModal(); // Refresh whichever pool the fix was launched from (Discovery or Wing It). if (typeof _wingItPoolOverlay !== 'undefined' && _wingItPoolOverlay) { refreshWingItPool(); } else { filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); } } else { showToast(data.error || 'Failed to fix track', 'error'); } } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } async function deleteMirroredPlaylist(playlistId, name) { if (!await showConfirmDialog({ title: 'Delete Playlist', message: `Delete mirrored playlist "${name}"?`, confirmText: 'Delete', destructive: true })) return; try { const res = await fetch(`/api/mirrored-playlists/${playlistId}`, { method: 'DELETE' }); const data = await res.json(); if (data.success) { showToast(`Deleted mirror: ${name}`, 'success'); loadMirroredPlaylists(); } else { showToast(data.error || 'Failed to delete', 'error'); } } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } /** * Launch the existing discovery modal for a mirrored playlist by creating * a temporary entry in youtubePlaylistStates and reusing openYouTubeDiscoveryModal. */ async function discoverMirroredPlaylist(playlistId) { closeMirroredModal(); const tempHash = `mirrored_${playlistId}`; // If state already exists (discovery in progress or completed), just reopen the modal const existingState = youtubePlaylistStates[tempHash]; const hasActiveDiscovery = activeYouTubePollers[tempHash] || document.getElementById(`youtube-discovery-modal-${tempHash}`); if (existingState && (existingState.phase !== 'fresh' || hasActiveDiscovery)) { openYouTubeDiscoveryModal(tempHash); // Resume polling if discovery is in progress but poller stopped if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) { startYouTubeDiscoveryPolling(tempHash); } return; } showLoadingOverlay('Preparing discovery...'); try { // Register the mirrored playlist on the backend so the YouTube discovery pipeline can find it const prepRes = await fetch(`/api/mirrored-playlists/${playlistId}/prepare-discovery`, { method: 'POST' }); const prepData = await prepRes.json(); if (prepData.error) throw new Error(prepData.error); // Also fetch the full data for the frontend state const res = await fetch(`/api/mirrored-playlists/${playlistId}`); const data = await res.json(); if (data.error) throw new Error(data.error); hideLoadingOverlay(); // Build tracks in the format the discovery modal expects const tracks = (data.tracks || []).map(t => ({ id: t.source_track_id || `mirrored_${t.id}`, name: t.track_name, artists: [t.artist_name], album: t.album_name || '', duration_ms: t.duration_ms || 0 })); // Check if backend returned cached results if (prepData.from_cache) { // Fetch the pre-populated status from the backend const statusRes = await fetch(`/api/youtube/discovery/status/${tempHash}`); const statusData = await statusRes.json(); if (statusData.error) throw new Error(statusData.error); youtubePlaylistStates[tempHash] = { playlist: { name: data.name, tracks: tracks, track_count: tracks.length }, phase: statusData.phase || 'discovered', discovery_results: statusData.results || [], discoveryResults: statusData.results || [], discovery_progress: statusData.progress || 100, spotify_matches: statusData.spotify_matches || 0, spotifyMatches: statusData.spotify_matches || 0, spotify_total: tracks.length, status: statusData.status || 'complete', url: `mirrored://${data.source}/${data.source_playlist_id}`, sync_playlist_id: null, converted_spotify_playlist_id: null, download_process_id: null, created_at: Date.now() / 1000, last_accessed: Date.now() / 1000, discovery_future: null, sync_progress: {}, is_mirrored_playlist: true, mirrored_source: data.source }; const cached = prepData.cached_matches || 0; const total = prepData.total_tracks || tracks.length; showToast(`Loaded ${cached}/${total} cached discovery results`, 'success'); } else { // No cached data — fresh state youtubePlaylistStates[tempHash] = { playlist: { name: data.name, tracks: tracks, track_count: tracks.length }, phase: 'fresh', discovery_results: [], discovery_progress: 0, spotify_matches: 0, spotify_total: tracks.length, status: 'parsed', url: `mirrored://${data.source}/${data.source_playlist_id}`, sync_playlist_id: null, converted_spotify_playlist_id: null, download_process_id: null, created_at: Date.now() / 1000, last_accessed: Date.now() / 1000, discovery_future: null, sync_progress: {}, is_mirrored_playlist: true, mirrored_source: data.source }; } openYouTubeDiscoveryModal(tempHash); } catch (err) { hideLoadingOverlay(); showToast(`Error: ${err.message}`, 'error'); } } // =============================== // AUTOMATIONS — Visual Builder // =============================== async function retryFailedMirroredDiscovery(urlHash) { // Extract playlist ID from url_hash (format: "mirrored_<id>") const playlistId = urlHash.replace('mirrored_', ''); try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/retry-failed-discovery`, { method: 'POST' }); const data = await res.json(); if (data.error) { showToast(`Error: ${data.error}`, 'error'); return; } if (data.retry_count === 0) { showToast('All tracks already found!', 'success'); return; } // Update frontend state to discovering const state = youtubePlaylistStates[urlHash]; if (state) { state.phase = 'discovering'; state.status = 'discovering'; state.discovery_progress = 0; // #815: stamp a baseline so the completion toast can report how many // of these retried tracks were newly found (not just overall matched). state._retryDiscovery = { matchesBefore: state.spotify_matches || 0, retryCount: data.retry_count, }; } // Update modal buttons to show discovering state updateYouTubeModalButtons(urlHash, 'discovering'); // Start polling for progress startYouTubeDiscoveryPolling(urlHash); showToast(`Retrying ${data.retry_count} failed tracks...`, 'info'); } catch (err) { showToast(`Error retrying discovery: ${err.message}`, 'error'); } } let _autoBlocks = null; // cached block definitions from /api/automations/blocks let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false }; let _autoMirroredPlaylists = null; // cached mirrored playlist list let _autoSpotifyAuthenticated = false; // whether Spotify is authed (for refresh filtering) const _autoIcons = { schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705', watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04', playlist_changed: '\u270F\uFE0F', process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F', scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01', discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D', notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10', signal_received: '\u26A1', fire_signal: '\u26A1', run_script: '\uD83D\uDCBB', // Phase 3 wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795', watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64', import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2', quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F', library_scan_completed: '\uD83D\uDCE1', start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F', clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9', update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA', backup_database: '\uD83D\uDCBE', refresh_beatport_cache: '\uD83C\uDFB5', clean_search_history: '\uD83D\uDDD1\uFE0F', clean_completed_downloads: '\u2705', full_cleanup: '\uD83E\uDDF9', playlist_pipeline: '\uD83D\uDE80', }; // --- Inspiration Templates --- // --- Automation Hub Data --- // ── Automation Hub: One-Click Pipeline Groups ── const AUTO_HUB_GROUPS = [ { id: 'playlist-pipeline', icon: '🚀', name: 'Playlist Pipeline (All-in-One)', desc: 'Single automation that runs the full playlist lifecycle: refresh → discover → sync → download missing. No signal wiring needed.', category: 'Sync', badge: '1 automation', color: '#8b5cf6', steps: [ { label: 'Refresh', icon: '🔄', type: 'action' }, { label: 'Discover', icon: '🔍', type: 'action' }, { label: 'Sync', icon: '🔗', type: 'action' }, { label: 'Download', icon: '📥', type: 'action' }, ], automations: [ { name: 'Playlist Pipeline', trigger_type: 'schedule', trigger_config: { interval: 6, unit: 'hours' }, action_type: 'playlist_pipeline', action_config: { all: true }, then_actions: [], group_name: 'Playlist Pipeline' }, ] }, { id: 'new-music-pipeline', icon: '🚀', name: 'New Music Pipeline', desc: 'Full hands-free new music workflow. Scans your watchlist for releases, downloads them, cleans up, and notifies you.', category: 'Discovery', badge: '4 automations', color: '#f97316', steps: [ { label: 'Scan Artists', icon: '🔍', type: 'action' }, { label: 'Download', icon: '📥', type: 'action' }, { label: 'Cleanup', icon: '🧹', type: 'action' }, { label: 'Notify', icon: '🔔', type: 'notify' }, ], automations: [ { name: 'New Music — Scan Watchlist', trigger_type: 'schedule', trigger_config: { interval: 12, unit: 'hours' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_scanned' } }], group_name: 'New Music Pipeline' }, { name: 'New Music — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_downloaded' } }], group_name: 'New Music Pipeline' }, { name: 'New Music — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_downloaded' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_cleaned' } }], group_name: 'New Music Pipeline' }, { name: 'New Music — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_cleaned' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'New Music Pipeline', needs_notify: true }, ] }, { id: 'nightly-ops', icon: '🌙', name: 'Nightly Operations', desc: 'Staggered overnight maintenance: scan, download, cleanup, and backup while you sleep.', category: 'Maintenance', badge: '4 automations', color: '#8b5cf6', steps: [ { label: '1AM Scan', icon: '🔍', type: 'action' }, { label: '2AM Download', icon: '📥', type: 'action' }, { label: '3AM Cleanup', icon: '🧹', type: 'action' }, { label: '4AM Backup', icon: '💾', type: 'action' }, ], automations: [ { name: 'Nightly — 1AM Scan', trigger_type: 'daily_time', trigger_config: { time: '01:00' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, { name: 'Nightly — 2AM Download', trigger_type: 'daily_time', trigger_config: { time: '02:00' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, { name: 'Nightly — 3AM Cleanup', trigger_type: 'daily_time', trigger_config: { time: '03:00' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, { name: 'Nightly — 4AM Backup', trigger_type: 'daily_time', trigger_config: { time: '04:00' }, action_type: 'backup_database', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, ] }, { id: 'download-monitor', icon: '📊', name: 'Download Monitor', desc: 'Stay informed about your downloads. Get notified on failures, quarantined files, and completed batches.', category: 'Alerts', badge: '3 automations', color: '#ef4444', steps: [ { label: 'Failures', icon: '❌', type: 'notify' }, { label: 'Quarantine', icon: '⚠️', type: 'notify' }, { label: 'Complete', icon: '✅', type: 'notify' }, ], automations: [ { name: 'Alert — Download Failed', trigger_type: 'download_failed', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, { name: 'Alert — File Quarantined', trigger_type: 'download_quarantined', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, { name: 'Alert — Batch Complete', trigger_type: 'batch_complete', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, ] }, { id: 'library-guardian', icon: '🛡️', name: 'Library Guardian', desc: 'Protect your library quality. After scans, runs quality checks and notifies you of any issues found.', category: 'Maintenance', badge: '2 automations', color: '#f59e0b', steps: [ { label: 'Quality Scan', icon: '✅', type: 'action' }, { label: 'Notify', icon: '🔔', type: 'notify' }, ], automations: [ { name: 'Guardian — Quality Check', trigger_type: 'library_scan_completed', trigger_config: {}, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'guardian_quality_done' } }], group_name: 'Library Guardian' }, { name: 'Guardian — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'guardian_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Library Guardian', needs_notify: true }, ] }, { id: 'startup-recovery', icon: '⚡', name: 'Startup Recovery', desc: 'Self-heal after a restart. Scans your library, processes pending wishlist items, and cleans up automatically.', category: 'Maintenance', badge: '3 automations', color: '#14b8a6', steps: [ { label: 'Scan Library', icon: '📚', type: 'action' }, { label: 'Process Wishlist', icon: '📥', type: 'action' }, { label: 'Cleanup', icon: '🧹', type: 'action' }, ], automations: [ { name: 'Startup — Scan Library', trigger_type: 'app_started', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_scanned' } }], group_name: 'Startup Recovery' }, { name: 'Startup — Process Wishlist', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_processed' } }], group_name: 'Startup Recovery' }, { name: 'Startup — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_processed' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Startup Recovery' }, ] }, { id: 'import-pipeline', icon: '📦', name: 'Import Pipeline', desc: 'After importing files, automatically scans your library, runs a quality check, and notifies you when complete.', category: 'Maintenance', badge: '3 automations', color: '#a855f7', steps: [ { label: 'Scan Library', icon: '📚', type: 'action' }, { label: 'Quality Check', icon: '✅', type: 'action' }, { label: 'Notify', icon: '🔔', type: 'notify' }, ], automations: [ { name: 'Import — Scan Library', trigger_type: 'import_completed', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_scanned' } }], group_name: 'Import Pipeline' }, { name: 'Import — Quality Check', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_scanned' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_quality_done' } }], group_name: 'Import Pipeline' }, { name: 'Import — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Import Pipeline', needs_notify: true }, ] }, { id: 'weekly-deep-clean', icon: '✨', name: 'Weekly Deep Clean', desc: 'Comprehensive weekly sweep: find duplicates, check quality, clean up, back up, and report results.', category: 'Maintenance', badge: '5 automations', color: '#ec4899', steps: [ { label: 'Duplicates', icon: '📋', type: 'action' }, { label: 'Quality', icon: '✅', type: 'action' }, { label: 'Cleanup', icon: '🧹', type: 'action' }, { label: 'Backup', icon: '💾', type: 'action' }, { label: 'Notify', icon: '🔔', type: 'notify' }, ], automations: [ { name: 'Deep Clean — Duplicates', trigger_type: 'weekly_time', trigger_config: { days: ['sunday'], time: '02:00' }, action_type: 'run_duplicate_cleaner', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_dedup_done' } }], group_name: 'Weekly Deep Clean' }, { name: 'Deep Clean — Quality', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_dedup_done' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_quality_done' } }], group_name: 'Weekly Deep Clean' }, { name: 'Deep Clean — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_quality_done' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_cleanup_done' } }], group_name: 'Weekly Deep Clean' }, { name: 'Deep Clean — Backup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_cleanup_done' }, action_type: 'backup_database', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_backup_done' } }], group_name: 'Weekly Deep Clean' }, { name: 'Deep Clean — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_backup_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Weekly Deep Clean', needs_notify: true }, ] }, { id: 'beatport-fresh', icon: '🎧', name: 'Beatport Fresh', desc: 'Keep your Beatport charts and playlists up to date with a daily cache refresh.', category: 'Discovery', badge: '1 automation', color: '#84cc16', steps: [ { label: 'Refresh Cache', icon: '🔄', type: 'action' }, ], automations: [ { name: 'Beatport — Daily Refresh', trigger_type: 'daily_time', trigger_config: { time: '05:00' }, action_type: 'refresh_beatport_cache', action_config: {}, then_actions: [], group_name: 'Beatport Fresh' }, ] }, ]; const AUTO_HUB_RECIPES = [ // Sync & Playlists { id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.', category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] }, { id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.', category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [], chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.' }, { id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.', category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] }, { id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.', category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] }, { id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.', category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: [] }, // New Music Discovery { id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.', category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }], chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'], note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.' }, { id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.', category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [] }, { id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.', category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] }, { id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.', category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: [] }, { id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.', category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] }, // Library Maintenance { id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.', category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: [] }, { id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.', category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: [] }, { id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.', category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: [] }, { id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.', category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }] }, { id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.', category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: [] }, // Notifications & Alerts { id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.', category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] }, { id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.', category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }] }, { id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.', category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] }, // Power User Chains { id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.', category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }], chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'], note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.' }, { id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.', category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [], chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'], note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.' }, ]; const AUTO_HUB_GUIDES = [ { id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner', steps: [ 'Go to the <strong>Playlists</strong> page and find a Spotify playlist you want to track.', 'Click <strong>Mirror Playlist</strong> to create a local copy.', 'Go to <strong>Automations</strong> and click <strong>New Automation</strong>.', 'Set WHEN to <strong>Schedule \u2192 Every 6 hours</strong>.', 'Set DO to <strong>Refresh Mirrored Playlists</strong>.', 'Save and enable \u2014 your playlist will now stay in sync automatically.' ], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab'] }, { id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner', steps: [ 'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.', 'Create a webhook and copy the URL.', 'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.', 'Go to <strong>Automations \u2192 New Automation</strong>.', 'Set WHEN to <strong>Download Failed</strong> (or any event), DO to <strong>Notify Only</strong>, THEN to <strong>Discord</strong>.' ], relatedRecipes: ['download-failure-alert', 'batch-complete-notify'] }, { id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate', steps: [ 'Create Automation 1: <strong>Schedule (12h) \u2192 Scan Watchlist</strong>, THEN fire signal <code>scan_done</code>.', 'Create Automation 2: <strong>Signal scan_done \u2192 Process Wishlist</strong>, THEN fire signal <code>process_done</code>.', 'Create Automation 3: <strong>Signal process_done \u2192 Full Cleanup</strong>.', 'Enable all three automations.', 'Test by manually running Automation 1 \u2014 watch the chain execute.', 'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.', 'Adjust the schedule interval based on how often you want new music checked.' ], relatedRecipes: ['complete-new-release', 'full-hands-free'] }, { id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced', steps: [ 'Understand the concept: <strong>fire_signal</strong> is a THEN action that emits a named signal. <strong>signal_received</strong> is a WHEN trigger that listens for it.', 'In your first automation, add a THEN action \u2192 <strong>Fire Signal</strong> and name it (e.g., <code>step1_done</code>).', 'Create a second automation with WHEN \u2192 <strong>Signal Received</strong> \u2192 signal name <code>step1_done</code>.', 'The second automation will fire automatically when the first one completes.', 'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.', 'Use descriptive signal names like <code>watchlist_scanned</code> or <code>cleanup_finished</code>.' ], relatedRecipes: ['quality-assurance', 'complete-new-release'] }, { id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate', steps: [ 'Create a <strong>Daily Time (04:00) \u2192 Backup Database</strong> automation.', 'Create a <strong>Weekly Time (Saturday, 05:00) \u2192 Full Cleanup</strong> automation.', 'Create a <strong>Daily Time (02:00) \u2192 Update Discovery Pool</strong> automation.', 'Stagger times by at least 1 hour to avoid resource contention.', 'Add Discord/Telegram notifications to any you want alerts for.' ], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly'] }, ]; const AUTO_HUB_TIPS = [ { icon: '\u26A1', title: 'Signal Chaining 101', body: '<strong>fire_signal</strong> (a THEN action) emits a named event. <strong>signal_received</strong> (a WHEN trigger) listens for it. This lets you chain automations: when one finishes, the next starts automatically.', tag: 'Signals' }, { icon: '\u23F0', title: 'Stagger Your Schedules', body: 'If you have multiple timed automations, space them at least 1 hour apart. Running scan, process, and cleanup at the same time creates resource contention and can slow everything down.', tag: 'Performance' }, { icon: '\uD83C\uDFAF', title: 'Use Conditions to Filter', body: 'Add conditions to event triggers to only fire on specific artists, formats, or quality levels. For example, trigger only when a downloaded track\'s artist matches "Radiohead".', tag: 'Filtering' }, { icon: '\uD83D\uDCC1', title: 'Group Related Automations', body: 'Use the Group dropdown when creating automations to organize them. Groups like "Nightly", "Notifications", or "Pipeline" make it easy to find and manage related automations.', tag: 'Organization' }, { icon: '\uD83D\uDD04', title: 'Avoid Chain Loops', body: 'SoulSync has built-in cycle detection, but it\'s good practice to design signal names carefully. If A fires signal X and B listens for X and fires Y, make sure nothing fires X again downstream.', tag: 'Safety' }, { icon: '\uD83D\uDCDA', title: 'Stack THEN Actions', body: 'Each automation supports up to 3 THEN actions. Combine notification channels (Discord + Telegram) with a fire_signal to both notify yourself and trigger the next automation.', tag: 'Power' }, { icon: '\u2699\uFE0F', title: 'System vs Custom', body: 'System automations handle core tasks like Spotify enrichment and are managed automatically. Create custom automations to extend their behavior \u2014 trigger on their completion events.', tag: 'Basics' }, { icon: '\uD83E\uDDEA', title: 'Test with Notify Only', body: 'Set DO to <strong>Notify Only</strong> when testing a new trigger. You\'ll see when it fires without any side effects. Once you\'re confident in the timing, switch to the real action.', tag: 'Testing' }, ]; const AUTO_HUB_REFERENCE = { triggers: [ { group: 'Time-Based', items: [ { type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' }, { type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' }, { type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' }, ] }, { group: 'Download Events', items: [ { type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' }, { type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' }, { type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' }, { type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' }, ] }, { group: 'Watchlist & Wishlist', items: [ { type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' }, { type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' }, { type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' }, { type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' }, { type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' }, { type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' }, ] }, { group: 'Playlists', items: [ { type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' }, { type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' }, { type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' }, { type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' }, ] }, { group: 'Library & System', items: [ { type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' }, { type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' }, { type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' }, { type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' }, { type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' }, { type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' }, ] }, { group: 'Signals', items: [ { type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' }, ] }, ], actions: [ { group: 'Downloads & Sync', items: [ { type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' }, { type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' }, { type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' }, { type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' }, { type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' }, { type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' }, { type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' }, ] }, { group: 'Library Tools', items: [ { type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' }, { type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' }, { type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' }, { type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' }, ] }, { group: 'Cleanup', items: [ { type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' }, { type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' }, { type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' }, { type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' }, { type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' }, { type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' }, ] }, { group: 'Other', items: [ { type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' }, ] }, ], thenActions: [ { group: 'Notifications', items: [ { type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' }, { type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' }, { type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' }, ] }, { group: 'Chaining', items: [ { type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' }, ] }, ], }; // --- Load & Render List --- // Drag-and-drop state let _autoDragState = null; let _autoDragEnterCount = 0; let _autoDragExpandTimer = null; function _buildAutomationSection(id, label, automations, useGrid, options = {}) { const groupName = options.groupName || null; const isProtected = options.isProtected || false; // System, Hub sections const section = document.createElement('div'); section.className = 'automations-section'; if (isProtected) section.classList.add('section-protected'); section.id = id; if (groupName) section.dataset.groupName = groupName; const collapsed = localStorage.getItem('auto_section_' + id) === '1'; if (collapsed) section.classList.add('collapsed'); const header = document.createElement('div'); header.className = 'automations-section-header'; // Group header actions (rename, bulk toggle, delete) — only for user groups let actionsHtml = ''; if (groupName && !isProtected) { const enabledCount = automations.filter(a => a.enabled).length; const allEnabled = enabledCount === automations.length; actionsHtml = ` <div class="section-actions" onclick="event.stopPropagation();"> <button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escJs(groupName)}', ${allEnabled})"> ${allEnabled ? '⏸' : '▶'} </button> <button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escJs(groupName)}', this)"> ✏️ </button> <button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escJs(groupName)}')"> 🗑️ </button> </div> `; } header.innerHTML = ` <span class="section-chevron">▼</span> <span class="section-label">${label}</span> <span class="section-count">${automations.length}</span> ${actionsHtml} <span class="section-line"></span> `; header.onclick = (e) => { if (e.target.closest('.section-actions')) return; section.classList.toggle('collapsed'); localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0'); }; const body = document.createElement('div'); body.className = 'automations-section-body'; // Drop zone setup (not for protected sections) if (!isProtected) { const dropGroupName = groupName; // null for "My Automations" body.addEventListener('dragover', (e) => { if (!_autoDragState) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; body.classList.add('drop-target'); }); body.addEventListener('dragenter', (e) => { if (!_autoDragState) return; _autoDragEnterCount++; body.classList.add('drop-target'); // Expand collapsed sections on drag-hover if (section.classList.contains('collapsed')) { _autoDragExpandTimer = setTimeout(() => { section.classList.remove('collapsed'); }, 500); } }); body.addEventListener('dragleave', (e) => { if (!_autoDragState) return; _autoDragEnterCount--; if (_autoDragEnterCount <= 0) { _autoDragEnterCount = 0; body.classList.remove('drop-target'); if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } } }); body.addEventListener('drop', async (e) => { e.preventDefault(); body.classList.remove('drop-target'); _autoDragEnterCount = 0; if (!_autoDragState) return; const draggedId = _autoDragState.id; const fromGroup = _autoDragState.groupName; if (fromGroup === dropGroupName) return; // Same group, no-op try { const res = await fetch('/api/automations/' + draggedId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_name: dropGroupName }) }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast(dropGroupName ? `Moved to "${dropGroupName}"` : 'Moved to My Automations', 'success'); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } }); } const container = document.createElement('div'); container.className = useGrid ? 'automations-grid' : 'automations-user-list'; automations.forEach(a => container.appendChild(renderAutomationCard(a))); body.appendChild(container); section.appendChild(header); section.appendChild(body); return section; } /** * Delete a group — ungroups all automations (moves to My Automations). */ async function _deleteGroup(groupName) { // Collect automation IDs in this group const ids = []; document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { if (card.dataset.id) ids.push(parseInt(card.dataset.id)); }); if (ids.length === 0) { await loadAutomations(); return; } // Show choice dialog — ungroup or delete all const choice = await _showDeleteGroupDialog(groupName, ids.length); if (!choice) return; try { if (choice === 'ungroup') { const res = await fetch('/api/automations/group', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ automation_ids: ids, group_name: null }) }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast(`Dissolved group "${groupName}" — ${data.updated} automations moved to My Automations`, 'success'); } else if (choice === 'delete_all') { // Delete each automation let deleted = 0; for (const id of ids) { try { const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); const data = await res.json(); if (data.success) deleted++; } catch (e) {} } showToast(`Deleted group "${groupName}" and ${deleted} automation${deleted !== 1 ? 's' : ''}`, 'success'); } await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } function _showDeleteGroupDialog(groupName, count) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; overlay.innerHTML = ` <div class="delete-group-dialog"> <div class="delete-group-icon">🗑️</div> <h3 class="delete-group-title">Delete Group "${groupName}"</h3> <p class="delete-group-message">This group contains ${count} automation${count !== 1 ? 's' : ''}. What would you like to do?</p> <div class="delete-group-actions"> <button class="delete-group-btn delete-group-keep" id="dg-ungroup"> Keep Automations — move to My Automations </button> <button class="delete-group-btn delete-group-remove" id="dg-delete"> Delete Everything — remove group and all ${count} automation${count !== 1 ? 's' : ''} </button> <button class="delete-group-btn delete-group-cancel" id="dg-cancel"> Cancel </button> </div> </div> `; overlay.querySelector('#dg-ungroup').onclick = () => { overlay.remove(); resolve('ungroup'); }; overlay.querySelector('#dg-delete').onclick = () => { overlay.remove(); resolve('delete_all'); }; overlay.querySelector('#dg-cancel').onclick = () => { overlay.remove(); resolve(null); }; document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); } }); document.body.appendChild(overlay); }); } /** * Rename a group — inline edit on the section header label. */ function _startRenameGroup(groupName, btnEl) { const section = btnEl.closest('.automations-section'); const labelEl = section?.querySelector('.section-label'); if (!labelEl) return; const input = document.createElement('input'); input.className = 'section-rename-input'; input.value = groupName; input.onclick = (e) => e.stopPropagation(); const originalText = labelEl.textContent; labelEl.textContent = ''; labelEl.appendChild(input); input.focus(); input.select(); const finish = async (save) => { const newName = input.value.trim(); input.removeEventListener('blur', blurHandler); if (!save || !newName || newName === groupName) { labelEl.textContent = originalText; return; } const ids = []; section.querySelectorAll('.automation-card').forEach(card => { if (card.dataset.id) ids.push(parseInt(card.dataset.id)); }); try { const res = await fetch('/api/automations/group', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ automation_ids: ids, group_name: newName }) }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast(`Renamed to "${newName}"`, 'success'); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); labelEl.textContent = originalText; } }; input.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); finish(true); } if (e.key === 'Escape') { finish(false); } }); const blurHandler = () => finish(true); input.addEventListener('blur', blurHandler); } /** * Bulk toggle all automations in a group. */ async function _bulkToggleGroup(groupName, currentlyAllEnabled) { const ids = []; document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { if (card.dataset.id) ids.push(parseInt(card.dataset.id)); }); if (ids.length === 0) return; const targetEnabled = !currentlyAllEnabled; try { const res = await fetch('/api/automations/bulk-toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ automation_ids: ids, enabled: targetEnabled }) }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast(`${targetEnabled ? 'Enabled' : 'Disabled'} ${data.updated} automations`, 'success'); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } async function loadAutomations() { const list = document.getElementById('automations-list'); const empty = document.getElementById('automations-empty'); const statsBar = document.getElementById('automations-stats'); if (!list || !empty) return; try { const res = await fetch('/api/automations'); const payload = await res.json(); if (payload.error) throw new Error(payload.error); // Hide video-app automations on the MUSIC page: they live in the shared automation // engine (music_library.db, owned_by='video') but belong to the separate video // automations page. Pure no-op for anyone without the video side — they have no such rows. const automations = (Array.isArray(payload) ? payload : []).filter(a => a.owned_by !== 'video'); if (!automations.length) { list.innerHTML = ''; empty.style.display = ''; if (statsBar) statsBar.innerHTML = ''; return; } empty.style.display = 'none'; list.innerHTML = ''; const systemAutos = automations.filter(a => a.is_system); const userAutos = automations.filter(a => !a.is_system); if (systemAutos.length) { list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true, { isProtected: true })); } // Automation Hub section list.appendChild(_buildAutomationHub()); // User automations — split by group const groups = [...new Set(userAutos.filter(a => a.group_name).map(a => a.group_name))].sort(); const ungrouped = userAutos.filter(a => !a.group_name); groups.forEach(g => { const groupAutos = userAutos.filter(a => a.group_name === g); if (groupAutos.length) { list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true, { groupName: g })); } }); if (ungrouped.length) { list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', ungrouped, true)); } // Stats summary bar if (statsBar) { const total = automations.length; const active = automations.filter(a => a.enabled).length; const sys = systemAutos.length; const custom = userAutos.length; statsBar.innerHTML = ` <span class="auto-stat"><strong>${active}</strong> Active</span> <span class="auto-stat"><strong>${sys}</strong> System</span> <span class="auto-stat"><strong>${custom}</strong> Custom</span> `; } // Filter bar — show when 6+ automations _initAutoFilterBar(automations); // Catch up on current automation progress try { const progRes = await fetch('/api/automations/progress'); const progData = await progRes.json(); if (!progData.error) updateAutomationProgressFromData(progData); } catch (e) { } } catch (err) { list.innerHTML = ''; empty.style.display = ''; if (statsBar) statsBar.innerHTML = ''; } } // --- Automation Hub --- function _buildAutomationHub() { const section = document.createElement('div'); section.className = 'automations-section'; section.id = 'auto-section-hub'; const collapsed = localStorage.getItem('auto_section_auto-section-hub') === '1'; if (collapsed) section.classList.add('collapsed'); const header = document.createElement('div'); header.className = 'automations-section-header'; header.innerHTML = ` <span class="section-chevron">▼</span> <span class="section-label">Automation Hub</span> <span class="section-count">${AUTO_HUB_GROUPS.length} pipelines · ${AUTO_HUB_RECIPES.length} recipes</span> <span class="section-line"></span> `; header.onclick = () => { section.classList.toggle('collapsed'); localStorage.setItem('auto_section_auto-section-hub', section.classList.contains('collapsed') ? '1' : '0'); }; const body = document.createElement('div'); body.className = 'automations-section-body'; const activeTab = localStorage.getItem('auto_hub_tab') || 'pipelines'; const tabs = [ { id: 'pipelines', label: 'Pipelines' }, { id: 'recipes', label: 'Singles' }, { id: 'guides', label: 'Quick Start' }, { id: 'tips', label: 'Tips' }, { id: 'reference', label: 'Reference' }, ]; const tabBar = document.createElement('div'); tabBar.className = 'auto-hub-tabs'; tabs.forEach(t => { const btn = document.createElement('button'); btn.className = 'auto-hub-tab' + (t.id === activeTab ? ' active' : ''); btn.textContent = t.label; btn.dataset.tab = t.id; btn.onclick = (e) => { e.stopPropagation(); _switchHubTab(t.id, body); }; tabBar.appendChild(btn); }); body.appendChild(tabBar); // Build all tab contents const pipelinesPane = _buildHubPipelines(); pipelinesPane.id = 'auto-hub-pane-pipelines'; pipelinesPane.className = 'auto-hub-tab-content' + (activeTab === 'pipelines' ? ' active' : ''); body.appendChild(pipelinesPane); const recipesPane = _buildHubRecipes(); recipesPane.id = 'auto-hub-pane-recipes'; recipesPane.className = 'auto-hub-tab-content' + (activeTab === 'recipes' ? ' active' : ''); body.appendChild(recipesPane); const guidesPane = _buildHubGuides(); guidesPane.id = 'auto-hub-pane-guides'; guidesPane.className = 'auto-hub-tab-content' + (activeTab === 'guides' ? ' active' : ''); body.appendChild(guidesPane); const tipsPane = _buildHubTips(); tipsPane.id = 'auto-hub-pane-tips'; tipsPane.className = 'auto-hub-tab-content' + (activeTab === 'tips' ? ' active' : ''); body.appendChild(tipsPane); const refPane = _buildHubReference(); refPane.id = 'auto-hub-pane-reference'; refPane.className = 'auto-hub-tab-content' + (activeTab === 'reference' ? ' active' : ''); body.appendChild(refPane); section.appendChild(header); section.appendChild(body); return section; } function _switchHubTab(tabId, bodyEl) { const container = bodyEl || document.querySelector('#auto-section-hub .automations-section-body'); if (!container) return; container.querySelectorAll('.auto-hub-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabId)); container.querySelectorAll('.auto-hub-tab-content').forEach(p => p.classList.toggle('active', p.id === 'auto-hub-pane-' + tabId)); localStorage.setItem('auto_hub_tab', tabId); } function _buildHubPipelines() { const pane = document.createElement('div'); const intro = document.createElement('div'); intro.className = 'auto-hub-pipeline-intro'; intro.innerHTML = 'One-click deployment — each pipeline creates multiple linked automations that work together.'; pane.appendChild(intro); const grid = document.createElement('div'); grid.className = 'auto-hub-pipeline-grid'; AUTO_HUB_GROUPS.forEach(group => { const card = document.createElement('div'); card.className = 'auto-hub-pipeline-card'; card.style.setProperty('--pipeline-color', group.color); // Pipeline flow visualization const stepsHtml = group.steps.map((step, i) => { const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action'; return (i > 0 ? '<span class="pipeline-connector"></span>' : '') + `<div class="pipeline-node ${nodeClass}"> <span class="pipeline-node-icon">${step.icon}</span> <span class="pipeline-node-label">${step.label}</span> </div>`; }).join(''); card.innerHTML = ` <div class="pipeline-card-top"> <span class="pipeline-card-icon">${group.icon}</span> <div class="pipeline-card-title-row"> <div class="pipeline-card-name">${group.name}</div> <span class="pipeline-card-badge">${group.badge}</span> </div> </div> <div class="pipeline-card-desc">${group.desc}</div> <div class="pipeline-flow">${stepsHtml}</div> <div class="pipeline-card-footer"> <button class="pipeline-deploy-btn" onclick="event.stopPropagation(); deployHubGroup('${group.id}')">Deploy Pipeline</button> </div> `; card.addEventListener('click', (e) => { if (e.target.closest('.pipeline-deploy-btn')) return; showPipelineDetail(group.id); }); grid.appendChild(card); }); pane.appendChild(grid); return pane; } function showPipelineDetail(groupId) { const group = AUTO_HUB_GROUPS.find(g => g.id === groupId); if (!group) return; // Build automation detail list const autoDetails = group.automations.map((auto, i) => { const triggerLabel = _autoFormatTrigger(auto.trigger_type, auto.trigger_config); const actionLabel = _autoFormatAction(auto.action_type); const thenLabels = auto.then_actions.map(t => { if (t.type === 'fire_signal') return `⚡ Signal: ${t.config.signal_name}`; return _autoFormatNotify(t.type); }); if (auto.needs_notify) thenLabels.push('🔔 Your notification'); return ` <div class="pipeline-detail-auto" style="--step-color: ${group.color}"> <div class="pipeline-detail-step-num">${i + 1}</div> <div class="pipeline-detail-step-body"> <div class="pipeline-detail-step-name">${auto.name}</div> <div class="pipeline-detail-step-flow"> <span class="pipeline-detail-tag when">WHEN</span> <span class="pipeline-detail-tag-value">${_esc(triggerLabel)}</span> <span class="pipeline-detail-tag do">DO</span> <span class="pipeline-detail-tag-value">${_esc(actionLabel)}</span> ${thenLabels.length ? `<span class="pipeline-detail-tag then">THEN</span><span class="pipeline-detail-tag-value">${thenLabels.map(t => _esc(t)).join(', ')}</span>` : ''} </div> </div> </div>`; }).join(''); // Build flow diagram const flowHtml = group.steps.map((step, i) => { const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action'; return (i > 0 ? '<span class="pipeline-connector"></span>' : '') + `<div class="pipeline-node ${nodeClass}"> <span class="pipeline-node-icon">${step.icon}</span> <span class="pipeline-node-label">${step.label}</span> </div>`; }).join(''); const overlay = document.createElement('div'); overlay.className = 'pipeline-detail-overlay'; overlay.innerHTML = ` <div class="pipeline-detail-modal" style="--pipeline-color: ${group.color}"> <button class="pipeline-detail-close" onclick="this.closest('.pipeline-detail-overlay').remove()">×</button> <div class="pipeline-detail-header"> <span class="pipeline-detail-icon">${group.icon}</span> <div> <div class="pipeline-detail-title">${group.name}</div> <div class="pipeline-detail-desc">${group.desc}</div> </div> </div> <div class="pipeline-detail-flow" style="--pipeline-color: ${group.color}">${flowHtml}</div> <div class="pipeline-detail-section-title">How It Works</div> <div class="pipeline-detail-section-desc">This pipeline deploys ${group.automations.length} automations${group.automations.some(a => a.then_actions.some(t => t.type === 'fire_signal')) ? ' linked by signals — each step triggers the next automatically' : ' running on independent schedules'}.</div> <div class="pipeline-detail-autos">${autoDetails}</div> <button class="pipeline-deploy-btn" style="--pipeline-color: ${group.color}; margin-top: 8px;" onclick="this.closest('.pipeline-detail-overlay').remove(); deployHubGroup('${group.id}')">Deploy Pipeline</button> </div> `; overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } function _buildHubRecipes() { const pane = document.createElement('div'); const categories = ['All', 'Sync', 'Discovery', 'Maintenance', 'Alerts', 'Chains']; const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced']; let activeCat = 'All', activeDiff = 'All'; // Category filters const catFilters = document.createElement('div'); catFilters.className = 'auto-hub-filters'; categories.forEach(c => { const pill = document.createElement('button'); pill.className = 'auto-hub-filter-pill' + (c === 'All' ? ' active' : ''); pill.textContent = c; pill.dataset.filter = c; pill.dataset.filterType = 'category'; pill.onclick = () => { activeCat = c; catFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === c)); filterRecipes(); }; catFilters.appendChild(pill); }); pane.appendChild(catFilters); // Difficulty filters const diffFilters = document.createElement('div'); diffFilters.className = 'auto-hub-filters'; difficulties.forEach(d => { const pill = document.createElement('button'); pill.className = 'auto-hub-filter-pill' + (d === 'All' ? ' active' : ''); pill.textContent = d; pill.dataset.filter = d; pill.dataset.filterType = 'difficulty'; pill.onclick = () => { activeDiff = d; diffFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === d)); filterRecipes(); }; diffFilters.appendChild(pill); }); pane.appendChild(diffFilters); const grid = document.createElement('div'); grid.className = 'auto-hub-recipes-grid'; AUTO_HUB_RECIPES.forEach(r => { const card = document.createElement('div'); card.className = 'auto-hub-recipe-card'; card.dataset.category = r.category; card.dataset.difficulty = r.difficulty; const trigLabel = _autoFormatTrigger(r.when.type, r.when.config); const actLabel = _autoFormatAction(r.do.type); let chainHTML = ''; if (r.chain) { chainHTML = '<div class="auto-hub-recipe-chain">' + r.chain.map((step, i) => { let cls = 'flow-action'; if (i === 0) cls = 'flow-trigger'; else if (step.startsWith('\u26A1')) cls = 'flow-notify'; return (i > 0 ? '<span class="flow-arrow">→</span>' : '') + `<span class="${cls}">${_esc(step)}</span>`; }).join('') + '</div>'; } else { chainHTML = `<div class="auto-hub-recipe-chain"> <span class="flow-trigger">${_esc(trigLabel)}</span> <span class="flow-arrow">→</span> <span class="flow-action">${_esc(actLabel)}</span> ${r.then.length ? r.then.map(th => `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(_autoFormatNotify(th.type))}</span>`).join('') : ''} </div>`; } card.innerHTML = ` <div class="auto-hub-recipe-header"> <div class="auto-hub-recipe-icon">${r.icon}</div> <div class="auto-hub-recipe-name">${_esc(r.name)}</div> <span class="auto-hub-badge ${r.difficulty}">${_esc(r.difficulty)}</span> </div> <div class="auto-hub-recipe-desc">${_esc(r.desc)}</div> ${chainHTML} ${r.note ? `<div class="auto-hub-recipe-note">${_esc(r.note)}</div>` : ''} <button class="auto-hub-recipe-use" onclick="event.stopPropagation(); useHubRecipe('${r.id}')">Use This</button> `; card.onclick = () => useHubRecipe(r.id); grid.appendChild(card); }); pane.appendChild(grid); function filterRecipes() { grid.querySelectorAll('.auto-hub-recipe-card').forEach(card => { const catMatch = activeCat === 'All' || card.dataset.category === activeCat; const diffMatch = activeDiff === 'All' || card.dataset.difficulty === activeDiff.toLowerCase(); card.style.display = (catMatch && diffMatch) ? '' : 'none'; }); } return pane; } function _buildHubGuides() { const pane = document.createElement('div'); const callout = document.createElement('div'); callout.className = 'auto-hub-callout'; callout.innerHTML = '<span class="auto-hub-callout-icon">\uD83D\uDCA1</span><span>Click any guide to expand step-by-step instructions. Related recipes let you jump straight to a pre-filled template.</span>'; pane.appendChild(callout); AUTO_HUB_GUIDES.forEach(g => { const card = document.createElement('div'); card.className = 'auto-hub-guide-card'; const headerEl = document.createElement('div'); headerEl.className = 'auto-hub-guide-header'; headerEl.innerHTML = ` <span class="auto-hub-guide-icon">${g.icon}</span> <span class="auto-hub-guide-title">${_esc(g.title)}</span> <span class="auto-hub-badge ${g.difficulty}">${_esc(g.difficulty)}</span> <span class="auto-hub-guide-chevron">▼</span> `; headerEl.onclick = () => card.classList.toggle('expanded'); card.appendChild(headerEl); const bodyEl = document.createElement('div'); bodyEl.className = 'auto-hub-guide-body'; bodyEl.innerHTML = ` <div class="auto-hub-guide-subtitle">${_esc(g.subtitle)}</div> <ol class="auto-hub-steps">${g.steps.map(s => `<li>${s}</li>`).join('')}</ol> ${g.relatedRecipes.length ? ` <div class="auto-hub-guide-related"> <span class="auto-hub-guide-related-label">Related:</span> ${g.relatedRecipes.map(rId => { const recipe = AUTO_HUB_RECIPES.find(r => r.id === rId); return recipe ? `<button class="auto-hub-guide-related-link" onclick="event.stopPropagation(); useHubRecipe('${rId}')">${recipe.icon} ${_esc(recipe.name)}</button>` : ''; }).join('')} </div> ` : ''} `; card.appendChild(bodyEl); pane.appendChild(card); }); return pane; } function _buildHubTips() { const pane = document.createElement('div'); const callout = document.createElement('div'); callout.className = 'auto-hub-callout'; callout.innerHTML = '<span class="auto-hub-callout-icon">\u2728</span><span>Power-user tips to get the most out of your automations.</span>'; pane.appendChild(callout); const grid = document.createElement('div'); grid.className = 'auto-hub-tips-grid'; AUTO_HUB_TIPS.forEach(t => { const card = document.createElement('div'); card.className = 'auto-hub-tip-card'; card.innerHTML = ` <div class="auto-hub-tip-header"> <span class="auto-hub-tip-icon">${t.icon}</span> <span class="auto-hub-tip-title">${_esc(t.title)}</span> <span class="auto-hub-tip-tag">${_esc(t.tag)}</span> </div> <div class="auto-hub-tip-body">${t.body}</div> `; grid.appendChild(card); }); pane.appendChild(grid); return pane; } function _buildHubReference() { const pane = document.createElement('div'); const sections = [ { label: 'Triggers (WHEN)', data: AUTO_HUB_REFERENCE.triggers }, { label: 'Actions (DO)', data: AUTO_HUB_REFERENCE.actions }, { label: 'Then Actions (THEN)', data: AUTO_HUB_REFERENCE.thenActions }, ]; sections.forEach(sec => { const totalItems = sec.data.reduce((n, g) => n + g.items.length, 0); const group = document.createElement('div'); group.className = 'auto-hub-ref-group'; const header = document.createElement('div'); header.className = 'auto-hub-ref-group-header'; header.innerHTML = ` <span class="auto-hub-ref-group-label">${_esc(sec.label)}</span> <span class="auto-hub-ref-group-count">${totalItems}</span> <span class="auto-hub-ref-chevron">▼</span> `; header.onclick = () => group.classList.toggle('expanded'); group.appendChild(header); const body = document.createElement('div'); body.className = 'auto-hub-ref-body'; sec.data.forEach(sub => { body.innerHTML += `<div class="auto-hub-ref-subheader">${_esc(sub.group)}</div>`; let tableHTML = '<table class="auto-hub-table"><thead><tr><th>Type</th><th>Description</th></tr></thead><tbody>'; sub.items.forEach(item => { tableHTML += `<tr><td>${_esc(item.label)}</td><td>${_esc(item.desc)}</td></tr>`; }); tableHTML += '</tbody></table>'; body.innerHTML += tableHTML; }); group.appendChild(body); pane.appendChild(group); }); return pane; } async function useHubRecipe(recipeId) { const t = AUTO_HUB_RECIPES.find(r => r.id === recipeId); if (!t) return; await showAutomationBuilder(); document.getElementById('builder-name').value = t.name; _autoBuilder.when = { type: t.when.type, config: JSON.parse(JSON.stringify(t.when.config)) }; _autoBuilder.do = { type: t.do.type, config: JSON.parse(JSON.stringify(t.do.config)) }; _autoBuilder.then = t.then.map(th => ({ type: th.type, config: JSON.parse(JSON.stringify(th.config)) })); _renderBuilderSidebar(); _renderBuilderCanvas(); if (t.note) { showToast(t.note, 'info'); } } async function deployHubGroup(groupId) { const group = AUTO_HUB_GROUPS.find(g => g.id === groupId); if (!group) return; // Check if any automations need notifications — prompt for config const needsNotify = group.automations.some(a => a.needs_notify); let notifyConfig = null; if (needsNotify) { notifyConfig = await _promptNotifyConfig(group.name); if (notifyConfig === null) return; // User cancelled if (notifyConfig === false) notifyConfig = null; // Skip notifications, still deploy } // Deploy all automations in the group let created = 0, failed = 0; for (const auto of group.automations) { try { const payload = { name: auto.name, trigger_type: auto.trigger_type, trigger_config: auto.trigger_config, action_type: auto.action_type, action_config: auto.action_config, then_actions: [...auto.then_actions], group_name: auto.group_name, enabled: true, }; // Inject notification config for automations that need it if (auto.needs_notify && notifyConfig) { payload.then_actions.push(notifyConfig); } const response = await fetch('/api/automations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (response.ok) { created++; } else { const err = await response.json(); console.error(`Failed to create "${auto.name}":`, err); failed++; } } catch (e) { console.error(`Error creating "${auto.name}":`, e); failed++; } } if (created > 0) { showToast(`Deployed "${group.name}" — ${created} automation${created > 1 ? 's' : ''} created${failed ? `, ${failed} failed` : ''}`, 'success'); loadAutomations(); } else { showToast(`Failed to deploy "${group.name}"`, 'error'); } } function _promptNotifyConfig(groupName) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; overlay.innerHTML = ` <div style="background:var(--bg-secondary, #1e1e2e);border:1px solid rgba(255,255,255,0.1);border-radius:14px;padding:28px;max-width:420px;width:90%;color:var(--text-primary, #fff);font-family:inherit;"> <h3 style="margin:0 0 6px;font-size:1.1em;">Configure Notifications</h3> <p style="margin:0 0 18px;font-size:0.85em;opacity:0.5;">${groupName} includes notification steps. Choose how to get notified.</p> <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:18px;"> <label style="font-size:0.85em;opacity:0.7;">Notification Type</label> <select id="deploy-notify-type" style="padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.9em;"> <option value="discord_webhook">Discord Webhook</option> <option value="telegram">Telegram</option> <option value="pushbullet">Pushbullet</option> <option value="none">Skip Notifications</option> </select> <div id="deploy-notify-fields"></div> </div> <div style="display:flex;gap:10px;justify-content:flex-end;"> <button id="deploy-notify-cancel" style="padding:8px 20px;border:1px solid rgba(255,255,255,0.1);border-radius:8px;background:transparent;color:rgba(255,255,255,0.7);cursor:pointer;font-size:0.88em;">Cancel</button> <button id="deploy-notify-confirm" style="padding:8px 20px;border:none;border-radius:8px;background:var(--accent-color,#1db954);color:#fff;cursor:pointer;font-size:0.88em;font-weight:600;">Deploy</button> </div> </div> `; document.body.appendChild(overlay); const typeSelect = overlay.querySelector('#deploy-notify-type'); const fieldsDiv = overlay.querySelector('#deploy-notify-fields'); function updateFields() { const type = typeSelect.value; if (type === 'discord_webhook') { fieldsDiv.innerHTML = '<input id="deploy-notify-url" type="text" placeholder="Discord Webhook URL" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">'; } else if (type === 'telegram') { fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Bot Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;"><input id="deploy-notify-chat" type="text" placeholder="Chat ID" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;"><input id="deploy-notify-thread" type="text" placeholder="Thread ID" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">'; } else if (type === 'pushbullet') { fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Access Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">'; } else { fieldsDiv.innerHTML = ''; } } typeSelect.addEventListener('change', updateFields); updateFields(); overlay.querySelector('#deploy-notify-cancel').addEventListener('click', () => { document.body.removeChild(overlay); resolve(null); }); overlay.querySelector('#deploy-notify-confirm').addEventListener('click', () => { const type = typeSelect.value; let config = {}; if (type === 'discord_webhook') { config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; } else if (type === 'telegram') { config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() }; } else if (type === 'pushbullet') { config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; } else { document.body.removeChild(overlay); resolve(false); // Skip notifications but still deploy return; } document.body.removeChild(overlay); resolve({ type, config }); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); } }); }); } // --- Filter Bar --- function _initAutoFilterBar(automations) { const bar = document.getElementById('auto-filter-bar'); if (!bar) return; if (automations.length < 7) { bar.style.display = 'none'; return; } bar.style.display = ''; // Populate trigger dropdown const trigSel = document.getElementById('auto-filter-trigger'); const actSel = document.getElementById('auto-filter-action'); const trigTypes = [...new Set(automations.map(a => a.trigger_type))].sort(); const actTypes = [...new Set(automations.map(a => a.action_type))].sort(); const prevTrig = trigSel.value; const prevAct = actSel.value; trigSel.innerHTML = '<option value="">All Triggers</option>' + trigTypes.map(t => `<option value="${_escAttr(t)}">${_esc(_autoFormatTrigger(t, {}))}</option>`).join(''); actSel.innerHTML = '<option value="">All Actions</option>' + actTypes.map(t => `<option value="${_escAttr(t)}">${_esc(_autoFormatAction(t))}</option>`).join(''); trigSel.value = prevTrig; actSel.value = prevAct; // Bind events (use a flag to avoid double-binding) if (!bar.dataset.bound) { bar.dataset.bound = '1'; document.getElementById('auto-filter-search').addEventListener('input', _filterAutomations); trigSel.addEventListener('change', _filterAutomations); actSel.addEventListener('change', _filterAutomations); } _filterAutomations(); } function _filterAutomations() { const q = (document.getElementById('auto-filter-search').value || '').toLowerCase().trim(); const trigFilter = document.getElementById('auto-filter-trigger').value; const actFilter = document.getElementById('auto-filter-action').value; const cards = document.querySelectorAll('#automations-list .automation-card'); let visible = 0; cards.forEach(card => { const name = (card.querySelector('.automation-name')?.textContent || '').toLowerCase(); const trig = card.querySelector('.flow-trigger')?.textContent || ''; const act = card.querySelector('.flow-action')?.textContent || ''; // Match search text against name, trigger label, action label const matchQ = !q || name.includes(q) || trig.toLowerCase().includes(q) || act.toLowerCase().includes(q); // Match trigger/action type filters using data attributes const matchTrig = !trigFilter || card.dataset.triggerType === trigFilter; const matchAct = !actFilter || card.dataset.actionType === actFilter; const show = matchQ && matchTrig && matchAct; card.style.display = show ? '' : 'none'; if (show) visible++; }); const countEl = document.getElementById('auto-filter-count'); if (countEl) { countEl.textContent = (q || trigFilter || actFilter) ? `${visible} of ${cards.length}` : ''; } } // --- Group Dropdown --- let _activeGroupDropdown = null; function _showGroupDropdown(event, autoId, currentGroup) { // Close any existing dropdown _closeGroupDropdown(); const btn = event.currentTarget; const card = btn.closest('.automation-card'); if (!card) return; // Collect all existing group names from visible cards const allGroups = new Set(); document.querySelectorAll('#automations-list .automation-card .automation-group-btn[data-group]').forEach(b => { const g = b.dataset.group; if (g) allGroups.add(g); }); const dropdown = document.createElement('div'); dropdown.className = 'auto-group-dropdown'; let html = ''; if (currentGroup) { html += `<div class="auto-group-option ungroup" onclick="_assignGroup(${autoId}, null)">Remove from group</div>`; html += '<div class="auto-group-divider"></div>'; } allGroups.forEach(g => { const isActive = g === currentGroup; html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escJs(g)}')">${_esc(g)}</div>`; }); if (allGroups.size) html += '<div class="auto-group-divider"></div>'; html += `<input class="auto-group-input" placeholder="New group name..." onkeydown="if(event.key==='Enter'){_assignGroup(${autoId}, this.value.trim()); event.preventDefault();}">`; dropdown.innerHTML = html; // Position dropdown on document.body to avoid overflow:hidden clipping const rect = btn.getBoundingClientRect(); dropdown.style.position = 'fixed'; dropdown.style.right = (window.innerWidth - rect.right) + 'px'; dropdown.style.left = 'auto'; document.body.appendChild(dropdown); _activeGroupDropdown = dropdown; // Open upward if not enough room below const dropdownHeight = dropdown.offsetHeight; if (rect.bottom + 4 + dropdownHeight > window.innerHeight && rect.top - 4 - dropdownHeight > 0) { dropdown.style.top = (rect.top - 4 - dropdownHeight) + 'px'; } else { dropdown.style.top = (rect.bottom + 4) + 'px'; } // Focus the input setTimeout(() => dropdown.querySelector('.auto-group-input')?.focus(), 50); // Close on outside click const handler = (e) => { if (!dropdown.contains(e.target) && e.target !== btn) { _closeGroupDropdown(); document.removeEventListener('click', handler, true); } }; setTimeout(() => document.addEventListener('click', handler, true), 10); } function _closeGroupDropdown() { if (_activeGroupDropdown) { _activeGroupDropdown.remove(); _activeGroupDropdown = null; } } async function _assignGroup(autoId, groupName) { _closeGroupDropdown(); try { const res = await fetch('/api/automations/' + autoId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_name: groupName || null }) }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast(groupName ? `Moved to "${groupName}"` : 'Removed from group', 'success'); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } function renderAutomationCard(a) { const card = document.createElement('div'); card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : ''); card.dataset.id = a.id; card.dataset.triggerType = a.trigger_type || ''; card.dataset.actionType = a.action_type || ''; // Drag-and-drop (non-system only) if (!a.is_system) { card.draggable = true; card.addEventListener('dragstart', (e) => { _autoDragState = { id: a.id, groupName: a.group_name || null }; e.dataTransfer.setData('text/plain', String(a.id)); e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging'); // Dim protected sections during drag document.querySelectorAll('.section-protected').forEach(s => s.classList.add('no-drop')); }); card.addEventListener('dragend', () => { card.classList.remove('dragging'); _autoDragState = null; _autoDragEnterCount = 0; document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); document.querySelectorAll('.no-drop').forEach(el => el.classList.remove('no-drop')); if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } }); } const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F'; const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F'; const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config); const al = aIcon + ' ' + _autoFormatAction(a.action_type); const thenItems = a.then_actions || []; const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0; const metaParts = []; if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run)); const _timerTriggers = ['schedule', 'daily_time', 'weekly_time']; if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('<span class="auto-next-run" data-next="' + _escAttr(a.next_run) + '">Next: ' + _autoTimeUntil(a.next_run) + '</span>'); if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening'); if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escJs(a.name) + '\', \'' + _escJs(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>'); if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error)); const dupeBtn = a.is_system ? '' : `<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">📋</button>`; const groupBtn = a.is_system ? '' : `<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escJs(a.group_name) + "'" : 'null'})">📁</button>`; const deleteBtn = a.is_system ? '' : `<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escJs(a.name)}')">🗑</button>`; card.innerHTML = ` <div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div> <div class="automation-info"> <div class="automation-name">${_esc(a.name)}</div> <div class="automation-flow"> <span class="flow-trigger">${_esc(tl)}</span> <span class="flow-arrow">→</span> ${actionDelay ? `<span class="flow-delay">\u23F3 ${actionDelay}m</span><span class="flow-arrow">→</span>` : ''} <span class="flow-action">${_esc(al)}</span> ${thenItems.length ? thenItems.map(t => `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(_autoFormatNotify(t.type))}</span>`).join('') : ''} </div> <div class="automation-meta">${metaParts.join(' · ')}</div> </div> <div class="automation-actions"> <button class="automation-run-btn" title="Run now" onclick="event.stopPropagation(); runAutomation(${a.id})">▶</button> <label class="automation-toggle" onclick="event.stopPropagation();"> <input type="checkbox" ${a.enabled ? 'checked' : ''} onchange="toggleAutomation(${a.id})"> <span class="toggle-slider"></span> </label> <button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">⚙</button> ${dupeBtn} ${groupBtn} ${deleteBtn} </div> `; return card; } function _autoFormatTrigger(type, config) { if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours'); if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00'); if (type === 'weekly_time' && config) { const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', '); return (days || 'Every day') + ' at ' + (config.time || '00:00'); } if (type === 'signal_received' && config) { const sig = config.signal_name || 'unknown'; return 'Signal: ' + sig; } const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete', watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced', playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete', wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done', database_update_completed: 'Database Updated', download_failed: 'Download Failed', download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added', watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched', import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored', quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done', library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' }; let label = labels[type] || type || 'Unknown'; if (config && config.conditions && config.conditions.length) { const first = config.conditions[0]; label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' + (config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')'; } return label; } function _autoFormatAction(type) { const labels = { process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist', scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored', sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist', notify_only: 'Notify Only', start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner', clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist', update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan', backup_database: 'Backup Database', refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History', clean_completed_downloads: 'Clean Completed Downloads', full_cleanup: 'Full Cleanup', playlist_pipeline: 'Playlist Pipeline' }; return labels[type] || type || 'Unknown'; } function _autoFormatNotify(type) { if (type === 'discord_webhook') return 'Discord'; if (type === 'pushbullet') return 'Pushbullet'; if (type === 'telegram') return 'Telegram'; if (type === 'fire_signal') return '\u26A1 Signal'; if (type === 'run_script') return '\uD83D\uDCBB Script'; return type || ''; } function _autoParseUTC(ts) { // If timestamp already has timezone info (+00:00 or Z), parse as-is; otherwise append Z to treat as UTC if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime(); return new Date(ts + 'Z').getTime(); } function _autoTimeAgo(ts) { if (!ts) return 'Never'; const d = (Date.now() - _autoParseUTC(ts)) / 1000; if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d / 60) + 'm ago'; if (d < 86400) return Math.floor(d / 3600) + 'h ago'; return Math.floor(d / 86400) + 'd ago'; } function _autoTimeUntil(ts) { if (!ts) return ''; const d = (_autoParseUTC(ts) - Date.now()) / 1000; if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's'; if (d < 3600) return 'in ' + Math.ceil(d / 60) + 'm'; if (d < 86400) return 'in ' + Math.round(d / 3600) + 'h'; return 'in ' + Math.round(d / 86400) + 'd'; } // --- Live countdown for "Next: in Xs" --- setInterval(() => { document.querySelectorAll('.auto-next-run[data-next]').forEach(el => { el.textContent = 'Next: ' + _autoTimeUntil(el.dataset.next); }); }, 1000); // --- CRUD --- async function deleteAutomation(id, name) { if (!await showConfirmDialog({ title: 'Delete Automation', message: `Delete automation "${name}"?`, confirmText: 'Delete', destructive: true })) return; try { const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast('Automation deleted', 'success'); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } async function duplicateAutomation(id) { try { const res = await fetch('/api/automations/' + id + '/duplicate', { method: 'POST' }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast('Automation duplicated', 'success'); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } async function toggleAutomation(id) { try { const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' }); const data = await res.json(); if (data.error) throw new Error(data.error); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } // --- Automation Progress Tracking --- const _autoProgressLogCounts = {}; const _autoProgressHideTimers = {}; function updateAutomationProgressFromData(data) { for (const [aidStr, state] of Object.entries(data)) { const aid = parseInt(aidStr); const card = document.querySelector(`.automation-card[data-id="${aid}"]`); if (!card) continue; let panel = card.querySelector('.automation-output'); if (!panel) { panel = document.createElement('div'); panel.className = 'automation-output'; panel.innerHTML = ` <div class="auto-progress-bar-wrap"><div class="auto-progress-bar" style="width:0%"></div></div> <div class="auto-progress-phase"></div> <div class="auto-progress-log"></div> `; card.appendChild(panel); _autoProgressLogCounts[aid] = 0; } // Update progress bar const bar = panel.querySelector('.auto-progress-bar'); bar.style.width = (state.progress || 0) + '%'; // Update phase text const phaseEl = panel.querySelector('.auto-progress-phase'); phaseEl.textContent = state.phase || ''; // Status indicator on card const statusDot = card.querySelector('.automation-status'); if (state.status === 'running') { if (statusDot) statusDot.className = 'automation-status running'; card.classList.add('running'); panel.classList.add('visible'); panel.classList.remove('finished', 'error'); if (_autoProgressHideTimers[aid]) { clearTimeout(_autoProgressHideTimers[aid]); delete _autoProgressHideTimers[aid]; } // Reset log for new run (handles re-run within hide window) if (_autoProgressLogCounts[aid] > 0 && state.log && state.log.length < _autoProgressLogCounts[aid]) { const existingLog = panel.querySelector('.auto-progress-log'); if (existingLog) existingLog.innerHTML = ''; _autoProgressLogCounts[aid] = 0; } } else if (state.status === 'finished' || state.status === 'error') { if (statusDot) statusDot.className = 'automation-status ' + (card.querySelector('input[type=checkbox]')?.checked ? 'enabled' : 'disabled'); card.classList.remove('running'); bar.style.width = '100%'; panel.classList.add('finished'); if (state.status === 'error') panel.classList.add('error'); if (!_autoProgressHideTimers[aid]) { _autoProgressHideTimers[aid] = setTimeout(() => { panel.classList.remove('visible'); delete _autoProgressHideTimers[aid]; _autoProgressLogCounts[aid] = 0; }, 30000); } } // Update log lines const logEl = panel.querySelector('.auto-progress-log'); const rendered = _autoProgressLogCounts[aid] || 0; const logLines = state.log || []; if (logLines.length > rendered) { // Normal append — log is still growing for (let i = rendered; i < logLines.length; i++) { const line = logLines[i]; const div = document.createElement('div'); div.className = 'auto-log-line ' + (line.type || 'info'); div.textContent = line.text; logEl.appendChild(div); } _autoProgressLogCounts[aid] = logLines.length; logEl.scrollTop = logEl.scrollHeight; } else if (logLines.length === rendered && logLines.length >= 50) { // Log buffer is full and rotating — replace last few lines const children = logEl.children; if (children.length > 0) { const lastServerLine = logLines[logLines.length - 1]; const lastDomLine = children[children.length - 1]; if (lastServerLine && lastDomLine.textContent !== lastServerLine.text) { // Content changed — full re-render logEl.innerHTML = ''; for (const line of logLines) { const div = document.createElement('div'); div.className = 'auto-log-line ' + (line.type || 'info'); div.textContent = line.text; logEl.appendChild(div); } _autoProgressLogCounts[aid] = logLines.length; logEl.scrollTop = logEl.scrollHeight; } } } } } async function runAutomation(id) { try { const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' }); const data = await res.json(); if (data.error) throw new Error(data.error); showToast('Automation triggered', 'success'); setTimeout(() => loadAutomations(), 1500); } catch (err) { showToast('Error: ' + err.message, 'error'); } } const _RESULT_DISPLAY_MAP = { 'start_database_update': [ { key: 'artists', label: 'Artists' }, { key: 'albums', label: 'Albums' }, { key: 'tracks', label: 'Tracks' }, { key: 'removed_artists', label: 'Removed Artists', hideZero: true }, { key: 'removed_albums', label: 'Removed Albums', hideZero: true }, { key: 'removed_tracks', label: 'Removed Tracks', hideZero: true }, ], 'deep_scan_library': [ { key: 'artists', label: 'Artists' }, { key: 'albums', label: 'Albums' }, { key: 'tracks', label: 'Tracks' }, { key: 'removed_artists', label: 'Removed Artists', hideZero: true }, { key: 'removed_albums', label: 'Removed Albums', hideZero: true }, { key: 'removed_tracks', label: 'Removed Tracks', hideZero: true }, ], 'scan_watchlist': [ { key: 'artists_scanned', label: 'Artists Scanned' }, { key: 'successful_scans', label: 'Successful' }, { key: 'new_tracks_found', label: 'New Tracks' }, { key: 'tracks_added_to_wishlist', label: 'Added to Wishlist' }, ], 'run_duplicate_cleaner': [ { key: 'files_scanned', label: 'Files Scanned' }, { key: 'duplicates_found', label: 'Duplicates Found' }, { key: 'files_deleted', label: 'Files Deleted' }, { key: 'space_freed_mb', label: 'Space Freed (MB)' }, ], 'start_quality_scan': [ { key: 'tracks_scanned', label: 'Tracks Scanned' }, { key: 'quality_met', label: 'Quality Met' }, { key: 'low_quality', label: 'Low Quality' }, { key: 'matched', label: 'Added to Wishlist' }, ], 'scan_library': [ { key: 'scan_duration_seconds', label: 'Duration (s)' }, ], 'backup_database': [ { key: 'size_mb', label: 'Backup Size (MB)' }, ], 'refresh_mirrored': [ { key: 'refreshed', label: 'Playlists Refreshed' }, { key: 'errors', label: 'Errors', hideZero: true }, ], 'clear_quarantine': [ { key: 'removed', label: 'Items Removed' }, ], 'cleanup_wishlist': [ { key: 'removed', label: 'Duplicates Removed' }, ], 'full_cleanup': [ { key: 'quarantine_removed', label: 'Quarantine Removed' }, { key: 'staging_removed', label: 'Import Dirs Removed' }, { key: 'total_removed', label: 'Total Items Removed' }, ], 'playlist_pipeline': [ { key: 'playlists_refreshed', label: 'Refreshed' }, { key: 'tracks_discovered', label: 'Discovered' }, { key: 'tracks_synced', label: 'Synced' }, { key: 'sync_skipped', label: 'Skipped', hideZero: true }, { key: 'wishlist_queued', label: 'Wishlist Queued' }, { key: 'duration_seconds', label: 'Duration (s)' }, ], 'sync_playlist': [ { key: 'matched_tracks', label: 'In Library' }, { key: 'failed_tracks', label: 'Missing' }, { key: 'wishlist_added_count', label: 'Added to Wishlist', hideZero: true }, { key: 'total_tracks', label: 'Total Tracks' }, { key: 'synced_tracks', label: 'On Server Playlist', hideZero: true }, ], }; function _renderResultStats(resultJson, actionType) { if (!resultJson || typeof resultJson !== 'object') return ''; var fields = _RESULT_DISPLAY_MAP[actionType]; var items = []; if (fields) { fields.forEach(function (f) { var val = resultJson[f.key]; if (val == null) return; if (f.hideZero && (val === 0 || val === '0')) return; items.push({ label: f.label, value: val }); }); } else { // Generic fallback: show all non-status, non-underscore keys Object.keys(resultJson).forEach(function (k) { if (k === 'status' || k.startsWith('_')) return; var label = k.replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); items.push({ label: label, value: resultJson[k] }); }); } if (items.length === 0) return ''; var html = '<div class="history-stats-grid">'; items.forEach(function (it) { html += '<div class="history-stat-item"><div class="history-stat-label">' + _esc(it.label) + '</div><div class="history-stat-value">' + _esc(String(it.value)) + '</div></div>'; }); html += '</div>'; return html; } async function showAutomationHistory(automationId, automationName, actionType) { let modal = document.getElementById('automation-history-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'automation-history-modal'; modal.className = 'modal-overlay'; document.body.appendChild(modal); } modal.innerHTML = '<div class="modal-container automation-history-modal"><div class="history-modal-header"><h3>Run History: ' + _esc(automationName) + '</h3><button class="history-close-btn" onclick="document.getElementById(\'automation-history-modal\').style.display=\'none\'">×</button></div><div class="history-modal-body"><div class="history-loading">Loading...</div></div></div>'; modal.style.display = 'flex'; modal.onclick = function (e) { if (e.target === modal) modal.style.display = 'none'; }; try { const res = await fetch('/api/automations/' + automationId + '/history?limit=50'); const data = await res.json(); if (data.error) throw new Error(data.error); const body = modal.querySelector('.history-modal-body'); if (!data.history || data.history.length === 0) { body.innerHTML = '<div class="history-empty">No run history yet. History will be recorded on future runs.</div>'; return; } let html = '<div class="history-entries">'; data.history.forEach(function (entry) { const statusClass = 'history-status-' + (entry.status || 'completed'); const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1); const timeAgo = _autoTimeAgo(entry.started_at); const duration = entry.duration_seconds != null ? _formatDuration(entry.duration_seconds) : ''; const summary = entry.summary ? _esc(entry.summary) : ''; const hasLogs = entry.log_lines && entry.log_lines.length > 0; const entryId = 'history-entry-' + entry.id; html += '<div class="history-entry">'; html += '<div class="history-entry-header" onclick="var el=document.getElementById(\'' + entryId + '\'); if(el) el.classList.toggle(\'expanded\')">'; html += '<span class="history-status-badge ' + statusClass + '">' + statusLabel + '</span>'; html += '<span class="history-time">' + timeAgo + '</span>'; if (duration) html += '<span class="history-duration">' + duration + '</span>'; if (hasLogs) html += '<span class="history-expand-icon">▼</span>'; html += '</div>'; if (summary) html += '<div class="history-summary">' + summary + '</div>'; if (entry.result_json && typeof entry.result_json === 'object') { html += _renderResultStats(entry.result_json, actionType); } if (hasLogs) { html += '<div id="' + entryId + '" class="history-log-section">'; entry.log_lines.forEach(function (log) { html += '<div class="history-log-line history-log-' + (log.type || 'info') + '">' + _esc(log.text || '') + '</div>'; }); html += '</div>'; } html += '</div>'; }); html += '</div>'; if (data.total > data.history.length) { html += '<div class="history-total">Showing ' + data.history.length + ' of ' + data.total + ' runs</div>'; } body.innerHTML = html; } catch (err) { const body = modal.querySelector('.history-modal-body'); if (body) body.innerHTML = '<div class="history-empty">Error loading history: ' + _esc(err.message) + '</div>'; } } function _formatDuration(seconds) { if (seconds < 1) return '<1s'; if (seconds < 60) return Math.round(seconds) + 's'; var m = Math.floor(seconds / 60); var s = Math.round(seconds % 60); if (m < 60) return m + 'm ' + s + 's'; var h = Math.floor(m / 60); m = m % 60; return h + 'h ' + m + 'm'; } async function saveAutomation() { const name = document.getElementById('builder-name').value.trim(); if (!name) { showToast('Name is required', 'error'); return; } if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; } if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; } // Read configs from DOM const triggerConfig = _readPlacedConfig('when'); const actionConfig = _readPlacedConfig('do'); // Read THEN actions (multi-slot) const thenActions = _autoBuilder.then.map((item, i) => ({ type: item.type, config: _readPlacedConfig('then-' + i), })); // Read optional delay from DO slot const delayEl = document.getElementById('cfg-do-delay'); const delayVal = delayEl ? parseInt(delayEl.value) : 0; if (delayVal > 0) actionConfig.delay = delayVal; const groupInput = document.getElementById('builder-group-name'); const groupName = groupInput ? groupInput.value.trim() : ''; const body = { name, trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig, action_type: _autoBuilder.do.type, action_config: actionConfig, then_actions: thenActions, group_name: groupName || null, }; try { let res; if (_autoBuilder.editId) { res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } else { res = await fetch('/api/automations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } const data = await res.json(); if (data.error) throw new Error(data.error); showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success'); hideAutomationBuilder(); await loadAutomations(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } // --- Builder View --- async function showAutomationBuilder(editId) { // Load block definitions (always refresh) try { const res = await fetch('/api/automations/blocks'); _autoBlocks = await res.json(); } catch (e) { if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; } } _autoMirroredPlaylists = null; // invalidate so it re-fetches _autoSpotifyAuthenticated = false; _autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false }; // Populate group datalist from existing automations try { const allRes = await fetch('/api/automations'); const allAutos = await allRes.json(); const groupSet = new Set(); if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); }); const datalist = document.getElementById('builder-group-list'); if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `<option value="${_escAttr(g)}">`).join(''); } catch (e) { } // If editing, load automation data if (editId) { try { const res = await fetch('/api/automations/' + editId); const a = await res.json(); if (a.error) throw new Error(a.error); document.getElementById('builder-name').value = a.name || ''; const groupInput = document.getElementById('builder-group-name'); if (groupInput) groupInput.value = a.group_name || ''; _autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} }; _autoBuilder.do = { type: a.action_type, config: a.action_config || {} }; // Load then_actions array _autoBuilder.then = (a.then_actions || []).map(item => ({ type: item.type, config: item.config || {} })); // Backward compat: if no then_actions but has notify_type if (!_autoBuilder.then.length && a.notify_type) { _autoBuilder.then = [{ type: a.notify_type, config: a.notify_config || {} }]; } _autoBuilder.isSystem = !!a.is_system; } catch (err) { showToast('Failed to load automation', 'error'); return; } } else { document.getElementById('builder-name').value = ''; const groupInput = document.getElementById('builder-group-name'); if (groupInput) groupInput.value = ''; } // System automations: lock the name field and hide group document.getElementById('builder-name').readOnly = _autoBuilder.isSystem; const groupEl = document.getElementById('builder-group-name'); if (groupEl) groupEl.style.display = _autoBuilder.isSystem ? 'none' : ''; _renderBuilderSidebar(); _renderBuilderCanvas(); document.getElementById('automations-list-view').style.display = 'none'; document.getElementById('automations-builder-view').style.display = ''; } function hideAutomationBuilder() { document.getElementById('automations-builder-view').style.display = 'none'; document.getElementById('automations-list-view').style.display = ''; document.getElementById('builder-name').readOnly = false; _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false }; } // --- Sidebar --- function _renderBuilderSidebar() { const sidebar = document.getElementById('builder-sidebar'); if (!sidebar || !_autoBlocks) return; let html = ''; const sections = [ { key: 'triggers', title: 'Triggers', slot: 'when' }, { key: 'actions', title: 'Actions', slot: 'do' }, { key: 'notifications', title: 'Then', slot: 'then' }, ]; sections.forEach(sec => { html += `<div class="sidebar-section"><div class="sidebar-section-title">${sec.title}</div>`; (_autoBlocks[sec.key] || []).forEach(block => { const icon = _autoIcons[block.type] || '\u2699\uFE0F'; const disabled = !block.available; const helpKey = 'auto-' + block.type; const hasHelp = !!TOOL_HELP_CONTENT[helpKey]; html += `<div class="block-item${disabled ? ' coming-soon' : ''}" ${!disabled ? `draggable="true" ondragstart="_autoDragStart(event,'${block.type}','${sec.slot}')" onclick="_autoClickBlock('${block.type}','${sec.slot}')"` : ''}> <div class="block-item-icon">${icon}</div> <div class="block-item-text"> <div class="block-item-label">${_esc(block.label)}</div> <div class="block-item-desc">${_esc(block.description)}</div> </div> ${disabled ? '<span class="coming-soon-badge">Soon</span>' : ''} ${hasHelp ? `<button class="tool-help-button block-help-btn" onclick="event.stopPropagation(); openToolHelpModal('${helpKey}')" title="Learn more">?</button>` : ''} </div>`; }); html += '</div>'; }); sidebar.innerHTML = html; } // --- Canvas --- function _renderBuilderCanvas() { const canvas = document.getElementById('builder-canvas'); if (!canvas) return; let html = ''; // WHEN slot const whenData = _autoBuilder.when; html += '<span class="flow-slot-label when">WHEN</span>'; if (whenData) { html += `<div class="flow-slot filled" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')"> ${_renderPlacedBlock('when', whenData)} </div>`; } else { html += `<div class="flow-slot empty" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')"> <div class="flow-slot-prompt">Drag a trigger here — WHEN does this run?</div> </div>`; } html += '<div class="flow-connector"></div>'; // DO slot const doData = _autoBuilder.do; html += '<span class="flow-slot-label do">DO</span>'; if (doData) { html += `<div class="flow-slot filled" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')"> ${_renderPlacedBlock('do', doData)} </div>`; } else { html += `<div class="flow-slot empty" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')"> <div class="flow-slot-prompt">Drag an action here — WHAT should it do?</div> </div>`; } html += '<div class="flow-connector"></div>'; // THEN section (multi-slot, 1-3 items) html += '<span class="flow-slot-label then">THEN</span>'; if (_autoBuilder.then.length > 0) { _autoBuilder.then.forEach((item, i) => { if (i > 0) html += '<div class="flow-connector small"></div>'; html += `<div class="flow-slot filled then-slot" id="slot-then-${i}"> ${_renderPlacedBlock('then-' + i, item)} </div>`; }); } if (_autoBuilder.then.length < 3) { if (_autoBuilder.then.length > 0) html += '<div class="flow-connector small"></div>'; html += `<div class="flow-slot empty then-add" id="slot-then-add" ondragover="_autoDragOver(event,'then')" ondragleave="_autoDragLeave(event,'then')" ondrop="_autoDrop(event,'then')"> <div class="flow-slot-prompt">${_autoBuilder.then.length === 0 ? 'Drag a then-action here (optional)' : '+ Add another (max 3)'}</div> </div>`; } canvas.innerHTML = html; // Load mirrored playlist selects if any are present _autoLoadMirroredSelects(); // Personalized-pipeline kind pickers (one per slot that uses it) ['when', 'do'].forEach(sk => { if (document.getElementById('cfg-' + sk + '-kinds-picker')) { _autoLoadPersonalizedKinds(sk); } }); _autoBuilder.then.forEach((item, i) => { const sk = 'then-' + i; if (document.getElementById('cfg-' + sk + '-kinds-picker')) { _autoLoadPersonalizedKinds(sk); } }); // Set up checkbox state for refresh_mirrored ['when', 'do'].forEach(sk => { const allCb = document.getElementById('cfg-' + sk + '-all'); if (allCb) _autoTogglePlaylistSelect(sk); }); // Also check then slots _autoBuilder.then.forEach((item, i) => { const allCb = document.getElementById('cfg-then-' + i + '-all'); if (allCb) _autoTogglePlaylistSelect('then-' + i); }); } function _renderPlacedBlock(slotKey, data) { const blockDef = _findBlockDef(data.type); const icon = _autoIcons[data.type] || '\u2699\uFE0F'; const label = blockDef ? blockDef.label : data.type; const configHtml = _renderBlockConfigFields(slotKey, data.type, data.config || {}); // Add optional delay field for action blocks let delayHtml = ''; if (slotKey === 'do') { const delayVal = (data.config && data.config.delay) || ''; delayHtml = `<div class="placed-block-config"><div class="config-row"> <label>Delay (minutes)</label> <input type="number" id="cfg-${slotKey}-delay" value="${delayVal}" min="0" placeholder="0" style="width:80px;" title="Wait before executing action"> </div></div>`; } // System automations: lock trigger and action slots (no remove, no replace) const locked = _autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do'); const removeBtn = locked ? '' : `<button class="placed-block-remove" onclick="_autoRemoveBlock('${slotKey}')">\u2715</button>`; return `<div class="placed-block${locked ? ' locked' : ''}" data-type="${_escAttr(data.type)}"> <div class="placed-block-header"> <span class="placed-block-icon">${icon}</span> <span class="placed-block-label">${_esc(label)}</span> ${removeBtn} </div> ${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''} ${delayHtml} </div>`; } function _renderBlockConfigFields(slotKey, blockType, config) { if (blockType === 'schedule') { const interval = config.interval || 6; const unit = config.unit || 'hours'; return `<div class="config-row"> <label>Every</label> <input type="number" id="cfg-${slotKey}-interval" value="${interval}" min="1" style="width:70px;"> <select id="cfg-${slotKey}-unit"> <option value="minutes"${unit === 'minutes' ? ' selected' : ''}>Minutes</option> <option value="hours"${unit === 'hours' ? ' selected' : ''}>Hours</option> <option value="days"${unit === 'days' ? ' selected' : ''}>Days</option> </select> </div>`; } if (blockType === 'daily_time') { const timeVal = config.time || '03:00'; return `<div class="config-row"> <label>At</label> <input type="time" id="cfg-${slotKey}-time" value="${timeVal}"> </div>`; } if (blockType === 'weekly_time') { const timeVal = config.time || '03:00'; const selectedDays = config.days || []; const allDays = [['mon', 'Mon'], ['tue', 'Tue'], ['wed', 'Wed'], ['thu', 'Thu'], ['fri', 'Fri'], ['sat', 'Sat'], ['sun', 'Sun']]; let dayHtml = '<div class="config-row"><label>Days</label><div class="day-picker" id="cfg-' + slotKey + '-days">'; allDays.forEach(([val, lbl]) => { const active = selectedDays.includes(val) ? ' active' : ''; dayHtml += `<button type="button" class="day-btn${active}" data-day="${val}" onclick="this.classList.toggle('active')">${lbl}</button>`; }); dayHtml += '</div></div>'; return `<div class="config-row"> <label>At</label> <input type="time" id="cfg-${slotKey}-time" value="${timeVal}"> </div>${dayHtml}`; } // Event triggers with conditions const blockDef = _findBlockDef(blockType); if (blockDef && blockDef.has_conditions) { return _renderConditionBuilder(slotKey, blockDef, config); } if (blockType === 'process_wishlist') { const cat = config.category || 'all'; return `<div class="config-row"> <label>Category</label> <select id="cfg-${slotKey}-category"> <option value="all"${cat === 'all' ? ' selected' : ''}>All</option> <option value="albums"${cat === 'albums' ? ' selected' : ''}>Albums</option> <option value="singles"${cat === 'singles' ? ' selected' : ''}>Singles</option> </select> </div>`; } if (blockType === 'signal_received') { const sigName = _escAttr(config.signal_name || ''); const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || []; return `<div class="config-row"> <label>Signal Name</label> <input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}" list="known-signals-list-${slotKey}" placeholder="e.g. libraryReady" oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')" style="font-family:monospace;"> <datalist id="known-signals-list-${slotKey}"> ${knownSignals.map(s => `<option value="${s}">`).join('')} </datalist> </div> <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Triggers when another automation fires this signal</div>`; } if (blockType === 'fire_signal') { const sigName = _escAttr(config.signal_name || ''); const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || []; return `<div class="config-row"> <label>Signal Name</label> <input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}" list="known-signals-fire-${slotKey}" placeholder="e.g. libraryReady" oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')" style="font-family:monospace;"> <datalist id="known-signals-fire-${slotKey}"> ${knownSignals.map(s => `<option value="${s}">`).join('')} </datalist> </div> <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Other automations with "Signal Received" trigger will wake up</div>`; } if (blockType === 'run_script') { const scriptName = _escAttr(config.script_name || ''); const timeout = config.timeout || 60; // Fetch scripts list and populate const selectId = `cfg-${slotKey}-script_name`; setTimeout(async () => { try { const resp = await fetch('/api/scripts'); const data = await resp.json(); const sel = document.getElementById(selectId); if (sel && data.scripts) { sel.innerHTML = '<option value="">Select a script...</option>' + data.scripts.map(s => `<option value="${_escAttr(s.name)}"${s.name === scriptName ? ' selected' : ''}>${escapeHtml(s.name)} (${s.extension})</option>`).join(''); } } catch (e) { console.warn('Failed to load scripts:', e); } }, 100); return `<div class="config-row"> <label>Script</label> <select id="${selectId}"> <option value="${scriptName}">${scriptName || 'Loading...'}</option> </select> </div> <div class="config-row"> <label>Timeout</label> <input type="number" id="cfg-${slotKey}-timeout" value="${timeout}" min="5" max="300" style="width:80px;"> seconds </div> <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Place scripts in the <code>scripts/</code> folder. Supported: .sh, .py, .bat, .ps1</div>`; } if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>'; } if (blockType === 'refresh_mirrored') { const allChecked = config.all ? ' checked' : ''; return `<div class="config-row"> <label>Playlist</label> <select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-block-type="refresh_mirrored" data-value="${_escAttr(config.playlist_id || '')}"> <option value="">Loading...</option> </select> </div> <div class="config-row"> <label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Refresh all mirrored playlists</label> </div>`; } if (blockType === 'sync_playlist') { return `<div class="config-row"> <label>Playlist</label> <select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}"> <option value="">Loading...</option> </select> </div>`; } if (blockType === 'discover_playlist') { const allChecked = config.all ? ' checked' : ''; return `<div class="config-row"> <label>Playlist</label> <select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}"> <option value="">Loading...</option> </select> </div> <div class="config-row"> <label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Discover all mirrored playlists</label> </div>`; } if (blockType === 'playlist_pipeline') { const allChecked = config.all ? ' checked' : ''; const skipWishlistChecked = config.skip_wishlist ? ' checked' : ''; return `<div class="config-row"> <label>Playlist</label> <select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}"> <option value="">Loading...</option> </select> </div> <div class="config-row"> <label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Process all mirrored playlists</label> </div> <div class="config-row"> <label><input type="checkbox" id="cfg-${slotKey}-skip_wishlist"${skipWishlistChecked}> Skip wishlist processing</label> </div> <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Runs 4 phases: Refresh → Discover → Sync → Download Missing</div>`; } if (blockType === 'personalized_pipeline') { const refreshFirstChecked = config.refresh_first ? ' checked' : ''; const skipWishlistChecked = config.skip_wishlist ? ' checked' : ''; // Stash existing selections on a hidden input as JSON so the // async populator can mark them checked once kinds load. The // visible picker container starts as a loading message. const initialKinds = JSON.stringify(Array.isArray(config.kinds) ? config.kinds : []); // The default `.config-row` flex layout puts label on the left // and input on the right — wrong for a tall multi-select picker. // Use a dedicated column-layout wrapper so the picker spans // the full card width. return `<div class="config-row" style="flex-direction:column;align-items:stretch;gap:6px;"> <label style="min-width:0;">Personalized playlists to sync</label> <input type="hidden" id="cfg-${slotKey}-kinds" data-initial='${_escAttr(initialKinds)}' value='${_escAttr(initialKinds)}'> <div id="cfg-${slotKey}-kinds-picker" class="personalized-kinds-picker" style="max-height:280px;overflow-y:auto;border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:10px 12px;background:rgba(0,0,0,0.25);width:100%;box-sizing:border-box;"> <div style="color:rgba(255,255,255,0.4);font-size:12px;">Loading personalized playlists…</div> </div> <div style="color:rgba(255,255,255,0.35);font-size:11px;text-transform:none;letter-spacing:0;">Pick which Discover-page playlists this automation will sync. Variant kinds (Time Machine, Genre, Daily Mix, Seasonal Mix) expose individual instances below their kind row.</div> </div> <div class="config-row"> <label><input type="checkbox" id="cfg-${slotKey}-refresh_first"${refreshFirstChecked}> Refresh playlists before sync (regenerate snapshots)</label> </div> <div class="config-row"> <label><input type="checkbox" id="cfg-${slotKey}-skip_wishlist"${skipWishlistChecked}> Skip wishlist processing</label> </div> <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Runs 2 phases: Snapshot → Sync → Download Missing</div>`; } // Shared variable tags builder for notification types function _notifyVarHtml(slotKey) { let allVars = ['time', 'name', 'run_count', 'status']; const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null; if (triggerDef && triggerDef.variables) { triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); }); } let html = '<div class="variable-tags">'; allVars.forEach(v => { html += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; }); return html + '</div>'; } if (blockType === 'discord_webhook') { const url = _escAttr(config.webhook_url || ''); return `<div class="config-row"> <label>Webhook URL</label> <input type="text" id="cfg-${slotKey}-webhook_url" value="${url}" placeholder="https://discord.com/api/webhooks/..."> </div> <div class="config-row"> <label>Message</label> <textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea> </div> ${_notifyVarHtml(slotKey)}`; } if (blockType === 'pushbullet') { const token = _escAttr(config.access_token || ''); return `<div class="config-row"> <label>Access Token</label> <input type="text" id="cfg-${slotKey}-access_token" value="${token}" placeholder="o.xxxxxxxxxxxxxxxxxxxx"> </div> <div class="config-row"> <label>Title</label> <input type="text" id="cfg-${slotKey}-title" value="${_escAttr(config.title || '{name}')}" placeholder="Notification title"> </div> <div class="config-row"> <label>Message</label> <textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || 'Completed with status: {status}'}</textarea> </div> ${_notifyVarHtml(slotKey)}`; } if (blockType === 'telegram') { const botToken = _escAttr(config.bot_token || ''); const chatId = _escAttr(config.chat_id || ''); const threadId = _escAttr(config.thread_id || ''); return `<div class="config-row"> <label>Bot Token</label> <input type="text" id="cfg-${slotKey}-bot_token" value="${botToken}" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"> </div> <div class="config-row"> <label>Chat ID</label> <input type="text" id="cfg-${slotKey}-chat_id" value="${chatId}" placeholder="-1001234567890 or @channelname"> </div> <div class="config-row"> <label>Thread ID</label> <input type="text" id="cfg-${slotKey}-thread_id" value="${threadId}" placeholder="Optional integer"> </div> <div class="config-row"> <label>Message</label> <textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea> </div> ${_notifyVarHtml(slotKey)}`; } if (blockType === 'webhook') { const url = _escAttr(config.url || ''); const hdrs = (config.headers || '').replace(/"/g, '"'); return `<div class="config-row"> <label>URL</label> <input type="text" id="cfg-${slotKey}-url" value="${url}" placeholder="https://your-server.com/hook"> </div> <div class="config-row"> <label>Headers <span style="opacity:0.4;font-weight:400">(one per line, Key: Value)</span></label> <textarea id="cfg-${slotKey}-headers" placeholder="Authorization: Bearer token123\nX-Custom: value" style="font-family:monospace;font-size:11px;">${hdrs}</textarea> </div> <div class="config-row"> <label>Custom Message <span style="opacity:0.4;font-weight:400">(optional)</span></label> <textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || ''}</textarea> </div> <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;"> Sends a JSON POST with all event variables. Custom message added as "message" field if set. </div> ${_notifyVarHtml(slotKey)}`; } return ''; } // --- Condition Builder --- function _renderConditionBuilder(slotKey, blockDef, config) { const conditions = config.conditions || []; const match = config.match || 'all'; const fields = blockDef.condition_fields || []; let html = '<div class="condition-builder" id="conditions-' + slotKey + '">'; html += `<div class="config-row condition-header"> <label>Match</label> <select id="cfg-${slotKey}-match" class="condition-match-select"> <option value="all"${match === 'all' ? ' selected' : ''}>All conditions</option> <option value="any"${match === 'any' ? ' selected' : ''}>Any condition</option> </select> </div>`; html += '<div id="condition-rows-' + slotKey + '">'; if (conditions.length) { conditions.forEach((cond, i) => { html += _renderConditionRow(slotKey, i, fields, cond); }); } html += '</div>'; html += `<button class="add-condition-btn" onclick="_autoAddCondition('${slotKey}')">+ Add Condition</button>`; html += '</div>'; if (!conditions.length) { html += '<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:12px;margin-top:4px;">No conditions = triggers on every event</div>'; } return html; } function _renderConditionRow(slotKey, index, fields, cond) { const field = cond ? cond.field : (fields[0] || ''); const operator = cond ? cond.operator : 'equals'; const value = cond ? _escAttr(cond.value) : ''; let fieldOpts = ''; fields.forEach(f => { fieldOpts += `<option value="${f}"${f === field ? ' selected' : ''}>${f}</option>`; }); // For playlist-related triggers, use a mirrored playlist dropdown instead of free text const triggerType = _autoBuilder.when ? _autoBuilder.when.type : ''; const usePlaylistSelect = ((triggerType === 'playlist_changed' || triggerType === 'discovery_completed') && field === 'playlist_name'); const valueHtml = usePlaylistSelect ? `<select class="cond-value mirrored-playlist-name-select" data-slot="${slotKey}" data-idx="${index}" data-value="${value}"></select>` : `<input type="text" class="cond-value" data-slot="${slotKey}" data-idx="${index}" value="${value}" placeholder="value...">`; return `<div class="condition-row" data-index="${index}"> <select class="cond-field" data-slot="${slotKey}" data-idx="${index}">${fieldOpts}</select> <select class="cond-operator" data-slot="${slotKey}" data-idx="${index}"> <option value="equals"${operator === 'equals' ? ' selected' : ''}>equals</option> <option value="contains"${operator === 'contains' ? ' selected' : ''}>contains</option> <option value="starts_with"${operator === 'starts_with' ? ' selected' : ''}>starts with</option> <option value="not_contains"${operator === 'not_contains' ? ' selected' : ''}>not contains</option> </select> ${valueHtml} <button class="remove-condition-btn" onclick="_autoRemoveCondition('${slotKey}',${index})">\u2715</button> </div>`; } function _autoAddCondition(slotKey) { const data = _autoBuilder[slotKey]; if (!data) return; if (!data.config) data.config = {}; if (!data.config.conditions) data.config.conditions = []; // Save existing conditions from DOM before re-render _autoSaveConditionsFromDOM(slotKey); const blockDef = _findBlockDef(data.type); const fields = blockDef ? (blockDef.condition_fields || []) : []; data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' }); _renderBuilderCanvas(); // Re-populate mirrored playlist selects if needed _autoLoadMirroredSelects(); } function _autoRemoveCondition(slotKey, index) { const data = _autoBuilder[slotKey]; if (!data || !data.config || !data.config.conditions) return; _autoSaveConditionsFromDOM(slotKey); data.config.conditions.splice(index, 1); _renderBuilderCanvas(); _autoLoadMirroredSelects(); } function _autoSaveConditionsFromDOM(slotKey) { const data = _autoBuilder[slotKey]; if (!data || !data.config) return; const container = document.getElementById('condition-rows-' + slotKey); if (!container) return; const rows = container.querySelectorAll('.condition-row'); const conditions = []; rows.forEach(row => { const field = row.querySelector('.cond-field')?.value || ''; const operator = row.querySelector('.cond-operator')?.value || 'contains'; const value = row.querySelector('.cond-value')?.value || ''; conditions.push({ field, operator, value }); }); data.config.conditions = conditions; // Also save match mode const matchEl = document.getElementById('cfg-' + slotKey + '-match'); if (matchEl) data.config.match = matchEl.value; } // --- Mirrored Playlist Select --- function _autoTogglePlaylistSelect(slotKey) { const allCb = document.getElementById('cfg-' + slotKey + '-all'); const sel = document.getElementById('cfg-' + slotKey + '-playlist_id'); if (sel) sel.disabled = allCb && allCb.checked; } // --- Personalized Playlist Picker (multi-select kind+variant) --- // Cached so the second action card renders instantly without re-fetching. let _autoPersonalizedKinds = null; async function _autoLoadPersonalizedKinds(slotKey) { const picker = document.getElementById('cfg-' + slotKey + '-kinds-picker'); const hidden = document.getElementById('cfg-' + slotKey + '-kinds'); if (!picker || !hidden) return; // Read existing selection so we can pre-check matching boxes after render. let selected = []; try { const initial = hidden.dataset.initial || hidden.value || '[]'; selected = JSON.parse(initial); if (!Array.isArray(selected)) selected = []; } catch (_) { selected = []; } const selectedKey = (kind, variant) => `${kind}::${variant || ''}`; const selectedSet = new Set(selected.map(s => selectedKey(s.kind, s.variant))); // Fetch + cache the kinds catalog. if (!_autoPersonalizedKinds) { try { const res = await fetch('/api/personalized/kinds'); const data = await res.json(); _autoPersonalizedKinds = (data && data.kinds) || []; } catch (e) { _autoPersonalizedKinds = []; } } if (!_autoPersonalizedKinds.length) { picker.innerHTML = '<div style="color:rgba(255,255,255,0.4);font-size:12px;">No personalized playlists registered. Restart the server if you just upgraded.</div>'; return; } // Render: one row per (kind, variant). Singletons get one row; // variant kinds get one row per resolved variant + an "all" toggle. // Inline styles override the parent `.placed-block-config label` // rule (uppercase / min-width / letter-spacing) — without these // overrides every row would render as a tiny narrow chip. const rowStyle = 'display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;font-size:13px;text-transform:none;letter-spacing:0;color:rgba(255,255,255,0.85);min-width:0;'; const variantRowStyle = rowStyle + 'padding-left:20px;font-size:12px;color:rgba(255,255,255,0.7);'; const sectionHeader = 'font-weight:600;margin:8px 0 2px;color:rgba(255,255,255,0.85);font-size:13px;text-transform:none;letter-spacing:0;'; let html = ''; _autoPersonalizedKinds.forEach(spec => { const baseLabel = spec.name_template ? spec.name_template.replace('{variant}', '').replace(/\s*[—-]\s*$/,'').trim() : spec.kind; if (!spec.requires_variant) { // Singleton: one checkbox. const key = selectedKey(spec.kind, ''); const checked = selectedSet.has(key) ? ' checked' : ''; html += `<label style="${rowStyle}font-weight:600;"> <input type="checkbox" data-kind="${_escAttr(spec.kind)}"${checked}> <span>${_escAttr(baseLabel)}</span> </label>`; } else { const variants = Array.isArray(spec.variants) ? spec.variants : []; html += `<div style="margin:6px 0 4px;border-top:1px solid rgba(255,255,255,0.05);padding-top:6px;"> <div style="${sectionHeader}">${_escAttr(baseLabel)}</div>`; if (variants.length === 0) { html += `<div style="font-size:11px;color:rgba(255,255,255,0.35);padding-left:20px;text-transform:none;letter-spacing:0;">(no variants available — populate via the discover page first)</div>`; } else { variants.forEach(variant => { const key = selectedKey(spec.kind, variant); const checked = selectedSet.has(key) ? ' checked' : ''; html += `<label style="${variantRowStyle}"> <input type="checkbox" data-kind="${_escAttr(spec.kind)}" data-variant="${_escAttr(variant)}"${checked}> <span>${_escAttr(variant)}</span> </label>`; }); } html += '</div>'; } }); picker.innerHTML = html; } async function _autoLoadMirroredSelects() { const selects = document.querySelectorAll('.mirrored-playlist-select'); const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select'); if (!selects.length && !nameSelects.length) return; if (!_autoMirroredPlaylists) { try { const res = await fetch('/api/mirrored-playlists/list'); const data = await res.json(); // New format returns { playlists, spotify_authenticated } if (Array.isArray(data)) { // Backward compat: old format was plain array _autoMirroredPlaylists = data; _autoSpotifyAuthenticated = false; } else { _autoMirroredPlaylists = data.playlists || []; _autoSpotifyAuthenticated = data.spotify_authenticated || false; } } catch (e) { _autoMirroredPlaylists = []; _autoSpotifyAuthenticated = false; } } selects.forEach(sel => { const savedValue = sel.dataset.value || ''; const isRefresh = sel.dataset.blockType === 'refresh_mirrored'; sel.innerHTML = '<option value="">-- Select playlist --</option>'; _autoMirroredPlaylists.forEach(p => { // For refresh selects: hide file playlists, hide spotify (library) if not authed if (isRefresh) { if (p.source === 'file' || p.source === 'beatport') return; if (p.source === 'spotify' && !_autoSpotifyAuthenticated) return; } sel.innerHTML += `<option value="${p.id}"${String(p.id) === savedValue ? ' selected' : ''}>${_esc(p.name)}</option>`; }); }); nameSelects.forEach(sel => { const savedValue = sel.dataset.value || ''; sel.innerHTML = '<option value="">-- Select playlist --</option>'; _autoMirroredPlaylists.forEach(p => { sel.innerHTML += `<option value="${_escAttr(p.name)}"${p.name === savedValue ? ' selected' : ''}>${_esc(p.name)}</option>`; }); }); } function _readPlacedConfig(slotKey) { let data; if (slotKey.startsWith('then-')) { const idx = parseInt(slotKey.split('-')[1]); data = _autoBuilder.then[idx]; } else { data = _autoBuilder[slotKey]; } if (!data) return {}; const type = data.type; if (type === 'schedule') { return { interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6, unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours', }; } if (type === 'daily_time') { return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' }; } if (type === 'weekly_time') { const daysEl = document.getElementById('cfg-' + slotKey + '-days'); const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : []; return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00', days, }; } // Event triggers with conditions const blockDef = _findBlockDef(type); if (blockDef && blockDef.has_conditions) { _autoSaveConditionsFromDOM(slotKey); return { conditions: (data.config && data.config.conditions) || [], match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all', }; } if (type === 'process_wishlist') { return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' }; } if (type === 'refresh_mirrored') { const allCb = document.getElementById('cfg-' + slotKey + '-all'); return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', all: allCb ? allCb.checked : false, }; } if (type === 'sync_playlist') { return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' }; } if (type === 'discover_playlist') { const allCb = document.getElementById('cfg-' + slotKey + '-all'); return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', all: allCb ? allCb.checked : false, }; } if (type === 'playlist_pipeline') { const allCb = document.getElementById('cfg-' + slotKey + '-all'); const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist'); return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', all: allCb ? allCb.checked : false, skip_wishlist: skipWl ? skipWl.checked : false, }; } if (type === 'personalized_pipeline') { // Each checked box has data-kind / data-variant attributes; // walk them to assemble the kinds list. const picker = document.getElementById('cfg-' + slotKey + '-kinds-picker'); const kinds = []; if (picker) { picker.querySelectorAll('input[type="checkbox"][data-kind]:checked').forEach(cb => { const entry = { kind: cb.dataset.kind }; if (cb.dataset.variant) entry.variant = cb.dataset.variant; kinds.push(entry); }); } const refreshFirstCb = document.getElementById('cfg-' + slotKey + '-refresh_first'); const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist'); return { kinds, refresh_first: refreshFirstCb ? refreshFirstCb.checked : false, skip_wishlist: skipWl ? skipWl.checked : false, }; } if (type === 'signal_received' || type === 'fire_signal') { return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' }; } if (type === 'run_script') { return { script_name: document.getElementById('cfg-' + slotKey + '-script_name')?.value || '', timeout: parseInt(document.getElementById('cfg-' + slotKey + '-timeout')?.value || '60') || 60, }; } if (type === 'discord_webhook') { return { webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } if (type === 'pushbullet') { return { access_token: document.getElementById('cfg-' + slotKey + '-access_token')?.value?.trim() || '', title: document.getElementById('cfg-' + slotKey + '-title')?.value || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } if (type === 'telegram') { return { bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', thread_id: document.getElementById('cfg-' + slotKey + '-thread_id')?.value?.trim() || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } if (type === 'webhook') { return { url: document.getElementById('cfg-' + slotKey + '-url')?.value?.trim() || '', headers: document.getElementById('cfg-' + slotKey + '-headers')?.value || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } return {}; } function _findBlockDef(type) { if (!_autoBlocks) return null; for (const cat of ['triggers', 'actions', 'notifications']) { const found = (_autoBlocks[cat] || []).find(b => b.type === type); if (found) return found; } return null; } // --- Drag & Drop --- function _autoDragStart(e, blockType, slotCategory) { e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory })); e.dataTransfer.effectAllowed = 'copy'; } function _autoDragOver(e, slotKey) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; document.getElementById(targetId)?.classList.add('drag-over'); } function _autoDragLeave(e, slotKey) { const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; document.getElementById(targetId)?.classList.remove('drag-over'); } function _autoDrop(e, slotKey) { e.preventDefault(); const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; document.getElementById(dropTargetId)?.classList.remove('drag-over'); if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; try { const data = JSON.parse(e.dataTransfer.getData('text/plain')); // Handle THEN slot (append to array) if (slotKey === 'then') { if (data.slot !== 'then') { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; } _autoBuilder.then.push({ type: data.type, config: {} }); } else { if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } _autoBuilder[slotKey] = { type: data.type, config: {} }; } _renderBuilderCanvas(); } catch (err) { } } // Click-to-add (alternative to drag) function _autoClickBlock(blockType, slotCategory) { if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return; if (slotCategory === 'then') { if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; } _autoBuilder.then.push({ type: blockType, config: {} }); } else { _autoBuilder[slotCategory] = { type: blockType, config: {} }; } _renderBuilderCanvas(); } function _autoRemoveBlock(slotKey) { if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; // Handle then-N slots if (slotKey.startsWith('then-')) { const idx = parseInt(slotKey.split('-')[1]); if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) { _autoBuilder.then.splice(idx, 1); } } else { _autoBuilder[slotKey] = null; } _renderBuilderCanvas(); } // Variable insertion function _autoInsertVar(textareaId, variable) { const el = document.getElementById(textareaId); if (!el) return; const start = el.selectionStart, end = el.selectionEnd; el.value = el.value.substring(0, start) + variable + el.value.substring(end); el.selectionStart = el.selectionEnd = start + variable.length; el.focus(); } // --- Report Issue Helper --- function showReportIssueModal(entityType, entityId, entityName, artistName, albumTitle) { const bridge = window.SoulSyncIssueDomain; if (bridge && typeof bridge.openReportIssue === 'function') { bridge.openReportIssue({ entityType, entityId, entityName, artistName, albumTitle: albumTitle || '', }); return; } console.warn('Report issue bridge is unavailable'); } // --- Helpers --- function _esc(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } function _escAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>'); } // Escape a string for use INSIDE a single-quoted JS string literal that itself // sits inside a double-quoted HTML attribute, e.g. onclick="fn('<value>')". // Such a value is decoded twice: first the HTML attribute parser resolves // entities, then the JS parser reads the string. _escAttr only HTML-escapes, so // an apostrophe becomes ' which the attribute parser turns back into a bare // ' — terminating the JS string and throwing a SyntaxError that silently kills // the whole handler (this is the "Road trip-The Rolfe's" delete-button bug). // Fix: backslash-escape the JS metacharacters (\ and ') FIRST so they survive // HTML decoding intact, then HTML-escape the characters that would otherwise // break the surrounding attribute. Order matters — & is escaped before the // later replacements insert their own entities. function _escJs(str) { if (!str) return ''; return String(str) .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/\r?\n/g, ' ') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>'); } // ===== ENHANCE QUALITY MODAL ===== let _enhanceQualityData = null; let _enhanceArtistId = null; const ENHANCE_TIER_MAP = { 'lossless': { num: 1, label: 'Lossless', cssClass: 'lossless' }, 'high_lossy': { num: 2, label: 'High Lossy', cssClass: 'high-lossy' }, 'standard_lossy': { num: 3, label: 'Standard Lossy', cssClass: 'standard-lossy' }, 'low_lossy': { num: 4, label: 'Low Lossy', cssClass: 'low-lossy' }, 'unknown': { num: 999, label: 'Unknown', cssClass: 'unknown' }, }; async function checkArtistEnhanceEligibility(artistId) { const btn = document.getElementById('library-artist-enhance-btn'); if (!btn) return; btn.classList.add('hidden'); _enhanceArtistId = artistId; try { const resp = await fetch(`/api/library/artist/${artistId}/quality-analysis`); if (!resp.ok) return; const data = await resp.json(); if (!data.success || !data.tracks || data.tracks.length === 0) return; _enhanceQualityData = data; // Show button if any tracks are below the user's min acceptable tier const minTier = data.min_acceptable_tier || 1; const belowCount = data.tracks.filter(t => t.tier_num > minTier).length; if (belowCount > 0) { btn.classList.remove('hidden'); btn.querySelector('.enhance-text').textContent = `Enhance Quality (${belowCount})`; } } catch (e) { console.debug('Enhance eligibility check failed:', e); } } async function playArtistRadio() { try { const artistId = artistDetailPageState.currentArtistId; const artistName = artistDetailPageState.currentArtistName || ''; if (!artistId) { showToast('No artist selected', 'error'); return; } // Get tracks from this artist's library const resp = await fetch(`/api/library/artist/${artistId}/enhanced`); if (!resp.ok) throw new Error('Failed to load artist data'); const data = await resp.json(); if (!data.success) throw new Error(data.error || 'Failed'); // Collect all tracks with file paths const allTracks = []; for (const album of (data.albums || [])) { for (const track of (album.tracks || [])) { if (track.file_path) { allTracks.push({ track, album }); } } } if (!allTracks.length) { showToast('No playable tracks found for this artist', 'error'); return; } // Pick a random track const random = allTracks[Math.floor(Math.random() * allTracks.length)]; const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; // Clear existing queue and disable radio before starting fresh npRadioMode = false; clearQueue(); if (audioPlayer && !audioPlayer.paused) { audioPlayer.pause(); } // Play the track and WAIT for it to resolve (playLibraryTrack is async // and fetches the canonical DB row before setting currentTrack) — so // currentTrack.id is reliably set before we fetch the radio queue. A // fixed setTimeout raced this and often fired before the id existed, // leaving the queue empty until the song ended. await playLibraryTrack({ id: random.track.id, title: random.track.title, file_path: random.track.file_path, bitrate: random.track.bitrate, artist_id: artistId, album_id: random.album.id, }, random.album.title || '', artistName); // Enable radio mode + immediately seed the queue with similar tracks — // same path the modal's Radio button uses (fetchIfNeeded also adds the // current track to the queue first). if (typeof npSetPlayContext === 'function') { npSetPlayContext(`${artistName || 'Artist'} Radio`); } if (typeof npSetRadioMode === 'function') { npSetRadioMode(true, { toast: false, fetchIfNeeded: true }); } else { npRadioMode = true; } showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); } catch (e) { showToast(`Failed to start artist radio: ${e.message}`, 'error'); } } async function writeArtistImageToDisk(overwrite) { // Issue #572: writes `artist.jpg` to the artist's folder so // Navidrome (which has no API for artist images) picks up a // real photo on its next scan. Plex/Jellyfin also read this // file as a fallback, so it's safe to run regardless of which // server is active. const artistId = artistDetailPageState.currentArtistId; if (!artistId) { showToast('No artist selected', 'error'); return; } const btn = document.getElementById('library-artist-write-image-btn'); if (btn) { btn.disabled = true; const txt = btn.querySelector('.write-image-text'); if (txt) txt.textContent = 'Writing…'; } try { let resp = await fetch(`/api/artist/${artistId}/write-image-to-disk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ overwrite: !!overwrite }), }); let data = await resp.json(); // Default protect-existing path: surface a confirm so the user // explicitly opts in to clobbering a hand-picked artist.jpg. if (!data.success && /already exists/i.test(data.error || '')) { if (confirm('artist.jpg already exists in the folder. Overwrite with the new image?')) { resp = await fetch(`/api/artist/${artistId}/write-image-to-disk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ overwrite: true }), }); data = await resp.json(); } else { showToast('Cancelled — existing artist.jpg kept', 'info'); return; } } if (!data.success) { showToast(`Failed: ${data.error || 'Unknown error'}`, 'error'); return; } const scan = data.scan_triggered ? ' (Navidrome scan triggered)' : ''; showToast(`artist.jpg written${scan}`, 'success'); } catch (e) { showToast(`Write failed: ${e.message}`, 'error'); } finally { if (btn) { btn.disabled = false; const txt = btn.querySelector('.write-image-text'); if (txt) txt.textContent = 'Write Artist Image'; } } } window.writeArtistImageToDisk = writeArtistImageToDisk; function openEnhanceQualityModal() { if (!_enhanceQualityData) return; const data = _enhanceQualityData; // Remove existing modal if any const existing = document.getElementById('enhance-quality-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'enhance-quality-overlay'; overlay.className = 'enhance-modal-overlay'; overlay.onclick = (e) => { if (e.target === overlay) closeEnhanceQualityModal(); }; const minTier = data.min_acceptable_tier || 1; const summary = data.quality_summary || {}; overlay.innerHTML = ` <div class="enhance-modal" onclick="event.stopPropagation()"> <div class="enhance-modal-header"> <h3>⚡ Enhance Quality — ${_esc(data.artist_name)}</h3> <button class="enhance-modal-close" onclick="closeEnhanceQualityModal()">×</button> </div> <div class="enhance-summary-bar"> ${_buildEnhanceSummaryChips(summary)} </div> <div class="enhance-controls"> <div class="enhance-tier-selector"> <label>Upgrade tracks below:</label> <select id="enhance-tier-dropdown" onchange="updateEnhanceThreshold(parseInt(this.value))"> <option value="1" ${minTier <= 1 ? 'selected' : ''}>Lossless (FLAC/WAV)</option> <option value="2" ${minTier === 2 ? 'selected' : ''}>High Lossy (OGG/Opus)</option> <option value="3" ${minTier === 3 ? 'selected' : ''}>Standard Lossy (M4A/AAC)</option> <option value="4" ${minTier >= 4 ? 'selected' : ''}>Low Lossy (MP3/WMA)</option> </select> </div> <div class="enhance-select-controls"> <button class="enhance-select-btn" onclick="enhanceSelectAll(true)">Select All Below</button> <button class="enhance-select-btn" onclick="enhanceSelectAll(false)">Deselect All</button> <span class="enhance-selected-count" id="enhance-selected-count">0 selected</span> </div> </div> <div class="enhance-modal-body"> <table class="enhance-track-table"> <thead> <tr> <th></th> <th>#</th> <th>Title</th> <th>Album</th> <th>Format</th> <th>Bitrate</th> </tr> </thead> <tbody id="enhance-track-tbody"> </tbody> </table> </div> <div class="enhance-modal-footer"> <div class="enhance-footer-info" id="enhance-footer-info"></div> <div class="enhance-footer-actions"> <button class="enhance-btn secondary" onclick="closeEnhanceQualityModal()">Cancel</button> <button class="enhance-btn primary" id="enhance-submit-btn" onclick="submitEnhanceQuality()" disabled> ⚡ Enhance 0 Tracks </button> </div> </div> </div> `; document.body.appendChild(overlay); renderEnhanceTrackRows(minTier); } function _buildEnhanceSummaryChips(summary) { const chips = [ { key: 'lossless', label: 'FLAC', cssClass: 'lossless' }, { key: 'high_lossy', label: 'OGG/Opus', cssClass: 'high-lossy' }, { key: 'standard_lossy', label: 'M4A/AAC', cssClass: 'standard-lossy' }, { key: 'low_lossy', label: 'MP3/WMA', cssClass: 'low-lossy' }, ]; return chips .filter(c => (summary[c.key] || 0) > 0) .map(c => ` <div class="enhance-summary-chip ${c.cssClass}"> <span class="chip-count">${summary[c.key]}</span> <span class="chip-label">${c.label}</span> </div> `).join(''); } function renderEnhanceTrackRows(thresholdTier) { const tbody = document.getElementById('enhance-track-tbody'); if (!tbody || !_enhanceQualityData) return; const tracks = _enhanceQualityData.tracks; // Sort: below-threshold first, then by album + track number const sorted = [...tracks].sort((a, b) => { const aBt = a.tier_num > thresholdTier ? 0 : 1; const bBt = b.tier_num > thresholdTier ? 0 : 1; if (aBt !== bBt) return aBt - bBt; const albumCmp = (a.album_title || '').localeCompare(b.album_title || ''); if (albumCmp !== 0) return albumCmp; return (a.disc_number || 1) * 1000 + (a.track_number || 0) - ((b.disc_number || 1) * 1000 + (b.track_number || 0)); }); tbody.innerHTML = sorted.map(track => { const isBelow = track.tier_num > thresholdTier; const tierInfo = ENHANCE_TIER_MAP[track.tier_name] || ENHANCE_TIER_MAP['unknown']; const bitrateStr = track.bitrate ? `${track.bitrate} kbps` : '-'; return ` <tr class="enhance-track-row ${isBelow ? 'below-threshold' : 'above-threshold'}" data-track-id="${_escAttr(track.track_id)}" data-tier="${track.tier_num}"> <td><input type="checkbox" class="enhance-track-check" ${isBelow ? 'checked' : ''} onchange="updateEnhanceSelectedCount()"></td> <td>${track.track_number || '-'}</td> <td>${_esc(track.title)}</td> <td>${_esc(track.album_title)}</td> <td><span class="enhance-format-badge ${tierInfo.cssClass}">${_esc(track.format)}</span></td> <td><span class="enhance-bitrate">${bitrateStr}</span></td> </tr> `; }).join(''); updateEnhanceSelectedCount(); } function updateEnhanceThreshold(tierNum) { const rows = document.querySelectorAll('.enhance-track-row'); rows.forEach(row => { const trackTier = parseInt(row.dataset.tier); const isBelow = trackTier > tierNum; const cb = row.querySelector('.enhance-track-check'); row.classList.toggle('below-threshold', isBelow); row.classList.toggle('above-threshold', !isBelow); if (cb) cb.checked = isBelow; }); updateEnhanceSelectedCount(); } function enhanceSelectAll(select) { const thresholdTier = parseInt(document.getElementById('enhance-tier-dropdown')?.value || '1'); const checks = document.querySelectorAll('.enhance-track-check'); checks.forEach(cb => { const row = cb.closest('.enhance-track-row'); const trackTier = parseInt(row?.dataset.tier || '999'); if (select) { cb.checked = trackTier > thresholdTier; } else { cb.checked = false; } }); updateEnhanceSelectedCount(); } function updateEnhanceSelectedCount() { const checks = document.querySelectorAll('.enhance-track-check:checked'); const count = checks.length; const countEl = document.getElementById('enhance-selected-count'); const submitBtn = document.getElementById('enhance-submit-btn'); if (countEl) countEl.textContent = `${count} selected`; if (submitBtn) { submitBtn.textContent = `⚡ Enhance ${count} Track${count !== 1 ? 's' : ''}`; submitBtn.disabled = count === 0; } } async function submitEnhanceQuality() { const checks = document.querySelectorAll('.enhance-track-check:checked'); const trackIds = []; checks.forEach(cb => { const row = cb.closest('.enhance-track-row'); if (row?.dataset.trackId) trackIds.push(row.dataset.trackId); }); if (trackIds.length === 0) return; const submitBtn = document.getElementById('enhance-submit-btn'); const footerInfo = document.getElementById('enhance-footer-info'); if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="enhance-spinner"></span>Processing...'; } if (footerInfo) footerInfo.textContent = 'Matching tracks across metadata sources and adding to wishlist...'; try { const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_ids: trackIds }) }); const result = await resp.json(); if (result.success) { const msg = `${result.enhanced_count} track${result.enhanced_count !== 1 ? 's' : ''} queued for enhancement`; if (footerInfo) footerInfo.textContent = msg; showToast(msg + (result.failed_count > 0 ? ` (${result.failed_count} failed)` : ''), 'success'); // Update button count const enhBtn = document.getElementById('library-artist-enhance-btn'); if (enhBtn && result.enhanced_count > 0) { const remaining = trackIds.length - result.enhanced_count; if (remaining <= 0) { enhBtn.classList.add('hidden'); } } if (submitBtn) { submitBtn.textContent = '✅ Done'; submitBtn.disabled = true; } // Auto-close after short delay setTimeout(() => closeEnhanceQualityModal(), 1500); } else { throw new Error(result.error || 'Enhancement failed'); } } catch (e) { console.error('Enhance quality error:', e); showToast(`Enhancement failed: ${e.message}`, 'error'); if (submitBtn) { submitBtn.textContent = `⚡ Enhance ${trackIds.length} Tracks`; submitBtn.disabled = false; } if (footerInfo) footerInfo.textContent = ''; } } function closeEnhanceQualityModal() { const overlay = document.getElementById('enhance-quality-overlay'); if (overlay) { overlay.classList.add('hidden'); setTimeout(() => overlay.remove(), 300); } } // Global exports window.openEnhanceQualityModal = openEnhanceQualityModal; window.closeEnhanceQualityModal = closeEnhanceQualityModal; window.updateEnhanceThreshold = updateEnhanceThreshold; window.enhanceSelectAll = enhanceSelectAll; window.updateEnhanceSelectedCount = updateEnhanceSelectedCount; window.submitEnhanceQuality = submitEnhanceQuality; // ===== END ENHANCE QUALITY MODAL =====