better matching on albums

This commit is contained in:
Broque Thomas 2025-08-06 11:31:35 -07:00
parent 78d037cba4
commit 019253087f
6 changed files with 447 additions and 123 deletions

View file

@ -2,7 +2,9 @@
import sqlite3
import json
import logging
import os
import re
import threading
from datetime import datetime
from typing import List, Optional, Dict, Any, Tuple
@ -11,6 +13,8 @@ from pathlib import Path
from utils.logging_config import get_logger
logger = get_logger("music_database")
# Temporarily enable debug logging for edition matching
logger.setLevel(logging.DEBUG)
@dataclass
class DatabaseArtist:
@ -578,38 +582,46 @@ class MusicDatabase:
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseTrack], float]:
"""
Check if a track exists in the database with fuzzy matching and confidence scoring.
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
Now uses the same sophisticated matching approach as album checking for consistency.
Returns (track, confidence) tuple where confidence is 0.0-1.0
"""
try:
# Search for potential matches
potential_matches = self.search_tracks(title=title, artist=artist, limit=20)
# Generate title variations for better matching (similar to album approach)
title_variations = self._generate_track_title_variations(title)
if not potential_matches:
return None, 0.0
logger.debug(f"🔍 Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
for i, var in enumerate(title_variations):
logger.debug(f" {i+1}. '{var}'")
# Simple confidence scoring based on string similarity
def calculate_confidence(db_track: DatabaseTrack) -> float:
title_similarity = self._string_similarity(title.lower().strip(), db_track.title.lower().strip())
artist_similarity = self._string_similarity(artist.lower().strip(), db_track.artist_name.lower().strip())
# Weight title slightly more than artist
return (title_similarity * 0.6) + (artist_similarity * 0.4)
# Find best match
best_match = None
best_confidence = 0.0
for track in potential_matches:
confidence = calculate_confidence(track)
if confidence > best_confidence:
best_confidence = confidence
best_match = track
# Try each title variation
for title_variation in title_variations:
# Search for potential matches with this variation
potential_matches = self.search_tracks(title=title_variation, artist=artist, limit=20)
if not potential_matches:
continue
logger.debug(f"🎵 Found {len(potential_matches)} tracks for variation '{title_variation}'")
# Score each potential match
for track in potential_matches:
confidence = self._calculate_track_confidence(title, artist, track)
logger.debug(f" 🎯 '{track.title}' confidence: {confidence:.3f}")
if confidence > best_confidence:
best_confidence = confidence
best_match = track
# Return match only if it meets threshold
if best_confidence >= confidence_threshold:
if best_match and best_confidence >= confidence_threshold:
logger.debug(f"✅ Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence
else:
logger.debug(f"❌ No confident track match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
return None, best_confidence
except Exception as e:
@ -736,11 +748,12 @@ class MusicDatabase:
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool]:
"""
Check if an album exists in the database with completeness information.
Enhanced to handle edition matching (standard <-> deluxe variants).
Returns (album, confidence, owned_tracks, expected_tracks, is_complete)
"""
try:
# First find the album match
album, confidence = self.check_album_exists(title, artist, confidence_threshold)
# Try enhanced edition-aware matching first with expected track count for Smart Edition Matching
album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count)
if not album:
return None, 0.0, 0, 0, False
@ -754,6 +767,301 @@ class MusicDatabase:
logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}")
return None, 0.0, 0, 0, False
def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None) -> Tuple[Optional[DatabaseAlbum], float]:
"""
Enhanced album existence check that handles edition variants.
Matches standard albums with deluxe/platinum/special editions and vice versa.
"""
try:
# Generate album title variations for edition matching
title_variations = self._generate_album_title_variations(title)
logger.debug(f"🔍 Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
for i, var in enumerate(title_variations):
logger.debug(f" {i+1}. '{var}'")
best_match = None
best_confidence = 0.0
for variation in title_variations:
# Search for this variation
albums = self.search_albums(title=variation, artist=artist, limit=10)
if albums:
logger.debug(f"📀 Found {len(albums)} albums for variation '{variation}'")
if not albums:
continue
# Score each potential match with Smart Edition Matching
for album in albums:
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
logger.debug(f" 🎯 '{album.title}' confidence: {confidence:.3f}")
if confidence > best_confidence:
best_confidence = confidence
best_match = album
# Return match only if it meets threshold
if best_match and best_confidence >= confidence_threshold:
logger.debug(f"✅ Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence
else:
logger.debug(f"❌ No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
return None, best_confidence
except Exception as e:
logger.error(f"Error in edition-aware album matching for '{title}' by '{artist}': {e}")
return None, 0.0
def _generate_album_title_variations(self, title: str) -> List[str]:
"""Generate variations of album title to handle edition matching"""
variations = [title] # Always include original
# Clean up the title
title_lower = title.lower().strip()
# Define edition patterns and their variations
edition_patterns = {
r'\s*\(deluxe\s*edition?\)': ['deluxe', 'deluxe edition'],
r'\s*\(expanded\s*edition?\)': ['expanded', 'expanded edition'],
r'\s*\(platinum\s*edition?\)': ['platinum', 'platinum edition'],
r'\s*\(special\s*edition?\)': ['special', 'special edition'],
r'\s*\(remastered?\)': ['remastered', 'remaster'],
r'\s*\(anniversary\s*edition?\)': ['anniversary', 'anniversary edition'],
r'\s*\(.*version\)': ['version'],
r'\s+deluxe\s*edition?$': ['deluxe', 'deluxe edition'],
r'\s+platinum\s*edition?$': ['platinum', 'platinum edition'],
r'\s+special\s*edition?$': ['special', 'special edition'],
r'\s*-\s*deluxe': ['deluxe'],
r'\s*-\s*platinum\s*edition?': ['platinum', 'platinum edition'],
}
# Check if title contains any edition indicators
base_title = title
found_editions = []
for pattern, edition_types in edition_patterns.items():
if re.search(pattern, title_lower):
# Remove the edition part to get base title
base_title = re.sub(pattern, '', title, flags=re.IGNORECASE).strip()
found_editions.extend(edition_types)
break
# Add base title (without edition markers)
if base_title != title:
variations.append(base_title)
# If we found a base title, add common edition variants
if base_title != title:
# Add common deluxe/platinum/special variants
common_editions = [
'deluxe edition',
'deluxe',
'platinum edition',
'platinum',
'special edition',
'expanded edition',
'remastered',
'anniversary edition'
]
for edition in common_editions:
variations.extend([
f"{base_title} ({edition.title()})",
f"{base_title} ({edition})",
f"{base_title} - {edition.title()}",
f"{base_title} {edition.title()}",
])
# If original title is base form, add edition variants
elif not any(re.search(pattern, title_lower) for pattern in edition_patterns.keys()):
# This appears to be a base album, add deluxe variants
common_editions = ['Deluxe Edition', 'Deluxe', 'Platinum Edition', 'Special Edition']
for edition in common_editions:
variations.extend([
f"{title} ({edition})",
f"{title} - {edition}",
f"{title} {edition}",
])
# Remove duplicates while preserving order
seen = set()
unique_variations = []
for var in variations:
var_clean = var.strip()
if var_clean and var_clean.lower() not in seen:
seen.add(var_clean.lower())
unique_variations.append(var_clean)
return unique_variations
def _calculate_album_confidence(self, search_title: str, search_artist: str, db_album: DatabaseAlbum, expected_track_count: Optional[int] = None) -> float:
"""Calculate confidence score for album match with Smart Edition Matching"""
try:
# Simple confidence based on string similarity
title_similarity = self._string_similarity(search_title.lower(), db_album.title.lower())
artist_similarity = self._string_similarity(search_artist.lower(), db_album.artist_name.lower())
# Also try with cleaned versions (removing edition markers)
clean_search_title = self._clean_album_title_for_comparison(search_title)
clean_db_title = self._clean_album_title_for_comparison(db_album.title)
clean_title_similarity = self._string_similarity(clean_search_title, clean_db_title)
# Use the best title similarity
best_title_similarity = max(title_similarity, clean_title_similarity)
# Weight: 50% title, 50% artist (equal weight to prevent false positives)
# Also require minimum artist similarity to prevent matching wrong artists
confidence = (best_title_similarity * 0.5) + (artist_similarity * 0.5)
# Apply artist similarity penalty: if artist match is too low, drastically reduce confidence
if artist_similarity < 0.6: # Less than 60% artist match
confidence *= 0.3 # Reduce confidence by 70%
# Smart Edition Matching: Boost confidence if we found a "better" edition
if expected_track_count and db_album.track_count and clean_title_similarity >= 0.8:
# If the cleaned titles match well, check if this is an edition upgrade
if db_album.track_count >= expected_track_count:
# Found same/better edition (e.g., Deluxe when searching for Standard)
edition_bonus = min(0.15, (db_album.track_count - expected_track_count) / expected_track_count * 0.1)
confidence += edition_bonus
logger.debug(f" 📀 Edition upgrade bonus: +{edition_bonus:.3f} ({db_album.track_count} >= {expected_track_count} tracks)")
elif db_album.track_count < expected_track_count * 0.8:
# Found significantly smaller edition, apply penalty
edition_penalty = 0.1
confidence -= edition_penalty
logger.debug(f" 📀 Edition downgrade penalty: -{edition_penalty:.3f} ({db_album.track_count} << {expected_track_count} tracks)")
return min(confidence, 1.0) # Cap at 1.0
except Exception as e:
logger.error(f"Error calculating album confidence: {e}")
return 0.0
def _generate_track_title_variations(self, title: str) -> List[str]:
"""Generate variations of track title for better matching"""
variations = [title] # Always include original
# Clean up the title
title_lower = title.lower().strip()
# Common track title variations
track_patterns = [
# Remove version/remix info
r'\s*\(.*version\)',
r'\s*\(.*remix\)',
r'\s*\(.*mix\)',
r'\s*\(.*edit\)',
r'\s*\(.*radio\)',
# Remove featuring artists
r'\s*\(.*feat\..*\)',
r'\s*\(.*featuring.*\)',
r'\s*\(.*ft\..*\)',
# Remove brackets/parentheses content
r'\s*\[.*\]',
r'\s*\(.*\)',
# Remove everything after dash
r'\s*-\s*.*'
]
for pattern in track_patterns:
# Apply pattern to original title
cleaned = re.sub(pattern, '', title, flags=re.IGNORECASE).strip()
if cleaned and cleaned.lower() != title_lower and cleaned not in variations:
variations.append(cleaned)
# Apply pattern to lowercase version
cleaned_lower = re.sub(pattern, '', title_lower, flags=re.IGNORECASE).strip()
if cleaned_lower and cleaned_lower != title_lower:
# Convert back to proper case
cleaned_proper = cleaned_lower.title()
if cleaned_proper not in variations:
variations.append(cleaned_proper)
# Remove duplicates while preserving order
seen = set()
unique_variations = []
for var in variations:
var_key = var.lower().strip()
if var_key not in seen and var.strip():
seen.add(var_key)
unique_variations.append(var.strip())
return unique_variations
def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float:
"""Calculate confidence score for track match with enhanced cleaning"""
try:
# Direct similarity
title_similarity = self._string_similarity(search_title.lower(), db_track.title.lower())
artist_similarity = self._string_similarity(search_artist.lower(), db_track.artist_name.lower())
# Also try with cleaned versions (removing parentheses, brackets, etc.)
clean_search_title = self._clean_track_title_for_comparison(search_title)
clean_db_title = self._clean_track_title_for_comparison(db_track.title)
clean_title_similarity = self._string_similarity(clean_search_title, clean_db_title)
# Use the best title similarity (direct or cleaned)
best_title_similarity = max(title_similarity, clean_title_similarity)
# Weight: 50% title, 50% artist (equal weight to prevent false positives)
# Also require minimum artist similarity to prevent matching wrong artists
confidence = (best_title_similarity * 0.5) + (artist_similarity * 0.5)
# Apply artist similarity penalty: if artist match is too low, drastically reduce confidence
if artist_similarity < 0.6: # Less than 60% artist match
confidence *= 0.3 # Reduce confidence by 70%
return confidence
except Exception as e:
logger.error(f"Error calculating track confidence: {e}")
return 0.0
def _clean_track_title_for_comparison(self, title: str) -> str:
"""Clean track title for comparison by removing common noise"""
cleaned = title.lower().strip()
# Remove common patterns that cause mismatches
patterns_to_remove = [
r'\s*\(.*\)', # Remove anything in parentheses
r'\s*\[.*\]', # Remove anything in brackets
r'\s*-\s*.*', # Remove everything after dash
r'\s*feat\..*', # Remove featuring artists
r'\s*ft\..*', # Remove ft. artists
r'\s*featuring.*', # Remove featuring
]
for pattern in patterns_to_remove:
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE).strip()
return cleaned
def _clean_album_title_for_comparison(self, title: str) -> str:
"""Clean album title by removing edition markers for comparison"""
cleaned = title.lower()
# Remove common edition patterns
patterns = [
r'\s*\(deluxe\s*edition?\)',
r'\s*\(expanded\s*edition?\)',
r'\s*\(platinum\s*edition?\)',
r'\s*\(special\s*edition?\)',
r'\s*\(remastered?\)',
r'\s*\(anniversary\s*edition?\)',
r'\s*\(.*version\)',
r'\s*-\s*deluxe\s*edition?',
r'\s*-\s*platinum\s*edition?',
r'\s+deluxe\s*edition?$',
r'\s+platinum\s*edition?$',
]
for pattern in patterns:
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
return cleaned.strip()
def get_album_completion_stats(self, artist_name: str) -> Dict[str, int]:
"""
Get completion statistics for all albums by an artist.

View file

@ -1128,30 +1128,17 @@ class AlbumCard(QFrame):
self.overlay.setFixedSize(164, 164)
self.overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
if self.is_owned:
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 24px;
font-weight: bold;
}
""")
self.overlay.setText("")
else:
self.overlay.setStyleSheet("""
QLabel {
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("📥\nDownload")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
# Set up initial overlay appearance (will be updated by update_ownership)
self.overlay.setStyleSheet("""
QLabel {
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("Loading...")
self.overlay.hide() # Initially hidden, shown on hover
# Download progress overlay (shown during downloads)
@ -1195,6 +1182,9 @@ class AlbumCard(QFrame):
layout.addWidget(album_label)
layout.addWidget(year_label)
layout.addStretch()
# Initialize overlay text based on current ownership status
self._refresh_overlay_text()
def load_album_image(self):
"""Load album image in background"""
@ -1216,14 +1206,98 @@ class AlbumCard(QFrame):
def enterEvent(self, event):
"""Show overlay on hover"""
self.overlay.show()
try:
if hasattr(self, 'overlay') and self.overlay:
self.overlay.show()
self.overlay.raise_() # Bring to front
except (RuntimeError, AttributeError):
# Object has been deleted or is invalid, skip
pass
super().enterEvent(event)
def leaveEvent(self, event):
"""Hide overlay when not hovering"""
self.overlay.hide()
try:
if hasattr(self, 'overlay') and self.overlay:
self.overlay.hide()
except (RuntimeError, AttributeError):
# Object has been deleted or is invalid, skip
pass
super().leaveEvent(event)
def _refresh_overlay_text(self):
"""Refresh overlay text based on current ownership status"""
if self.is_owned:
if self.ownership_status and self.ownership_status.is_complete:
# Complete album (90%+) - green checkmark overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("✓ Complete\nVerify tracks")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
elif self.ownership_status and self.ownership_status.is_nearly_complete:
# Nearly complete album (80-89%) - blue overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(13, 110, 253, 0.8);
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: bold;
}
""")
percentage = int(self.ownership_status.completion_ratio * 100)
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
self.overlay.setText(f"◐ Nearly Complete\n({percentage}%)\nGet {missing_tracks} missing")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
elif self.ownership_status:
# Partial album (<80%) - yellow warning overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(255, 193, 7, 0.8);
border-radius: 6px;
color: #212529;
font-size: 14px;
font-weight: bold;
}
""")
percentage = int(self.ownership_status.completion_ratio * 100)
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
self.overlay.setText(f"⚠ Partial\n({percentage}%)\nGet {missing_tracks} missing")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
else:
# Legacy complete album - green checkmark overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("✓ Complete\nVerify tracks")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
else:
# Missing album - download overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("📥 Missing\n(0%)\nDownload")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
def update_status_indicator(self):
"""Update the permanent status indicator"""
if self.is_owned:
@ -1319,83 +1393,25 @@ class AlbumCard(QFrame):
self.update_status_indicator()
# Update the hover overlay
if self.is_owned:
if self.ownership_status and self.ownership_status.is_complete:
# Complete album (90%+) - green checkmark overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("✓ Complete\nVerify tracks")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
elif self.ownership_status and self.ownership_status.is_nearly_complete:
# Nearly complete album (80-89%) - blue overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(13, 110, 253, 0.8);
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: bold;
}
""")
percentage = int(self.ownership_status.completion_ratio * 100)
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
self.overlay.setText(f"{percentage}%\nGet {missing_tracks} missing")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
elif self.ownership_status:
# Partial album (<80%) - yellow warning overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(255, 193, 7, 0.8);
border-radius: 6px;
color: #212529;
font-size: 14px;
font-weight: bold;
}
""")
percentage = int(self.ownership_status.completion_ratio * 100)
missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks
self.overlay.setText(f"{percentage}%\nGet {missing_tracks} missing")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
else:
# Legacy complete album - green checkmark overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(29, 185, 84, 0.8);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("✓ Complete\nVerify tracks")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
else:
# Missing album - download overlay
self.overlay.setStyleSheet("""
QLabel {
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: white;
font-size: 16px;
font-weight: bold;
}
""")
self.overlay.setText("📥\nDownload")
self.overlay.setCursor(Qt.CursorShape.PointingHandCursor)
self._refresh_overlay_text()
def set_download_in_progress(self):
"""Set album card to download in progress state"""
# Hide hover overlay and show progress overlay
self.overlay.hide()
self.progress_overlay.setText("\nPreparing...")
self.progress_overlay.show()
try:
if hasattr(self, 'overlay') and self.overlay:
self.overlay.hide()
except (RuntimeError, AttributeError):
# Object has been deleted or is invalid, skip
pass
try:
if hasattr(self, 'progress_overlay') and self.progress_overlay and not self.progress_overlay.isNull():
self.progress_overlay.setText("\nPreparing...")
self.progress_overlay.show()
except (RuntimeError, AttributeError):
# Object has been deleted or is invalid, skip
pass
# Update status indicator
self.status_indicator.setStyleSheet("""

View file

@ -223,10 +223,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations:
if self._cancelled: return None, 0.0
# Use database check_track_exists method
# Use database check_track_exists method with consistent thresholds
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
if db_track and confidence >= 0.8:
if db_track and confidence >= 0.7:
print(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
# Convert database track to format compatible with existing code