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
5 changed files with 66 additions and 14 deletions
Showing only changes of commit a3d9effca3 - Show all commits
+2 -1
View File
@@ -101,7 +101,8 @@ func TestWrongSlots(t *testing.T) {
}, []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},
{"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, and every slot is clearly another song's", copies("Reach", "Slimewar (into the void)"), void, -1, []int{0, 1}, true},
{"no slot fits, but one is near", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 226000), track("Reach", 281000)}, -1, nil, false},
{"an unknown length counts for nothing", copies("Void", "Reach"), []dbq.MbReleaseTrack{track("Void", 220000), track("Reach", 0)}, -1, nil, false},
}
for _, tc := range cases {
@@ -405,6 +405,27 @@ func TestResolveDuplicates_RemovesACopyFromAnotherSongsSlot_Integration(t *testi
}
}
// One recording filling two slots whose songs are both clearly something else
// (Shnabubula's whole album in one file): both copies go, nothing is kept.
func TestResolveDuplicates_RemovesEveryCopyWhenNoSlotIsTheirs_Integration(t *testing.T) {
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
seedTrackList(t, f.pool, f.album, map[string]int{"Void": 282000, "Reach": 421000})
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
if err != nil {
t.Fatal(err)
}
if res.WrongFiles != 2 || exists(f.aPath) || exists(f.bPath) {
t.Fatalf("result %+v, void exists %v, reach exists %v; want both copies removed",
res, exists(f.aPath), exists(f.bPath))
}
if status, _, _, auto := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "merged" || !auto {
t.Errorf("group = (%s, auto=%v), want settled by the resolver", status, auto)
}
if n := auditCount(t, f.pool, "duplicate_wrong_file", f.groupID); n != 1 {
t.Errorf("wrong-file audit rows = %d, want 1", n)
}
}
// An album whose track list the catalogue never read: the resolver asks for
// it, once, and decides with what comes back.
func TestResolveDuplicates_FetchesAMissingTrackList_Integration(t *testing.T) {
+23 -13
View File
@@ -31,6 +31,7 @@ import (
// long as the file and every other slot's song is clearly not, the copies
// in the other slots are removed and their tracks marked missing, so the
// reacquisition sweeper wants the album back and a good copy fills them.
// When every slot's song is clearly not the file, every copy goes.
// Anything less certain stays with the operator.
const (
@@ -52,8 +53,11 @@ type slotCopy struct {
}
// 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
// when the track list cannot say, and nothing should be removed.
// is not theirs. keep is the copy whose slot fits the recording, or -1 when
// every copy is clearly another song's (Shnabubula's whole album, 45 minutes
// in one file, filed as both "Fading Light" and "Looking for Answers"): then
// all of them go. ok is false 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
@@ -92,7 +96,9 @@ func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, loser
return -1, nil, false
}
}
if keep < 0 || len(losers) == 0 {
// A copy that neither fits nor is clearly another song's has returned
// above, so with no keep every copy is a loser.
if len(losers) == 0 {
return -1, nil, false
}
return keep, losers, true
@@ -261,17 +267,21 @@ func removeWrongSlots(
if err != nil {
return 0, err
}
kept := g.members[keep]
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateWrongFile, map[string]any{
"automatic": true,
"group_id": syncpkg.FormatUUID(g.id),
"artist": kept.ArtistName,
"kept_title": kept.Title,
"kept_path": kept.FilePath,
"removed": removedFiles,
})
details := map[string]any{
"automatic": true,
"group_id": syncpkg.FormatUUID(g.id),
"artist": g.members[0].ArtistName,
"removed": removedFiles,
}
keptPath := ""
if keep >= 0 {
kept := g.members[keep]
details["kept_title"], details["kept_path"] = kept.Title, kept.FilePath
keptPath = kept.FilePath
}
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateWrongFile, details)
logger.Info("duplicate resolve: removed copies from slots that were not theirs",
"group_id", syncpkg.FormatUUID(g.id), "kept", kept.FilePath, "removed", len(gone))
"group_id", syncpkg.FormatUUID(g.id), "kept", keptPath, "removed", len(gone))
return len(gone), removeErr
}
@@ -248,6 +248,11 @@
{item.details.artist}: {(item.details.titles ?? []).join(' and ')} are different versions,
not duplicates. Both kept.
</p>
{:else if item.action === 'duplicate_wrong_file' && !item.details.kept_title}
<p class="text-text-primary">
{item.details.artist}: one recording was filed as {removed.map((r) => r.title).join(' and ')},
and it is none of them. Removed; the album will be fetched again.
</p>
{:else if item.action === 'duplicate_wrong_file'}
<p class="text-text-primary">
{item.details.artist}: the recording of {item.details.kept_title} was also filed as
@@ -372,6 +372,18 @@ describe('admin duplicates', () => {
kept_title: 'Void',
removed: [{ track_id: 't-9', file_path: '/data/Music/Savant/Void/17 - Reach.m4a', title: 'Reach' }]
}
},
{
id: 'a-4',
action: 'duplicate_wrong_file',
created_at: ago(HOUR),
details: {
artist: 'Shnabubula',
removed: [
{ track_id: 't-5', file_path: '/data/Music/Shnabubula/05 - Fading Light.mp3', title: 'Fading Light' },
{ track_id: 't-10', file_path: '/data/Music/Shnabubula/10 - Looking for Answers.mp3', title: 'Looking for Answers' }
]
}
}
]);
await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i }));
@@ -381,6 +393,9 @@ describe('admin duplicates', () => {
'Savant: the recording of Void was also filed as Reach. Removed from there; the album will be fetched again.'
);
expect(items[1]).toContain('/data/Music/Savant/Void/17 - Reach.m4a');
expect(items[2]).toContain(
'Shnabubula: one recording was filed as Fading Light and Looking for Answers, and it is none of them. Removed; the album will be fetched again.'
);
});
});