Files
lintunes/tests/test_round33.py
T
travandClaude Opus 5 6fad0247ee 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>
2026-08-14 11:21:50 -04:00

387 lines
15 KiB
Python

"""Round 33: deleting songs from the library.
Right-clicking a track (or a multi-selection) now offers "Remove from Library"
and "Remove from Library and Delete File". The second moves the file to the
desktop trash rather than unlinking it, so it stays recoverable both by Ctrl+Z
in-app and by "Restore" from the file manager afterwards.
Three things this covers that are easy to get wrong:
1. **The trash is per-filesystem.** Music usually sits on a mounted volume, not
the home filesystem, so the file belongs in ``<topdir>/.Trash-<uid>``. Putting
it in ``~/.local/share/Trash`` would be a cross-device copy and would record
an original path the desktop can't restore to.
2. **Playlist cleanup has to be synchronous.** 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.
3. **A file that can't be trashed must keep its track.** Better a song you have
to delete again than a library entry orphaned from a file still on disk.
"""
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from lintunes import trash
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Playlist, PlaylistType, Track
# --------------------------------------------------------------------------
# 1. lintunes/trash.py — the freedesktop Trash spec
# --------------------------------------------------------------------------
@pytest.fixture
def home_trash(tmp_path, monkeypatch):
"""Point XDG_DATA_HOME at tmp_path so the 'home' trash is under test."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg"))
return tmp_path / "xdg" / "Trash"
def _info_for(trashed: Path) -> dict:
text = (trashed.parent.parent / "info" / f"{trashed.name}.trashinfo").read_text()
assert text.splitlines()[0] == "[Trash Info]"
return dict(line.split("=", 1) for line in text.splitlines()[1:] if "=" in line)
class TestTrashModule:
def test_file_lands_in_trash_with_info(self, tmp_path, home_trash):
song = tmp_path / "song.mp3"
song.write_bytes(b"audio")
dest = trash.send_to_trash(song)
assert not song.exists()
assert dest == home_trash / "files" / "song.mp3"
assert dest.read_bytes() == b"audio"
info = _info_for(dest)
# Home trash records an absolute path.
assert info["Path"] == str(song.resolve())
assert "T" in info["DeletionDate"]
def test_path_is_percent_encoded(self, tmp_path, home_trash):
song = tmp_path / "a song & more.mp3"
song.write_bytes(b"x")
info = _info_for(trash.send_to_trash(song))
assert "%20" in info["Path"] and " " not in info["Path"]
assert trash.original_path(
home_trash / "files" / "a song & more.mp3") == song.resolve()
def test_volume_trash_used_for_other_filesystem(self, tmp_path, monkeypatch):
"""A file on its own mount goes to <topdir>/.Trash-<uid> with a
topdir-relative Path, not to the home trash."""
volume = tmp_path / "volume"
(volume / "music").mkdir(parents=True)
song = volume / "music" / "song.mp3"
song.write_bytes(b"audio")
monkeypatch.setattr(os.path, "ismount", lambda p: Path(p) == volume)
real_stat = os.stat
monkeypatch.setattr(
os, "stat",
lambda p, *a, **k: (MagicMock(st_dev=99) if Path(p) == song
else real_stat(p, *a, **k)))
dest = trash.send_to_trash(song)
assert dest == volume / f".Trash-{os.getuid()}" / "files" / "song.mp3"
assert _info_for(dest)["Path"] == "music/song.mp3" # relative to topdir
def test_name_collision_gets_a_suffix(self, tmp_path, home_trash):
first = tmp_path / "song.mp3"
first.write_bytes(b"one")
trash.send_to_trash(first)
(tmp_path / "other").mkdir()
second = tmp_path / "other" / "song.mp3"
second.write_bytes(b"two")
dest = trash.send_to_trash(second)
assert dest.name == "song 1.mp3"
assert dest.read_bytes() == b"two"
assert (home_trash / "files" / "song.mp3").read_bytes() == b"one"
def test_restore_round_trip(self, tmp_path, home_trash):
song = tmp_path / "song.mp3"
song.write_bytes(b"audio")
dest = trash.send_to_trash(song)
trash.restore_from_trash(dest, song)
assert song.read_bytes() == b"audio"
assert not dest.exists()
# The .trashinfo must go too, or the name stays claimed forever.
assert not (home_trash / "info" / "song.mp3.trashinfo").exists()
def test_failed_move_leaves_no_orphan_info(self, tmp_path, home_trash,
monkeypatch):
song = tmp_path / "song.mp3"
song.write_bytes(b"audio")
def boom(*a, **k):
raise OSError("disk on fire")
monkeypatch.setattr(os, "rename", boom)
with pytest.raises(trash.TrashError):
trash.send_to_trash(song)
assert song.exists() # caller can still treat it as un-deleted
assert list((home_trash / "info").iterdir()) == []
def test_missing_file_raises(self, tmp_path, home_trash):
with pytest.raises(trash.TrashError):
trash.send_to_trash(tmp_path / "nope.mp3")
# --------------------------------------------------------------------------
# 2. LibraryManager.delete_tracks
# --------------------------------------------------------------------------
def _library(tmp_path, count=3):
"""A library whose tracks are real files under an organized music tree."""
music = tmp_path / "music"
library = Library(music_folder=str(music))
for tid in range(1, count + 1):
path = music / "Music" / f"Artist{tid}" / "Album" / f"T{tid}.mp3"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"audio" + str(tid).encode())
library.tracks[tid] = Track(track_id=tid, name=f"T{tid}",
artist=f"Artist{tid}", location=str(path))
return library
def _manager(tmp_path, library):
return LibraryManager(library, tmp_path / "data")
def _playlist(library, pid, track_ids, name="Mix"):
library.playlists[pid] = Playlist(
name=name, persistent_id=pid, playlist_type=PlaylistType.REGULAR,
track_ids=list(track_ids))
return library.playlists[pid]
class TestDeleteTracks:
def test_removes_from_library_and_playlists(self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
playlist = _playlist(library, "AAAA1111", [1, 2, 3])
manager = _manager(tmp_path, library)
removed, failures = manager.delete_tracks([2], delete_files=True)
assert removed == [2] and failures == []
assert 2 not in manager.library.tracks
# No dangling id: stored rows must match what the view will show.
assert playlist.track_ids == [1, 3]
def test_file_goes_to_the_trash(self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
path = Path(library.tracks[1].location)
manager = _manager(tmp_path, library)
manager.delete_tracks([1], delete_files=True)
assert not path.exists()
assert (home_trash / "files" / "T1.mp3").read_bytes() == b"audio1"
def test_keep_file_variant_leaves_it_alone(self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
path = Path(library.tracks[1].location)
manager = _manager(tmp_path, library)
manager.delete_tracks([1], delete_files=False)
assert 1 not in manager.library.tracks
assert path.exists()
assert not (home_trash / "files").exists()
def test_emptied_dirs_pruned_but_not_the_root(self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
album = Path(library.tracks[1].location).parent
manager = _manager(tmp_path, library)
manager.delete_tracks([1], delete_files=True)
assert not album.exists() and not album.parent.exists()
assert manager.organize_root().exists()
def test_trash_failure_keeps_the_track(self, qapp, tmp_path, home_trash,
monkeypatch):
library = _library(tmp_path)
manager = _manager(tmp_path, library)
monkeypatch.setattr(trash, "send_to_trash",
MagicMock(side_effect=trash.TrashError("nope")))
removed, failures = manager.delete_tracks([1], delete_files=True)
assert removed == []
assert 1 in manager.library.tracks # never orphan the entry
assert failures and failures[0][0] == "T1"
def test_partial_failure_still_deletes_the_rest(self, qapp, tmp_path,
home_trash, monkeypatch):
library = _library(tmp_path)
manager = _manager(tmp_path, library)
real = trash.send_to_trash
def flaky(path):
if Path(path).name == "T1.mp3":
raise trash.TrashError("nope")
return real(path)
monkeypatch.setattr(trash, "send_to_trash", flaky)
removed, failures = manager.delete_tracks([1, 2], delete_files=True)
assert removed == [2] and len(failures) == 1
assert 1 in manager.library.tracks and 2 not in manager.library.tracks
def test_unknown_ids_are_ignored(self, qapp, tmp_path, home_trash):
manager = _manager(tmp_path, _library(tmp_path))
assert manager.delete_tracks([999], delete_files=True) == ([], [])
def test_emits_tracks_removed(self, qapp, tmp_path, home_trash):
manager = _manager(tmp_path, _library(tmp_path))
seen = []
manager.tracks_removed.connect(seen.append)
manager.delete_tracks([1, 3], delete_files=False)
assert seen == [[1, 3]]
class TestDeleteUndo:
def test_undo_restores_track_file_and_playlist_position(
self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
playlist = _playlist(library, "AAAA1111", [1, 2, 3])
path = Path(library.tracks[2].location)
manager = _manager(tmp_path, library)
manager.delete_tracks([2], delete_files=True)
manager.undo_stack.undo()
assert 2 in manager.library.tracks
assert path.read_bytes() == b"audio2"
# Back in the middle, not appended to the end.
assert playlist.track_ids == [1, 2, 3]
def test_redo_deletes_again(self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
playlist = _playlist(library, "AAAA1111", [1, 2, 3])
path = Path(library.tracks[2].location)
manager = _manager(tmp_path, library)
manager.delete_tracks([2], delete_files=True)
manager.undo_stack.undo()
manager.undo_stack.redo()
assert 2 not in manager.library.tracks
assert not path.exists()
assert playlist.track_ids == [1, 3]
def test_undo_label_reflects_the_variant(self, qapp, tmp_path, home_trash):
manager = _manager(tmp_path, _library(tmp_path))
manager.delete_tracks([1], delete_files=True)
assert manager.undo_stack.undo_label() == "Delete from Library"
manager.delete_tracks([2], delete_files=False)
assert manager.undo_stack.undo_label() == "Remove from Library"
def test_undo_restores_entry_even_if_trash_was_emptied(
self, qapp, tmp_path, home_trash):
library = _library(tmp_path)
manager = _manager(tmp_path, library)
manager.delete_tracks([1], delete_files=True)
(home_trash / "files" / "T1.mp3").unlink() # user emptied the trash
manager.undo_stack.undo()
# The file is unrecoverable, but losing the library entry too would be
# a second, silent loss.
assert 1 in manager.library.tracks
def test_deleted_track_is_gone_from_saved_library(self, qapp, tmp_path,
home_trash):
library = _library(tmp_path)
manager = _manager(tmp_path, library)
manager.delete_tracks([1], delete_files=True)
manager.flush()
import json
saved = json.loads((tmp_path / "data" / "library.json").read_text())
assert "1" not in saved and "2" in saved
# --------------------------------------------------------------------------
# 3. Player.drop_tracks
# --------------------------------------------------------------------------
def _player(qapp, track_ids):
from lintunes.player import Player
library = Library()
for tid in track_ids:
library.tracks[tid] = Track(track_id=tid, name=f"T{tid}")
manager = MagicMock()
manager.library = library
player = Player(manager)
player._queue = list(track_ids)
return player
class TestPlayerDropTracks:
def test_stops_when_the_playing_track_is_deleted(self, qapp):
player = _player(qapp, [1, 2, 3])
player._index = 1
player._current_track = player._manager.library.tracks[2]
player._sink = MagicMock()
player.drop_tracks({2})
assert player.current_track is None
assert player._queue == [1, 3]
def test_current_track_keeps_its_place(self, qapp):
player = _player(qapp, [1, 2, 3])
player._index = 2
player._current_track = player._manager.library.tracks[3]
player.drop_tracks({1})
# Track 3 moved from index 2 to index 1; a stale index would make
# "next" replay the wrong song.
assert player._queue == [2, 3]
assert player._index == 1
assert player.current_track.track_id == 3
def test_empty_set_is_a_no_op(self, qapp):
player = _player(qapp, [1, 2, 3])
player._index = 1
player.drop_tracks(set())
assert player._queue == [1, 2, 3] and player._index == 1
# --------------------------------------------------------------------------
# 4. The context-menu signals
# --------------------------------------------------------------------------
class TestContextMenuSignals:
@pytest.mark.parametrize("playlist_mode", [False, True])
def test_both_actions_exist_in_both_modes(self, qapp, playlist_mode):
"""Unlike "Remove from Playlist", library removal is offered in the
library view too."""
from lintunes.gui.track_table import TrackTableView
table = TrackTableView(playlist_mode=playlist_mode)
assert hasattr(table, "remove_from_library_requested")
assert hasattr(table, "delete_from_library_requested")
def test_signals_carry_track_ids(self, qapp):
from lintunes.gui.track_table import TrackTableView
table = TrackTableView()
seen = []
table.delete_from_library_requested.connect(seen.append)
table.delete_from_library_requested.emit([7, 9])
assert seen == [[7, 9]]