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
@@ -175,6 +175,55 @@ describe('library store: caching', () => {
expect(calls()).toEqual([]);
});
/**
* Removing tracks is the same bargain the play count makes, one
* collection wider: the event carries the paths so the tracks array
* — the expensive one — is patched rather than refetched, while the
* album/artist/genre summaries, whose counts really did change, are
* dropped and reloaded.
*/
describe('tracks removed from the library', () => {
beforeEach(async () => {
emit(Events.TracksRemovedFromLibrary, {
filePaths: ['/a.mp3'],
count: 1,
});
await flush();
});
it('does not refetch the tracks', () => {
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
});
it('splices the removed track out in place', () => {
expect(libraryStore.getCachedTracks()?.map((t) => t.FilePath)).toEqual([
'/b.mp3',
]);
});
it('reloads the summaries, whose counts changed', () => {
expect(
[
'library.Library.GetAllAlbums',
'library.Library.GetAllArtists',
'library.Library.GetAllGenresWithCounts',
].map((path) => calls(path).length),
).toEqual([1, 1, 1]);
});
});
it('ignores a removal naming a track it does not hold, without dropping the array', async () => {
const before = libraryStore.getCachedTracks();
emit(Events.TracksRemovedFromLibrary, {
filePaths: ['/not-in-this-library.mp3'],
count: 1,
});
await flush();
expect(libraryStore.getCachedTracks()).toBe(before);
});
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
libraryStore.setScrollPosition('albums', 4200);
emit(Events.LibraryScanComplete);