Files
minstrel/internal/library/duplicate_classify.go
T
bvandeusenandClaude Opus 5.5 c5868c00cc
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m31s
release / android (push) Successful in 4m51s
release / integration (push) Successful in 4m53s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a version named after a dash counts as a version, and a song held twice beside a wrong slot no longer blocks it (M498)
- songKey drops a dashed version suffix (" - Instrumental Version", " - Radio
  Edit", live, remix, mix, acoustic, demo, reprise, rework, VIP), so Andora's
  "Goodbye Yesterday - Instrumental Version" is distinct, not a wrong slot.
- wrongSlots accepts several fitting copies when they share one title
  (Killjoy twice beside Dreamland): the other slot's copy is removed, and the
  doubled song is left for the next sweep to find as a pair the resolver merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:24:47 -04:00

161 lines
6.0 KiB
Go

package library
import (
"regexp"
"strings"
"unicode"
)
// Duplicate classification (M498 #5435; settled further 2026-10-19).
//
// A duplicate group is one recording found more than once. What should happen
// to it depends on where the copies sit:
//
// - same_release: every copy is on one album under one title. An album holds
// a song once, so the extra copies are the ones to fold away.
// - cross_release: the copies are on different albums. A single and the
// album it came from, a deluxe edition and the standard one, or one
// recording a later release retitled ("The Ragtime Cat", "F.O.D. (live)",
// a romanized title): each copy belongs to its own release, so none is
// deleted (unless it is a video rip; the resolver decides). The measured
// library (2026-10-19) had 451 such groups waiting on the operator under
// different titles, and they were retitles, not different songs.
// - distinct: one album, two versions of a song that sound alike, named as
// such ("Lovesick", "Lovesick (Instrumental)"). Not duplicates.
// - mismatch: one album holds the same audio under different song titles:
// one file filling several track slots, a broken download. Which slot is
// right is the resolver's to decide from MusicBrainz's track lengths.
// - review: what none of these fits. The operator decides.
// DuplicateClass is a group's shape. The values are duplicate_groups.class.
type DuplicateClass string
const (
ClassSameRelease DuplicateClass = "same_release"
ClassCrossRelease DuplicateClass = "cross_release"
ClassDistinct DuplicateClass = "distinct"
ClassMismatch DuplicateClass = "mismatch"
ClassReview DuplicateClass = "review"
)
// ClassifyMember is what classifying needs to know about one copy.
type ClassifyMember struct {
AlbumID string
Title string
ArtistName string
}
// ClassifyDuplicateGroup decides a group's shape from its tier ("exact" or
// "acoustic") and its copies.
//
// Titles are compared by MatchTitleKey, which drops what a release or an upload
// adds to a title (featuring credits, remaster notes, video markers) and keeps
// what names a different recording (live, demo, remix, instrumental). On one
// album, titles that differ only by such a word are two versions (distinct)
// unless the bytes are identical, which is one file twice: the #3885 case of
// "WWW" and "WWW (instrumental)" holding the same file is a wrong slot.
func ClassifyDuplicateGroup(tier string, members []ClassifyMember) DuplicateClass {
if len(members) < 2 {
return ClassReview
}
oneAlbum, oneTitle, oneSong := true, true, true
key := MatchTitleKey(members[0].Title, members[0].ArtistName)
song := songKey(members[0].Title, members[0].ArtistName)
for _, m := range members[1:] {
if m.AlbumID != members[0].AlbumID {
oneAlbum = false
}
if MatchTitleKey(m.Title, m.ArtistName) != key {
oneTitle = false
}
if songKey(m.Title, m.ArtistName) != song {
oneSong = false
}
}
exact := tier == string(tierExact)
switch {
case !oneAlbum:
return ClassCrossRelease
case oneTitle:
return ClassSameRelease
case oneSong && !exact:
return ClassDistinct
default:
return ClassMismatch
}
}
// 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 {
t := versionSuffix.ReplaceAllString(bracketed.ReplaceAllString(title, " "), "")
if key := MatchTitleKey(t, artist); key != "" {
return key
}
return MatchTitleKey(title, artist)
}
var (
// A bracketed credit: "(feat. Panda Bear)", "[ft. X]", "(featuring Y)".
featBracket = regexp.MustCompile(`(?i)[(\[][^)\]]*\b(feat\.?|ft\.?|featuring)\s[^)\]]*[)\]]`)
// A credit trailing the title without brackets: "Doin' It Right ft. Panda Bear".
featTrailing = regexp.MustCompile(`(?i)\s(feat\.?|ft\.?|featuring)\s.*$`)
// "(2011 Remaster)", "[Remastered]", "- Remastered 2009".
remasterBracket = regexp.MustCompile(`(?i)[(\[][^)\]]*remaster[^)\]]*[)\]]`)
remasterSuffix = regexp.MustCompile(`(?i)\s-\s[^-]*remaster.*$`)
// Any bracketed segment, to test each against the video-rip markers.
bracketed = regexp.MustCompile(`[(\[][^)\]]*[)\]]`)
)
// MatchTitleKey reduces a title to what identifies the song, for deciding
// whether two copies carry the same one.
//
// Dropped: a leading "Artist - " (a video upload's title), bracketed featuring
// credits and remaster notes, and bracketed segments carrying a video-rip marker
// ("(Official Video)", "[HD]"). Kept: every other bracketed word, so "(live)",
// "(demo)", "(acoustic)", "(remix)" and "(instrumental)" still tell recordings
// apart. Then case, "&" against "and", and punctuation and spacing are ignored.
func MatchTitleKey(title, artist string) string {
t := strings.TrimSpace(title)
if a := strings.TrimSpace(artist); a != "" {
if prefix := a + " - "; len(t) > len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) {
t = t[len(prefix):]
}
}
t = featBracket.ReplaceAllString(t, " ")
t = remasterBracket.ReplaceAllString(t, " ")
t = bracketed.ReplaceAllStringFunc(t, func(seg string) string {
for _, re := range suspectSourceRegexps {
if re.MatchString(seg) {
return " "
}
}
return seg
})
t = remasterSuffix.ReplaceAllString(t, "")
t = featTrailing.ReplaceAllString(t, "")
if key := alnumKey(t); key != "" {
return key
}
return alnumKey(title)
}
// alnumKey lowercases, reads "&" as "and", and keeps only letters and digits.
func alnumKey(s string) string {
s = strings.ReplaceAll(strings.ToLower(s), "&", " and ")
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}