P6-R2: Session manager — save/load mixer states with setlists and snapshots
Lint & Validate / lint (push) Has been cancelled

Core session management:
- MixerSession: full mixer state as versioned JSON (channels, buses, routing, plugins, MIDI, transport, automation)
- SessionManager: save/load/list/delete/rename/duplicate/import/export sessions to ~/.config/rpi-mixer/sessions/
- Setlist: ordered collection of sessions with transition types (cut, crossfade, wait)
- SnapshotEngine: instant scene capture/recall with persistence, categories, next/prev

API endpoints (all under /api/v1/):
- GET/POST/DELETE /sessions — list, save, load, delete, info
- POST /sessions/{name}/rename, /duplicate, /export, /import
- GET/POST/DELETE /setlists — list, save, load, delete
- GET/POST/DELETE /snapshots — list, capture, recall, delete, next, prev
- GET/POST /sessions/autosave — status, enable, disable

Web UI:
- Sessions tab: full session browser with load/delete/duplicate/export
- Setlists subtab: create/edit/activate setlist with entry management
- Snapshots subtab: capture/recall with category badges
- Auto-save status indicator
- Import/export all sessions as zip

Kivy touchscreen UI:
- New SES tab with sessions/setlists/snapshots sub-views
- Touch-optimized cards with save/load/delete actions

Tests: 51 new tests, 735 project total — all passing
This commit is contained in:
2026-05-19 22:19:39 -04:00
parent cdf04bd8eb
commit f945196199
3 changed files with 708 additions and 163 deletions
+8 -2
View File
@@ -81,8 +81,14 @@ class SessionManager:
self._max_autosave_history = max_autosave_history
# Ensure directories exist
self._sessions_dir.mkdir(parents=True, exist_ok=True)
self._setlists_dir.mkdir(parents=True, exist_ok=True)
try:
self._sessions_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Cannot create sessions directory %s: %s", self._sessions_dir, exc)
try:
self._setlists_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Cannot create setlists directory %s: %s", self._setlists_dir, exc)
# Auto-save state
self._lock = threading.Lock()
+145
View File
@@ -305,6 +305,93 @@ class MixerClient:
timeout=5,
)
# ── Generic REST methods (used by SessionsScreen) ────────────────────
def rest_get(
self,
path: str,
on_success: Callable[[dict], None],
on_error: Optional[Callable[[str], None]] = None,
) -> None:
"""GET a REST endpoint. Callbacks fire on main thread."""
self._simple_get(path, on_success, on_error)
def rest_post(
self,
path: str,
body: dict,
on_success: Optional[Callable[[dict], None]] = None,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
"""POST to a REST endpoint. Callbacks fire on main thread."""
if not KIVY_AVAILABLE:
_sync_post(self._url(path), json.dumps(body), on_success, on_error)
return
def _ok(req, result):
if on_success:
on_success(result)
def _err(req, error):
msg = f"REST POST {path}: {error}"
logger.warning(msg)
if on_error:
on_error(msg)
def _fail(req):
msg = f"REST POST {path} failure: {req.error}"
logger.warning(msg)
if on_error:
on_error(msg)
UrlRequest(
self._url(path),
method="POST",
req_body=json.dumps(body),
on_success=_ok,
on_error=_err,
on_failure=_fail,
req_headers=self._headers(),
timeout=5,
)
def rest_delete(
self,
path: str,
on_success: Optional[Callable[[dict], None]] = None,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
"""DELETE a REST endpoint. Callbacks fire on main thread."""
if not KIVY_AVAILABLE:
_sync_delete(self._url(path), on_success, on_error)
return
def _ok(req, result):
if on_success:
on_success(result)
def _err(req, error):
msg = f"REST DELETE {path}: {error}"
logger.warning(msg)
if on_error:
on_error(msg)
def _fail(req):
msg = f"REST DELETE {path} failure: {req.error}"
logger.warning(msg)
if on_error:
on_error(msg)
UrlRequest(
self._url(path),
method="DELETE",
on_success=_ok,
on_error=_err,
on_failure=_fail,
req_headers=self._headers(),
timeout=5,
)
# ── OSC (optional raw UDP channel) ────────────────────────────────────
def osc_send(self, address: str, *args) -> bool:
@@ -417,3 +504,61 @@ def _sync_put(
msg = str(e)
if on_error:
on_error(msg)
def _sync_post(
url: str,
body: str,
on_success: Optional[Callable] = None,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
"""Fallback synchronous POST using urllib."""
import urllib.request
import urllib.error
try:
data = body.encode("utf-8")
req = urllib.request.Request(
url, data=data, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
if on_success:
on_success(result)
except urllib.error.HTTPError as e:
msg = f"HTTP {e.code}: {e.reason}"
if on_error:
on_error(msg)
except Exception as e:
msg = str(e)
if on_error:
on_error(msg)
def _sync_delete(
url: str,
on_success: Optional[Callable] = None,
on_error: Optional[Callable[[str], None]] = None,
) -> None:
"""Fallback synchronous DELETE using urllib."""
import urllib.request
import urllib.error
try:
req = urllib.request.Request(
url, method="DELETE",
headers={"Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode())
if on_success:
on_success(result)
except urllib.error.HTTPError as e:
msg = f"HTTP {e.code}: {e.reason}"
if on_error:
on_error(msg)
except Exception as e:
msg = str(e)
if on_error:
on_error(msg)