Files
raspberry-pi-mixer/src/session/session_manager.py
T
shawn f945196199
Lint & Validate / lint (push) Has been cancelled
P6-R2: Session manager — save/load mixer states with setlists and snapshots
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
2026-05-19 22:19:39 -04:00

580 lines
20 KiB
Python

"""SessionManager — save, load, list, delete, import, and export mixer sessions.
Manages persistent storage of MixerSession objects as JSON files.
Supports auto-save on parameter changes with configurable debounce.
Sessions directory: ~/.config/rpi-mixer/sessions/
Each session is a .json file named after the session (sanitized).
Usage:
mgr = SessionManager()
mgr.save(engine, "Soundcheck Mix")
sessions = mgr.list_sessions()
session = mgr.load("Soundcheck Mix")
session.apply_to(engine)
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Callable, Optional
from .mixer_session import MixerSession, SessionMetadata, SESSION_SCHEMA_VERSION
logger = logging.getLogger(__name__)
# Default session directories
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "rpi-mixer"
DEFAULT_SESSIONS_DIR = DEFAULT_CONFIG_DIR / "sessions"
DEFAULT_SETLISTS_DIR = DEFAULT_CONFIG_DIR / "setlists"
DEFAULT_EXPORT_DIR = Path.home() / "mixer-sessions-export"
# Auto-save debounce (seconds)
DEFAULT_AUTOSAVE_DEBOUNCE = 2.0
# Maximum sessions to keep in auto-save history
MAX_AUTOSAVE_HISTORY = 5
# Sanitize: allow alphanumeric, hyphen, underscore, space, dot
_SAFE_NAME_RE = re.compile(r"[^\w\s.\-]", re.UNICODE)
def _safe_filename(name: str) -> str:
"""Convert a session name to a safe filename."""
safe = _SAFE_NAME_RE.sub("_", name).strip().replace(" ", "_")
return safe or "untitled"
class SessionManager:
"""Manages mixer session files on disk.
Thread-safe for concurrent access from UI and auto-save timer.
Args:
sessions_dir: Directory to store session JSON files.
setlists_dir: Directory to store setlist JSON files.
auto_save_enabled: Enable auto-save on parameter changes.
auto_save_debounce: Seconds to debounce before auto-saving.
max_autosave_history: Number of auto-save backups to keep.
"""
def __init__(
self,
sessions_dir: Optional[Path] = None,
setlists_dir: Optional[Path] = None,
auto_save_enabled: bool = True,
auto_save_debounce: float = DEFAULT_AUTOSAVE_DEBOUNCE,
max_autosave_history: int = MAX_AUTOSAVE_HISTORY,
):
self._sessions_dir = Path(sessions_dir) if sessions_dir else DEFAULT_SESSIONS_DIR
self._setlists_dir = Path(setlists_dir) if setlists_dir else DEFAULT_SETLISTS_DIR
self._auto_save_enabled = auto_save_enabled
self._auto_save_debounce = auto_save_debounce
self._max_autosave_history = max_autosave_history
# Ensure directories exist
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()
self._auto_save_timer: Optional[threading.Timer] = None
self._auto_save_pending = False
self._auto_save_session_name: str = "__autosave__"
self._auto_save_engine: Optional[Any] = None
self._auto_save_callback: Optional[Callable[[str], None]] = None
logger.info("SessionManager initialized: sessions=%s, setlists=%s",
self._sessions_dir, self._setlists_dir)
# ── Session I/O ─────────────────────────────────────────────────────
def save(
self,
engine: Any,
name: str,
metadata: Optional[SessionMetadata] = None,
overwrite: bool = True,
) -> MixerSession:
"""Save the current mixer state as a named session.
Args:
engine: DSPEngine instance to capture state from.
name: Session name (used for filename and display).
metadata: Optional session metadata.
overwrite: If True, overwrite existing session with same name.
Returns:
The saved MixerSession.
Raises:
FileExistsError: If overwrite is False and session exists.
"""
filename = _safe_filename(name) + ".json"
filepath = self._sessions_dir / filename
if not overwrite and filepath.exists():
raise FileExistsError(f"Session already exists: {name}")
session = MixerSession.from_engine(engine, name=name, metadata=metadata)
session.metadata.modified_at = time.time()
with self._lock:
session.save_to_file(filepath)
logger.info("Session saved: %s%s", name, filepath)
return session
def load(self, name: str) -> Optional[MixerSession]:
"""Load a session by name.
Args:
name: Session name (with or without .json extension).
Returns:
MixerSession if found, None otherwise.
"""
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
filepath = self._sessions_dir / filename
if not filepath.exists():
# Try with original name (maybe it already has .json)
alt_path = self._sessions_dir / name
if alt_path.exists():
filepath = alt_path
else:
logger.debug("Session not found: %s", name)
return None
try:
with self._lock:
session = MixerSession.from_file(filepath)
logger.debug("Session loaded: %s", name)
return session
except (json.JSONDecodeError, KeyError, ValueError) as exc:
logger.error("Failed to load session '%s': %s", name, exc)
return None
def load_and_apply(self, engine: Any, name: str) -> bool:
"""Load a session and apply it to the engine.
Returns:
True if loaded and applied successfully.
"""
session = self.load(name)
if session is None:
return False
return session.apply_to(engine)
def delete(self, name: str) -> bool:
"""Delete a session by name.
Returns:
True if deleted, False if not found.
"""
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
filepath = self._sessions_dir / filename
if not filepath.exists():
alt_path = self._sessions_dir / name
if alt_path.exists():
filepath = alt_path
else:
return False
with self._lock:
filepath.unlink()
logger.info("Session deleted: %s", name)
return True
def rename(self, old_name: str, new_name: str) -> bool:
"""Rename a session.
Returns:
True if renamed, False if source not found or target exists.
"""
old_filename = _safe_filename(old_name)
if not old_filename.endswith(".json"):
old_filename += ".json"
old_path = self._sessions_dir / old_filename
if not old_path.exists():
alt_path = self._sessions_dir / old_name
if alt_path.exists():
old_path = alt_path
else:
return False
new_filename = _safe_filename(new_name) + ".json"
new_path = self._sessions_dir / new_filename
if new_path.exists():
return False
with self._lock:
old_path.rename(new_path)
# Update metadata inside the file
session = self.load(new_name)
if session:
session.metadata.name = new_name
session.metadata.modified_at = time.time()
session.save_to_file(new_path)
logger.info("Session renamed: %s%s", old_name, new_name)
return True
def duplicate(self, source_name: str, target_name: str) -> Optional[MixerSession]:
"""Duplicate a session under a new name.
Returns:
The duplicated MixerSession, or None if source not found.
"""
session = self.load(source_name)
if session is None:
return None
session.metadata.name = target_name
session.metadata.created_at = time.time()
session.metadata.modified_at = time.time()
filename = _safe_filename(target_name) + ".json"
filepath = self._sessions_dir / filename
with self._lock:
session.save_to_file(filepath)
logger.info("Session duplicated: %s%s", source_name, target_name)
return session
def list_sessions(self) -> list[dict]:
"""List all saved sessions with metadata.
Returns:
List of dicts with keys: name, file, size_bytes, modified_at.
"""
sessions = []
try:
with self._lock:
for entry in sorted(self._sessions_dir.glob("*.json")):
try:
stat = entry.stat()
# Try to read metadata from the file
try:
data = json.loads(entry.read_text(encoding="utf-8"))
meta = data.get("metadata", {})
name = meta.get("name", entry.stem.replace("_", " "))
except (json.JSONDecodeError, UnicodeDecodeError):
name = entry.stem.replace("_", " ")
sessions.append({
"name": name,
"filename": entry.name,
"size_bytes": stat.st_size,
"modified": stat.st_mtime,
"created": stat.st_ctime,
})
except OSError:
continue
except OSError as exc:
logger.warning("Error listing sessions: %s", exc)
return sessions
def get_session_info(self, name: str) -> Optional[dict]:
"""Get detailed information about a session without loading full state.
Returns:
Dict with metadata, channel count, plugin count, etc.
"""
session = self.load(name)
if session is None:
return None
return {
"name": session.metadata.name,
"version": session.version,
"metadata": session.metadata.to_dict(),
"channel_count": session.channel_count,
"has_plugins": session.has_plugins,
"has_midi_mappings": session.has_midi_mappings,
"size_bytes": len(session.to_json()),
}
# ── Import / Export ──────────────────────────────────────────────────
def export_session(self, name: str, target_dir: Optional[Path] = None) -> Optional[Path]:
"""Export a session JSON file to a target directory.
Args:
name: Session name to export.
target_dir: Directory to export to (default: ~/mixer-sessions-export/).
Returns:
Path to exported file, or None if session not found.
"""
if target_dir is None:
target_dir = DEFAULT_EXPORT_DIR
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
source_path = self._sessions_dir / filename
if not source_path.exists():
alt_path = self._sessions_dir / name
if alt_path.exists():
source_path = alt_path
else:
return None
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / source_path.name
with self._lock:
shutil.copy2(source_path, target_path)
logger.info("Session exported: %s%s", name, target_path)
return target_path
def import_session(self, filepath: Path, new_name: Optional[str] = None) -> Optional[MixerSession]:
"""Import a session JSON file from an external path.
Args:
filepath: Path to the JSON session file.
new_name: Optional new name (uses metadata name if None).
Returns:
Imported MixerSession, or None if import fails.
"""
if not filepath.exists() or not filepath.is_file():
logger.error("Import file not found: %s", filepath)
return None
try:
data = json.loads(filepath.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.error("Invalid JSON in import file: %s", exc)
return None
# Validate basic structure
if not isinstance(data, dict) or "channels" not in data:
logger.error("Import file is not a valid mixer session")
return None
session = MixerSession.from_dict(data)
if new_name:
session.metadata.name = new_name
session.metadata.modified_at = time.time()
filename = _safe_filename(session.metadata.name) + ".json"
dest_path = self._sessions_dir / filename
with self._lock:
session.save_to_file(dest_path)
logger.info("Session imported: %s%s", filepath, dest_path)
return session
def export_all(self, target_dir: Optional[Path] = None) -> Path:
"""Export all sessions as a zip file.
Args:
target_dir: Directory for the export zip.
Returns:
Path to the created zip file.
"""
if target_dir is None:
target_dir = DEFAULT_EXPORT_DIR
target_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
zip_name = f"rpi-mixer-sessions_{timestamp}"
zip_path = target_dir / zip_name
with self._lock:
# Create a zip archive of the sessions directory
shutil.make_archive(
str(zip_path),
"zip",
root_dir=str(self._sessions_dir.parent),
base_dir=self._sessions_dir.name,
)
logger.info("All sessions exported: %s.zip", zip_path)
return Path(f"{zip_path}.zip")
# ── Auto-save ───────────────────────────────────────────────────────
def set_auto_save_engine(self, engine: Any) -> None:
"""Set the engine to auto-save from."""
self._auto_save_engine = engine
def set_auto_save_callback(self, callback: Callable[[str], None]) -> None:
"""Set a callback invoked after each auto-save (receives session name)."""
self._auto_save_callback = callback
def enable_auto_save(self) -> None:
"""Enable auto-save."""
self._auto_save_enabled = True
logger.debug("Auto-save enabled")
def disable_auto_save(self) -> None:
"""Disable auto-save and cancel any pending timer."""
self._auto_save_enabled = False
with self._lock:
if self._auto_save_timer:
self._auto_save_timer.cancel()
self._auto_save_timer = None
self._auto_save_pending = False
logger.debug("Auto-save disabled")
def notify_change(self) -> None:
"""Notify that a parameter has changed (debounced auto-save trigger).
Call this from the DSP engine's parameter handler to trigger
auto-save after the debounce period of inactivity.
"""
if not self._auto_save_enabled or self._auto_save_engine is None:
return
with self._lock:
# Cancel any pending timer
if self._auto_save_timer:
self._auto_save_timer.cancel()
self._auto_save_timer = None
# Set pending flag
self._auto_save_pending = True
# Schedule a new auto-save
self._auto_save_timer = threading.Timer(
self._auto_save_debounce,
self._do_auto_save,
)
self._auto_save_timer.daemon = True
self._auto_save_timer.start()
def _do_auto_save(self) -> None:
"""Perform the actual auto-save."""
with self._lock:
self._auto_save_pending = False
self._auto_save_timer = None
if self._auto_save_engine is None:
return
try:
session = MixerSession.from_engine(
self._auto_save_engine,
name=self._auto_save_session_name,
)
filename = _safe_filename(self._auto_save_session_name) + ".json"
filepath = self._sessions_dir / filename
# Rotate old auto-saves
self._rotate_autosaves(filename)
session.save_to_file(filepath)
if self._auto_save_callback:
try:
self._auto_save_callback(self._auto_save_session_name)
except Exception:
pass
logger.debug("Auto-saved session: %s", filepath)
except Exception as exc:
logger.error("Auto-save failed: %s", exc)
def _rotate_autosaves(self, filename: str) -> None:
"""Rotate auto-save backups, keeping the last N versions."""
base = Path(filename).stem
pattern = f"{base}.*.json"
existing = sorted(self._sessions_dir.glob(pattern))
# Remove oldest if we have too many
while len(existing) >= self._max_autosave_history:
oldest = existing.pop(0)
try:
oldest.unlink()
except OSError:
pass
# Rename current to .1, .1 to .2, etc.
current = self._sessions_dir / filename
if current.exists():
for i in range(self._max_autosave_history - 1, 0, -1):
old = self._sessions_dir / f"{base}.{i}.json"
new = self._sessions_dir / f"{base}.{i + 1}.json"
if old.exists():
try:
old.rename(new)
except OSError:
pass
# Rename current to .1
try:
current.rename(self._sessions_dir / f"{base}.1.json")
except OSError:
pass
# ── Convenience ─────────────────────────────────────────────────────
def exists(self, name: str) -> bool:
"""Check if a session exists."""
filename = _safe_filename(name)
if not filename.endswith(".json"):
filename += ".json"
return (self._sessions_dir / filename).exists()
@property
def sessions_dir(self) -> Path:
return self._sessions_dir
@property
def setlists_dir(self) -> Path:
return self._setlists_dir
@property
def session_count(self) -> int:
try:
return len(list(self._sessions_dir.glob("*.json")))
except OSError:
return 0
@property
def auto_save_enabled(self) -> bool:
return self._auto_save_enabled
@property
def stats(self) -> dict:
return {
"sessions_dir": str(self._sessions_dir),
"session_count": self.session_count,
"auto_save_enabled": self._auto_save_enabled,
"auto_save_debounce": self._auto_save_debounce,
"auto_save_pending": self._auto_save_pending,
}