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
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>
137 lines
4.7 KiB
Go
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()
|
|
}
|