release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.
- The headline and the Duplicates count leave out cross-release groups, which
the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
album's cached MusicBrainz track list decides. When exactly one slot's song
is as long as the file and every other slot's song is clearly not, the other
copies are removed and their tracks marked missing, so the reacquisition
sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
merges.
Migration 0094 adds 'distinct' to the class CHECK (rule 36).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
4.9 KiB
Go
106 lines
4.9 KiB
Go
package library
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// The shapes measured on the operator's library (milestone 498's body).
|
|
func TestClassifyDuplicateGroup(t *testing.T) {
|
|
m := func(album, title string) ClassifyMember {
|
|
return ClassifyMember{AlbumID: album, Title: title, ArtistName: "Gorillaz"}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
tier string
|
|
members []ClassifyMember
|
|
want DuplicateClass
|
|
}{
|
|
{"Humanz: one album, one song twice", "exact",
|
|
[]ClassifyMember{m("humanz", "Saturnz Barz"), m("humanz", "Saturnz Barz (Official Video)")}, ClassSameRelease},
|
|
{"one album, video upload title with artist prefix", "acoustic",
|
|
[]ClassifyMember{m("humanz", "Andromeda"), m("humanz", "Gorillaz - Andromeda (Official Audio)")}, ClassSameRelease},
|
|
{"one album, identical bytes under a version title: one file in two slots", "exact",
|
|
[]ClassifyMember{m("www", "WWW"), m("www", "WWW (instrumental)")}, ClassMismatch},
|
|
{"one album, acoustic match, two versions named", "acoustic",
|
|
[]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},
|
|
{"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",
|
|
[]ClassifyMember{m("single", "Big Me"), m("album", "Big Me")}, ClassCrossRelease},
|
|
{"remaster and featuring credits are one song", "acoustic",
|
|
[]ClassifyMember{m("a", "Feel Good Inc. (2017 Remaster)"), m("b", "Feel Good Inc feat. De La Soul")}, ClassCrossRelease},
|
|
{"a later release retitles the recording", "acoustic",
|
|
[]ClassifyMember{m("coco", "Ragtime Cat"), m("single", "The Ragtime Cat")}, ClassCrossRelease},
|
|
{"identical bytes on two albums under two titles", "exact",
|
|
[]ClassifyMember{m("dookie", "F.O.D."), m("live", "F.O.D. (live)")}, ClassCrossRelease},
|
|
{"a lone copy", "exact", []ClassifyMember{m("a", "Song")}, ClassReview},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := ClassifyDuplicateGroup(tc.tier, tc.members); got != tc.want {
|
|
t.Errorf("%s: got %s, want %s", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchTitleKey(t *testing.T) {
|
|
cases := []struct{ title, artist, want string }{
|
|
{"Saturnz Barz (Official Video)", "Gorillaz", "saturnzbarz"},
|
|
{"Gorillaz - Saturnz Barz", "Gorillaz", "saturnzbarz"},
|
|
{"Doin' It Right (Music Video) ft. Panda Bear", "Daft Punk", "doinitright"},
|
|
{"Song [feat. X]", "", "song"},
|
|
{"Song - Remastered 2009", "", "song"},
|
|
{"Rock & Roll", "", "rockandroll"},
|
|
// What names a different recording stays.
|
|
{"Song (live)", "", "songlive"},
|
|
{"Song (demo)", "", "songdemo"},
|
|
{"Song (Remix)", "", "songremix"},
|
|
// A title that is nothing but a marker keeps its words rather than vanishing.
|
|
{"(Official Video)", "", "officialvideo"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := MatchTitleKey(tc.title, tc.artist); got != tc.want {
|
|
t.Errorf("MatchTitleKey(%q, %q) = %q, want %q", tc.title, tc.artist, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWrongSlots(t *testing.T) {
|
|
track := func(title string, ms int32) dbq.MbReleaseTrack { return dbq.MbReleaseTrack{Title: title, LengthMs: ms} }
|
|
copies := func(titles ...string) []slotCopy {
|
|
out := make([]slotCopy, len(titles))
|
|
for i, title := range titles {
|
|
out[i] = slotCopy{Title: title, Artist: "Savant", DurationMs: 220000}
|
|
}
|
|
return out
|
|
}
|
|
void := []dbq.MbReleaseTrack{track("Slimewar (Into the Void)", 300000), track("Reach", 281000), track("Void", 220500)}
|
|
cases := []struct {
|
|
name string
|
|
copies []slotCopy
|
|
tracks []dbq.MbReleaseTrack
|
|
keep int
|
|
losers []int
|
|
ok bool
|
|
}{
|
|
{"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},
|
|
{"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},
|
|
{"an unknown length counts for nothing", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 0)}, -1, nil, false},
|
|
}
|
|
for _, tc := range cases {
|
|
keep, losers, ok := wrongSlots(tc.copies, tc.tracks)
|
|
if keep != tc.keep || ok != tc.ok || fmt.Sprint(losers) != fmt.Sprint(tc.losers) {
|
|
t.Errorf("%s: got keep=%d losers=%v ok=%v, want keep=%d losers=%v ok=%v",
|
|
tc.name, keep, losers, ok, tc.keep, tc.losers, tc.ok)
|
|
}
|
|
}
|
|
}
|