From 759fa851539f00a0e1b3f3942b2b92a2d6b4bf72 Mon Sep 17 00:00:00 2001 From: trav Date: Mon, 28 Sep 2026 21:18:14 -0700 Subject: [PATCH] =?UTF-8?q?v0.28.0:=20Cassette=20share=20selector=20?= =?UTF-8?q?=E2=80=94=20choose=20what=20friends=20can=20cassette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync Settings gets a share selector on each friend page (and on General when "Share the same selection with all friends" is on; each friend's own selection is kept, greyed, and comes back when it's turned off). - Library: a genre/artist/album column browser with a checkbox beside every artist, album and track, search, Select All / Select None and "Share whole library". Playlists: a checklist with "Share all playlists". Nothing is shared by default. - Checking an artist or album is a snapshot of its tracks; the whole library and shared playlists are live. A shared playlist's tracks are checked and locked, with a tooltip naming the playlist(s). - cassette/share.py writes the trimmed copy (library.json, playlists.json) into each friend's folder: tags yes, listening history and local paths no. Written on OK and after library edits (debounced), and only when the bytes would change. Co-Authored-By: Claude Opus 5.5 --- lintunes/__init__.py | 2 +- lintunes/cassette/service.py | 31 ++ lintunes/cassette/share.py | 113 ++++++++ lintunes/gui/cassette_ui.py | 44 ++- lintunes/gui/main_window.py | 2 +- lintunes/gui/share_selector.py | 414 +++++++++++++++++++++++++++ lintunes/gui/sync_settings_dialog.py | 65 ++++- lintunes/library_manager.py | 2 + tests/test_round66.py | 245 ++++++++++++++++ 9 files changed, 906 insertions(+), 12 deletions(-) create mode 100644 lintunes/cassette/share.py create mode 100644 lintunes/gui/share_selector.py create mode 100644 tests/test_round66.py diff --git a/lintunes/__init__.py b/lintunes/__init__.py index fd9949a..384e710 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.27.0" +__version__ = "0.28.0" diff --git a/lintunes/cassette/service.py b/lintunes/cassette/service.py index de6a3b2..ccb38f6 100644 --- a/lintunes/cassette/service.py +++ b/lintunes/cassette/service.py @@ -142,6 +142,10 @@ class CassetteService(QObject): changed = pyqtSignal() # state or statuses moved notice = pyqtSignal(str) # " is now connected." step_failed = pyqtSignal(str, str) # step, what Syncthing said + # A friendship was added (their copy of my share needs writing). Emitted + # from the background thread; the GUI side does the writing, because + # that reads the library. + friends_added = pyqtSignal() def __init__(self, root: Path | None = None, config_loader=load_config, session_factory=None, addresses_for=None, parent=None): @@ -160,6 +164,7 @@ class CassetteService(QObject): self._kick = threading.Event() self._thread: threading.Thread | None = None self._last_failure: tuple[str, str] | None = None + self._library_provider = None # ---- lifecycle ---- @@ -209,6 +214,29 @@ class CassetteService(QObject): publish.publish_intro(out_dir(self.root, token), self._state.display_name, avatar) + # ---- publishing my share ---- + + def set_library_provider(self, provider): + """``provider()`` returns the live Library. Only ever called on the + GUI thread (republish), which is the thread that mutates it.""" + self._library_provider = provider + + def republish(self) -> list[str]: + """Write every friend's trimmed copy of my library; unchanged files + aren't touched. Returns the tokens whose copy changed.""" + if self._library_provider is None: + return [] + from lintunes.cassette import share + library = self._library_provider() + changed = [] + with self._lock: + for token, friend in self._state.friends.items(): + selection = self._state.selection_for(friend) + if share.publish_share(out_dir(self.root, token), library, + selection): + changed.append(token) + return changed + # ---- user actions ---- def create_invite(self) -> str: @@ -270,6 +298,7 @@ class CassetteService(QObject): added=now_iso()) self._state.friends[code.token] = friend self._save() + self.republish() self.changed.emit() self.kick() return friend @@ -325,6 +354,7 @@ class CassetteService(QObject): self._save() for token in list(state.friends) + [i.token for i in state.open_invites()]: self._publish_intro(token) + self.republish() self.changed.emit() if errors: raise errors[0] @@ -453,6 +483,7 @@ class CassetteService(QObject): invite.status = USED self._state.probing.pop(device_id, None) self._save() + self.friends_added.emit() def _reject(self, client, device_id: str): self._state.probing.pop(device_id, None) diff --git a/lintunes/cassette/share.py b/lintunes/cassette/share.py new file mode 100644 index 0000000..ec9cb75 --- /dev/null +++ b/lintunes/cassette/share.py @@ -0,0 +1,113 @@ +"""What a friend sees: the trimmed library copy in my outgoing folder. + +Pure and Qt-free. A ``Selection`` (state.py) says what I offer: + +* ``all_library`` — everything, live: a track added next week is offered too. +* ``track_ids`` — tracks checked in the browser. Checking an artist or album + stores the ids it covered *then*, so a new album shows up unchecked. +* ``all_playlists`` / ``playlist_ids`` — shared playlists, live: a song added + to a shared playlist is offered with it. Their tracks are **locked** in the + selector (``locks``), because unchecking them there would break the + playlist on the friend's side. + +Offering only makes a track *available*; nothing is sent until the friend +asks for it. + +The copy keeps what describes a song (the tags) and drops what describes *my +listening* — play and skip counts, ratings, loved, last played, dates — and +anything that only means something on this machine (location, comments, +start/stop times, EQ). +""" +from __future__ import annotations + +from pathlib import Path + +from lintunes.models import PlaylistType +from lintunes.cassette import publish + +LIBRARY_FILE = "library.json" +PLAYLISTS_FILE = "playlists.json" + +# Track fields a friend receives. Everything else stays home. +SHARED_FIELDS = ( + "name", "artist", "album_artist", "album", "genre", "composer", + "grouping", "kind", "size", "total_time", "disc_number", "disc_count", + "track_number", "track_count", "year", "bpm", "bit_rate", "sample_rate", + "compilation", "sort_name", "sort_artist", "sort_album_artist", + "sort_album", "sort_composer", "work", "movement_name", + "movement_number", "movement_count", +) + + +def shareable_playlists(library) -> list: + """Playlists that can be shared at all: regular and smart. Folders are + structure, and system playlists are the library itself.""" + return [p for p in library.playlists.values() + if p.playlist_type in (PlaylistType.REGULAR, PlaylistType.SMART) + and not p.is_system] + + +def shared_playlists(library, selection) -> list: + playlists = shareable_playlists(library) + if not selection.all_playlists: + playlists = [p for p in playlists + if p.persistent_id in selection.playlist_ids] + return sorted(playlists, key=lambda p: (p.name.casefold(), p.persistent_id)) + + +def locks(library, selection) -> dict[int, list[str]]: + """{track id: names of the shared playlists that require it}.""" + required: dict[int, list[str]] = {} + for playlist in shared_playlists(library, selection): + for tid in playlist.track_ids: + if tid in library.tracks: + names = required.setdefault(tid, []) + if playlist.name not in names: + names.append(playlist.name) + return required + + +def offered_ids(library, selection) -> set[int]: + if selection.all_library: + return set(library.tracks) + offered = {tid for tid in selection.track_ids if tid in library.tracks} + offered.update(locks(library, selection)) + return offered + + +def track_entry(track) -> dict: + entry = {"id": track.track_id} + for name in SHARED_FIELDS: + value = getattr(track, name) + if value not in ("", 0, None, False): + entry[name] = value + return entry + + +def build_library(library, selection) -> dict: + offered = offered_ids(library, selection) + tracks = [track_entry(library.tracks[tid]) for tid in sorted(offered)] + return {"format": publish.FORMAT_VERSION, "tracks": tracks} + + +def build_playlists(library, selection) -> dict: + offered = offered_ids(library, selection) + playlists = [] + for playlist in shared_playlists(library, selection): + playlists.append({ + "id": playlist.persistent_id, + "name": playlist.name, + "smart": playlist.is_smart, + "track_ids": [t for t in playlist.track_ids if t in offered], + }) + return {"format": publish.FORMAT_VERSION, "playlists": playlists} + + +def publish_share(out: Path, library, selection) -> bool: + """Write the trimmed copy into ``out``. True when either file changed.""" + publish.prepare_out_dir(out) + wrote_library = publish.write_json_if_changed( + out / LIBRARY_FILE, build_library(library, selection)) + wrote_playlists = publish.write_json_if_changed( + out / PLAYLISTS_FILE, build_playlists(library, selection)) + return wrote_library or wrote_playlists diff --git a/lintunes/gui/cassette_ui.py b/lintunes/gui/cassette_ui.py index 387d56b..0deb78a 100644 --- a/lintunes/gui/cassette_ui.py +++ b/lintunes/gui/cassette_ui.py @@ -7,7 +7,7 @@ and calls ``build_menu`` / ``refresh_menu`` / ``shutdown``. """ from __future__ import annotations -from PyQt6.QtCore import QObject +from PyQt6.QtCore import QObject, QTimer from PyQt6.QtGui import QAction from PyQt6.QtWidgets import QDialog, QMessageBox @@ -16,13 +16,22 @@ from lintunes.cassette import syncthing_api as st from lintunes.config import load_config, save_config NOTICE_MS = 10000 +# Library edits are batched before friends' copies are rebuilt: an import of +# a hundred files is one rewrite, not a hundred. +REPUBLISH_DELAY_MS = 5000 class CassetteUi(QObject): - def __init__(self, window, prefs, parent=None, service_factory=None): + def __init__(self, window, prefs, manager=None, parent=None, + service_factory=None): super().__init__(parent or window) self._window = window self._prefs = prefs + self._manager = manager + self._republish_timer = QTimer(self) + self._republish_timer.setSingleShot(True) + self._republish_timer.setInterval(REPUBLISH_DELAY_MS) + self._republish_timer.timeout.connect(self._republish) self._elsewhere_action: QAction | None = None self._actions: list[QAction] = [] self._service_factory = service_factory @@ -44,9 +53,37 @@ class CassetteUi(QObject): self.service = CassetteService(parent=self) self.service.notice.connect(self._on_notice) self.service.step_failed.connect(self._on_step_failed) + self.service.friends_added.connect(self._republish_timer.start) + if self._manager is not None: + self.service.set_library_provider(self._library) + manager = self._manager + for signal in (manager.playlists_changed, + manager.playlist_content_changed, + manager.track_fields_edited, + manager.library_reloaded, + manager.tracks_removed, + manager.tracks_restored, + manager.tracks_added): + signal.connect(self._schedule_republish) self.service.start() + # Whatever changed while LinTunes was closed. + self._republish_timer.start() return self.service + def _library(self): + return self._manager.library if self._manager is not None else None + + def _schedule_republish(self, *_): + self._republish_timer.start() + + def _republish(self): + if self.service is not None: + try: + self.service.republish() + except OSError as exc: + self._on_step_failed("Updating what your friends can see", + str(exc)) + def shutdown(self): if self.service is not None: self.service.stop() @@ -148,7 +185,8 @@ class CassetteUi(QObject): service = self.service config = load_config() dialog = SyncSettingsDialog(service.state(), service.root, config, - self._window, service=service) + self._window, service=service, + library=self._library()) if dialog.exec() != QDialog.DialogCode.Accepted: return dialog.apply() diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 58d8fcd..2b59404 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -98,7 +98,7 @@ class MainWindow(QMainWindow): # Friend sharing's menu group and dialogs. Its background service is # started by run_gui (host machine only), never by constructing the # window — tests build windows by the dozen. - self._cassette = CassetteUi(self, prefs) + self._cassette = CassetteUi(self, prefs, manager) self.setWindowTitle("LinTunes") self.resize(1280, 800) diff --git a/lintunes/gui/share_selector.py b/lintunes/gui/share_selector.py new file mode 100644 index 0000000..9f6645d --- /dev/null +++ b/lintunes/gui/share_selector.py @@ -0,0 +1,414 @@ +"""The share selector on Sync Settings' General and friend pages: what a +friend may cassette from me. + +Two independent sections. **Library** is a column browser like the main +window's (genre / artist / album, then the tracks), with a checkbox beside +every artist, album and track and a **Share whole library** box above it. +**Playlists** is every playlist with a checkbox and **Share all playlists**. +Both have Select All / Select None. Nothing is shared by default. + +Checking an artist or album checks the tracks shown under it (a snapshot: a +new album appears unchecked later). A shared playlist's tracks are checked +and **locked** in the Library section — not user-checkable, with a tooltip +naming the playlist(s) — because unchecking them there would break the +playlist on the friend's side. + +The widget edits the ``Selection`` it's given in place; the dialog owns the +copy and only OK writes it. +""" +from __future__ import annotations + +from collections import defaultdict + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import ( + QCheckBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, + QListWidgetItem, QPushButton, QSplitter, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget, +) + +from lintunes.cassette import share +from lintunes.gui.library_view import ( + _artist_sort_key, _canonical_values, _matches_search, +) + +CHECKED = Qt.CheckState.Checked +UNCHECKED = Qt.CheckState.Unchecked +PARTIAL = Qt.CheckState.PartiallyChecked +TID_ROLE = Qt.ItemDataRole.UserRole +KEY_ROLE = Qt.ItemDataRole.UserRole + 1 + + +def _artist_of(track) -> str: + return track.album_artist or track.artist + + +def lock_tooltip(names: list[str]) -> str: + quoted = ", ".join(f"“{n}”" for n in names) + noun = "playlist" if len(names) == 1 else "playlists" + return (f"Shared with the {noun} {quoted}. To stop sharing this song, " + f"unshare the {noun} below.") + + +def _tristate(ids, checked: set) -> Qt.CheckState: + if not ids: + return UNCHECKED + hits = sum(1 for tid in ids if tid in checked) + if hits == 0: + return UNCHECKED + return CHECKED if hits == len(ids) else PARTIAL + + +class _CheckList(QListWidget): + """A browser column whose rows (after "All") carry a checkbox.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(120) + self.setUniformItemSizes(True) + + def set_values(self, label: str, values: list[str]): + current = self.selected_value() + self.blockSignals(True) + self.clear() + self.addItem(f"All ({len(values)} {label})") + for value in values: + item = QListWidgetItem(value) + item.setData(KEY_ROLE, value) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(UNCHECKED) + self.addItem(item) + row = 0 + if current: + matches = self.findItems(current, Qt.MatchFlag.MatchExactly) + if matches: + row = self.row(matches[0]) + self.setCurrentRow(row) + self.blockSignals(False) + + def selected_value(self) -> str: + item = self.currentItem() + if item is None or self.row(item) == 0: + return "" + return item.text() + + def value_items(self): + for row in range(1, self.count()): + yield self.item(row) + + +class ShareSelector(QWidget): + changed = pyqtSignal() + + def __init__(self, library, selection, parent=None): + super().__init__(parent) + self._library = library + self.selection = selection + self._tracks = list(library.tracks.values()) + self._visible = self._tracks + self._search_text = "" + self._locks: dict[int, list[str]] = {} + self._updating = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self._build_library_box()) + splitter.addWidget(self._build_playlists_box()) + splitter.setSizes([560, 140]) + splitter.setCollapsible(0, False) + layout.addWidget(splitter) + + self._refresh_locks() + self._populate_genres() + + # ---- building ---- + + def _build_library_box(self) -> QWidget: + box = QGroupBox("Library") + layout = QVBoxLayout(box) + top = QHBoxLayout() + self.whole_library = QCheckBox("Share whole library") + self.whole_library.setToolTip( + "Everything in your library can be cassetted, including songs " + "you add later. Nothing is sent until your friend asks for it.") + self.whole_library.setChecked(self.selection.all_library) + self.whole_library.toggled.connect(self._on_whole_library) + top.addWidget(self.whole_library) + top.addStretch(1) + self.search = QLineEdit() + self.search.setPlaceholderText("search") + self.search.setClearButtonEnabled(True) + self.search.textChanged.connect(self._on_search) + top.addWidget(self.search) + self.lib_all = QPushButton("Select All") + self.lib_none = QPushButton("Select None") + self.lib_all.clicked.connect(lambda: self._select_all_tracks(True)) + self.lib_none.clicked.connect(lambda: self._select_all_tracks(False)) + top.addWidget(self.lib_all) + top.addWidget(self.lib_none) + layout.addLayout(top) + + self._browser_host = QWidget() + host = QVBoxLayout(self._browser_host) + host.setContentsMargins(0, 0, 0, 0) + split = QSplitter(Qt.Orientation.Vertical) + columns = QSplitter(Qt.Orientation.Horizontal) + self.genres = QListWidget() + self.genres.setUniformItemSizes(True) + self.artists = _CheckList() + self.albums = _CheckList() + for widget in (self.genres, self.artists, self.albums): + widget.setMinimumHeight(110) + columns.addWidget(widget) + self.tracks = QTreeWidget() + self.tracks.setHeaderLabels(["Name", "Artist", "Album"]) + self.tracks.setRootIsDecorated(False) + self.tracks.setUniformRowHeights(True) + self.tracks.setColumnWidth(0, 260) + self.tracks.setColumnWidth(1, 180) + self.tracks.setMinimumHeight(160) + split.addWidget(columns) + split.addWidget(self.tracks) + split.setSizes([170, 330]) + host.addWidget(split) + layout.addWidget(self._browser_host, stretch=1) + + self.genres.currentRowChanged.connect(lambda _: self._populate_artists()) + self.artists.currentRowChanged.connect(lambda _: self._populate_albums()) + self.albums.currentRowChanged.connect(lambda _: self._populate_tracks()) + self.artists.itemChanged.connect(self._on_group_checked) + self.albums.itemChanged.connect(self._on_group_checked) + self.tracks.itemChanged.connect(self._on_track_checked) + self._browser_host.setEnabled(not self.selection.all_library) + return box + + def _build_playlists_box(self) -> QWidget: + box = QGroupBox("Playlists") + layout = QVBoxLayout(box) + top = QHBoxLayout() + self.all_playlists = QCheckBox("Share all playlists") + self.all_playlists.setToolTip( + "Every playlist, including ones you make later. Songs you add to " + "a shared playlist are shared with it.") + self.all_playlists.setChecked(self.selection.all_playlists) + self.all_playlists.toggled.connect(self._on_all_playlists) + top.addWidget(self.all_playlists) + top.addStretch(1) + self.pl_all = QPushButton("Select All") + self.pl_none = QPushButton("Select None") + self.pl_all.clicked.connect(lambda: self._select_all_playlists(True)) + self.pl_none.clicked.connect(lambda: self._select_all_playlists(False)) + top.addWidget(self.pl_all) + top.addWidget(self.pl_none) + layout.addLayout(top) + self.playlists = QListWidget() + for playlist in sorted(share.shareable_playlists(self._library), + key=lambda p: p.name.casefold()): + item = QListWidgetItem(playlist.name) + item.setData(KEY_ROLE, playlist.persistent_id) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(CHECKED if playlist.persistent_id + in self.selection.playlist_ids else UNCHECKED) + self.playlists.addItem(item) + self.playlists.itemChanged.connect(self._on_playlist_checked) + self.playlists.setEnabled(not self.selection.all_playlists) + layout.addWidget(self.playlists) + return box + + # ---- state helpers ---- + + def _checked(self) -> set: + """Tracks that show as checked: chosen ones plus locked ones.""" + return self.selection.track_ids | set(self._locks) + + def _refresh_locks(self): + self._locks = share.locks(self._library, self.selection) + + def _filtered(self, up_to: str) -> list: + tracks = self._visible + genre = "" + item = self.genres.currentItem() + if item is not None and self.genres.row(item) > 0: + genre = item.text().casefold() + if genre: + tracks = [t for t in tracks if t.genre.casefold() == genre] + if up_to in ("artist", "album"): + artist = self.artists.selected_value().casefold() + if artist: + tracks = [t for t in tracks if _artist_of(t).casefold() == artist] + if up_to == "album": + album = self.albums.selected_value().casefold() + if album: + tracks = [t for t in tracks if t.album.casefold() == album] + return tracks + + # ---- populating ---- + + def _populate_genres(self): + genres = sorted(_canonical_values(t.genre for t in self._visible if t.genre), + key=str.casefold) + current = self.genres.currentItem() + keep = current.text() if current is not None and self.genres.row(current) > 0 else "" + self.genres.blockSignals(True) + self.genres.clear() + self.genres.addItem(f"All ({len(genres)} Genres)") + self.genres.addItems(genres) + row = 0 + if keep: + found = self.genres.findItems(keep, Qt.MatchFlag.MatchExactly) + row = self.genres.row(found[0]) if found else 0 + self.genres.setCurrentRow(row) + self.genres.blockSignals(False) + self._populate_artists() + + def _populate_artists(self): + tracks = self._filtered("genre") + names = _canonical_values(_artist_of(t) for t in tracks if _artist_of(t)) + self._updating = True + self.artists.set_values("Artists", sorted(names, key=_artist_sort_key)) + self._updating = False + self._populate_albums() + + def _populate_albums(self): + tracks = self._filtered("artist") + names = _canonical_values(t.album for t in tracks if t.album) + self._updating = True + self.albums.set_values("Albums", sorted(names, key=str.casefold)) + self._updating = False + self._populate_tracks() + + def _populate_tracks(self): + tracks = sorted(self._filtered("album"), + key=lambda t: (_artist_sort_key(_artist_of(t)), + t.album.casefold(), t.disc_number, + t.track_number, t.name.casefold())) + checked = self._checked() + self._updating = True + self.tracks.clear() + items = [] + for track in tracks: + item = QTreeWidgetItem([track.name, track.artist, track.album]) + item.setData(0, TID_ROLE, track.track_id) + locked = self._locks.get(track.track_id) + if locked and not self.selection.all_library: + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, CHECKED) + tip = lock_tooltip(locked) + for column in range(3): + item.setToolTip(column, tip) + else: + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, CHECKED if (self.selection.all_library + or track.track_id in checked) + else UNCHECKED) + items.append(item) + self.tracks.addTopLevelItems(items) + self._updating = False + self._refresh_group_states() + + def _group_ids(self, column: _CheckList, key: str) -> list[int]: + key = key.casefold() + if column is self.artists: + pool = self._filtered("genre") + return [t.track_id for t in pool if _artist_of(t).casefold() == key] + pool = self._filtered("artist") + return [t.track_id for t in pool if t.album.casefold() == key] + + def _refresh_group_states(self): + """Artist and album boxes: checked / partly / not, from their tracks.""" + checked = set(self._library.tracks) if self.selection.all_library \ + else self._checked() + self._updating = True + for column in (self.artists, self.albums): + index = defaultdict(list) + pool = self._filtered("genre" if column is self.artists else "artist") + for t in pool: + name = _artist_of(t) if column is self.artists else t.album + index[name.casefold()].append(t.track_id) + for item in column.value_items(): + item.setCheckState(_tristate(index.get(item.text().casefold(), []), + checked)) + self._updating = False + + def refresh(self): + """Rebuild after the selection changed underneath (locks moved).""" + self._refresh_locks() + self._populate_tracks() + + # ---- user actions ---- + + def _on_search(self, text: str): + self._search_text = text.strip().casefold() + tokens = self._search_text.split() + self._visible = ([t for t in self._tracks if _matches_search(t, tokens)] + if tokens else self._tracks) + self._populate_genres() + + def _on_whole_library(self, on: bool): + self.selection.all_library = on + self._browser_host.setEnabled(not on) + self._populate_tracks() + self.changed.emit() + + def _on_all_playlists(self, on: bool): + self.selection.all_playlists = on + self.playlists.setEnabled(not on) + self.refresh() + self.changed.emit() + + def _on_group_checked(self, item: QListWidgetItem): + if self._updating: + return + column = item.listWidget() + ids = self._group_ids(column, item.text()) + # A click on a partly-checked box means "all of it". + if item.checkState() == UNCHECKED: + self.selection.track_ids.difference_update(ids) + else: + self.selection.track_ids.update(ids) + self._populate_tracks() + self.changed.emit() + + def _on_track_checked(self, item: QTreeWidgetItem, column: int): + if self._updating or column != 0: + return + tid = item.data(0, TID_ROLE) + if item.checkState(0) == CHECKED: + self.selection.track_ids.add(tid) + else: + self.selection.track_ids.discard(tid) + self._refresh_group_states() + self.changed.emit() + + def _on_playlist_checked(self, item: QListWidgetItem): + pid = item.data(KEY_ROLE) + if item.checkState() == CHECKED: + self.selection.playlist_ids.add(pid) + else: + self.selection.playlist_ids.discard(pid) + self.refresh() + self.changed.emit() + + def _select_all_tracks(self, on: bool): + if on: + self.selection.track_ids = set(self._library.tracks) + else: + self.selection.track_ids = set() + self._populate_tracks() + self.changed.emit() + + def _select_all_playlists(self, on: bool): + pids = {p.persistent_id for p in share.shareable_playlists(self._library)} + self.selection.playlist_ids = set(pids) if on else set() + self.playlists.blockSignals(True) + for row in range(self.playlists.count()): + self.playlists.item(row).setCheckState(CHECKED if on else UNCHECKED) + self.playlists.blockSignals(False) + self.refresh() + self.changed.emit() + + def set_read_only(self, read_only: bool): + """Greyed out on a friend page while "same for all friends" is on.""" + for widget in self.findChildren(QGroupBox): + widget.setEnabled(not read_only) diff --git a/lintunes/gui/sync_settings_dialog.py b/lintunes/gui/sync_settings_dialog.py index ab0ccc6..3aad890 100644 --- a/lintunes/gui/sync_settings_dialog.py +++ b/lintunes/gui/sync_settings_dialog.py @@ -13,7 +13,7 @@ from pathlib import Path from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtWidgets import ( - QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, + QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton, QSplitter, QStackedWidget, QVBoxLayout, QWidget, ) @@ -174,7 +174,16 @@ class GeneralPage(QWidget): avatar_row.addStretch(1) you_form.addRow("Avatar:", avatar_row) layout.addWidget(you) - layout.addStretch(1) + + self.same_for_all = QCheckBox("Share the same selection with all friends") + self.same_for_all.setChecked(state.share_same_for_all) + layout.addWidget(self.same_for_all) + # The general selector is built the first time it's needed. + self.selector_host = QVBoxLayout() + layout.addLayout(self.selector_host, stretch=1) + self.selector = None + self._stretch = QWidget() + layout.addWidget(self._stretch, stretch=1) self.show_probe(None) @@ -257,7 +266,7 @@ class FriendPage(QWidget): def __init__(self, friend, root: Path, parent=None): super().__init__(parent) - self.friend = friend + self.friend = friend # the dialog's copy: the selector edits it self.removed = False layout = QVBoxLayout(self) @@ -286,7 +295,13 @@ class FriendPage(QWidget): box_layout.addWidget(self.hint) box_layout.addWidget(self.details) layout.addWidget(box) - layout.addStretch(1) + + self.using_general = QLabel("Using general settings — see General.") + self.using_general.setVisible(False) + layout.addWidget(self.using_general) + self.selector_host = QVBoxLayout() + layout.addLayout(self.selector_host, stretch=1) + self.selector = None row = QHBoxLayout() row.addStretch(1) @@ -299,6 +314,18 @@ class FriendPage(QWidget): layout.addLayout(row) self.show_status(None) + def ensure_selector(self, library, same_for_all: bool): + if self.selector is None and library is not None: + from lintunes.gui.share_selector import ShareSelector + self.selector = ShareSelector(library, self.friend.selection) + self.selector_host.addWidget(self.selector) + self.set_same_for_all(same_for_all) + + def set_same_for_all(self, on: bool): + self.using_general.setVisible(on) + if self.selector is not None: + self.selector.set_read_only(on) + def show_status(self, status): if status is None: self.status.setText("Checking…") @@ -337,11 +364,12 @@ class SyncSettingsDialog(QDialog): the change affects.""" def __init__(self, state, root: Path, config: dict, parent=None, - probe=None, service=None): + probe=None, service=None, library=None): super().__init__(parent) self._service = service + self._library = library self.setWindowTitle("Sync Settings") - self.resize(820, 560) + self.resize(1080, 880) self.state = state.copy() self._root = root self._config = config @@ -362,7 +390,9 @@ class SyncSettingsDialog(QDialog): page = FriendPage(friend, root) self.friend_pages[friend.token] = page self._add_page(friend.name, page) - self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex) + self.pages_list.currentRowChanged.connect(self._show_page) + self.general.same_for_all.toggled.connect(self._on_same_for_all) + self._on_same_for_all(self.state.share_same_for_all) self.pages_list.setCurrentRow(0) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok @@ -403,6 +433,27 @@ class SyncSettingsDialog(QDialog): pass super().done(result) + def _show_page(self, row: int): + self.pages.setCurrentIndex(row) + page = self.pages.widget(row) + if isinstance(page, FriendPage): + page.ensure_selector(self._library, self.state.share_same_for_all) + + def _on_same_for_all(self, on: bool): + """General shows the shared selector; each friend page greys its own + out (kept, not cleared, so turning this off restores it).""" + self.state.share_same_for_all = on + general = self.general + if on and general.selector is None and self._library is not None: + from lintunes.gui.share_selector import ShareSelector + general.selector = ShareSelector(self._library, self.state.general) + general.selector_host.addWidget(general.selector) + if general.selector is not None: + general.selector.setVisible(on) + general._stretch.setVisible(not on) + for page in self.friend_pages.values(): + page.set_same_for_all(on) + def _add_page(self, label: str, page: QWidget): self.pages_list.addItem(label) self.pages.addWidget(page) diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index d0f59c8..3a78d10 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -44,6 +44,7 @@ class LibraryManager(QObject): file_move_failed = pyqtSignal(str, str) # track name, error text tracks_removed = pyqtSignal(list) # track ids gone from the library tracks_restored = pyqtSignal(list) # track ids back after an undo + tracks_added = pyqtSignal(list) # track ids new to the library music_folder_changed = pyqtSignal(str) # new media folder ('' if none) def __init__(self, library: Library, data_dir: Path, parent=None): @@ -391,6 +392,7 @@ class LibraryManager(QObject): self._dirty_metadata = True self._schedule_save() self._touch_smart(None) + self.tracks_added.emit([track.track_id]) return track.track_id def delete_tracks(self, track_ids: list[int], diff --git a/tests/test_round66.py b/tests/test_round66.py new file mode 100644 index 0000000..31ddc65 --- /dev/null +++ b/tests/test_round66.py @@ -0,0 +1,245 @@ +"""Round 66: the share selector and the trimmed library copy a friend reads. + +* Nothing is shared by default. "Share whole library" is live (new songs are + offered too); checked artists/albums store the track ids they covered then, + so a new album shows up unchecked; shared playlists are live and their + tracks are locked in the Library section with a tooltip naming them. +* Offering never sends anything — the copy only lists what may be asked for, + and keeps the tags while dropping my listening history and anything local. +* The copy is written only when its bytes would change. +""" +import json + +from PyQt6.QtCore import Qt + +from lintunes.cassette import share +from lintunes.cassette.state import CassetteState, Friend, Selection, out_dir +from lintunes.models import Playlist, PlaylistType, Track +from lintunes.models.library import Library + + +def _library(): + library = Library() + rows = [ + (1, "Reel One", "Analog Ghost", "Tape Hiss", "Folk"), + (2, "Reel Two", "Analog Ghost", "Tape Hiss", "Jazz"), + (3, "B-Side", "Analog Ghost", "Other Album", "Jazz"), + (4, "Étoile", "Marguerite Vaux", "Nuit Blanche", "Jazz"), + (5, "Café", "Marguerite Vaux", "Nuit Blanche", "Electronic"), + ] + for tid, name, artist, album, genre in rows: + library.tracks[tid] = Track( + track_id=tid, name=name, artist=artist, album=album, genre=genre, + year=1987, total_time=180000, size=4_000_000, play_count=41, + rating=80, loved=True, comments="private note", + location=f"/music/{tid}.mp3", date_added="2020-01-01T00:00:00", + play_date_utc="2026-01-01T00:00:00") + library.playlists["P1"] = Playlist(name="Road Trip", persistent_id="P1", + track_ids=[4, 1]) + library.playlists["P2"] = Playlist(name="Morning", persistent_id="P2", + track_ids=[5]) + library.playlists["F"] = Playlist(name="Boxes", persistent_id="F", + playlist_type=PlaylistType.FOLDER) + library.playlists["S"] = Playlist(name="Music", persistent_id="S", + is_system=True, + playlist_type=PlaylistType.SYSTEM) + return library + + +# ---- what is offered ---- + +class TestOffered: + def test_nothing_by_default(self): + assert share.offered_ids(_library(), Selection()) == set() + + def test_whole_library_is_live(self): + library = _library() + selection = Selection(all_library=True) + library.tracks[9] = Track(track_id=9, name="New") + assert 9 in share.offered_ids(library, selection) + + def test_checked_tracks_plus_shared_playlists(self): + selection = Selection(track_ids={2, 99}, playlist_ids={"P1"}) + assert share.offered_ids(_library(), selection) == {1, 2, 4} + + def test_shared_playlist_is_live(self): + library = _library() + selection = Selection(playlist_ids={"P1"}) + library.playlists["P1"].track_ids.append(3) + assert 3 in share.offered_ids(library, selection) + + def test_locks_name_every_playlist(self): + library = _library() + library.playlists["P2"].track_ids.append(4) + locks = share.locks(library, Selection(all_playlists=True)) + assert sorted(locks[4]) == ["Morning", "Road Trip"] + + def test_folders_and_system_playlists_are_never_shared(self): + names = [p.name for p in share.shared_playlists( + _library(), Selection(all_playlists=True))] + assert names == ["Morning", "Road Trip"] + + +# ---- what the copy holds ---- + +class TestCopy: + def test_tags_yes_history_no(self): + library = _library() + entry = share.build_library(library, Selection(track_ids={1}))["tracks"][0] + assert entry["id"] == 1 and entry["name"] == "Reel One" + assert entry["artist"] == "Analog Ghost" and entry["year"] == 1987 + assert entry["size"] == 4_000_000 and entry["total_time"] == 180000 + for private in ("play_count", "rating", "loved", "comments", "location", + "date_added", "play_date_utc", "persistent_id"): + assert private not in entry + + def test_playlists_keep_order_and_drop_unoffered(self): + library = _library() + library.playlists["P1"].track_ids.append(77) # dangling id + data = share.build_playlists(library, Selection(playlist_ids={"P1"})) + assert data["playlists"] == [{"id": "P1", "name": "Road Trip", + "smart": False, "track_ids": [4, 1]}] + + def test_written_only_when_changed(self, tmp_path): + library = _library() + selection = Selection(track_ids={1}) + assert share.publish_share(tmp_path, library, selection) + mtime = (tmp_path / share.LIBRARY_FILE).stat().st_mtime_ns + assert not share.publish_share(tmp_path, library, selection) + assert (tmp_path / share.LIBRARY_FILE).stat().st_mtime_ns == mtime + selection.track_ids.add(2) + assert share.publish_share(tmp_path, library, selection) + data = json.loads((tmp_path / share.LIBRARY_FILE).read_text()) + assert [t["id"] for t in data["tracks"]] == [1, 2] + + +# ---- the service ---- + +class TestRepublish: + def test_each_friend_gets_their_selection(self, qapp, tmp_path): + from lintunes.cassette.service import CassetteService + library = _library() + service = CassetteService(root=tmp_path, config_loader=lambda: {}) + service.set_library_provider(lambda: library) + service._state.friends["a"] = Friend(token="a", device_id="A", name="Amy", + selection=Selection(track_ids={1})) + service._state.friends["b"] = Friend(token="b", device_id="B", name="Bo", + selection=Selection(playlist_ids={"P2"})) + assert sorted(service.republish()) == ["a", "b"] + assert service.republish() == [] + + def ids(token): + data = json.loads((out_dir(tmp_path, token) / share.LIBRARY_FILE).read_text()) + return [t["id"] for t in data["tracks"]] + assert ids("a") == [1] + assert ids("b") == [5] + + service._state.share_same_for_all = True + service._state.general = Selection(track_ids={3}) + service.republish() + assert ids("a") == ids("b") == [3] + + +# ---- the selector ---- + +def _selector(qapp, selection=None, library=None): + from lintunes.gui.share_selector import ShareSelector + return ShareSelector(library or _library(), selection or Selection()) + + +def _track_items(selector): + return {selector.tracks.topLevelItem(i).data(0, Qt.ItemDataRole.UserRole): + selector.tracks.topLevelItem(i) + for i in range(selector.tracks.topLevelItemCount())} + + +def _row(column, text): + return column.findItems(text, Qt.MatchFlag.MatchExactly)[0] + + +class TestSelector: + def test_starts_empty(self, qapp): + selector = _selector(qapp) + assert all(item.checkState(0) == Qt.CheckState.Unchecked + for item in _track_items(selector).values()) + + def test_checking_an_artist_checks_its_tracks(self, qapp): + selection = Selection() + selector = _selector(qapp, selection) + _row(selector.artists, "Analog Ghost").setCheckState(Qt.CheckState.Checked) + assert selection.track_ids == {1, 2, 3} + assert _row(selector.albums, "Tape Hiss").checkState() == Qt.CheckState.Checked + + def test_album_then_partial_artist(self, qapp): + selection = Selection() + selector = _selector(qapp, selection) + _row(selector.albums, "Tape Hiss").setCheckState(Qt.CheckState.Checked) + assert selection.track_ids == {1, 2} + assert _row(selector.artists, "Analog Ghost").checkState() == \ + Qt.CheckState.PartiallyChecked + + def test_genre_filter_scopes_the_artist_box(self, qapp): + selection = Selection() + selector = _selector(qapp, selection) + selector.genres.setCurrentItem(_row(selector.genres, "Jazz")) + _row(selector.artists, "Analog Ghost").setCheckState(Qt.CheckState.Checked) + assert selection.track_ids == {2, 3} # not the Folk one + + def test_playlist_tracks_are_locked_with_a_tooltip(self, qapp): + selection = Selection() + selector = _selector(qapp, selection) + _row(selector.playlists, "Road Trip").setCheckState(Qt.CheckState.Checked) + assert selection.playlist_ids == {"P1"} + item = _track_items(selector)[4] + assert item.checkState(0) == Qt.CheckState.Checked + assert not item.flags() & Qt.ItemFlag.ItemIsUserCheckable + assert "Road Trip" in item.toolTip(0) + assert 4 not in selection.track_ids # locked, not chosen + + def test_select_none_keeps_locked_tracks(self, qapp): + selection = Selection(track_ids={2, 3}, playlist_ids={"P1"}) + selector = _selector(qapp, selection) + selector.lib_none.click() + assert selection.track_ids == set() + assert _track_items(selector)[1].checkState(0) == Qt.CheckState.Checked + + def test_select_all_buttons(self, qapp): + selection = Selection() + selector = _selector(qapp, selection) + selector.lib_all.click() + selector.pl_all.click() + assert selection.track_ids == {1, 2, 3, 4, 5} + assert selection.playlist_ids == {"P1", "P2"} + + def test_whole_library_greys_the_browser(self, qapp): + selection = Selection() + selector = _selector(qapp, selection) + selector.whole_library.setChecked(True) + assert selection.all_library + assert not selector._browser_host.isEnabled() + assert all(i.checkState(0) == Qt.CheckState.Checked + for i in _track_items(selector).values()) + + +# ---- the dialog ---- + +class TestSameForAll: + def test_turning_it_off_restores_each_friend(self, qapp, tmp_path): + from lintunes.gui.sync_settings_dialog import SyncSettingsDialog + from tests.test_round64 import _NoProbe + state = CassetteState() + state.friends["a"] = Friend(token="a", device_id="A", name="Amy", + selection=Selection(track_ids={1})) + dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe(), + library=_library()) + dialog._show_page(1) + page = dialog.friend_pages["a"] + dialog.general.same_for_all.setChecked(True) + assert dialog.general.selector is not None + assert page.using_general.isVisibleTo(dialog) + assert not page.selector.whole_library.isEnabled() + dialog.general.selector.lib_all.click() + dialog.general.same_for_all.setChecked(False) + assert dialog.state.friends["a"].selection.track_ids == {1} + assert dialog.state.general.track_ids == {1, 2, 3, 4, 5} + assert not dialog.state.share_same_for_all