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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:21:50 -04:00
co-authored by Claude Opus 5
parent 01996d5fe2
commit 6fad0247ee
10 changed files with 878 additions and 5 deletions
+48
View File
@@ -1,5 +1,53 @@
## Done
### Round 33 (2026-08-14) — Delete songs from the library (v0.6.0)
An iTunes 12 habit LinTunes had no answer for: getting rid of a song you don't
want to keep. `LibraryManager` had `add_track` but no inverse, and nothing in the
app had ever deleted a local music file. Deletion goes to the desktop trash
rather than `unlink`, so it's recoverable both by Ctrl+Z and from the file
manager afterwards.
Two constraints shaped it. The trash is **per-filesystem** — the music sits on a
mounted volume, so the right destination is `<topdir>/.Trash-<uid>`, and using
the home trash would mean a cross-device copy plus a recorded original path
"Restore" can't reach. And playlist cleanup has to be **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.
- [x] `lintunes/trash.py` — freedesktop Trash spec 1.0 by hand, no new
dependency (a new pip dep would need a manual `pip install -e .` on the
other machine). Picks the trash by the file's own mount, writes the
percent-encoded `.trashinfo` with `O_EXCL` first to claim the name, then
renames the file in; a failed rename cleans up the info file. `TrashError`
leaves the file untouched.
- [x] `LibraryManager.delete_tracks(ids, delete_files=)` — the single funnel.
Removes from `library.tracks` and every non-smart playlist, prunes the
dirs it emptied, invalidates the exported artwork, and pushes one undo
Command covering the whole selection. A file that can't be trashed
**keeps its track** rather than leaving an orphaned entry. New
`tracks_removed` / `tracks_restored` signals.
- [x] Undo restores the file from the trash *and* the track's original position
in each playlist; redo re-trashes (re-recording paths, since a redo can
land on a different collision-suffixed name). If the trash was emptied
behind us, the library entry is restored anyway — a visible broken track
beats a silent second loss.
- [x] `Player.drop_tracks()` — stops if the deleted track is playing, otherwise
keeps the current track's place in the shortened queue. Previously a dead
id in the queue made `_load_current` stop everything with an error dialog
once the walk reached it.
- [x] Context menu gains "Remove from Library" and "Remove from Library and
Delete File", in **both** the library and playlist views. No keyboard
shortcut — `Del` still means "remove from playlist". Confirmation uses the
existing destructive-dialog idiom (DestructiveRole button, Cancel as
default) and names the playlists affected.
- [x] `tests/test_round33.py` (26 tests) covers the spec details (volume vs home
trash, percent-encoding, collision suffixes, no orphaned info file on
failure), the manager (playlist cleanup, pruning, partial failure, undo of
position, emptied-trash undo) and `drop_tracks`. Verified for real against
`/run/media/trav/tummult/.Trash-1000` with a scratch file.
### Round 32 (2026-08-14) — Custom start times survive a track change (v0.5.2)
trav reported "Pretty Girls is a Motherfucker" starting at 0:00 despite its