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.
This commit is contained in:
2026-09-28 21:18:14 -07:00
parent 5520eb333e
commit 662217a7b7
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."""
__version__ = "0.27.0"
__version__ = "0.28.0"
+31
View File
@@ -142,6 +142,10 @@ class CassetteService(QObject):
changed = pyqtSignal() # state or statuses moved
notice = pyqtSignal(str) # "<Name> 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)
+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 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()
+1 -1
View File
@@ -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)
+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.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)
+2
View File
@@ -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],