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);