v0.28.0: Cassette share selector — choose what friends can cassette

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:18:14 -07:00
co-authored by Claude Opus 5.5
parent f04e69f9b2
commit 759fa85153
9 changed files with 906 additions and 12 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.27.0" __version__ = "0.28.0"
+31
View File
@@ -142,6 +142,10 @@ class CassetteService(QObject):
changed = pyqtSignal() # state or statuses moved changed = pyqtSignal() # state or statuses moved
notice = pyqtSignal(str) # "<Name> is now connected." notice = pyqtSignal(str) # "<Name> is now connected."
step_failed = pyqtSignal(str, str) # step, what Syncthing said 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, def __init__(self, root: Path | None = None, config_loader=load_config,
session_factory=None, addresses_for=None, parent=None): session_factory=None, addresses_for=None, parent=None):
@@ -160,6 +164,7 @@ class CassetteService(QObject):
self._kick = threading.Event() self._kick = threading.Event()
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._last_failure: tuple[str, str] | None = None self._last_failure: tuple[str, str] | None = None
self._library_provider = None
# ---- lifecycle ---- # ---- lifecycle ----
@@ -209,6 +214,29 @@ class CassetteService(QObject):
publish.publish_intro(out_dir(self.root, token), publish.publish_intro(out_dir(self.root, token),
self._state.display_name, avatar) 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 ---- # ---- user actions ----
def create_invite(self) -> str: def create_invite(self) -> str:
@@ -270,6 +298,7 @@ class CassetteService(QObject):
added=now_iso()) added=now_iso())
self._state.friends[code.token] = friend self._state.friends[code.token] = friend
self._save() self._save()
self.republish()
self.changed.emit() self.changed.emit()
self.kick() self.kick()
return friend return friend
@@ -325,6 +354,7 @@ class CassetteService(QObject):
self._save() self._save()
for token in list(state.friends) + [i.token for i in state.open_invites()]: for token in list(state.friends) + [i.token for i in state.open_invites()]:
self._publish_intro(token) self._publish_intro(token)
self.republish()
self.changed.emit() self.changed.emit()
if errors: if errors:
raise errors[0] raise errors[0]
@@ -453,6 +483,7 @@ class CassetteService(QObject):
invite.status = USED invite.status = USED
self._state.probing.pop(device_id, None) self._state.probing.pop(device_id, None)
self._save() self._save()
self.friends_added.emit()
def _reject(self, client, device_id: str): def _reject(self, client, device_id: str):
self._state.probing.pop(device_id, None) self._state.probing.pop(device_id, None)
+113
View File
@@ -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
+41 -3
View File
@@ -7,7 +7,7 @@ and calls ``build_menu`` / ``refresh_menu`` / ``shutdown``.
""" """
from __future__ import annotations from __future__ import annotations
from PyQt6.QtCore import QObject from PyQt6.QtCore import QObject, QTimer
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QDialog, QMessageBox 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 from lintunes.config import load_config, save_config
NOTICE_MS = 10000 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): 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) super().__init__(parent or window)
self._window = window self._window = window
self._prefs = prefs 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._elsewhere_action: QAction | None = None
self._actions: list[QAction] = [] self._actions: list[QAction] = []
self._service_factory = service_factory self._service_factory = service_factory
@@ -44,9 +53,37 @@ class CassetteUi(QObject):
self.service = CassetteService(parent=self) self.service = CassetteService(parent=self)
self.service.notice.connect(self._on_notice) self.service.notice.connect(self._on_notice)
self.service.step_failed.connect(self._on_step_failed) 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() self.service.start()
# Whatever changed while LinTunes was closed.
self._republish_timer.start()
return self.service 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): def shutdown(self):
if self.service is not None: if self.service is not None:
self.service.stop() self.service.stop()
@@ -148,7 +185,8 @@ class CassetteUi(QObject):
service = self.service service = self.service
config = load_config() config = load_config()
dialog = SyncSettingsDialog(service.state(), service.root, 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: if dialog.exec() != QDialog.DialogCode.Accepted:
return return
dialog.apply() dialog.apply()
+1 -1
View File
@@ -98,7 +98,7 @@ class MainWindow(QMainWindow):
# Friend sharing's menu group and dialogs. Its background service is # Friend sharing's menu group and dialogs. Its background service is
# started by run_gui (host machine only), never by constructing the # started by run_gui (host machine only), never by constructing the
# window — tests build windows by the dozen. # window — tests build windows by the dozen.
self._cassette = CassetteUi(self, prefs) self._cassette = CassetteUi(self, prefs, manager)
self.setWindowTitle("LinTunes") self.setWindowTitle("LinTunes")
self.resize(1280, 800) self.resize(1280, 800)
+414
View File
@@ -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)
+58 -7
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
QSplitter, QStackedWidget, QVBoxLayout, QWidget, QSplitter, QStackedWidget, QVBoxLayout, QWidget,
) )
@@ -174,7 +174,16 @@ class GeneralPage(QWidget):
avatar_row.addStretch(1) avatar_row.addStretch(1)
you_form.addRow("Avatar:", avatar_row) you_form.addRow("Avatar:", avatar_row)
layout.addWidget(you) 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) self.show_probe(None)
@@ -257,7 +266,7 @@ class FriendPage(QWidget):
def __init__(self, friend, root: Path, parent=None): def __init__(self, friend, root: Path, parent=None):
super().__init__(parent) super().__init__(parent)
self.friend = friend self.friend = friend # the dialog's copy: the selector edits it
self.removed = False self.removed = False
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -286,7 +295,13 @@ class FriendPage(QWidget):
box_layout.addWidget(self.hint) box_layout.addWidget(self.hint)
box_layout.addWidget(self.details) box_layout.addWidget(self.details)
layout.addWidget(box) 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 = QHBoxLayout()
row.addStretch(1) row.addStretch(1)
@@ -299,6 +314,18 @@ class FriendPage(QWidget):
layout.addLayout(row) layout.addLayout(row)
self.show_status(None) 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): def show_status(self, status):
if status is None: if status is None:
self.status.setText("Checking…") self.status.setText("Checking…")
@@ -337,11 +364,12 @@ class SyncSettingsDialog(QDialog):
the change affects.""" the change affects."""
def __init__(self, state, root: Path, config: dict, parent=None, def __init__(self, state, root: Path, config: dict, parent=None,
probe=None, service=None): probe=None, service=None, library=None):
super().__init__(parent) super().__init__(parent)
self._service = service self._service = service
self._library = library
self.setWindowTitle("Sync Settings") self.setWindowTitle("Sync Settings")
self.resize(820, 560) self.resize(1080, 880)
self.state = state.copy() self.state = state.copy()
self._root = root self._root = root
self._config = config self._config = config
@@ -362,7 +390,9 @@ class SyncSettingsDialog(QDialog):
page = FriendPage(friend, root) page = FriendPage(friend, root)
self.friend_pages[friend.token] = page self.friend_pages[friend.token] = page
self._add_page(friend.name, 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) self.pages_list.setCurrentRow(0)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
@@ -403,6 +433,27 @@ class SyncSettingsDialog(QDialog):
pass pass
super().done(result) 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): def _add_page(self, label: str, page: QWidget):
self.pages_list.addItem(label) self.pages_list.addItem(label)
self.pages.addWidget(page) self.pages.addWidget(page)
+2
View File
@@ -44,6 +44,7 @@ class LibraryManager(QObject):
file_move_failed = pyqtSignal(str, str) # track name, error text file_move_failed = pyqtSignal(str, str) # track name, error text
tracks_removed = pyqtSignal(list) # track ids gone from the library tracks_removed = pyqtSignal(list) # track ids gone from the library
tracks_restored = pyqtSignal(list) # track ids back after an undo 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) music_folder_changed = pyqtSignal(str) # new media folder ('' if none)
def __init__(self, library: Library, data_dir: Path, parent=None): def __init__(self, library: Library, data_dir: Path, parent=None):
@@ -391,6 +392,7 @@ class LibraryManager(QObject):
self._dirty_metadata = True self._dirty_metadata = True
self._schedule_save() self._schedule_save()
self._touch_smart(None) self._touch_smart(None)
self.tracks_added.emit([track.track_id])
return track.track_id return track.track_id
def delete_tracks(self, track_ids: list[int], def delete_tracks(self, track_ids: list[int],
+245
View File
@@ -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