diff --git a/web_server.py b/web_server.py
index cb1586e9..40b3525e 100644
--- a/web_server.py
+++ b/web_server.py
@@ -5849,6 +5849,93 @@ def library_completion_stream():
}
)
+@app.route('/api/library/check-tracks', methods=['POST'])
+def library_check_tracks():
+ """Check which tracks from a list are already owned in the library.
+ Uses a single batch DB query + in-memory fuzzy matching for speed."""
+ try:
+ data = request.get_json()
+ if not data or 'artist_name' not in data or 'tracks' not in data:
+ return jsonify({"success": False, "error": "Missing artist_name or tracks"}), 400
+
+ artist_name = data['artist_name']
+ tracks = data['tracks']
+
+ from database.music_database import MusicDatabase
+ db = MusicDatabase()
+ active_server = config_manager.get_active_media_server()
+
+ # Single query: get ALL tracks by this artist from the DB
+ db_tracks = db.search_tracks(artist=artist_name, limit=500, server_source=active_server)
+
+ if not db_tracks:
+ # No tracks by this artist in DB — none owned
+ owned_map = {t.get('name', ''): False for t in tracks if t.get('name')}
+ return jsonify({"success": True, "owned_tracks": owned_map})
+
+ # Pre-normalize all DB track titles for fast in-memory comparison
+ from difflib import SequenceMatcher
+ try:
+ from unidecode import unidecode
+ except ImportError:
+ unidecode = lambda x: x
+
+ def _normalize(text):
+ if not text:
+ return ""
+ return unidecode(text).lower().strip()
+
+ def _clean_title(text):
+ import re
+ cleaned = _normalize(text)
+ # Remove parenthetical/bracket content, dashes, feat/ft, remaster tags
+ cleaned = re.sub(r'\s*[\[\(].*?[\]\)]', '', cleaned)
+ cleaned = re.sub(r'\s*-\s*', ' ', cleaned)
+ cleaned = re.sub(r'\s*feat\..*', '', cleaned)
+ cleaned = re.sub(r'\s*featuring.*', '', cleaned)
+ cleaned = re.sub(r'\s*ft\..*', '', cleaned)
+ cleaned = re.sub(r'\s*\d{4}\s*remaster.*', '', cleaned)
+ cleaned = re.sub(r'\s*remaster(ed)?.*', '', cleaned)
+ cleaned = re.sub(r'\s+', ' ', cleaned).strip()
+ return cleaned
+
+ # Pre-compute normalized DB titles once
+ db_title_pairs = [(_normalize(t.title), _clean_title(t.title)) for t in db_tracks]
+
+ owned_map = {}
+ for track in tracks:
+ track_name = track.get('name', '')
+ if not track_name:
+ continue
+
+ search_norm = _normalize(track_name)
+ search_clean = _clean_title(track_name)
+ is_owned = False
+
+ for db_norm, db_clean in db_title_pairs:
+ # Check normalized match first (fast path for exact/near-exact)
+ if search_norm == db_norm or search_clean == db_clean:
+ is_owned = True
+ break
+ # Fuzzy match: try both normalized and cleaned
+ sim = max(
+ SequenceMatcher(None, search_norm, db_norm).ratio(),
+ SequenceMatcher(None, search_clean, db_clean).ratio()
+ )
+ if sim >= 0.7:
+ is_owned = True
+ break
+
+ owned_map[track_name] = is_owned
+
+ return jsonify({"success": True, "owned_tracks": owned_map})
+
+ except Exception as e:
+ print(f"❌ Error checking track ownership: {e}")
+ import traceback
+ traceback.print_exc()
+ return jsonify({"success": False, "error": str(e)}), 500
+
@app.route('/api/stream/start', methods=['POST'])
def stream_start():
"""Start streaming a track in the background"""
diff --git a/webui/static/script.js b/webui/static/script.js
index 51bf4403..a1e7d293 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -10579,6 +10579,7 @@ function getSuccessfulDownloadCount(process) {
// ===============================
let currentWishlistModalData = null;
+let wishlistModalVersion = 0;
/**
* Open the Add to Wishlist modal for an album/EP/single
@@ -10587,7 +10588,8 @@ let currentWishlistModalData = null;
* @param {Array} tracks - Array of track objects
* @param {string} albumType - Type of release (album, EP, single)
*/
-async function openAddToWishlistModal(album, artist, tracks, albumType) {
+async function openAddToWishlistModal(album, artist, tracks, albumType, trackOwnership) {
+ wishlistModalVersion++;
showLoadingOverlay('Preparing wishlist...');
console.log(`🎵 Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`);
@@ -10609,14 +10611,14 @@ async function openAddToWishlistModal(album, artist, tracks, albumType) {
}
// Generate and populate hero section
- const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType);
+ const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership);
const heroContainer = document.getElementById('add-to-wishlist-modal-hero');
if (heroContainer) {
heroContainer.innerHTML = heroContent;
}
// Generate and populate track list
- const trackListHTML = generateWishlistTrackList(tracks);
+ const trackListHTML = generateWishlistTrackList(tracks, trackOwnership);
const trackListContainer = document.getElementById('wishlist-track-list');
if (trackListContainer) {
trackListContainer.innerHTML = trackListHTML;
@@ -10644,11 +10646,21 @@ async function openAddToWishlistModal(album, artist, tracks, albumType) {
/**
* Generate the hero section HTML for the wishlist modal
*/
-function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
+function generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership) {
const artistImage = artist.image_url || '';
const albumImage = album.image_url || '';
const trackCount = tracks.length;
+ // Calculate missing tracks if ownership info is available
+ let trackDetailText = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
+ if (trackOwnership) {
+ const ownedCount = Object.values(trackOwnership).filter(v => v === true).length;
+ const missingCount = trackCount - ownedCount;
+ if (missingCount > 0 && ownedCount > 0) {
+ trackDetailText = `${missingCount} of ${trackCount} tracks missing`;
+ }
+ }
+
let heroBackgroundImage = '';
if (albumImage) {
heroBackgroundImage = `
`;
@@ -10665,7 +10677,7 @@ function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
by ${escapeHtml(artist.name || 'Unknown Artist')}
${albumType || 'Album'}
- ${trackCount} track${trackCount !== 1 ? 's' : ''}
+ ${trackDetailText}
@@ -10680,7 +10692,7 @@ function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
/**
* Generate the track list HTML for the wishlist modal
*/
-function generateWishlistTrackList(tracks) {
+function generateWishlistTrackList(tracks, trackOwnership) {
if (!tracks || tracks.length === 0) {
return 'No tracks found
';
}
@@ -10691,14 +10703,21 @@ function generateWishlistTrackList(tracks) {
const artistsString = formatArtists(track.artists) || 'Unknown Artist';
const duration = formatDuration(track.duration_ms);
+ const isOwned = trackOwnership ? trackOwnership[track.name] === true : null;
+ const ownershipClass = isOwned === true ? 'owned' : (isOwned === false ? 'missing' : '');
+ const badge = isOwned === true
+ ? ''
+ : '';
+
return `
-
+
${trackNumber}
${trackName}
${artistsString}
${duration}
+ ${badge}
`;
}).join('');
@@ -10843,6 +10862,84 @@ async function handleAddToWishlist() {
}
}
+/**
+ * Lazy-load per-track ownership indicators into an already-open wishlist modal.
+ * Fetches ownership from the backend, then updates the modal DOM in-place.
+ * If all tracks are owned (Spotify metadata discrepancy), also fixes the source card.
+ */
+async function lazyLoadTrackOwnership(artistName, tracks, sourceCard) {
+ const myVersion = wishlistModalVersion;
+ try {
+ const resp = await fetch('/api/library/check-tracks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ artist_name: artistName,
+ tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number }))
+ })
+ });
+ const data = await resp.json();
+ if (!data.success) return;
+
+ // Guard against stale updates if user reopened modal for a different album
+ if (myVersion !== wishlistModalVersion) return;
+
+ const ownership = data.owned_tracks;
+ const trackItems = document.querySelectorAll('#wishlist-track-list .wishlist-track-item');
+
+ let ownedCount = 0;
+ trackItems.forEach((item, index) => {
+ const track = tracks[index];
+ if (!track) return;
+ const isOwned = ownership[track.name] === true;
+ if (isOwned) {
+ ownedCount++;
+ item.classList.add('owned');
+ const badge = document.createElement('div');
+ badge.className = 'wishlist-track-badge owned';
+ badge.innerHTML = '
';
+ item.appendChild(badge);
+ } else {
+ item.classList.add('missing');
+ }
+ });
+
+ // Update hero subtitle with missing count
+ const missingCount = tracks.length - ownedCount;
+ const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail');
+ const trackDetailEl = heroDetails.length > 1 ? heroDetails[heroDetails.length - 1] : null;
+ if (trackDetailEl && missingCount > 0 && ownedCount > 0) {
+ trackDetailEl.textContent = `${missingCount} of ${tracks.length} tracks missing`;
+ }
+
+ // If ALL returned tracks are owned, this is a Spotify metadata discrepancy
+ // (e.g. total_tracks says 15 but API only returns 14, and all 14 are owned)
+ // Fix the source card to show complete
+ if (missingCount === 0 && sourceCard && sourceCard._releaseData) {
+ sourceCard._releaseData.track_completion = {
+ owned_tracks: ownedCount,
+ total_tracks: tracks.length,
+ percentage: 100,
+ missing_tracks: 0
+ };
+ const completionText = sourceCard.querySelector('.completion-text');
+ if (completionText) {
+ completionText.textContent = `Complete (${ownedCount})`;
+ completionText.className = 'completion-text complete';
+ completionText.title = '';
+ }
+ const completionFill = sourceCard.querySelector('.completion-fill');
+ if (completionFill) {
+ completionFill.style.width = '100%';
+ completionFill.classList.remove('partial');
+ completionFill.classList.add('complete');
+ }
+ }
+ } catch (e) {
+ console.warn('Could not load track ownership:', e);
+ }
+}
+
/**
* Close the Add to Wishlist modal
*/
@@ -26012,11 +26109,15 @@ function createReleaseCard(release) {
// Use the actual album type from release data
const albumType = rel.album_type || rel.type || 'album';
- // Open the Add to Wishlist modal
- // Note: openAddToWishlistModal has its own loading overlay
+ // Open the Add to Wishlist modal immediately (no waiting for ownership check)
hideLoadingOverlay();
await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType);
+ // Lazy-load per-track ownership for partial albums (non-blocking)
+ if (rel.track_completion && typeof rel.track_completion === 'object' && rel.track_completion.missing_tracks > 0) {
+ lazyLoadTrackOwnership(currentArtist.name, data.tracks, card);
+ }
+
} catch (error) {
hideLoadingOverlay();
console.error('❌ Error handling release click:', error);
@@ -26150,15 +26251,20 @@ function updateLibraryReleaseCard(data) {
card.classList.add('missing');
}
+ // If backend says "completed" (>=90%), trust it — Spotify metadata track counts
+ // can be wrong (e.g. total_tracks=15 but API only returns 14 actual tracks)
+ const isComplete = data.status === 'completed';
+ const effectiveMissing = isComplete ? 0 : (data.expected_tracks - data.owned_tracks);
+
// Update the mutable release data on the card
if (card._releaseData) {
card._releaseData.owned = isOwned;
if (isOwned && data.expected_tracks > 0) {
card._releaseData.track_completion = {
owned_tracks: data.owned_tracks,
- total_tracks: data.expected_tracks,
- percentage: data.completion_percentage,
- missing_tracks: data.expected_tracks - data.owned_tracks
+ total_tracks: isComplete ? data.owned_tracks : data.expected_tracks,
+ percentage: isComplete ? 100 : data.completion_percentage,
+ missing_tracks: effectiveMissing
};
} else if (isOwned) {
card._releaseData.track_completion = {
@@ -26177,14 +26283,13 @@ function updateLibraryReleaseCard(data) {
if (completionText) {
completionText.classList.remove('checking', 'complete', 'partial', 'missing');
if (isOwned) {
- const missing = data.expected_tracks - data.owned_tracks;
- if (missing <= 0) {
+ if (effectiveMissing <= 0) {
completionText.textContent = `Complete (${data.owned_tracks})`;
completionText.className = 'completion-text complete';
} else {
completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`;
completionText.className = 'completion-text partial';
- completionText.title = `Missing ${missing} track${missing !== 1 ? 's' : ''}`;
+ completionText.title = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`;
}
} else {
completionText.textContent = 'Missing';
@@ -26197,10 +26302,9 @@ function updateLibraryReleaseCard(data) {
if (completionFill) {
completionFill.classList.remove('checking', 'complete', 'partial', 'missing');
if (isOwned) {
- const pct = data.completion_percentage || 100;
+ const pct = isComplete ? 100 : (data.completion_percentage || 100);
completionFill.style.width = `${pct}%`;
- const missing = data.expected_tracks - data.owned_tracks;
- completionFill.classList.add(missing <= 0 ? 'complete' : 'partial');
+ completionFill.classList.add(effectiveMissing <= 0 ? 'complete' : 'partial');
} else {
completionFill.style.width = '0%';
completionFill.classList.add('missing');
diff --git a/webui/static/style.css b/webui/static/style.css
index a9e0da15..d63f1491 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -13946,6 +13946,35 @@ body {
text-align: right;
}
+/* Track ownership indicators */
+.wishlist-track-item.owned {
+ opacity: 0.5;
+ border-left: 3px solid rgba(29, 185, 84, 0.6);
+}
+
+.wishlist-track-item.owned:hover {
+ opacity: 0.7;
+}
+
+.wishlist-track-item.missing {
+ border-left: 3px solid rgba(255, 152, 0, 0.6);
+}
+
+.wishlist-track-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ font-size: 12px;
+ flex-shrink: 0;
+}
+
+.wishlist-track-badge.owned {
+ color: rgba(29, 185, 84, 0.9);
+}
+
/* Modal Footer */
.add-to-wishlist-modal-footer {
background: linear-gradient(135deg,