Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 56 additions & 11 deletions backend/integrations/spotify/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
duration_confidence,
check_album_context_via_tracklist,
has_version_keyword,
version_keyword_is_album_wide,
is_compilation_artist,
match_track_to_recording,
track_artist_matches_recording_leader,
Expand Down Expand Up @@ -454,6 +455,7 @@ def match_releases(self, song_identifier: str, start_from: int = 1,
alt_titles=song.get('alt_titles'),
spotify_tracks=spotify_tracks,
expected_album_artist=artist_name,
spotify_album_name=spotify_match.get('name'),
)

if track_matched:
Expand Down Expand Up @@ -532,7 +534,8 @@ def match_tracks_for_release(self, conn, song_id: str, release_id: str,
spotify_album_id: str, song_title: str,
alt_titles: List[str] = None,
spotify_tracks: List[dict] = None,
expected_album_artist: str = None) -> bool:
expected_album_artist: str = None,
spotify_album_name: str = None) -> bool:
"""
Match Spotify tracks to recordings for a release

Expand All @@ -559,6 +562,11 @@ def match_tracks_for_release(self, conn, song_id: str, release_id: str,
the matcher post-validates each track-level artist
against the recording's leader performer, since the
album-level artist check at search time was bypassed.
spotify_album_name: The matched Spotify album's title, when
known. Used to recognise album-wide version labelling
("After The Fall (Live)"), which must not trigger the
version-keyword asymmetry rejection below. Safe to omit —
the per-track ratio check covers the same ground.

Returns:
bool: True if at least one track was matched, False otherwise
Expand Down Expand Up @@ -752,17 +760,54 @@ def match_tracks_for_release(self, conn, song_id: str, release_id: str,
query_has_version = has_version_keyword(track_title)
candidate_has_version = has_version_keyword(matched_track['name'])
if query_has_version != candidate_has_version:
self.logger.info(
f" Rejecting version-asymmetric match: "
f"'{track_title}' (version-keyword={query_has_version}) → "
f"'{matched_track['name']}' (version-keyword={candidate_has_version})"
# Two escapes, each covering a case where the asymmetry
# is labelling convention rather than a performance
# difference. Both only ever apply when the keyword is on
# the SPOTIFY side: an annotation on the MB side that
# Spotify lacks is the original Peace-vs-studio case and
# stays rejected regardless of the album.
#
# 1. Album-wide labelling. Spotify suffixes every track
# on a live album " - Live" and titles the album
# "(Live)"; MB annotates neither. The keyword then
# says nothing about which performance this is.
# 2. Tight duration. A sub-5s delta on a title that
# otherwise scores as a match is the same take —
# the Peace false positive leaned on a 63s gap.
album_wide = (
candidate_has_version
and not query_has_version
and version_keyword_is_album_wide(
spotify_tracks, spotify_album_name)
)
if recording.get('spotify_track_id'):
clear_recording_release_track(
conn, recording['recording_id'], release_id,
dry_run=self.dry_run, log=self.logger)
self.stats['tracks_no_match'] += 1
continue
duration_is_tight = bool(
candidate_has_version
and not query_has_version
and recording_duration_ms
and matched_track.get('duration_ms')
and abs(recording_duration_ms
- matched_track['duration_ms']) <= 5000
)
if album_wide or duration_is_tight:
reason = ('album-wide version labelling' if album_wide
else 'duration within 5s')
self.logger.debug(
f" Allowing version-asymmetric match "
f"({reason}): '{track_title}' → "
f"'{matched_track['name']}'"
)
else:
self.logger.info(
f" Rejecting version-asymmetric match: "
f"'{track_title}' (version-keyword={query_has_version}) → "
f"'{matched_track['name']}' (version-keyword={candidate_has_version})"
)
if recording.get('spotify_track_id'):
clear_recording_release_track(
conn, recording['recording_id'], release_id,
dry_run=self.dry_run, log=self.logger)
self.stats['tracks_no_match'] += 1
continue

match_method = 'album_context' if rescued else 'fuzzy_search'
update_recording_release_track_id(
Expand Down
49 changes: 49 additions & 0 deletions backend/integrations/spotify/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,55 @@ def has_version_keyword(title: str) -> bool:
return bool(_VERSION_KEYWORD_RE.search(title))


# Fraction of a Spotify album's tracks that must carry a version keyword
# before we treat the keyword as album-wide labelling rather than a
# recording-level distinction. Deliberately high: a genuinely mixed album
# (a few bonus live cuts on a studio record) must NOT clear this bar,
# because there the keyword really does distinguish one track from another.
_ALBUM_WIDE_VERSION_RATIO = 0.8


def version_keyword_is_album_wide(spotify_tracks: list,
spotify_album_name: str = None) -> bool:
"""True when a version keyword on a Spotify track title is album-wide
boilerplate rather than a recording-level distinction.

Spotify labels every track on a live album with a " - Live" suffix and
titles the album itself "<Name> (Live)". MusicBrainz does not annotate
the individual tracks of a live release. The result is a systematic
asymmetry — MB "I'll See You Again" vs Spotify "I'll See You Again -
Live" — that carries no information about *which* performance a track
is, so has_version_keyword's asymmetry rejection must not fire on it.

This is the inverse of the case that rejection exists for: an MB title
of "Peace (live at Newport)" against a lone studio "Peace" on an
otherwise-studio album. There the keyword is the only signal that the
two are different performances, and `spotify_tracks` will not clear the
album-wide bar.

Args:
spotify_tracks: The album's tracks as returned by
SpotifyClient.get_album_tracks (dicts with a 'name' key).
An empty or missing list returns False — with nothing to
compare against, we keep the stricter behavior.
spotify_album_name: The Spotify album title, when known. An
annotated album title ("After The Fall (Live)") is on its own
enough, since it labels the whole release.

Returns:
True when the keyword should be treated as album-wide labelling.
"""
if spotify_album_name and has_version_keyword(spotify_album_name):
return True

names = [t.get('name') for t in (spotify_tracks or []) if t.get('name')]
if not names:
return False

annotated = sum(1 for n in names if has_version_keyword(n))
return (annotated / len(names)) >= _ALBUM_WIDE_VERSION_RATIO


def track_artist_matches_recording_leader(
recording_leader: str,
track_artists: list,
Expand Down
75 changes: 75 additions & 0 deletions backend/tests/test_spotify_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class instantiation. Drive them with handcrafted track dicts and assert
strip_mb_year_disambiguator,
strip_track_decorations,
track_artist_matches_recording_leader,
version_keyword_is_album_wide,
_strip_common_affixes,
_strip_trailing_balanced_parens,
validate_album_match,
Expand Down Expand Up @@ -231,6 +232,80 @@ def test_classifies_titles(self, title, expected):
assert has_version_keyword(title) is expected


class TestVersionKeywordIsAlbumWide:
"""Production case: Keith Jarrett's "After the Fall" (MB release
01dd675f, Spotify album 6KCtVTAKemrqpVZLLdxB2c). Spotify suffixes
all twelve tracks " - Live" and titles the album "After The Fall
(Live)"; MusicBrainz annotates neither. The asymmetry rejection then
threw away a 105%-title, 53ms-duration match for "I'll See You
Again" — and with it the whole album link.

The inverse case (a lone studio "Peace" on Day Breaks, matched from
an MB title of "Peace (live at Newport)") must still be rejected, so
a mostly-studio tracklist must NOT read as album-wide."""

def _tracks(self, *names):
return [{'name': n} for n in names]

def test_all_tracks_annotated_is_album_wide(self):
tracks = self._tracks(
"The Masquerade Is Over - Live",
"Scrapple From The Apple - Live",
"Old Folks - Live",
"I’ll See You Again - Live",
)
assert version_keyword_is_album_wide(tracks) is True

def test_annotated_album_title_alone_is_enough(self):
# Tracklist unavailable or unannotated, but the album itself is
# labelled — that labels the whole release.
assert version_keyword_is_album_wide(
[], "After The Fall (Live)") is True
assert version_keyword_is_album_wide(
self._tracks("Autumn Leaves"), "At The Deer Head Inn (Live)"
) is True

def test_studio_album_with_one_live_cut_is_not_album_wide(self):
# The Peace / Day Breaks shape: the keyword here really does
# distinguish one track from the rest.
tracks = self._tracks(
"Peace",
"Waltz For Debby",
"Blue Moon",
"Everything Happens To Me",
"Bouncing With Bud (Live)",
)
assert version_keyword_is_album_wide(tracks) is False

def test_mixed_album_below_ratio_is_not_album_wide(self):
# 3 of 5 annotated = 60%, under the 80% bar.
tracks = self._tracks(
"One - Live", "Two - Live", "Three - Live", "Four", "Five",
)
assert version_keyword_is_album_wide(tracks) is False

def test_at_ratio_boundary_is_album_wide(self):
# 4 of 5 = 80%, exactly the bar.
tracks = self._tracks(
"One - Live", "Two - Live", "Three - Live", "Four - Live", "Five",
)
assert version_keyword_is_album_wide(tracks) is True

@pytest.mark.parametrize("tracks,album", [
(None, None),
([], None),
([], ""),
([{}, {}], None), # dicts without 'name'
([{'name': None}], None),
])
def test_no_evidence_keeps_strict_behavior(self, tracks, album):
assert version_keyword_is_album_wide(tracks, album) is False

def test_unannotated_album_is_not_album_wide(self):
tracks = self._tracks("Autumn Leaves", "Blue In Green", "So What")
assert version_keyword_is_album_wide(tracks, "Kind Of Blue") is False


class TestTrackArtistMatchesRecordingLeader:
"""Issue #100 phase 3: track-level artist verification on compilations.

Expand Down
Loading