v0.26.0: Cassette footing — Syncthing client, host machine, Sync Settings

First step of friend library sharing (Cassette):

- cassette/syncthing_api.py: a typed wrapper over the *local* Syncthing's
  REST API. Key and address come from Syncthing's config.xml (config.json
  may override); every failure is a SyncthingError naming the step, what
  Syncthing said, and whether it's not running / a bad key / a refusal.
- cassette/state.py: friends, invites and share selections, kept in the
  machine-local ~/.local/share/lintunes/cassette, never the synced data dir.
- cassette/host.py: one machine runs Cassette. config.json flags it,
  preferences.json names it, and the other machine greys the menu group
  out and says where friend sharing lives.
- Connections ▸ Sync Settings…: VLC-style list + page, editing a copy
  that only OK writes. General shows Syncthing's status with a next step
  for every failure, the display name and an avatar picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:01:17 -07:00
co-authored by Claude Opus 5.5
parent 6d65ca4050
commit 0dad4cc95e
11 changed files with 1404 additions and 2 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.25.0"
__version__ = "0.26.0"
View File
+59
View File
@@ -0,0 +1,59 @@
"""Which of my machines runs Cassette.
trav runs LinTunes on two machines against one synced library. Friend folders,
outboxes and the delivery loop must run in exactly one place, or both machines
would import the same delivery. So one machine is the **host**:
* ``config.json`` ``cassette_host: true`` on that machine (per machine, never
synced), and
* ``preferences.json`` ``cassette_host: {machine_id, hostname}`` (synced), so
the *other* machine can say "Cassette runs on <hostname>" instead of just
greying the menu out.
The first Cassette action on a library with no host asks to claim it.
"""
from __future__ import annotations
import socket
from lintunes.config import load_config, save_config
from lintunes.storage.play_journal import machine_id
CONFIG_KEY = "cassette_host"
PREFS_KEY = "cassette_host"
HERE = "here"
ELSEWHERE = "elsewhere"
UNCLAIMED = "unclaimed"
def hostname() -> str:
try:
return socket.gethostname() or "this computer"
except OSError:
return "this computer"
def host_status(prefs) -> tuple[str, str]:
"""(HERE | ELSEWHERE | UNCLAIMED, the host's name)."""
record = prefs.get(PREFS_KEY) or {}
flagged = bool(load_config().get(CONFIG_KEY))
if record.get("machine_id"):
if record["machine_id"] == machine_id():
return HERE, record.get("hostname") or hostname()
return ELSEWHERE, record.get("hostname") or "another computer"
# No synced record: our own flag still counts (the record is rewritten
# the next time claim() runs), anything else is up for grabs.
return (HERE, hostname()) if flagged else (UNCLAIMED, "")
def is_host(prefs) -> bool:
return host_status(prefs)[0] == HERE
def claim(prefs):
"""Make this machine the Cassette host."""
config = load_config()
config[CONFIG_KEY] = True
save_config(config)
prefs.set(PREFS_KEY, {"machine_id": machine_id(), "hostname": hostname()})
+64
View File
@@ -0,0 +1,64 @@
"""Is Syncthing there, and can we talk to it? Asked off the GUI thread.
A stopped Syncthing refuses the connection instantly, but a wedged one can sit
on the socket until the timeout — so the answer arrives as a signal, and every
screen that shows it starts out saying "Checking…".
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.config import load_config
from lintunes.cassette import syncthing_api as st
# ProbeResult.state
OK = "ok"
NOT_RUNNING = "not_running"
NO_API = "no_api" # no key found, or the key was refused
@dataclass
class ProbeResult:
state: str
version: str = ""
my_id: str = ""
detail: str = ""
@property
def ok(self) -> bool:
return self.state == OK
def probe(config: dict | None = None, session=None) -> ProbeResult:
"""Synchronous check; ``SyncthingProbe`` runs it on a thread."""
endpoint = st.resolve_endpoint(config if config is not None else load_config())
if endpoint is None:
return ProbeResult(NO_API, detail="No API key was found in Syncthing's "
"config.")
client = st.SyncthingClient(endpoint, session=session)
try:
version = client.version()
my_id = client.my_id()
except st.SyncthingError as exc:
if exc.kind == st.NOT_RUNNING:
return ProbeResult(NOT_RUNNING, detail=exc.detail)
if exc.kind == st.BAD_KEY:
return ProbeResult(NO_API, detail=exc.detail)
return ProbeResult(NOT_RUNNING, detail=str(exc))
return ProbeResult(OK, version=version, my_id=my_id)
class SyncthingProbe(QObject):
finished = pyqtSignal(object) # ProbeResult
def start(self, config: dict | None = None):
def run():
try:
result = probe(config)
except Exception as exc: # never leave a "Checking…" forever
result = ProbeResult(NOT_RUNNING, detail=str(exc))
self.finished.emit(result)
threading.Thread(target=run, daemon=True).start()
+232
View File
@@ -0,0 +1,232 @@
"""Cassette's own state: who my friends are, what I share with each, what I've
asked each of them for.
It lives in the machine-local cassette root (``~/.local/share/lintunes/
cassette/state.json``), **not** in the synced data dir: only the host machine
runs Cassette, the friend folders it describes exist only here, and a second
copy riding Syncthing would just be a conflict waiting to happen.
Plain dataclasses with ``to_dict``/``from_dict``, like ``lintunes/models``;
``save`` is atomic (``json_storage.write_json``).
"""
from __future__ import annotations
import copy
import getpass
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from lintunes.config import load_config
from lintunes.storage.json_storage import read_json, write_json
ROOT_KEY = "cassette_root" # config.json override
STATE_FILE = "state.json"
FORMAT_VERSION = 1
# Invite.status
OPEN = "open"
USED = "used"
CANCELLED = "cancelled"
# Friend.layout — how their followed playlists appear in my sidebar
LAYOUT_MIXED = "mixed"
LAYOUT_FOLDER = "folder"
# Friend.followed_mode — what happens to a followed playlist's songs
FOLLOW_SEPARATE = "separate"
FOLLOW_LIBRARY = "library"
# Friend.role — which side of the invite this machine was on
INVITER = "inviter"
INVITEE = "invitee"
def now_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat(
timespec="seconds")
def cassette_root() -> Path:
override = (load_config().get(ROOT_KEY) or "").strip()
if override:
return Path(override).expanduser()
base = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))
return Path(base) / "lintunes" / "cassette"
def default_display_name() -> str:
try:
return getpass.getuser()
except Exception:
return "LinTunes"
@dataclass
class Selection:
"""What one friend (or everyone, in share-same mode) may cassette.
Artists and albums are stored as the track ids they covered when checked
(a snapshot: a new album shows up unchecked). ``all_library`` and shared
playlists are live — they're evaluated each time the copy is built."""
all_library: bool = False
track_ids: set = field(default_factory=set)
all_playlists: bool = False
playlist_ids: set = field(default_factory=set)
def to_dict(self) -> dict:
return {"all_library": self.all_library,
"track_ids": sorted(self.track_ids),
"all_playlists": self.all_playlists,
"playlist_ids": sorted(self.playlist_ids)}
@classmethod
def from_dict(cls, d: dict | None) -> "Selection":
d = d or {}
return cls(all_library=bool(d.get("all_library", False)),
track_ids={int(t) for t in d.get("track_ids", [])},
all_playlists=bool(d.get("all_playlists", False)),
playlist_ids=set(d.get("playlist_ids", [])))
def is_empty(self) -> bool:
return not (self.all_library or self.track_ids
or self.all_playlists or self.playlist_ids)
@dataclass
class Invite:
token: str
created: str = ""
status: str = OPEN
folder_id: str = "" # my outgoing folder, created with the invite
def to_dict(self) -> dict:
return {"token": self.token, "created": self.created,
"status": self.status, "folder_id": self.folder_id}
@classmethod
def from_dict(cls, d: dict) -> "Invite":
return cls(token=d["token"], created=d.get("created", ""),
status=d.get("status", OPEN),
folder_id=d.get("folder_id", ""))
@dataclass
class Friend:
token: str
device_id: str
name: str
role: str = INVITEE
out_folder: str = "" # Syncthing folder id I write (send-only)
in_folder: str = "" # Syncthing folder id they write (receive-only here)
added: str = ""
connected_at: str = "" # first time the handshake completed
selection: Selection = field(default_factory=Selection)
layout: str = LAYOUT_FOLDER
followed_mode: str = FOLLOW_SEPARATE
followed: list = field(default_factory=list) # their playlist pids
cassetted: list = field(default_factory=list) # their track ids I want
def to_dict(self) -> dict:
return {"token": self.token, "device_id": self.device_id,
"name": self.name, "role": self.role,
"out_folder": self.out_folder, "in_folder": self.in_folder,
"added": self.added, "connected_at": self.connected_at,
"selection": self.selection.to_dict(), "layout": self.layout,
"followed_mode": self.followed_mode,
"followed": list(self.followed),
"cassetted": list(self.cassetted)}
@classmethod
def from_dict(cls, d: dict) -> "Friend":
return cls(token=d["token"], device_id=d.get("device_id", ""),
name=d.get("name", ""), role=d.get("role", INVITEE),
out_folder=d.get("out_folder", ""),
in_folder=d.get("in_folder", ""),
added=d.get("added", ""),
connected_at=d.get("connected_at", ""),
selection=Selection.from_dict(d.get("selection")),
layout=d.get("layout", LAYOUT_FOLDER),
followed_mode=d.get("followed_mode", FOLLOW_SEPARATE),
followed=list(d.get("followed", [])),
cassetted=[int(t) for t in d.get("cassetted", [])])
@dataclass
class CassetteState:
display_name: str = field(default_factory=default_display_name)
avatar: str = "" # filename inside the cassette root, "" = none
share_same_for_all: bool = False
general: Selection = field(default_factory=Selection)
invites: dict = field(default_factory=dict) # token -> Invite
friends: dict = field(default_factory=dict) # token -> Friend
def to_dict(self) -> dict:
return {"version": FORMAT_VERSION,
"display_name": self.display_name, "avatar": self.avatar,
"share_same_for_all": self.share_same_for_all,
"general": self.general.to_dict(),
"invites": [i.to_dict() for i in self.invites.values()],
"friends": [f.to_dict() for f in self.friends.values()]}
@classmethod
def from_dict(cls, d: dict | None) -> "CassetteState":
d = d or {}
state = cls(display_name=d.get("display_name") or default_display_name(),
avatar=d.get("avatar", ""),
share_same_for_all=bool(d.get("share_same_for_all", False)),
general=Selection.from_dict(d.get("general")))
for raw in d.get("invites", []):
invite = Invite.from_dict(raw)
state.invites[invite.token] = invite
for raw in d.get("friends", []):
friend = Friend.from_dict(raw)
state.friends[friend.token] = friend
return state
def copy(self) -> "CassetteState":
return copy.deepcopy(self)
def open_invites(self) -> list[Invite]:
return [i for i in self.invites.values() if i.status == OPEN]
def selection_for(self, friend: Friend) -> Selection:
"""The selection that actually applies to ``friend``."""
return self.general if self.share_same_for_all else friend.selection
def friend_by_device(self, device_id: str) -> Friend | None:
return next((f for f in self.friends.values()
if f.device_id == device_id), None)
def load_state(root: Path | None = None) -> CassetteState:
path = (root or cassette_root()) / STATE_FILE
if not path.exists():
return CassetteState()
try:
return CassetteState.from_dict(read_json(path))
except (ValueError, OSError, KeyError):
return CassetteState()
def save_state(state: CassetteState, root: Path | None = None):
root = root or cassette_root()
root.mkdir(parents=True, exist_ok=True)
write_json(root / STATE_FILE, state.to_dict())
def friend_dir(root: Path, token: str) -> Path:
return root / "friends" / token
def out_dir(root: Path, token: str) -> Path:
return friend_dir(root, token) / "out"
def in_dir(root: Path, token: str) -> Path:
return friend_dir(root, token) / "in"
def cache_dir(root: Path, token: str) -> Path:
return friend_dir(root, token) / "cache"
+292
View File
@@ -0,0 +1,292 @@
"""The local Syncthing daemon's REST API — the only "network" Cassette uses.
LinTunes never talks to a friend's machine. It asks *this* machine's Syncthing
to add a device, share a folder, accept one, and to say what it can see;
Syncthing moves every byte. So this module is a thin, typed wrapper over
``http://127.0.0.1:8384/rest/...`` with one rule: **every failure says which
step failed and what Syncthing said** (``SyncthingError``), because the UI
promises never to fail silently.
Where the API lives and its key come from Syncthing's own ``config.xml`` (the
same file ``sync_identity`` reads for device names). ``config.json`` may
override either, for a Syncthing that isn't where we look.
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
import requests
from lintunes import sync_identity
log = logging.getLogger(__name__)
# config.json keys (per machine — which Syncthing this is is nobody else's
# business, same as ytdlp_cookies_browser).
API_KEY_KEY = "syncthing_api_key"
ADDRESS_KEY = "syncthing_address"
DEFAULT_ADDRESS = "127.0.0.1:8384"
TIMEOUT_S = 10
# SyncthingError.kind
NOT_RUNNING = "not_running" # nothing listening — Syncthing isn't started
BAD_KEY = "bad_key" # it answered 401/403: missing or wrong API key
NO_KEY = "no_key" # we couldn't find a key to send at all
OTHER = "other" # it answered, and said no
class SyncthingError(Exception):
"""A step that failed, and what Syncthing (or the socket) said."""
def __init__(self, step: str, detail: str, kind: str = OTHER,
status: int | None = None):
super().__init__(f"{step}: {detail}")
self.step = step
self.detail = detail
self.kind = kind
self.status = status # HTTP status, when Syncthing answered
@dataclass(frozen=True)
class Endpoint:
address: str # host:port
api_key: str
tls: bool = False
@property
def base_url(self) -> str:
return f"{'https' if self.tls else 'http'}://{self.address}"
def config_dir() -> Path | None:
"""Syncthing's home directory (holds config.xml), or None."""
return sync_identity._config_dir()
def read_gui_config(directory: Path | None = None) -> Endpoint | None:
"""The API endpoint as Syncthing's config.xml describes it, or None when
there is no config.xml or it names no key."""
directory = directory or config_dir()
if directory is None:
return None
try:
root = ET.parse(directory / "config.xml").getroot()
except (OSError, ET.ParseError):
return None
gui = root.find("gui")
if gui is None:
return None
key = (gui.findtext("apikey") or "").strip()
if not key:
return None
address = (gui.findtext("address") or DEFAULT_ADDRESS).strip()
# A GUI bound to 0.0.0.0 still answers on loopback, which is where we are.
if address.startswith("0.0.0.0:"):
address = "127.0.0.1:" + address.split(":", 1)[1]
tls = (gui.get("tls") or "").lower() == "true"
return Endpoint(address=address, api_key=key, tls=tls)
def resolve_endpoint(config: dict) -> Endpoint | None:
"""config.json overrides layered over config.xml. None means we have no
key to send — the "Can't reach Syncthing's API" state, not "not running"."""
found = read_gui_config()
key = (config.get(API_KEY_KEY) or "").strip() or (found.api_key if found else "")
if not key:
return None
address = ((config.get(ADDRESS_KEY) or "").strip()
or (found.address if found else DEFAULT_ADDRESS))
tls = found.tls if found else False
return Endpoint(address=address, api_key=key, tls=tls)
class SyncthingClient:
"""Typed calls over one Endpoint. Thread-safe enough for our use: each
call is one request on a shared Session (requests' Session is safe for
concurrent simple requests); the event long-poll gets its own client."""
def __init__(self, endpoint: Endpoint, session=None):
self.endpoint = endpoint
self._session = session or requests.Session()
self._session.headers["X-API-Key"] = endpoint.api_key
# ---- plumbing ----
def _request(self, method: str, path: str, step: str, *, params=None,
json=None, timeout: float = TIMEOUT_S):
url = self.endpoint.base_url + path
try:
response = self._session.request(
method, url, params=params, json=json, timeout=timeout,
verify=False) # Syncthing's GUI cert is self-signed
except requests.ConnectionError as exc:
raise SyncthingError(step, f"nothing answered at {self.endpoint.address}"
f" ({exc.__class__.__name__})", NOT_RUNNING) from exc
except requests.Timeout as exc:
raise SyncthingError(step, "Syncthing didn't answer in time",
NOT_RUNNING) from exc
except requests.RequestException as exc:
raise SyncthingError(step, str(exc)) from exc
if response.status_code in (401, 403):
raise SyncthingError(step, "Syncthing refused the API key", BAD_KEY)
if response.status_code >= 400:
text = (response.text or "").strip() or f"HTTP {response.status_code}"
raise SyncthingError(step, text, status=response.status_code)
if not response.content:
return None
try:
return response.json()
except ValueError:
return response.text
# ---- system ----
def version(self) -> str:
return self._request("GET", "/rest/system/version",
"Checking Syncthing's version")["version"]
def my_id(self) -> str:
return self._request("GET", "/rest/system/status",
"Reading this computer's device ID")["myID"]
def connections(self) -> dict:
"""{device id: {"connected": bool, ...}}"""
data = self._request("GET", "/rest/system/connections",
"Checking connections")
return (data or {}).get("connections", {})
def device_stats(self) -> dict:
"""{device id: {"lastSeen": iso, ...}}"""
return self._request("GET", "/rest/stats/device",
"Reading when devices were last seen") or {}
def folder_stats(self) -> dict:
"""{folder id: {"lastFile": {"at": iso, ...}, "lastScan": iso}}"""
return self._request("GET", "/rest/stats/folder",
"Reading folder activity") or {}
# ---- devices ----
def device(self, device_id: str) -> dict | None:
try:
return self._request("GET", f"/rest/config/devices/{device_id}",
"Looking up a device")
except SyncthingError as exc:
if exc.status == 404:
return None
raise
def add_device(self, device_id: str, name: str):
"""Add (or rename) a device. Idempotent."""
self._request("PUT", f"/rest/config/devices/{device_id}",
f"Adding {name}'s computer to Syncthing",
json={"deviceID": device_id, "name": name,
"addresses": ["dynamic"]})
def remove_device(self, device_id: str, name: str = "the device"):
self._request("DELETE", f"/rest/config/devices/{device_id}",
f"Removing {name}'s computer from Syncthing")
# ---- folders ----
def folder(self, folder_id: str) -> dict | None:
try:
return self._request("GET", f"/rest/config/folders/{folder_id}",
"Looking up a folder")
except SyncthingError as exc:
if exc.status == 404:
return None
raise
def add_folder(self, folder_id: str, label: str, path: Path, kind: str,
device_ids=(), step: str = "Creating a shared folder"):
"""Create or replace a folder. ``kind`` is sendonly/receiveonly/…"""
self._request("PUT", f"/rest/config/folders/{folder_id}", step, json={
"id": folder_id, "label": label, "path": str(path), "type": kind,
"devices": [{"deviceID": d} for d in device_ids],
"fsWatcherEnabled": True,
})
def share_folder(self, folder_id: str, device_id: str,
step: str = "Sharing a folder"):
"""Add ``device_id`` to a folder's device list. Idempotent."""
folder = self.folder(folder_id)
if folder is None:
raise SyncthingError(step, f"folder {folder_id} doesn't exist")
devices = folder.get("devices") or []
if any(d.get("deviceID") == device_id for d in devices):
return
devices.append({"deviceID": device_id})
self._request("PATCH", f"/rest/config/folders/{folder_id}", step,
json={"devices": devices})
def remove_folder(self, folder_id: str, step: str = "Removing a folder"):
self._request("DELETE", f"/rest/config/folders/{folder_id}", step)
def min_disk_free(self, folder_id: str | None = None) -> tuple[float, str]:
"""(value, unit) of a folder's free-space floor — below it Syncthing
stops syncing. Falls back to the defaults for new folders."""
data = None
if folder_id:
data = self.folder(folder_id)
if data is None:
data = self._request("GET", "/rest/config/defaults/folder",
"Reading Syncthing's folder defaults")
floor = (data or {}).get("minDiskFree") or {}
return float(floor.get("value", 1)), floor.get("unit", "%")
# ---- pending (someone knocking) ----
def pending_devices(self) -> dict:
"""{device id: {"name": str, "time": iso, "address": str}}"""
return self._request("GET", "/rest/cluster/pending/devices",
"Checking for computers asking to connect") or {}
def pending_folders(self, device_id: str | None = None) -> dict:
"""{folder id: {"offeredBy": {device id: {...}}}}"""
params = {"device": device_id} if device_id else None
return self._request("GET", "/rest/cluster/pending/folders",
"Checking for folders offered to us",
params=params) or {}
# ---- database ----
def completion(self, folder_id: str, device_id: str | None = None) -> dict:
params = {"folder": folder_id}
if device_id:
params["device"] = device_id
return self._request("GET", "/rest/db/completion",
"Checking sync progress", params=params) or {}
def file_info(self, folder_id: str, name: str) -> dict | None:
try:
return self._request("GET", "/rest/db/file", "Checking a file",
params={"folder": folder_id, "file": name})
except SyncthingError as exc:
if exc.status == 404:
return None
raise
# ---- events ----
def events(self, since: int, types=(), timeout_s: int = 60) -> list:
"""Long-poll /rest/events. Returns [] on the server's own timeout."""
params = {"since": since, "timeout": timeout_s}
if types:
params["events"] = ",".join(types)
return self._request("GET", "/rest/events", "Waiting for Syncthing events",
params=params, timeout=timeout_s + 10) or []
def disk_floor_bytes(value: float, unit: str, total_bytes: int) -> int:
"""A minDiskFree setting in bytes, for a disk of ``total_bytes``."""
if unit == "%":
return int(total_bytes * value / 100)
scale = {"": 1, "B": 1, "kB": 10**3, "MB": 10**6, "GB": 10**9,
"TB": 10**12, "KiB": 2**10, "MiB": 2**20, "GiB": 2**30,
"TiB": 2**40}
return int(value * scale.get(unit, 1))
+94
View File
@@ -0,0 +1,94 @@
"""Cassette's place in the main window: its Connections-menu group, the
"which machine is the host" gate, and the dialogs those items open.
Kept out of main_window.py, which is big enough; MainWindow owns one of these
and calls ``build_menu`` / ``refresh_menu``.
"""
from __future__ import annotations
from PyQt6.QtCore import QObject
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QDialog, QMessageBox
from lintunes.cassette import host
from lintunes.cassette.state import cassette_root, load_state, save_state
from lintunes.config import load_config, save_config
class CassetteUi(QObject):
def __init__(self, window, prefs, parent=None):
super().__init__(parent or window)
self._window = window
self._prefs = prefs
self._elsewhere_action: QAction | None = None
self._actions: list[QAction] = []
# ---- menu ----
def build_menu(self, menu):
"""Add the Cassette group, between two separators."""
menu.addSeparator()
self._elsewhere_action = QAction("", self._window)
self._elsewhere_action.setEnabled(False)
self._elsewhere_action.setVisible(False)
menu.addAction(self._elsewhere_action)
settings = QAction("Sync Settings…", self._window)
settings.triggered.connect(self.show_sync_settings)
menu.addAction(settings)
self._actions.append(settings)
menu.addSeparator()
def refresh_menu(self):
status, name = host.host_status(self._prefs)
elsewhere = status == host.ELSEWHERE
for action in self._actions:
action.setEnabled(not elsewhere)
if self._elsewhere_action is not None:
self._elsewhere_action.setText(f"Friend sharing runs on {name}")
self._elsewhere_action.setVisible(elsewhere)
# ---- host gate ----
def ensure_host(self) -> bool:
"""True when this machine may run Cassette — asking to claim it when
no machine has yet."""
status, name = host.host_status(self._prefs)
if status == host.HERE:
return True
if status == host.ELSEWHERE:
QMessageBox.information(
self._window, "Friend Sharing",
f"Friend sharing runs on {name}. Open LinTunes there to "
"share with friends or see their libraries.")
return False
answer = QMessageBox.question(
self._window, "Friend Sharing",
f"Use this computer ({host.hostname()}) for sharing with friends?\n\n"
"Only one of your computers handles friends: it keeps their "
"folders and brings in the songs you ask for. Songs it adds to "
"your library still reach your other computers as usual.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Yes)
if answer != QMessageBox.StandardButton.Yes:
return False
host.claim(self._prefs)
return True
# ---- dialogs ----
def show_sync_settings(self):
if not self.ensure_host():
return
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
root = cassette_root()
config = load_config()
dialog = SyncSettingsDialog(load_state(root), root, config, self._window)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
dialog.apply()
changes = dialog.config_changes()
if changes:
config = load_config()
config.update(changes)
save_config(config)
save_state(dialog.state, root)
+6 -1
View File
@@ -25,6 +25,7 @@ from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.art_ops import embed_artwork
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.cassette_ui import CassetteUi
from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
from lintunes.gui.identify_dialog import IdentifyDialog
@@ -94,6 +95,8 @@ class MainWindow(QMainWindow):
# Context playback started from: "library" or "playlist:<pid>". Scopes
# the now-playing speaker icon to where the song is actually playing.
self._now_playing_context = ""
# Friend sharing's menu group and dialogs.
self._cassette = CassetteUi(self, prefs)
self.setWindowTitle("LinTunes")
self.resize(1280, 800)
@@ -375,7 +378,8 @@ class MainWindow(QMainWindow):
# "Sync Playlist to Rabbit (Auxio)" was retired in Round 51, once
# andTunes could play what it syncs. device_sync's helpers stay: the
# export and andTunes code import them.
connections_menu.addSeparator()
# Friend sharing (Cassette) sits between two separators of its own.
self._cassette.build_menu(connections_menu)
self._cast_action = self._add_action(
connections_menu, "Connect to Chromecast…", "", self._toggle_cast)
# Re-checked every time the menu opens: cheap (one gvfs listdir), and
@@ -411,6 +415,7 @@ class MainWindow(QMainWindow):
# Whole-library sync doesn't care which view is open — only that a
# device is plugged in and at least one playlist is ticked.
self._andtunes_action.setEnabled(not busy and device is not None)
self._cassette.refresh_menu()
# Enabled whenever there's an APK to offer; the click finds the
# Rabbit (adb or MTP) and says so if it can't — cheaper than asking
# adb on every menu open.
+288
View File
@@ -0,0 +1,288 @@
"""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 pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QPushButton, QSplitter,
QStackedWidget, QVBoxLayout, QWidget,
)
from lintunes.cassette import probe as probe_mod
from lintunes.cassette import syncthing_api as st
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)
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)
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)
layout.addStretch(1)
self.show_probe(None)
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 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):
super().__init__(parent)
self.setWindowTitle("Sync Settings")
self.resize(820, 560)
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 = self._friend_page(friend)
self.friend_pages[friend.token] = page
self._add_page(friend.name, page)
self.pages_list.currentRowChanged.connect(self.pages.setCurrentIndex)
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())
def _add_page(self, label: str, page: QWidget):
self.pages_list.addItem(label)
self.pages.addWidget(page)
def _friend_page(self, friend) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
title = QLabel(friend.name)
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
layout.addWidget(title)
layout.addStretch(1)
return 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 = ""
+3
View File
@@ -41,6 +41,9 @@ DEFAULTS = {
"playlist_ids": [],
"copy_art": True,
},
# Which machine runs friend sharing (Cassette): {machine_id, hostname}.
# Synced so the other machine can name it; see lintunes/cassette/host.py.
"cassette_host": None,
}
+365
View File
@@ -0,0 +1,365 @@
"""Round 64: Cassette's footing — the Syncthing REST client, the host machine,
the Cassette state file and the Sync Settings shell.
* LinTunes only ever talks to *local* Syncthing. The key and address come from
Syncthing's config.xml, overridable in config.json, and every failure names
the step and what Syncthing said, sorted into "not running" / "bad key" /
"it said no" so the UI can offer the right next step.
* One machine is the Cassette host: config.json flags it, preferences.json
(synced) names it so the other machine can say where friend sharing lives.
* Sync Settings edits a copy; Cancel leaves everything as it was.
"""
import json
import pytest
import requests
from PyQt6.QtGui import QColor, QImage
from lintunes.cassette import host, probe as probe_mod, syncthing_api as st
from lintunes.cassette.state import (
CassetteState, Friend, Invite, Selection, load_state, save_state,
)
from lintunes.preferences import Preferences
# ---- a fake Syncthing ----
class _Response:
def __init__(self, status=200, body=None):
self.status_code = status
self._body = body
if body is None:
self.content = b""
self.text = ""
elif isinstance(body, str):
self.content = body.encode()
self.text = body
else:
self.text = json.dumps(body)
self.content = self.text.encode()
def json(self):
if isinstance(self._body, str):
raise ValueError("not json")
return self._body
class FakeSession:
"""Routes (method, path) to canned responses and records every call."""
def __init__(self, routes=None, error=None):
self.headers = {}
self.routes = routes or {}
self.error = error
self.calls = []
def request(self, method, url, params=None, json=None, timeout=None,
verify=None):
path = url.split("8384", 1)[-1]
self.calls.append((method, path, params, json))
if self.error is not None:
raise self.error
route = self.routes.get((method, path))
if callable(route):
return route(params, json)
return route if route is not None else _Response(404, "404 page not found")
def _client(routes=None, error=None):
session = FakeSession(routes, error)
return st.SyncthingClient(st.Endpoint("127.0.0.1:8384", "KEY"), session), session
# ---- endpoint discovery ----
CONFIG_XML = """<configuration version="37">
<gui enabled="true" tls="{tls}">
<address>{address}</address>
<apikey>{key}</apikey>
</gui>
</configuration>"""
def _write_config(tmp_path, address="127.0.0.1:8384", key="abc", tls="false"):
(tmp_path / "config.xml").write_text(
CONFIG_XML.format(address=address, key=key, tls=tls))
return tmp_path
class TestEndpoint:
def test_reads_config_xml(self, tmp_path):
endpoint = st.read_gui_config(_write_config(tmp_path))
assert endpoint == st.Endpoint("127.0.0.1:8384", "abc", False)
assert endpoint.base_url == "http://127.0.0.1:8384"
def test_any_address_means_loopback(self, tmp_path):
endpoint = st.read_gui_config(
_write_config(tmp_path, address="0.0.0.0:9999", tls="true"))
assert endpoint.address == "127.0.0.1:9999"
assert endpoint.base_url.startswith("https://")
def test_no_key_is_none(self, tmp_path):
assert st.read_gui_config(_write_config(tmp_path, key="")) is None
assert st.read_gui_config(tmp_path / "nowhere") is None
def test_config_json_overrides(self, tmp_path, monkeypatch):
monkeypatch.setattr(st, "config_dir", lambda: _write_config(tmp_path))
assert st.resolve_endpoint({}).api_key == "abc"
endpoint = st.resolve_endpoint({st.API_KEY_KEY: "mine",
st.ADDRESS_KEY: "127.0.0.1:1"})
assert (endpoint.api_key, endpoint.address) == ("mine", "127.0.0.1:1")
def test_no_key_anywhere(self, monkeypatch):
monkeypatch.setattr(st, "config_dir", lambda: None)
assert st.resolve_endpoint({}) is None
# ---- the client ----
class TestClient:
def test_sends_the_key(self):
client, session = _client({("GET", "/rest/system/version"):
_Response(body={"version": "v1.30.0"})})
assert client.version() == "v1.30.0"
assert session.headers["X-API-Key"] == "KEY"
def test_not_running(self):
client, _ = _client(error=requests.ConnectionError("refused"))
with pytest.raises(st.SyncthingError) as info:
client.version()
assert info.value.kind == st.NOT_RUNNING
assert info.value.step == "Checking Syncthing's version"
def test_bad_key(self):
client, _ = _client({("GET", "/rest/system/version"): _Response(403, "CSRF")})
with pytest.raises(st.SyncthingError) as info:
client.version()
assert info.value.kind == st.BAD_KEY
def test_syncthing_said_no_is_quoted(self):
client, _ = _client({("PUT", "/rest/config/devices/DEV"):
_Response(400, "invalid device ID")})
with pytest.raises(st.SyncthingError) as info:
client.add_device("DEV", "Sam")
assert info.value.kind == st.OTHER
assert "Sam" in info.value.step
assert info.value.detail == "invalid device ID"
def test_missing_folder_is_none(self):
client, _ = _client()
assert client.folder("nope") is None
def test_share_folder_is_idempotent(self):
folder = {"id": "f", "devices": [{"deviceID": "ME"}]}
patched = []
routes = {
("GET", "/rest/config/folders/f"): lambda p, j: _Response(body=folder),
("PATCH", "/rest/config/folders/f"):
lambda p, j: (patched.append(j), folder.update(j), _Response())[-1],
}
client, _ = _client(routes)
client.share_folder("f", "SAM")
client.share_folder("f", "SAM")
assert len(patched) == 1
assert [d["deviceID"] for d in folder["devices"]] == ["ME", "SAM"]
def test_add_folder_body(self):
client, session = _client({("PUT", "/rest/config/folders/x"): _Response()})
client.add_folder("x", "Cassette", "/tmp/x", "sendonly", ["SAM"])
_, _, _, body = session.calls[-1]
assert body["type"] == "sendonly"
assert body["devices"] == [{"deviceID": "SAM"}]
assert body["path"] == "/tmp/x"
def test_min_disk_free_falls_back_to_defaults(self):
client, _ = _client({("GET", "/rest/config/defaults/folder"):
_Response(body={"minDiskFree": {"value": 1, "unit": "%"}})})
assert client.min_disk_free("missing") == (1.0, "%")
def test_disk_floor_bytes(self):
assert st.disk_floor_bytes(1, "%", 1000) == 10
assert st.disk_floor_bytes(2, "GB", 0) == 2 * 10**9
assert st.disk_floor_bytes(1, "GiB", 0) == 2**30
# ---- probe ----
class TestProbe:
def test_ok(self, monkeypatch):
monkeypatch.setattr(st, "config_dir", lambda: None)
session = FakeSession({
("GET", "/rest/system/version"): _Response(body={"version": "v1.30.0"}),
("GET", "/rest/system/status"): _Response(body={"myID": "AAA"}),
})
result = probe_mod.probe({st.API_KEY_KEY: "k"}, session=session)
assert result.ok and result.version == "v1.30.0" and result.my_id == "AAA"
def test_no_key(self, monkeypatch):
monkeypatch.setattr(st, "config_dir", lambda: None)
assert probe_mod.probe({}).state == probe_mod.NO_API
def test_not_running(self, monkeypatch):
monkeypatch.setattr(st, "config_dir", lambda: None)
session = FakeSession(error=requests.ConnectionError("refused"))
result = probe_mod.probe({st.API_KEY_KEY: "k"}, session=session)
assert result.state == probe_mod.NOT_RUNNING
# ---- state ----
class TestState:
def test_round_trip(self, tmp_path):
state = CassetteState(display_name="trav", share_same_for_all=True)
state.general = Selection(all_library=True, playlist_ids={"P1"})
state.invites["t1"] = Invite(token="t1", created="2026-09-28")
state.friends["t2"] = Friend(token="t2", device_id="DEV", name="Sam",
selection=Selection(track_ids={3, 1}),
cassetted=[5, 6])
save_state(state, tmp_path)
loaded = load_state(tmp_path)
assert loaded.to_dict() == state.to_dict()
assert loaded.friends["t2"].selection.track_ids == {1, 3}
assert loaded.selection_for(loaded.friends["t2"]) is loaded.general
def test_missing_or_broken_is_fresh(self, tmp_path):
assert load_state(tmp_path).friends == {}
(tmp_path / "state.json").write_text("{nope")
assert load_state(tmp_path).invites == {}
def test_copy_is_deep(self):
state = CassetteState()
state.general.track_ids.add(1)
clone = state.copy()
clone.general.track_ids.add(2)
assert state.general.track_ids == {1}
# ---- host ----
@pytest.fixture
def isolated_config(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
return tmp_path
class TestHost:
def test_unclaimed_then_claimed(self, qapp, isolated_config):
prefs = Preferences(isolated_config / "data")
assert host.host_status(prefs)[0] == host.UNCLAIMED
host.claim(prefs)
assert host.host_status(prefs) == (host.HERE, host.hostname())
assert prefs.get("cassette_host")["hostname"] == host.hostname()
def test_elsewhere_names_the_host(self, qapp, isolated_config):
prefs = Preferences(isolated_config / "data")
prefs.set("cassette_host", {"machine_id": "other", "hostname": "tummult"})
assert host.host_status(prefs) == (host.ELSEWHERE, "tummult")
assert not host.is_host(prefs)
# ---- the dialog ----
class _NoProbe:
"""Stands in for SyncthingProbe: never touches a socket."""
def __init__(self):
from PyQt6.QtCore import QObject, pyqtSignal
class _Sig(QObject):
finished = pyqtSignal(object)
self._sig = _Sig()
self.finished = self._sig.finished
self.starts = []
def start(self, config=None):
self.starts.append(config)
class TestSyncSettings:
def _dialog(self, tmp_path, state=None, config=None):
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
return SyncSettingsDialog(state or CassetteState(display_name="trav"),
tmp_path, config or {}, probe=_NoProbe())
def test_general_first_then_friends(self, qapp, tmp_path):
state = CassetteState()
state.friends["b"] = Friend(token="b", device_id="B", name="zed")
state.friends["a"] = Friend(token="a", device_id="A", name="Amy")
dialog = self._dialog(tmp_path, state)
labels = [dialog.pages_list.item(i).text()
for i in range(dialog.pages_list.count())]
assert labels == ["General", "Amy", "zed"]
def test_cancel_changes_nothing(self, qapp, tmp_path):
original = CassetteState(display_name="trav")
dialog = self._dialog(tmp_path, original)
dialog.general.display_name.setText("someone else")
dialog.reject()
assert original.display_name == "trav"
def test_ok_takes_the_name(self, qapp, tmp_path):
dialog = self._dialog(tmp_path)
dialog.general.display_name.setText(" T ")
dialog._accept()
assert dialog.state.display_name == "T"
def test_status_text_always_has_a_next_step(self):
from lintunes.gui.sync_settings_dialog import status_text
for state in (probe_mod.NOT_RUNNING, probe_mod.NO_API):
headline, hint = status_text(probe_mod.ProbeResult(state))
assert headline and hint
assert "Retry" in status_text(probe_mod.ProbeResult(probe_mod.NOT_RUNNING))[1]
assert status_text(probe_mod.ProbeResult(probe_mod.OK, version="v1"))[0] \
== "Syncthing v1 is running."
def test_key_row_only_when_the_api_is_the_problem(self, qapp, tmp_path):
dialog = self._dialog(tmp_path)
dialog.general.show_probe(probe_mod.ProbeResult(probe_mod.NO_API))
assert not dialog.general._key_row.isHidden()
dialog.general.show_probe(probe_mod.ProbeResult(probe_mod.OK, version="v"))
assert dialog.general._key_row.isHidden()
assert dialog.general._retry.isHidden()
def test_api_key_change_goes_to_config(self, qapp, tmp_path):
dialog = self._dialog(tmp_path, config={st.API_KEY_KEY: "old"})
assert dialog.config_changes() == {}
dialog.general.api_key.setText("new")
assert dialog.config_changes() == {st.API_KEY_KEY: "new"}
def test_avatar_is_cropped_square_and_written_on_apply(self, qapp, tmp_path):
from lintunes.gui.sync_settings_dialog import AVATAR_PX, load_avatar_image
wide = QImage(400, 200, QImage.Format.Format_RGB32)
wide.fill(QColor("red"))
source = tmp_path / "me.png"
wide.save(str(source))
image = load_avatar_image(source)
assert (image.width(), image.height()) == (AVATAR_PX, AVATAR_PX)
assert load_avatar_image(tmp_path / "missing.png") is None
root = tmp_path / "root"
dialog = self._dialog(root)
dialog.general.new_avatar = image
dialog._accept()
dialog.apply()
assert dialog.state.avatar == "avatar.png"
assert not QImage(str(root / "avatar.png")).isNull()
class TestMenu:
def test_other_machine_greys_the_group_and_says_where(self, qapp, isolated_config):
from PyQt6.QtWidgets import QMainWindow, QMenu
from lintunes.gui.cassette_ui import CassetteUi
prefs = Preferences(isolated_config / "data")
window = QMainWindow()
ui = CassetteUi(window, prefs)
menu = QMenu()
ui.build_menu(menu)
ui.refresh_menu()
assert all(a.isEnabled() for a in ui._actions)
assert not ui._elsewhere_action.isVisible()
prefs.set("cassette_host", {"machine_id": "other", "hostname": "tummult"})
ui.refresh_menu()
assert not any(a.isEnabled() for a in ui._actions)
assert ui._elsewhere_action.isVisible()
assert "tummult" in ui._elsewhere_action.text()