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:
@@ -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()
|
||||
Reference in New Issue
Block a user