"""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 ``/.Trash-``. 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 /.Trash- 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]]