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:
+134
-1
@@ -7,7 +7,7 @@ from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from lintunes import smart, tagging
|
||||
from lintunes import smart, tagging, trash
|
||||
from lintunes.importers.file_importer import organized_destination, unique_path
|
||||
from lintunes.models import Library, Playlist, PlaylistType
|
||||
from lintunes.perf import timed
|
||||
@@ -39,6 +39,8 @@ class LibraryManager(QObject):
|
||||
conflict_resolved = pyqtSignal(list) # list[ConflictSummary] just merged
|
||||
tag_write_failed = pyqtSignal(str, str) # track name, error text
|
||||
file_move_failed = pyqtSignal(str, str) # track name, error text
|
||||
tracks_removed = pyqtSignal(list) # track ids gone from the library
|
||||
tracks_restored = pyqtSignal(list) # track ids back after an undo
|
||||
|
||||
def __init__(self, library: Library, data_dir: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -328,6 +330,137 @@ class LibraryManager(QObject):
|
||||
self._touch_smart(None)
|
||||
return track.track_id
|
||||
|
||||
def delete_tracks(self, track_ids: list[int],
|
||||
delete_files: bool = False) -> tuple[list[int], list[tuple[str, str]]]:
|
||||
"""Remove tracks from the library (and every playlist holding them),
|
||||
optionally moving their files to the desktop trash.
|
||||
|
||||
Returns ``(removed_ids, failures)`` where a failure is
|
||||
``(track name, error)``. A track whose file can't be trashed is **kept**
|
||||
— a library entry pointing at a file still on disk is recoverable, a
|
||||
silently-orphaned one is not.
|
||||
|
||||
Playlist cleanup happens here rather than lazily on display: playlist
|
||||
edits address tracks by row index into ``track_ids``, so an id left
|
||||
dangling would shift the stored rows out of step with the displayed ones
|
||||
and a later removal would hit the wrong track.
|
||||
"""
|
||||
tracks = [self.library.tracks[tid] for tid in dict.fromkeys(track_ids)
|
||||
if tid in self.library.tracks]
|
||||
if not tracks:
|
||||
return [], []
|
||||
|
||||
failures: list[tuple[str, str]] = []
|
||||
trashed: list[tuple[object, Path]] = []
|
||||
if delete_files:
|
||||
doomed = []
|
||||
for track in tracks:
|
||||
try:
|
||||
trashed.append((track, self._trash_file(track)))
|
||||
doomed.append(track)
|
||||
except trash.TrashError as e:
|
||||
log.warning("Could not trash %s: %s", track.location, e)
|
||||
failures.append((track.name or str(track.location), str(e)))
|
||||
self.file_move_failed.emit(track.name or str(track.location), str(e))
|
||||
tracks = doomed
|
||||
if not tracks:
|
||||
return [], failures
|
||||
|
||||
snapshots = self._playlist_snapshots([t.track_id for t in tracks])
|
||||
self._remove_tracks(tracks, snapshots)
|
||||
|
||||
# The trashed paths live in a mutable cell: a redo re-trashes the files
|
||||
# and may land on different collision-suffixed names than the first pass.
|
||||
state = {"trashed": trashed}
|
||||
|
||||
def undo():
|
||||
for track, path in state["trashed"]:
|
||||
try:
|
||||
trash.restore_from_trash(path, Path(track.location))
|
||||
except trash.TrashError as e:
|
||||
# Trash emptied behind our back. Restore the library entry
|
||||
# anyway — a track pointing at a missing file is something
|
||||
# the user can see and fix; a silently dropped one isn't.
|
||||
log.warning("Could not restore %s: %s", track.location, e)
|
||||
self.file_move_failed.emit(track.name or str(path), str(e))
|
||||
self._restore_tracks(tracks, snapshots)
|
||||
|
||||
def redo():
|
||||
state["trashed"] = []
|
||||
if delete_files:
|
||||
for track in tracks:
|
||||
try:
|
||||
state["trashed"].append((track, self._trash_file(track)))
|
||||
except trash.TrashError as e:
|
||||
log.warning("Could not re-trash %s: %s", track.location, e)
|
||||
self.file_move_failed.emit(
|
||||
track.name or str(track.location), str(e))
|
||||
self._remove_tracks(tracks, snapshots)
|
||||
|
||||
self.undo_stack.push(Command(
|
||||
"Delete from Library" if delete_files else "Remove from Library",
|
||||
undo=undo, redo=redo))
|
||||
return [t.track_id for t in tracks], failures
|
||||
|
||||
def _trash_file(self, track) -> Path:
|
||||
"""Send a track's file to the trash and tidy up the folders it emptied."""
|
||||
if not track.location:
|
||||
raise trash.TrashError("This track has no file on disk.")
|
||||
src = Path(track.location)
|
||||
dest = trash.send_to_trash(src)
|
||||
root = self.organize_root()
|
||||
if root is not None and root in src.parents:
|
||||
self._prune_empty_dirs(src.parent, root)
|
||||
return dest
|
||||
|
||||
def _playlist_snapshots(self, track_ids: list[int]) -> dict[str, list[int]]:
|
||||
"""``{pid: track_ids before removal}`` for each non-smart playlist holding
|
||||
any of these tracks — enough for undo to restore original positions."""
|
||||
doomed = set(track_ids)
|
||||
return {p.persistent_id: list(p.track_ids)
|
||||
for p in self.library.playlists.values()
|
||||
if not p.is_smart and doomed.intersection(p.track_ids)}
|
||||
|
||||
def _remove_tracks(self, tracks: list, snapshots: dict[str, list[int]]):
|
||||
doomed = {t.track_id for t in tracks}
|
||||
for track in tracks:
|
||||
self.library.tracks.pop(track.track_id, None)
|
||||
self._invalidate_artwork(track)
|
||||
for pid in snapshots:
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist is not None:
|
||||
self._set_track_ids(
|
||||
pid, [tid for tid in playlist.track_ids if tid not in doomed])
|
||||
self._dirty_tracks = True
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
self._touch_smart(None)
|
||||
self.tracks_removed.emit(sorted(doomed))
|
||||
|
||||
def _restore_tracks(self, tracks: list, snapshots: dict[str, list[int]]):
|
||||
for track in tracks:
|
||||
self.library.tracks[track.track_id] = track
|
||||
self._max_track_id = max(self._max_track_id, track.track_id)
|
||||
for pid, ids in snapshots.items():
|
||||
if pid in self.library.playlists:
|
||||
self._set_track_ids(pid, list(ids))
|
||||
self._dirty_tracks = True
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
self._touch_smart(None)
|
||||
self.tracks_restored.emit(sorted(t.track_id for t in tracks))
|
||||
self.playlists_changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _invalidate_artwork(track):
|
||||
"""Drop the track's exported art. Imported lazily: mpris pulls in QtDBus,
|
||||
which core library code shouldn't need just to delete a song."""
|
||||
try:
|
||||
from lintunes.mpris import invalidate_artwork
|
||||
invalidate_artwork(track)
|
||||
except Exception: # no D-Bus bindings here — the cache just ages out
|
||||
pass
|
||||
|
||||
def update_track_fields(self, track_id: int, fields: dict):
|
||||
"""In-memory-only field update (no file write, not undoable)."""
|
||||
track = self.library.tracks.get(track_id)
|
||||
|
||||
Reference in New Issue
Block a user