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:
@@ -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]):
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user