- 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.
525 lines
21 KiB
Python
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 = ""
|