feat(tracks): remove from library behind a confirmation

The context menu's one destructive command. Its impact line says the
files are not deleted, because a user who reads "remove" as "delete"
and finds their music gone was failed by the copy rather than by the
operation.

The store patches rather than invalidates: the event carries the paths,
so the tracks array — the expensive collection — is spliced in place
and only the album/artist/genre summaries, whose counts really did
change, are refetched. It falls back to a full invalidate when a tracks
fetch is already in flight, which is the one case a patch cannot be
shown to be equivalent to.

Deleting an audio_files row cascades to queue_tracks, so the removal
also compacts the queue — the same reload RemoveLibrary does, which
unloads the player if the removed track was the one playing.
This commit is contained in:
2026-08-13 13:11:31 -04:00
parent acbe7c4676
commit 6d97e3c872
5 changed files with 236 additions and 0 deletions
+9
View File
@@ -129,6 +129,15 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
}
}
// Deleting an audio_files row cascades to queue_tracks, so the
// queue's in-memory copy now holds tracks the database does not —
// including, possibly, the one playing. This is the same reload
// RemoveLibrary does, and it unloads the player if the current
// track was among them.
if l.removalHooks.CompactQueue != nil {
l.removalHooks.CompactQueue()
}
// An album, artist or genre whose last track just went is now a row
// with nothing behind it, and the album list selects from
// release_groups rather than from audio_files — so it would keep
+26
View File
@@ -257,6 +257,32 @@ func TestRemoveFromLibrary_EmitsPatchablePayload(t *testing.T) {
}
}
// TestRemoveFromLibrary_CompactsTheQueue pins the half that is invisible
// from the track list: deleting an audio_files row cascades to
// queue_tracks, so the queue's in-memory copy — and the player, if it
// was the track playing — has to be told.
func TestRemoveFromLibrary_CompactsTheQueue(t *testing.T) {
t.Parallel()
lib, dir, paths, _, libID := setupScanLibrary(t, 2)
lib.scanInternal(libID, "Test", dir)
compacted := 0
lib.SetRemovalHooks(RemovalHooks{
CompactQueue: func() { compacted++ },
})
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
if compacted != 1 {
t.Errorf("CompactQueue called %d times, want 1", compacted)
}
}
// TestRemoveFromLibrary_RejectsAnEmptyRequest keeps a stray Delete on
// an empty selection from reaching the database at all.
func TestRemoveFromLibrary_RejectsAnEmptyRequest(t *testing.T) {