v0.6.0: delete songs from the library

Right-click a track (or a multi-selection) for "Remove from Library" or
"Remove from Library and Delete File". The second moves the file to the
desktop trash rather than unlinking it, so it stays recoverable by Ctrl+Z
in-app and by "Restore" from the file manager afterwards.

The trash is per-filesystem: music lives on a mounted volume, so the file
belongs in <topdir>/.Trash-<uid> with a topdir-relative, percent-encoded
Path. Using ~/.local/share/Trash would be a cross-device copy recording an
original path "Restore" can't reach. lintunes/trash.py implements the
freedesktop spec directly rather than adding a dependency that would need
a manual reinstall on the other machine.

Playlist cleanup is synchronous with the removal: playlist edits address
tracks by row index into track_ids while the view skips ids missing from
the library, so a dangling id would desync the two and make a later
"Remove from Playlist" hit the wrong track.

A file that can't be trashed keeps its track — better a song to delete
again than a library entry orphaned from a file still on disk. Player
gains drop_tracks() so deleting the playing track stops cleanly instead
of erroring out when the queue walk later reaches a dead id.
This commit is contained in:
2026-08-14 11:21:50 -04:00
parent d1371ab703
commit c0455c7f75
10 changed files with 878 additions and 5 deletions
+79
View File
@@ -171,7 +171,13 @@ class MainWindow(QMainWindow):
view.show_in_playlist_requested.connect(self._show_track_in_playlist)
view.table.tracks_changed.connect(self._update_totals)
view.table.download_art_requested.connect(self._download_album_art)
view.table.remove_from_library_requested.connect(
lambda ids: self._delete_tracks(ids, delete_files=False))
view.table.delete_from_library_requested.connect(
lambda ids: self._delete_tracks(ids, delete_files=True))
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
manager.tracks_removed.connect(self._on_tracks_removed)
manager.tracks_restored.connect(self._on_tracks_restored)
self.player.error_occurred.connect(
lambda msg: QMessageBox.warning(self, "Can't play track", msg))
self.player.track_missing.connect(self._on_track_missing)
@@ -589,6 +595,79 @@ class MainWindow(QMainWindow):
def _import_files_to_playlist(self, paths, pid, position=None):
self.import_files([Path(p) for p in paths], pid, position)
# ---- deleting tracks ----
def _delete_tracks(self, track_ids: list[int], delete_files: bool):
tracks = [self._manager.library.tracks[tid] for tid in track_ids
if tid in self._manager.library.tracks]
if not tracks or not self._confirm_delete(tracks, delete_files):
return
removed, failures = self._manager.delete_tracks(
[t.track_id for t in tracks], delete_files=delete_files)
if removed:
where = "moved to Trash" if delete_files else "file kept"
self.statusBar().showMessage(
f"Removed {len(removed)} song(s) from your library "
f"({where}) — undo with Ctrl+Z", 8000)
if failures:
QMessageBox.warning(
self, "Couldn't delete",
f"{len(failures)} file(s) could not be moved to the Trash, so "
"those songs are still in your library:\n\n"
+ "\n".join(f"• “{name}”: {err}" for name, err in failures[:5]))
def _confirm_delete(self, tracks: list, delete_files: bool) -> bool:
if len(tracks) == 1:
track = tracks[0]
who = f" by {track.artist}" if track.artist else ""
question = f"Delete “{track.name}”{who} from your library?"
else:
question = f"Delete {len(tracks)} songs from your library?"
if delete_files:
fate = ("The file will be moved to your Trash, so you can put it "
"back later." if len(tracks) == 1 else
"The files will be moved to your Trash, so you can put "
"them back later.")
else:
fate = ("The file stays where it is on disk."
if len(tracks) == 1 else
"The files stay where they are on disk.")
doomed = {t.track_id for t in tracks}
playlists = {pid for pid, _name in
[entry for t in tracks
for entry in self._manager.playlists_containing(t.track_id)]}
lines = [question, fate]
if playlists:
lines.append(f"{'It' if len(doomed) == 1 else 'They'} will also be "
f"removed from {len(playlists)} playlist(s).")
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
box.setWindowTitle("Delete from Library")
box.setText("\n".join(lines))
delete_button = box.addButton(
"Delete Song" if len(tracks) == 1 else "Delete Songs",
QMessageBox.ButtonRole.DestructiveRole)
cancel = box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.setDefaultButton(cancel)
box.exec()
return box.clickedButton() is delete_button
def _on_tracks_removed(self, track_ids: list):
"""Tracks left the library (deleted here, or undone/redone). Keep the
player off dead ids and rebuild the library view."""
self.player.drop_tracks(set(track_ids))
self._on_library_reloaded()
self._update_totals()
def _on_tracks_restored(self, track_ids: list):
"""An undo put tracks back — show them again."""
self._on_library_reloaded()
self._update_totals()
# ---- album art download ----
def _download_album_art(self, track_ids: list[int]):
+14
View File
@@ -426,6 +426,8 @@ class TrackTableView(QTableView):
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
rating_edited = pyqtSignal(int, int) # track_id, new rating 0-100
download_art_requested = pyqtSignal(list) # selected track ids
remove_from_library_requested = pyqtSignal(list) # track ids, file kept
delete_from_library_requested = pyqtSignal(list) # track ids, file trashed
def __init__(self, parent=None, playlist_mode=False):
super().__init__(parent)
@@ -701,6 +703,14 @@ class TrackTableView(QTableView):
if self._playlist_mode and self._content_editable:
menu.addSeparator()
remove_action = menu.addAction("Remove from Playlist\tDel")
# Library removal is offered in both modes (unlike the playlist-only
# remove above) and has no keyboard shortcut — Del stays "remove from
# playlist", and touching files shouldn't be one keystroke away.
menu.addSeparator()
unlist_action = menu.addAction("Remove from Library")
delete_action = menu.addAction("Remove from Library and Delete File")
chosen = menu.exec(self.viewport().mapToGlobal(pos))
if chosen is None:
return
@@ -722,6 +732,10 @@ class TrackTableView(QTableView):
self.show_in_playlist_requested.emit(selected[0], chosen.data())
elif remove_action is not None and chosen is remove_action:
self.remove_requested.emit(self.selected_source_rows())
elif chosen is unlist_action:
self.remove_from_library_requested.emit(self.selected_track_ids())
elif chosen is delete_action:
self.delete_from_library_requested.emit(self.selected_track_ids())
# ---- rating hover / click ----