Fix false album completion badges, add multi-artist album matching

Completion accuracy:
- Exact match only: "Complete" requires owned_tracks >= expected_tracks,
  no more 90% rounding that hid missing tracks
- Deduplicate track counting: DISTINCT (title, track_number) prevents
  duplicate album entries from inflating owned count (e.g., 3 "GNX"
  entries with 12+1+2 rows counted as 12 unique tracks, not 15)
- MAX(track_count) instead of SUM for stored count — uses largest
  album entry rather than summing duplicates
- file_path IS NOT NULL filter ensures only real files are counted
- Frontend uses real numbers instead of overriding missing=0 when
  backend says "completed"

Multi-artist albums:
- Title-only fallback search when artist-specific search fails
- Finds "Anger Management" filed under "The Alchemist" when checking
  from Rico Nasty's page
- Same confidence scoring prevents false matches
This commit is contained in:
Broque Thomas 2026-03-27 09:25:08 -07:00
parent a33f891fa6
commit a0b2fa9441
3 changed files with 43 additions and 24 deletions

View file

@ -4991,12 +4991,19 @@ class MusicDatabase:
sibling_ids = [row['id'] for row in cursor.fetchall()]
# Get actual track count across all sibling album entries
# Count DISTINCT titles to deduplicate across split/duplicate album entries
# (e.g., 3 "GNX" albums with 12+1+2 tracks = 15 rows but only 12 unique songs)
placeholders = ','.join('?' for _ in sibling_ids)
cursor.execute(f"SELECT COUNT(*) FROM tracks WHERE album_id IN ({placeholders})", sibling_ids)
cursor.execute(f"""
SELECT COUNT(*) FROM (
SELECT DISTINCT LOWER(title), track_number FROM tracks
WHERE album_id IN ({placeholders}) AND file_path IS NOT NULL AND file_path != ''
)
""", sibling_ids)
owned_tracks = cursor.fetchone()[0]
# Get combined expected track count from all sibling album entries
cursor.execute(f"SELECT SUM(track_count) FROM albums WHERE id IN ({placeholders})", sibling_ids)
# Get the max track_count from sibling albums (not SUM — avoids inflating from duplicates)
cursor.execute(f"SELECT MAX(track_count) FROM albums WHERE id IN ({placeholders})", sibling_ids)
result = cursor.fetchone()
stored_track_count = result[0] if result and result[0] else 0
@ -5008,9 +5015,7 @@ class MusicDatabase:
if (expected_track_count is not None and stored_track_count > 0
and owned_tracks >= stored_track_count
and stored_track_count >= expected_track_count * 0.6):
# Album is complete by its own metadata — don't inflate expected with a different edition's count.
# Guard: stored count must be >=60% of expected to look like a plausible edition variant
# (standard 12 vs deluxe 20 = 60%), not just Plex's leafCount reflecting partial ownership.
# Album is complete by its own metadata — standard vs deluxe edition difference
expected_tracks = stored_track_count
elif expected_track_count is not None:
expected_tracks = expected_track_count
@ -5019,11 +5024,10 @@ class MusicDatabase:
# Determine completeness with refined thresholds
if expected_tracks and expected_tracks > 0:
completion_ratio = owned_tracks / expected_tracks
# Complete: 90%+, Nearly Complete: 80-89%, Partial: <80%
is_complete = completion_ratio >= 0.9 and owned_tracks > 0
# Exact match — complete only when owned == expected
is_complete = owned_tracks >= expected_tracks
else:
# Fallback: if we have any tracks, consider it owned
# No expected count known — complete if we have any tracks
is_complete = owned_tracks > 0
# Get distinct format strings for owned tracks
@ -5167,6 +5171,26 @@ class MusicDatabase:
logger.debug(f"✅ Fallback match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence
# Multi-artist fallback: search by title only (any artist)
# Handles collaborative albums filed under a different artist in the library
if best_confidence < confidence_threshold:
logger.debug(f"⚠️ Artist-specific search failed, trying title-only fallback for '{title}'")
try:
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
for album in title_only_albums:
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
# Slightly penalize cross-artist matches to prefer same-artist when possible
if confidence > best_confidence:
best_confidence = confidence
best_match = album
logger.debug(f" 🎯 Title-only match: '{album.title}' (confidence: {confidence:.3f})")
except Exception as title_error:
logger.warning(f"Title-only fallback search failed: {title_error}")
if best_match and best_confidence >= confidence_threshold:
logger.debug(f"✅ Title-only match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence
logger.debug(f"❌ No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
return None, best_confidence

View file

@ -9736,12 +9736,10 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b
else:
completion_percentage = 100 if owned_tracks > 0 else 0
# Determine completion status based on percentage
if completion_percentage >= 90 and owned_tracks > 0:
# Determine completion status — exact match, no percentage rounding
if owned_tracks > 0 and owned_tracks >= (expected_tracks or total_tracks):
status = "completed"
elif completion_percentage >= 60:
status = "nearly_complete"
elif completion_percentage > 0:
elif owned_tracks > 0:
status = "partial"
else:
status = "missing"
@ -9820,12 +9818,10 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
else:
completion_percentage = (owned_tracks / total_tracks) * 100
# Determine status
if completion_percentage >= 90 and owned_tracks > 0:
# Determine status — exact match, no percentage rounding
if owned_tracks > 0 and owned_tracks >= (expected_tracks or total_tracks):
status = "completed"
elif completion_percentage >= 60:
status = "nearly_complete"
elif completion_percentage > 0:
elif owned_tracks > 0:
status = "partial"
else:
status = "missing"

View file

@ -40110,10 +40110,9 @@ 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);
// Use real numbers — no rounding or overrides
const isComplete = data.owned_tracks >= data.expected_tracks && data.owned_tracks > 0;
const effectiveMissing = data.expected_tracks - data.owned_tracks;
// Update the mutable release data on the card
if (card._releaseData) {