From ba795e3c69b1dfaf549c46af9fb8b82e39104fa7 Mon Sep 17 00:00:00 2001 From: trav Date: Thu, 1 Oct 2026 00:45:42 -0700 Subject: [PATCH] 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 --- CLAUDE.md | 9 + lintunes/__init__.py | 2 +- lintunes/andtunes/art.py | 35 ++-- lintunes/andtunes/sync.py | 41 +++-- lintunes/gui/device_sync_dialog.py | 228 ++++++++++++++++++++++---- lintunes/gui/main_window.py | 25 +-- lintunes/gui/preferences_dialog.py | 98 +++++++++-- lintunes/gui/sounds.py | 40 +++++ lintunes/preferences.py | 5 + lintunes/sounds/tink.wav | Bin 0 -> 30912 bytes scripts/make_tink.py | 41 +++++ setup.py | 1 + tests/test_round70.py | 254 +++++++++++++++++++++++++++++ 13 files changed, 699 insertions(+), 80 deletions(-) create mode 100644 lintunes/gui/sounds.py create mode 100644 lintunes/sounds/tink.wav create mode 100644 scripts/make_tink.py create mode 100644 tests/test_round70.py diff --git a/CLAUDE.md b/CLAUDE.md index c174a30..04acb8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + `.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 + diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 810756f..0c6c7e4 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.31.9" +__version__ = "0.32.0" diff --git a/lintunes/andtunes/art.py b/lintunes/andtunes/art.py index 1cae7c3..30a920a 100644 --- a/lintunes/andtunes/art.py +++ b/lintunes/andtunes/art.py @@ -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: diff --git a/lintunes/andtunes/sync.py b/lintunes/andtunes/sync.py index 321f72c..b5c28fc 100644 --- a/lintunes/andtunes/sync.py +++ b/lintunes/andtunes/sync.py @@ -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): diff --git a/lintunes/gui/device_sync_dialog.py b/lintunes/gui/device_sync_dialog.py index 75a5571..10df6ed 100644 --- a/lintunes/gui/device_sync_dialog.py +++ b/lintunes/gui/device_sync_dialog.py @@ -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() diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 8944716..9b6a08a 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -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) diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index b0cce23..03213e7 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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 diff --git a/lintunes/gui/sounds.py b/lintunes/gui/sounds.py new file mode 100644 index 0000000..344a5a8 --- /dev/null +++ b/lintunes/gui/sounds.py @@ -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") diff --git a/lintunes/preferences.py b/lintunes/preferences.py index fecad6b..7519f8d 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -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, } diff --git a/lintunes/sounds/tink.wav b/lintunes/sounds/tink.wav new file mode 100644 index 0000000000000000000000000000000000000000..c13fa88995352c750741c64dce5b985727cf78d0 GIT binary patch literal 30912 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 literal 0 HcmV?d00001 diff --git a/scripts/make_tink.py b/scripts/make_tink.py new file mode 100644 index 0000000..139dfad --- /dev/null +++ b/scripts/make_tink.py @@ -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("= 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