Files
minstrel/internal/library/duplicate_classify.go
T
bvandeusenandClaude Opus 5.5 f32abc974f
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m31s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m0s
release / Build signed APK (releases and dev) (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a duplicate fold never deletes a lossless copy for a lossy one, nor folds two versions (M498)
The first album-copy fold pass on 2026-10-20 removed five FLACs (Måneskin
Rush!, Weezer 2016) in favour of mp3s whose only edge was a recording MBID,
and an explicit "Suite-Pee" for its clean version. ProposeSurvivor now ranks
lossless before tag fit; tag fit still outranks file size, which is what the
Humanz rips needed. The fold keys copies by their version words (title and
file name), so a clean, live or instrumental copy holding the same title tag
is linked, not folded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:36:55 -04:00

192 lines
7.1 KiB
Go

package library
import (
"path/filepath"
"regexp"
"sort"
"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[^-]*$`)
// versionWord matches a word that names a version of a song.
var versionWord = regexp.MustCompile(
`(?i)\b(clean|explicit|instrumentals?|live|acoustic|unplugged|demo|remix|mix|edit|radio|karaoke|a ?cappella|acapella|reprise)\b`)
// versionWords lists, sorted and lowercased, the version words in a copy's
// title and file name together. Two copies holding one title tag can still be
// two versions, told apart only by the file name: "Suite-Pee" and
// "Suite-Pee (Clean Version)", "Luv (sic) Grand Finale" and its instrumental.
func versionWords(title, path string) string {
seen := map[string]bool{}
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
for _, w := range versionWord.FindAllString(title+" "+name, -1) {
w = strings.ToLower(w)
switch {
case w == "instrumentals":
w = "instrumental"
case strings.Contains(w, "cappella") || w == "acapella":
w = "acappella"
}
seen[w] = true
}
words := make([]string, 0, len(seen))
for w := range seen {
words = append(words, w)
}
sort.Strings(words)
return strings.Join(words, ",")
}
// 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()
}