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() }