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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.5.2"
__version__ = "0.6.0"
+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 ----
+134 -1
View File
@@ -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)
+22
View File
@@ -400,6 +400,28 @@ class Player(QObject):
self._index = self._queue.index(current_id)
self._reshuffle()
def drop_tracks(self, track_ids):
"""Forget tracks that no longer exist in the library.
Stops if one of them is playing; otherwise the current track keeps its
place in the shortened queue. Without this the queue keeps dead ids and
``_load_current`` stops the whole thing with an error dialog when the
walk eventually reaches one.
"""
doomed = set(track_ids)
if not doomed:
return
if self._current_track is not None and self._current_track.track_id in doomed:
self.stop()
current_id = None
else:
current_id = (self._queue[self._index]
if 0 <= self._index < len(self._queue) else None)
self._queue = [tid for tid in self._queue if tid not in doomed]
self._index = (self._queue.index(current_id)
if current_id is not None and current_id in self._queue else -1)
self._reshuffle()
# ---- shuffle ----
def is_shuffle(self) -> bool:
+179
View File
@@ -0,0 +1,179 @@
"""Move files to the desktop trash, per the freedesktop.org Trash spec 1.0.
Music usually lives on whatever volume the user mounted it from, not on the home
filesystem — so the trash that matters is the one at the top of the *file's own*
mount (``<topdir>/.Trash-<uid>``), not ``~/.local/share/Trash``. Getting that
wrong would mean a slow cross-device copy and, worse, a trashed file the file
manager's "Restore" can't put back, because the recorded original path would sit
on a different filesystem than the trash holding it.
Nothing here knows about the library; ``LibraryManager`` is the only caller.
"""
import logging
import os
import stat
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, unquote
log = logging.getLogger(__name__)
class TrashError(OSError):
"""Raised when a file could not be trashed. The file is left where it is."""
def _home_trash() -> Path:
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
return Path(base) / "Trash"
def _topdir(path: Path) -> Path:
"""The mount point the path lives on."""
current = path if path.is_dir() else path.parent
current = current.resolve()
while not os.path.ismount(current) and current != current.parent:
current = current.parent
return current
def trash_dir_for(path: Path) -> tuple[Path, Path | None]:
"""Where ``path`` should be trashed.
Returns ``(trash_dir, topdir)``. ``topdir`` is None for the home trash — the
spec wants an absolute ``Path=`` there and a topdir-relative one in a volume
trash, so the caller needs to know which it got.
"""
home_trash = _home_trash()
top = _topdir(path)
# Same filesystem as the home trash? Then that's the right one, whether or
# not it already exists. Compare devices rather than paths: /home is often
# its own mount, which would otherwise look like a "volume".
try:
home_dev = os.stat(_first_existing(home_trash)).st_dev
if os.stat(path).st_dev == home_dev:
return home_trash, None
except OSError: # no readable home — fall through to the volume trash
pass
# Spec: an admin-provided <topdir>/.Trash must be a sticky, non-symlink dir,
# and we get our own uid-named subdir inside it. Otherwise we make our own.
uid = os.getuid()
admin = top / ".Trash"
try:
st = os.lstat(admin)
if stat.S_ISDIR(st.st_mode) and (st.st_mode & stat.S_ISVTX):
return admin / str(uid), top
except OSError:
pass
return top / f".Trash-{uid}", top
def _first_existing(path: Path) -> Path:
"""Nearest existing ancestor (inclusive) — for stat'ing a dir we may create."""
current = path
while not current.exists() and current != current.parent:
current = current.parent
return current
def _ensure_trash(trash_dir: Path) -> tuple[Path, Path]:
files_dir, info_dir = trash_dir / "files", trash_dir / "info"
try:
for directory in (trash_dir, files_dir, info_dir):
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
except OSError as e:
raise TrashError(f"Could not prepare the trash at {trash_dir}: {e}") from e
return files_dir, info_dir
def _claim(info_dir: Path, name: str) -> tuple[Path, str]:
"""Reserve a name in the trash by creating its .trashinfo exclusively.
Creating the info file is what makes the claim atomic against another app
trashing a same-named file at the same moment; the audio file is moved in
afterwards, under the name we just won.
"""
stem, suffix = Path(name).stem, Path(name).suffix
for i in range(0, 1000):
candidate = name if i == 0 else f"{stem} {i}{suffix}"
info_path = info_dir / f"{candidate}.trashinfo"
try:
fd = os.open(info_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
continue
except OSError as e:
raise TrashError(f"Could not write {info_path}: {e}") from e
os.close(fd)
return info_path, candidate
raise TrashError(f"Could not find a free name in {info_dir} for {name}")
def send_to_trash(path: Path) -> Path:
"""Move ``path`` into the trash for its filesystem; return its new location.
Raises ``TrashError`` without touching the file if anything goes wrong, so a
caller can treat a failure as "this was not deleted".
"""
path = Path(path)
if not path.exists():
raise TrashError(f"{path} does not exist")
trash_dir, top = trash_dir_for(path)
files_dir, info_dir = _ensure_trash(trash_dir)
info_path, name = _claim(info_dir, path.name)
# Path= is relative to the topdir in a volume trash, absolute in the home
# one, and percent-encoded either way.
original = path.resolve()
recorded = original.relative_to(top) if top is not None else original
info_path.write_text(
"[Trash Info]\n"
f"Path={quote(str(recorded))}\n"
f"DeletionDate={datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}\n",
encoding="utf-8")
dest = files_dir / name
try:
os.rename(original, dest)
except OSError as e:
info_path.unlink(missing_ok=True) # never leave a half-trashed pair
raise TrashError(f"Could not move {path} to the trash: {e}") from e
log.info("Trashed %s -> %s", original, dest)
return dest
def restore_from_trash(trashed: Path, original: Path) -> None:
"""Move a trashed file back and drop its .trashinfo. Raises TrashError."""
trashed, original = Path(trashed), Path(original)
if not trashed.exists():
raise TrashError(f"{trashed} is no longer in the trash")
try:
original.parent.mkdir(parents=True, exist_ok=True)
os.rename(trashed, original)
except OSError as e:
raise TrashError(f"Could not restore {trashed} to {original}: {e}") from e
info_path = trashed.parent.parent / "info" / f"{trashed.name}.trashinfo"
info_path.unlink(missing_ok=True)
def original_path(trashed: Path) -> Path | None:
"""The path a trashed file came from, per its .trashinfo — None if unreadable.
Only used by tests and diagnostics; restoring uses the location the library
already knows.
"""
info_path = Path(trashed).parent.parent / "info" / f"{Path(trashed).name}.trashinfo"
try:
text = info_path.read_text(encoding="utf-8")
except OSError:
return None
for line in text.splitlines():
if line.startswith("Path="):
recorded = Path(unquote(line[len("Path="):]))
if recorded.is_absolute():
return recorded
return _topdir(Path(trashed)) / recorded
return None