diff --git a/database/music_database.py b/database/music_database.py index 11dde045..83ecec0b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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, diff --git a/web_server.py b/web_server.py index e97c3c4d..ed211321 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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//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.""" diff --git a/webui/index.html b/webui/index.html index b6c8a7c3..806a954c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -657,6 +657,26 @@ +
+
+

Discovery Pool

+
+

View and fix matched/failed discovery results across all mirrored playlists

+
+
+ Matched: + +
+
+ Failed: + +
+
+
+ +
+
+

Retag Tool

@@ -1505,6 +1525,7 @@

Mirrored Playlists

+
diff --git a/webui/static/script.js b/webui/static/script.js index 2dc1cab6..0c9a69e7 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 => ``) + .join(''); + + const failedCount = _discoveryPoolData.stats.failed || 0; + const matchedCount = _discoveryPoolData.stats.matched || 0; + + overlay.innerHTML = ` + + `; + + 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 = `
`; + 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 = '
No failed discoveries. All tracks matched successfully.
'; + return; + } + container.innerHTML = tracks.map(t => ` +
+
+
${_esc(t.track_name)}
+
+ ${_esc(t.artist_name)} + ${_esc(t.playlist_name)} +
+
+ +
+ `).join(''); + } else { + const entries = _discoveryPoolData.matched || []; + if (entries.length === 0) { + container.innerHTML = '
No cached discovery matches yet.
'; + 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 ` +
+ ${imgUrl ? `` : '
'} +
+
${_esc(e.original_title)}
+
+ ${_esc(e.original_artist)} + + ${_esc(md.name || '?')} + ${_esc(e.provider)} +
+
+ ${conf}% + ${e.use_count}× + +
+ `; + }).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 = ` +
+
+

Fix Track Match

+ +
+
+
+

Source Track

+
+
${_esc(trackName)}
+
${_esc(artistName)}
+
+
+
+

Search for Match

+
+ + + +
+
+
+

Results

+
+
Searching...
+
+
+
+ +
+ `; + + 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 = '
Enter a search term
'; + return; + } + + resultsContainer.innerHTML = '
Searching...
'; + + 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 = '
No results found
'; + return; + } + + resultsContainer.innerHTML = tracks.map((track, i) => { + const artists = (track.artists || []).join(', '); + const duration = track.duration_ms ? formatDuration(track.duration_ms) : ''; + return ` +
+
+
${_esc(track.name || 'Unknown')}
+
${_esc(artists)}
+
${_esc(track.album || '')}
+ ${duration ? `
${duration}
` : ''} +
+
+ `; + }).join(''); + } catch (err) { + resultsContainer.innerHTML = `
Search failed: ${_esc(err.message)}
`; + } +} + +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 { diff --git a/webui/static/style.css b/webui/static/style.css index b2931404..1980ea1a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;