diff --git a/web_server.py b/web_server.py index 15036cfc..b3063966 100644 --- a/web_server.py +++ b/web_server.py @@ -7152,6 +7152,55 @@ def enhanced_search_source(source_name): return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) +@app.route('/api/enhanced-search/library-check', methods=['POST']) +def enhanced_search_library_check(): + """Batch check which albums/tracks from search results exist in the library. + Called async after search results render — doesn't block the search.""" + try: + data = request.get_json() or {} + albums = data.get('albums', []) + tracks = data.get('tracks', []) + + database = get_database() + conn = database._get_connection() + try: + cursor = conn.cursor() + + # Build lookup sets from library — two fast queries + cursor.execute("SELECT LOWER(al.title) || '|||' || LOWER(ar.name) FROM albums al JOIN artists ar ON ar.id = al.artist_id") + owned_albums = {r[0] for r in cursor.fetchall()} + + cursor.execute(""" + SELECT LOWER(t.title) || '|||' || LOWER(a.name), t.id, t.file_path, t.title, a.name, al.title + FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id + """) + owned_tracks = {} + for r in cursor.fetchall(): + if r[0] not in owned_tracks: # Keep first match only + owned_tracks[r[0]] = {'track_id': r[1], 'file_path': r[2], 'title': r[3], 'artist_name': r[4], 'album_title': r[5]} + + # O(1) lookups per item + album_results = [] + for a in albums: + key = (a.get('name', '').lower() + '|||' + a.get('artist', '').split(',')[0].strip().lower()) + album_results.append(key in owned_albums) + + track_results = [] + for t in tracks: + key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower()) + match = owned_tracks.get(key) + if match: + track_results.append({'in_library': True, **match}) + else: + track_results.append({'in_library': False}) + finally: + conn.close() + + return jsonify({'albums': album_results, 'tracks': track_results}) + except Exception as e: + logger.debug(f"Library check error: {e}") + return jsonify({'albums': [], 'tracks': []}) + @app.route('/api/enhanced-search/stream-track', methods=['POST']) def stream_enhanced_search_track(): """ diff --git a/webui/index.html b/webui/index.html index eff1ec99..c9906448 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1939,7 +1939,7 @@ This top-level container replicates the QSplitter from downloads.py, creating the two-panel layout for the page. --> -
+
diff --git a/webui/static/script.js b/webui/static/script.js index b6d1d2e4..b6953b4a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2477,8 +2477,8 @@ function initializeDownloadManagerToggle() { return; } - // Load saved state from localStorage - const isHidden = localStorage.getItem('downloadManagerHidden') === 'true'; + // Load saved state from localStorage (hidden by default for more search space) + const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false'; if (isHidden) { downloadsContent.classList.add('manager-hidden'); } @@ -7856,6 +7856,81 @@ function initializeSearchModeToggle() { // Lazy load artist images that are missing lazyLoadEnhancedSearchArtistImages(); + + // Async library ownership check — doesn't block rendering + _checkSearchResultsLibraryOwnership(data); + } + + async function _checkSearchResultsLibraryOwnership(data) { + try { + const allAlbums = data.spotify_albums || []; + const allTracks = data.spotify_tracks || []; + if (!allAlbums.length && !allTracks.length) return; + + const resp = await fetch('/api/enhanced-search/library-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + albums: allAlbums.map(a => ({ name: a.name, artist: a.artist })), + tracks: allTracks.map(t => ({ name: t.name, artist: t.artist })), + }), + }); + const result = await resp.json(); + + // Tag album cards with staggered animation + const albumCards = document.querySelectorAll('#enh-albums-list .enh-compact-item, #enh-singles-list .enh-compact-item'); + const albumResults = result.albums || []; + let delay = 0; + albumCards.forEach((card, i) => { + if (albumResults[i]) { + setTimeout(() => { + const badge = document.createElement('div'); + badge.className = 'enh-item-lib-badge'; + badge.textContent = 'In Library'; + card.appendChild(badge); + }, delay); + delay += 30; + } + }); + + // Tag track rows + wire up library playback + const trackCards = document.querySelectorAll('#enh-tracks-list .enh-compact-item'); + const trackResults = result.tracks || []; + trackCards.forEach((card, i) => { + const tr = trackResults[i]; + if (tr && tr.in_library) { + setTimeout(() => { + const badge = document.createElement('div'); + badge.className = 'enh-item-lib-badge'; + badge.textContent = 'In Library'; + card.appendChild(badge); + + // Replace stream button to play from library instead of searching + if (tr.file_path) { + const playBtn = card.querySelector('.enh-item-play-btn'); + if (playBtn) { + const newBtn = playBtn.cloneNode(true); + newBtn.title = 'Play from library'; + newBtn.textContent = '▶'; + const trackInfo = tr; + newBtn.addEventListener('click', (e) => { + e.stopPropagation(); + playLibraryTrack( + { id: trackInfo.track_id, title: trackInfo.title, file_path: trackInfo.file_path }, + trackInfo.album_title || '', + trackInfo.artist_name || '' + ); + }); + playBtn.replaceWith(newBtn); + } + } + }, delay); + delay += 30; + } + }); + } catch (e) { + console.debug('Library check failed:', e); + } } async function _fetchAlternateSource(sourceName, query) { diff --git a/webui/static/style.css b/webui/static/style.css index 2a258352..e959805f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -18276,18 +18276,20 @@ body.helper-mode-active #dashboard-activity-feed:hover { position: absolute; inset: 0; background: linear-gradient(0deg, - rgba(0, 0, 0, 0.88) 0%, - rgba(0, 0, 0, 0.35) 40%, - transparent 65%); + rgba(0, 0, 0, 0.95) 0%, + rgba(0, 0, 0, 0.7) 25%, + rgba(0, 0, 0, 0.2) 50%, + transparent 70%); pointer-events: none; z-index: 1; } .album-card:hover::after { background: linear-gradient(0deg, - rgba(0, 0, 0, 0.92) 0%, - rgba(0, 0, 0, 0.3) 45%, - transparent 70%); + rgba(0, 0, 0, 0.95) 0%, + rgba(0, 0, 0, 0.6) 30%, + rgba(0, 0, 0, 0.15) 55%, + transparent 75%); } .album-card-image { @@ -29802,6 +29804,38 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: none; } +/* In Library badge on search results */ +.enh-item-lib-badge { + position: absolute; + top: 6px; + left: 6px; + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + background: rgba(var(--accent-rgb), 0.85); + color: #fff; + padding: 3px 7px; + z-index: 5; + animation: libBadgeFadeIn 0.3s ease; + border-radius: 6px; + backdrop-filter: blur(4px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +/* Track items need relative positioning for the badge */ +@keyframes libBadgeFadeIn { + from { opacity: 0; transform: scale(0.8); } + to { opacity: 1; transform: scale(1); } +} + +.enh-compact-item.track-item .enh-item-lib-badge { + position: static; + flex-shrink: 0; + margin-left: auto; + font-size: 9px; +} + /* Hydrabase badge (dev mode) */ .enh-badge-hydrabase { background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3)); @@ -29928,39 +29962,48 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .enh-compact-item.album-card .enh-item-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; padding: 10px 12px 12px; + z-index: 3; +} + +/* Gradient overlay for text readability on enhanced search album cards */ +.enh-compact-item.album-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(0deg, + rgba(0, 0, 0, 0.9) 0%, + rgba(0, 0, 0, 0.5) 30%, + transparent 60%); + pointer-events: none; + z-index: 2; } .enh-compact-item.album-card .enh-item-name { font-size: 13px; font-weight: 600; color: #fff; - margin: 0 0 3px 0; + margin: 0 0 2px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - display: block; - -webkit-line-clamp: unset; - -webkit-box-orient: unset; - line-height: 1.4; - max-height: none; - letter-spacing: -0.1px; - position: relative; - z-index: 1; + line-height: 1.3; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7); } .enh-compact-item.album-card .enh-item-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.45); + font-size: 11px; + color: rgba(255, 255, 255, 0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - display: block; - -webkit-line-clamp: unset; - -webkit-box-orient: unset; - line-height: 1.4; + line-height: 1.3; margin: 0; - position: relative; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); z-index: 1; }