diff --git a/database/music_database.py b/database/music_database.py index c2df3f44..3fa62e02 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -9743,6 +9743,52 @@ class MusicDatabase: logger.error(f"Error getting mirrored playlist discovery counts: {e}") return (0, 0) + def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict: + """Return discovery, wishlisted, and downloaded counts for a mirrored playlist. + Discovery counts are critical (same as old method). Library/wishlist counts are + best-effort extras that won't break discovery detection if they fail.""" + result = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0} + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Core counts — same reliable queries as get_mirrored_playlist_discovery_counts + cursor.execute( + "SELECT COUNT(*) as total FROM mirrored_playlist_tracks WHERE playlist_id = ?", + (playlist_id,) + ) + result['total'] = cursor.fetchone()['total'] + cursor.execute( + "SELECT COUNT(*) as discovered FROM mirrored_playlist_tracks WHERE playlist_id = ? AND extra_data LIKE '%\"discovered\": true%'", + (playlist_id,) + ) + result['discovered'] = cursor.fetchone()['discovered'] + + # Best-effort extras — won't break if tracks table has issues + try: + cursor.execute(""" + SELECT + SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != '' + AND EXISTS (SELECT 1 FROM wishlist_tracks wt + WHERE wt.spotify_track_id = mpt.source_track_id) + THEN 1 ELSE 0 END) as wishlisted, + SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t + WHERE t.title = mpt.track_name COLLATE NOCASE + AND t.artist = mpt.artist_name COLLATE NOCASE) + THEN 1 ELSE 0 END) as in_library + FROM mirrored_playlist_tracks mpt + WHERE mpt.playlist_id = ? + """, (playlist_id,)) + row = cursor.fetchone() + result['wishlisted'] = row['wishlisted'] or 0 + result['in_library'] = row['in_library'] or 0 + except Exception as extra_err: + logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}") + + except Exception as e: + logger.error(f"Error getting mirrored playlist status counts: {e}") + return result + def delete_mirrored_playlist(self, playlist_id: int) -> bool: """Delete a mirrored playlist and its tracks (CASCADE).""" try: diff --git a/web_server.py b/web_server.py index 6a06a86b..874bdd04 100644 --- a/web_server.py +++ b/web_server.py @@ -43938,9 +43938,11 @@ def get_mirrored_playlists_endpoint(): profile_id = get_current_profile_id() playlists = database.get_mirrored_playlists(profile_id=profile_id) for pl in playlists: - discovered, total = database.get_mirrored_playlist_discovery_counts(pl['id']) - pl['discovered_count'] = discovered - pl['total_count'] = total + counts = database.get_mirrored_playlist_status_counts(pl['id']) + pl['discovered_count'] = counts['discovered'] + pl['total_count'] = counts['total'] + pl['wishlisted_count'] = counts['wishlisted'] + pl['in_library_count'] = counts['in_library'] return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") diff --git a/webui/static/script.js b/webui/static/script.js index 46e76d72..ad263f1e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -66982,7 +66982,35 @@ function initExplorer() { if (_explorer.initialized) return; _explorer.initialized = true; _explorer._playlists = []; + _explorer._activeSource = null; + _explorerLoadPlaylists(); + + // Listen for discovery completion to auto-refresh playlist cards + if (typeof socket !== 'undefined') { + socket.on('discovery:progress', (data) => { + if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) return; + // Match mirrored playlist discovery events + if (data.phase === 'discovered' || data.phase === 'sync_complete' || data.complete) { + // Discovery finished — refresh playlists after brief delay for DB commit + setTimeout(() => _explorerLoadPlaylists(), 1500); + } + // Live progress update on cards during discovery + if (data.id && data.id.startsWith('mirrored_')) { + const plId = parseInt(data.id.replace('mirrored_', '')); + const card = document.querySelector(`.explorer-picker-card[data-id="${plId}"]`); + if (card) { + const meta = card.querySelector('.explorer-picker-card-meta'); + if (meta && data.progress != null) { + meta.innerHTML = `Discovering... ${Math.round(data.progress)}%`; + } + } + } + }); + } +} + +function _explorerLoadPlaylists() { fetch('/api/mirrored-playlists') .then(r => r.json()) .then(data => { @@ -67009,24 +67037,27 @@ function initExplorer() { const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' }; const sources = Object.keys(groups); if (sources.length <= 1) { - // Only one source — no tabs needed tabsEl.style.display = 'none'; } else { tabsEl.innerHTML = sources.map((src, i) => { const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1); const count = groups[src].length; - return ``; + const isActive = _explorer._activeSource === src || (!_explorer._activeSource && i === 0); + return ``; }).join(''); } - // Show first source - explorerRenderPickerCards(sources[0]); + // Show active or first source + const activeSource = _explorer._activeSource || sources[0]; + _explorer._activeSource = activeSource; + explorerRenderPickerCards(activeSource); } }) .catch(() => {}); } function explorerSwitchPickerTab(source) { + _explorer._activeSource = source; document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source)); explorerRenderPickerCards(source); } @@ -67043,18 +67074,60 @@ function explorerRenderPickerCards(source) { const pct = total > 0 ? Math.round((discovered / total) * 100) : 0; const isReady = pct >= 50; const isActive = _explorer.playlistId === p.id; + const isFullyDiscovered = pct === 100; + const wasExplored = p.explored || false; + const wishlisted = p.wishlisted_count || 0; + const inLibrary = p.in_library_count || 0; + + // Status badge: checkmark if explored/in-library, star if ready, % if needs discovery + let statusBadge = ''; + if (inLibrary > 0 && inLibrary >= total * 0.8) { + statusBadge = '
'; + } else if (wasExplored) { + statusBadge = '
'; + } else if (wishlisted > 0) { + statusBadge = '
'; + } else if (isFullyDiscovered) { + statusBadge = '
'; + } else if (!isReady) { + statusBadge = `
${pct}%
`; + } + + // Meta line with status indicators + let metaHTML; + const statusParts = []; + if (inLibrary > 0) statusParts.push(`${inLibrary} in library`); + if (wishlisted > 0) statusParts.push(`${wishlisted} wishlisted`); + + if (isFullyDiscovered) { + metaHTML = `${total} tracks · Fully discovered`; + } else if (isReady) { + metaHTML = `${total} tracks · ${pct}% discovered`; + } else { + metaHTML = `${total} tracks · ${pct}% discovered`; + } + if (statusParts.length > 0) { + metaHTML += `
${statusParts.join(' · ')}`; + } + + // Discover button for undiscovered playlists (replaces redirect to Sync) + const discoverBtn = !isReady ? `` : ''; + return ` -
+ onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : ''}">
${img ? `` : '
'}
-
${p.name || 'Untitled'}
-
${total} tracks · ${isReady ? `${pct}% discovered` : `${pct}% — needs discovery`}
+
+
${p.name || 'Untitled'}
+ ${statusBadge} +
+
${metaHTML}
+ ${discoverBtn ? `
${discoverBtn}
` : ''}
- ${isReady ? '' : '
🔒
'}
`; }).join(''); @@ -67069,13 +67142,59 @@ function explorerSelectPlaylist(id, el) { function explorerRedirectToDiscover(playlistId) { showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info'); navigateToPage('sync'); - // Switch to mirrored tab after page loads setTimeout(() => { const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); if (mirroredBtn) mirroredBtn.click(); }, 200); } +async function explorerStartDiscovery(playlistId) { + const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); + const btn = card?.querySelector('.explorer-picker-discover-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; } + + try { + if (typeof discoverMirroredPlaylist === 'function') { + await discoverMirroredPlaylist(playlistId); + if (btn) { btn.disabled = false; btn.textContent = 'Open'; btn.title = 'Reopen discovery modal'; } + + // Poll for card updates while discovery is in progress + _explorerStartDiscoveryPoller(playlistId); + } else { + explorerRedirectToDiscover(playlistId); + } + } catch (err) { + showToast(`Discovery failed: ${err.message}`, 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Discover'; } + } +} + +function _explorerStartDiscoveryPoller(playlistId) { + // Poll every 5s to refresh playlist cards until this playlist is ready + if (_explorer._discoveryPoller) clearInterval(_explorer._discoveryPoller); + _explorer._discoveryPoller = setInterval(async () => { + // Stop polling if Explorer page isn't active + if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) { + clearInterval(_explorer._discoveryPoller); + _explorer._discoveryPoller = null; + return; + } + // Check if the mirrored playlist state shows discovery is done + const tempHash = `mirrored_${playlistId}`; + const state = youtubePlaylistStates[tempHash]; + const isDone = state && (state.phase === 'discovered' || state.phase === 'sync_complete'); + + // Refresh cards from API + await _explorerLoadPlaylists(); + + // Stop polling once discovery is complete + if (isDone) { + clearInterval(_explorer._discoveryPoller); + _explorer._discoveryPoller = null; + } + }, 5000); +} + function explorerSetMode(mode) { _explorer.mode = mode; document.querySelectorAll('.explorer-mode-btn').forEach(btn => { @@ -67175,6 +67294,31 @@ async function explorerBuildTree() { if (progress) progress.style.display = 'none'; _explorerUpdateCount(); + // Mark playlist as explored (persists in session for badge display) + const exploredPl = _explorer._playlists.find(p => p.id === playlistId); + if (exploredPl) { + exploredPl.explored = true; + // Update card badge without full re-render + const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); + if (card) { + card.classList.add('explored'); + const oldBadge = card.querySelector('.explorer-picker-card-badge'); + const badgeHTML = '
'; + if (oldBadge) { + oldBadge.outerHTML = badgeHTML; + } else { + // Insert badge into the name row + const nameRow = card.querySelector('.explorer-picker-card-name-row'); + if (nameRow) { + nameRow.insertAdjacentHTML('beforeend', badgeHTML); + } + } + // Remove discover button if present (no longer needed) + const discoverBtn = card.querySelector('.explorer-picker-card-actions'); + if (discoverBtn) discoverBtn.remove(); + } + } + // Draw all connections now that the tree is stable setTimeout(() => _explorerRedrawAllConnections(true), 100); diff --git a/webui/static/style.css b/webui/static/style.css index d1621c5d..15286d8d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -50569,11 +50569,11 @@ tr.tag-diff-same { .explorer-picker-card { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; - padding: 8px 14px 8px 8px; - min-width: 200px; - max-width: 260px; + padding: 10px 14px 10px 10px; + min-width: 220px; + max-width: 280px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; @@ -50625,6 +50625,12 @@ tr.tag-diff-same { flex: 1; } +.explorer-picker-card-name-row { + display: flex; + align-items: center; + gap: 6px; +} + .explorer-picker-card-name { font-size: 12px; font-weight: 600; @@ -50638,6 +50644,11 @@ tr.tag-diff-same { font-size: 10px; color: rgba(255, 255, 255, 0.35); margin-top: 2px; + line-height: 1.4; +} + +.explorer-picker-card-actions { + margin-top: 6px; } .explorer-picker-card.not-ready { @@ -50654,6 +50665,25 @@ tr.tag-diff-same { color: rgba(245, 166, 35, 0.8); } +.explorer-picker-discovered { + color: rgba(74, 222, 128, 0.8); +} +.explorer-picker-in-library { + color: rgba(96, 165, 250, 0.7); font-size: 10px; +} +.explorer-picker-wishlisted { + color: rgba(244, 114, 182, 0.7); font-size: 10px; +} + +.explorer-discovering-live { + color: rgba(96, 165, 250, 0.9); + animation: explorer-pulse 1.5s ease-in-out infinite; +} +@keyframes explorer-pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + .explorer-picker-card-lock { position: absolute; top: 6px; @@ -50666,6 +50696,54 @@ tr.tag-diff-same { position: relative; } +/* Status badges on picker cards — inline, not absolute */ +.explorer-picker-card-badge { + font-size: 9px; font-weight: 800; padding: 2px 6px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 5px; flex-shrink: 0; margin-left: auto; line-height: 1; +} +.explorer-picker-card-badge.explored { + background: rgba(74, 222, 128, 0.15); color: #4ade80; + border: 1px solid rgba(74, 222, 128, 0.3); +} +.explorer-picker-card-badge.ready { + background: rgba(251, 191, 36, 0.12); color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.25); font-size: 10px; +} +.explorer-picker-card-badge.needs-discovery { + background: rgba(245, 166, 35, 0.1); color: rgba(245, 166, 35, 0.7); + border: 1px solid rgba(245, 166, 35, 0.2); +} +.explorer-picker-card-badge.wishlisted { + background: rgba(244, 114, 182, 0.15); color: #f472b6; + border: 1px solid rgba(244, 114, 182, 0.3); +} +.explorer-picker-card-badge.downloaded { + background: rgba(96, 165, 250, 0.15); color: #60a5fa; + border: 1px solid rgba(96, 165, 250, 0.3); +} +.explorer-picker-card.explored { + border-color: rgba(74, 222, 128, 0.15); +} +.explorer-picker-card.explored:hover { + border-color: rgba(74, 222, 128, 0.3); +} + +/* Discover button — inline in card info area */ +.explorer-picker-discover-btn { + padding: 3px 8px; border-radius: 5px; font-size: 9px; font-weight: 700; + background: rgba(96, 165, 250, 0.12); color: #60a5fa; + border: 1px solid rgba(96, 165, 250, 0.2); cursor: pointer; + transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px; + margin-left: auto; flex-shrink: 0; +} +.explorer-picker-discover-btn:hover { + background: rgba(96, 165, 250, 0.25); +} +.explorer-picker-discover-btn:disabled { + opacity: 0.5; cursor: default; +} + .explorer-picker-empty { font-size: 13px; color: rgba(255, 255, 255, 0.3);