diff --git a/internal/library/duplicate_classify.go b/internal/library/duplicate_classify.go index b8356b4d..d9c3b70b 100644 --- a/internal/library/duplicate_classify.go +++ b/internal/library/duplicate_classify.go @@ -85,11 +85,18 @@ func ClassifyDuplicateGroup(tier string, members []ClassifyMember) DuplicateClas } } -// songKey is MatchTitleKey with every bracketed segment dropped: the song a -// title names, whatever version of it. "Lovesick (Instrumental)" and -// "Lovesick" share one. +// versionSuffix is a version named after a dash rather than in brackets: +// "Goodbye Yesterday - Instrumental Version", "Song - Radio Edit". +var versionSuffix = regexp.MustCompile( + `(?i)\s-\s[^-]*\b(version|instrumental|remix|mix|live|acoustic|demo|edit|reprise|rework|vip)\b[^-]*$`) + +// songKey is MatchTitleKey with every bracketed segment and a dashed version +// suffix dropped: the song a title names, whatever version of it. +// "Lovesick (Instrumental)", "Lovesick - Instrumental Version" and "Lovesick" +// share one. func songKey(title, artist string) string { - if key := MatchTitleKey(bracketed.ReplaceAllString(title, " "), artist); key != "" { + t := versionSuffix.ReplaceAllString(bracketed.ReplaceAllString(title, " "), "") + if key := MatchTitleKey(t, artist); key != "" { return key } return MatchTitleKey(title, artist) diff --git a/internal/library/duplicate_classify_test.go b/internal/library/duplicate_classify_test.go index e9c21db6..b5dba9f8 100644 --- a/internal/library/duplicate_classify_test.go +++ b/internal/library/duplicate_classify_test.go @@ -28,6 +28,10 @@ func TestClassifyDuplicateGroup(t *testing.T) { []ClassifyMember{m("moe", "Lovesick"), m("moe", "Lovesick (Instrumental)")}, ClassDistinct}, {"Void: one album, one audio under three song titles", "acoustic", []ClassifyMember{m("void", "Slimewar (into the void)"), m("void", "Void"), m("void", "Reach")}, ClassMismatch}, + {"a version named after a dash", "acoustic", + []ClassifyMember{m("andora", "Goodbye Yesterday"), m("andora", "Goodbye Yesterday - Instrumental Version")}, ClassDistinct}, + {"a dash that names no version", "acoustic", + []ClassifyMember{m("a", "Monday Chill - Part One"), m("a", "Monday Chill - Part Two")}, ClassMismatch}, {"Slasher and Slasher II are not versions of one song", "acoustic", []ClassifyMember{m("slasher", "Slasher"), m("slasher", "Slasher II")}, ClassMismatch}, {"Big Me: the single and the album", "exact", @@ -90,6 +94,11 @@ func TestWrongSlots(t *testing.T) { {"Void: the file is Void's; the other two slots are other songs", copies("Slimewar (into the void)", "Void", "Reach"), void, 1, []int{0, 2}, true}, {"no track list", copies("Void", "Reach"), nil, -1, nil, false}, {"a title the track list does not hold", copies("Void", "Bonus"), void, -1, nil, false}, + {"Killjoy held twice beside Dreamland's slot", []slotCopy{ + {Title: "Dreamland", Artist: "Fox Stevenson", DurationMs: 167000}, + {Title: "Killjoy", Artist: "Fox Stevenson", DurationMs: 167000}, + {Title: "Killjoy", Artist: "Fox Stevenson", DurationMs: 166000}, + }, []dbq.MbReleaseTrack{track("Killjoy", 166500), track("Dreamland", 214000)}, 1, []int{0}, true}, {"two slots fit the file", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 221500)}, -1, nil, false}, {"a slot near but not within the fit", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 226000)}, -1, nil, false}, {"no slot fits", copies("Reach", "Slimewar (into the void)"), void, -1, nil, false}, diff --git a/internal/library/duplicate_settle.go b/internal/library/duplicate_settle.go index cea06672..fa9a246a 100644 --- a/internal/library/duplicate_settle.go +++ b/internal/library/duplicate_settle.go @@ -49,8 +49,15 @@ type slotCopy struct { // wrongSlots decides which copies of a one-album mismatch sit in a slot that // is not theirs. keep is the copy whose slot fits the recording; ok is false // when the track list cannot say, and nothing should be removed. +// +// Several copies may fit when they carry the same title ("Killjoy" twice +// beside "Dreamland"): they are one song held twice, not a wrong slot, so they +// are neither kept nor lost here. Once the wrong slot is emptied, the next +// sweep finds the pair on its own, and the resolver merges it as any song held +// twice on one album. func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, losers []int, ok bool) { keep = -1 + keepKey := "" for i, c := range copies { key := MatchTitleKey(c.Title, c.Artist) fits, other, known := false, true, false @@ -68,11 +75,12 @@ func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, loser } } switch { - case fits: - if keep >= 0 { + case fits && keep >= 0: + if key != keepKey { return -1, nil, false } - keep = i + case fits: + keep, keepKey = i, key case known && other: losers = append(losers, i) default: