Add per-track ownership indicators to Library wishlist modal
When clicking a partially-complete album on the Library page, the wishlist modal now shows which tracks are owned (dimmed with checkmark) and which are missing (orange border). Ownership data lazy-loads after the modal opens to avoid blocking the UI, using a batch DB query for speed. Also fixes albums like DAMN. showing "14/15" when all available tracks are owned — the frontend now trusts the backend's "completed" status instead of doing raw track count math against potentially inaccurate Spotify metadata.
This commit is contained in:
parent
f7c929abec
commit
20c7aa33a3
3 changed files with 238 additions and 18 deletions
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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 = `<div class="add-to-wishlist-modal-hero-bg" style="background-image: url('${albumImage}');"></div>`;
|
||||
|
|
@ -10665,7 +10677,7 @@ function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
|
|||
<div class="add-to-wishlist-modal-hero-subtitle">by ${escapeHtml(artist.name || 'Unknown Artist')}</div>
|
||||
<div class="add-to-wishlist-modal-hero-details">
|
||||
<span class="add-to-wishlist-modal-hero-detail">${albumType || 'Album'}</span>
|
||||
<span class="add-to-wishlist-modal-hero-detail">${trackCount} track${trackCount !== 1 ? 's' : ''}</span>
|
||||
<span class="add-to-wishlist-modal-hero-detail">${trackDetailText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 '<div style="text-align: center; padding: 40px; color: rgba(255, 255, 255, 0.6);">No tracks found</div>';
|
||||
}
|
||||
|
|
@ -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
|
||||
? '<div class="wishlist-track-badge owned"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="wishlist-track-item">
|
||||
<div class="wishlist-track-item ${ownershipClass}">
|
||||
<div class="wishlist-track-number">${trackNumber}</div>
|
||||
<div class="wishlist-track-info">
|
||||
<div class="wishlist-track-name">${trackName}</div>
|
||||
<div class="wishlist-track-artists">${artistsString}</div>
|
||||
</div>
|
||||
<div class="wishlist-track-duration">${duration}</div>
|
||||
${badge}
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue