Files
lintunes/lintunes/gui/sync_settings_dialog.py
T
trav 35281b9864 v0.31.0: Cassette followed playlists — a friend's playlist in your sidebar
- A followed playlist (the UFO in friend mode) appears in my sidebar with
  a purple UFO: mixed in with my playlists alphabetically, or inside a
  folder named after the friend (per-friend, in Sync Settings). It's never
  a Playlist of mine, so it stays out of the library and its merges.
- It plays from the cache. Player's queue accepts friend-track keys,
  resolved through Cassette: they scrobble to Last.fm but don't count in
  my play journal. Songs still on their way are grey and skipped.
- The cassette column there is hidden by default (right-click the
  header); pressing one takes the song into my library — straight from
  the cache if it's there, otherwise by asking for it.
- Sync Settings per friend: keep followed songs separate (default) or add
  them to my library. Unfollowing (right-click) frees their cache.
- The bottom bar says when the friend's folder last synced.
2026-09-28 21:40:59 -07:00

525 lines
21 KiB
Python

"""Connections ▸ Sync Settings… — Cassette's settings, laid out like VLC's.
A list on the left (General first, then one entry per friend) and the chosen
page on the right. Unlike Preferences, which applies as you go, this window
edits a **copy** of the Cassette state and writes nothing until OK: "Select
None" can wipe a careful selection, and Cancel has to be able to undo that.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
QButtonGroup, QCheckBox, QDialog, QRadioButton, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton,
QSplitter, QStackedWidget, QVBoxLayout, QWidget,
)
from lintunes.cassette import probe as probe_mod
from lintunes.cassette import publish
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.state import (
FOLLOW_LIBRARY, FOLLOW_SEPARATE, LAYOUT_FOLDER, LAYOUT_MIXED, OPEN, in_dir,
)
AVATAR_PX = 256
AVATAR_PREVIEW_PX = 72
AVATAR_FILE = "avatar.png"
def load_avatar_image(path: Path) -> QImage | None:
"""``path`` decoded and cropped/scaled to a square AVATAR_PX, or None if
it isn't an image Qt can read."""
image = QImage(str(path))
if image.isNull():
return None
side = min(image.width(), image.height())
image = image.copy((image.width() - side) // 2,
(image.height() - side) // 2, side, side)
return image.scaled(AVATAR_PX, AVATAR_PX,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation)
def format_when(stamp: str) -> str:
"""A stored UTC stamp (ours naive, Syncthing's with Z) as local time a
person reads: "today 14:02", "yesterday 09:15", "3 Sep 2026 18:40"."""
if not stamp:
return "never"
try:
when = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
except ValueError:
return stamp
if when.year < 2000: # Syncthing's zero time: never seen
return "never"
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
local = when.astimezone()
today = datetime.now().astimezone().date()
clock = local.strftime("%H:%M")
if local.date() == today:
return f"today {clock}"
if (today - local.date()).days == 1:
return f"yesterday {clock}"
return f"{local.day} {local.strftime('%b %Y')} {clock}"
class AvatarSquare(QLabel):
"""Fixed square preview; gray with a hint when empty."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(AVATAR_PREVIEW_PX, AVATAR_PREVIEW_PX)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setStyleSheet("QLabel { border: 1px solid palette(mid);"
" border-radius: 6px; background: palette(mid); }")
self.set_image(None)
def set_image(self, image: QImage | None):
if image is None or image.isNull():
self.setPixmap(QPixmap())
self.setText("none")
return
self.setText("")
self.setPixmap(QPixmap.fromImage(image).scaled(
AVATAR_PREVIEW_PX - 2, AVATAR_PREVIEW_PX - 2,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
def status_text(result) -> tuple[str, str]:
"""(headline, next step) for a ProbeResult — plain language, always with
something to do."""
if result is None:
return "Checking Syncthing…", ""
if result.state == probe_mod.OK:
return f"Syncthing {result.version} is running.", ""
if result.state == probe_mod.NOT_RUNNING:
return ("Syncthing isn't running.",
"Start it (for example: systemctl --user start syncthing, or "
"open the Syncthing app), then press Retry.")
return ("Can't reach Syncthing's API.",
"LinTunes needs Syncthing's API key. It's in Syncthing's web page "
"under Actions ▸ Settings ▸ General ▸ API Key, or LinTunes can read "
"it from Syncthing's config file for you.")
class GeneralPage(QWidget):
retry_requested = pyqtSignal()
def __init__(self, state, root: Path, config: dict, parent=None):
super().__init__(parent)
self._state = state
self._root = root
self._config = config
self.new_avatar: QImage | None = None # staged until OK
self.avatar_removed = False
layout = QVBoxLayout(self)
box = QGroupBox("Syncthing")
form = QVBoxLayout(box)
self._status = QLabel()
self._status.setWordWrap(True)
self._hint = QLabel()
self._hint.setWordWrap(True)
self._hint.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
form.addWidget(self._status)
form.addWidget(self._hint)
self._key_row = QWidget()
key_layout = QHBoxLayout(self._key_row)
key_layout.setContentsMargins(0, 0, 0, 0)
self.api_key = QLineEdit(config.get(st.API_KEY_KEY, ""))
# Empty = use whatever Syncthing's config says (follows a key change).
self.api_key.setPlaceholderText("API key (blank: read from Syncthing)")
self.api_key.setEchoMode(QLineEdit.EchoMode.Password)
read = QPushButton("Read from Syncthing")
read.clicked.connect(self._read_key)
key_layout.addWidget(self.api_key, stretch=1)
key_layout.addWidget(read)
form.addWidget(self._key_row)
self._retry = QPushButton("Retry")
self._retry.clicked.connect(self.retry_requested)
form.addWidget(self._retry, alignment=Qt.AlignmentFlag.AlignLeft)
layout.addWidget(box)
self.invites_box = QGroupBox("Invites waiting to be used")
self._invites_layout = QVBoxLayout(self.invites_box)
layout.addWidget(self.invites_box)
self.cancelled_invites: set[str] = set()
self.refresh_invites(None)
you = QGroupBox("You")
you_form = QFormLayout(you)
self.display_name = QLineEdit(state.display_name)
self.display_name.setToolTip("What friends see")
you_form.addRow("Your display name:", self.display_name)
avatar_row = QHBoxLayout()
self._avatar = AvatarSquare()
existing = root / state.avatar if state.avatar else None
if existing is not None and existing.exists():
self._avatar.set_image(QImage(str(existing)))
choose = QPushButton("Choose…")
choose.clicked.connect(self._choose_avatar)
remove = QPushButton("Remove")
remove.clicked.connect(self._remove_avatar)
buttons = QVBoxLayout()
buttons.addWidget(choose)
buttons.addWidget(remove)
buttons.addStretch(1)
avatar_row.addWidget(self._avatar)
avatar_row.addLayout(buttons)
avatar_row.addStretch(1)
you_form.addRow("Avatar:", avatar_row)
layout.addWidget(you)
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)
def refresh_invites(self, service):
"""One row per open invite: when it was made, its status, and a
Cancel Invite button (staged — carried out on OK)."""
while self._invites_layout.count():
item = self._invites_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
invites = sorted((i for i in self._state.invites.values()
if i.status == OPEN), key=lambda i: i.created)
self.invites_box.setVisible(bool(invites))
for invite in invites:
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
status = service.status(invite.token) if service else None
text = status.text if status else "Waiting for your friend to paste the code"
label = QLabel(f"Made {format_when(invite.created)} — {text}")
label.setWordWrap(True)
row_layout.addWidget(label, stretch=1)
if invite.token in self.cancelled_invites:
row_layout.addWidget(QLabel("Cancelled when you press OK"))
else:
cancel = QPushButton("Cancel Invite")
cancel.clicked.connect(
lambda _=False, t=invite.token: self._cancel(t, service))
row_layout.addWidget(cancel)
self._invites_layout.addWidget(row)
def _cancel(self, token: str, service):
self.cancelled_invites.add(token)
self.refresh_invites(service)
def show_probe(self, result):
headline, hint = status_text(result)
self._status.setText(headline)
self._hint.setText(hint)
self._hint.setVisible(bool(hint))
self._retry.setVisible(result is not None and not result.ok)
self._key_row.setVisible(result is not None
and result.state == probe_mod.NO_API)
def _read_key(self):
found = st.read_gui_config()
if found is None:
self._hint.setText("Couldn't find Syncthing's config file. Copy the "
"key from Syncthing's web page instead.")
return
# Blank means "use Syncthing's own", so a key Syncthing later rotates
# is followed rather than pinned in config.json.
self.api_key.clear()
self.retry_requested.emit()
def _choose_avatar(self):
path, _ = QFileDialog.getOpenFileName(
self, "Choose an Avatar", str(Path.home()),
"Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp)")
if not path:
return
image = load_avatar_image(Path(path))
if image is None:
self._hint.setText("That file isn't an image LinTunes can read.")
self._hint.setVisible(True)
return
self.new_avatar = image
self.avatar_removed = False
self._avatar.set_image(image)
def _remove_avatar(self):
self.new_avatar = None
self.avatar_removed = True
self._avatar.set_image(None)
class FriendPage(QWidget):
"""One friend: who they are, how the connection is doing, Remove Friend.
(Their share selector joins this page later.)"""
def __init__(self, friend, root: Path, parent=None):
super().__init__(parent)
self.friend = friend # the dialog's copy: the selector edits it
self.removed = False
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.avatar = AvatarSquare()
avatar_path = in_dir(root, friend.token) / publish.AVATAR_FILE
if avatar_path.exists():
self.avatar.set_image(QImage(str(avatar_path)))
header.addWidget(self.avatar)
title = QLabel(friend.name)
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
header.addWidget(title, stretch=1)
layout.addLayout(header)
box = QGroupBox("Connection")
box_layout = QVBoxLayout(box)
self.status = QLabel()
self.status.setWordWrap(True)
self.hint = QLabel()
self.hint.setWordWrap(True)
self.details = QLabel()
box_layout.addWidget(self.status)
box_layout.addWidget(self.hint)
box_layout.addWidget(self.details)
layout.addWidget(box)
follow = QGroupBox(f"Playlists you follow from {friend.name}")
follow_layout = QVBoxLayout(follow)
self.layout_mixed = QRadioButton("Mix them in with my playlists")
self.layout_folder = QRadioButton(f"Put them in a folder called “{friend.name}”")
self.mode_separate = QRadioButton(
"Keep their songs separate from my library (they still play)")
self.mode_library = QRadioButton("Add their songs to my library")
for first, second in ((self.layout_mixed, self.layout_folder),
(self.mode_separate, self.mode_library)):
group = QButtonGroup(self)
group.addButton(first)
group.addButton(second)
self.layout_folder.setChecked(friend.layout == LAYOUT_FOLDER)
self.layout_mixed.setChecked(friend.layout != LAYOUT_FOLDER)
self.mode_library.setChecked(friend.followed_mode == FOLLOW_LIBRARY)
self.mode_separate.setChecked(friend.followed_mode != FOLLOW_LIBRARY)
for button in (self.layout_mixed, self.layout_folder,
self.mode_separate, self.mode_library):
follow_layout.addWidget(button)
button.toggled.connect(self._on_follow_settings)
layout.addWidget(follow)
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)
self.removed_note = QLabel("Removed when you press OK")
self.removed_note.setVisible(False)
self.remove_button = QPushButton("Remove Friend…")
self.remove_button.clicked.connect(self._remove)
row.addWidget(self.removed_note)
row.addWidget(self.remove_button)
layout.addLayout(row)
self.show_status(None)
def _on_follow_settings(self, _checked=False):
self.friend.layout = (LAYOUT_FOLDER if self.layout_folder.isChecked()
else LAYOUT_MIXED)
self.friend.followed_mode = (FOLLOW_LIBRARY if self.mode_library.isChecked()
else FOLLOW_SEPARATE)
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…")
self.hint.setVisible(False)
self.details.setText(f"Last synced: {format_when(self.friend.last_synced)}")
return
text = status.text
if status.code == "connected":
text += " — up to date" if status.up_to_date else " — syncing"
self.status.setText(text)
self.hint.setText(status.hint)
self.hint.setVisible(bool(status.hint))
self.details.setText(
f"Last seen: {format_when(status.last_seen)}\n"
f"Last synced: {format_when(status.last_synced)}")
def _remove(self):
answer = QMessageBox.question(
self, "Remove Friend",
f"Remove {self.friend.name}?\n\nYou'll stop sharing with each "
f"other, and the copy of their library on this computer is "
f"deleted. Songs you already brought into your library stay.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer == QMessageBox.StandardButton.Yes:
self.removed = True
self.removed_note.setVisible(True)
self.remove_button.setEnabled(False)
class SyncSettingsDialog(QDialog):
"""Edits ``state`` (a copy — the caller's is untouched until accepted).
After ``exec()`` returns Accepted, read ``state`` and ``config_changes``
and call ``apply()`` — the caller owns saving so it can regenerate what
the change affects."""
def __init__(self, state, root: Path, config: dict, parent=None,
probe=None, service=None, library=None):
super().__init__(parent)
self._service = service
self._library = library
self.setWindowTitle("Sync Settings")
self.resize(1080, 880)
self.state = state.copy()
self._root = root
self._config = config
splitter = QSplitter(Qt.Orientation.Horizontal)
self.pages_list = QListWidget()
self.pages = QStackedWidget()
splitter.addWidget(self.pages_list)
splitter.addWidget(self.pages)
splitter.setSizes([190, 630])
splitter.setCollapsible(1, False)
self.general = GeneralPage(self.state, root, config)
self._add_page("General", self.general)
self.friend_pages = {}
for friend in sorted(self.state.friends.values(),
key=lambda f: f.name.casefold()):
page = FriendPage(friend, root)
self.friend_pages[friend.token] = page
self._add_page(friend.name, page)
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
| QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(splitter, stretch=1)
layout.addWidget(buttons)
self._probe = probe or probe_mod.SyncthingProbe(self)
self._probe.finished.connect(self.general.show_probe)
self.general.retry_requested.connect(self._retry)
self._probe.start(self._probe_config())
if service is not None:
service.changed.connect(self._refresh_statuses)
self._refresh_statuses()
def _refresh_statuses(self):
self.general.refresh_invites(self._service)
for token, page in self.friend_pages.items():
page.show_status(self._service.status(token))
@property
def cancelled_invites(self) -> list[str]:
return sorted(self.general.cancelled_invites)
@property
def removed_friends(self) -> list[str]:
return [t for t, page in self.friend_pages.items() if page.removed]
def done(self, result):
if self._service is not None:
try:
self._service.changed.disconnect(self._refresh_statuses)
except TypeError:
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)
def _probe_config(self) -> dict:
config = dict(self._config)
key = self.general.api_key.text().strip()
if key:
config[st.API_KEY_KEY] = key
return config
def _retry(self):
self.general.show_probe(None)
self._probe.start(self._probe_config())
def _accept(self):
name = self.general.display_name.text().strip()
self.state.display_name = name or self.state.display_name
self.accept()
def config_changes(self) -> dict:
"""config.json keys the user changed here."""
key = self.general.api_key.text().strip()
if key != (self._config.get(st.API_KEY_KEY) or ""):
return {st.API_KEY_KEY: key}
return {}
def apply(self):
"""Write the staged avatar into the cassette root and point the state
at it. Called by the owner after an accepted exec()."""
if self.general.new_avatar is not None:
self._root.mkdir(parents=True, exist_ok=True)
tmp = self._root / (AVATAR_FILE + ".tmp.png")
if self.general.new_avatar.save(str(tmp), "PNG"):
tmp.replace(self._root / AVATAR_FILE)
self.state.avatar = AVATAR_FILE
elif self.general.avatar_removed:
self.state.avatar = ""