v0.32.0: folder tree in Rabbit Sync Settings, quieter art pass, tink, VLC-style Preferences
- 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>
This commit is contained in:
@@ -377,6 +377,10 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
`device_sync` (so it rides the Syncthing share and both machines agree);
|
||||
keeping it off the `Playlist` keeps it out of the conflict-merge machinery,
|
||||
and **unticking is the only way a playlist comes off the device**.
|
||||
A fully ticked *folder* is stored as `device_sync.folder_ids` and follows
|
||||
the folder — playlists added to it later sync too
|
||||
(`device_sync_dialog.resolve_sync_playlists` is what both the dialog and
|
||||
the sync read).
|
||||
**The device's filesystem is case-insensitive**, so "RJD2" and "Rjd2" are one
|
||||
folder there and one artist here: since Round 54 the app's `Library.group()`
|
||||
keys *both* `albumsByKey` and `artistsByName` case-folded (and fetches the
|
||||
@@ -401,6 +405,11 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
(`_write_index`, buttons, pruning) must keep emitting progress — it was
|
||||
minutes of silence with the line stuck on `Album art 501/501`, and
|
||||
`_write_index` reads `Art/` in **one listing**, never a stat per album.
|
||||
The art pass is held to the same rule (Round 70): it compares each cover
|
||||
against `plan.art_sizes`, the sizes the planner's scan already read, never a
|
||||
stat per album, and `art.album_art_jpeg` caches "this album has no art" as a
|
||||
`<key>.none` marker, so an unchanged library costs the art pass no device
|
||||
I/O and no mutagen opens.
|
||||
Since Round 50 the app exists: `andtunes/app/` is plain Java against the
|
||||
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
|
||||
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.31.9"
|
||||
__version__ = "0.32.0"
|
||||
|
||||
+21
-14
@@ -71,30 +71,37 @@ def album_art_jpeg(track) -> bytes | None:
|
||||
return None
|
||||
|
||||
cache = _cache_dir() / f"{album_key(track)}.jpg"
|
||||
try:
|
||||
if cache.stat().st_mtime >= src_mtime:
|
||||
return cache.read_bytes()
|
||||
except OSError:
|
||||
pass # not cached yet, or unreadable — fall through and re-render
|
||||
# "This album has no art" is cached too, as an empty marker with the same
|
||||
# freshness rule — otherwise every artless album cost a mutagen open on
|
||||
# every sync, forever.
|
||||
none = cache.with_suffix(".none")
|
||||
for path in (cache, none):
|
||||
try:
|
||||
if path.stat().st_mtime >= src_mtime:
|
||||
return cache.read_bytes() if path is cache else None
|
||||
except OSError:
|
||||
pass # not cached yet, or unreadable — fall through and re-render
|
||||
|
||||
raw = tagging.read_embedded_artwork(src)
|
||||
if not raw:
|
||||
return None
|
||||
jpeg = render_jpeg(raw)
|
||||
if not jpeg:
|
||||
return None
|
||||
jpeg = render_jpeg(raw) if raw else None
|
||||
try:
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cache.with_suffix(".tmp")
|
||||
tmp.write_bytes(jpeg)
|
||||
tmp.replace(cache)
|
||||
if jpeg:
|
||||
tmp = cache.with_suffix(".tmp")
|
||||
tmp.write_bytes(jpeg)
|
||||
tmp.replace(cache)
|
||||
none.unlink(missing_ok=True)
|
||||
else:
|
||||
none.touch()
|
||||
os.utime(none)
|
||||
cache.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # a cache we can't write is a slow sync, not a failed one
|
||||
return jpeg
|
||||
|
||||
|
||||
def clear_cache():
|
||||
for path in _cache_dir().glob("*.jpg"):
|
||||
for path in [*_cache_dir().glob("*.jpg"), *_cache_dir().glob("*.none")]:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
|
||||
+31
-10
@@ -75,6 +75,10 @@ class AndTunesPlan:
|
||||
skipped: int = 0
|
||||
playlists: list = field(default_factory=list) # PlaylistEntry
|
||||
albums: dict = field(default_factory=dict) # album key -> representative MediaItem
|
||||
# album key -> size of its cover already on the device, from the scan
|
||||
# planning does anyway. The art pass compares against this instead of a
|
||||
# stat per album, which over gvfs-MTP is a round trip each.
|
||||
art_sizes: dict = field(default_factory=dict)
|
||||
copy_art: bool = True
|
||||
bytes_to_copy: int = 0
|
||||
bytes_freed: int = 0
|
||||
@@ -241,6 +245,12 @@ def plan_andtunes_sync(playlists, tracks_by_id, root: Path, device=None,
|
||||
on_device_folded = {rel.lower(): size for rel, size in on_device.items()}
|
||||
expected_folded = {rel.lower() for rel in expected}
|
||||
|
||||
if plan.copy_art:
|
||||
for key in plan.albums:
|
||||
size = on_device_folded.get(art_relpath(key).lower())
|
||||
if size is not None:
|
||||
plan.art_sizes[key] = size
|
||||
|
||||
for item in plan.items:
|
||||
if item.convert_to:
|
||||
# Its FLAC size is unknown until the worker converts it.
|
||||
@@ -391,6 +401,7 @@ class AndTunesSyncWorker(QObject):
|
||||
self._removed = 0
|
||||
self._present: set[int] = set() # track ids really on the device
|
||||
self._art: dict[str, str] = {} # album key -> art rel path
|
||||
self._art_written = 0 # covers actually (re)written
|
||||
self._total_kib = max(plan.bytes_to_copy // 1024, 1)
|
||||
self._done = 0
|
||||
self._plays = 0 # tracks whose play counts came back
|
||||
@@ -418,6 +429,7 @@ class AndTunesSyncWorker(QObject):
|
||||
"removed": self._removed,
|
||||
"skipped": plan.skipped,
|
||||
"albums": len(self._art),
|
||||
"covers_copied": self._art_written,
|
||||
"tracks": len(self._present),
|
||||
"plays": self._plays,
|
||||
"refused": len(plan.refused),
|
||||
@@ -569,7 +581,14 @@ class AndTunesSyncWorker(QObject):
|
||||
self._present.add(item.track_id)
|
||||
|
||||
def _sync_art(self):
|
||||
"""One cover per album, written only when it differs by size."""
|
||||
"""One cover per album, written only when it differs by size.
|
||||
|
||||
The device side of the comparison comes from the planner's scan, and
|
||||
an album without art is remembered by the cache, so an unchanged
|
||||
library costs no device I/O here at all — it used to be an MTP stat
|
||||
per album, plus a mutagen open and an unlink attempt for every
|
||||
artless one, every sync.
|
||||
"""
|
||||
plan = self._plan
|
||||
if not plan.copy_art:
|
||||
return
|
||||
@@ -578,24 +597,26 @@ class AndTunesSyncWorker(QObject):
|
||||
self._check_cancel()
|
||||
if item.track_id not in self._present:
|
||||
continue
|
||||
self._emit(f"Album art {i}/{total}")
|
||||
rel = art_relpath(key)
|
||||
dest = plan.root / rel
|
||||
on_device = plan.art_sizes.get(key)
|
||||
jpeg = art_mod.album_art_jpeg(item.track)
|
||||
if not jpeg:
|
||||
# The album lost its art (or never had any): don't leave a
|
||||
# cover behind that the manifest no longer points at.
|
||||
try:
|
||||
assert_inside(plan.root, dest).unlink()
|
||||
except (layout.DeviceLayoutError, OSError):
|
||||
pass
|
||||
if on_device is not None:
|
||||
try:
|
||||
assert_inside(plan.root, dest).unlink()
|
||||
except (layout.DeviceLayoutError, OSError):
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
if dest.stat().st_size != len(jpeg):
|
||||
raise FileNotFoundError
|
||||
except OSError:
|
||||
if on_device != len(jpeg):
|
||||
self._emit(f"Copying album art {i}/{total}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(jpeg)
|
||||
self._art_written += 1
|
||||
elif i % 25 == 0 or i == total:
|
||||
self._emit(f"Checking album art {i}/{total}")
|
||||
self._art[key] = rel
|
||||
|
||||
def _write_index(self):
|
||||
|
||||
@@ -7,16 +7,34 @@ unticking is how a playlist comes off, and the next sync does it.
|
||||
|
||||
The selection is stored in preferences.json, so it rides the same Syncthing
|
||||
share as the library and both machines agree on what belongs on the device.
|
||||
|
||||
Playlists are shown in the sidebar's folder tree (Round 70), and a folder has
|
||||
a checkbox of its own. A *fully* ticked folder is stored as the folder
|
||||
(``folder_ids``), not as the playlists it held that day, so a playlist added
|
||||
to it later goes to the device too. Untick one playlist inside and the folder
|
||||
goes back to being remembered playlist by playlist.
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QVBoxLayout,
|
||||
QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QTreeWidget,
|
||||
QTreeWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..models.playlist import PlaylistType
|
||||
|
||||
PID_ROLE = Qt.ItemDataRole.UserRole
|
||||
CHECKED = Qt.CheckState.Checked
|
||||
UNCHECKED = Qt.CheckState.Unchecked
|
||||
|
||||
|
||||
def _is_folder(playlist) -> bool:
|
||||
return playlist.playlist_type == PlaylistType.FOLDER
|
||||
|
||||
|
||||
def _syncable(playlist) -> bool:
|
||||
return not _is_folder(playlist) and not playlist.is_system
|
||||
|
||||
|
||||
def syncable_playlists(library) -> list:
|
||||
"""Playlists a user can put on a device, in name order.
|
||||
@@ -25,49 +43,100 @@ def syncable_playlists(library) -> list:
|
||||
a smart playlist is fine — by the time we see it, its membership has
|
||||
already been computed.
|
||||
"""
|
||||
return sorted(
|
||||
(p for p in library.playlists.values()
|
||||
if p.playlist_type != PlaylistType.FOLDER and not p.is_system),
|
||||
key=lambda p: p.name.casefold())
|
||||
return sorted((p for p in library.playlists.values() if _syncable(p)),
|
||||
key=lambda p: p.name.casefold())
|
||||
|
||||
|
||||
def _children(library) -> dict:
|
||||
"""parent pid ("" for the top level) -> its playlists and folders in the
|
||||
sidebar's order. A parent that doesn't resolve counts as the top level,
|
||||
exactly as the sidebar draws it."""
|
||||
playlists = library.playlists
|
||||
children: dict[str, list] = {}
|
||||
for playlist in playlists.values():
|
||||
if playlist.is_system:
|
||||
continue
|
||||
parent = playlist.parent_persistent_id
|
||||
if parent not in playlists:
|
||||
parent = ""
|
||||
children.setdefault(parent, []).append(playlist)
|
||||
for level in children.values():
|
||||
level.sort(key=lambda p: p.name.lower())
|
||||
return children
|
||||
|
||||
|
||||
def _descendants(children, folder_pid) -> list:
|
||||
"""Every syncable playlist under a folder, at any depth, in tree order."""
|
||||
found = []
|
||||
for playlist in children.get(folder_pid, []):
|
||||
if _is_folder(playlist):
|
||||
found += _descendants(children, playlist.persistent_id)
|
||||
else:
|
||||
found.append(playlist)
|
||||
return found
|
||||
|
||||
|
||||
def resolve_sync_playlists(library, device_sync: dict) -> list:
|
||||
"""The playlists a sync carries: explicitly ticked ones in their stored
|
||||
order, then everything under each ticked folder, without repeats.
|
||||
|
||||
Ids that no longer resolve are skipped rather than cleaned up: a playlist
|
||||
can be missing because the other machine hasn't synced yet, and silently
|
||||
forgetting the choice would be worse than ignoring it.
|
||||
"""
|
||||
playlists = library.playlists
|
||||
result, seen = [], set()
|
||||
|
||||
def add(playlist):
|
||||
if playlist.persistent_id not in seen and _syncable(playlist):
|
||||
seen.add(playlist.persistent_id)
|
||||
result.append(playlist)
|
||||
|
||||
for pid in device_sync.get("playlist_ids", []):
|
||||
if pid in playlists:
|
||||
add(playlists[pid])
|
||||
children = None
|
||||
for fid in device_sync.get("folder_ids", []):
|
||||
if fid in playlists and _is_folder(playlists[fid]):
|
||||
children = children if children is not None else _children(library)
|
||||
for playlist in _descendants(children, fid):
|
||||
add(playlist)
|
||||
return result
|
||||
|
||||
|
||||
class DeviceSyncSettingsDialog(QDialog):
|
||||
def __init__(self, prefs, library, parent=None, device_name="Rabbit"):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"{device_name} Sync Settings")
|
||||
self.resize(460, 620)
|
||||
self._prefs = prefs
|
||||
self._playlists = syncable_playlists(library)
|
||||
|
||||
selected = set(prefs.device_sync.get("playlist_ids", []))
|
||||
self._library = library
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(
|
||||
intro = QLabel(
|
||||
f"Playlists to keep on the {device_name}. Unticking one removes "
|
||||
"it from the device on the next sync."))
|
||||
"it from the device on the next sync. A ticked folder also brings "
|
||||
"along playlists you add to it later.")
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
|
||||
# 500 playlists is a normal library here, so the list is unusable
|
||||
# without a filter. Hiding rows doesn't renumber them, so the
|
||||
# row-to-playlist mapping in selected_ids() stays valid.
|
||||
# 500 playlists is a normal library here, so the tree is unusable
|
||||
# without a filter.
|
||||
self._filter = QLineEdit(self)
|
||||
self._filter.setPlaceholderText("Filter playlists…")
|
||||
self._filter.setClearButtonEnabled(True)
|
||||
self._filter.textChanged.connect(self._apply_filter)
|
||||
layout.addWidget(self._filter)
|
||||
|
||||
self._list = QListWidget(self)
|
||||
for playlist in self._playlists:
|
||||
item = QListWidgetItem(
|
||||
f"{playlist.name} ({len(playlist.track_ids)})", self._list)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(
|
||||
Qt.CheckState.Checked
|
||||
if playlist.persistent_id in selected
|
||||
else Qt.CheckState.Unchecked)
|
||||
layout.addWidget(self._list)
|
||||
self._tree = QTreeWidget(self)
|
||||
self._tree.setHeaderHidden(True)
|
||||
self._tree.setIndentation(14)
|
||||
self._build_tree(prefs.device_sync)
|
||||
layout.addWidget(self._tree, stretch=1)
|
||||
|
||||
self._count = QLabel(self)
|
||||
layout.addWidget(self._count)
|
||||
self._list.itemChanged.connect(lambda _item: self._refresh_count())
|
||||
self._tree.itemChanged.connect(lambda _item, _col: self._refresh_count())
|
||||
self._refresh_count()
|
||||
|
||||
self._art = QCheckBox("Copy album art to the device", self)
|
||||
@@ -84,24 +153,117 @@ class DeviceSyncSettingsDialog(QDialog):
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
# ---- tree ----
|
||||
|
||||
def _build_tree(self, device_sync: dict):
|
||||
children = _children(self._library)
|
||||
selected = set(device_sync.get("playlist_ids", []))
|
||||
folders = set(device_sync.get("folder_ids", []))
|
||||
|
||||
def add_level(parent_item, parent_pid, inherited: bool):
|
||||
for playlist in children.get(parent_pid, []):
|
||||
pid = playlist.persistent_id
|
||||
if _is_folder(playlist):
|
||||
# A folder with nothing syncable anywhere inside has no
|
||||
# checkbox worth offering.
|
||||
if not _descendants(children, pid):
|
||||
continue
|
||||
item = self._new_item(parent_item, playlist)
|
||||
item.setFlags(item.flags()
|
||||
| Qt.ItemFlag.ItemIsAutoTristate)
|
||||
add_level(item, pid, inherited or pid in folders)
|
||||
item.setExpanded(item.checkState(0) != UNCHECKED)
|
||||
else:
|
||||
item = self._new_item(parent_item, playlist)
|
||||
item.setCheckState(
|
||||
0, CHECKED if inherited or pid in selected
|
||||
else UNCHECKED)
|
||||
|
||||
self._tree.blockSignals(True)
|
||||
add_level(None, "", False)
|
||||
self._tree.blockSignals(False)
|
||||
|
||||
def _new_item(self, parent_item, playlist) -> QTreeWidgetItem:
|
||||
label = (playlist.name if _is_folder(playlist)
|
||||
else f"{playlist.name} ({len(playlist.track_ids)})")
|
||||
item = QTreeWidgetItem([label])
|
||||
item.setData(0, PID_ROLE, playlist.persistent_id)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(0, UNCHECKED)
|
||||
if parent_item is None:
|
||||
self._tree.addTopLevelItem(item)
|
||||
else:
|
||||
parent_item.addChild(item)
|
||||
return item
|
||||
|
||||
def _items(self, parent=None):
|
||||
"""Every item, depth first."""
|
||||
parent = parent or self._tree.invisibleRootItem()
|
||||
for i in range(parent.childCount()):
|
||||
child = parent.child(i)
|
||||
yield child
|
||||
yield from self._items(child)
|
||||
|
||||
def _apply_filter(self, text: str):
|
||||
needle = text.strip().casefold()
|
||||
for row, playlist in enumerate(self._playlists):
|
||||
self._list.item(row).setHidden(
|
||||
bool(needle) and needle not in playlist.name.casefold())
|
||||
|
||||
def visit(item, ancestor_matches: bool) -> bool:
|
||||
"""Show/hide item's subtree; True when anything in it shows."""
|
||||
matches = ancestor_matches or not needle or (
|
||||
needle in item.text(0).casefold())
|
||||
if item.childCount() == 0:
|
||||
item.setHidden(not matches)
|
||||
return matches
|
||||
shown = False
|
||||
for i in range(item.childCount()):
|
||||
shown |= visit(item.child(i), matches)
|
||||
item.setHidden(not shown)
|
||||
if needle and shown:
|
||||
item.setExpanded(True)
|
||||
return shown
|
||||
|
||||
root = self._tree.invisibleRootItem()
|
||||
for i in range(root.childCount()):
|
||||
visit(root.child(i), False)
|
||||
|
||||
# ---- selection ----
|
||||
|
||||
def _refresh_count(self):
|
||||
n = len(self.selected_ids())
|
||||
n = len(self._ticked_playlist_ids())
|
||||
self._count.setText(
|
||||
f"{n} playlist{'' if n == 1 else 's'} selected"
|
||||
if n else "No playlists selected — nothing will be synced.")
|
||||
|
||||
def _ticked_playlist_ids(self) -> list:
|
||||
return [item.data(0, PID_ROLE) for item in self._items()
|
||||
if item.childCount() == 0 and item.checkState(0) == CHECKED]
|
||||
|
||||
def selection(self) -> tuple[list, list]:
|
||||
"""(playlist_ids, folder_ids) as stored: a fully ticked folder
|
||||
stands for everything under it, so nothing beneath it is listed."""
|
||||
playlist_ids, folder_ids = [], []
|
||||
|
||||
def visit(item):
|
||||
if item.childCount():
|
||||
if item.checkState(0) == CHECKED:
|
||||
folder_ids.append(item.data(0, PID_ROLE))
|
||||
return
|
||||
for i in range(item.childCount()):
|
||||
visit(item.child(i))
|
||||
elif item.checkState(0) == CHECKED:
|
||||
playlist_ids.append(item.data(0, PID_ROLE))
|
||||
|
||||
root = self._tree.invisibleRootItem()
|
||||
for i in range(root.childCount()):
|
||||
visit(root.child(i))
|
||||
return playlist_ids, folder_ids
|
||||
|
||||
def selected_ids(self) -> list:
|
||||
return [playlist.persistent_id
|
||||
for row, playlist in enumerate(self._playlists)
|
||||
if self._list.item(row).checkState() == Qt.CheckState.Checked]
|
||||
return self._ticked_playlist_ids()
|
||||
|
||||
def accept(self):
|
||||
self._prefs.update_device_sync(playlist_ids=self.selected_ids(),
|
||||
playlist_ids, folder_ids = self.selection()
|
||||
self._prefs.update_device_sync(playlist_ids=playlist_ids,
|
||||
folder_ids=folder_ids,
|
||||
copy_art=self._art.isChecked())
|
||||
super().accept()
|
||||
|
||||
+13
-12
@@ -26,7 +26,10 @@ from lintunes.gui.album_art_dialog import AlbumArtDialog
|
||||
from lintunes.gui.art_ops import embed_artwork
|
||||
from lintunes.gui.cast_dialog import ChromecastDialog
|
||||
from lintunes.gui.cassette_ui import CassetteUi
|
||||
from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog
|
||||
from lintunes.gui import sounds
|
||||
from lintunes.gui.device_sync_dialog import (
|
||||
DeviceSyncSettingsDialog, resolve_sync_playlists,
|
||||
)
|
||||
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
|
||||
from lintunes.gui.identify_dialog import IdentifyDialog
|
||||
from lintunes.gui.sidebar import SIDEBAR_INSET, SidebarPanel
|
||||
@@ -595,16 +598,9 @@ class MainWindow(QMainWindow):
|
||||
dialog.exec()
|
||||
|
||||
def _selected_sync_playlists(self) -> list:
|
||||
"""The ticked playlists, in the order they were ticked in settings.
|
||||
|
||||
Ids that no longer resolve are skipped rather than cleaned up: a
|
||||
playlist can be missing because the other machine hasn't synced yet,
|
||||
and silently forgetting the choice would be worse than ignoring it.
|
||||
"""
|
||||
library = self._manager.library
|
||||
return [library.playlists[pid]
|
||||
for pid in self._prefs.device_sync.get("playlist_ids", [])
|
||||
if pid in library.playlists]
|
||||
"""The ticked playlists (and everything in ticked folders)."""
|
||||
return resolve_sync_playlists(self._manager.library,
|
||||
self._prefs.device_sync)
|
||||
|
||||
def _sync_to_andtunes(self):
|
||||
# Everything may have changed since the menu opened — re-verify.
|
||||
@@ -693,7 +689,9 @@ class MainWindow(QMainWindow):
|
||||
msg = (f"Synced {summary['playlists']} playlist(s) to the "
|
||||
f"{summary['device']}: {summary['tracks']} tracks "
|
||||
f"({summary['copied']} copied, {summary['kept']} up to date), "
|
||||
f"{summary['albums']} covers, {summary['removed']} removed")
|
||||
f"{summary['removed']} removed")
|
||||
if summary.get("covers_copied"):
|
||||
msg += f", {summary['covers_copied']} covers updated"
|
||||
if summary["skipped"]:
|
||||
msg += f", {summary['skipped']} skipped (no local file)"
|
||||
if summary.get("refused"):
|
||||
@@ -709,6 +707,7 @@ class MainWindow(QMainWindow):
|
||||
# waiting for the file watcher.
|
||||
self._manager.reload_from_disk()
|
||||
self.statusBar().showMessage(msg, 10000)
|
||||
sounds.play_done(self._prefs)
|
||||
if unwritable:
|
||||
# Worth a dialog: these songs are *not* in the library the app
|
||||
# reads, so they'd otherwise just be quietly absent from the
|
||||
@@ -953,6 +952,7 @@ class MainWindow(QMainWindow):
|
||||
msg += (f", {summary['vanished']} changed under us "
|
||||
"(export again to pick them up)")
|
||||
self.statusBar().showMessage(msg, 12000)
|
||||
sounds.play_done(self._prefs)
|
||||
|
||||
def _on_export_cancelled(self, summary):
|
||||
self._export_inhibitor.release()
|
||||
@@ -1296,6 +1296,7 @@ class MainWindow(QMainWindow):
|
||||
msg = f"Import cancelled — {count} song(s) imported"
|
||||
else:
|
||||
msg = f"Imported {count} song(s) from the link"
|
||||
sounds.play_done(self._prefs)
|
||||
target = self._url_target
|
||||
playlist = (self._manager.library.playlists.get(target.pid)
|
||||
if target is not None else None)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
|
||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||
QFormLayout, QFontComboBox,
|
||||
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
||||
QStackedWidget, QWidget, QDialogButtonBox, QFrame,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.gui import sounds
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
|
||||
|
||||
@@ -26,6 +28,9 @@ _COLOR_ROWS = [
|
||||
class PreferencesDialog(QDialog):
|
||||
"""App preferences. Applies immediately — there is no OK/Cancel.
|
||||
|
||||
Laid out like VLC's (and Sync Settings'): categories on the left, the
|
||||
chosen page on the right — General, Appearance, Integrations.
|
||||
|
||||
``music_folder``/``on_music_folder_change`` are optional callbacks rather
|
||||
than a LibraryManager reference, keeping this dialog manager-free (the same
|
||||
injection style as track_table's ``playlists_for_track``). When they are
|
||||
@@ -49,22 +54,95 @@ class PreferencesDialog(QDialog):
|
||||
self._color_save_timer.setInterval(400)
|
||||
self._color_save_timer.timeout.connect(self._prefs.save)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._build_general_group())
|
||||
general = [self._build_sound_group()]
|
||||
if self._music_folder is not None:
|
||||
layout.addWidget(self._build_music_group())
|
||||
layout.addWidget(self._build_colors_group())
|
||||
layout.addWidget(self._build_lastfm_group())
|
||||
layout.addWidget(self._build_acoustid_group())
|
||||
layout.addStretch()
|
||||
general.insert(0, self._build_music_group())
|
||||
self.pages_list = QListWidget()
|
||||
self.pages = QStackedWidget()
|
||||
for title, groups in (
|
||||
("General", general),
|
||||
("Appearance", [self._build_general_group(),
|
||||
self._build_colors_group()]),
|
||||
("Integrations", [self._build_lastfm_group(),
|
||||
self._build_acoustid_group()])):
|
||||
self._add_page(title, groups)
|
||||
self.pages_list.currentRowChanged.connect(self._show_page)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.addWidget(self.pages_list)
|
||||
splitter.addWidget(self.pages)
|
||||
splitter.setCollapsible(1, False)
|
||||
list_width = self.pages_list.sizeHintForColumn(0) + 32
|
||||
self.pages_list.setFixedWidth(list_width)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
# Open big enough for the tallest page, so nothing starts cut off
|
||||
# (the scroll areas are only a net for a small screen).
|
||||
tallest = max(self.pages.widget(i).widget().sizeHint().height()
|
||||
for i in range(self.pages.count()))
|
||||
widest = max(self.pages.widget(i).widget().sizeHint().width()
|
||||
for i in range(self.pages.count()))
|
||||
screen = self.screen().availableGeometry() if self.screen() else None
|
||||
height = tallest + buttons.sizeHint().height() + 48
|
||||
if screen is not None:
|
||||
height = min(height, screen.height() - 80)
|
||||
self.resize(list_width + widest + 64, height)
|
||||
self.pages_list.setCurrentRow(PreferencesDialog._last_page)
|
||||
|
||||
lastfm.login_finished.connect(self._on_login_finished)
|
||||
self._refresh_lastfm_state()
|
||||
|
||||
# ---- General ----
|
||||
_last_page = 0 # the page last viewed, for this session only
|
||||
|
||||
def _add_page(self, title: str, groups: list):
|
||||
body = QWidget()
|
||||
column = QVBoxLayout(body)
|
||||
for group in groups:
|
||||
column.addWidget(group)
|
||||
column.addStretch()
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
# Never narrower than the page wants: only height may scroll.
|
||||
scroll.setMinimumWidth(body.sizeHint().width()
|
||||
+ scroll.verticalScrollBar().sizeHint().width())
|
||||
scroll.setWidget(body)
|
||||
self.pages.addWidget(scroll)
|
||||
self.pages_list.addItem(title)
|
||||
|
||||
def _show_page(self, row: int):
|
||||
self.pages.setCurrentIndex(row)
|
||||
PreferencesDialog._last_page = row
|
||||
|
||||
# ---- Sound ----
|
||||
|
||||
def _build_sound_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Sound effects")
|
||||
row = QHBoxLayout(group)
|
||||
self._sound_check = QCheckBox("Play a sound when a sync, export or "
|
||||
"import finishes")
|
||||
self._sound_check.setChecked(bool(self._prefs.get("sound_effects", True)))
|
||||
self._sound_check.toggled.connect(
|
||||
lambda on: self._prefs.set("sound_effects", on))
|
||||
row.addWidget(self._sound_check, stretch=1)
|
||||
preview = QPushButton("Play")
|
||||
preview.clicked.connect(lambda: sounds.play_done(self._prefs, force=True))
|
||||
row.addWidget(preview)
|
||||
return group
|
||||
|
||||
# ---- Appearance ----
|
||||
|
||||
def _build_general_group(self) -> QGroupBox:
|
||||
group = QGroupBox("General")
|
||||
group = QGroupBox("Interface")
|
||||
form = QFormLayout(group)
|
||||
|
||||
# UI size: 3-position slider
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The quiet "tink" when a long job finishes (Rabbit sync, export, URL
|
||||
import), switched off by the ``sound_effects`` preference.
|
||||
|
||||
The QSoundEffect is built on first use, never at import: Qt Multimedia must
|
||||
not start before a QMainWindow exists (the Qt 6.10 QAudioOutput deadlock),
|
||||
and a test that never finishes a job never touches the audio stack.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from importlib import resources
|
||||
|
||||
from PyQt6.QtCore import QUrl
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VOLUME = 0.5 # the WAV is already rendered quiet (-18 dBFS)
|
||||
_effect = None
|
||||
|
||||
|
||||
def _tink():
|
||||
global _effect
|
||||
if _effect is None:
|
||||
from PyQt6.QtMultimedia import QSoundEffect
|
||||
path = resources.files("lintunes") / "sounds" / "tink.wav"
|
||||
_effect = QSoundEffect()
|
||||
_effect.setSource(QUrl.fromLocalFile(str(path)))
|
||||
_effect.setVolume(VOLUME)
|
||||
return _effect
|
||||
|
||||
|
||||
def play_done(prefs, *, force: bool = False):
|
||||
"""Play the tink, unless sound effects are off. ``force`` is the
|
||||
Preferences preview button, which plays it either way."""
|
||||
if not force and not prefs.get("sound_effects", True):
|
||||
return
|
||||
try:
|
||||
_tink().play()
|
||||
except Exception: # a missing audio stack is silence, not a crash
|
||||
log.exception("couldn't play the done sound")
|
||||
@@ -39,11 +39,16 @@ DEFAULTS = {
|
||||
# machines should agree on what the Rabbit is carrying.
|
||||
"device_sync": {
|
||||
"playlist_ids": [],
|
||||
# Fully ticked folders: everything under them syncs, including
|
||||
# playlists added to them later (gui/device_sync_dialog.py).
|
||||
"folder_ids": [],
|
||||
"copy_art": True,
|
||||
},
|
||||
# Which machine runs friend sharing (Cassette): {machine_id, hostname}.
|
||||
# Synced so the other machine can name it; see lintunes/cassette/host.py.
|
||||
"cassette_host": None,
|
||||
# The quiet "tink" when a sync, export or URL import finishes.
|
||||
"sound_effects": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
"""Render lintunes/sounds/tink.wav — the quiet "done" chime.
|
||||
|
||||
A bell-ish 1.6 kHz partial with a softer inharmonic overtone, a 3 ms attack
|
||||
(no click) and a fast exponential decay. Run once; the WAV is committed.
|
||||
|
||||
python3 scripts/make_tink.py
|
||||
"""
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
RATE = 44100
|
||||
LENGTH = 0.35 # seconds; the decay is inaudible well before this
|
||||
PEAK_DBFS = -18.0
|
||||
|
||||
|
||||
def render() -> np.ndarray:
|
||||
t = np.arange(int(RATE * LENGTH)) / RATE
|
||||
tone = (np.sin(2 * np.pi * 1600 * t)
|
||||
+ 0.35 * np.sin(2 * np.pi * 1600 * 2.76 * t) * np.exp(-t * 40))
|
||||
envelope = np.exp(-t * 18) * np.minimum(t / 0.003, 1.0)
|
||||
signal = tone * envelope
|
||||
signal /= np.abs(signal).max()
|
||||
return signal * 10 ** (PEAK_DBFS / 20)
|
||||
|
||||
|
||||
def main():
|
||||
out = Path(__file__).resolve().parent.parent / "lintunes" / "sounds" / "tink.wav"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
pcm = (render() * 32767).astype("<i2")
|
||||
with wave.open(str(out), "wb") as f:
|
||||
f.setnchannels(1)
|
||||
f.setsampwidth(2)
|
||||
f.setframerate(RATE)
|
||||
f.writeframes(pcm.tobytes())
|
||||
print(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,6 +17,7 @@ setup(
|
||||
# The andTunes APK LinTunes installs, and the default button artwork
|
||||
# sync ships to the device.
|
||||
package_data={
|
||||
"lintunes": ["sounds/*.wav"],
|
||||
"lintunes.export": ["templates/*"],
|
||||
"lintunes.android": ["andTunes.apk", "andTunes.json"],
|
||||
"lintunes.andtunes": ["buttons/*.png"],
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user