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
This commit is contained in:
Broque Thomas 2026-03-25 19:39:19 -07:00
parent 6c3b9ddfc2
commit f6709c7cc3
4 changed files with 192 additions and 25 deletions

View file

@ -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():
"""

View file

@ -1939,7 +1939,7 @@
This top-level container replicates the QSplitter from downloads.py,
creating the two-panel layout for the page.
-->
<div class="downloads-content">
<div class="downloads-content manager-hidden">
<!-- ======================================================= -->
<!-- == LEFT PANEL: Search, Filters, and Results == -->

View file

@ -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) {

View file

@ -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;
}