From f6709c7cc3c61bbed1dd0c43c5826210dbfb88d8 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:39:19 -0700
Subject: [PATCH] Add library ownership badges to enhanced search results
Search results now show "In Library" badges on albums and tracks
that already exist in the user's library. Badges appear with a
staggered fade-in animation after results render (non-blocking).
- Backend: /api/enhanced-search/library-check endpoint builds
owned album/track sets in 2 queries, O(1) lookups per result
- Frontend: async call after render, 30ms stagger per badge
- Tracks in library get play button rewired for direct playback
from media server instead of searching download sources
- Fixed enhanced search album card text not visible (info div
now absolute-positioned with gradient overlay)
- Download manager panel hidden by default for more search space
---
web_server.py | 49 ++++++++++++++++++++++++
webui/index.html | 2 +-
webui/static/script.js | 79 +++++++++++++++++++++++++++++++++++++-
webui/static/style.css | 87 +++++++++++++++++++++++++++++++-----------
4 files changed, 192 insertions(+), 25 deletions(-)
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;
}