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,