Files
minstrel/internal/library/duplicate_classify.go
T
bvandeusenandClaude Opus 5.5 5306a261c1
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: duplicates resolve themselves without Lidarr (M502 #5517)
Lidarr is off, and with it the resolver had stopped merging: it only
merged when Lidarr had said which copies it mapped. Minstrel is now the
only program writing the library, so the rule is about the copies:

- A song held twice on one album: the extra copies merge into the one
  whose tags fit best (then lossless, larger, older).
- Across releases or under another title: when every copy but one is a
  video rip (held back, or named like one and not marked fine), the rips
  merge into the one that is not.
- Clean copies on different releases are linked as one song, as before.
- Anything else waits for the operator.

Gone with Lidarr: release switches and their settle window, the
unmapped-file read, the rip search, the merge guard and its 409/503,
lidarr.Client's release methods, and duplicate_group_members.lidarr_state
(migration 0078). The duplicates page drops the per-copy Lidarr badge
and the blocked-merge notice; past release changes stay in its history.

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

137 lines
4.7 KiB
Go

package library
import (
"regexp"
"strings"
"unicode"
)
// Duplicate classification (M498 #5435).
//
// A duplicate group is one recording found more than once. What should happen
// to it depends on where the copies sit, and the measured library (milestone
// 498's body) splits into four shapes:
//
// - same_release: every copy is on one album. An album holds a song once, so
// the extra copies are the ones to fold away.
// - cross_release: the copies are on different albums under one title. A
// single and the album it came from, a deluxe edition and the standard one:
// each copy belongs to its own release, so none is deleted (unless it is a
// video rip; the resolver decides).
// - mismatch: byte-identical audio on different albums under different
// titles. One file carries another song's tags — a wrong-file import.
// - review: anything else. 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"
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). So one
// album holding "WWW" and "WWW (instrumental)" as an acoustic match is review,
// not a copy to fold away: the #3885 case, where they were in fact different
// recordings mislabelled. Byte-identical copies on one album are the same file
// twice whatever their tags say.
func ClassifyDuplicateGroup(tier string, members []ClassifyMember) DuplicateClass {
if len(members) < 2 {
return ClassReview
}
oneAlbum, oneTitle := true, true
key := MatchTitleKey(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
}
}
exact := tier == string(tierExact)
switch {
case oneAlbum && (oneTitle || exact):
return ClassSameRelease
case oneAlbum:
return ClassReview
case oneTitle:
return ClassCrossRelease
case exact:
return ClassMismatch
default:
return ClassReview
}
}
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()
}