"""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, }