Files
lintunes/lintunes/cassette/service.py
T
travandClaude Opus 5.5 ea7f57e38b v0.30.0: Cassette delivery — requested songs arrive in your library
- Sending: a friend's requests ∩ what I still offer is copied into my
  outbox (via a .part name Syncthing never sees); whatever they no longer
  request is deleted, so the outbox cleans itself up.
- Receiving: a finished file I cassetted is imported exactly like Add to
  Library — organized into Artist/Album, tags read, date added now, none
  of their listening history — and a song wanted only for a followed
  playlist lands in the cache. Then requests.json is rewritten without
  what arrived and without what they stopped offering.
- Safe to repeat: Syncthing temp files ignored, Syncthing asked whether a
  file is whole, and an exact match already in my library is never
  imported twice.
- Folders are watched (re-armed after each rename), with a slow fallback.
- file_importer split into stage_file (off the GUI thread) and
  track_from_file (date added = now).
- pytest --syncthing now runs a real invite → share → request → deliver →
  cleanup between two Syncthing instances.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:32:25 -07:00

639 lines
28 KiB
Python

"""The Cassette handshake: invites, knocks, and what each friendship's status is.
``CassetteService`` runs only on the host machine. It owns the Cassette state
(``state.py``) and keeps it true to what Syncthing can actually see, on a
background thread that waits on Syncthing's event stream and reconciles after
each batch. User actions (create an invite, paste a code, remove a friend) run
on the caller's thread and raise ``SyncthingError`` / ``InviteError`` with the
step that failed, so a dialog can show it.
**The knock.** Syncthing hides an *unknown* device's folders: a stranger
connecting shows up only as a pending device, with its hostname. So while an
invite is open, a newly pending device is **probed** — added with nothing
shared, which grants it nothing but lets it tell us which folder it offers.
If that is ``-b`` for an open invite, the friendship completes (share ``-a``
with them, accept ``-b`` receive-only, invite used). If it offers anything
else, or connects and offers nothing, it is removed again and remembered as
rejected; Syncthing goes back to listing it as pending, untouched.
Everything is safe to repeat: every step checks what exists before creating
it, and reconcile rebuilds its picture from Syncthing each time.
"""
from __future__ import annotations
import logging
import shutil
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.config import load_config
from lintunes.cassette import invite as inv
from lintunes.cassette import publish
from lintunes.cassette import syncthing_api as st
from lintunes.cassette.state import (
CANCELLED, INVITEE, INVITER, OPEN, USED, CassetteState, Friend, Invite,
cassette_root, friend_dir, in_dir, load_state, now_iso, out_dir,
save_state,
)
log = logging.getLogger(__name__)
ALREADY_USED = ("This invite has already been used. Ask your friend for a new "
"one.")
OWN_CODE = "That's your own invite code. Send it to a friend to paste."
# A probed device that connects but offers no invite folder within this long
# is let go. One that never connects is let go after the longer window, and
# may be probed again after RETRY_PROBE (it may simply have been offline).
PROBE_CONNECTED_TIMEOUT = timedelta(minutes=2)
PROBE_TIMEOUT = timedelta(minutes=15)
RETRY_PROBE = timedelta(hours=1)
EVENT_TYPES = ("PendingDevicesChanged", "PendingFoldersChanged",
"DeviceConnected", "DeviceDisconnected", "FolderCompletion",
"StateChanged", "ConfigSaved")
EVENT_TIMEOUT_S = 60
DOWN_RETRY_S = 15
# Status.code
WAITING_PASTE = "waiting_paste"
WAITING_COMPUTER = "waiting_computer"
WAITING_LINTUNES = "waiting_lintunes"
CONNECTED = "connected"
OFFLINE = "offline"
USED_CODE = "used"
CANCELLED_CODE = "cancelled"
SYNCTHING_DOWN = "syncthing_down"
NO_API = "no_api"
@dataclass
class Status:
code: str
text: str
hint: str = ""
last_seen: str = ""
last_synced: str = ""
up_to_date: bool = False
def _parse(when: str) -> datetime | None:
"""An ISO stamp as naive UTC (ours are naive UTC; Syncthing's end in Z or
carry an offset)."""
if not when:
return None
try:
parsed = datetime.fromisoformat(when.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _utc_now() -> datetime:
return datetime.fromisoformat(now_iso())
def friend_status(friend: Friend, *, connected: bool, in_accepted: bool,
remote_state: str, up_to_date: bool, last_seen: str) -> Status:
"""Plain-language status for one friendship. Pure, so it's testable."""
name = friend.name or "your friend"
computer_hint = (
f"Both computers need to be on with Syncthing running. If this stays "
f"like this, check that global discovery and relaying are enabled in "
f"Syncthing's settings (Actions ▸ Settings ▸ Connections).")
if not in_accepted:
# The paste side can't tell "their computer is off" from "their
# LinTunes isn't open": Syncthing reports a stranger's connection as
# up for the moment before the other side hangs up on it. So one
# honest status covering both.
return Status(WAITING_COMPUTER,
f"Waiting for {name}'s computer",
computer_hint + f" It also waits until {name} opens "
f"LinTunes. If it never connects, the code may already "
f"have been used — ask {name} for a new one.",
last_seen=last_seen)
if not connected:
if not friend.connected_at:
return Status(WAITING_COMPUTER, f"Waiting for {name}'s computer",
computer_hint, last_seen=last_seen,
last_synced=friend.last_synced)
return Status(OFFLINE, f"{name}'s computer is offline",
"Everything catches up the next time both computers "
"are on.", last_seen=last_seen,
last_synced=friend.last_synced)
if remote_state == "notSharing":
return Status(WAITING_LINTUNES, f"Waiting for {name} to open LinTunes",
f"{name}'s Syncthing can see you; their LinTunes accepts "
f"the connection the next time it's open.",
last_seen=last_seen, last_synced=friend.last_synced)
return Status(CONNECTED, "Connected",
"" if up_to_date else "Syncing…",
last_seen=last_seen, last_synced=friend.last_synced,
up_to_date=up_to_date)
class CassetteService(QObject):
changed = pyqtSignal() # state or statuses moved
notice = pyqtSignal(str) # "<Name> is now connected."
step_failed = pyqtSignal(str, str) # step, what Syncthing said
# A friendship was added (their copy of my share needs writing). Emitted
# from the background thread; the GUI side does the writing, because
# that reads the library.
friends_added = pyqtSignal()
def __init__(self, root: Path | None = None, config_loader=load_config,
session_factory=None, addresses_for=None, parent=None):
super().__init__(parent)
self.root = root or cassette_root()
self._config_loader = config_loader
self._session_factory = session_factory
# Where to dial a new device. "dynamic" (discovery) in real life; the
# two-instance test pair runs on loopback, where discovery can't work.
self._addresses_for = addresses_for or (lambda device_id: ["dynamic"])
self._lock = threading.RLock()
self._state = load_state(self.root)
self._statuses: dict[str, Status] = {}
self._api = Status(SYNCTHING_DOWN, "Checking Syncthing…")
self._stop = threading.Event()
self._kick = threading.Event()
self._thread: threading.Thread | None = None
self._last_failure: tuple[str, str] | None = None
self._library_provider = None
# ---- lifecycle ----
def start(self):
if self._thread is None:
self._thread = threading.Thread(target=self._loop, daemon=True,
name="cassette")
self._thread.start()
def stop(self):
self._stop.set()
self._kick.set()
def kick(self):
"""Reconcile soon (after the current event wait ends)."""
self._kick.set()
# ---- reading ----
def state(self) -> CassetteState:
with self._lock:
return self._state.copy()
def status(self, token: str) -> Status | None:
with self._lock:
return self._statuses.get(token)
def api_status(self) -> Status:
with self._lock:
return self._api
# ---- plumbing ----
def client(self) -> st.SyncthingClient:
endpoint = st.resolve_endpoint(self._config_loader())
if endpoint is None:
raise st.SyncthingError("Connecting to Syncthing",
"no API key was found", st.NO_KEY)
session = self._session_factory() if self._session_factory else None
return st.SyncthingClient(endpoint, session=session)
def _save(self):
save_state(self._state, self.root)
def _publish_intro(self, token: str):
avatar = self.root / self._state.avatar if self._state.avatar else None
publish.publish_intro(out_dir(self.root, token),
self._state.display_name, avatar)
# ---- publishing my share ----
def set_library_provider(self, provider):
"""``provider()`` returns the live Library. Only ever called on the
GUI thread (republish), which is the thread that mutates it."""
self._library_provider = provider
def republish(self) -> list[str]:
"""Write every friend's trimmed copy of my library; unchanged files
aren't touched. Returns the tokens whose copy changed."""
if self._library_provider is None:
return []
from lintunes.cassette import share
library = self._library_provider()
changed = []
with self._lock:
for token, friend in self._state.friends.items():
selection = self._state.selection_for(friend)
if share.publish_share(out_dir(self.root, token), library,
selection):
changed.append(token)
return changed
# ---- requests ----
def save_requests(self, token: str, cassetted, followed, requested):
"""Friend mode's Save and Close: remember what I cassetted and which
playlists I follow, and tell the friend (``requests.json`` in my
folder to them) which of their track ids to send."""
from lintunes.cassette import requests as req
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return
friend.cassetted = sorted(int(t) for t in cassetted)
friend.followed = sorted(followed)
self._save()
req.write_requests(out_dir(self.root, token), requested)
self.changed.emit()
def apply_delivery(self, token: str, cassetted, requested):
"""After a batch arrived: what's still wanted, and the requests file
that says so (rewritten only if it changed)."""
from lintunes.cassette import requests as req
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return
cassetted = sorted(int(t) for t in cassetted)
if cassetted != friend.cassetted:
friend.cassetted = cassetted
self._save()
req.write_requests(out_dir(self.root, token), requested)
def is_complete(self, token: str, path) -> bool:
"""Has Syncthing finished writing this file of the friend's? It only
renames a download into place once it's whole, so a finished name is
normally enough; this double-checks with Syncthing when it can."""
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return False
try:
client = self.client()
relative = path.relative_to(in_dir(self.root, token)).as_posix()
info = client.file_info(friend.in_folder, relative)
except (st.SyncthingError, ValueError):
return True
if not info:
return True
local, global_ = info.get("local") or {}, info.get("global") or {}
if local.get("deleted") or global_.get("deleted"):
return False
return (local.get("version") == global_.get("version")
and local.get("size") == global_.get("size"))
# ---- user actions ----
def create_invite(self) -> str:
"""Create a token and its outgoing folder; return the code to send."""
client = self.client()
my_id = client.my_id()
with self._lock:
token = inv.new_token()
self._publish_intro(token)
folder_id = inv.inviter_folder(token)
client.add_folder(folder_id, f"LinTunes: {self._state.display_name}"
" (invite)", out_dir(self.root, token), "sendonly",
step="Creating your shared folder")
self._state.invites[token] = Invite(token=token, created=now_iso(),
folder_id=folder_id)
self._save()
code = inv.encode(my_id, self._state.display_name, token)
self.changed.emit()
self.kick()
return code
def cancel_invite(self, token: str, client=None):
with self._lock:
invite = self._state.invites.get(token)
if invite is None or invite.status != OPEN:
return
client = client or self.client()
if client.folder(invite.folder_id) is not None:
client.remove_folder(invite.folder_id,
step="Removing the invite's shared folder")
invite.status = CANCELLED
shutil.rmtree(friend_dir(self.root, token), ignore_errors=True)
self._save()
self.changed.emit()
def accept_code(self, text: str) -> Friend:
"""Paste side: add the inviter, share our folder, then wait for theirs."""
code = inv.decode(text)
client = self.client()
my_id = client.my_id()
with self._lock:
if code.device_id == my_id or code.token in self._state.invites:
raise inv.InviteError(OWN_CODE)
if code.token in self._state.friends:
raise inv.InviteError(ALREADY_USED)
if client.device(code.device_id) is None:
client.add_device(code.device_id, code.name,
addresses=self._addresses_for(code.device_id))
self._publish_intro(code.token)
client.add_folder(inv.invitee_folder(code.token),
f"LinTunes: {self._state.display_name} → {code.name}",
out_dir(self.root, code.token), "sendonly",
[code.device_id],
step=f"Sharing your folder with {code.name}")
friend = Friend(token=code.token, device_id=code.device_id,
name=code.name, role=INVITEE,
out_folder=inv.invitee_folder(code.token),
in_folder=inv.inviter_folder(code.token),
added=now_iso())
self._state.friends[code.token] = friend
self._save()
self.republish()
self.changed.emit()
self.kick()
return friend
def remove_friend(self, token: str, client=None):
with self._lock:
friend = self._state.friends.get(token)
if friend is None:
return
client = client or self.client()
for folder_id in (friend.out_folder, friend.in_folder):
if folder_id and client.folder(folder_id) is not None:
client.remove_folder(folder_id,
step=f"Removing {friend.name}'s folder")
# Only drop the device if no other friendship rides on it.
if not any(f.device_id == friend.device_id
for t, f in self._state.friends.items() if t != token):
if client.device(friend.device_id) is not None:
client.remove_device(friend.device_id, friend.name)
shutil.rmtree(friend_dir(self.root, token), ignore_errors=True)
del self._state.friends[token]
self._statuses.pop(token, None)
self._save()
self.changed.emit()
def apply_settings(self, edited: CassetteState, cancel_invites=(),
remove_friends=()):
"""Sync Settings pressed OK: adopt the edited fields, carry out the
staged removals, republish what changed."""
errors = []
for token in cancel_invites:
try:
self.cancel_invite(token)
except st.SyncthingError as exc:
errors.append(exc)
for token in remove_friends:
try:
self.remove_friend(token)
except st.SyncthingError as exc:
errors.append(exc)
with self._lock:
state = self._state
state.display_name = edited.display_name
state.avatar = edited.avatar
state.share_same_for_all = edited.share_same_for_all
state.general = edited.general
for token, friend in state.friends.items():
mine = edited.friends.get(token)
if mine is not None:
friend.selection = mine.selection
friend.layout = mine.layout
friend.followed_mode = mine.followed_mode
self._save()
for token in list(state.friends) + [i.token for i in state.open_invites()]:
self._publish_intro(token)
self.republish()
self.changed.emit()
if errors:
raise errors[0]
# ---- the background loop ----
def _loop(self):
since = 0
client = None
while not self._stop.is_set():
try:
client = client or self.client()
self.reconcile(client)
self._kick.clear()
events = client.events(since, EVENT_TYPES, EVENT_TIMEOUT_S)
if events:
since = max(e.get("id", since) for e in events)
except st.SyncthingError as exc:
client = None
since = 0
self._set_api_down(exc)
self._kick.wait(DOWN_RETRY_S)
self._kick.clear()
except Exception: # never let the thread die silently
log.exception("cassette loop")
self._kick.wait(DOWN_RETRY_S)
self._kick.clear()
def _set_api_down(self, exc: st.SyncthingError):
if exc.kind in (st.BAD_KEY, st.NO_KEY):
status = Status(NO_API, "Can't reach Syncthing's API",
"Enter Syncthing's API key in Sync Settings ▸ "
"General, or let LinTunes read it from "
"Syncthing's config.")
else:
status = Status(SYNCTHING_DOWN, "Syncthing isn't running",
"Start Syncthing, then press Retry in Sync "
"Settings ▸ General.")
with self._lock:
changed = self._api.code != status.code
self._api = status
if changed:
self.changed.emit()
def _fail(self, exc: st.SyncthingError):
"""A background step failed: say so once (not every reconcile)."""
key = (exc.step, exc.detail)
if key != self._last_failure:
self._last_failure = key
self.step_failed.emit(exc.step, exc.detail)
def reconcile(self, client: st.SyncthingClient):
"""Bring state in line with Syncthing and recompute every status."""
version = client.version()
with self._lock:
self._api = Status(CONNECTED, f"Syncthing {version} is running.")
try:
pending_devices = client.pending_devices()
pending_folders = client.pending_folders()
self._handle_offers(client, pending_folders, pending_devices)
self._handle_probes(client, pending_devices, pending_folders)
self._last_failure = None
except st.SyncthingError as exc:
if exc.kind != st.OTHER:
raise
self._fail(exc)
notices = self._refresh_statuses(client)
for message in notices:
self.notice.emit(message)
self.changed.emit()
def _handle_offers(self, client, pending_folders: dict,
pending_devices: dict):
state = self._state
for folder_id, info in pending_folders.items():
parsed = inv.parse_folder_id(folder_id)
offered_by = (info or {}).get("offeredBy", {})
for device_id in offered_by:
if parsed is None:
continue
token, side = parsed
if side == "b":
invite = state.invites.get(token)
if invite is not None and invite.status == OPEN \
and token not in state.friends:
name = (pending_devices.get(device_id, {}).get("name")
or "New friend")
self._complete_invite(client, invite, device_id, name)
elif device_id in state.probing:
# A knock with a used or cancelled code.
self._reject(client, device_id)
elif side == "a":
friend = state.friends.get(token)
if (friend is not None and friend.role == INVITEE
and friend.device_id == device_id
and client.folder(folder_id) is None):
in_path = in_dir(self.root, token)
in_path.mkdir(parents=True, exist_ok=True)
client.add_folder(
folder_id, f"LinTunes: from {friend.name}", in_path,
"receiveonly", [device_id],
step=f"Accepting {friend.name}'s folder")
# A probed device that offers folders, none of them an open invite's.
for device_id in list(state.probing):
offers = [fid for fid, info in pending_folders.items()
if device_id in (info or {}).get("offeredBy", {})]
if offers and device_id in state.probing:
self._reject(client, device_id)
def _complete_invite(self, client, invite: Invite, device_id: str,
hostname: str):
token = invite.token
client.add_device(device_id, hostname,
addresses=self._addresses_for(device_id))
client.share_folder(invite.folder_id, device_id,
step="Sharing your folder with your friend")
in_path = in_dir(self.root, token)
in_path.mkdir(parents=True, exist_ok=True)
client.add_folder(inv.invitee_folder(token), "LinTunes: from a friend",
in_path, "receiveonly", [device_id],
step="Accepting your friend's folder")
self._state.friends[token] = Friend(
token=token, device_id=device_id, name=hostname, role=INVITER,
out_folder=invite.folder_id, in_folder=inv.invitee_folder(token),
added=now_iso())
invite.status = USED
self._state.probing.pop(device_id, None)
self._save()
self.friends_added.emit()
def _reject(self, client, device_id: str):
self._state.probing.pop(device_id, None)
self._state.rejected[device_id] = now_iso()
if client.device(device_id) is not None:
client.remove_device(device_id, "an unknown computer")
self._save()
def _handle_probes(self, client, pending_devices: dict,
pending_folders: dict):
state = self._state
now = _utc_now()
known = {f.device_id for f in state.friends.values()}
opens = state.open_invites()
if opens:
oldest = min((_parse(i.created) or now) for i in opens)
for device_id, info in pending_devices.items():
if device_id in known or device_id in state.probing:
continue
rejected = _parse(state.rejected.get(device_id, ""))
if rejected is not None:
continue
seen = _parse((info or {}).get("time", "")) or now
if seen + timedelta(seconds=5) < oldest:
continue # knocking since before any open invite
client.add_device(device_id, (info or {}).get("name") or "?",
addresses=self._addresses_for(device_id))
state.probing[device_id] = now_iso()
self._save()
if not state.probing:
return
connections = client.connections()
for device_id, started in list(state.probing.items()):
began = _parse(started) or now
connected = connections.get(device_id, {}).get("connected")
if not opens:
self._release_probe(client, device_id)
elif connected and now - began > PROBE_CONNECTED_TIMEOUT:
self._reject(client, device_id)
elif now - began > PROBE_TIMEOUT:
self._release_probe(client, device_id)
def _release_probe(self, client, device_id: str):
"""Stop probing without holding it against the device."""
self._state.probing.pop(device_id, None)
if client.device(device_id) is not None:
client.remove_device(device_id, "an unknown computer")
self._save()
def _refresh_statuses(self, client) -> list[str]:
state = self._state
notices = []
statuses = {}
for invite in state.invites.values():
if invite.status == OPEN:
statuses[invite.token] = Status(
WAITING_PASTE, "Waiting for your friend to paste the code")
elif invite.status == CANCELLED:
statuses[invite.token] = Status(CANCELLED_CODE,
"This invite was cancelled")
if not state.friends:
self._statuses = statuses
return notices
connections = client.connections()
stats = client.device_stats()
dirty = False
for token, friend in state.friends.items():
in_accepted = client.folder(friend.in_folder) is not None
connected = bool(connections.get(friend.device_id, {}).get("connected"))
remote_state, up_to_date = "", False
if in_accepted and connected:
remote = client.completion(friend.out_folder, friend.device_id)
remote_state = remote.get("remoteState", "")
local = client.completion(friend.in_folder)
up_to_date = (remote.get("completion", 0) >= 100
and local.get("completion", 0) >= 100
and remote_state != "notSharing")
# The friend's own display name, once their folder has synced.
intro = publish.read_intro(in_dir(self.root, token))
name = str(intro.get("name") or "").strip()[:80]
if name and name != friend.name:
friend.name = name
dirty = True
if in_accepted and connected and remote_state != "notSharing" \
and not friend.connected_at:
friend.connected_at = now_iso()
notices.append(f"{friend.name} is now connected.")
dirty = True
if up_to_date:
friend.last_synced = now_iso()
dirty = dirty or not self._statuses.get(token, Status("", "")).up_to_date
statuses[token] = friend_status(
friend, connected=connected, in_accepted=in_accepted,
remote_state=remote_state, up_to_date=up_to_date,
last_seen=stats.get(friend.device_id, {}).get("lastSeen", ""))
self._statuses = statuses
if dirty:
self._save()
return notices