Merge pull request #625 from Nezreka/codex/artist-owned-album-matching
Tighten artist discography soundtrack matching
This commit is contained in:
commit
3e67ac6887
3 changed files with 209 additions and 8 deletions
|
|
@ -132,6 +132,7 @@ def check_album_completion(
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
server_source=active_server,
|
server_source=active_server,
|
||||||
candidate_albums=candidate_albums,
|
candidate_albums=candidate_albums,
|
||||||
|
strict_discography_match=True,
|
||||||
)
|
)
|
||||||
except Exception as db_error:
|
except Exception as db_error:
|
||||||
logger.error(f"Database error for album '{album_name}': {db_error}")
|
logger.error(f"Database error for album '{album_name}': {db_error}")
|
||||||
|
|
@ -239,6 +240,7 @@ def check_single_completion(
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
server_source=active_server,
|
server_source=active_server,
|
||||||
candidate_albums=candidate_albums,
|
candidate_albums=candidate_albums,
|
||||||
|
strict_discography_match=True,
|
||||||
)
|
)
|
||||||
except Exception as db_error:
|
except Exception as db_error:
|
||||||
logger.error(f"Database error for EP '{single_name}': {db_error}")
|
logger.error(f"Database error for EP '{single_name}': {db_error}")
|
||||||
|
|
|
||||||
|
|
@ -6257,7 +6257,7 @@ class MusicDatabase:
|
||||||
logger.error(f"Error fetching candidate tracks for {len(album_ids)} album IDs: {e}")
|
logger.error(f"Error fetching candidate tracks for {len(album_ids)} album IDs: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool, List[str]]:
|
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None, strict_discography_match: bool = False) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool, List[str]]:
|
||||||
"""
|
"""
|
||||||
Check if an album exists in the database with completeness information.
|
Check if an album exists in the database with completeness information.
|
||||||
Enhanced to handle edition matching (standard <-> deluxe variants).
|
Enhanced to handle edition matching (standard <-> deluxe variants).
|
||||||
|
|
@ -6269,7 +6269,7 @@ class MusicDatabase:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try enhanced edition-aware matching first with expected track count for Smart Edition Matching
|
# 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, server_source, candidate_albums=candidate_albums)
|
album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source, candidate_albums=candidate_albums, strict_discography_match=strict_discography_match)
|
||||||
|
|
||||||
if not album:
|
if not album:
|
||||||
return None, 0.0, 0, 0, False, []
|
return None, 0.0, 0, 0, False, []
|
||||||
|
|
@ -6283,7 +6283,7 @@ class MusicDatabase:
|
||||||
logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}")
|
logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}")
|
||||||
return None, 0.0, 0, 0, False, []
|
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, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None) -> Tuple[Optional[DatabaseAlbum], float]:
|
def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None, strict_discography_match: bool = False) -> Tuple[Optional[DatabaseAlbum], float]:
|
||||||
"""
|
"""
|
||||||
Enhanced album existence check that handles edition variants.
|
Enhanced album existence check that handles edition variants.
|
||||||
Matches standard albums with deluxe/platinum/special editions and vice versa.
|
Matches standard albums with deluxe/platinum/special editions and vice versa.
|
||||||
|
|
@ -6306,7 +6306,7 @@ class MusicDatabase:
|
||||||
# per-variation SQL widening that the legacy path does.
|
# per-variation SQL widening that the legacy path does.
|
||||||
logger.debug(f"Edition matching for '{title}' by '{artist}': batched against {len(candidate_albums)} candidates")
|
logger.debug(f"Edition matching for '{title}' by '{artist}': batched against {len(candidate_albums)} candidates")
|
||||||
for album in candidate_albums:
|
for album in candidate_albums:
|
||||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
best_match = album
|
best_match = album
|
||||||
|
|
@ -6339,7 +6339,7 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Score each potential match with Smart Edition Matching
|
# Score each potential match with Smart Edition Matching
|
||||||
for album in albums:
|
for album in albums:
|
||||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
|
||||||
logger.debug(f" '{album.title}' confidence: {confidence:.3f}")
|
logger.debug(f" '{album.title}' confidence: {confidence:.3f}")
|
||||||
|
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
|
|
@ -6375,7 +6375,7 @@ class MusicDatabase:
|
||||||
logger.debug(f" Found {len(artist_albums)} total albums for artist fallback")
|
logger.debug(f" Found {len(artist_albums)} total albums for artist fallback")
|
||||||
|
|
||||||
for album in artist_albums:
|
for album in artist_albums:
|
||||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
best_match = album
|
best_match = album
|
||||||
|
|
@ -6394,7 +6394,7 @@ class MusicDatabase:
|
||||||
try:
|
try:
|
||||||
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
|
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
|
||||||
for album in title_only_albums:
|
for album in title_only_albums:
|
||||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
|
||||||
# Slightly penalize cross-artist matches to prefer same-artist when possible
|
# Slightly penalize cross-artist matches to prefer same-artist when possible
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
|
|
@ -6511,7 +6511,7 @@ class MusicDatabase:
|
||||||
|
|
||||||
return unique_variations
|
return unique_variations
|
||||||
|
|
||||||
def _calculate_album_confidence(self, search_title: str, search_artist: str, db_album: DatabaseAlbum, expected_track_count: Optional[int] = None) -> float:
|
def _calculate_album_confidence(self, search_title: str, search_artist: str, db_album: DatabaseAlbum, expected_track_count: Optional[int] = None, strict_discography_match: bool = False) -> float:
|
||||||
"""Calculate confidence score for album match with Smart Edition Matching"""
|
"""Calculate confidence score for album match with Smart Edition Matching"""
|
||||||
try:
|
try:
|
||||||
# Simple confidence based on string similarity
|
# Simple confidence based on string similarity
|
||||||
|
|
@ -6531,6 +6531,18 @@ class MusicDatabase:
|
||||||
# Use the best title similarity
|
# Use the best title similarity
|
||||||
best_title_similarity = max(title_similarity, clean_title_similarity, normalized_title_similarity)
|
best_title_similarity = max(title_similarity, clean_title_similarity, normalized_title_similarity)
|
||||||
|
|
||||||
|
if strict_discography_match and not self._passes_strict_discography_album_match(
|
||||||
|
search_title,
|
||||||
|
db_album.title,
|
||||||
|
title_similarity,
|
||||||
|
clean_title_similarity,
|
||||||
|
normalized_title_similarity,
|
||||||
|
expected_track_count,
|
||||||
|
db_album.track_count,
|
||||||
|
):
|
||||||
|
logger.debug(" Strict discography match rejected: '%s' -> '%s'", search_title, db_album.title)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
# Log when normalized matching helps (only if it's the best score and better than others)
|
# Log when normalized matching helps (only if it's the best score and better than others)
|
||||||
if normalized_title_similarity == best_title_similarity and normalized_title_similarity > max(title_similarity, clean_title_similarity):
|
if normalized_title_similarity == best_title_similarity and normalized_title_similarity > max(title_similarity, clean_title_similarity):
|
||||||
logger.debug(f" Diacritic normalization improved match: '{search_title}' -> '{db_album.title}' (normalized: {normalized_title_similarity:.3f} vs raw: {title_similarity:.3f})")
|
logger.debug(f" Diacritic normalization improved match: '{search_title}' -> '{db_album.title}' (normalized: {normalized_title_similarity:.3f} vs raw: {title_similarity:.3f})")
|
||||||
|
|
@ -6567,6 +6579,92 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating album confidence: {e}")
|
logger.error(f"Error calculating album confidence: {e}")
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
def _passes_strict_discography_album_match(
|
||||||
|
self,
|
||||||
|
search_title: str,
|
||||||
|
db_title: str,
|
||||||
|
title_similarity: float,
|
||||||
|
clean_title_similarity: float,
|
||||||
|
normalized_title_similarity: float,
|
||||||
|
expected_track_count: Optional[int],
|
||||||
|
db_track_count: Optional[int],
|
||||||
|
) -> bool:
|
||||||
|
"""Guard artist-page owned status against generic soundtrack false positives."""
|
||||||
|
if not self._is_soundtrack_like_album_title(search_title) and not self._is_soundtrack_like_album_title(db_title):
|
||||||
|
return True
|
||||||
|
|
||||||
|
normalized_search_title = self._normalize_for_comparison(search_title)
|
||||||
|
normalized_db_title = self._normalize_for_comparison(db_title)
|
||||||
|
if normalized_search_title == normalized_db_title:
|
||||||
|
return True
|
||||||
|
|
||||||
|
clean_search_title = self._normalize_for_comparison(self._clean_album_title_for_comparison(search_title))
|
||||||
|
clean_db_title = self._normalize_for_comparison(self._clean_album_title_for_comparison(db_title))
|
||||||
|
if clean_search_title and clean_search_title == clean_db_title:
|
||||||
|
return True
|
||||||
|
|
||||||
|
best_title_similarity = max(title_similarity, clean_title_similarity, normalized_title_similarity)
|
||||||
|
search_tokens = self._distinctive_soundtrack_title_tokens(search_title)
|
||||||
|
db_tokens = self._distinctive_soundtrack_title_tokens(db_title)
|
||||||
|
if not search_tokens or not db_tokens:
|
||||||
|
return False
|
||||||
|
|
||||||
|
shared_tokens = search_tokens & db_tokens
|
||||||
|
smaller_overlap = len(shared_tokens) / min(len(search_tokens), len(db_tokens))
|
||||||
|
jaccard_overlap = len(shared_tokens) / len(search_tokens | db_tokens)
|
||||||
|
if smaller_overlap < 0.75 or jaccard_overlap < 0.55:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if expected_track_count and db_track_count and best_title_similarity < 0.9:
|
||||||
|
track_ratio = min(expected_track_count, db_track_count) / max(expected_track_count, db_track_count)
|
||||||
|
if track_ratio < 0.5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _is_soundtrack_like_album_title(self, title: str) -> bool:
|
||||||
|
title = (title or "").lower()
|
||||||
|
patterns = [
|
||||||
|
r"\bsoundtrack\b",
|
||||||
|
r"\bscore\b",
|
||||||
|
r"\bost\b",
|
||||||
|
r"original\s+motion\s+picture",
|
||||||
|
r"music\s+from\s+(?:the\s+)?(?:motion\s+picture|film|movie|series|anime|tv|television)",
|
||||||
|
r"complete\s+recordings?",
|
||||||
|
]
|
||||||
|
return any(re.search(pattern, title) for pattern in patterns)
|
||||||
|
|
||||||
|
def _distinctive_soundtrack_title_tokens(self, title: str) -> set[str]:
|
||||||
|
normalized = self._normalize_for_comparison(title)
|
||||||
|
tokens = set(re.findall(r"[a-z0-9]+", normalized))
|
||||||
|
noise = {
|
||||||
|
"album",
|
||||||
|
"anime",
|
||||||
|
"complete",
|
||||||
|
"deluxe",
|
||||||
|
"edition",
|
||||||
|
"film",
|
||||||
|
"from",
|
||||||
|
"motion",
|
||||||
|
"movie",
|
||||||
|
"music",
|
||||||
|
"official",
|
||||||
|
"original",
|
||||||
|
"ost",
|
||||||
|
"picture",
|
||||||
|
"recording",
|
||||||
|
"recordings",
|
||||||
|
"score",
|
||||||
|
"series",
|
||||||
|
"soundtrack",
|
||||||
|
"special",
|
||||||
|
"television",
|
||||||
|
"the",
|
||||||
|
"tv",
|
||||||
|
"version",
|
||||||
|
}
|
||||||
|
return {token for token in tokens if token not in noise and len(token) > 1}
|
||||||
|
|
||||||
def _generate_track_title_variations(self, title: str) -> List[str]:
|
def _generate_track_title_variations(self, title: str) -> List[str]:
|
||||||
"""Generate variations of track title for better matching"""
|
"""Generate variations of track title for better matching"""
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,7 @@ def test_iter_artist_discography_completion_uses_primary_source_first(monkeypatc
|
||||||
assert spotify.album_calls == []
|
assert spotify.album_calls == []
|
||||||
assert itunes.album_calls == []
|
assert itunes.album_calls == []
|
||||||
assert db.album_calls and db.album_calls[0]["expected_track_count"] == 2
|
assert db.album_calls and db.album_calls[0]["expected_track_count"] == 2
|
||||||
|
assert db.album_calls[0]["strict_discography_match"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_iter_artist_discography_completion_respects_source_override(monkeypatch):
|
def test_iter_artist_discography_completion_respects_source_override(monkeypatch):
|
||||||
|
|
@ -360,6 +361,106 @@ def test_iter_artist_discography_completion_respects_source_override(monkeypatch
|
||||||
assert spotify.album_calls == []
|
assert spotify.album_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_artist_discography_completion_uses_strict_matching_for_eps(monkeypatch):
|
||||||
|
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
|
||||||
|
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary])
|
||||||
|
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source_name, **kwargs: None)
|
||||||
|
|
||||||
|
db = _CompletionFakeDB(owned_tracks=1, expected_tracks=2)
|
||||||
|
events = list(metadata_completion.iter_artist_discography_completion_events(
|
||||||
|
{
|
||||||
|
"albums": [],
|
||||||
|
"singles": [{
|
||||||
|
"id": "ep-1",
|
||||||
|
"name": "Original Motion Picture Soundtrack EP",
|
||||||
|
"album_type": "ep",
|
||||||
|
"total_tracks": 2,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
artist_name="Composer One",
|
||||||
|
db=db,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert events[1]["type"] == "single_completion"
|
||||||
|
assert db.album_calls[0]["strict_discography_match"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_discography_matching_rejects_distinct_soundtrack_siblings():
|
||||||
|
db = object.__new__(MusicDatabase)
|
||||||
|
album = types.SimpleNamespace(
|
||||||
|
title="Star Wars: Episode I - The Phantom Menace (Original Motion Picture Soundtrack)",
|
||||||
|
artist_name="John Williams",
|
||||||
|
track_count=17,
|
||||||
|
)
|
||||||
|
|
||||||
|
confidence = db._calculate_album_confidence(
|
||||||
|
"Star Wars: Episode II - Attack of the Clones (Original Motion Picture Soundtrack)",
|
||||||
|
"John Williams",
|
||||||
|
album,
|
||||||
|
expected_track_count=13,
|
||||||
|
strict_discography_match=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert confidence == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_discography_matching_allows_same_soundtrack_title():
|
||||||
|
db = object.__new__(MusicDatabase)
|
||||||
|
album = types.SimpleNamespace(
|
||||||
|
title="Star Wars: Episode I - The Phantom Menace (Original Motion Picture Soundtrack)",
|
||||||
|
artist_name="John Williams",
|
||||||
|
track_count=17,
|
||||||
|
)
|
||||||
|
|
||||||
|
confidence = db._calculate_album_confidence(
|
||||||
|
"Star Wars: Episode I - The Phantom Menace (Original Motion Picture Soundtrack)",
|
||||||
|
"John Williams",
|
||||||
|
album,
|
||||||
|
expected_track_count=17,
|
||||||
|
strict_discography_match=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert confidence >= 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_strict_album_matching_keeps_edition_behavior():
|
||||||
|
db = object.__new__(MusicDatabase)
|
||||||
|
album = types.SimpleNamespace(
|
||||||
|
title="DAMN. (Deluxe Edition)",
|
||||||
|
artist_name="Kendrick Lamar",
|
||||||
|
track_count=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
confidence = db._calculate_album_confidence(
|
||||||
|
"DAMN.",
|
||||||
|
"Kendrick Lamar",
|
||||||
|
album,
|
||||||
|
expected_track_count=14,
|
||||||
|
strict_discography_match=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert confidence >= 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_discography_matching_does_not_change_normal_albums():
|
||||||
|
db = object.__new__(MusicDatabase)
|
||||||
|
album = types.SimpleNamespace(
|
||||||
|
title="DAMN. (Deluxe Edition)",
|
||||||
|
artist_name="Kendrick Lamar",
|
||||||
|
track_count=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
confidence = db._calculate_album_confidence(
|
||||||
|
"DAMN.",
|
||||||
|
"Kendrick Lamar",
|
||||||
|
album,
|
||||||
|
expected_track_count=14,
|
||||||
|
strict_discography_match=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert confidence >= 0.9
|
||||||
|
|
||||||
|
|
||||||
def test_iter_artist_discography_completion_uses_release_artist_metadata(monkeypatch):
|
def test_iter_artist_discography_completion_uses_release_artist_metadata(monkeypatch):
|
||||||
source = _FakeSourceClient()
|
source = _FakeSourceClient()
|
||||||
clients = {"deezer": source}
|
clients = {"deezer": source}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue