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 0000000..c13fa88 Binary files /dev/null and b/lintunes/sounds/tink.wav differ 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