"""Round 70: Rabbit Sync Settings as a folder tree, an album-art pass that does no device I/O when nothing changed, the "tink", and Preferences in VLC-style pages. * A fully ticked folder is stored as the folder, so a playlist added to it later syncs too; unticking one inside makes the folder partial and the selection falls back to playlist ids. * The art pass compares against the sizes the planner's scan already read, never a stat per album over MTP, and "this album has no art" is cached so an artless album isn't re-opened every sync. """ import json from pathlib import Path from unittest.mock import patch from PyQt6.QtCore import QObject, Qt, pyqtSignal from lintunes import tagging from lintunes.andtunes import art, layout from lintunes.andtunes.layout import album_key, art_relpath from lintunes.andtunes.sync import AndTunesSyncWorker, plan_andtunes_sync from lintunes.gui import sounds from lintunes.gui.device_sync_dialog import ( DeviceSyncSettingsDialog, resolve_sync_playlists, ) from lintunes.models import Track from lintunes.models.playlist import Playlist, PlaylistType from lintunes.preferences import Preferences CHECKED = Qt.CheckState.Checked UNCHECKED = Qt.CheckState.Unchecked PARTIAL = Qt.CheckState.PartiallyChecked class _Lib: def __init__(self, *playlists): self.playlists = {p.persistent_id: p for p in playlists} def _pl(name, pid, parent="", kind=PlaylistType.REGULAR, **kw): return Playlist(name=name, persistent_id=pid, playlist_type=kind, parent_persistent_id=parent, **kw) def _library(): return _Lib( _pl("Zed", "Z1"), _pl("Boxes", "F1", kind=PlaylistType.FOLDER), _pl("Inside B", "B1", parent="F1"), _pl("Inside A", "A1", parent="F1"), _pl("Nested", "F2", parent="F1", kind=PlaylistType.FOLDER), _pl("Deep", "D1", parent="F2"), _pl("Empty Folder", "F3", kind=PlaylistType.FOLDER), _pl("Music", "S1", is_system=True), ) def _item(dialog, pid): return next(i for i in dialog._items() if i.data(0, Qt.ItemDataRole.UserRole) == pid) class TestFolderTree: def test_tree_mirrors_the_sidebar(self, qapp, tmp_path): dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library()) top = [dialog._tree.topLevelItem(i).text(0) for i in range(dialog._tree.topLevelItemCount())] # Folders mixed in by name; the empty folder and system list gone. assert top == ["Boxes", "Zed (0)"] boxes = _item(dialog, "F1") assert [boxes.child(i).text(0) for i in range(boxes.childCount())] == [ "Inside A (0)", "Inside B (0)", "Nested"] def test_ticking_a_folder_ticks_everything_inside(self, qapp, tmp_path): dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library()) _item(dialog, "F1").setCheckState(0, CHECKED) assert _item(dialog, "D1").checkState(0) == CHECKED assert dialog.selection() == ([], ["F1"]) def test_unticking_one_inside_makes_the_folder_partial(self, qapp, tmp_path): dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library()) _item(dialog, "F1").setCheckState(0, CHECKED) _item(dialog, "A1").setCheckState(0, UNCHECKED) assert _item(dialog, "F1").checkState(0) == PARTIAL # The fully ticked subfolder is still remembered as a folder. assert dialog.selection() == (["B1"], ["F2"]) def test_ok_writes_and_reopening_restores(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = DeviceSyncSettingsDialog(prefs, _library()) _item(dialog, "F1").setCheckState(0, CHECKED) _item(dialog, "Z1").setCheckState(0, CHECKED) dialog.accept() assert prefs.device_sync["folder_ids"] == ["F1"] assert prefs.device_sync["playlist_ids"] == ["Z1"] again = DeviceSyncSettingsDialog(prefs, _library()) assert _item(again, "F1").checkState(0) == CHECKED assert _item(again, "F1").isExpanded() def test_filter_keeps_ancestors_of_a_match(self, qapp, tmp_path): dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library()) dialog._filter.setText("deep") assert not _item(dialog, "D1").isHidden() assert not _item(dialog, "F1").isHidden() assert _item(dialog, "A1").isHidden() assert _item(dialog, "Z1").isHidden() class TestResolve: def test_a_ticked_folder_follows_new_playlists(self): library = _library() sync = {"playlist_ids": ["Z1"], "folder_ids": ["F1"]} library.playlists["N1"] = _pl("Added Later", "N1", parent="F2") assert [p.persistent_id for p in resolve_sync_playlists(library, sync)] \ == ["Z1", "A1", "B1", "N1", "D1"] # "Added Later" < "Deep" def test_old_prefs_and_missing_ids(self): sync = {"playlist_ids": ["B1", "GONE", "B1"]} assert [p.persistent_id for p in resolve_sync_playlists(_library(), sync)] == ["B1"] # ---- album art ---- def _track(tid, path, album="Record"): return Track(track_id=tid, name=f"Song {tid}", artist="Band", album=album, location=str(path), total_time=180_000) def _copy(src, tmp_path, name): dest = tmp_path / "local" / name dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(Path(src).read_bytes()) return dest class TestArtPass: def test_planner_records_cover_sizes_from_its_scan(self, qapp, tmp_path, mp3_file): track = _track(1, mp3_file) root = tmp_path / "dev" cover = root / art_relpath(album_key(track)) cover.parent.mkdir(parents=True) cover.write_bytes(b"j" * 77) plan = plan_andtunes_sync([_pl("A", "P1", track_ids=[1])], {1: track}, root) assert plan.art_sizes == {album_key(track): 77} def test_second_sync_touches_no_covers(self, qapp, tmp_path, mp3_file, jpeg_bytes, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) bare = _track(2, _copy(mp3_file, tmp_path, "bare.mp3"), album="Bare") tagging.write_artwork(mp3_file, jpeg_bytes) with_art = _track(1, mp3_file, album="Covered") root = tmp_path / "dev" playlists = [_pl("A", "P1", track_ids=[1, 2])] tracks = {1: with_art, 2: bare} first = [] worker = AndTunesSyncWorker(plan_andtunes_sync(playlists, tracks, root)) worker.finished.connect(first.append) worker._run() assert first[0]["covers_copied"] >= 1 reads = [] real_read = tagging.read_embedded_artwork monkeypatch.setattr(tagging, "read_embedded_artwork", lambda p: reads.append(p) or real_read(p)) writes = [] real_write = Path.write_bytes def spy_write(self, data): if "Art" in self.parts: writes.append(self) return real_write(self, data) second = [] with patch.object(Path, "write_bytes", spy_write): worker = AndTunesSyncWorker(plan_andtunes_sync(playlists, tracks, root)) worker.finished.connect(second.append) worker._run() assert second[0]["covers_copied"] == 0 assert writes == [] assert reads == [] # neither album re-opened, art or not def test_no_art_is_cached_and_invalidated_by_a_new_file( self, qapp, tmp_path, mp3_file, monkeypatch): import os monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) track = _track(1, mp3_file) calls = [] real_read = tagging.read_embedded_artwork monkeypatch.setattr(tagging, "read_embedded_artwork", lambda p: calls.append(p) or real_read(p)) assert art.album_art_jpeg(track) is None assert art.album_art_jpeg(track) is None assert len(calls) == 1 os.utime(mp3_file, (9e9, 9e9)) assert art.album_art_jpeg(track) is None assert len(calls) == 2 # ---- sound + preferences ---- class _FakeLastFm(QObject): login_finished = pyqtSignal(bool, str) def is_logged_in(self): return False def __getattr__(self, name): return lambda *a, **k: False class TestSound: def test_off_means_silent(self, tmp_path): prefs = Preferences(tmp_path) prefs.set("sound_effects", False) with patch.object(sounds, "_tink") as tink: sounds.play_done(prefs) tink.assert_not_called() sounds.play_done(prefs, force=True) # the preview button tink.return_value.play.assert_called_once() def test_on_by_default(self, tmp_path): with patch.object(sounds, "_tink") as tink: sounds.play_done(Preferences(tmp_path)) tink.return_value.play.assert_called_once() def test_the_wav_ships(self): from importlib import resources assert (resources.files("lintunes") / "sounds" / "tink.wav").is_file() class TestPreferencesPages: def test_three_pages_and_switching(self, qapp, tmp_path): from lintunes.gui.preferences_dialog import PreferencesDialog PreferencesDialog._last_page = 0 dialog = PreferencesDialog(Preferences(tmp_path), _FakeLastFm()) names = [dialog.pages_list.item(i).text() for i in range(dialog.pages_list.count())] assert names == ["General", "Appearance", "Integrations"] dialog.pages_list.setCurrentRow(2) assert dialog.pages.currentIndex() == 2 PreferencesDialog._last_page = 0 def test_sound_checkbox_writes_the_pref(self, qapp, tmp_path): from lintunes.gui.preferences_dialog import PreferencesDialog prefs = Preferences(tmp_path) dialog = PreferencesDialog(prefs, _FakeLastFm()) dialog._sound_check.setChecked(False) assert prefs.get("sound_effects") is False saved = json.loads((tmp_path / "preferences.json").read_text()) assert saved["sound_effects"] is False