- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders, platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle manager with auto-reconnect, scene management, stream statistics - Keyboard shortcut controller (evdev + pynput fallback) for stream control: Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect) - REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras, platforms, and hotkey listing — integrated into NetworkServer - StreamStateProvider bridging layer connects REST routes to Streamer instance - GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and V4L2/libcamera video capture with H.264 hardware encoding on RPi4B - Low-latency streaming settings for live performance (zerolatency, no B-frames) - 89 streaming tests (709 total project tests pass)
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
"""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
|
||||
self._sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._setlists_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 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,
|
||||
}
|
||||
Reference in New Issue
Block a user