DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
3 changed files with 31 additions and 7 deletions
Showing only changes of commit c5868c00cc - Show all commits
+11 -4
View File
@@ -85,11 +85,18 @@ func ClassifyDuplicateGroup(tier string, members []ClassifyMember) DuplicateClas
} }
} }
// songKey is MatchTitleKey with every bracketed segment dropped: the song a // versionSuffix is a version named after a dash rather than in brackets:
// title names, whatever version of it. "Lovesick (Instrumental)" and // "Goodbye Yesterday - Instrumental Version", "Song - Radio Edit".
// "Lovesick" share one. 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 { func songKey(title, artist string) string {
if key := MatchTitleKey(bracketed.ReplaceAllString(title, " "), artist); key != "" { t := versionSuffix.ReplaceAllString(bracketed.ReplaceAllString(title, " "), "")
if key := MatchTitleKey(t, artist); key != "" {
return key return key
} }
return MatchTitleKey(title, artist) return MatchTitleKey(title, artist)
@@ -28,6 +28,10 @@ func TestClassifyDuplicateGroup(t *testing.T) {
[]ClassifyMember{m("moe", "Lovesick"), m("moe", "Lovesick (Instrumental)")}, ClassDistinct}, []ClassifyMember{m("moe", "Lovesick"), m("moe", "Lovesick (Instrumental)")}, ClassDistinct},
{"Void: one album, one audio under three song titles", "acoustic", {"Void: one album, one audio under three song titles", "acoustic",
[]ClassifyMember{m("void", "Slimewar (into the void)"), m("void", "Void"), m("void", "Reach")}, ClassMismatch}, []ClassifyMember{m("void", "Slimewar (into the void)"), m("void", "Void"), m("void", "Reach")}, ClassMismatch},
{"a version named after a dash", "acoustic",
[]ClassifyMember{m("andora", "Goodbye Yesterday"), m("andora", "Goodbye Yesterday - Instrumental Version")}, ClassDistinct},
{"a dash that names no version", "acoustic",
[]ClassifyMember{m("a", "Monday Chill - Part One"), m("a", "Monday Chill - Part Two")}, ClassMismatch},
{"Slasher and Slasher II are not versions of one song", "acoustic", {"Slasher and Slasher II are not versions of one song", "acoustic",
[]ClassifyMember{m("slasher", "Slasher"), m("slasher", "Slasher II")}, ClassMismatch}, []ClassifyMember{m("slasher", "Slasher"), m("slasher", "Slasher II")}, ClassMismatch},
{"Big Me: the single and the album", "exact", {"Big Me: the single and the album", "exact",
@@ -90,6 +94,11 @@ func TestWrongSlots(t *testing.T) {
{"Void: the file is Void's; the other two slots are other songs", copies("Slimewar (into the void)", "Void", "Reach"), void, 1, []int{0, 2}, true}, {"Void: the file is Void's; the other two slots are other songs", copies("Slimewar (into the void)", "Void", "Reach"), void, 1, []int{0, 2}, true},
{"no track list", copies("Void", "Reach"), nil, -1, nil, false}, {"no track list", copies("Void", "Reach"), nil, -1, nil, false},
{"a title the track list does not hold", copies("Void", "Bonus"), void, -1, nil, false}, {"a title the track list does not hold", copies("Void", "Bonus"), void, -1, nil, false},
{"Killjoy held twice beside Dreamland's slot", []slotCopy{
{Title: "Dreamland", Artist: "Fox Stevenson", DurationMs: 167000},
{Title: "Killjoy", Artist: "Fox Stevenson", DurationMs: 167000},
{Title: "Killjoy", Artist: "Fox Stevenson", DurationMs: 166000},
}, []dbq.MbReleaseTrack{track("Killjoy", 166500), track("Dreamland", 214000)}, 1, []int{0}, true},
{"two slots fit the file", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 221500)}, -1, nil, false}, {"two slots fit the file", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 221500)}, -1, nil, false},
{"a slot near but not within the fit", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 226000)}, -1, nil, false}, {"a slot near but not within the fit", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 226000)}, -1, nil, false},
{"no slot fits", copies("Reach", "Slimewar (into the void)"), void, -1, nil, false}, {"no slot fits", copies("Reach", "Slimewar (into the void)"), void, -1, nil, false},
+11 -3
View File
@@ -49,8 +49,15 @@ type slotCopy struct {
// wrongSlots decides which copies of a one-album mismatch sit in a slot that // wrongSlots decides which copies of a one-album mismatch sit in a slot that
// is not theirs. keep is the copy whose slot fits the recording; ok is false // is not theirs. keep is the copy whose slot fits the recording; ok is false
// when the track list cannot say, and nothing should be removed. // when the track list cannot say, and nothing should be removed.
//
// Several copies may fit when they carry the same title ("Killjoy" twice
// beside "Dreamland"): they are one song held twice, not a wrong slot, so they
// are neither kept nor lost here. Once the wrong slot is emptied, the next
// sweep finds the pair on its own, and the resolver merges it as any song held
// twice on one album.
func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, losers []int, ok bool) { func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, losers []int, ok bool) {
keep = -1 keep = -1
keepKey := ""
for i, c := range copies { for i, c := range copies {
key := MatchTitleKey(c.Title, c.Artist) key := MatchTitleKey(c.Title, c.Artist)
fits, other, known := false, true, false fits, other, known := false, true, false
@@ -68,11 +75,12 @@ func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, loser
} }
} }
switch { switch {
case fits: case fits && keep >= 0:
if keep >= 0 { if key != keepKey {
return -1, nil, false return -1, nil, false
} }
keep = i case fits:
keep, keepKey = i, key
case known && other: case known && other:
losers = append(losers, i) losers = append(losers, i)
default: default: