f945196199
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
580 lines
20 KiB
Python
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,
|
|
}
|