Editing artist/album_artist/album now relocates the file to its new <music_folder>/Music/Artist/Album/ home (folders created as needed, "Unknown Artist" fallback via the same organized_destination the importer uses), deliberately relaxing the old never-move rule: - LibraryManager.organize_root() is the shared definition of the managed tree; MainWindow._music_import_dir delegates to it. - Only files already inside the organize root are managed; external files get their tags edited but are never pulled into the tree. - The move rides the existing undo Command maps as a "location" field: Ctrl+Z moves the file back, redo re-moves, both best-effort (a failed physical move drops "location" so memory tracks the real file). - A failed move applies the edit, keeps the old path, and reports via the new file_move_failed signal (status bar), mirroring tag_write_failed. Collisions get the importer's " 1" suffix. - Directories left empty are pruned up to (never including) the root. - conflict_resolver._merge_track_fields now carries "location" with the newest-date_modified rule, so a rename-move on one machine propagates through both the sync-conflict merge and the live reload_from_disk reconcile on the other. - The on-disk basename is kept verbatim (no re-sanitizing), so files named on macOS aren't gratuitously renamed by a tag edit. Same-filesystem rename keeps the player's open fd valid, so moving the currently-playing track is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""Round 18: the 2026-07-02 "New backlog" batch.
|
|
|
|
1. Visualizer: gray-mode color slider pref + mode persists across launches.
|
|
2. External file drops insert at the drop position in a playlist.
|
|
3. Renaming artist/album/album_artist moves the file to keep the music
|
|
folder organized (Artist/Album, Unknown Artist fallback).
|
|
"""
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from PyQt6.QtCore import QObject, pyqtSignal
|
|
from PyQt6.QtGui import QColor
|
|
|
|
from lintunes import theme
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.models import Library, Track
|
|
from lintunes.preferences import Preferences
|
|
from lintunes.gui.visualizer import (
|
|
VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF)
|
|
|
|
|
|
class _FakePlayer(QObject):
|
|
audio_buffer = pyqtSignal(object)
|
|
playing_changed = pyqtSignal(bool)
|
|
track_changed = pyqtSignal(object)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._playing = False
|
|
|
|
def is_playing(self):
|
|
return self._playing
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 1. Visualizer: gray color pref + persistent mode
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestVisualizerModePersistence:
|
|
def test_no_prefs_defaults_on(self, qapp):
|
|
vis = VisualizerWidget(_FakePlayer())
|
|
assert vis._mode == MODE_ON
|
|
|
|
def test_saved_mode_restored(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
prefs.set("visualizer_mode", MODE_DIM)
|
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
|
assert vis._mode == MODE_DIM
|
|
|
|
def test_saved_off_restored_and_stays_idle(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
prefs.set("visualizer_mode", MODE_OFF)
|
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
|
assert vis._mode == MODE_OFF
|
|
vis._on_playing_changed(True) # playback starts
|
|
assert not vis._timer.isActive()
|
|
|
|
def test_bogus_saved_mode_falls_back_to_on(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
prefs.set("visualizer_mode", "sparkle") # hand-edited prefs file
|
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
|
assert vis._mode == MODE_ON
|
|
|
|
def test_click_persists_mode_to_disk(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
|
|
|
vis.mousePressEvent(None) # on -> dim
|
|
|
|
assert prefs.get("visualizer_mode") == MODE_DIM
|
|
# A fresh Preferences (= next launch) sees it too.
|
|
assert Preferences(tmp_path).get("visualizer_mode") == MODE_DIM
|
|
|
|
|
|
class TestVisualizerGrayColor:
|
|
def test_default_gray_resolves_for_visualizer_key(self, qapp):
|
|
value = theme.default_gray("color_visualizer_gray")
|
|
assert isinstance(value, int)
|
|
assert 0 <= value <= 255
|
|
|
|
def test_slider_pref_drives_dim_color(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
prefs.set("color_visualizer_gray", 100)
|
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
|
vis._mode = MODE_DIM
|
|
assert vis._bar_color() == QColor(100, 100, 100)
|
|
|
|
def test_unset_pref_keeps_derived_color(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
with_prefs = VisualizerWidget(_FakePlayer(), prefs)
|
|
without = VisualizerWidget(_FakePlayer())
|
|
with_prefs._mode = MODE_DIM
|
|
without._mode = MODE_DIM
|
|
assert with_prefs._bar_color() == without._bar_color()
|
|
|
|
def test_on_mode_ignores_gray_pref(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
prefs.set("color_visualizer_gray", 100)
|
|
vis = VisualizerWidget(_FakePlayer(), prefs)
|
|
assert vis._mode == MODE_ON
|
|
assert vis._bar_color() == QColor(vis.palette().highlight().color())
|
|
|
|
def test_visualizer_row_in_preferences_dialog(self, qapp):
|
|
from lintunes.gui.preferences_dialog import _COLOR_ROWS
|
|
assert "color_visualizer_gray" in [key for key, _label in _COLOR_ROWS]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 2. External file drops insert at the drop position
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _seeded_manager(tmp_path, track_ids=(1, 2, 3)):
|
|
tracks = {tid: Track(track_id=tid, name=f"T{tid}") for tid in track_ids}
|
|
return LibraryManager(Library(tracks=tracks), tmp_path / "data")
|
|
|
|
|
|
class TestFileDropPosition:
|
|
def test_import_paths_inserts_at_position(self, qapp, tmp_path, mp3_file):
|
|
from lintunes.importers import file_importer
|
|
manager = _seeded_manager(tmp_path)
|
|
playlist = manager.create_playlist("Drops")
|
|
manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2, 3])
|
|
|
|
imported = file_importer.import_paths(
|
|
[mp3_file], tmp_path / "music", manager,
|
|
playlist.persistent_id, position=1)
|
|
|
|
assert len(imported) == 1
|
|
new_id = imported[0].track_id
|
|
assert playlist.track_ids == [1, new_id, 2, 3]
|
|
|
|
def test_import_paths_default_appends(self, qapp, tmp_path, mp3_file):
|
|
from lintunes.importers import file_importer
|
|
manager = _seeded_manager(tmp_path)
|
|
playlist = manager.create_playlist("Drops")
|
|
manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2])
|
|
|
|
imported = file_importer.import_paths(
|
|
[mp3_file], tmp_path / "music", manager, playlist.persistent_id)
|
|
|
|
assert playlist.track_ids == [1, 2, imported[0].track_id]
|
|
|
|
def test_duplicate_file_inserts_without_new_track(self, qapp, tmp_path,
|
|
mp3_file):
|
|
from lintunes.importers import file_importer
|
|
manager = _seeded_manager(tmp_path)
|
|
existing = file_importer.import_file(
|
|
mp3_file, tmp_path / "music", manager)
|
|
playlist = manager.create_playlist("Drops")
|
|
manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2])
|
|
count_before = len(manager.library.tracks)
|
|
|
|
imported = file_importer.import_paths(
|
|
[mp3_file], tmp_path / "music", manager,
|
|
playlist.persistent_id, position=0)
|
|
|
|
assert imported[0].track_id == existing.track_id
|
|
assert playlist.track_ids == [existing.track_id, 1, 2]
|
|
assert len(manager.library.tracks) == count_before
|
|
|
|
def test_playlist_view_relays_drop_row(self, qapp, tmp_path):
|
|
from lintunes.gui.playlist_view import PlaylistView
|
|
manager = _seeded_manager(tmp_path)
|
|
playlist = manager.create_playlist("Drops")
|
|
view = PlaylistView(manager)
|
|
view.show_playlist(playlist)
|
|
received = []
|
|
view.files_dropped.connect(
|
|
lambda paths, pid, row: received.append((paths, pid, row)))
|
|
|
|
view._on_files_dropped(["/x.mp3"], 2)
|
|
view._on_files_dropped(["/y.mp3"], None)
|
|
|
|
assert received == [(["/x.mp3"], playlist.persistent_id, 2),
|
|
(["/y.mp3"], playlist.persistent_id, None)]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 3. Renaming artist/album moves the file (keep the music folder organized)
|
|
# --------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def organized(qapp, tmp_path, mp3_file):
|
|
"""A library whose music folder holds one organized track:
|
|
<share>/Music/Old Artist/Old Album/test.mp3."""
|
|
share = tmp_path / "share"
|
|
src = share / "Music" / "Old Artist" / "Old Album" / "test.mp3"
|
|
src.parent.mkdir(parents=True)
|
|
shutil.copy2(mp3_file, src)
|
|
track = Track(track_id=1, name="Song", artist="Old Artist",
|
|
album="Old Album", location=str(src))
|
|
library = Library(tracks={1: track}, music_folder=str(share))
|
|
manager = LibraryManager(library, share / "lintunes")
|
|
return manager, track, share
|
|
|
|
|
|
class TestRenameMovesFile:
|
|
def test_artist_edit_moves_file(self, organized):
|
|
manager, track, share = organized
|
|
old_path = Path(track.location)
|
|
|
|
manager.edit_track_fields(1, {"artist": "New Artist"})
|
|
|
|
expected = share / "Music" / "New Artist" / "Old Album" / "test.mp3"
|
|
assert Path(track.location) == expected
|
|
assert expected.is_file()
|
|
assert not old_path.exists()
|
|
assert track.artist == "New Artist"
|
|
|
|
def test_album_edit_moves_file(self, organized):
|
|
manager, track, share = organized
|
|
|
|
manager.edit_track_fields(1, {"album": "New Album"})
|
|
|
|
expected = share / "Music" / "Old Artist" / "New Album" / "test.mp3"
|
|
assert Path(track.location) == expected
|
|
assert expected.is_file()
|
|
|
|
def test_album_artist_shields_artist_edit(self, organized):
|
|
manager, track, share = organized
|
|
track.album_artist = "Main Act"
|
|
dest = share / "Music" / "Main Act" / "Old Album" / "test.mp3"
|
|
dest.parent.mkdir(parents=True)
|
|
Path(track.location).rename(dest)
|
|
track.location = str(dest)
|
|
|
|
# Folder is keyed on album_artist, so an artist edit changes nothing.
|
|
manager.edit_track_fields(1, {"artist": "Feat Guy"})
|
|
|
|
assert Path(track.location) == dest
|
|
assert dest.is_file()
|
|
assert track.artist == "Feat Guy"
|
|
|
|
def test_empty_dirs_pruned_but_not_root(self, organized):
|
|
manager, track, share = organized
|
|
|
|
manager.edit_track_fields(1, {"artist": "New Artist",
|
|
"album": "New Album"})
|
|
|
|
assert not (share / "Music" / "Old Artist").exists() # pruned
|
|
assert (share / "Music").is_dir() # root survives
|
|
|
|
def test_outside_root_file_untouched(self, organized, mp3_file):
|
|
manager, _track, share = organized
|
|
outside = Path(mp3_file)
|
|
external = Track(track_id=2, name="Ext", artist="Someone",
|
|
location=str(outside))
|
|
manager.library.tracks[2] = external
|
|
|
|
manager.edit_track_fields(2, {"artist": "Someone Else"})
|
|
|
|
assert Path(external.location) == outside
|
|
assert outside.is_file()
|
|
assert external.artist == "Someone Else"
|
|
|
|
def test_undo_moves_back_redo_removes(self, organized):
|
|
manager, track, share = organized
|
|
old_path = Path(track.location)
|
|
|
|
manager.edit_track_fields(1, {"artist": "New Artist"})
|
|
new_path = Path(track.location)
|
|
|
|
manager.undo_stack.undo()
|
|
assert Path(track.location) == old_path
|
|
assert old_path.is_file() and not new_path.exists()
|
|
assert track.artist == "Old Artist"
|
|
|
|
manager.undo_stack.redo()
|
|
assert Path(track.location) == new_path
|
|
assert new_path.is_file() and not old_path.exists()
|
|
assert track.artist == "New Artist"
|
|
|
|
def test_collision_gets_suffix(self, organized):
|
|
manager, track, share = organized
|
|
taken = share / "Music" / "New Artist" / "Old Album" / "test.mp3"
|
|
taken.parent.mkdir(parents=True)
|
|
taken.write_bytes(b"other file already here")
|
|
|
|
manager.edit_track_fields(1, {"artist": "New Artist"})
|
|
|
|
assert Path(track.location) == taken.with_stem("test 1")
|
|
assert Path(track.location).is_file()
|
|
assert taken.read_bytes() == b"other file already here"
|
|
|
|
def test_move_failure_keeps_edit_and_old_location(self, organized,
|
|
monkeypatch):
|
|
manager, track, share = organized
|
|
old_path = Path(track.location)
|
|
failures = []
|
|
manager.file_move_failed.connect(
|
|
lambda name, err: failures.append((name, err)))
|
|
monkeypatch.setattr(
|
|
LibraryManager, "_move_file",
|
|
lambda self, src, dest: (_ for _ in ()).throw(OSError("nope")))
|
|
|
|
manager.edit_track_fields(1, {"artist": "New Artist"})
|
|
|
|
assert track.artist == "New Artist" # edit applied
|
|
assert Path(track.location) == old_path # location kept
|
|
assert old_path.is_file()
|
|
assert failures and failures[0][0] == "Song"
|
|
|
|
def test_bulk_album_rename_moves_all_and_one_undo(self, organized,
|
|
mp3_file):
|
|
manager, track, share = organized
|
|
second_src = share / "Music" / "Old Artist" / "Old Album" / "two.mp3"
|
|
shutil.copy2(mp3_file, second_src)
|
|
second = Track(track_id=2, name="Two", artist="Old Artist",
|
|
album="Old Album", location=str(second_src))
|
|
manager.library.tracks[2] = second
|
|
|
|
manager.edit_tracks_fields([1, 2], {"album": "Remaster"})
|
|
|
|
new_dir = share / "Music" / "Old Artist" / "Remaster"
|
|
assert Path(track.location).parent == new_dir
|
|
assert Path(second.location).parent == new_dir
|
|
assert Path(track.location).is_file()
|
|
assert Path(second.location).is_file()
|
|
|
|
manager.undo_stack.undo() # ONE undo restores both
|
|
old_dir = share / "Music" / "Old Artist" / "Old Album"
|
|
assert Path(track.location).parent == old_dir
|
|
assert Path(second.location).parent == old_dir
|
|
assert Path(track.location).is_file()
|
|
assert Path(second.location).is_file()
|
|
assert not new_dir.exists() # pruned again
|
|
|
|
|
|
class TestLocationSync:
|
|
def test_merge_track_fields_newest_wins_location(self):
|
|
from lintunes.storage.conflict_resolver import _merge_track_fields
|
|
orig = {"location": "Music/A/B/x.mp3", "date_modified": "2026-01-01"}
|
|
newer = {"location": "Music/C/B/x.mp3", "date_modified": "2026-06-01"}
|
|
_merge_track_fields(orig, newer)
|
|
assert orig["location"] == "Music/C/B/x.mp3"
|
|
|
|
older = {"location": "Music/Z/B/x.mp3", "date_modified": "2025-01-01"}
|
|
_merge_track_fields(orig, older)
|
|
assert orig["location"] == "Music/C/B/x.mp3" # older copy loses
|
|
|
|
def test_reload_from_disk_adopts_external_move(self, organized):
|
|
from lintunes.storage import json_storage
|
|
manager, track, share = organized
|
|
data_dir = manager.data_dir
|
|
json_storage.save_library(manager.library, data_dir)
|
|
|
|
# Another machine renamed the artist: file moved + library.json
|
|
# rewritten with a newer date_modified and the new relative path.
|
|
new_loc = share / "Music" / "Elsewhere" / "Old Album" / "test.mp3"
|
|
new_loc.parent.mkdir(parents=True)
|
|
Path(track.location).rename(new_loc)
|
|
from lintunes.storage.json_storage import read_json, write_json
|
|
disk = read_json(data_dir / "library.json")
|
|
entry = disk["1"]
|
|
entry["artist"] = "Elsewhere"
|
|
entry["location"] = "../Music/Elsewhere/Old Album/test.mp3"
|
|
entry["date_modified"] = "2099-01-01T00:00:00"
|
|
write_json(data_dir / "library.json", disk)
|
|
|
|
manager.reload_from_disk()
|
|
|
|
assert Path(track.location) == new_loc
|
|
assert track.artist == "Elsewhere"
|