- Rabbit Sync Settings shows the sidebar's folder tree. A folder has its own tristate checkbox; a fully ticked folder is stored as device_sync.folder_ids and follows the folder, so playlists added to it later sync too. - The album-art pass compares covers against the sizes the planner's device scan already read (plan.art_sizes) instead of an MTP stat per album, caches "no art" as a .none marker, and only unlinks covers that are really there. The summary reports covers actually updated rather than every cover. - A quiet tink when a Rabbit sync, export or URL import finishes, with an on/off switch (sound_effects, on by default). - Preferences is now General / Appearance / Integrations, a list on the left and the page on the right, opened big enough for the tallest page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""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
|