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.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.25.0"
|
||||
__version__ = "0.26.0"
|
||||
|
||||
@@ -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()})
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user