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})
|
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'])
|
@app.route('/api/enhanced-search/stream-track', methods=['POST'])
|
||||||
def stream_enhanced_search_track():
|
def stream_enhanced_search_track():
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1939,7 +1939,7 @@
|
||||||
This top-level container replicates the QSplitter from downloads.py,
|
This top-level container replicates the QSplitter from downloads.py,
|
||||||
creating the two-panel layout for the page.
|
creating the two-panel layout for the page.
|
||||||
-->
|
-->
|
||||||
<div class="downloads-content">
|
<div class="downloads-content manager-hidden">
|
||||||
|
|
||||||
<!-- ======================================================= -->
|
<!-- ======================================================= -->
|
||||||
<!-- == LEFT PANEL: Search, Filters, and Results == -->
|
<!-- == LEFT PANEL: Search, Filters, and Results == -->
|
||||||
|
|
|
||||||
|
|
@ -2477,8 +2477,8 @@ function initializeDownloadManagerToggle() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved state from localStorage
|
// Load saved state from localStorage (hidden by default for more search space)
|
||||||
const isHidden = localStorage.getItem('downloadManagerHidden') === 'true';
|
const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false';
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
downloadsContent.classList.add('manager-hidden');
|
downloadsContent.classList.add('manager-hidden');
|
||||||
}
|
}
|
||||||
|
|
@ -7856,6 +7856,81 @@ function initializeSearchModeToggle() {
|
||||||
|
|
||||||
// Lazy load artist images that are missing
|
// Lazy load artist images that are missing
|
||||||
lazyLoadEnhancedSearchArtistImages();
|
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) {
|
async function _fetchAlternateSource(sourceName, query) {
|
||||||
|
|
|
||||||
|
|
@ -18276,18 +18276,20 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(0deg,
|
background: linear-gradient(0deg,
|
||||||
rgba(0, 0, 0, 0.88) 0%,
|
rgba(0, 0, 0, 0.95) 0%,
|
||||||
rgba(0, 0, 0, 0.35) 40%,
|
rgba(0, 0, 0, 0.7) 25%,
|
||||||
transparent 65%);
|
rgba(0, 0, 0, 0.2) 50%,
|
||||||
|
transparent 70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card:hover::after {
|
.album-card:hover::after {
|
||||||
background: linear-gradient(0deg,
|
background: linear-gradient(0deg,
|
||||||
rgba(0, 0, 0, 0.92) 0%,
|
rgba(0, 0, 0, 0.95) 0%,
|
||||||
rgba(0, 0, 0, 0.3) 45%,
|
rgba(0, 0, 0, 0.6) 30%,
|
||||||
transparent 70%);
|
rgba(0, 0, 0, 0.15) 55%,
|
||||||
|
transparent 75%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card-image {
|
.album-card-image {
|
||||||
|
|
@ -29802,6 +29804,38 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
display: none;
|
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) */
|
/* Hydrabase badge (dev mode) */
|
||||||
.enh-badge-hydrabase {
|
.enh-badge-hydrabase {
|
||||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3));
|
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 {
|
.enh-compact-item.album-card .enh-item-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
padding: 10px 12px 12px;
|
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 {
|
.enh-compact-item.album-card .enh-item-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin: 0 0 3px 0;
|
margin: 0 0 2px 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: block;
|
line-height: 1.3;
|
||||||
-webkit-line-clamp: unset;
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||||
-webkit-box-orient: unset;
|
|
||||||
line-height: 1.4;
|
|
||||||
max-height: none;
|
|
||||||
letter-spacing: -0.1px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.enh-compact-item.album-card .enh-item-meta {
|
.enh-compact-item.album-card .enh-item-meta {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: block;
|
line-height: 1.3;
|
||||||
-webkit-line-clamp: unset;
|
|
||||||
-webkit-box-orient: unset;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: relative;
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue