feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own orphan cleanup does and records each path in excluded_paths. The exclusion is not an enhancement: without it the next scan finds the file, sees no row and imports it again, so the button undoes itself. The soft scan compares files on disk against rows in the database, so surveyAudioFiles and countAudioFiles both take the exclusion set — otherwise an excluded path makes the two disagree forever and queues a full scan on every launch. Deleting a row cascades to queue_tracks, so the removal calls the same CompactQueue hook RemoveLibrary does. Also lands the requested badge: library-status-indicator is a button again where it can act, utils/library-status.ts states once what owning and wanting mean, and the long-declared queued state finally has a producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
@@ -63,6 +63,9 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||
import { RemoveFromLibrary } from '@go/library/Library';
|
||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
@@ -1212,8 +1215,29 @@ export class TrackList
|
||||
'shortcut:tracklist-play',
|
||||
this.handleShortcutPlay,
|
||||
);
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
'shortcut:tracklist-delete',
|
||||
this.handleShortcutDelete,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete opens the confirmation and does nothing else.
|
||||
*
|
||||
* That is the whole design of the binding: one keystroke from a
|
||||
* focused row, a key that *asks* is defensible and a key that
|
||||
* *acts* is not — so this is the same dialog the menu command
|
||||
* opens, reached by a different route.
|
||||
*/
|
||||
private handleShortcutDelete = (): void => {
|
||||
const filePaths = this.selection.getSelectedKeysOrdered();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
void this.removeFromLibrary(filePaths);
|
||||
};
|
||||
|
||||
/** Enter plays the selection — the `tracklist.play` binding, which
|
||||
* has existed in the defaults and in Settings since it was written
|
||||
* and has never had anything on the other end of it. */
|
||||
@@ -1639,12 +1663,79 @@ export class TrackList
|
||||
void this.openBatchTrackDetails(filePaths);
|
||||
}
|
||||
break;
|
||||
case 'remove-from-library':
|
||||
// The only destructive command in this menu: it asks
|
||||
// first, and it keeps the selection until the user has
|
||||
// answered — the dialog names a count, and clearing the
|
||||
// selection under it would make that count a claim
|
||||
// about nothing.
|
||||
this.ctxMenu.close();
|
||||
void this.removeFromLibrary(filePaths);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* "Remove from library", behind a confirmation that says what it
|
||||
* does *and* what it does not.
|
||||
*
|
||||
* The second half is the point. This deletes the database rows and
|
||||
* stops the scanner importing those paths again; the audio files
|
||||
* are left exactly where they are. A user who reads "remove" as
|
||||
* "delete" and finds their music gone would have been failed by the
|
||||
* copy, not by the operation — so the copy says so in the impact
|
||||
* line, where the consequence of every other destructive action in
|
||||
* the app is written.
|
||||
*/
|
||||
private async removeFromLibrary(filePaths: string[]) {
|
||||
const count = filePaths.length;
|
||||
const only =
|
||||
count === 1
|
||||
? tracksByFilePath(this.tracks).get(filePaths[0]!)
|
||||
: undefined;
|
||||
|
||||
const ok = await confirmAction({
|
||||
title:
|
||||
count === 1
|
||||
? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
|
||||
: `Remove ${count.toLocaleString()} tracks from the library?`,
|
||||
message:
|
||||
count === 1
|
||||
? 'It is removed from YellowJacket and will not be added' +
|
||||
' back by a future scan.'
|
||||
: 'They are removed from YellowJacket and will not be' +
|
||||
' added back by a future scan.',
|
||||
impact:
|
||||
count === 1
|
||||
? 'The file is not deleted — it stays on disk exactly' +
|
||||
' where it is. A full rescan brings it back.'
|
||||
: 'The files are not deleted — they stay on disk exactly' +
|
||||
' where they are. A full rescan brings them back.',
|
||||
confirmLabel:
|
||||
count === 1
|
||||
? 'Remove track'
|
||||
: `Remove ${count.toLocaleString()} tracks`,
|
||||
danger: true,
|
||||
});
|
||||
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await RemoveFromLibrary(filePaths);
|
||||
this.selection.clear();
|
||||
} catch (error) {
|
||||
console.error('Error removing tracks from library:', error);
|
||||
notificationStore.persistent({
|
||||
title: 'Could not remove from library',
|
||||
text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
this.selection.getSelectedKeysOrdered();
|
||||
@@ -2105,6 +2196,20 @@ export class TrackList
|
||||
></wa-icon>
|
||||
Track Details
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuAction(
|
||||
'remove-from-library',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name="trash"
|
||||
></wa-icon>
|
||||
Remove from Library
|
||||
</wa-dropdown-item>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
Reference in New Issue
Block a user