P6-R2: Session manager — save/load mixer states with setlists and snapshots
Lint & Validate / lint (push) Has been cancelled
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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user