Add Discovery Pool dashboard tool card and revamp modal with premium category-card design

This commit is contained in:
Broque Thomas 2026-03-06 11:59:45 -08:00
parent c9d83d5d13
commit 7647ac22ed
5 changed files with 1135 additions and 0 deletions

View file

@ -6080,6 +6080,106 @@ class MusicDatabase:
logger.error(f"Error invalidating sync match cache: {e}")
return 0
# ==================== Discovery Pool Methods ====================
def get_discovery_pool_matched(self, limit: int = 500) -> list:
"""Get all cached discovery matches, ordered by most recently used."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, original_title, original_artist, normalized_title, normalized_artist,
provider, match_confidence, matched_data_json, use_count, last_used_at, created_at
FROM discovery_match_cache
ORDER BY last_used_at DESC
LIMIT ?
""", (limit,))
results = []
for row in cursor.fetchall():
try:
matched_data = json.loads(row['matched_data_json'])
except (json.JSONDecodeError, TypeError):
matched_data = {}
results.append({
'id': row['id'],
'original_title': row['original_title'] or row['normalized_title'],
'original_artist': row['original_artist'] or row['normalized_artist'],
'provider': row['provider'],
'confidence': row['match_confidence'],
'matched_data': matched_data,
'use_count': row['use_count'],
'last_used_at': row['last_used_at'],
'created_at': row['created_at'],
})
return results
except Exception as e:
logger.error(f"Error getting discovery pool matched: {e}")
return []
def get_discovery_pool_failed(self, profile_id: int = None, playlist_id: int = None) -> list:
"""Get all tracks where discovery was attempted but failed."""
try:
conn = self._get_connection()
cursor = conn.cursor()
query = """
SELECT mpt.id, mpt.track_name, mpt.artist_name, mpt.album_name,
mpt.playlist_id, mp.name as playlist_name
FROM mirrored_playlist_tracks mpt
JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
WHERE mpt.extra_data LIKE '%"discovery_attempted": true%'
AND mpt.extra_data NOT LIKE '%"discovered": true%'
"""
params = []
if playlist_id:
query += " AND mpt.playlist_id = ?"
params.append(playlist_id)
elif profile_id:
query += " AND mp.profile_id = ?"
params.append(profile_id)
query += " ORDER BY mp.name, mpt.track_name"
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting discovery pool failed: {e}")
return []
def delete_discovery_cache_entry(self, entry_id: int) -> bool:
"""Delete a single entry from the discovery match cache."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM discovery_match_cache WHERE id = ?", (entry_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error deleting discovery cache entry: {e}")
return False
def get_discovery_pool_stats(self, profile_id: int = None) -> dict:
"""Get counts for matched and failed discovery tracks."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as cnt FROM discovery_match_cache")
matched = cursor.fetchone()['cnt']
query = """
SELECT COUNT(*) as cnt FROM mirrored_playlist_tracks mpt
JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
WHERE mpt.extra_data LIKE '%"discovery_attempted": true%'
AND mpt.extra_data NOT LIKE '%"discovered": true%'
"""
params = []
if profile_id:
query += " AND mp.profile_id = ?"
params.append(profile_id)
cursor.execute(query, params)
failed = cursor.fetchone()['cnt']
return {'matched': matched, 'failed': failed}
except Exception as e:
logger.error(f"Error getting discovery pool stats: {e}")
return {'matched': 0, 'failed': 0}
# ==================== Retag Tool Methods ====================
def add_retag_group(self, group_type: str, artist_name: str, album_name: str,

View file

@ -29138,6 +29138,108 @@ def clear_mirrored_discovery_endpoint(playlist_id):
logger.error(f"Error clearing mirrored discovery: {e}")
return jsonify({"error": str(e)}), 500
# ==================== Discovery Pool ====================
@app.route('/api/discovery-pool', methods=['GET'])
def get_discovery_pool():
"""List matched and failed discovery tracks, optionally filtered by playlist."""
try:
database = get_database()
profile_id = get_current_profile_id()
playlist_id = request.args.get('playlist_id', type=int)
matched = database.get_discovery_pool_matched()
failed = database.get_discovery_pool_failed(profile_id=profile_id, playlist_id=playlist_id)
stats = database.get_discovery_pool_stats(profile_id=profile_id)
# Playlist list for the filter dropdown
playlists = database.get_mirrored_playlists(profile_id=profile_id)
playlist_options = [{'id': p['id'], 'name': p['name']} for p in playlists]
return jsonify({
'matched': matched,
'failed': failed,
'stats': stats,
'playlists': playlist_options,
})
except Exception as e:
logger.error(f"Error getting discovery pool: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/discovery-pool/fix', methods=['POST'])
def fix_discovery_pool_track():
"""Manually fix a failed discovery by linking a mirrored track to a Spotify/iTunes result."""
try:
data = request.get_json()
track_id = data.get('track_id')
spotify_track = data.get('spotify_track')
if not track_id or not spotify_track:
return jsonify({"error": "track_id and spotify_track required"}), 400
database = get_database()
# Build matched_data in the same format as the discovery flow
artists = spotify_track.get('artists', [])
album_raw = spotify_track.get('album', '')
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
image_url = spotify_track.get('image_url', '')
if not image_url and isinstance(album_raw, dict):
images = album_raw.get('images', [])
image_url = images[0].get('url', '') if images else ''
matched_data = {
'id': spotify_track.get('id', ''),
'name': spotify_track.get('name', ''),
'artists': [{'name': a} if isinstance(a, str) else a for a in artists],
'album': album_obj,
'duration_ms': spotify_track.get('duration_ms', 0),
'image_url': image_url,
'source': 'spotify',
}
# Update the mirrored track's extra_data
extra_data = {
'discovered': True,
'provider': 'spotify',
'confidence': 1.0,
'matched_data': matched_data,
'manual_match': True,
}
database.update_mirrored_track_extra_data(track_id, extra_data)
# Also save to discovery cache so future discoveries hit the cache
# Need to get the track's original name/artist for the cache key
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT track_name, artist_name FROM mirrored_playlist_tracks WHERE id = ?", (track_id,))
row = cursor.fetchone()
if row:
cache_key = _get_discovery_cache_key(row['track_name'], row['artist_name'])
database.save_discovery_cache_match(
cache_key[0], cache_key[1], 'spotify', 1.0, matched_data,
row['track_name'], row['artist_name']
)
except Exception:
pass
return jsonify({"success": True})
except Exception as e:
logger.error(f"Error fixing discovery pool track: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/discovery-pool/cache/<int:entry_id>', methods=['DELETE'])
def delete_discovery_pool_cache_entry(entry_id):
"""Remove a single entry from the discovery match cache."""
try:
database = get_database()
if database.delete_discovery_cache_entry(entry_id):
return jsonify({"success": True})
return jsonify({"error": "Entry not found"}), 404
except Exception as e:
logger.error(f"Error deleting discovery cache entry: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/<int:playlist_id>/prepare-discovery', methods=['POST'])
def prepare_mirrored_discovery(playlist_id):
"""Register a mirrored playlist into youtube_playlist_states so the YouTube discovery pipeline can run."""

View file

@ -657,6 +657,26 @@
</div>
</div>
<div class="tool-card" id="discovery-pool-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Discovery Pool</h4>
</div>
<p class="tool-card-info">View and fix matched/failed discovery results across all mirrored playlists</p>
<div class="tool-card-stats" id="discovery-pool-stats">
<div class="stat-item">
<span class="stat-item-label">Matched:</span>
<span class="stat-item-value" id="discovery-pool-matched-count">&mdash;</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Failed:</span>
<span class="stat-item-value" id="discovery-pool-failed-count" style="background-color: rgba(239, 68, 68, 0.15); color: #ef4444;">&mdash;</span>
</div>
</div>
<div class="tool-card-controls">
<button onclick="openDiscoveryPoolModal()">Open Discovery Pool</button>
</div>
</div>
<div class="tool-card" id="retag-tool-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Retag Tool</h4>
@ -1505,6 +1525,7 @@
<div class="sync-tab-content" id="mirrored-tab-content">
<div class="playlist-header">
<h3>Mirrored Playlists</h3>
<button class="pool-trigger-btn" onclick="openDiscoveryPoolModal()" title="View matched and failed discovery tracks">Discovery Pool</button>
<button class="refresh-button mirrored" id="mirrored-refresh-btn">Refresh</button>
</div>
<div class="playlist-scroll-container" id="mirrored-playlist-container">

View file

@ -16335,6 +16335,9 @@ async function loadDashboardData() {
stopDbStatsPolling(); // Ensure no duplicates
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 30000);
// Initial load of discovery pool stats for the tool card
loadDiscoveryPoolStats();
// Initial load of wishlist count
await updateWishlistCount();
@ -42065,6 +42068,433 @@ async function clearMirroredDiscovery(playlistId, name) {
}
}
// ==================== 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()">&times;</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">&#9888;</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">&#10003;</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()">&larr; Back</button>
<span class="pool-list-title" id="pool-list-title"></span>
</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();
}
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';
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;
if (_discoveryPoolView === 'failed') {
const tracks = _discoveryPoolData.failed || [];
if (tracks.length === 0) {
container.innerHTML = '<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}, '${_escAttr(t.track_name)}', '${_escAttr(t.artist_name)}')">Fix Match</button>
</div>
`).join('');
} else {
const entries = _discoveryPoolData.matched || [];
if (entries.length === 0) {
container.innerHTML = '<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 imgUrl = md.image_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">&rarr;</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}&times;</span>
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">&times;</button>
</div>
`;
}).join('');
}
}
async function removePoolCacheEntry(entryId) {
if (!confirm('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';
fixOverlay.onclick = (e) => { if (e.target === fixOverlay) closePoolFixModal(); };
fixOverlay.innerHTML = `
<div class="pool-fix-modal">
<div class="discovery-fix-modal-header">
<h2>Fix Track Match</h2>
<button class="modal-close-btn" onclick="closePoolFixModal()"></button>
</div>
<div class="discovery-fix-modal-content">
<div class="source-track-info">
<h3>Source Track</h3>
<div class="source-track-display">
<div class="source-field"><label>Track:</label><span>${_esc(trackName)}</span></div>
<div class="source-field"><label>Artist:</label><span>${_esc(artistName)}</span></div>
</div>
</div>
<div class="search-inputs-section">
<h3>Search for Match</h3>
<div class="search-input-group">
<input type="text" id="pool-fix-track-input" placeholder="Track name" class="fix-modal-input" value="${_escAttr(trackName)}">
<input type="text" id="pool-fix-artist-input" placeholder="Artist name" class="fix-modal-input" value="${_escAttr(artistName)}">
<button class="search-btn" onclick="searchPoolFix()">Search</button>
</div>
</div>
<div class="search-results-section">
<h3>Results</h3>
<div id="pool-fix-results" class="fix-modal-results">
<div class="pool-empty">Searching...</div>
</div>
</div>
</div>
<div class="discovery-fix-modal-footer">
<button class="modal-btn secondary" 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);
// Auto-search
setTimeout(() => searchPoolFix(), 100);
}
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 query = `${artistInput.value.trim()} ${trackInput.value.trim()}`.trim();
if (!query) {
resultsContainer.innerHTML = '<div class="pool-empty">Enter a search term</div>';
return;
}
resultsContainer.innerHTML = '<div class="pool-empty">Searching...</div>';
try {
const res = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`);
const data = await res.json();
const tracks = data.tracks || [];
if (tracks.length === 0) {
resultsContainer.innerHTML = '<div class="pool-empty">No results found</div>';
return;
}
resultsContainer.innerHTML = tracks.map((track, i) => {
const artists = (track.artists || []).join(', ');
const duration = track.duration_ms ? formatDuration(track.duration_ms) : '';
return `
<div class="fix-result-card" onclick='selectPoolFixTrack(${JSON.stringify(track).replace(/'/g, "&#39;")})'>
<div class="fix-result-card-content">
<div class="fix-result-title">${_esc(track.name || 'Unknown')}</div>
<div class="fix-result-artist">${_esc(artists)}</div>
<div class="fix-result-album">${_esc(track.album || '')}</div>
${duration ? `<div class="fix-result-duration">${duration}</div>` : ''}
</div>
</div>
`;
}).join('');
} catch (err) {
resultsContainer.innerHTML = `<div class="pool-empty">Search failed: ${_esc(err.message)}</div>`;
}
}
async function selectPoolFixTrack(track) {
const fixOverlay = document.getElementById('pool-fix-overlay');
if (!fixOverlay) return;
const trackId = parseInt(fixOverlay.dataset.trackId);
try {
const res = await fetch('/api/discovery-pool/fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: trackId,
spotify_track: track,
}),
});
const data = await res.json();
if (data.success) {
showToast(`Matched: ${track.name}`, 'success');
closePoolFixModal();
// Refresh pool data
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
} else {
showToast(data.error || 'Failed to fix track', 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function deleteMirroredPlaylist(playlistId, name) {
if (!confirm(`Delete mirrored playlist "${name}"?`)) return;
try {

View file

@ -6010,6 +6010,488 @@ body {
color: #22c55e;
}
/* Discovery Pool trigger button (secondary - dashboard card is primary entry) */
.pool-trigger-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.5);
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.pool-trigger-btn:hover {
background: rgba(var(--accent-rgb), 0.1);
border-color: rgba(var(--accent-rgb), 0.2);
color: rgba(255, 255, 255, 0.8);
}
/* ===== Discovery Pool — Premium Modal ===== */
/* Playlist filter in header */
.pool-playlist-filter {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
padding: 5px 10px;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
outline: none;
}
.pool-playlist-filter:focus {
border-color: rgba(var(--accent-rgb), 0.4);
}
.pool-playlist-filter option {
background: #1a1a1a;
color: #fff;
}
/* Header failed highlight */
.pool-header-failed-highlight {
color: #ef4444 !important;
background: rgba(239, 68, 68, 0.12) !important;
border-radius: 12px;
padding: 4px 10px !important;
}
/* ===== Category Grid ===== */
.pool-category-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
padding: 28px;
}
.pool-category-card {
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.95) 0%,
rgba(18, 18, 18, 0.98) 100%);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 32px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
min-height: 250px;
display: flex;
align-items: center;
justify-content: center;
}
.pool-category-card .pool-category-top-bar {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
opacity: 0;
transition: opacity 0.3s ease;
}
.pool-category-card .pool-category-top-bar.failed {
background: linear-gradient(90deg, #ef4444, #f97316);
}
.pool-category-card .pool-category-top-bar.matched {
background: linear-gradient(90deg, #22c55e, rgb(var(--accent-light-rgb)));
}
.pool-category-card:hover .pool-category-top-bar {
opacity: 1;
}
/* Failed card accent */
.pool-category-card.failed {
border-color: rgba(239, 68, 68, 0.12);
}
.pool-category-card.failed:hover {
border-color: rgba(239, 68, 68, 0.35);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.2);
}
/* Matched card accent */
.pool-category-card.matched {
border-color: rgba(34, 197, 94, 0.12);
}
.pool-category-card.matched:hover {
border-color: rgba(34, 197, 94, 0.35);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(34, 197, 94, 0.2);
}
/* Fallback gradient backgrounds */
.pool-category-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0;
animation: fadeInRow 1s ease-out forwards, gradientShift 8s ease infinite;
}
.pool-category-fallback.failed {
background: linear-gradient(135deg,
rgba(239, 68, 68, 0.25) 0%,
rgba(249, 115, 22, 0.2) 50%,
rgba(239, 68, 68, 0.15) 100%);
background-size: 200% 200%;
}
.pool-category-fallback.matched {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.25) 0%,
rgba(var(--accent-rgb), 0.2) 50%,
rgba(34, 197, 94, 0.15) 100%);
background-size: 200% 200%;
}
/* Content overlay for text readability */
.pool-category-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.82) 0%,
rgba(0, 0, 0, 0.72) 100%);
z-index: 1;
pointer-events: none;
}
.pool-category-content {
position: relative;
z-index: 2;
width: 100%;
}
.pool-category-icon {
font-size: 48px;
margin-bottom: 16px;
}
.pool-category-card.failed .pool-category-icon {
filter: drop-shadow(0 4px 12px rgba(239, 68, 68, 0.4));
color: #ef4444;
}
.pool-category-card.matched .pool-category-icon {
filter: drop-shadow(0 4px 12px rgba(34, 197, 94, 0.4));
color: #22c55e;
}
.pool-category-count {
font-size: 36px;
font-weight: 700;
margin: 8px 0;
}
.pool-category-count.failed {
color: #ef4444;
}
.pool-category-count.matched {
color: #22c55e;
}
.pool-category-label {
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
}
/* Matched card mosaic hover */
.pool-category-card.matched:hover .wishlist-mosaic-image {
transform: scale(1.1);
}
.pool-category-card.matched:hover .pool-category-overlay {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.12) 0%,
rgba(0, 0, 0, 0.78) 100%);
}
.pool-category-card.failed:hover .pool-category-overlay {
background: linear-gradient(135deg,
rgba(239, 68, 68, 0.12) 0%,
rgba(0, 0, 0, 0.78) 100%);
}
/* ===== List View ===== */
.pool-list-view {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 28px;
}
.pool-list-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 8px;
position: sticky;
top: 0;
z-index: 3;
}
.pool-back-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #e0e0e0;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.pool-back-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
transform: translateX(-2px);
}
.pool-list-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
flex: 1;
}
.pool-list-content {
flex: 1;
overflow-y: auto;
padding-right: 8px;
max-height: 55vh;
}
.pool-list-content::-webkit-scrollbar {
width: 8px;
}
.pool-list-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.pool-list-content::-webkit-scrollbar-thumb {
background: rgba(var(--accent-rgb), 0.4);
border-radius: 4px;
}
/* ===== Track Rows (Premium) ===== */
.pool-empty {
text-align: center;
color: rgba(255, 255, 255, 0.3);
padding: 40px 20px;
font-size: 14px;
}
.pool-track-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
transition: background 0.15s ease;
}
.pool-track-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.pool-track-row:last-child {
border-bottom: none;
}
.pool-track-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.pool-track-name {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pool-track-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
.pool-track-artist {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.pool-track-playlist-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(var(--accent-rgb), 0.8);
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
padding: 2px 8px;
border-radius: 10px;
}
.pool-track-arrow {
color: rgba(var(--accent-rgb), 0.5);
font-size: 12px;
}
.pool-match-name {
font-size: 12px;
color: rgb(var(--accent-light-rgb));
font-weight: 500;
}
.pool-match-provider {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.06);
padding: 2px 6px;
border-radius: 4px;
}
/* Album art thumbnail */
.pool-match-image {
width: 40px;
height: 40px;
border-radius: 6px;
flex-shrink: 0;
object-fit: cover;
}
.pool-match-image-placeholder {
width: 40px;
height: 40px;
border-radius: 6px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* Confidence badge */
.pool-confidence-badge {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 8px;
flex-shrink: 0;
}
.pool-confidence-badge.high {
color: #22c55e;
background: rgba(34, 197, 94, 0.12);
}
.pool-confidence-badge.mid {
color: #eab308;
background: rgba(234, 179, 8, 0.12);
}
.pool-confidence-badge.low {
color: #ef4444;
background: rgba(239, 68, 68, 0.12);
}
/* Use count */
.pool-use-count {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
flex-shrink: 0;
min-width: 30px;
text-align: right;
}
/* Fix button in list */
.pool-fix-btn {
flex-shrink: 0;
font-size: 12px !important;
padding: 6px 14px !important;
}
/* Remove button */
.pool-remove-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.3);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.pool-remove-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
}
/* ===== Fix Sub-Modal (kept) ===== */
.pool-fix-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.pool-fix-modal {
background: linear-gradient(135deg, rgba(26, 26, 26, 0.98) 0%, rgba(18, 18, 18, 0.99) 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: slideUp 0.25s ease;
}
/* Mirrored playlist track modal */
.mirrored-modal-overlay {
position: fixed;