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:
parent
6c3b9ddfc2
commit
f6709c7cc3
4 changed files with 192 additions and 25 deletions
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 == -->
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue