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
- 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>
161 lines
6.0 KiB
Go
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()
|
|
}
|