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:
parent
93fb082172
commit
8b58434c17
4 changed files with 287 additions and 17 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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">✓</div>';
|
||||
} else if (wasExplored) {
|
||||
statusBadge = '<div class="explorer-picker-card-badge explored" title="Already explored">✓</div>';
|
||||
} else if (wishlisted > 0) {
|
||||
statusBadge = '<div class="explorer-picker-card-badge wishlisted" title="Tracks wishlisted">♥</div>';
|
||||
} else if (isFullyDiscovered) {
|
||||
statusBadge = '<div class="explorer-picker-card-badge ready" title="Ready to explore">★</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 · <span class="explorer-picker-discovered">Fully discovered</span>`;
|
||||
} else if (isReady) {
|
||||
metaHTML = `${total} tracks · ${pct}% discovered`;
|
||||
} else {
|
||||
metaHTML = `${total} tracks · <span class="explorer-picker-not-ready">${pct}% discovered</span>`;
|
||||
}
|
||||
if (statusParts.length > 0) {
|
||||
metaHTML += `<br>${statusParts.join(' · ')}`;
|
||||
}
|
||||
|
||||
// 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">♫</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 · ${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">🔒</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">✓</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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue