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);
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 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.31.9"
__version__ = "0.32.0"
+21 -14
View File
@@ -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
View File
@@ -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):
+195 -33
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
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
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.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)
+88 -10
View File
@@ -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
+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.
"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.
+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
# sync ships to the device.
package_data={
"lintunes": ["sounds/*.wav"],
"lintunes.export": ["templates/*"],
"lintunes.android": ["andTunes.apk", "andTunes.json"],
"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