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 — when Lidarr does not need // them (the resolver checks). // - 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 fulfils its own release in Lidarr, so none is deleted. // - 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) } // ReleaseTitleKey is the lighter comparison for asking whether one release // lists a song twice: case, "&" and punctuation only. Every word stays, so a // box set's "Song" and "Song (demo)" are two songs, as the release means them. func ReleaseTitleKey(title string) string { 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() }