Fix release matching for featured artists and credit annotations
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s

This commit is contained in:
mrleo1nid committed 2026-10-30 10:23:12 +03:00
1 parent f4681da876
commit fd17c06397
7 files changed
+146 -12

No files matched your search

+42 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"math"
"regexp"
"sort"
"strings"
@@ -70,7 +71,11 @@ func releaseHash(files []File) string {
entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() })
b, _ := json.Marshal(entries)
// Invalidate persisted reports when matching rules change, as well as tags.
b, _ := json.Marshal(struct {
Version int
Files []entry
}{2, entries})
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
@@ -111,6 +116,39 @@ func batchReleaseQuery(files []File) (musicbrainz.Query, bool) {
}
return q, q.Artist != ""
}
// Only credit annotations are removed for tentative matching. Version labels
// such as live, remix or instrumental remain significant.
var releaseTitleCredits = regexp.MustCompile(`(?i)\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring|prod\.?|produced by)\s+[^()\[\]]+[\)\]]\s*$`)
func releaseTitleKey(title string) string {
for {
cleaned := releaseTitleCredits.ReplaceAllString(title, "")
if cleaned == title {
return meta.Key(title)
}
title = cleaned
}
}
func sameReleaseArtists(a, b string) bool {
left := meta.SplitArtists(strings.ToLower(a), nil)
right := meta.SplitArtists(strings.ToLower(b), nil)
if len(left) == 0 || len(left) != len(right) {
return false
}
credits := make(map[string]bool, len(left))
for _, artist := range left {
credits[meta.Key(artist.Name)] = true
}
for _, artist := range right {
if !credits[meta.Key(artist.Name)] {
return false
}
}
return true
}
func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
if f.Meta == nil {
return 0
@@ -137,8 +175,9 @@ func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
if title && (t.DurationMs == 0 || f.DurationMs == nil) {
return 70
}
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) && meta.Key(m.Artist) == meta.Key(t.Artist) {
return 60
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) &&
releaseTitleKey(m.Title) != "" && releaseTitleKey(m.Title) == releaseTitleKey(t.Title) && sameReleaseArtists(m.Artist, t.Artist) {
return 75
}
return 0
}
@@ -58,7 +58,7 @@ func TestReleaseMatchingDoesNotClaimAmbiguousOrWeakMatchesAreComplete(t *testing
f := releaseFile("Untitled", 1, 1, 12000)
c = matchRelease([]File{f}, r)
require.False(t, c.Complete)
require.Equal(t, "medium", c.Confidence)
require.Empty(t, c.Matches, "number and duration alone do not identify a differently titled recording")
f.Meta.Title = "Intro"
f.Meta.Artist = "Someone Else"
f.Meta.AlbumArtist = "Someone Else"
@@ -93,3 +93,88 @@ func TestReleaseFingerprintInvalidatesEditsButNotApproval(t *testing.T) {
files[0].Status = StatusWithdrawn
require.NotEqual(t, hash, releaseHash(files))
}
func TestReleaseMatchesOldBloodCreditsWithoutClaimingVerifiedCompleteness(t *testing.T) {
tracks := []struct {
title, uploaded, guest string
seconds int
}{
{"Введение", "Введение", "", 34},
{"Ни много ни мало", "Ни много ни мало (Prod. Blacksurfer)", "", 143},
{"MOONWORK", "MOONWORK (feat. Basic Boy) (Prod. SP4K)", "Basic Boy", 214},
{"Angry Toy$", "Angry toy$ (Prod. Ray Qwa)", "", 127},
{"Возраст", "Возраст (feat. Artur Kreem) (Prod. Babyboi)", "Artur Kreem", 138},
{"OLD BLOOD", "OLD BLOOD (Prod. Chivaz)", "", 138},
{"Умы", "Умы (feat. DopeVvs) (Prod. MRKBVNGXR)", "DopeVvs", 125},
{"NO HOOK NO HOES", "NO HOOK NO HOES (feat. Yung Hurn) (Prod. Pretty Scream)", "Yung Hurn", 133},
{"ART HOES", "ART HOES (Prod. DavidRema)", "", 183},
{"Friendly Fire", "Friendly Fire (Prod. Avepack)", "", 122},
{"X2", "X2 (Prod. Gredy)", "", 176},
{"4 шага", "4 шага (Prod. DavidRema)", "", 135},
}
r := musicbrainz.Release{Artist: "Boulevard Depo", Title: "OLD BLOOD"}
files := []File{}
for i, track := range tracks {
artist, uploadedArtist := "Boulevard Depo", "BOULEVARD DEPO"
if track.guest != "" {
artist += " feat. " + track.guest
uploadedArtist += ", " + track.guest
}
r.Tracks = append(r.Tracks, musicbrainz.Track{Title: track.title, Artist: artist, Disc: 1, Position: i + 1, DurationMs: track.seconds * 1000})
f := releaseFile(track.uploaded, i+1, 1, track.seconds*1000+500)
f.Meta.Artist, f.Meta.AlbumArtist, f.Meta.Album = uploadedArtist, "BOULEVARD DEPO", "OLD BLOOD"
files = append([]File{f}, files...)
}
c := matchRelease(files, r)
require.Equal(t, 12, c.Matched)
require.Empty(t, c.Missing)
require.Empty(t, c.Unmatched)
require.False(t, c.Complete, "credit-normalized matches still require review")
require.Equal(t, "medium", c.Confidence)
for _, match := range c.Matches {
require.Equal(t, files[len(files)-1-match.TrackIndex].ID, match.FileID)
}
}
func TestReleaseCreditMatchingRequiresTitleArtistsPositionAndDuration(t *testing.T) {
for _, change := range []string{"none", "different guest", "different title", "remix", "live", "instrumental", "disc", "position", "duration", "unknown duration"} {
t.Run(change, func(t *testing.T) {
f := releaseFile("Song (feat. Guest) (Prod. Producer)", 3, 1, 180500)
f.Meta.Artist = "ARTIST; Guest"
r := musicbrainz.Release{Artist: "Artist", Tracks: []musicbrainz.Track{{Title: "Song", Artist: "Artist feat. Guest", Disc: 1, Position: 3, DurationMs: 180000}}}
switch change {
case "different guest":
f.Meta.Artist = "Artist, Someone Else"
case "different title":
f.Meta.Title = "Another song (Prod. Producer)"
f.Meta.Artist = r.Tracks[0].Artist
case "remix":
f.Meta.Title = "Song (Remix) (feat. Guest)"
f.Meta.Artist = r.Tracks[0].Artist
case "live":
f.Meta.Title = "Song [Live] (feat. Guest)"
f.Meta.Artist = r.Tracks[0].Artist
case "instrumental":
f.Meta.Title = "Song (Instrumental) (feat. Guest)"
f.Meta.Artist = r.Tracks[0].Artist
case "disc":
*f.Meta.Disc = 2
case "position":
*f.Meta.Track = 4
case "duration":
*f.DurationMs = 190000
case "unknown duration":
f.DurationMs = nil
}
c := matchRelease([]File{f}, r)
if change == "none" {
require.Equal(t, 1, c.Matched)
require.Equal(t, "medium", c.Matches[0].Confidence)
} else {
require.Zero(t, c.Matched)
require.Equal(t, []uuid.UUID{f.ID}, c.Unmatched)
}
require.False(t, c.Complete)
})
}
}