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:
2026-10-01 00:45:42 -07:00
co-authored by Claude Opus 5.5
parent 4a019d4e27
commit ba795e3c69
13 changed files with 699 additions and 80 deletions
+9
View File
@@ -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); `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, 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**. 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 **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()` folder there and one artist here: since Round 54 the app's `Library.group()`
keys *both* `albumsByKey` and `artistsByName` case-folded (and fetches the 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 (`_write_index`, buttons, pruning) must keep emitting progress — it was
minutes of silence with the line stuck on `Album art 501/501`, and 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. `_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 Since Round 50 the app exists: `andtunes/app/` is plain Java against the
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 → Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 + zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.31.9" __version__ = "0.32.0"
+15 -8
View File
@@ -71,30 +71,37 @@ def album_art_jpeg(track) -> bytes | None:
return None return None
cache = _cache_dir() / f"{album_key(track)}.jpg" cache = _cache_dir() / f"{album_key(track)}.jpg"
# "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: try:
if cache.stat().st_mtime >= src_mtime: if path.stat().st_mtime >= src_mtime:
return cache.read_bytes() return cache.read_bytes() if path is cache else None
except OSError: except OSError:
pass # not cached yet, or unreadable — fall through and re-render pass # not cached yet, or unreadable — fall through and re-render
raw = tagging.read_embedded_artwork(src) raw = tagging.read_embedded_artwork(src)
if not raw: jpeg = render_jpeg(raw) if raw else None
return None
jpeg = render_jpeg(raw)
if not jpeg:
return None
try: try:
cache.parent.mkdir(parents=True, exist_ok=True) cache.parent.mkdir(parents=True, exist_ok=True)
if jpeg:
tmp = cache.with_suffix(".tmp") tmp = cache.with_suffix(".tmp")
tmp.write_bytes(jpeg) tmp.write_bytes(jpeg)
tmp.replace(cache) tmp.replace(cache)
none.unlink(missing_ok=True)
else:
none.touch()
os.utime(none)
cache.unlink(missing_ok=True)
except OSError: except OSError:
pass # a cache we can't write is a slow sync, not a failed one pass # a cache we can't write is a slow sync, not a failed one
return jpeg return jpeg
def clear_cache(): def clear_cache():
for path in _cache_dir().glob("*.jpg"): for path in [*_cache_dir().glob("*.jpg"), *_cache_dir().glob("*.none")]:
try: try:
path.unlink() path.unlink()
except OSError: except OSError:
+27 -6
View File
@@ -75,6 +75,10 @@ class AndTunesPlan:
skipped: int = 0 skipped: int = 0
playlists: list = field(default_factory=list) # PlaylistEntry playlists: list = field(default_factory=list) # PlaylistEntry
albums: dict = field(default_factory=dict) # album key -> representative MediaItem 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 copy_art: bool = True
bytes_to_copy: int = 0 bytes_to_copy: int = 0
bytes_freed: 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()} on_device_folded = {rel.lower(): size for rel, size in on_device.items()}
expected_folded = {rel.lower() for rel in expected} 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: for item in plan.items:
if item.convert_to: if item.convert_to:
# Its FLAC size is unknown until the worker converts it. # Its FLAC size is unknown until the worker converts it.
@@ -391,6 +401,7 @@ class AndTunesSyncWorker(QObject):
self._removed = 0 self._removed = 0
self._present: set[int] = set() # track ids really on the device self._present: set[int] = set() # track ids really on the device
self._art: dict[str, str] = {} # album key -> art rel path 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._total_kib = max(plan.bytes_to_copy // 1024, 1)
self._done = 0 self._done = 0
self._plays = 0 # tracks whose play counts came back self._plays = 0 # tracks whose play counts came back
@@ -418,6 +429,7 @@ class AndTunesSyncWorker(QObject):
"removed": self._removed, "removed": self._removed,
"skipped": plan.skipped, "skipped": plan.skipped,
"albums": len(self._art), "albums": len(self._art),
"covers_copied": self._art_written,
"tracks": len(self._present), "tracks": len(self._present),
"plays": self._plays, "plays": self._plays,
"refused": len(plan.refused), "refused": len(plan.refused),
@@ -569,7 +581,14 @@ class AndTunesSyncWorker(QObject):
self._present.add(item.track_id) self._present.add(item.track_id)
def _sync_art(self): 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 plan = self._plan
if not plan.copy_art: if not plan.copy_art:
return return
@@ -578,24 +597,26 @@ class AndTunesSyncWorker(QObject):
self._check_cancel() self._check_cancel()
if item.track_id not in self._present: if item.track_id not in self._present:
continue continue
self._emit(f"Album art {i}/{total}")
rel = art_relpath(key) rel = art_relpath(key)
dest = plan.root / rel dest = plan.root / rel
on_device = plan.art_sizes.get(key)
jpeg = art_mod.album_art_jpeg(item.track) jpeg = art_mod.album_art_jpeg(item.track)
if not jpeg: if not jpeg:
# The album lost its art (or never had any): don't leave a # The album lost its art (or never had any): don't leave a
# cover behind that the manifest no longer points at. # cover behind that the manifest no longer points at.
if on_device is not None:
try: try:
assert_inside(plan.root, dest).unlink() assert_inside(plan.root, dest).unlink()
except (layout.DeviceLayoutError, OSError): except (layout.DeviceLayoutError, OSError):
pass pass
continue continue
try: if on_device != len(jpeg):
if dest.stat().st_size != len(jpeg): self._emit(f"Copying album art {i}/{total}")
raise FileNotFoundError
except OSError:
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(jpeg) 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 self._art[key] = rel
def _write_index(self): def _write_index(self):
+194 -32
View File
@@ -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 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. 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.QtCore import Qt
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QListWidget, QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QTreeWidget,
QListWidgetItem, QVBoxLayout, QTreeWidgetItem, QVBoxLayout,
) )
from ..models.playlist import PlaylistType 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: def syncable_playlists(library) -> list:
"""Playlists a user can put on a device, in name order. """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 a smart playlist is fine — by the time we see it, its membership has
already been computed. already been computed.
""" """
return sorted( return sorted((p for p in library.playlists.values() if _syncable(p)),
(p for p in library.playlists.values()
if p.playlist_type != PlaylistType.FOLDER and not p.is_system),
key=lambda p: p.name.casefold()) 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): class DeviceSyncSettingsDialog(QDialog):
def __init__(self, prefs, library, parent=None, device_name="Rabbit"): def __init__(self, prefs, library, parent=None, device_name="Rabbit"):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(f"{device_name} Sync Settings") self.setWindowTitle(f"{device_name} Sync Settings")
self.resize(460, 620)
self._prefs = prefs self._prefs = prefs
self._playlists = syncable_playlists(library) self._library = library
selected = set(prefs.device_sync.get("playlist_ids", []))
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget(QLabel( intro = QLabel(
f"Playlists to keep on the {device_name}. Unticking one removes " 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 # 500 playlists is a normal library here, so the tree is unusable
# without a filter. Hiding rows doesn't renumber them, so the # without a filter.
# row-to-playlist mapping in selected_ids() stays valid.
self._filter = QLineEdit(self) self._filter = QLineEdit(self)
self._filter.setPlaceholderText("Filter playlists…") self._filter.setPlaceholderText("Filter playlists…")
self._filter.setClearButtonEnabled(True) self._filter.setClearButtonEnabled(True)
self._filter.textChanged.connect(self._apply_filter) self._filter.textChanged.connect(self._apply_filter)
layout.addWidget(self._filter) layout.addWidget(self._filter)
self._list = QListWidget(self) self._tree = QTreeWidget(self)
for playlist in self._playlists: self._tree.setHeaderHidden(True)
item = QListWidgetItem( self._tree.setIndentation(14)
f"{playlist.name} ({len(playlist.track_ids)})", self._list) self._build_tree(prefs.device_sync)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) layout.addWidget(self._tree, stretch=1)
item.setCheckState(
Qt.CheckState.Checked
if playlist.persistent_id in selected
else Qt.CheckState.Unchecked)
layout.addWidget(self._list)
self._count = QLabel(self) self._count = QLabel(self)
layout.addWidget(self._count) 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._refresh_count()
self._art = QCheckBox("Copy album art to the device", self) self._art = QCheckBox("Copy album art to the device", self)
@@ -84,24 +153,117 @@ class DeviceSyncSettingsDialog(QDialog):
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addWidget(buttons) 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): def _apply_filter(self, text: str):
needle = text.strip().casefold() needle = text.strip().casefold()
for row, playlist in enumerate(self._playlists):
self._list.item(row).setHidden( def visit(item, ancestor_matches: bool) -> bool:
bool(needle) and needle not in playlist.name.casefold()) """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): def _refresh_count(self):
n = len(self.selected_ids()) n = len(self._ticked_playlist_ids())
self._count.setText( self._count.setText(
f"{n} playlist{'' if n == 1 else 's'} selected" f"{n} playlist{'' if n == 1 else 's'} selected"
if n else "No playlists selected — nothing will be synced.") 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: def selected_ids(self) -> list:
return [playlist.persistent_id return self._ticked_playlist_ids()
for row, playlist in enumerate(self._playlists)
if self._list.item(row).checkState() == Qt.CheckState.Checked]
def accept(self): 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()) copy_art=self._art.isChecked())
super().accept() super().accept()
+13 -12
View File
@@ -26,7 +26,10 @@ from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.art_ops import embed_artwork from lintunes.gui.art_ops import embed_artwork
from lintunes.gui.cast_dialog import ChromecastDialog from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.cassette_ui import CassetteUi 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.export_dialog import ExportKindDialog, WebMixDialog
from lintunes.gui.identify_dialog import IdentifyDialog from lintunes.gui.identify_dialog import IdentifyDialog
from lintunes.gui.sidebar import SIDEBAR_INSET, SidebarPanel from lintunes.gui.sidebar import SIDEBAR_INSET, SidebarPanel
@@ -595,16 +598,9 @@ class MainWindow(QMainWindow):
dialog.exec() dialog.exec()
def _selected_sync_playlists(self) -> list: def _selected_sync_playlists(self) -> list:
"""The ticked playlists, in the order they were ticked in settings. """The ticked playlists (and everything in ticked folders)."""
return resolve_sync_playlists(self._manager.library,
Ids that no longer resolve are skipped rather than cleaned up: a self._prefs.device_sync)
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]
def _sync_to_andtunes(self): def _sync_to_andtunes(self):
# Everything may have changed since the menu opened — re-verify. # 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 " msg = (f"Synced {summary['playlists']} playlist(s) to the "
f"{summary['device']}: {summary['tracks']} tracks " f"{summary['device']}: {summary['tracks']} tracks "
f"({summary['copied']} copied, {summary['kept']} up to date), " 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"]: if summary["skipped"]:
msg += f", {summary['skipped']} skipped (no local file)" msg += f", {summary['skipped']} skipped (no local file)"
if summary.get("refused"): if summary.get("refused"):
@@ -709,6 +707,7 @@ class MainWindow(QMainWindow):
# waiting for the file watcher. # waiting for the file watcher.
self._manager.reload_from_disk() self._manager.reload_from_disk()
self.statusBar().showMessage(msg, 10000) self.statusBar().showMessage(msg, 10000)
sounds.play_done(self._prefs)
if unwritable: if unwritable:
# Worth a dialog: these songs are *not* in the library the app # Worth a dialog: these songs are *not* in the library the app
# reads, so they'd otherwise just be quietly absent from the # 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 " msg += (f", {summary['vanished']} changed under us "
"(export again to pick them up)") "(export again to pick them up)")
self.statusBar().showMessage(msg, 12000) self.statusBar().showMessage(msg, 12000)
sounds.play_done(self._prefs)
def _on_export_cancelled(self, summary): def _on_export_cancelled(self, summary):
self._export_inhibitor.release() self._export_inhibitor.release()
@@ -1296,6 +1296,7 @@ class MainWindow(QMainWindow):
msg = f"Import cancelled — {count} song(s) imported" msg = f"Import cancelled — {count} song(s) imported"
else: else:
msg = f"Imported {count} song(s) from the link" msg = f"Imported {count} song(s) from the link"
sounds.play_done(self._prefs)
target = self._url_target target = self._url_target
playlist = (self._manager.library.playlists.get(target.pid) playlist = (self._manager.library.playlists.get(target.pid)
if target is not None else None) if target is not None else None)
+88 -10
View File
@@ -1,12 +1,14 @@
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider, QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox, QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
QFormLayout, QFontComboBox, QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
QStackedWidget, QWidget, QDialogButtonBox, QFrame,
) )
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from lintunes import theme from lintunes import theme
from lintunes.gui import sounds
from lintunes.theme import HIGHLIGHT_COLORS from lintunes.theme import HIGHLIGHT_COLORS
@@ -26,6 +28,9 @@ _COLOR_ROWS = [
class PreferencesDialog(QDialog): class PreferencesDialog(QDialog):
"""App preferences. Applies immediately — there is no OK/Cancel. """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 ``music_folder``/``on_music_folder_change`` are optional callbacks rather
than a LibraryManager reference, keeping this dialog manager-free (the same than a LibraryManager reference, keeping this dialog manager-free (the same
injection style as track_table's ``playlists_for_track``). When they are 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.setInterval(400)
self._color_save_timer.timeout.connect(self._prefs.save) self._color_save_timer.timeout.connect(self._prefs.save)
layout = QVBoxLayout(self) general = [self._build_sound_group()]
layout.addWidget(self._build_general_group())
if self._music_folder is not None: if self._music_folder is not None:
layout.addWidget(self._build_music_group()) general.insert(0, self._build_music_group())
layout.addWidget(self._build_colors_group()) self.pages_list = QListWidget()
layout.addWidget(self._build_lastfm_group()) self.pages = QStackedWidget()
layout.addWidget(self._build_acoustid_group()) for title, groups in (
layout.addStretch() ("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) lastfm.login_finished.connect(self._on_login_finished)
self._refresh_lastfm_state() 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: def _build_general_group(self) -> QGroupBox:
group = QGroupBox("General") group = QGroupBox("Interface")
form = QFormLayout(group) form = QFormLayout(group)
# UI size: 3-position slider # UI size: 3-position slider
+40
View File
@@ -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")
+5
View File
@@ -39,11 +39,16 @@ DEFAULTS = {
# machines should agree on what the Rabbit is carrying. # machines should agree on what the Rabbit is carrying.
"device_sync": { "device_sync": {
"playlist_ids": [], "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, "copy_art": True,
}, },
# Which machine runs friend sharing (Cassette): {machine_id, hostname}. # Which machine runs friend sharing (Cassette): {machine_id, hostname}.
# Synced so the other machine can name it; see lintunes/cassette/host.py. # Synced so the other machine can name it; see lintunes/cassette/host.py.
"cassette_host": None, "cassette_host": None,
# The quiet "tink" when a sync, export or URL import finishes.
"sound_effects": True,
} }
Binary file not shown.
+41
View File
@@ -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()
+1
View File
@@ -17,6 +17,7 @@ setup(
# The andTunes APK LinTunes installs, and the default button artwork # The andTunes APK LinTunes installs, and the default button artwork
# sync ships to the device. # sync ships to the device.
package_data={ package_data={
"lintunes": ["sounds/*.wav"],
"lintunes.export": ["templates/*"], "lintunes.export": ["templates/*"],
"lintunes.android": ["andTunes.apk", "andTunes.json"], "lintunes.android": ["andTunes.apk", "andTunes.json"],
"lintunes.andtunes": ["buttons/*.png"], "lintunes.andtunes": ["buttons/*.png"],
+254
View File
@@ -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