Fix track ownership false positives, wing-it wishlist leak, debug counts

Track ownership: check-tracks endpoint now filters by album context
when provided, preventing false "Found" when a track exists in a
different album by the same artist (e.g. Thriller on HIStory).

Wing-it wishlist: manual "Add to Wishlist" button now skips wing-it
fallback tracks (wing_it_ ID prefix), matching the behavior of
failed download and failed sync paths.

Debug info: watchlist/wishlist/automation counts were always 0
because get_db() doesn't exist — fixed to get_database().
This commit is contained in:
Broque Thomas 2026-04-20 10:17:47 -07:00
parent 2f2e5ddbd0
commit 21ae328955
3 changed files with 63 additions and 24 deletions

View file

@ -5712,21 +5712,21 @@ def get_debug_info():
# Watchlist count
try:
db = get_db()
db = get_database()
info['watchlist_count'] = db.get_watchlist_count()
except Exception:
info['watchlist_count'] = 0
# Wishlist pending count
try:
db = get_db()
db = get_database()
info['wishlist_count'] = db.get_wishlist_count()
except Exception:
info['wishlist_count'] = 0
# Automation count
try:
db = get_db()
db = get_database()
automations = db.get_automations()
info['automations'] = {
'total': len(automations),
@ -12034,6 +12034,36 @@ def library_check_tracks():
# Pre-compute normalized DB titles once (keep reference to db_track for metadata)
db_title_entries = [(_normalize(t.title), _clean_title(t.title), t) for t in db_tracks]
# Album-aware matching: if album_name provided, prefer tracks from that album
target_album = data.get('album_name', '')
target_album_norm = _normalize(target_album) if target_album else ''
def _match_title(search_norm, search_clean, candidates):
"""Find best matching track from a list of (norm, clean, db_track) candidates."""
for db_norm, db_clean, db_track in candidates:
if search_norm == db_norm or search_clean == db_clean:
return db_track
sim = max(
SequenceMatcher(None, search_norm, db_norm).ratio(),
SequenceMatcher(None, search_clean, db_clean).ratio()
)
if sim >= 0.7:
return db_track
return None
# Split DB tracks by album if album-aware matching is active
album_entries = []
other_entries = []
if target_album_norm:
for entry in db_title_entries:
db_album = _normalize(getattr(entry[2], 'album_title', '') or '')
if db_album and SequenceMatcher(None, target_album_norm, db_album).ratio() >= 0.7:
album_entries.append(entry)
else:
other_entries.append(entry)
else:
other_entries = db_title_entries
owned_map = {}
for track in tracks:
track_name = track.get('name', '')
@ -12042,21 +12072,14 @@ def library_check_tracks():
search_norm = _normalize(track_name)
search_clean = _clean_title(track_name)
matched_db_track = None
for db_norm, db_clean, db_track in db_title_entries:
# Check normalized match first (fast path for exact/near-exact)
if search_norm == db_norm or search_clean == db_clean:
matched_db_track = db_track
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:
matched_db_track = db_track
break
# When album context provided, only match within that album —
# prevents false positives where "Thriller" on Album A shows as owned
# because it exists on Album B. Without album context, search all tracks.
if target_album_norm:
matched_db_track = _match_title(search_norm, search_clean, album_entries)
else:
matched_db_track = _match_title(search_norm, search_clean, other_entries)
if matched_db_track:
import os

View file

@ -3603,6 +3603,9 @@ const WHATS_NEW = {
// --- April 19, 2026 ---
{ date: 'April 19, 2026' },
{ title: 'Fix Wishlist Albums Cycle Stuck at 1 Concurrent', desc: 'Auto-wishlist processing during the "albums" cycle was limited to 1 concurrent download even with higher configured settings. The max_concurrent=1 restriction is only needed for Soulseek folder-based album grabs, not individual wishlist track downloads. Albums cycle now uses the configured concurrency like singles' },
{ title: 'Fix Track Ownership False Positives Across Albums', desc: 'Track ownership check on the artist detail page now filters by album context. Previously "Thriller" from Thriller 25 would show as owned on every Michael Jackson album containing a track called Thriller. Now only matches within the specific album being checked' },
{ title: 'Fix Wing It Tracks Added to Wishlist via Button', desc: 'Wing It fallback tracks were skipped from wishlist on failed downloads but not when manually clicking "Add to Wishlist". Now consistently skipped across all paths' },
{ title: 'Fix Debug Info Showing Zero Counts', desc: 'Copy Debug Info button showed 0 for watchlist, wishlist, and automation counts due to calling get_db() instead of get_database(). Silent NameError was caught by try/except' },
{ title: 'Fix Album Track Lookup Hardcoded to Spotify', desc: 'Clicking an album on the Artists page to download tracks was hardcoded to use Spotify even when the user\'s primary metadata source was Deezer or iTunes. Now uses the configured primary source with Spotify as fallback' },
{ title: 'Fix Wishlist Splitting Albums by Track Artist', desc: 'Adding a multi-artist album (like a soundtrack) to wishlist was creating separate entries per track artist instead of keeping all tracks under the album artist. Now uses the album-level artist context when available to keep tracks grouped correctly' },
{ title: 'Fix Artist Search Case Sensitivity', desc: 'Artist search on the Artists page now normalizes all-lowercase queries to title case before hitting metadata APIs. Some APIs return fewer or no results for lowercase queries like "foreigner" vs "Foreigner"' },

View file

@ -20127,16 +20127,18 @@ async function handleAddToWishlist() {
* 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) {
async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName = null) {
const myVersion = wishlistModalVersion;
try {
const checkBody = {
artist_name: artistName,
tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number }))
};
if (albumName) checkBody.album_name = albumName;
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 }))
})
body: JSON.stringify(checkBody)
});
const data = await resp.json();
if (!data.success) return;
@ -20371,8 +20373,18 @@ async function addModalTracksToWishlist(playlistId) {
let errorCount = 0;
// Add each track to wishlist individually
let wingItSkipped = 0;
for (const track of tracks) {
try {
// Skip wing-it fallback tracks — they have no real metadata,
// adding them to wishlist would just retry with raw data
const trackId = track.id || '';
if (String(trackId).startsWith('wing_it_')) {
wingItSkipped++;
console.log(`⏭️ Skipping wing-it track from wishlist: ${track.name}`);
continue;
}
// Format artists field to match backend expectations
let formattedArtists = track.artists;
if (typeof track.artists === 'string') {
@ -20475,9 +20487,10 @@ async function addModalTracksToWishlist(playlistId) {
// Show result toast
if (successCount > 0) {
const message = errorCount > 0
let message = errorCount > 0
? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)`
: `Added ${successCount} tracks to wishlist`;
if (wingItSkipped > 0) message += ` (${wingItSkipped} wing-it skipped)`;
showToast(message, 'success');
// Close the modal on success
@ -45289,7 +45302,7 @@ function createReleaseCard(release) {
await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType);
// Always lazy-load track ownership + metadata (non-blocking)
lazyLoadTrackOwnership(currentArtist.name, data.tracks, card);
lazyLoadTrackOwnership(currentArtist.name, data.tracks, card, albumData.name);
} catch (error) {
hideLoadingOverlay();