Explorer improvements: discover from Explorer, status badges, auto-refresh

1. Discover button on undiscovered playlist cards — triggers discovery
   directly from Explorer instead of redirecting to Sync page. Button
   changes to "Open" to reopen modal after closing.

2. Status badges on playlist cards: checkmark (in library), heart
   (wishlisted), star (fully discovered), percentage (needs discovery).
   Meta line shows "N in library · M wishlisted" counts.

3. Auto-refresh: polls every 5s during active discovery to update cards.
   WebSocket listener for discovery:progress events. Cards refresh when
   discovery completes.

4. Explored tracking: playlists get green checkmark badge after tree is
   built (session-only, resets on reload).

Backend: new get_mirrored_playlist_status_counts with fail-safe design —
core discovery counts use simple reliable queries, library/wishlist
counts are best-effort extras that won't break discovery detection.

Card layout redesigned: badges inline with playlist name, discover
button below meta text, no more absolute positioning overlaps.
This commit is contained in:
Broque Thomas 2026-04-03 13:00:04 -07:00
parent 93fb082172
commit 8b58434c17
4 changed files with 287 additions and 17 deletions

View file

@ -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:

View file

@ -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}")

View file

@ -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 = `<span class="explorer-discovering-live">Discovering... ${Math.round(data.progress)}%</span>`;
}
}
}
});
}
}
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 `<button class="explorer-picker-tab ${i === 0 ? 'active' : ''}" data-source="${src}" onclick="explorerSwitchPickerTab('${src}')">${label} <span class="explorer-picker-tab-count">${count}</span></button>`;
const isActive = _explorer._activeSource === src || (!_explorer._activeSource && i === 0);
return `<button class="explorer-picker-tab ${isActive ? 'active' : ''}" data-source="${src}" onclick="explorerSwitchPickerTab('${src}')">${label} <span class="explorer-picker-tab-count">${count}</span></button>`;
}).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 = '<div class="explorer-picker-card-badge downloaded" title="Most tracks in library">&#10003;</div>';
} else if (wasExplored) {
statusBadge = '<div class="explorer-picker-card-badge explored" title="Already explored">&#10003;</div>';
} else if (wishlisted > 0) {
statusBadge = '<div class="explorer-picker-card-badge wishlisted" title="Tracks wishlisted">&#9829;</div>';
} else if (isFullyDiscovered) {
statusBadge = '<div class="explorer-picker-card-badge ready" title="Ready to explore">&#9733;</div>';
} else if (!isReady) {
statusBadge = `<div class="explorer-picker-card-badge needs-discovery" title="Needs discovery (${pct}%)">${pct}%</div>`;
}
// Meta line with status indicators
let metaHTML;
const statusParts = [];
if (inLibrary > 0) statusParts.push(`<span class="explorer-picker-in-library">${inLibrary} in library</span>`);
if (wishlisted > 0) statusParts.push(`<span class="explorer-picker-wishlisted">${wishlisted} wishlisted</span>`);
if (isFullyDiscovered) {
metaHTML = `${total} tracks &middot; <span class="explorer-picker-discovered">Fully discovered</span>`;
} else if (isReady) {
metaHTML = `${total} tracks &middot; ${pct}% discovered`;
} else {
metaHTML = `${total} tracks &middot; <span class="explorer-picker-not-ready">${pct}% discovered</span>`;
}
if (statusParts.length > 0) {
metaHTML += `<br>${statusParts.join(' &middot; ')}`;
}
// Discover button for undiscovered playlists (replaces redirect to Sync)
const discoverBtn = !isReady ? `<button class="explorer-picker-discover-btn" onclick="event.stopPropagation(); explorerStartDiscovery(${p.id})" title="Start discovery">Discover</button>` : '';
return `
<div class="explorer-picker-card ${isActive ? 'active' : ''} ${!isReady ? 'not-ready' : ''}"
<div class="explorer-picker-card ${isActive ? 'active' : ''} ${!isReady ? 'not-ready' : ''} ${wasExplored ? 'explored' : ''}"
data-id="${p.id}"
onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : `explorerRedirectToDiscover(${p.id})`}">
onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : ''}">
<div class="explorer-picker-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="explorer-picker-card-art-placeholder">&#9835;</div>'}
</div>
<div class="explorer-picker-card-info">
<div class="explorer-picker-card-name">${p.name || 'Untitled'}</div>
<div class="explorer-picker-card-meta">${total} tracks &middot; ${isReady ? `${pct}% discovered` : `<span class="explorer-picker-not-ready">${pct}% — needs discovery</span>`}</div>
<div class="explorer-picker-card-name-row">
<div class="explorer-picker-card-name">${p.name || 'Untitled'}</div>
${statusBadge}
</div>
<div class="explorer-picker-card-meta">${metaHTML}</div>
${discoverBtn ? `<div class="explorer-picker-card-actions">${discoverBtn}</div>` : ''}
</div>
${isReady ? '' : '<div class="explorer-picker-card-lock" title="Less than 50% discovered — click to go discover">&#128274;</div>'}
</div>
`;
}).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 = '<div class="explorer-picker-card-badge explored" title="Already explored">&#10003;</div>';
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);

View file

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