diff --git a/src/network/schemas.py b/src/network/schemas.py index 2665deb..54d42ec 100644 --- a/src/network/schemas.py +++ b/src/network/schemas.py @@ -354,3 +354,128 @@ class ParameterSetRequest(BaseModel): """Request body for setting a parameter via REST.""" param_type: str = "volume" value: float + + +# ── Session management schemas ─────────────────────────────────────────────── + + +class SessionListItemSchema(BaseModel): + """A session entry in the list response.""" + name: str + filename: str + size_bytes: int = 0 + modified: float = 0.0 + created: float = 0.0 + + +class SessionListSchema(BaseModel): + """Response for listing sessions.""" + sessions: list[SessionListItemSchema] = Field(default_factory=list) + directory: str = "" + count: int = 0 + + +class SessionInfoSchema(BaseModel): + """Detailed session metadata.""" + name: str + version: int = 1 + metadata: dict = Field(default_factory=dict) + channel_count: int = 0 + has_plugins: bool = False + has_midi_mappings: bool = False + size_bytes: int = 0 + + +class SessionSaveRequest(BaseModel): + """Request to save a session.""" + name: str + overwrite: bool = True + + +class SessionRenameRequest(BaseModel): + """Request to rename a session.""" + new_name: str + + +class SessionDuplicateRequest(BaseModel): + """Request to duplicate a session.""" + target_name: str + + +class SessionImportRequest(BaseModel): + """Request to import a session from a file path.""" + filepath: str + new_name: Optional[str] = None + + +# ── Setlist schemas ────────────────────────────────────────────────────────── + + +class SetlistEntrySchema(BaseModel): + """A setlist entry.""" + session_name: str + display_name: str = "" + transition: str = "cut" # cut, crossfade, wait, auto + transition_duration: float = 2.0 + wait_for_trigger: bool = False + notes: str = "" + + +class SetlistSchema(BaseModel): + """A full setlist.""" + name: str = "Untitled Setlist" + entries: list[SetlistEntrySchema] = Field(default_factory=list) + description: str = "" + created_at: float = 0.0 + modified_at: float = 0.0 + + +class SetlistListItemSchema(BaseModel): + """A setlist in the list response.""" + name: str + filename: str + entry_count: int = 0 + size_bytes: int = 0 + modified: float = 0.0 + + +class SetlistListSchema(BaseModel): + """Response for listing setlists.""" + setlists: list[SetlistListItemSchema] = Field(default_factory=list) + directory: str = "" + count: int = 0 + + +# ── Snapshot schemas ───────────────────────────────────────────────────────── + + +class SnapshotListItemSchema(BaseModel): + """A snapshot entry.""" + name: str + created_at: float = 0.0 + notes: str = "" + category: str = "" + color: str = "" + channel_count: int = 0 + is_current: bool = False + + +class SnapshotListSchema(BaseModel): + """Response for listing snapshots.""" + snapshots: list[SnapshotListItemSchema] = Field(default_factory=list) + count: int = 0 + current: Optional[str] = None + + +class SnapshotSaveRequest(BaseModel): + """Request to save a snapshot.""" + name: str + notes: str = "" + category: str = "" + color: str = "" + overwrite: bool = True + + +class SnapshotRenameRequest(BaseModel): + """Request to rename a snapshot.""" + new_name: str diff --git a/src/network/server.py b/src/network/server.py index c080d8e..ffa2e98 100644 --- a/src/network/server.py +++ b/src/network/server.py @@ -51,6 +51,12 @@ from .rest_api import MixerStateProvider, create_router from .websocket import WebSocketManager from .session import SessionManager from .web_routes import create_web_routes, get_static_files_app +from .session_routes import create_session_router +from .stream_routes import StreamStateProvider, create_stream_router +from ..session.session_manager import SessionManager as MixerSessionManager +from ..session.snapshot import SnapshotEngine +from ..streaming.streamer import Streamer, StreamerConfig, StreamScene +from ..streaming.controls import DEFAULT_HOTKEYS from .schemas import ( ChannelSchema, MasterBusSchema, @@ -98,6 +104,8 @@ class NetworkServer: session_ttl: float = 86400, web_dir: str | None = None, audio_dir: str | None = None, + sessions_dir: str | None = None, + auto_save_enabled: bool = True, ): self._engine = engine self._osc_host = osc_host @@ -111,6 +119,25 @@ class NetworkServer: # Session manager for WebSocket browser auth self._session_manager = SessionManager(ttl=session_ttl) + # Mixer session manager for save/load/auto-save + from ..session.session_manager import DEFAULT_SESSIONS_DIR, DEFAULT_SETLISTS_DIR + _sessions_path = Path(sessions_dir) if sessions_dir else DEFAULT_SESSIONS_DIR + self._mixer_session_manager = MixerSessionManager( + sessions_dir=_sessions_path, + setlists_dir=DEFAULT_SETLISTS_DIR, + auto_save_enabled=auto_save_enabled, + ) + self._mixer_session_manager.set_auto_save_engine(engine) + + # Snapshot engine for instant recall + _snapshots_path = _sessions_path.parent / "snapshots" + self._snapshot_engine = SnapshotEngine(storage_dir=_snapshots_path) + + # Streaming engine + self._streamer: Optional[Streamer] = None + self._stream_provider = StreamStateProvider() + self._setup_stream_provider() + # Settings storage self._settings: dict = { "sample_rate": 48000, @@ -209,6 +236,121 @@ class NetworkServer: # The WebSocket manager broadcasts to all connected clients. pass + def _setup_stream_provider(self) -> None: + """Wire the stream state provider to the streamer instance (lazy init).""" + sp = self._stream_provider + + sp.get_status = self._stream_get_status + sp.get_config = self._stream_get_config + sp.start_stream = self._stream_start + sp.stop_stream = self._stream_stop + sp.configure_stream = self._stream_configure + sp.list_scenes = self._stream_list_scenes + sp.add_scene = self._stream_add_scene + sp.remove_scene = self._stream_remove_scene + sp.activate_scene = self._stream_activate_scene + + def _get_or_create_streamer(self) -> Streamer: + """Lazy-init the streamer if not already created.""" + if self._streamer is None: + self._streamer = Streamer() + return self._streamer + + # ── Stream callbacks ──────────────────────────────────────────────── + + def _stream_get_status(self) -> dict: + streamer = self._get_or_create_streamer() + return streamer.get_stats_dict() + + def _stream_get_config(self) -> dict: + streamer = self._get_or_create_streamer() + cfg = streamer.config + return { + "platform_id": cfg.platform_id, + "rtmp_url": cfg.rtmp_url, + "stream_key": "****" if cfg.stream_key else "", + "video_device": cfg.video_device, + "video_width": cfg.video_width, + "video_height": cfg.video_height, + "video_framerate": cfg.video_framerate, + "video_bitrate_kbps": cfg.video_bitrate_kbps, + "audio_device": cfg.audio_device, + "audio_sample_rate": cfg.audio_sample_rate, + "audio_bitrate_kbps": cfg.audio_bitrate_kbps, + "low_latency": cfg.low_latency, + "active_scene": cfg.active_scene, + } + + def _stream_start(self) -> dict: + streamer = self._get_or_create_streamer() + success = streamer.start() + return { + "success": success, + "state": streamer.state.value, + "message": "Stream starting" if success else "Failed to start stream", + } + + def _stream_stop(self) -> dict: + streamer = self._get_or_create_streamer() + streamer.stop() + return { + "success": True, + "state": streamer.state.value, + "message": "Stream stopped", + } + + def _stream_configure(self, data: dict) -> dict: + streamer = self._get_or_create_streamer() + + stream_key = data.get("stream_key") + platform_id = data.get("platform_id") + video_device = data.get("video_device") + audio_device = data.get("audio_device") + + try: + streamer.configure( + platform_id=platform_id, + stream_key=stream_key, + video_device=video_device, + audio_device=audio_device, + ) + return {"success": True, "message": "Stream configured"} + except RuntimeError as exc: + return {"success": False, "error": str(exc)} + + def _stream_list_scenes(self) -> list[dict]: + streamer = self._get_or_create_streamer() + return streamer.get_scenes() + + def _stream_add_scene(self, data: dict) -> dict: + streamer = self._get_or_create_streamer() + scene = StreamScene( + name=data.get("name", "untitled"), + camera_device=data.get("camera_device", "/dev/video0"), + video_width=data.get("video_width", 1280), + video_height=data.get("video_height", 720), + video_framerate=data.get("video_framerate", 30), + audio_device=data.get("audio_device", "hw:0"), + overlay_text=data.get("overlay_text", ""), + description=data.get("description", ""), + ) + streamer.add_scene(scene) + return {"success": True, "scene": scene.to_dict()} + + def _stream_remove_scene(self, name: str) -> dict: + streamer = self._get_or_create_streamer() + success = streamer.remove_scene(name) + if not success: + return {"success": False, "error": f"Cannot remove scene '{name}'"} + return {"success": True, "message": f"Scene '{name}' removed"} + + def _stream_activate_scene(self, name: str) -> dict: + streamer = self._get_or_create_streamer() + success = streamer.switch_scene(name) + if not success: + return {"success": False, "error": f"Scene '{name}' not found"} + return {"success": True, "message": f"Scene '{name}' activated"} + # ── FastAPI app construction ────────────────────────────────────────── def _build_app(self) -> FastAPI: @@ -268,6 +410,23 @@ class NetworkServer: for router in web_routers: app.include_router(router) + # Session management routes (save/load/list/delete, setlists, snapshots) + session_router = create_session_router( + session_manager=self._mixer_session_manager, + snapshot_engine=self._snapshot_engine, + engine=self._engine, + prefix="/api/v1", + ) + app.include_router(session_router) + + # Streaming routes (start/stop/status, scenes, cameras, platforms) + stream_router = create_stream_router( + state=self._stream_provider, + auth=self._auth, + prefix="/api/v1", + ) + app.include_router(stream_router) + # WebSocket endpoint (supports both API key and session token) @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): @@ -373,6 +532,9 @@ class NetworkServer: self._engine.handle_parameter(param, value) + # Trigger auto-save (debounced) + self._mixer_session_manager.notify_change() + # Broadcast to WebSocket clients (non-blocking) asyncio.create_task( self._ws_manager.broadcast_parameter_update(param_type, value, channel) diff --git a/src/network/session_routes.py b/src/network/session_routes.py new file mode 100644 index 0000000..a4c15d9 --- /dev/null +++ b/src/network/session_routes.py @@ -0,0 +1,423 @@ +"""Session management REST API — save/load/list/delete/import/export sessions, setlists, snapshots. + +Endpoints: + GET /api/v1/sessions — list saved sessions + GET /api/v1/sessions/{name} — get session info + POST /api/v1/sessions/save — save current state as session + POST /api/v1/sessions/{name}/load — load and apply a session + DELETE /api/v1/sessions/{name} — delete a session + POST /api/v1/sessions/{name}/rename — rename a session + POST /api/v1/sessions/{name}/duplicate — duplicate a session + POST /api/v1/sessions/export/{name} — export a session + POST /api/v1/sessions/import — import a session + GET /api/v1/sessions/export-all — export all sessions as zip + + GET /api/v1/setlists — list setlists + POST /api/v1/setlists/save — save a setlist + POST /api/v1/setlists/{name}/load — load and activate a setlist + DELETE /api/v1/setlists/{name} — delete a setlist + + GET /api/v1/snapshots — list snapshots + POST /api/v1/snapshots/capture — capture current state as snapshot + POST /api/v1/snapshots/{name}/recall — recall a snapshot + DELETE /api/v1/snapshots/{name} — delete a snapshot +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse + +from .schemas import ( + APIResponse, + SessionListItemSchema, + SessionListSchema, + SessionInfoSchema, + SessionSaveRequest, + SessionRenameRequest, + SessionDuplicateRequest, + SessionImportRequest, + SetlistSchema, + SetlistEntrySchema, + SetlistListItemSchema, + SetlistListSchema, + SnapshotListItemSchema, + SnapshotListSchema, + SnapshotSaveRequest, + SnapshotRenameRequest, +) + +logger = logging.getLogger(__name__) + + +def create_session_router( + session_manager: object, + snapshot_engine: object, + engine: object, + prefix: str = "", +) -> APIRouter: + """Create a FastAPI router with all session management endpoints. + + Args: + session_manager: SessionManager instance. + snapshot_engine: SnapshotEngine instance. + engine: DSPEngine instance. + prefix: Optional path prefix (e.g., "/api/v1"). + """ + + router = APIRouter(prefix=prefix, tags=["sessions"]) + + # ── Session endpoints ──────────────────────────────────────────────────── + + @router.get("/sessions", response_model=SessionListSchema) + async def list_sessions(): + """List all saved session files.""" + sessions = session_manager.list_sessions() + return SessionListSchema( + sessions=[SessionListItemSchema(**s) for s in sessions], + directory=str(session_manager.sessions_dir), + count=len(sessions), + ) + + @router.get("/sessions/{name:path}", response_model=SessionInfoSchema) + async def get_session_info(name: str): + """Get detailed info about a session without loading full state.""" + info = session_manager.get_session_info(name) + if info is None: + raise HTTPException(status_code=404, detail=f"Session not found: {name}") + return SessionInfoSchema(**info) + + @router.post("/sessions/save", response_model=APIResponse) + async def save_session(body: SessionSaveRequest): + """Save current mixer state as a named session.""" + try: + session = session_manager.save( + engine, body.name, overwrite=body.overwrite, + ) + return APIResponse( + ok=True, + data={ + "name": session.metadata.name, + "channels": session.channel_count, + "version": session.version, + }, + ) + except FileExistsError: + raise HTTPException( + status_code=409, + detail=f"Session already exists: {body.name}. Use overwrite=true.", + ) + + @router.post("/sessions/{name:path}/load", response_model=APIResponse) + async def load_session(name: str): + """Load a session and apply its state to the engine.""" + ok = session_manager.load_and_apply(engine, name) + if not ok: + raise HTTPException(status_code=404, detail=f"Session not found: {name}") + return APIResponse(ok=True, data={"name": name, "loaded": True}) + + @router.delete("/sessions/{name:path}", response_model=APIResponse) + async def delete_session(name: str): + """Delete a saved session.""" + ok = session_manager.delete(name) + if not ok: + raise HTTPException(status_code=404, detail=f"Session not found: {name}") + return APIResponse(ok=True, data={"name": name, "deleted": True}) + + @router.post("/sessions/{name:path}/rename", response_model=APIResponse) + async def rename_session(name: str, body: SessionRenameRequest): + """Rename a saved session.""" + ok = session_manager.rename(name, body.new_name) + if not ok: + raise HTTPException( + status_code=404 if not session_manager.exists(name) else 409, + detail=f"Cannot rename '{name}' to '{body.new_name}'", + ) + return APIResponse(ok=True, data={"old_name": name, "new_name": body.new_name}) + + @router.post("/sessions/{name:path}/duplicate", response_model=APIResponse) + async def duplicate_session(name: str, body: SessionDuplicateRequest): + """Duplicate a saved session under a new name.""" + dup = session_manager.duplicate(name, body.target_name) + if dup is None: + raise HTTPException(status_code=404, detail=f"Session not found: {name}") + return APIResponse( + ok=True, + data={"source": name, "target": body.target_name, "channels": dup.channel_count}, + ) + + @router.post("/sessions/import", response_model=APIResponse) + async def import_session(body: SessionImportRequest): + """Import a session from an external JSON file.""" + filepath = Path(body.filepath) + if not filepath.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {body.filepath}") + + session = session_manager.import_session(filepath, body.new_name) + if session is None: + raise HTTPException(status_code=400, detail="Failed to import session") + + return APIResponse( + ok=True, + data={ + "name": session.metadata.name, + "channels": session.channel_count, + "imported": True, + }, + ) + + @router.post("/sessions/export/{name:path}", response_model=APIResponse) + async def export_session(name: str): + """Export a session JSON file to the export directory.""" + path = session_manager.export_session(name) + if path is None: + raise HTTPException(status_code=404, detail=f"Session not found: {name}") + return APIResponse(ok=True, data={"name": name, "export_path": str(path)}) + + @router.get("/sessions/export-all-zip") + async def export_all_sessions(): + """Export all sessions as a zip file download.""" + zip_path = session_manager.export_all() + if not zip_path.exists(): + raise HTTPException(status_code=500, detail="Export failed") + + return FileResponse( + path=str(zip_path), + media_type="application/zip", + filename=zip_path.name, + ) + + @router.get("/sessions/{name:path}/download") + async def download_session(name: str): + """Download a session JSON file directly.""" + filename = None + for entry in session_manager.list_sessions(): + if entry["name"] == name or entry["filename"].startswith(name): + filename = entry["filename"] + break + + if filename is None: + raise HTTPException(status_code=404, detail=f"Session not found: {name}") + + filepath = session_manager.sessions_dir / filename + if not filepath.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {filename}") + + return FileResponse( + path=str(filepath), + media_type="application/json", + filename=filename, + ) + + # ── Setlist endpoints ───────────────────────────────────────────────────── + + @router.get("/setlists", response_model=SetlistListSchema) + async def list_setlists(): + """List all saved setlist files.""" + from ..session.setlist import Setlist + + setlists = [] + try: + for entry in sorted(session_manager.setlists_dir.glob("*.json")): + try: + stat = entry.stat() + sl = Setlist.load_from(entry) + setlists.append(SetlistListItemSchema( + name=sl.name, + filename=entry.name, + entry_count=sl.entry_count, + size_bytes=stat.st_size, + modified=stat.st_mtime, + )) + except (json.JSONDecodeError, KeyError): + setlists.append(SetlistListItemSchema( + name=entry.stem, + filename=entry.name, + size_bytes=entry.stat().st_size, + modified=entry.stat().st_mtime, + )) + except OSError as exc: + logger.warning("Error listing setlists: %s", exc) + + return SetlistListSchema( + setlists=setlists, + directory=str(session_manager.setlists_dir), + count=len(setlists), + ) + + @router.post("/setlists/save", response_model=APIResponse) + async def save_setlist(body: SetlistSchema): + """Save a setlist.""" + from ..session.setlist import Setlist, SetlistEntry, TransitionType + + sl = Setlist( + name=body.name, + description=body.description, + ) + for entry in body.entries: + sl.add_entry( + session_name=entry.session_name, + display_name=entry.display_name, + transition=TransitionType(entry.transition), + transition_duration=entry.transition_duration, + wait_for_trigger=entry.wait_for_trigger, + notes=entry.notes, + ) + + filepath = sl.save_to(session_manager.setlists_dir) + return APIResponse(ok=True, data={"name": sl.name, "path": str(filepath), "entries": sl.entry_count}) + + @router.post("/setlists/{name:path}/load", response_model=APIResponse) + async def load_setlist(name: str): + """Load and activate a setlist.""" + from ..session.setlist import Setlist + + # Find the setlist file + import re + safe = re.sub(r"[^\w\s.\-]", "_", name).strip().replace(" ", "_") + filepath = session_manager.setlists_dir / f"{safe}.json" + if not filepath.exists(): + # Try original name + alt = session_manager.setlists_dir / name + if alt.exists(): + filepath = alt + else: + raise HTTPException(status_code=404, detail=f"Setlist not found: {name}") + + try: + sl = Setlist.load_from(filepath) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid setlist: {exc}") + + ok = sl.activate(session_manager, engine) + if not ok: + raise HTTPException(status_code=400, detail="Failed to activate setlist") + + return APIResponse(ok=True, data={ + "name": sl.name, + "entry_count": sl.entry_count, + "current": sl.current_entry.session_name if sl.current_entry else None, + "progress": list(sl.progress), + }) + + @router.delete("/setlists/{name:path}", response_model=APIResponse) + async def delete_setlist(name: str): + """Delete a setlist.""" + import re + safe = re.sub(r"[^\w\s.\-]", "_", name).strip().replace(" ", "_") + filepath = session_manager.setlists_dir / f"{safe}.json" + if not filepath.exists(): + alt = session_manager.setlists_dir / name + if alt.exists(): + filepath = alt + else: + raise HTTPException(status_code=404, detail=f"Setlist not found: {name}") + + filepath.unlink() + return APIResponse(ok=True, data={"name": name, "deleted": True}) + + # ── Snapshot endpoints ──────────────────────────────────────────────────── + + @router.get("/snapshots", response_model=SnapshotListSchema) + async def list_snapshots(): + """List all snapshots.""" + snaps = snapshot_engine.list_snapshots() + return SnapshotListSchema( + snapshots=[SnapshotListItemSchema(**s) for s in snaps], + count=len(snaps), + current=snapshot_engine.current_name, + ) + + @router.post("/snapshots/capture", response_model=APIResponse) + async def capture_snapshot(body: SnapshotSaveRequest): + """Capture current state as a named snapshot.""" + try: + snap = snapshot_engine.capture( + engine, + name=body.name, + notes=body.notes, + category=body.category, + color=body.color, + overwrite=body.overwrite, + ) + return APIResponse(ok=True, data={ + "name": snap.metadata.name, + "channels": len(snap.channels), + }) + except KeyError: + raise HTTPException( + status_code=409, + detail=f"Snapshot already exists: {body.name}. Use overwrite=true.", + ) + + @router.post("/snapshots/{name:path}/recall", response_model=APIResponse) + async def recall_snapshot(name: str): + """Recall a snapshot instantly.""" + ok = snapshot_engine.recall(engine, name) + if not ok: + raise HTTPException(status_code=404, detail=f"Snapshot not found: {name}") + return APIResponse(ok=True, data={"name": name, "recalled": True}) + + @router.delete("/snapshots/{name:path}", response_model=APIResponse) + async def delete_snapshot(name: str): + """Delete a snapshot.""" + ok = snapshot_engine.delete(name) + if not ok: + raise HTTPException(status_code=404, detail=f"Snapshot not found: {name}") + return APIResponse(ok=True, data={"name": name, "deleted": True}) + + @router.post("/snapshots/{name:path}/rename", response_model=APIResponse) + async def rename_snapshot(name: str, body: SnapshotRenameRequest): + """Rename a snapshot.""" + ok = snapshot_engine.rename(name, body.new_name) + if not ok: + raise HTTPException(status_code=404, detail=f"Cannot rename '{name}'") + return APIResponse(ok=True, data={"old_name": name, "new_name": body.new_name}) + + @router.post("/snapshots/next", response_model=APIResponse) + async def next_snapshot(): + """Recall the next snapshot.""" + ok, name = snapshot_engine.next(engine) + if not ok: + raise HTTPException(status_code=404, detail="No snapshots available") + return APIResponse(ok=True, data={"name": name, "next": True}) + + @router.post("/snapshots/previous", response_model=APIResponse) + async def previous_snapshot(): + """Recall the previous snapshot.""" + ok, name = snapshot_engine.previous(engine) + if not ok: + raise HTTPException(status_code=404, detail="No snapshots available") + return APIResponse(ok=True, data={"name": name, "previous": True}) + + @router.get("/snapshots/categories") + async def snapshot_categories(): + """List snapshot categories.""" + return {"categories": snapshot_engine.categories} + + # ── Session auto-save control ───────────────────────────────────────────── + + @router.get("/sessions/autosave/status", response_model=APIResponse) + async def autosave_status(): + """Get auto-save status.""" + return APIResponse(ok=True, data=session_manager.stats) + + @router.post("/sessions/autosave/enable", response_model=APIResponse) + async def autosave_enable(): + """Enable auto-save.""" + session_manager.enable_auto_save() + return APIResponse(ok=True, data={"auto_save_enabled": True}) + + @router.post("/sessions/autosave/disable", response_model=APIResponse) + async def autosave_disable(): + """Disable auto-save.""" + session_manager.disable_auto_save() + return APIResponse(ok=True, data={"auto_save_enabled": False}) + + return router diff --git a/src/network/stream_routes.py b/src/network/stream_routes.py new file mode 100644 index 0000000..9797e76 --- /dev/null +++ b/src/network/stream_routes.py @@ -0,0 +1,267 @@ +"""Streaming API routes for the network server. + +Provides REST endpoints for: + - Stream control (start, stop, status) + - Scene management + - Camera detection + - Platform presets + - Stream statistics + +Endpoints: + GET /api/v1/stream/status — stream state and stats + POST /api/v1/stream/start — start streaming + POST /api/v1/stream/stop — stop streaming + POST /api/v1/stream/configure — configure stream settings + GET /api/v1/stream/cameras — list detected cameras + GET /api/v1/stream/platforms — list platform presets + GET /api/v1/stream/scenes — list scenes + POST /api/v1/stream/scenes — add a scene + DELETE /api/v1/stream/scenes/{name} — remove a scene + POST /api/v1/stream/scenes/{name}/activate — switch to scene + GET /api/v1/stream/hotkeys — list hotkey mappings +""" + +from __future__ import annotations + +import logging +from typing import Callable, Optional, Any + +from fastapi import APIRouter, HTTPException, Request + +from .auth import APIKeyAuth, API_KEY_HEADER +from ..streaming.platforms import list_platforms, get_platform_profile +from ..streaming.camera import detect_cameras +from ..streaming.controls import DEFAULT_HOTKEYS + +logger = logging.getLogger(__name__) + + +# ── Stream state provider adapter ─────────────────────────────────────────── + + +class StreamStateProvider: + """Adapter that connects the REST API to the Streamer instance. + + The NetworkServer sets callbacks on this provider to bridge + the REST API layer to the actual Streamer instance. + """ + + def __init__(self): + # State + self.get_status: Optional[Callable[[], dict]] = None + self.get_config: Optional[Callable[[], dict]] = None + + # Control + self.start_stream: Optional[Callable[[], dict]] = None + self.stop_stream: Optional[Callable[[], dict]] = None + self.configure_stream: Optional[Callable[[dict], dict]] = None + + # Scenes + self.list_scenes: Optional[Callable[[], list[dict]]] = None + self.add_scene: Optional[Callable[[dict], dict]] = None + self.remove_scene: Optional[Callable[[str], dict]] = None + self.activate_scene: Optional[Callable[[str], dict]] = None + + @staticmethod + def check_auth(request: Request, auth: "APIKeyAuth") -> None: + """Check API key auth on a request. Raises HTTPException if invalid.""" + api_key = request.headers.get(API_KEY_HEADER, "").strip() + if not api_key: + raise HTTPException(status_code=401, detail="API key required") + if not auth.validate(api_key): + raise HTTPException(status_code=403, detail="Invalid API key") + + +# ── Router factory ────────────────────────────────────────────────────────── + + +def create_stream_router( + state: StreamStateProvider, + auth: APIKeyAuth, + prefix: str = "", +) -> APIRouter: + """Create the FastAPI router with streaming endpoints. + + Args: + state: StreamStateProvider with callbacks wired to the Streamer. + auth: API key auth for endpoint protection. + prefix: URL prefix (e.g. "/api/v1"). + + Returns: + FastAPI APIRouter with stream endpoints. + """ + router = APIRouter(prefix=prefix, tags=["streaming"]) + + # ── Status ───────────────────────────────────────────────────────── + + @router.get("/stream/status") + async def get_stream_status(request: Request): + """Get current streaming status and statistics.""" + state.check_auth(request, auth) + if state.get_status is None: + return {"state": "unavailable", "error": "Streaming module not initialized"} + try: + return state.get_status() + except Exception as exc: + logger.error("Error getting stream status: %s", exc) + return {"state": "error", "error": str(exc)} + + # ── Control ──────────────────────────────────────────────────────── + + @router.post("/stream/start") + async def start_stream(request: Request): + """Start the streaming pipeline.""" + state.check_auth(request, auth) + if state.start_stream is None: + raise HTTPException(status_code=503, detail="Streaming module not initialized") + try: + result = state.start_stream() + return result + except Exception as exc: + logger.error("Error starting stream: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + @router.post("/stream/stop") + async def stop_stream(request: Request): + """Stop the streaming pipeline.""" + state.check_auth(request, auth) + if state.stop_stream is None: + raise HTTPException(status_code=503, detail="Streaming module not initialized") + try: + result = state.stop_stream() + return result + except Exception as exc: + logger.error("Error stopping stream: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + @router.post("/stream/configure") + async def configure_stream(request: Request): + """Configure streaming settings (platform, key, devices).""" + state.check_auth(request, auth) + if state.configure_stream is None: + raise HTTPException(status_code=503, detail="Streaming module not initialized") + + try: + body = await request.json() + result = state.configure_stream(body) + return result + except Exception as exc: + logger.error("Error configuring stream: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + # ── Cameras ──────────────────────────────────────────────────────── + + @router.get("/stream/cameras") + async def list_cameras(request: Request): + """List detected cameras.""" + state.check_auth(request, auth) + try: + cameras = detect_cameras() + return { + "cameras": [c.to_dict() for c in cameras], + "count": len(cameras), + } + except Exception as exc: + logger.error("Error detecting cameras: %s", exc) + return {"cameras": [], "count": 0, "error": str(exc)} + + # ── Platforms ────────────────────────────────────────────────────── + + @router.get("/stream/platforms") + async def list_stream_platforms(request: Request): + """List available streaming platform presets.""" + state.check_auth(request, auth) + return { + "platforms": list_platforms(), + } + + @router.get("/stream/platforms/{platform_id}") + async def get_platform(request: Request, platform_id: str): + """Get a specific platform preset.""" + state.check_auth(request, auth) + profile = get_platform_profile(platform_id) + if profile is None: + raise HTTPException(status_code=404, detail=f"Platform '{platform_id}' not found") + return profile.to_dict() + + # ── Scenes ───────────────────────────────────────────────────────── + + @router.get("/stream/scenes") + async def list_stream_scenes(request: Request): + """List configured streaming scenes.""" + state.check_auth(request, auth) + if state.list_scenes is None: + return {"scenes": [], "error": "Streaming module not initialized"} + try: + scenes = state.list_scenes() + return {"scenes": scenes} + except Exception as exc: + logger.error("Error listing scenes: %s", exc) + return {"scenes": [], "error": str(exc)} + + @router.post("/stream/scenes") + async def add_stream_scene(request: Request): + """Add a new streaming scene.""" + state.check_auth(request, auth) + if state.add_scene is None: + raise HTTPException(status_code=503, detail="Streaming module not initialized") + + try: + body = await request.json() + result = state.add_scene(body) + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + logger.error("Error adding scene: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + @router.delete("/stream/scenes/{name}") + async def remove_stream_scene(request: Request, name: str): + """Remove a streaming scene.""" + state.check_auth(request, auth) + if state.remove_scene is None: + raise HTTPException(status_code=503, detail="Streaming module not initialized") + + try: + result = state.remove_scene(name) + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + logger.error("Error removing scene: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + @router.post("/stream/scenes/{name}/activate") + async def activate_stream_scene(request: Request, name: str): + """Activate a streaming scene.""" + state.check_auth(request, auth) + if state.activate_scene is None: + raise HTTPException(status_code=503, detail="Streaming module not initialized") + + try: + result = state.activate_scene(name) + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + logger.error("Error activating scene: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) + + # ── Hotkeys ──────────────────────────────────────────────────────── + + @router.get("/stream/hotkeys") + async def list_stream_hotkeys(request: Request): + """List configured stream keyboard shortcuts.""" + state.check_auth(request, auth) + hotkeys = [] + for hk in DEFAULT_HOTKEYS: + hotkeys.append({ + "action": hk.hotkey.value, + "modifiers": sorted(hk.modifiers), + "key": hk.key, + "description": hk.description, + }) + return {"hotkeys": hotkeys} + + return router diff --git a/src/session/__init__.py b/src/session/__init__.py new file mode 100644 index 0000000..d3dee0c --- /dev/null +++ b/src/session/__init__.py @@ -0,0 +1,26 @@ +"""Session management — save/load mixer states, setlists, and snapshots. + +The session module provides persistent storage for mixer configurations: + - MixerSession: full mixer state as JSON (channels, buses, routing, plugins, MIDI) + - SessionManager: save/load/auto-save/list/delete sessions to disk + - Setlist: ordered collection of sessions with transitions + - SnapshotEngine: instant scene recall during performance + +Sessions are stored as JSON files in ~/.config/rpi-mixer/sessions/. +Setlists are stored as JSON files in ~/.config/rpi-mixer/setlists/. +""" + +from .mixer_session import MixerSession, SessionMetadata +from .session_manager import SessionManager +from .setlist import Setlist, SetlistEntry, TransitionType +from .snapshot import SnapshotEngine + +__all__ = [ + "MixerSession", + "SessionMetadata", + "SessionManager", + "Setlist", + "SetlistEntry", + "TransitionType", + "SnapshotEngine", +] diff --git a/src/session/mixer_session.py b/src/session/mixer_session.py new file mode 100644 index 0000000..d8028a0 --- /dev/null +++ b/src/session/mixer_session.py @@ -0,0 +1,308 @@ +"""MixerSession — canonical JSON format for full mixer state. + +A MixerSession captures every parameter of the mixer in a versioned, +human-readable JSON format suitable for save/load, import/export, +and version control. + +Schema version 1 includes: + - Metadata: name, timestamps, notes, performance metadata + - Channels: all channel strip states (fader, EQ, comp, gate, FX sends) + - Master: master bus + aux buses + subgroups + VCA groups + - Routing: full routing matrix (nodes + edges) + - Plugins: Carla plugin chain configuration + - MIDI: MIDI mapping configuration + - Transport: transport state (tempo, position) + - Automation: fader automation lanes and scenes +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# Current schema version +SESSION_SCHEMA_VERSION = 1 + + +@dataclass +class SessionMetadata: + """Per-session notes and performance metadata.""" + + name: str = "Untitled Session" + created_at: float = field(default_factory=time.time) + modified_at: float = field(default_factory=time.time) + author: str = "" + notes: str = "" + # Performance metadata + tempo: float = 120.0 # BPM + key: str = "" # e.g. "C major", "Am" + time_signature: str = "4/4" + lyrics: str = "" + genre: str = "" + tags: list[str] = field(default_factory=list) + # Custom fields + custom: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "name": self.name, + "created_at": self.created_at, + "modified_at": self.modified_at, + "author": self.author, + "notes": self.notes, + "tempo": self.tempo, + "key": self.key, + "time_signature": self.time_signature, + "lyrics": self.lyrics, + "genre": self.genre, + "tags": self.tags, + "custom": self.custom, + } + + @classmethod + def from_dict(cls, data: dict) -> SessionMetadata: + return cls( + name=data.get("name", "Untitled Session"), + created_at=data.get("created_at", time.time()), + modified_at=data.get("modified_at", time.time()), + author=data.get("author", ""), + notes=data.get("notes", ""), + tempo=data.get("tempo", 120.0), + key=data.get("key", ""), + time_signature=data.get("time_signature", "4/4"), + lyrics=data.get("lyrics", ""), + genre=data.get("genre", ""), + tags=data.get("tags", []), + custom=data.get("custom", {}), + ) + + +@dataclass +class MixerSession: + """A complete mixer session — all parameters in one serializable object. + + This is the canonical on-disk format for mixer state. It is + designed to be human-readable JSON for easy editing, diffing, + and version control. + + Usage: + # Save + session = MixerSession.from_engine(engine, name="My Mix") + json_str = session.to_json() + Path("session.json").write_text(json_str) + + # Load + session = MixerSession.from_json(json_str) + session.apply_to(engine) + """ + + version: int = SESSION_SCHEMA_VERSION + metadata: SessionMetadata = field(default_factory=SessionMetadata) + # Mixer state + channels: list[dict] = field(default_factory=list) + master: dict = field(default_factory=dict) + routing: dict = field(default_factory=dict) + plugins: list[dict] = field(default_factory=list) + midi_mappings: list[dict] = field(default_factory=list) + transport: dict = field(default_factory=dict) + automation: dict = field(default_factory=dict) + # Additional state + settings: dict = field(default_factory=dict) + + # ── Serialization ────────────────────────────────────────────────── + + def to_dict(self) -> dict: + """Serialize to a plain dict.""" + return { + "version": self.version, + "metadata": self.metadata.to_dict(), + "channels": self.channels, + "master": self.master, + "routing": self.routing, + "plugins": self.plugins, + "midi_mappings": self.midi_mappings, + "transport": self.transport, + "automation": self.automation, + "settings": self.settings, + } + + def to_json(self, indent: int = 2) -> str: + """Serialize to a human-readable JSON string.""" + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + + @classmethod + def from_dict(cls, data: dict) -> MixerSession: + """Deserialize from a plain dict.""" + return cls( + version=data.get("version", SESSION_SCHEMA_VERSION), + metadata=SessionMetadata.from_dict(data.get("metadata", {})), + channels=data.get("channels", []), + master=data.get("master", {}), + routing=data.get("routing", {}), + plugins=data.get("plugins", []), + midi_mappings=data.get("midi_mappings", []), + transport=data.get("transport", {}), + automation=data.get("automation", {}), + settings=data.get("settings", {}), + ) + + @classmethod + def from_json(cls, json_str: str) -> MixerSession: + """Deserialize from a JSON string.""" + data = json.loads(json_str) + return cls.from_dict(data) + + @classmethod + def from_file(cls, path: Path) -> MixerSession: + """Load a session from a JSON file.""" + return cls.from_json(path.read_text(encoding="utf-8")) + + def save_to_file(self, path: Path) -> None: + """Save session to a JSON file.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self.to_json(), encoding="utf-8") + + # ── Engine integration ───────────────────────────────────────────── + + @classmethod + def from_engine(cls, engine: Any, name: str = "", metadata: Optional[SessionMetadata] = None) -> MixerSession: + """Capture the current state of a DSPEngine. + + Args: + engine: DSPEngine instance. + name: Session name (used if metadata is None). + metadata: Optional pre-built metadata. + + Returns: + A MixerSession capturing the full engine state. + """ + from ..mixer.dsp_engine import DSPEngine + + if metadata is None: + metadata = SessionMetadata(name=name or "Untitled Session") + + # Capture channel states + channels = [] + for i, ch in enumerate(engine.channels): + state_dict = dict(ch.state.__dict__) + channels.append({ + "channel": i, + "label": f"CH{i + 1}", + **{k: v for k, v in state_dict.items()}, + }) + + # Capture master/bus state + master = engine.buses.to_dict() + + # Capture routing + routing = engine.routing.to_dict() + + # Capture plugins (from Carla OSC client) + plugins_data = [] + for i, ch in enumerate(engine.channels): + for role, info in ch._plugins.items(): + plugins_data.append({ + "channel": i, + "role": role, + "plugin_id": info.plugin_id, + "name": info.name, + "param_map": dict(info.param_map), + }) + + # Capture transport state + transport = { + "playing": engine.automation.is_playing, + "recording": engine.automation.is_recording, + "tempo_bpm": 120.0, # default, could be read from MIDI clock + "position_sec": engine.automation.current_time, + } + + # Capture automation + automation = engine.automation.to_dict() + + return cls( + version=SESSION_SCHEMA_VERSION, + metadata=metadata, + channels=channels, + master=master, + routing=routing, + plugins=plugins_data, + midi_mappings=[], # populated separately if available + transport=transport, + automation=automation, + settings={ + "num_channels": engine.config.num_channels, + "num_aux": engine.config.num_aux, + "num_subgroups": engine.config.num_subgroups, + "num_vca": engine.config.num_vca, + "samplerate": engine.config.samplerate, + "buffer_size": engine.config.buffer_size, + }, + ) + + def apply_to(self, engine: Any) -> bool: + """Apply this session's state to a DSPEngine. + + Args: + engine: DSPEngine instance to restore state to. + + Returns: + True if all state was applied successfully. + """ + from ..mixer.channel_strip import ChannelState + + try: + # Restore channel states + for ch_data in self.channels: + ch_idx = ch_data.get("channel", 0) + if 0 <= ch_idx < len(engine.channels): + # Build ChannelState from dict, filtering only known fields + cs_fields = {f.name for f in ChannelState.__dataclass_fields__.values()} + state_dict = {k: v for k, v in ch_data.items() if k in cs_fields} + cs = ChannelState(**state_dict) + engine.channels[ch_idx].restore(cs, send_osc=True) + + # Restore bus state + engine.buses.from_dict(self.master) + + # Restore routing + if self.routing: + engine.routing.from_dict(self.routing) + if engine.config.jack_routing_enabled: + engine.routing.apply_full_matrix() + + # Restore automation + engine.automation.from_dict(self.automation) + + logger.info("Session applied: %s (%d channels)", + self.metadata.name, len(self.channels)) + return True + + except Exception as exc: + logger.error("Failed to apply session: %s", exc) + return False + + # ── Info ─────────────────────────────────────────────────────────── + + @property + def channel_count(self) -> int: + return len(self.channels) + + @property + def has_plugins(self) -> bool: + return len(self.plugins) > 0 + + @property + def has_midi_mappings(self) -> bool: + return len(self.midi_mappings) > 0 + + def __repr__(self) -> str: + return (f"MixerSession(name={self.metadata.name!r}, " + f"channels={self.channel_count}, " + f"v{self.version})") diff --git a/src/session/session_manager.py b/src/session/session_manager.py new file mode 100644 index 0000000..e3d76a5 --- /dev/null +++ b/src/session/session_manager.py @@ -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, + } diff --git a/src/session/setlist.py b/src/session/setlist.py new file mode 100644 index 0000000..6b1fd00 --- /dev/null +++ b/src/session/setlist.py @@ -0,0 +1,380 @@ +"""Setlist — ordered collection of mixer sessions with transitions. + +A setlist is a performance sequence: a list of sessions with +transition instructions between them (crossfade, hard cut, wait). + +Setlists are stored as JSON files in ~/.config/rpi-mixer/setlists/. + +Usage: + setlist = Setlist(name="Gig Night") + setlist.add_entry("Soundcheck", transition=TransitionType.CROSSFADE, duration=2.0) + setlist.add_entry("Set 1 - Rock") + setlist.add_entry("Set 2 - Jazz", transition=TransitionType.CROSSFADE) + setlist.save() +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +class TransitionType(StrEnum): + """How to transition between setlist entries.""" + + CUT = "cut" # Instant switch (default) + CROSSFADE = "crossfade" # Smooth crossfade over duration + WAIT = "wait" # Wait for manual trigger + AUTO = "auto" # Auto-advance after session playback + + +@dataclass +class SetlistEntry: + """One entry in a setlist — references a session with transition info.""" + + session_name: str + display_name: str = "" + transition: TransitionType = TransitionType.CUT + transition_duration: float = 2.0 # seconds (for crossfade) + wait_for_trigger: bool = False # if True, pause before this entry + notes: str = "" + # Runtime state (not persisted) + _loaded: bool = field(default=False, repr=False) + + def __post_init__(self): + if not self.display_name: + self.display_name = self.session_name + + def to_dict(self) -> dict: + return { + "session_name": self.session_name, + "display_name": self.display_name, + "transition": self.transition.value, + "transition_duration": self.transition_duration, + "wait_for_trigger": self.wait_for_trigger, + "notes": self.notes, + } + + @classmethod + def from_dict(cls, data: dict) -> SetlistEntry: + return cls( + session_name=data.get("session_name", ""), + display_name=data.get("display_name", ""), + transition=TransitionType(data.get("transition", "cut")), + transition_duration=data.get("transition_duration", 2.0), + wait_for_trigger=data.get("wait_for_trigger", False), + notes=data.get("notes", ""), + ) + + +@dataclass +class Setlist: + """An ordered collection of mixer sessions for performance. + + A setlist defines the sequence of sessions to load during a + performance, with transition instructions between each. + + Usage: + sl = Setlist(name="Gig Night") + sl.add_entry("Soundcheck") + sl.add_entry("Set 1") + sl.activate(session_manager) # Load first entry + sl.next(session_manager, engine) # Advance to next + """ + + name: str = "Untitled Setlist" + entries: list[SetlistEntry] = field(default_factory=list) + created_at: float = field(default_factory=time.time) + modified_at: float = field(default_factory=time.time) + description: str = "" + # Runtime state + _current_index: int = field(default=-1, repr=False) + _active: bool = field(default=False, repr=False) + + def add_entry( + self, + session_name: str, + display_name: str = "", + transition: TransitionType = TransitionType.CUT, + transition_duration: float = 2.0, + wait_for_trigger: bool = False, + notes: str = "", + ) -> SetlistEntry: + """Add an entry to the setlist. + + Args: + session_name: Name of the session to load. + display_name: Optional display name (defaults to session_name). + transition: How to transition to this entry. + transition_duration: Crossfade duration in seconds. + wait_for_trigger: If True, pause before this entry. + notes: Optional notes for this entry. + + Returns: + The created SetlistEntry. + """ + entry = SetlistEntry( + session_name=session_name, + display_name=display_name, + transition=transition, + transition_duration=transition_duration, + wait_for_trigger=wait_for_trigger, + notes=notes, + ) + self.entries.append(entry) + self.modified_at = time.time() + return entry + + def remove_entry(self, index: int) -> bool: + """Remove an entry by index.""" + if 0 <= index < len(self.entries): + self.entries.pop(index) + self.modified_at = time.time() + # Adjust current index if needed + if self._current_index >= len(self.entries): + self._current_index = len(self.entries) - 1 + return True + return False + + def move_entry(self, from_idx: int, to_idx: int) -> bool: + """Move an entry to a new position.""" + if 0 <= from_idx < len(self.entries) and 0 <= to_idx < len(self.entries): + entry = self.entries.pop(from_idx) + self.entries.insert(to_idx, entry) + self.modified_at = time.time() + return True + return False + + def clear(self) -> None: + """Remove all entries.""" + self.entries.clear() + self._current_index = -1 + self._active = False + self.modified_at = time.time() + + # ── Playback ──────────────────────────────────────────────────────── + + def activate( + self, + session_manager: Any, + engine: Any, + start_index: int = 0, + ) -> bool: + """Activate the setlist and load the first session. + + Args: + session_manager: SessionManager instance. + engine: DSPEngine instance. + start_index: Index to start at (default 0). + + Returns: + True if activated successfully. + """ + if not self.entries: + logger.warning("Cannot activate empty setlist: %s", self.name) + return False + + if not (0 <= start_index < len(self.entries)): + start_index = 0 + + entry = self.entries[start_index] + ok = session_manager.load_and_apply(engine, entry.session_name) + if ok: + self._current_index = start_index + self._active = True + entry._loaded = True + logger.info("Setlist activated: %s → entry %d: %s", + self.name, start_index, entry.session_name) + else: + logger.error("Failed to load session for setlist entry: %s", + entry.session_name) + return ok + + def next( + self, + session_manager: Any, + engine: Any, + ) -> tuple[bool, Optional[SetlistEntry], Optional[SetlistEntry]]: + """Advance to the next setlist entry. + + Returns: + (success, previous_entry, next_entry) — previous_entry is + the entry that was active, next_entry is the new one. + """ + if not self._active or not self.entries: + return (False, None, None) + + prev_idx = self._current_index + next_idx = prev_idx + 1 + + if next_idx >= len(self.entries): + # End of setlist + self._active = False + logger.info("Setlist complete: %s", self.name) + return (True, self.entries[prev_idx] if prev_idx >= 0 else None, None) + + prev_entry = self.entries[prev_idx] if prev_idx >= 0 else None + next_entry = self.entries[next_idx] + + # Apply transition + if next_entry.transition == TransitionType.CROSSFADE: + # Crossfade to the next session + session_manager.load_and_apply(engine, next_entry.session_name) + # Note: crossfade is handled at the DSP engine level via + # engine.crossfade_to() — but for session loading we just + # load the session immediately since crossfade applies to + # parameter automation, not session loading. + else: + # Hard cut or wait + session_manager.load_and_apply(engine, next_entry.session_name) + + self._current_index = next_idx + if prev_entry: + prev_entry._loaded = False + next_entry._loaded = True + + logger.info("Setlist advance: %s → %s (transition=%s)", + prev_entry.session_name if prev_entry else "start", + next_entry.session_name, + next_entry.transition.value) + + return (True, prev_entry, next_entry) + + def previous( + self, + session_manager: Any, + engine: Any, + ) -> tuple[bool, Optional[SetlistEntry], Optional[SetlistEntry]]: + """Go to the previous setlist entry.""" + if not self._active or not self.entries: + return (False, None, None) + + prev_idx = self._current_index + new_idx = prev_idx - 1 + + if new_idx < 0: + return (False, self.entries[prev_idx] if prev_idx >= 0 else None, None) + + prev_entry = self.entries[prev_idx] if prev_idx >= 0 else None + new_entry = self.entries[new_idx] + + session_manager.load_and_apply(engine, new_entry.session_name) + + self._current_index = new_idx + if prev_entry: + prev_entry._loaded = False + new_entry._loaded = True + + return (True, prev_entry, new_entry) + + def go_to( + self, + index: int, + session_manager: Any, + engine: Any, + ) -> bool: + """Jump to a specific entry by index.""" + if not (0 <= index < len(self.entries)): + return False + + entry = self.entries[index] + ok = session_manager.load_and_apply(engine, entry.session_name) + if ok: + # Unload previous + if self._current_index >= 0 and self._current_index < len(self.entries): + self.entries[self._current_index]._loaded = False + self._current_index = index + entry._loaded = True + return ok + + def stop(self) -> None: + """Deactivate the setlist.""" + self._active = False + if self._current_index >= 0 and self._current_index < len(self.entries): + self.entries[self._current_index]._loaded = False + self._current_index = -1 + + @property + def current_entry(self) -> Optional[SetlistEntry]: + if 0 <= self._current_index < len(self.entries): + return self.entries[self._current_index] + return None + + @property + def is_active(self) -> bool: + return self._active + + @property + def entry_count(self) -> int: + return len(self.entries) + + @property + def progress(self) -> tuple[int, int]: + """Returns (current_index + 1, total_entries).""" + return (self._current_index + 1, len(self.entries)) + + @property + def has_next(self) -> bool: + return self._active and self._current_index + 1 < len(self.entries) + + @property + def has_previous(self) -> bool: + return self._active and self._current_index > 0 + + # ── Serialization ─────────────────────────────────────────────────── + + def to_dict(self) -> dict: + return { + "name": self.name, + "entries": [e.to_dict() for e in self.entries], + "created_at": self.created_at, + "modified_at": self.modified_at, + "description": self.description, + } + + def to_json(self, indent: int = 2) -> str: + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + + @classmethod + def from_dict(cls, data: dict) -> Setlist: + return cls( + name=data.get("name", "Untitled Setlist"), + entries=[SetlistEntry.from_dict(e) for e in data.get("entries", [])], + created_at=data.get("created_at", time.time()), + modified_at=data.get("modified_at", time.time()), + description=data.get("description", ""), + ) + + @classmethod + def from_json(cls, json_str: str) -> Setlist: + return cls.from_dict(json.loads(json_str)) + + # ── File I/O ──────────────────────────────────────────────────────── + + def save_to(self, directory: Path) -> Path: + """Save the setlist to a JSON file in the given directory. + + Returns: + Path to the saved file. + """ + import re + safe_name = re.sub(r"[^\w\s.\-]", "_", self.name).strip().replace(" ", "_") + filepath = directory / f"{safe_name}.json" + directory.mkdir(parents=True, exist_ok=True) + filepath.write_text(self.to_json(), encoding="utf-8") + return filepath + + @classmethod + def load_from(cls, filepath: Path) -> Setlist: + """Load a setlist from a JSON file.""" + return cls.from_json(filepath.read_text(encoding="utf-8")) + + def __repr__(self) -> str: + return f"Setlist(name={self.name!r}, entries={len(self.entries)})" diff --git a/src/session/snapshot.py b/src/session/snapshot.py new file mode 100644 index 0000000..b8f66cd --- /dev/null +++ b/src/session/snapshot.py @@ -0,0 +1,420 @@ +"""Snapshot engine — instant scene recall with disk persistence. + +Extends the DSP engine's FaderAutomation Scene system with: + - Persistent storage to disk (alongside session files) + - Named snapshots with metadata + - Instant recall (no crossfade) for live performance + - MIDI-triggerable snapshot switching + - Snapshot chaining (next/prev) + +Snapshots are lighter than full sessions — they store only the +parameter state needed for instant recall during a performance. +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class SnapshotMetadata: + """Metadata for a named snapshot.""" + + name: str + created_at: float = field(default_factory=time.time) + notes: str = "" + category: str = "" # e.g. "intro", "verse", "chorus", "bridge" + color: str = "" # hex color for UI display + + +@dataclass +class Snapshot: + """A lightweight parameter snapshot for instant recall. + + Unlike a full MixerSession, a Snapshot only stores mutable + parameter values (channels, master, routing) without plugin + config or MIDI mappings. Designed for sub-millisecond recall + during live performance. + """ + + metadata: SnapshotMetadata + # Parameter state + channels: dict[int, dict] = field(default_factory=dict) # ch_idx → state dict + master: dict = field(default_factory=dict) + routing: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "metadata": { + "name": self.metadata.name, + "created_at": self.metadata.created_at, + "notes": self.metadata.notes, + "category": self.metadata.category, + "color": self.metadata.color, + }, + "channels": {str(k): v for k, v in self.channels.items()}, + "master": self.master, + "routing": self.routing, + } + + @classmethod + def from_dict(cls, data: dict) -> Snapshot: + meta = data.get("metadata", {}) + return cls( + metadata=SnapshotMetadata( + name=meta.get("name", "Untitled"), + created_at=meta.get("created_at", time.time()), + notes=meta.get("notes", ""), + category=meta.get("category", ""), + color=meta.get("color", ""), + ), + channels={int(k): v for k, v in data.get("channels", {}).items()}, + master=data.get("master", {}), + routing=data.get("routing", {}), + ) + + @classmethod + def from_engine(cls, engine: Any, name: str = "", notes: str = "", + category: str = "", color: str = "") -> Snapshot: + """Capture current engine state as a snapshot.""" + channels = {} + for i, ch in enumerate(engine.channels): + channels[i] = dict(ch.state.__dict__) + + return cls( + metadata=SnapshotMetadata( + name=name or f"Snapshot {time.strftime('%H:%M:%S')}", + notes=notes, + category=category, + color=color, + ), + channels=channels, + master=engine.buses.to_dict(), + routing=engine.routing.to_dict(), + ) + + def apply_to(self, engine: Any) -> bool: + """Apply this snapshot to a DSP engine instantly.""" + from ..mixer.channel_strip import ChannelState + + try: + # Restore channels + for ch_idx, state_dict in self.channels.items(): + if 0 <= ch_idx < len(engine.channels): + cs = ChannelState(**state_dict) + engine.channels[ch_idx].restore(cs, send_osc=True) + + # Restore master/buses + engine.buses.from_dict(self.master) + + # Restore routing + if self.routing: + engine.routing.from_dict(self.routing) + if engine.config.jack_routing_enabled: + engine.routing.apply_full_matrix() + + return True + except Exception as exc: + logger.error("Snapshot apply failed: %s", exc) + return False + + +class SnapshotEngine: + """Manages snapshots for instant scene recall during performance. + + Snapshots are stored in memory for fast access and can be + persisted to disk. Supports chaining (next/prev), MIDI trigger, + and categories for organizing performance sections. + + Usage: + snap = SnapshotEngine() + snap.capture(engine, "Intro") + snap.capture(engine, "Verse") + snap.recall(engine, "Intro") + snap.next(engine) + """ + + def __init__(self, storage_dir: Optional[Path] = None): + self._snapshots: dict[str, Snapshot] = {} + self._ordered_names: list[str] = [] # capture order + self._current_name: Optional[str] = None + self._storage_dir = storage_dir + + if self._storage_dir: + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._load_all() + + # ── Capture / Recall ──────────────────────────────────────────────── + + def capture( + self, + engine: Any, + name: str, + notes: str = "", + category: str = "", + color: str = "", + overwrite: bool = True, + ) -> Snapshot: + """Capture current engine state as a named snapshot. + + Args: + engine: DSPEngine instance. + name: Snapshot name. + notes: Optional notes. + category: Optional category (e.g., "intro", "verse"). + color: Optional hex color. + overwrite: If False, raises KeyError on duplicate name. + + Returns: + The created Snapshot. + """ + if not overwrite and name in self._snapshots: + raise KeyError(f"Snapshot already exists: {name}") + + snapshot = Snapshot.from_engine( + engine, name=name, notes=notes, + category=category, color=color, + ) + + is_new = name not in self._snapshots + self._snapshots[name] = snapshot + + if is_new: + self._ordered_names.append(name) + + self._current_name = name + + # Persist to disk if storage is configured + if self._storage_dir: + self._save_to_disk(name, snapshot) + + logger.info("Snapshot captured: %s (%d channels)", name, len(snapshot.channels)) + return snapshot + + def recall(self, engine: Any, name: str) -> bool: + """Recall a snapshot instantly. + + Args: + engine: DSPEngine instance. + name: Snapshot name to recall. + + Returns: + True if recalled successfully. + """ + snapshot = self._snapshots.get(name) + if snapshot is None: + # Try loading from disk + if self._storage_dir: + snapshot = self._load_from_disk(name) + if snapshot: + self._snapshots[name] = snapshot + else: + logger.warning("Snapshot not found: %s", name) + return False + else: + logger.warning("Snapshot not found: %s", name) + return False + + ok = snapshot.apply_to(engine) + if ok: + self._current_name = name + logger.info("Snapshot recalled: %s", name) + return ok + + def next(self, engine: Any) -> tuple[bool, Optional[str]]: + """Recall the next snapshot (alphabetically by name). + + Returns: + (success, snapshot_name). + """ + names = sorted(self._snapshots.keys()) + if not names: + return (False, None) + + if self._current_name is None or self._current_name not in names: + target = names[0] + else: + idx = names.index(self._current_name) + target = names[(idx + 1) % len(names)] + + ok = self.recall(engine, target) + return (ok, target if ok else None) + + def previous(self, engine: Any) -> tuple[bool, Optional[str]]: + """Recall the previous snapshot. + + Returns: + (success, snapshot_name). + """ + names = sorted(self._snapshots.keys()) + if not names: + return (False, None) + + if self._current_name is None or self._current_name not in names: + target = names[-1] + else: + idx = names.index(self._current_name) + target = names[(idx - 1) % len(names)] + + ok = self.recall(engine, target) + return (ok, target if ok else None) + + def go_to(self, engine: Any, index: int) -> bool: + """Recall a snapshot by its capture order index.""" + if 0 <= index < len(self._ordered_names): + return self.recall(engine, self._ordered_names[index]) + return False + + # ── Management ────────────────────────────────────────────────────── + + def delete(self, name: str) -> bool: + """Delete a snapshot.""" + if name not in self._snapshots: + return False + del self._snapshots[name] + if name in self._ordered_names: + self._ordered_names.remove(name) + if self._current_name == name: + self._current_name = None + # Delete from disk + if self._storage_dir: + filepath = self._storage_dir / f"{name}.json" + try: + filepath.unlink() + except OSError: + pass + return True + + def rename(self, old_name: str, new_name: str) -> bool: + """Rename a snapshot.""" + if old_name not in self._snapshots or new_name in self._snapshots: + return False + + snapshot = self._snapshots.pop(old_name) + snapshot.metadata.name = new_name + self._snapshots[new_name] = snapshot + + if old_name in self._ordered_names: + idx = self._ordered_names.index(old_name) + self._ordered_names[idx] = new_name + + if self._current_name == old_name: + self._current_name = new_name + + # Update disk + if self._storage_dir: + old_path = self._storage_dir / f"{old_name}.json" + try: + old_path.unlink() + except OSError: + pass + self._save_to_disk(new_name, snapshot) + + return True + + def clear(self) -> None: + """Delete all snapshots.""" + self._snapshots.clear() + self._ordered_names.clear() + self._current_name = None + if self._storage_dir: + for f in self._storage_dir.glob("*.json"): + try: + f.unlink() + except OSError: + pass + + def list_snapshots(self) -> list[dict]: + """List all snapshots with metadata.""" + result = [] + for name, snap in self._snapshots.items(): + result.append({ + "name": name, + "created_at": snap.metadata.created_at, + "notes": snap.metadata.notes, + "category": snap.metadata.category, + "color": snap.metadata.color, + "channel_count": len(snap.channels), + "is_current": name == self._current_name, + }) + return sorted(result, key=lambda x: x["name"]) + + def get_by_category(self, category: str) -> list[Snapshot]: + """Get all snapshots in a category.""" + return [s for s in self._snapshots.values() + if s.metadata.category == category] + + # ── Properties ────────────────────────────────────────────────────── + + @property + def current_name(self) -> Optional[str]: + return self._current_name + + @property + def snapshot_count(self) -> int: + return len(self._snapshots) + + @property + def names(self) -> list[str]: + return sorted(self._snapshots.keys()) + + @property + def categories(self) -> list[str]: + cats = {s.metadata.category for s in self._snapshots.values() + if s.metadata.category} + return sorted(cats) + + # ── Disk persistence ──────────────────────────────────────────────── + + def _save_to_disk(self, name: str, snapshot: Snapshot) -> None: + """Persist a snapshot to disk.""" + if not self._storage_dir: + return + filepath = self._storage_dir / f"{name}.json" + filepath.write_text( + json.dumps(snapshot.to_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + def _load_from_disk(self, name: str) -> Optional[Snapshot]: + """Load a snapshot from disk.""" + if not self._storage_dir: + return None + filepath = self._storage_dir / f"{name}.json" + if not filepath.exists(): + return None + try: + data = json.loads(filepath.read_text(encoding="utf-8")) + return Snapshot.from_dict(data) + except (json.JSONDecodeError, KeyError) as exc: + logger.warning("Failed to load snapshot from disk: %s — %s", name, exc) + return None + + def _load_all(self) -> None: + """Load all snapshots from disk.""" + if not self._storage_dir or not self._storage_dir.exists(): + return + for filepath in sorted(self._storage_dir.glob("*.json")): + try: + data = json.loads(filepath.read_text(encoding="utf-8")) + snapshot = Snapshot.from_dict(data) + name = snapshot.metadata.name + self._snapshots[name] = snapshot + self._ordered_names.append(name) + except (json.JSONDecodeError, KeyError): + continue + logger.info("Loaded %d snapshots from disk", len(self._snapshots)) + + def to_dict(self) -> dict: + return { + "snapshots": {k: v.to_dict() for k, v in self._snapshots.items()}, + "ordered_names": self._ordered_names, + "current_name": self._current_name, + } diff --git a/src/streaming/__init__.py b/src/streaming/__init__.py new file mode 100644 index 0000000..8c50346 --- /dev/null +++ b/src/streaming/__init__.py @@ -0,0 +1,96 @@ +"""Audio/video streaming pipeline for the RPi Audio Mixer. + +Streams the mixer master mix + camera video to RTMP platforms +(YouTube, Twitch, custom RTMP servers) using GStreamer with +hardware-accelerated h264 encoding on Raspberry Pi 4B. + +Modules: + - camera: camera detection (USB webcam, Pi Camera Module) + - platforms: streaming platform profiles and RTMP URLs + - gst_pipeline: GStreamer pipeline string builder + - streamer: Streamer lifecycle manager (start/stop/status) + - controls: keyboard shortcut handler for stream control + +Architecture: + Audio: JACK system output (master mix) → alsasink bridge → GStreamer + Video: v4l2src (USB) or libcamerasrc (Pi Cam) → h264 hardware encoder + Output: RTMP sink → streaming platform + +Integration: + - REST API: /api/v1/stream/* endpoints for start/stop/status/scenes + - WebSocket: real-time stream status updates + - Keyboard: global hotkeys for stream control +""" + +from __future__ import annotations + +from .camera import ( + CameraInfo, + CameraType, + detect_cameras, + get_default_camera, +) + +from .platforms import ( + PlatformProfile, + YOUTUBE_RTMP, + TWITCH_RTMP, + CUSTOM_RTMP, + PRESET_PLATFORMS, + get_platform_profile, +) + +from .gst_pipeline import ( + GstPipelineBuilder, + VideoSource, + AudioSource, + EncoderType, + build_pipeline_string, +) + +from .streamer import ( + Streamer, + StreamerConfig, + StreamState, + StreamScene, + StreamStats, + create_streamer, +) + +from .controls import ( + StreamKeyboardController, + StreamHotkey, + start_keyboard_controller, +) + +__all__ = [ + # Camera + "CameraInfo", + "CameraType", + "detect_cameras", + "get_default_camera", + # Platforms + "PlatformProfile", + "YOUTUBE_RTMP", + "TWITCH_RTMP", + "CUSTOM_RTMP", + "PRESET_PLATFORMS", + "get_platform_profile", + # Pipeline + "GstPipelineBuilder", + "VideoSource", + "AudioSource", + "EncoderType", + "build_pipeline_string", + # Streamer + "Streamer", + "StreamerConfig", + "StreamState", + "StreamScene", + "StreamStats", + "create_streamer", + # Controls + "StreamKeyboardController", + "StreamHotkey", + "start_keyboard_controller", +] diff --git a/src/streaming/camera.py b/src/streaming/camera.py new file mode 100644 index 0000000..1159abe --- /dev/null +++ b/src/streaming/camera.py @@ -0,0 +1,398 @@ +"""Camera detection and configuration for the streaming pipeline. + +Detects available cameras on Raspberry Pi 4B: + - USB webcams (v4l2, /dev/video*) + - Raspberry Pi Camera Module (libcamera, /dev/video* via bcm2835-unicam) + +Returns CameraInfo objects with device paths, supported resolutions, +and frame rates for use in GStreamer pipeline construction. +""" + +from __future__ import annotations + +import logging +import os +import re +import subprocess +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class CameraType(StrEnum): + """Camera hardware type.""" + USB_WEBCAM = "usb_webcam" + PI_CAMERA = "pi_camera" + PI_CAMERA_LEGACY = "pi_camera_legacy" # legacy raspicam stack + UNKNOWN = "unknown" + + +@dataclass +class CameraResolution: + """Supported resolution for a camera.""" + width: int + height: int + fps: float = 30.0 + + +@dataclass +class CameraInfo: + """Information about a detected camera.""" + device_path: str # e.g. "/dev/video0" + camera_type: CameraType + name: str = "" + driver: str = "" + bus_info: str = "" + resolutions: list[CameraResolution] = field(default_factory=list) + pixel_formats: list[str] = field(default_factory=list) + + @property + def supports_h264(self) -> bool: + """Check if the camera supports hardware h264 encoding.""" + return "H264" in self.pixel_formats or "h264" in self.pixel_formats + + @property + def supports_mjpeg(self) -> bool: + """Check if the camera supports MJPEG output.""" + return "MJPG" in self.pixel_formats or "mjpeg" in self.pixel_formats + + @property + def best_resolution(self) -> Optional[CameraResolution]: + """Get the highest resolution available.""" + if not self.resolutions: + return None + return max(self.resolutions, key=lambda r: r.width * r.height) + + def to_dict(self) -> dict: + return { + "device_path": self.device_path, + "camera_type": self.camera_type.value, + "name": self.name, + "driver": self.driver, + "bus_info": self.bus_info, + "resolutions": [ + {"width": r.width, "height": r.height, "fps": r.fps} + for r in self.resolutions + ], + "pixel_formats": self.pixel_formats, + } + + +# ── Detection ──────────────────────────────────────────────────────────────── + + +def detect_cameras() -> list[CameraInfo]: + """Detect all available cameras on the system. + + Checks for: + 1. Pi Camera Module (via libcamera or bcm2835-unicam driver) + 2. USB webcams (via v4l2 /dev/video* devices) + + Returns a list of CameraInfo objects, best candidate first. + """ + cameras: list[CameraInfo] = [] + + # Check for Pi Camera Module + pi_cams = _detect_pi_camera() + cameras.extend(pi_cams) + + # Check for USB webcams + usb_cams = _detect_usb_webcams() + cameras.extend(usb_cams) + + # Sort: Pi Camera first (better performance on RPi), then by name + def _sort_key(cam: CameraInfo) -> tuple[int, str]: + type_order = { + CameraType.PI_CAMERA: 0, + CameraType.PI_CAMERA_LEGACY: 1, + CameraType.USB_WEBCAM: 2, + CameraType.UNKNOWN: 3, + } + return (type_order.get(cam.camera_type, 99), cam.name) + + cameras.sort(key=_sort_key) + return cameras + + +def get_default_camera() -> Optional[CameraInfo]: + """Get the best available camera for streaming.""" + cameras = detect_cameras() + return cameras[0] if cameras else None + + +def _detect_pi_camera() -> list[CameraInfo]: + """Detect Raspberry Pi Camera Module (libcamera or legacy).""" + cameras: list[CameraInfo] = [] + + # Check for libcamera support (modern Pi camera stack) + try: + result = subprocess.run( + ["libcamera-hello", "--list-cameras"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + # Parse output for camera info + info = _parse_libcamera_output(result.stdout) + if info: + cameras.append(info) + + # Also check for libcamera devices in /dev + for dev in sorted(os.listdir("/dev")): + if dev.startswith("video"): + devpath = f"/dev/{dev}" + driver = _get_v4l2_driver(devpath) + if driver and "unicam" in driver.lower(): + if not any(c.device_path == devpath for c in cameras): + cam = CameraInfo( + device_path=devpath, + camera_type=CameraType.PI_CAMERA, + name=f"Pi Camera ({dev})", + driver=driver, + ) + _enrich_v4l2_info(cam) + cameras.append(cam) + except FileNotFoundError: + logger.debug("libcamera-hello not found (not on a Pi or libcamera not installed)") + + # Check for legacy raspicam + try: + if os.path.exists("/dev/vchiq"): + # Legacy Pi camera stack devices + for dev in sorted(os.listdir("/dev")): + if dev.startswith("video"): + devpath = f"/dev/{dev}" + driver = _get_v4l2_driver(devpath) + if driver and "bcm2835" in driver.lower(): + cam = CameraInfo( + device_path=devpath, + camera_type=CameraType.PI_CAMERA_LEGACY, + name=f"Pi Camera Legacy ({dev})", + driver=driver, + ) + _enrich_v4l2_info(cam) + cameras.append(cam) + except Exception: + pass + + return cameras + + +def _detect_usb_webcams() -> list[CameraInfo]: + """Detect USB webcams via v4l2 device enumeration.""" + cameras: list[CameraInfo] = [] + + # Try using v4l2-ctl + try: + result = subprocess.run( + ["v4l2-ctl", "--list-devices"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + cameras.extend(_parse_v4l2_list(result.stdout)) + return cameras + except FileNotFoundError: + logger.debug("v4l2-ctl not found, falling back to manual detection") + + # Fallback: scan /dev/video* devices manually + for dev in sorted(os.listdir("/dev")): + if re.match(r"^video\d+$", dev): + devpath = f"/dev/{dev}" + driver = _get_v4l2_driver(devpath) + if driver and "unicam" not in driver.lower() and "bcm2835" not in driver.lower(): + cam = CameraInfo( + device_path=devpath, + camera_type=CameraType.USB_WEBCAM, + name=_get_v4l2_name(devpath) or f"USB Camera ({dev})", + driver=driver, + ) + _enrich_v4l2_info(cam) + cameras.append(cam) + + return cameras + + +# ── v4l2 helpers ───────────────────────────────────────────────────────────── + + +def _get_v4l2_driver(device: str) -> str: + """Get the driver name for a v4l2 device.""" + try: + result = subprocess.run( + ["v4l2-ctl", "-d", device, "--info"], + capture_output=True, text=True, timeout=3, + ) + for line in result.stdout.splitlines(): + if "Driver name" in line: + return line.split(":", 1)[1].strip() + except Exception: + pass + + # Try reading from sysfs + try: + devname = os.path.basename(device) + uevent = f"/sys/class/video4linux/{devname}/device/uevent" + if os.path.exists(uevent): + with open(uevent) as f: + for line in f: + if line.startswith("DRIVER="): + return line.split("=", 1)[1].strip() + except Exception: + pass + + return "" + + +def _get_v4l2_name(device: str) -> str: + """Get the card name for a v4l2 device.""" + try: + result = subprocess.run( + ["v4l2-ctl", "-d", device, "--info"], + capture_output=True, text=True, timeout=3, + ) + for line in result.stdout.splitlines(): + if "Card type" in line: + return line.split(":", 1)[1].strip() + except Exception: + pass + return "" + + +def _enrich_v4l2_info(cam: CameraInfo) -> None: + """Add resolution and format info to a CameraInfo via v4l2-ctl.""" + try: + # Get supported formats + result = subprocess.run( + ["v4l2-ctl", "-d", cam.device_path, "--list-formats-ext"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + _parse_v4l2_formats(result.stdout, cam) + except Exception as exc: + logger.debug("Failed to get v4l2 formats for %s: %s", cam.device_path, exc) + + +def _parse_v4l2_list(output: str) -> list[CameraInfo]: + """Parse v4l2-ctl --list-devices output.""" + cameras: list[CameraInfo] = [] + current_name = "" + current_driver = "" + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + + # Device name line (e.g. "USB Camera: USB Camera (usb-0000:01:00.0-1.2):") + if "(" in line and not line.startswith("/dev/"): + # Extract name and bus info + match = re.match(r"^(.+?)\s*\((.+?)\):", line) + if match: + current_name = match.group(1).strip() + current_driver = match.group(2).strip() + else: + current_name = line.rstrip(":") + elif line.startswith("/dev/video"): + devpath = line.strip() + cam = CameraInfo( + device_path=devpath, + camera_type=_classify_camera(current_name, current_driver), + name=current_name, + driver=current_driver, + ) + _enrich_v4l2_info(cam) + cameras.append(cam) + + return cameras + + +def _parse_v4l2_formats(output: str, cam: CameraInfo) -> None: + """Parse v4l2-ctl --list-formats-ext output.""" + resolutions: list[CameraResolution] = [] + pixel_formats: list[str] = [] + current_format = "" + current_size = "" + + for line in output.splitlines(): + line = line.strip() + + # Format line: "[0]: 'YUYV' (YUYV 4:2:2)" + if line.startswith("[") and "'" in line: + match = re.search(r"'(\w+)'", line) + if match: + current_format = match.group(1) + if current_format not in pixel_formats: + pixel_formats.append(current_format) + + # Size line: "Size: Discrete 640x480" + elif line.startswith("Size:"): + match = re.search(r"(\d+)x(\d+)", line) + if match: + current_size = f"{match.group(1)}x{match.group(2)}" + w, h = int(match.group(1)), int(match.group(2)) + resolutions.append(CameraResolution(width=w, height=h, fps=30.0)) + + # FPS line: "Interval: Discrete 0.033s (30.000 fps)" + elif line.startswith("Interval:") and resolutions: + match = re.search(r"\((\d+\.?\d*)\s*fps\)", line) + if match: + fps = float(match.group(1)) + resolutions[-1].fps = min(resolutions[-1].fps, fps) + + cam.resolutions = resolutions + cam.pixel_formats = pixel_formats + + +def _parse_libcamera_output(output: str) -> Optional[CameraInfo]: + """Parse libcamera-hello --list-cameras output.""" + # libcamera output format: + # Available cameras + # ---------------- + # 0 : imx219 [3280x2464] (/base/soc/i2c0mux/i2c@1/imx219@10) + # Modes: 'SRGGB10_CSI2P' : 640x480, 1640x1232, 1920x1080, 3280x2464 + # 'SRGGB8' : 640x480, 1640x1232, 1920x1080, 3280x2464 + + lines = output.splitlines() + for i, line in enumerate(lines): + match = re.match(r"^\d+\s*:\s*(\w+)\s*\[(\d+)x(\d+)\]", line) + if match: + sensor = match.group(1) + width = int(match.group(2)) + height = int(match.group(3)) + + cam = CameraInfo( + device_path="/dev/video0", # libcamera abstracts device path + camera_type=CameraType.PI_CAMERA, + name=f"Pi Camera ({sensor})", + driver="unicam", + ) + cam.resolutions.append(CameraResolution(width=width, height=height, fps=30.0)) + + # Parse modes from following lines + for mode_line in lines[i+1:]: + mode_line = mode_line.strip() + if mode_line.startswith("Modes:") or mode_line.startswith("'"): + for res_match in re.finditer(r"(\d+)x(\d+)", mode_line): + w, h = int(res_match.group(1)), int(res_match.group(2)) + existing = {(r.width, r.height) for r in cam.resolutions} + if (w, h) not in existing: + cam.resolutions.append(CameraResolution(width=w, height=h, fps=30.0)) + elif mode_line and not mode_line.startswith("'"): + break + + return cam + + return None + + +def _classify_camera(name: str, driver: str) -> CameraType: + """Classify camera type from name and driver info.""" + combined = f"{name} {driver}".lower() + if "pi camera" in combined or "imx" in combined or "ov5647" in combined: + return CameraType.PI_CAMERA + if "bcm2835" in combined or "mmal" in combined: + return CameraType.PI_CAMERA_LEGACY + if "usb" in combined or "webcam" in combined or "uvc" in combined: + return CameraType.USB_WEBCAM + return CameraType.USB_WEBCAM # default assumption diff --git a/src/streaming/controls.py b/src/streaming/controls.py new file mode 100644 index 0000000..2989904 --- /dev/null +++ b/src/streaming/controls.py @@ -0,0 +1,384 @@ +"""Keyboard shortcut handler for stream control. + +Provides global keyboard shortcuts for controlling the streaming +pipeline without needing the web UI or touchscreen: + + Ctrl+Shift+S — Start/stop stream + Ctrl+Shift+1..9 — Switch to scene 1-9 + Ctrl+Shift+R — Force reconnect + +On Linux, uses evdev for direct keyboard input (works without +a window manager, ideal for headless RPi setups). + +On macOS/Windows, uses pynput as fallback. + +The controller runs a background thread that listens for key +combinations and dispatches them to the Streamer. +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass +from enum import StrEnum +from typing import Optional, Callable + +logger = logging.getLogger(__name__) + + +class StreamHotkey(StrEnum): + """Stream control hotkey actions.""" + START_STOP = "start_stop" # Toggle streaming + SCENE_1 = "scene_1" + SCENE_2 = "scene_2" + SCENE_3 = "scene_3" + SCENE_4 = "scene_4" + SCENE_5 = "scene_5" + SCENE_6 = "scene_6" + SCENE_7 = "scene_7" + SCENE_8 = "scene_8" + SCENE_9 = "scene_9" + RECONNECT = "reconnect" # Force reconnect + MUTE_VIDEO = "mute_video" # Toggle video (audio only) + MUTE_AUDIO = "mute_audio" # Toggle audio (video only) + + +@dataclass +class HotkeyMapping: + """Maps a key combination to a stream action.""" + hotkey: StreamHotkey + modifiers: set[str] # e.g. {"ctrl", "shift"} + key: str # e.g. "s", "1", "r" + description: str = "" + + +# Default hotkey mappings for the streaming controller +DEFAULT_HOTKEYS: list[HotkeyMapping] = [ + HotkeyMapping(StreamHotkey.START_STOP, {"ctrl", "shift"}, "s", "Start/Stop streaming"), + HotkeyMapping(StreamHotkey.SCENE_1, {"ctrl", "shift"}, "1", "Switch to scene 1"), + HotkeyMapping(StreamHotkey.SCENE_2, {"ctrl", "shift"}, "2", "Switch to scene 2"), + HotkeyMapping(StreamHotkey.SCENE_3, {"ctrl", "shift"}, "3", "Switch to scene 3"), + HotkeyMapping(StreamHotkey.SCENE_4, {"ctrl", "shift"}, "4", "Switch to scene 4"), + HotkeyMapping(StreamHotkey.SCENE_5, {"ctrl", "shift"}, "5", "Switch to scene 5"), + HotkeyMapping(StreamHotkey.SCENE_6, {"ctrl", "shift"}, "6", "Switch to scene 6"), + HotkeyMapping(StreamHotkey.SCENE_7, {"ctrl", "shift"}, "7", "Switch to scene 7"), + HotkeyMapping(StreamHotkey.SCENE_8, {"ctrl", "shift"}, "8", "Switch to scene 8"), + HotkeyMapping(StreamHotkey.SCENE_9, {"ctrl", "shift"}, "9", "Switch to scene 9"), + HotkeyMapping(StreamHotkey.RECONNECT, {"ctrl", "shift"}, "r", "Force reconnect"), + HotkeyMapping(StreamHotkey.MUTE_VIDEO, {"ctrl", "shift"}, "v", "Toggle video mute"), + HotkeyMapping(StreamHotkey.MUTE_AUDIO, {"ctrl", "shift"}, "a", "Toggle audio mute"), +] + + +class StreamKeyboardController: + """Global keyboard shortcut handler for stream control. + + Runs a background listener thread. On Linux, prefers evdev for + direct keyboard access (works without X11/Wayland, ideal for + headless Raspberry Pi setups). + + Usage: + from src.streaming import Streamer, StreamKeyboardController + + streamer = Streamer(...) + controller = StreamKeyboardController(streamer) + controller.start() + + # ... streamer operations ... + + controller.stop() + """ + + def __init__( + self, + streamer, # Streamer (avoid circular import by not type-hinting) + hotkeys: list[HotkeyMapping] | None = None, + use_evdev: bool = True, + evdev_device: str | None = None, + ): + """ + Args: + streamer: The Streamer instance to control. + hotkeys: Custom hotkey mappings (uses defaults if None). + use_evdev: Prefer evdev on Linux (True) or fallback to pynput. + evdev_device: Specific evdev device path (auto-detected if None). + """ + self._streamer = streamer + self._hotkeys = hotkeys or DEFAULT_HOTKEYS + self._use_evdev = use_evdev + self._evdev_device = evdev_device + self._running = False + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + + # Build hotkey lookup: (frozenset(modifiers), key) → action + self._action_map: dict[tuple[frozenset[str], str], StreamHotkey] = {} + self._hotkey_descriptions: dict[StreamHotkey, str] = {} + for hk in self._hotkeys: + key = hk.key.lower() + mods = frozenset(m.lower() for m in hk.modifiers) + self._action_map[(mods, key)] = hk.hotkey + self._hotkey_descriptions[hk.hotkey] = hk.description + + def start(self) -> bool: + """Start the keyboard listener in a background thread. + + Returns: + True if the listener started successfully. + """ + with self._lock: + if self._running: + return True + + self._running = True + self._thread = threading.Thread( + target=self._listen_loop, + daemon=True, + name="stream-keyboard", + ) + self._thread.start() + logger.info("Stream keyboard controller started") + return True + + def stop(self) -> None: + """Stop the keyboard listener.""" + with self._lock: + self._running = False + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + + logger.info("Stream keyboard controller stopped") + + def get_hotkeys(self) -> list[dict]: + """List all configured hotkeys (for API display).""" + return [ + { + "action": hk.hotkey.value, + "modifiers": sorted(hk.modifiers), + "key": hk.key, + "description": hk.description, + } + for hk in self._hotkeys + ] + + # ── Internals ────────────────────────────────────────────────────────── + + def _listen_loop(self) -> None: + """Main keyboard listen loop. Tries evdev first, then pynput.""" + if self._use_evdev: + success = self._try_evdev_listener() + if success: + return + logger.info("evdev listener failed, trying pynput fallback") + + self._try_pynput_listener() + + def _try_evdev_listener(self) -> bool: + """Try to listen for keyboard events via evdev.""" + try: + import evdev + from evdev import ecodes, InputDevice + + # Find keyboard devices + device = self._find_evdev_keyboard() + if device is None: + logger.warning("No evdev keyboard device found") + return False + + logger.info("Listening on keyboard: %s (%s)", device.name, device.path) + + # Modifier state tracking + modifiers_active: set[str] = set() + modifier_map = { + ecodes.KEY_LEFTCTRL: "ctrl", + ecodes.KEY_RIGHTCTRL: "ctrl", + ecodes.KEY_LEFTSHIFT: "shift", + ecodes.KEY_RIGHTSHIFT: "shift", + ecodes.KEY_LEFTALT: "alt", + ecodes.KEY_RIGHTALT: "alt", + ecodes.KEY_LEFTMETA: "meta", + ecodes.KEY_RIGHTMETA: "meta", + } + key_name_map = { + ecodes.KEY_S: "s", ecodes.KEY_R: "r", ecodes.KEY_V: "v", ecodes.KEY_A: "a", + ecodes.KEY_1: "1", ecodes.KEY_2: "2", ecodes.KEY_3: "3", + ecodes.KEY_4: "4", ecodes.KEY_5: "5", ecodes.KEY_6: "6", + ecodes.KEY_7: "7", ecodes.KEY_8: "8", ecodes.KEY_9: "9", + ecodes.KEY_0: "0", + } + + for event in device.read_loop(): + if not self._running: + break + + if event.type != ecodes.EV_KEY: + continue + + keycode = event.code + keystate = event.value # 0=up, 1=down, 2=hold + + # Track modifiers + if keycode in modifier_map: + if keystate == 1: # pressed + modifiers_active.add(modifier_map[keycode]) + elif keystate == 0: # released + modifiers_active.discard(modifier_map[keycode]) + + # Check for hotkey on key-down + if keystate == 1 and keycode in key_name_map: + key_name = key_name_map[keycode] + self._check_hotkey(modifiers_active, key_name) + + return True + + except ImportError: + logger.debug("evdev not installed (pip install evdev)") + return False + except Exception as exc: + logger.warning("evdev listener error: %s", exc) + return False + + def _try_pynput_listener(self) -> bool: + """Try to listen for keyboard events via pynput.""" + try: + from pynput import keyboard + + modifiers_active: set[str] = set() + modifier_map = { + keyboard.Key.ctrl_l: "ctrl", + keyboard.Key.ctrl_r: "ctrl", + keyboard.Key.shift_l: "shift", + keyboard.Key.shift_r: "shift", + keyboard.Key.alt_l: "alt", + keyboard.Key.alt_r: "alt", + } + + def on_press(key): + if not self._running: + return False + + if hasattr(key, "name"): # Special key + mod = modifier_map.get(key) + if mod: + modifiers_active.add(mod) + else: # Character key + try: + char = key.char.lower() + except AttributeError: + return + self._check_hotkey(modifiers_active, char) + + def on_release(key): + if hasattr(key, "name"): + mod = modifier_map.get(key) + if mod: + modifiers_active.discard(mod) + + with keyboard.Listener( + on_press=on_press, + on_release=on_release, + ) as listener: + listener.join() + + return True + + except ImportError: + logger.debug("pynput not installed (pip install pynput)") + return False + except Exception as exc: + logger.warning("pynput listener error: %s", exc) + return False + + def _find_evdev_keyboard(self) -> Optional[object]: + """Find an evdev keyboard device.""" + try: + import evdev + + if self._evdev_device: + return evdev.InputDevice(self._evdev_device) + + devices = [evdev.InputDevice(path) for path in evdev.list_devices()] + for device in devices: + if "keyboard" in device.name.lower() or "key" in device.name.lower(): + # Check if it has keyboard keys + caps = device.capabilities() + if 1 in caps: # EV_KEY + return device + + # Fallback: first device with EV_KEY capability + for device in devices: + caps = device.capabilities() + if 1 in caps: + return device + + except ImportError: + pass + + return None + + def _check_hotkey(self, modifiers: set[str], key: str) -> None: + """Check if a key combination matches a hotkey, and dispatch.""" + # Normalize + mods = frozenset(m.lower() for m in modifiers) + key = key.lower() + + action = self._action_map.get((mods, key)) + if action is None: + return + + logger.info("Hotkey triggered: %s", action.value) + self._dispatch_action(action) + + def _dispatch_action(self, action: StreamHotkey) -> None: + """Execute the action on the streamer.""" + try: + if action == StreamHotkey.START_STOP: + if self._streamer.is_live: + self._streamer.stop() + else: + self._streamer.start() + + elif action == StreamHotkey.RECONNECT: + if self._streamer.is_live: + self._streamer.stop() + time.sleep(1.0) + self._streamer.start() + + elif action.value.startswith("scene_"): + scene_num = action.value.split("_")[1] + scenes = self._streamer.get_scenes() + if len(scenes) >= int(scene_num): + scene_name = scenes[int(scene_num) - 1]["name"] + self._streamer.switch_scene(scene_name) + + elif action == StreamHotkey.MUTE_VIDEO: + logger.info("Video mute toggle (not yet implemented)") + + elif action == StreamHotkey.MUTE_AUDIO: + logger.info("Audio mute toggle (not yet implemented)") + + except Exception as exc: + logger.error("Error dispatching hotkey %s: %s", action.value, exc) + + +def start_keyboard_controller( + streamer, # Streamer + hotkeys: list[HotkeyMapping] | None = None, +) -> StreamKeyboardController: + """Create and start a keyboard controller for the given streamer. + + Convenience factory function. + + Args: + streamer: The Streamer to control. + hotkeys: Custom hotkey mappings. + + Returns: + Started StreamKeyboardController. + """ + controller = StreamKeyboardController(streamer, hotkeys=hotkeys) + controller.start() + return controller diff --git a/src/streaming/gst_pipeline.py b/src/streaming/gst_pipeline.py new file mode 100644 index 0000000..9a1bd88 --- /dev/null +++ b/src/streaming/gst_pipeline.py @@ -0,0 +1,366 @@ +"""GStreamer pipeline builder for audio/video streaming. + +Constructs GStreamer pipeline strings for the Raspberry Pi 4B +hardware-accelerated encoding path: + + Video: camera → h264 encoder → RTMP mux + Audio: JACK/ALSA → AAC encoder → RTMP mux + +Supports both Python GObject bindings (preferred) and CLI +gst-launch-1.0 subprocess mode for reliability. + +Raspberry Pi 4B hardware encoders: + - v4l2h264enc: V4L2 stateless hardware h264 encoder (Pi 4/5) + - omxh264enc: Legacy Broadcom MMAL hardware encoder (Pi 3/older) + - x264enc: Software fallback (works everywhere, higher CPU) + +Audio capture methods: + - alsasrc: Direct ALSA device (e.g., JACK ALSA bridge) + - pulsesrc: PulseAudio source + - jackaudiosrc: JACK audio source (requires gst-plugins-bad) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class VideoSource(StrEnum): + """Video input source type for GStreamer pipeline.""" + V4L2 = "v4l2" # USB webcam /dev/video* + LIBCAMERA = "libcamera" # Pi Camera Module (modern libcamera) + RASPICAM = "raspicam" # Legacy Pi Camera (rpicamsrc) + TEST = "test" # Test pattern (videotestsrc) + + +class AudioSource(StrEnum): + """Audio input source type for GStreamer pipeline.""" + ALSA = "alsa" # Direct ALSA device + JACK = "jack" # JACK audio server + PULSE = "pulse" # PulseAudio + TEST = "test" # Test tone (audiotestsrc) + + +class EncoderType(StrEnum): + """Video encoder type.""" + V4L2_H264 = "v4l2h264enc" # V4L2 stateless h264 (Pi 4/5) + OMX_H264 = "omxh264enc" # Broadcom OMX h264 (Pi 3/older) + X264 = "x264enc" # Software x264 + LIBCAMERA_H264 = "libcamera_h264" # libcamera built-in h264 + + +@dataclass +class GstPipelineConfig: + """Configuration for building a GStreamer pipeline.""" + + # Video + video_source: VideoSource = VideoSource.TEST + video_device: str = "/dev/video0" + video_width: int = 1280 + video_height: int = 720 + video_framerate: int = 30 + + # Audio + audio_source: AudioSource = AudioSource.TEST + audio_device: str = "hw:0" # ALSA device name + audio_sample_rate: int = 48000 + audio_channels: int = 2 + audio_buffer_time_us: int = 20000 # 20ms buffer for low latency + + # Encoding + video_encoder: EncoderType = EncoderType.V4L2_H264 + video_bitrate_kbps: int = 2500 + video_bitrate_max_kbps: int = 4000 + keyframe_interval: int = 60 # frames between keyframes + h264_profile: str = "main" + h264_level: str = "4.0" + b_frames: int = 0 # 0 for lowest latency + audio_bitrate_kbps: int = 128 + audio_encoder: str = "avenc_aac" # or voaacenc, lamemp3enc + + # Output + rtmp_url: str = "" + rtmp_stream_key: str = "" + + # Latency tuning + low_latency: bool = True + video_queue_max_time_ms: int = 1000 + zerolatency_tune: bool = True + + # Test mode + test_video_pattern: int = 0 # videotestsrc pattern number + test_audio_freq: float = 440.0 # audiotestsrc frequency + + @property + def full_rtmp_url(self) -> str: + """Construct full RTMP URL with stream key.""" + if not self.rtmp_url: + return "" + url = self.rtmp_url.rstrip("/") + if self.rtmp_stream_key: + url = f"{url}/{self.rtmp_stream_key}" + return url + + +class GstPipelineBuilder: + """Builds GStreamer pipeline strings for various configurations. + + Usage: + builder = GstPipelineBuilder(config) + pipeline_str = builder.build() + # Or for CLI mode: + subprocess.run(["gst-launch-1.0", "-e"] + builder.build_args()) + """ + + def __init__(self, config: GstPipelineConfig): + self.config = config + + def build(self) -> str: + """Build a complete GStreamer pipeline string.""" + elements = self._build_elements() + return " ! ".join(elements) + + def build_args(self) -> list[str]: + """Build GStreamer pipeline as argument list for subprocess.""" + return self.build().split(" ! ") + + def _build_elements(self) -> list[str]: + """Build all pipeline element strings.""" + elements: list[str] = [] + + # Video branch + video_elements = self._build_video_branch() + # Audio branch + audio_elements = self._build_audio_branch() + + # Combine with flvmux + rtmpsink + elements.append( + f"flvmux name=mux streamable=true " + f"{' ! rtmpsink location=' + self.config.full_rtmp_url + ' sync=false' if self.config.full_rtmp_url else ''}" + ) + + # Build the full pipeline string with named branches + video_str = " ! ".join(video_elements) + audio_str = " ! ".join(audio_elements) + + # Return GStreamer pipeline with named pads + flvmux_elements = ["flvmux", "name=mux", "streamable=true"] + if self.config.full_rtmp_url: + flvmux_elements.append(f"rtmpsink location={self.config.full_rtmp_url} sync=false") + else: + flvmux_elements.append("fakesink sync=false") + + mux_str = " ! ".join(flvmux_elements) + + full = f"{video_str} ! mux.video {audio_str} ! mux.audio {mux_str}" + return full.split(" ! ") + + def _build_video_branch(self) -> list[str]: + """Build the video source + encoding branch.""" + elements: list[str] = [] + cfg = self.config + + # Video source + source = self._build_video_source() + elements.append(source) + + # Video processing + elements.append("videoconvert") + elements.append(f"video/x-raw,format=I420,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1") + + # Queue for buffering + if cfg.low_latency: + elements.append(f"queue max-size-time={cfg.video_queue_max_time_ms * 1000000} max-size-buffers=0 max-size-bytes=0") + else: + elements.append("queue") + + # Video encoder + encoder = self._build_video_encoder() + elements.extend(encoder) + + # H.264 parsing + elements.append("h264parse") + elements.append(f"video/x-h264,profile={cfg.h264_profile},level={cfg.h264_level}") + + return elements + + def _build_video_source(self) -> str: + """Build the video source element string.""" + cfg = self.config + + if cfg.video_source == VideoSource.V4L2: + return f"v4l2src device={cfg.video_device} do-timestamp=true" + + elif cfg.video_source == VideoSource.LIBCAMERA: + # libcamerasrc — modern Pi camera stack + return ( + f"libcamerasrc camera-name={cfg.video_device} " + f"! video/x-raw,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1" + ) + + elif cfg.video_source == VideoSource.RASPICAM: + # rpicamsrc — legacy Pi camera stack + return ( + f"rpicamsrc preview=false bitrate={cfg.video_bitrate_kbps * 1000} " + f"! video/x-h264,width={cfg.video_width},height={cfg.video_height},framerate={cfg.video_framerate}/1,profile={cfg.h264_profile}" + ) + + elif cfg.video_source == VideoSource.TEST: + return f"videotestsrc pattern={cfg.test_video_pattern} is-live=true" + + return f"v4l2src device={cfg.video_device}" + + def _build_video_encoder(self) -> list[str]: + """Build the video encoder element(s).""" + cfg = self.config + + if cfg.video_encoder == EncoderType.LIBCAMERA_H264: + # libcamera does its own encoding, skip external encoder + return [] + + if cfg.video_encoder == EncoderType.V4L2_H264: + return [ + f"v4l2h264enc " + f"extra-controls='controls," + f"video_bitrate={cfg.video_bitrate_kbps * 1000}," + f"repeat_sequence_header=1," + f"h264_profile=4," # main profile + f"h264_level=10," # level 4.0 + f"video_bitrate_mode=0'" # VBR + ] + + elif cfg.video_encoder == EncoderType.OMX_H264: + return [ + f"omxh264enc " + f"target-bitrate={cfg.video_bitrate_kbps * 1000} " + f"control-rate=variable " + f"periodicity-idr={cfg.keyframe_interval} " + f"inline-header=true" + ] + + elif cfg.video_encoder == EncoderType.X264: + tune = "zerolatency" if cfg.low_latency else "film" + speed_preset = "ultrafast" if cfg.low_latency else "veryfast" + return [ + f"x264enc " + f"bitrate={cfg.video_bitrate_kbps} " + f"speed-preset={speed_preset} " + f"tune={tune} " + f"key-int-max={cfg.keyframe_interval} " + f"bframes={cfg.b_frames} " + f"byte-stream=true" + ] + + return ["x264enc"] + + def _build_audio_branch(self) -> list[str]: + """Build the audio source + encoding branch.""" + elements: list[str] = [] + cfg = self.config + + # Audio source + source = self._build_audio_source() + elements.append(source) + + # Audio conversion + elements.append("audioconvert") + if cfg.audio_channels == 1: + elements.append("audio/x-raw,channels=1") + elements.append("audioresample") + elements.append(f"audio/x-raw,rate={cfg.audio_sample_rate}") + + # Queue for buffering + if cfg.low_latency: + elements.append(f"queue max-size-time={cfg.video_queue_max_time_ms * 1000000} max-size-buffers=0 max-size-bytes=0") + else: + elements.append("queue") + + # Audio encoder + audio_enc = self._build_audio_encoder() + elements.append(audio_enc) + + return elements + + def _build_audio_source(self) -> str: + """Build the audio source element string.""" + cfg = self.config + + if cfg.audio_source == AudioSource.ALSA: + return ( + f"alsasrc device={cfg.audio_device} " + f"buffer-time={cfg.audio_buffer_time_us} " + f"do-timestamp=true" + ) + + elif cfg.audio_source == AudioSource.JACK: + # jackaudiosrc — requires gst-plugins-bad + return "jackaudiosrc connect=0" + + elif cfg.audio_source == AudioSource.PULSE: + return "pulsesrc do-timestamp=true" + + elif cfg.audio_source == AudioSource.TEST: + return f"audiotestsrc freq={cfg.test_audio_freq} is-live=true" + + return "alsasrc" + + def _build_audio_encoder(self) -> str: + """Build the audio encoder element string.""" + cfg = self.config + return f"{cfg.audio_encoder} bitrate={cfg.audio_bitrate_kbps * 1000}" + + +def build_pipeline_string( + video_device: str = "/dev/video0", + video_width: int = 1280, + video_height: int = 720, + video_framerate: int = 30, + video_bitrate_kbps: int = 2500, + audio_device: str = "hw:0", + audio_sample_rate: int = 48000, + audio_bitrate_kbps: int = 128, + rtmp_url: str = "", + low_latency: bool = True, +) -> str: + """Quick helper to build a streaming pipeline string. + + This is a convenience function that creates a default configuration + and returns the GStreamer pipeline string. For full control, use + GstPipelineBuilder with a custom GstPipelineConfig. + + Args: + video_device: Path to video device (e.g. /dev/video0). + video_width: Output video width. + video_height: Output video height. + video_framerate: Output framerate. + video_bitrate_kbps: Video encoder bitrate in kbps. + audio_device: ALSA device name (e.g. hw:0). + audio_sample_rate: Audio sample rate in Hz. + audio_bitrate_kbps: Audio encoder bitrate in kbps. + rtmp_url: Full RTMP URL including stream key. + low_latency: Enable zero-latency tuning. + + Returns: + GStreamer pipeline string suitable for gst-launch-1.0. + """ + config = GstPipelineConfig( + video_source=VideoSource.V4L2 if video_device != "test" else VideoSource.TEST, + video_device=video_device, + video_width=video_width, + video_height=video_height, + video_framerate=video_framerate, + video_bitrate_kbps=video_bitrate_kbps, + audio_source=AudioSource.ALSA if audio_device != "test" else AudioSource.TEST, + audio_device=audio_device, + audio_sample_rate=audio_sample_rate, + audio_bitrate_kbps=audio_bitrate_kbps, + rtmp_url=rtmp_url, + low_latency=low_latency, + ) + builder = GstPipelineBuilder(config) + return builder.build() diff --git a/src/streaming/platforms.py b/src/streaming/platforms.py new file mode 100644 index 0000000..630d45a --- /dev/null +++ b/src/streaming/platforms.py @@ -0,0 +1,300 @@ +"""Streaming platform configurations and RTMP profiles. + +Defines preset configurations for popular streaming platforms +(YouTube, Twitch, Facebook Live, custom RTMP servers) with +recommended encoding settings for Raspberry Pi 4B h264 hardware. + +Each platform profile includes: + - RTMP ingest URL template + - Recommended resolution, bitrate, and framerate + - Audio bitrate and sample rate + - Platform-specific encoding notes +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +# ── Preset RTMP URLs ───────────────────────────────────────────────────────── + +YOUTUBE_RTMP = "rtmp://a.rtmp.youtube.com/live2" +"""YouTube Live RTMP ingest URL (primary).""" + +YOUTUBE_RTMP_BACKUP = "rtmp://b.rtmp.youtube.com/live2" +"""YouTube Live RTMP ingest URL (backup).""" + +TWITCH_RTMP = "rtmp://live.twitch.tv/app" +"""Twitch RTMP ingest URL (auto-selects closest server).""" + +FACEBOOK_RTMP = "rtmp://live-api-s.facebook.com:80/rtmp" +"""Facebook Live RTMP ingest URL.""" + +CUSTOM_RTMP = "rtmp://localhost/live" +"""Placeholder for custom RTMP server.""" + + +# ── Platform profile ───────────────────────────────────────────────────────── + + +@dataclass +class PlatformProfile: + """Streaming platform configuration for encoding and RTMP. + + All values are defaults; users can override via API or UI. + """ + + # Identity + name: str + """Human-readable platform name.""" + + platform_id: str + """Short identifier (e.g. 'youtube', 'twitch').""" + + # RTMP + rtmp_url: str = "" + """RTMP ingest URL. Can include {stream_key} placeholder.""" + + # Video settings + width: int = 1280 + """Output video width in pixels.""" + + height: int = 720 + """Output video height in pixels.""" + + framerate: int = 30 + """Output framerate (fps).""" + + video_bitrate_kbps: int = 2500 + """Video encoder target bitrate in kbps.""" + + video_bitrate_max_kbps: int = 4000 + """Video encoder maximum bitrate in kbps.""" + + keyframe_interval: int = 60 + """Keyframe interval in frames (2s at 30fps).""" + + # Audio settings + audio_bitrate_kbps: int = 128 + """Audio encoder bitrate in kbps.""" + + audio_sample_rate: int = 48000 + """Audio sample rate in Hz.""" + + audio_channels: int = 2 + """Audio channels (1=mono, 2=stereo).""" + + # Encoder + encoder: str = "h264" + """Video encoder type: 'h264' for RPi hardware, 'x264' for software.""" + + h264_profile: str = "main" + """H.264 profile: 'baseline', 'main', or 'high'.""" + + h264_level: str = "4.0" + """H.264 level for compatibility.""" + + # Control + low_latency: bool = True + """Enable low-latency tuning (for live performance).""" + + b_frames: int = 0 + """Number of B-frames (0 for lowest latency).""" + + # Experimental + description: str = "" + """Human-readable description of the platform preset.""" + + def to_dict(self) -> dict: + """Serialize to dictionary for API responses.""" + return { + "name": self.name, + "platform_id": self.platform_id, + "rtmp_url": self.rtmp_url, + "width": self.width, + "height": self.height, + "framerate": self.framerate, + "video_bitrate_kbps": self.video_bitrate_kbps, + "video_bitrate_max_kbps": self.video_bitrate_max_kbps, + "keyframe_interval": self.keyframe_interval, + "audio_bitrate_kbps": self.audio_bitrate_kbps, + "audio_sample_rate": self.audio_sample_rate, + "audio_channels": self.audio_channels, + "encoder": self.encoder, + "low_latency": self.low_latency, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: dict) -> PlatformProfile: + """Create from dictionary (for API deserialization).""" + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) + + +# ── Preset platforms ───────────────────────────────────────────────────────── + + +PRESET_PLATFORMS: dict[str, PlatformProfile] = { + "youtube": PlatformProfile( + name="YouTube Live", + platform_id="youtube", + rtmp_url=YOUTUBE_RTMP, + width=1280, + height=720, + framerate=30, + video_bitrate_kbps=2500, + video_bitrate_max_kbps=4000, + keyframe_interval=60, + audio_bitrate_kbps=128, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="main", + h264_level="4.0", + low_latency=True, + b_frames=0, + description="YouTube Live RTMP ingest. Recommended: 720p@30fps, 2500–4000 kbps.", + ), + "youtube_1080p": PlatformProfile( + name="YouTube Live 1080p", + platform_id="youtube_1080p", + rtmp_url=YOUTUBE_RTMP, + width=1920, + height=1080, + framerate=30, + video_bitrate_kbps=4500, + video_bitrate_max_kbps=6000, + keyframe_interval=60, + audio_bitrate_kbps=128, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="main", + h264_level="4.1", + low_latency=True, + b_frames=0, + description="YouTube Live 1080p@30fps. Requires strong network upload.", + ), + "twitch": PlatformProfile( + name="Twitch", + platform_id="twitch", + rtmp_url=TWITCH_RTMP, + width=1280, + height=720, + framerate=30, + video_bitrate_kbps=2500, + video_bitrate_max_kbps=4000, + keyframe_interval=60, + audio_bitrate_kbps=128, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="main", + h264_level="4.0", + low_latency=True, + b_frames=0, + description="Twitch RTMP ingest. Recommended: 720p@30fps, 2500–4000 kbps.", + ), + "twitch_1080p": PlatformProfile( + name="Twitch 1080p", + platform_id="twitch_1080p", + rtmp_url=TWITCH_RTMP, + width=1920, + height=1080, + framerate=30, + video_bitrate_kbps=4500, + video_bitrate_max_kbps=6000, + keyframe_interval=60, + audio_bitrate_kbps=128, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="main", + h264_level="4.1", + low_latency=True, + b_frames=0, + description="Twitch 1080p@30fps. Affiliate/Partner recommended only.", + ), + "facebook": PlatformProfile( + name="Facebook Live", + platform_id="facebook", + rtmp_url=FACEBOOK_RTMP, + width=1280, + height=720, + framerate=30, + video_bitrate_kbps=2500, + video_bitrate_max_kbps=4000, + keyframe_interval=60, + audio_bitrate_kbps=128, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="main", + h264_level="4.0", + low_latency=False, + b_frames=0, + description="Facebook Live RTMP ingest. 720p@30fps.", + ), + "custom": PlatformProfile( + name="Custom RTMP", + platform_id="custom", + rtmp_url=CUSTOM_RTMP, + width=1280, + height=720, + framerate=30, + video_bitrate_kbps=2500, + video_bitrate_max_kbps=4000, + keyframe_interval=60, + audio_bitrate_kbps=128, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="main", + h264_level="4.0", + low_latency=True, + b_frames=0, + description="Custom RTMP server. Edit the URL and stream key in settings.", + ), + "audio_only": PlatformProfile( + name="Audio Only", + platform_id="audio_only", + rtmp_url="", + width=320, + height=240, + framerate=5, + video_bitrate_kbps=100, + video_bitrate_max_kbps=200, + keyframe_interval=300, + audio_bitrate_kbps=192, + audio_sample_rate=48000, + audio_channels=2, + encoder="h264", + h264_profile="baseline", + h264_level="3.0", + low_latency=True, + b_frames=0, + description="Audio-only stream with minimal video placeholder. High quality audio at 192kbps.", + ), +} + + +def get_platform_profile(platform_id: str) -> Optional[PlatformProfile]: + """Get a preset platform profile by ID. + + Args: + platform_id: One of 'youtube', 'youtube_1080p', 'twitch', + 'twitch_1080p', 'facebook', 'custom', 'audio_only'. + + Returns: + PlatformProfile or None if not found. + """ + return PRESET_PLATFORMS.get(platform_id) + + +def list_platforms() -> list[dict]: + """List all available platform presets (for API display).""" + return [ + {"id": pid, "name": p.name, "description": p.description} + for pid, p in PRESET_PLATFORMS.items() + ] diff --git a/src/streaming/streamer.py b/src/streaming/streamer.py new file mode 100644 index 0000000..d3562e5 --- /dev/null +++ b/src/streaming/streamer.py @@ -0,0 +1,728 @@ +"""Streamer — manages the GStreamer streaming pipeline lifecycle. + +The Streamer class is the top-level controller for audio/video streaming. +It manages: + - GStreamer subprocess lifecycle (start, stop, restart) + - Pipeline monitoring (bitrate, uptime, dropped frames) + - Scene management (camera sources, overlays) + - Stream statistics collection + - Error recovery and reconnection + +Architecture: + Streamer + ├── GStreamer subprocess (gst-launch-1.0 -e) + ├── Statistics collector (stderr parsing) + ├── Scene manager (named presets) + └── State machine (idle → starting → live → stopping → idle) +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shutil +import signal +import subprocess +import threading +import time +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Optional, Callable + +from .gst_pipeline import ( + GstPipelineBuilder, + GstPipelineConfig, + VideoSource, + AudioSource, + EncoderType, + build_pipeline_string, +) +from .platforms import PlatformProfile, PRESET_PLATFORMS, get_platform_profile +from .camera import CameraInfo, detect_cameras, get_default_camera + +logger = logging.getLogger(__name__) + + +class StreamState(StrEnum): + """Streamer lifecycle states.""" + IDLE = "idle" # Not streaming, not configured + CONFIGURED = "configured" # Ready to stream + STARTING = "starting" # Pipeline launching + LIVE = "live" # Streaming to platform + RECONNECTING = "reconnecting" # Lost connection, retrying + STOPPING = "stopping" # Shutting down + ERROR = "error" # Fatal error, requires reset + + +@dataclass +class StreamScene: + """A named streaming scene (camera + settings preset).""" + name: str + camera_device: str = "/dev/video0" + video_width: int = 1280 + video_height: int = 720 + video_framerate: int = 30 + audio_device: str = "hw:0" + overlay_text: str = "" + description: str = "" + + def to_dict(self) -> dict: + return { + "name": self.name, + "camera_device": self.camera_device, + "video_width": self.video_width, + "video_height": self.video_height, + "video_framerate": self.video_framerate, + "audio_device": self.audio_device, + "overlay_text": self.overlay_text, + "description": self.description, + } + + +@dataclass +class StreamStats: + """Real-time stream statistics.""" + state: StreamState = StreamState.IDLE + uptime_seconds: float = 0.0 + video_bitrate_kbps: float = 0.0 + audio_bitrate_kbps: float = 0.0 + total_bytes_sent: int = 0 + dropped_frames: int = 0 + fps: float = 0.0 + cpu_usage_percent: float = 0.0 + reconnect_count: int = 0 + last_error: str = "" + camera_name: str = "" + platform_name: str = "" + + def to_dict(self) -> dict: + return { + "state": self.state.value, + "uptime_seconds": round(self.uptime_seconds, 1), + "video_bitrate_kbps": round(self.video_bitrate_kbps, 1), + "audio_bitrate_kbps": round(self.audio_bitrate_kbps, 1), + "total_bytes_sent": self.total_bytes_sent, + "dropped_frames": self.dropped_frames, + "fps": round(self.fps, 1), + "cpu_usage_percent": round(self.cpu_usage_percent, 1), + "reconnect_count": self.reconnect_count, + "last_error": self.last_error, + "camera_name": self.camera_name, + "platform_name": self.platform_name, + } + + +@dataclass +class StreamerConfig: + """Configuration for the Streamer.""" + # Platform + platform_id: str = "custom" + rtmp_url: str = "" + stream_key: str = "" + + # Video + video_device: str = "/dev/video0" + video_source: VideoSource = VideoSource.V4L2 + video_width: int = 1280 + video_height: int = 720 + video_framerate: int = 30 + video_bitrate_kbps: int = 2500 + + # Audio + audio_device: str = "hw:0" + audio_source: AudioSource = AudioSource.ALSA + audio_sample_rate: int = 48000 + audio_bitrate_kbps: int = 128 + + # Encoder + encoder_type: EncoderType = EncoderType.V4L2_H264 + h264_profile: str = "main" + h264_level: str = "4.0" + low_latency: bool = True + + # Recovery + auto_reconnect: bool = True + max_reconnect_attempts: int = 5 + reconnect_delay_seconds: float = 3.0 + + # Scenes + scenes: list[StreamScene] = field(default_factory=list) + active_scene: str = "default" + + @property + def full_rtmp_url(self) -> str: + """Construct full RTMP URL with stream key.""" + if not self.rtmp_url: + return "" + url = self.rtmp_url.rstrip("/") + if self.stream_key: + url = f"{url}/{self.stream_key}" + return url + + @classmethod + def from_platform(cls, platform_id: str, stream_key: str = "") -> StreamerConfig: + """Create config from a platform preset.""" + profile = get_platform_profile(platform_id) + if profile is None: + profile = PRESET_PLATFORMS.get("custom", PlatformProfile( + name="Custom", platform_id="custom", + )) + + return cls( + platform_id=profile.platform_id, + rtmp_url=profile.rtmp_url, + stream_key=stream_key, + video_width=profile.width, + video_height=profile.height, + video_framerate=profile.framerate, + video_bitrate_kbps=profile.video_bitrate_kbps, + audio_bitrate_kbps=profile.audio_bitrate_kbps, + audio_sample_rate=profile.audio_sample_rate, + h264_profile=profile.h264_profile, + h264_level=profile.h264_level, + low_latency=profile.low_latency, + ) + + +class Streamer: + """Manages the GStreamer streaming pipeline. + + Usage: + streamer = Streamer(StreamerConfig(platform_id="youtube", stream_key="xxxx")) + streamer.configure() + streamer.start() + # ... stream is live ... + streamer.stop() + + Thread-safe: all public methods use a lock for state transitions. + """ + + def __init__(self, config: StreamerConfig | None = None): + self._config = config or StreamerConfig() + self._state = StreamState.IDLE + self._state_lock = threading.Lock() + self._process: Optional[subprocess.Popen] = None + self._stderr_thread: Optional[threading.Thread] = None + self._stats = StreamStats() + self._stats_lock = threading.Lock() + self._start_time: float = 0.0 + self._reconnect_count: int = 0 + self._stop_event = threading.Event() + self._on_state_change: Optional[Callable[[StreamState, StreamStats], None]] = None + self._camera_info: Optional[CameraInfo] = None + self._scenes: dict[str, StreamScene] = {} + self._active_scene: str = "default" + + # Initialize with defaults + self._init_default_scenes() + + # ── Configuration ────────────────────────────────────────────────────── + + def configure( + self, + platform_id: str | None = None, + stream_key: str | None = None, + video_device: str | None = None, + audio_device: str | None = None, + ) -> None: + """Configure or reconfigure the streamer. + + Args: + platform_id: Platform preset ID (youtube, twitch, etc.). + stream_key: RTMP stream key for authentication. + video_device: Path to video device (e.g. /dev/video0). + audio_device: ALSA device name (e.g. hw:0). + """ + with self._state_lock: + if self._state == StreamState.LIVE: + raise RuntimeError("Cannot reconfigure while streaming. Stop first.") + + # Apply platform preset if specified + if platform_id: + profile = get_platform_profile(platform_id) + if profile: + self._config.platform_id = platform_id + self._config.rtmp_url = profile.rtmp_url + self._config.video_width = profile.width + self._config.video_height = profile.height + self._config.video_framerate = profile.framerate + self._config.video_bitrate_kbps = profile.video_bitrate_kbps + self._config.audio_bitrate_kbps = profile.audio_bitrate_kbps + self._config.audio_sample_rate = profile.audio_sample_rate + self._config.h264_profile = profile.h264_profile + self._config.h264_level = profile.h264_level + self._config.low_latency = profile.low_latency + + if stream_key is not None: + self._config.stream_key = stream_key + + if video_device is not None: + self._config.video_device = video_device + self._detect_camera() + + if audio_device is not None: + self._config.audio_device = audio_device + + self._state = StreamState.CONFIGURED + logger.info( + "Streamer configured: platform=%s, video=%s, audio=%s", + self._config.platform_id, + self._config.video_device, + self._config.audio_device, + ) + + self._notify_state_change() + + def _detect_camera(self) -> None: + """Auto-detect camera capabilities.""" + cameras = detect_cameras() + if cameras: + self._camera_info = cameras[0] + self._stats.camera_name = self._camera_info.name + logger.info("Detected camera: %s (%s)", self._camera_info.name, self._camera_info.device_path) + + # ── Lifecycle ────────────────────────────────────────────────────────── + + def start(self, block: bool = False, timeout: float = 10.0) -> bool: + """Start the streaming pipeline. + + Args: + block: If True, wait until stream is LIVE or timeout. + timeout: Maximum seconds to wait when blocking. + + Returns: + True if streaming started successfully. + """ + with self._state_lock: + if self._state == StreamState.LIVE: + logger.warning("Stream already live") + return True + + if self._state == StreamState.STARTING: + logger.warning("Stream already starting") + return False + + # Validate configuration + if not self._config.full_rtmp_url: + self._state = StreamState.ERROR + self._stats.last_error = "No RTMP URL configured. Set stream_key and platform." + self._notify_state_change() + return False + + self._state = StreamState.STARTING + self._reconnect_count = 0 + self._stop_event.clear() + self._notify_state_change() + + # Launch pipeline in background + success = self._launch_pipeline() + + if success and block: + # Wait for LIVE state + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with self._stats_lock: + if self._stats.state == StreamState.LIVE: + return True + if self._stats.state == StreamState.ERROR: + return False + time.sleep(0.1) + + return success + + def stop(self, timeout: float = 5.0) -> None: + """Stop the streaming pipeline gracefully. + + Args: + timeout: Maximum seconds to wait for process to exit. + """ + with self._state_lock: + if self._state not in (StreamState.LIVE, StreamState.STARTING, StreamState.RECONNECTING): + logger.debug("Stream not running (state=%s)", self._state.value) + self._state = StreamState.IDLE + return + + self._state = StreamState.STOPPING + self._notify_state_change() + + self._stop_event.set() + self._terminate_process(timeout) + + with self._state_lock: + self._state = StreamState.IDLE + self._stats.state = StreamState.IDLE + self._notify_state_change() + + logger.info("Stream stopped") + + def switch_scene(self, scene_name: str) -> bool: + """Switch to a named streaming scene. + + Scenes allow changing camera source and video settings + without restarting the entire pipeline (if supported by + the GStreamer pipeline). + + Args: + scene_name: Name of the scene to activate. + + Returns: + True if the scene was found and activated. + """ + scene = self._scenes.get(scene_name) + if scene is None: + logger.warning("Scene not found: %s", scene_name) + return False + + self._active_scene = scene_name + self._config.video_device = scene.camera_device + self._config.video_width = scene.video_width + self._config.video_height = scene.video_height + self._config.video_framerate = scene.video_framerate + self._config.audio_device = scene.audio_device + + # If currently streaming, we need to restart for the new camera + was_live = self._state == StreamState.LIVE + if was_live: + logger.info("Restarting stream for scene switch: %s", scene_name) + self.stop() + time.sleep(1.0) + self.start() + + logger.info("Scene activated: %s", scene_name) + return True + + def get_scenes(self) -> list[dict]: + """List all configured scenes.""" + return [s.to_dict() for s in self._scenes.values()] + + def add_scene(self, scene: StreamScene) -> None: + """Add a new scene.""" + self._scenes[scene.name] = scene + logger.info("Scene added: %s", scene.name) + + def remove_scene(self, name: str) -> bool: + """Remove a scene by name. Cannot remove 'default'.""" + if name == "default": + return False + if name in self._scenes: + del self._scenes[name] + return True + return False + + # ── Stats ────────────────────────────────────────────────────────────── + + @property + def state(self) -> StreamState: + """Current streamer state.""" + with self._state_lock: + return self._state + + @property + def stats(self) -> StreamStats: + """Current stream statistics (copy).""" + with self._stats_lock: + return StreamStats( + state=self._stats.state, + uptime_seconds=self._stats.uptime_seconds, + video_bitrate_kbps=self._stats.video_bitrate_kbps, + audio_bitrate_kbps=self._stats.audio_bitrate_kbps, + total_bytes_sent=self._stats.total_bytes_sent, + dropped_frames=self._stats.dropped_frames, + fps=self._stats.fps, + cpu_usage_percent=self._stats.cpu_usage_percent, + reconnect_count=self._stats.reconnect_count, + last_error=self._stats.last_error, + camera_name=self._stats.camera_name, + platform_name=self._stats.platform_name, + ) + + def get_stats_dict(self) -> dict: + """Get statistics as a dictionary.""" + return self.stats.to_dict() + + # ── Callbacks ────────────────────────────────────────────────────────── + + def on_state_change(self, callback: Callable[[StreamState, StreamStats], None]) -> None: + """Register a callback for state changes. + + The callback receives (new_state, current_stats). + """ + self._on_state_change = callback + + # ── Internals ────────────────────────────────────────────────────────── + + def _init_default_scenes(self) -> None: + """Initialize default scenes.""" + self._scenes["default"] = StreamScene( + name="default", + camera_device=self._config.video_device, + video_width=self._config.video_width, + video_height=self._config.video_height, + video_framerate=self._config.video_framerate, + audio_device=self._config.audio_device, + description="Default streaming scene", + ) + + def _build_pipeline_config(self) -> GstPipelineConfig: + """Build a GstPipelineConfig from current StreamerConfig.""" + cfg = self._config + return GstPipelineConfig( + video_source=cfg.video_source, + video_device=cfg.video_device, + video_width=cfg.video_width, + video_height=cfg.video_height, + video_framerate=cfg.video_framerate, + audio_source=cfg.audio_source, + audio_device=cfg.audio_device, + audio_sample_rate=cfg.audio_sample_rate, + audio_channels=2, + video_encoder=cfg.encoder_type, + video_bitrate_kbps=cfg.video_bitrate_kbps, + video_bitrate_max_kbps=cfg.video_bitrate_kbps * 2, + keyframe_interval=60, + h264_profile=cfg.h264_profile, + h264_level=cfg.h264_level, + b_frames=0, + audio_bitrate_kbps=cfg.audio_bitrate_kbps, + rtmp_url=cfg.rtmp_url, + rtmp_stream_key=cfg.stream_key, + low_latency=cfg.low_latency, + ) + + def _launch_pipeline(self) -> bool: + """Launch the GStreamer pipeline as a subprocess.""" + try: + pipeline_config = self._build_pipeline_config() + builder = GstPipelineBuilder(pipeline_config) + pipeline_str = builder.build() + + logger.info("Launching GStreamer pipeline") + logger.debug("Pipeline: %s", pipeline_str) + + # Check gst-launch-1.0 availability + gst_launch = shutil.which("gst-launch-1.0") + if not gst_launch: + self._set_error("gst-launch-1.0 not found. Install gstreamer-tools.") + return False + + self._start_time = time.monotonic() + self._reconnect_count = 0 + + # Launch subprocess + self._process = subprocess.Popen( + [gst_launch, "-e", pipeline_str], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + preexec_fn=os.setsid if hasattr(os, "setsid") else None, + ) + + # Start stderr reader thread + self._stderr_thread = threading.Thread( + target=self._read_stderr, + daemon=True, + ) + self._stderr_thread.start() + + # Start process monitor thread + monitor_thread = threading.Thread( + target=self._monitor_process, + daemon=True, + ) + monitor_thread.start() + + return True + + except Exception as exc: + logger.error("Failed to launch pipeline: %s", exc) + self._set_error(str(exc)) + return False + + def _read_stderr(self) -> None: + """Read and parse GStreamer stderr for statistics.""" + if not self._process or not self._process.stderr: + return + + try: + for line in self._process.stderr: + line = line.strip() + if not line: + continue + + # Detect live state from GStreamer output + if "Redistribute latency" in line or "Pipeline is live" in line: + self._set_live() + + # Parse bitrate information (GStreamer doesn't always output this) + # Most RTMP stats come from rtmpsink messages + + # Detect errors + if "ERROR" in line or "error:" in line.lower(): + logger.error("GStreamer error: %s", line) + with self._stats_lock: + self._stats.last_error = line[:200] + + # Log at debug level + logger.debug("GST: %s", line) + + except (ValueError, IOError) as exc: + logger.debug("Stderr reader stopped: %s", exc) + + def _monitor_process(self) -> None: + """Monitor the GStreamer process for exit.""" + if not self._process: + return + + while not self._stop_event.is_set(): + # Update uptime + if self._start_time > 0: + with self._stats_lock: + self._stats.uptime_seconds = time.monotonic() - self._start_time + + # Check if process exited + if self._process.poll() is not None: + exit_code = self._process.returncode + if self._stop_event.is_set(): + # Expected shutdown + break + + logger.warning("GStreamer process exited with code %d", exit_code) + + # Try reconnect + if self._config.auto_reconnect and self._reconnect_count < self._config.max_reconnect_attempts: + self._reconnect_count += 1 + with self._stats_lock: + self._stats.reconnect_count = self._reconnect_count + self._stats.state = StreamState.RECONNECTING + + logger.info( + "Reconnecting (attempt %d/%d) in %.1fs...", + self._reconnect_count, + self._config.max_reconnect_attempts, + self._config.reconnect_delay_seconds, + ) + time.sleep(self._config.reconnect_delay_seconds) + + if not self._stop_event.is_set(): + self._launch_pipeline() + else: + error_msg = f"Process exited with code {exit_code}" + if self._reconnect_count >= self._config.max_reconnect_attempts: + error_msg = f"Max reconnects ({self._config.max_reconnect_attempts}) reached. {error_msg}" + self._set_error(error_msg) + break + + time.sleep(0.5) + + def _set_live(self) -> None: + """Transition to LIVE state.""" + with self._state_lock: + self._state = StreamState.LIVE + with self._stats_lock: + self._stats.state = StreamState.LIVE + self._stats.platform_name = self._config.platform_id + self._notify_state_change() + logger.info("Stream is LIVE") + + def _set_error(self, message: str) -> None: + """Transition to ERROR state.""" + with self._state_lock: + self._state = StreamState.ERROR + with self._stats_lock: + self._stats.state = StreamState.ERROR + self._stats.last_error = message + self._notify_state_change() + logger.error("Stream error: %s", message) + + def _terminate_process(self, timeout: float = 5.0) -> None: + """Terminate the GStreamer subprocess gracefully.""" + if not self._process: + return + + try: + # Send SIGINT for graceful shutdown (gst-launch-1.0 -e handles this) + if hasattr(os, "killpg"): + os.killpg(os.getpgid(self._process.pid), signal.SIGINT) + else: + self._process.send_signal(signal.SIGINT) + + try: + self._process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + logger.warning("GStreamer did not exit gracefully, sending SIGKILL") + if hasattr(os, "killpg"): + os.killpg(os.getpgid(self._process.pid), signal.SIGKILL) + else: + self._process.kill() + self._process.wait(timeout=2.0) + + except ProcessLookupError: + pass # Already exited + + self._process = None + self._stderr_thread = None + + def _notify_state_change(self) -> None: + """Notify state change callback.""" + if self._on_state_change: + try: + stats = self.stats + self._on_state_change(self._state, stats) + except Exception as exc: + logger.error("State change callback error: %s", exc) + + # ── Properties ───────────────────────────────────────────────────────── + + @property + def config(self) -> StreamerConfig: + return self._config + + @property + def is_live(self) -> bool: + return self._state == StreamState.LIVE + + @property + def camera_info(self) -> Optional[CameraInfo]: + return self._camera_info + + +def create_streamer( + platform_id: str = "custom", + stream_key: str = "", + video_device: str | None = None, + audio_device: str = "hw:0", +) -> Streamer: + """Create a configured Streamer instance. + + Convenience factory that creates a Streamer with sensible defaults, + auto-detects cameras, and applies a platform preset. + + Args: + platform_id: Platform preset ('youtube', 'twitch', 'custom', etc.). + stream_key: RTMP stream key. + video_device: Camera device path (auto-detected if None). + audio_device: ALSA device name. + + Returns: + Configured Streamer instance. + """ + config = StreamerConfig( + platform_id=platform_id, + stream_key=stream_key, + audio_device=audio_device, + ) + + # Auto-detect camera + if video_device: + config.video_device = video_device + else: + cam = get_default_camera() + if cam: + config.video_device = cam.device_path + if "pi camera" in cam.name.lower(): + config.video_source = VideoSource.LIBCAMERA + + streamer = Streamer(config) + streamer.configure(platform_id=platform_id, stream_key=stream_key) + return streamer diff --git a/src/ui/app.py b/src/ui/app.py index b5a808e..39b76e5 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -43,6 +43,7 @@ from .screens.mixer import MixerScreen from .screens.routing import RoutingScreen from .screens.plugins import PluginScreen from .screens.settings import SettingsScreen +from .screens.sessions import SessionsScreen logger = logging.getLogger(__name__) @@ -88,6 +89,7 @@ class TabBar(BoxLayout): ("mixer", "MIX"), ("routing", "RTG"), ("plugins", "PLG"), + ("sessions", "SES"), ("settings", "SET"), ] for name, label in tabs: @@ -173,11 +175,13 @@ class MixerRoot(BoxLayout): self.mixer_screen = MixerScreen(name="mixer") self.routing_screen = RoutingScreen(name="routing") self.plugin_screen = PluginScreen(name="plugins") + self.sessions_screen = SessionsScreen(name="sessions") self.settings_screen = SettingsScreen(name="settings") self.screen_manager.add_widget(self.mixer_screen) self.screen_manager.add_widget(self.routing_screen) self.screen_manager.add_widget(self.plugin_screen) + self.screen_manager.add_widget(self.sessions_screen) self.screen_manager.add_widget(self.settings_screen) # Tab bar (bottom) @@ -192,6 +196,7 @@ class MixerRoot(BoxLayout): self.mixer_screen.client = client self.routing_screen.client = client self.plugin_screen.client = client + self.sessions_screen.client = client self.settings_screen.client = client def update_status(self, connected: bool, info: str = ""): diff --git a/src/ui/screens/sessions.py b/src/ui/screens/sessions.py new file mode 100644 index 0000000..f5fbbd5 --- /dev/null +++ b/src/ui/screens/sessions.py @@ -0,0 +1,635 @@ +"""Sessions screen — save/load mixer sessions, setlists, and snapshots. + +Touch-optimized layout for Raspberry Pi 5"/7" displays. +Provides: + - Session list with load/delete buttons + - Save current session button + - Snapshot capture/recall + - Setlist browser +""" + +from __future__ import annotations + +from kivy.properties import ObjectProperty, StringProperty, ListProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.button import Button +from kivy.uix.label import Label +from kivy.uix.screenmanager import Screen +from kivy.uix.scrollview import ScrollView +from kivy.uix.textinput import TextInput +from kivy.uix.togglebutton import ToggleButton +from kivy.metrics import dp +from kivy.clock import Clock + +from ..theme import Colors, Fonts, Sizes + + +class SessionCard(BoxLayout): + """A single session/snapshot entry card.""" + + session_name = StringProperty("") + session_info = StringProperty("") + is_snapshot = False + + def __init__(self, name="", info="", is_snapshot=False, on_load=None, + on_delete=None, on_recall=None, **kwargs): + super().__init__(**kwargs) + self.orientation = "horizontal" + self.size_hint = (1, None) + self.height = dp(52) + self.padding = [dp(8), dp(4)] + self.spacing = dp(6) + self._name = name + self._is_snapshot = is_snapshot + + icon = "📸" if is_snapshot else "💾" + + # Name + info + info_layout = BoxLayout(orientation="vertical", size_hint=(1, 1)) + name_label = Label( + text=f"{icon} {name}", + color=Colors.TEXT, + font_size=Fonts.SMALL, + halign="left", + valign="middle", + size_hint=(1, 1), + text_size=(None, None), + ) + name_label.bind(size=name_label.setter("text_size")) + info_label = Label( + text=info, + color=Colors.TEXT_MUTED, + font_size=Fonts.XSMALL, + halign="left", + size_hint=(1, 0.5), + text_size=(None, None), + ) + info_label.bind(size=info_label.setter("text_size")) + info_layout.add_widget(name_label) + info_layout.add_widget(info_label) + + # Buttons + btn_layout = BoxLayout( + orientation="horizontal", + size_hint=(None, 1), + width=dp(140), + spacing=dp(4), + ) + + if is_snapshot: + load_btn = Button( + text="Recall", + font_size=Fonts.XSMALL, + size_hint=(1, 1), + background_color=Colors.BG_BUTTON, + color=Colors.TEXT, + ) + if on_recall: + load_btn.bind(on_press=lambda x: on_recall(name)) + else: + load_btn = Button( + text="Load", + font_size=Fonts.XSMALL, + size_hint=(1, 1), + background_color=Colors.BG_BUTTON, + color=Colors.TEXT, + ) + if on_load: + load_btn.bind(on_press=lambda x: on_load(name)) + + del_btn = Button( + text="✕", + font_size=Fonts.XSMALL, + size_hint=(None, 1), + width=dp(40), + background_color=Colors.MUTE_ON, + color=Colors.TEXT, + ) + if on_delete: + del_btn.bind(on_press=lambda x: on_delete(name)) + + btn_layout.add_widget(load_btn) + btn_layout.add_widget(del_btn) + + self.add_widget(info_layout) + self.add_widget(btn_layout) + + +class SessionsScreen(Screen): + """Session management screen for the touch UI.""" + + client = ObjectProperty(None) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.name = "sessions" + self._sessions_data = [] + self._snapshots_data = [] + self._current_subtab = "sessions" + + root = BoxLayout(orientation="vertical") + + # Header + header = BoxLayout( + orientation="horizontal", + size_hint=(1, None), + height=Sizes.HEADER_HEIGHT, + padding=[Sizes.PAD_MD, Sizes.PAD_XS], + ) + header.bg_color = Colors.BG_HEADER + title = Label( + text="SESSIONS", + color=Colors.ACCENT, + font_size=Fonts.HEADER, + bold=True, + size_hint=(None, 1), + width=dp(150), + halign="left", + ) + title.bind(size=title.setter("text_size")) + + # Sub-tab bar + sub_tabs = BoxLayout( + orientation="horizontal", + size_hint=(1, None), + height=dp(40), + spacing=0, + ) + sub_tabs.bg_color = Colors.BG_DARK + + self._tab_sessions = ToggleButton( + text="Sessions", + font_size=Fonts.SMALL, + group="subtab", + state="down", + background_color=Colors.BG_HEADER, + color=Colors.ACCENT, + ) + self._tab_setlists = ToggleButton( + text="Setlists", + font_size=Fonts.SMALL, + group="subtab", + background_color=Colors.BG_HEADER, + color=Colors.TEXT_MUTED, + ) + self._tab_snapshots = ToggleButton( + text="Snapshots", + font_size=Fonts.SMALL, + group="subtab", + background_color=Colors.BG_HEADER, + color=Colors.TEXT_MUTED, + ) + + self._tab_sessions.bind(on_press=lambda x: self._switch_subtab("sessions")) + self._tab_setlists.bind(on_press=lambda x: self._switch_subtab("setlists")) + self._tab_snapshots.bind(on_press=lambda x: self._switch_subtab("snapshots")) + + sub_tabs.add_widget(self._tab_sessions) + sub_tabs.add_widget(self._tab_setlists) + sub_tabs.add_widget(self._tab_snapshots) + + # Save bar + save_bar = BoxLayout( + orientation="horizontal", + size_hint=(1, None), + height=dp(44), + padding=[dp(8), dp(4)], + spacing=dp(6), + ) + self._name_input = TextInput( + text="", + hint_text="Session name...", + font_size=Fonts.SMALL, + size_hint=(1, 1), + background_color=Colors.BG_INPUT, + foreground_color=Colors.TEXT, + multiline=False, + ) + self._notes_input = TextInput( + text="", + hint_text="Notes...", + font_size=Fonts.SMALL, + size_hint=(0.6, 1), + background_color=Colors.BG_INPUT, + foreground_color=Colors.TEXT, + multiline=False, + ) + save_btn = Button( + text="Save", + font_size=Fonts.SMALL, + size_hint=(None, 1), + width=dp(80), + background_color=Colors.ACCENT, + color=Colors.BG_DARK, + ) + save_btn.bind(on_press=self._on_save) + + save_bar.add_widget(self._name_input) + save_bar.add_widget(self._notes_input) + save_bar.add_widget(save_btn) + + # Snapshot bar (hidden by default) + self._snapshot_bar = BoxLayout( + orientation="horizontal", + size_hint=(1, None), + height=dp(44), + padding=[dp(8), dp(4)], + spacing=dp(6), + ) + self._snap_name_input = TextInput( + text="", + hint_text="Snapshot name...", + font_size=Fonts.SMALL, + size_hint=(1, 1), + background_color=Colors.BG_INPUT, + foreground_color=Colors.TEXT, + multiline=False, + ) + snap_btn = Button( + text="Capture", + font_size=Fonts.SMALL, + size_hint=(None, 1), + width=dp(80), + background_color=Colors.ACCENT, + color=Colors.BG_DARK, + ) + snap_btn.bind(on_press=self._on_capture_snapshot) + + next_btn = Button( + text="▶", + font_size=Fonts.SMALL, + size_hint=(None, 1), + width=dp(44), + background_color=Colors.BG_BUTTON, + color=Colors.TEXT, + ) + next_btn.bind(on_press=self._on_snapshot_next) + + prev_btn = Button( + text="◀", + font_size=Fonts.SMALL, + size_hint=(None, 1), + width=dp(44), + background_color=Colors.BG_BUTTON, + color=Colors.TEXT, + ) + prev_btn.bind(on_press=self._on_snapshot_prev) + + self._snapshot_bar.add_widget(self._snap_name_input) + self._snapshot_bar.add_widget(snap_btn) + self._snapshot_bar.add_widget(prev_btn) + self._snapshot_bar.add_widget(next_btn) + + # Scrollable list area + self._list_scroll = ScrollView(size_hint=(1, 1), do_scroll_x=False) + self._list_layout = BoxLayout( + orientation="vertical", + size_hint=(1, None), + spacing=dp(2), + padding=[dp(4), dp(4)], + ) + self._list_layout.bind(minimum_height=self._list_layout.setter("height")) + self._list_scroll.add_widget(self._list_layout) + + # Setlist view (hidden by default) + self._setlist_view = BoxLayout( + orientation="vertical", + size_hint=(1, 1), + spacing=dp(4), + ) + self._setlist_view.opacity = 0 + self._setlist_view.disabled = True + + self._setlist_list = BoxLayout( + orientation="vertical", + size_hint=(1, 1), + spacing=dp(2), + padding=[dp(4), dp(4)], + ) + + setlist_bar = BoxLayout( + orientation="horizontal", + size_hint=(1, None), + height=dp(44), + padding=[dp(8), dp(4)], + spacing=dp(6), + ) + self._setlist_name_input = TextInput( + text="", + hint_text="Setlist name...", + font_size=Fonts.SMALL, + size_hint=(1, 1), + background_color=Colors.BG_INPUT, + foreground_color=Colors.TEXT, + multiline=False, + ) + new_setlist_btn = Button( + text="Create", + font_size=Fonts.SMALL, + size_hint=(None, 1), + width=dp(80), + background_color=Colors.ACCENT, + color=Colors.BG_DARK, + ) + new_setlist_btn.bind(on_press=self._on_create_setlist) + setlist_bar.add_widget(self._setlist_name_input) + setlist_bar.add_widget(new_setlist_btn) + + self._setlist_view.add_widget(setlist_bar) + self._setlist_view.add_widget(self._setlist_list) + + # Content area + content = BoxLayout(orientation="vertical", size_hint=(1, 1)) + content.add_widget(self._list_scroll) + content.add_widget(self._setlist_view) + + # Combine + header.add_widget(title) + root.add_widget(header) + root.add_widget(sub_tabs) + root.add_widget(save_bar) + root.add_widget(self._snapshot_bar) + root.add_widget(content) + + self.add_widget(root) + + # Refresh periodically + Clock.schedule_interval(self._refresh, 10.0) + + def on_enter(self, *args): + """Called when the screen is displayed.""" + self._refresh() + + def _switch_subtab(self, tab: str): + """Switch between sessions/setlists/snapshots.""" + self._current_subtab = tab + + # Update toggle states + self._tab_sessions.state = "down" if tab == "sessions" else "normal" + self._tab_setlists.state = "down" if tab == "setlists" else "normal" + self._tab_snapshots.state = "down" if tab == "snapshots" else "normal" + + # Toggle visibility + if tab == "setlists": + self._list_scroll.opacity = 0 + self._list_scroll.disabled = True + self._setlist_view.opacity = 1 + self._setlist_view.disabled = False + self._snapshot_bar.opacity = 0 + self._snapshot_bar.disabled = True + elif tab == "snapshots": + self._list_scroll.opacity = 1 + self._list_scroll.disabled = False + self._setlist_view.opacity = 0 + self._setlist_view.disabled = True + self._snapshot_bar.opacity = 1 + self._snapshot_bar.disabled = False + else: + self._list_scroll.opacity = 1 + self._list_scroll.disabled = False + self._setlist_view.opacity = 0 + self._setlist_view.disabled = True + self._snapshot_bar.opacity = 0 + self._snapshot_bar.disabled = True + + self._refresh() + + def _refresh(self, *args): + """Refresh the session list from the REST API.""" + if not self.client: + return + + if self._current_subtab in ("sessions", "snapshots"): + self._fetch_sessions() + if self._current_subtab == "snapshots": + self._fetch_snapshots() + elif self._current_subtab == "setlists": + self._fetch_setlists() + + def _fetch_sessions(self): + """Fetch sessions list from REST API.""" + try: + self.client.rest_get( + "/api/v1/sessions", + on_success=self._on_sessions_loaded, + on_error=lambda e: None, + ) + except Exception: + pass + + def _fetch_snapshots(self): + """Fetch snapshots list from REST API.""" + try: + self.client.rest_get( + "/api/v1/snapshots", + on_success=self._on_snapshots_loaded, + on_error=lambda e: None, + ) + except Exception: + pass + + def _fetch_setlists(self): + """Fetch setlists list from REST API.""" + try: + self.client.rest_get( + "/api/v1/setlists", + on_success=self._on_setlists_loaded, + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_sessions_loaded(self, data: dict): + """Render session cards.""" + sessions = data.get("sessions", []) + self._sessions_data = sessions + self._render_session_list() + + def _on_snapshots_loaded(self, data: dict): + """Render snapshot cards.""" + snapshots = data.get("snapshots", []) + self._snapshots_data = snapshots + self._render_session_list() + + def _render_session_list(self): + """Build the session/snapshot card list.""" + self._list_layout.clear_widgets() + + items = [] + for s in (self._sessions_data or []): + items.append({ + "type": "session", + "name": s.get("name", ""), + "info": f"{s.get('size_bytes', 0)} bytes", + "is_snapshot": False, + }) + for s in (self._snapshots_data or []): + cat = s.get("category", "") + cur = " (active)" if s.get("is_current") else "" + items.append({ + "type": "snapshot", + "name": s.get("name", ""), + "info": f"{cat}{cur}" if (cat or cur) else "", + "is_snapshot": True, + }) + + if not items: + self._list_layout.add_widget(Label( + text="No sessions saved yet.\nUse the Save button above.", + color=Colors.TEXT_MUTED, + font_size=Fonts.SMALL, + size_hint=(1, None), + height=dp(60), + halign="center", + valign="middle", + )) + return + + for item in items: + card = SessionCard( + name=item["name"], + info=item["info"], + is_snapshot=item["is_snapshot"], + on_load=self._on_load if not item["is_snapshot"] else None, + on_delete=self._on_delete, + on_recall=self._on_recall if item["is_snapshot"] else None, + ) + self._list_layout.add_widget(card) + + def _on_setlists_loaded(self, data: dict): + """Render setlist cards.""" + self._setlist_list.clear_widgets() + setlists = data.get("setlists", []) + + if not setlists: + self._setlist_list.add_widget(Label( + text="No setlists. Create one above.", + color=Colors.TEXT_MUTED, + font_size=Fonts.SMALL, + size_hint=(1, None), + height=dp(40), + halign="center", + )) + return + + def _on_load(self, name: str): + """Load a session.""" + if not self.client: + return + try: + self.client.rest_post( + f"/api/v1/sessions/{name}/load", + body={}, + on_success=lambda d: None, + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_delete(self, name: str): + """Delete a session or snapshot.""" + if not self.client: + return + prefix = "snapshots" if self._current_subtab == "snapshots" else "sessions" + try: + self.client.rest_delete( + f"/api/v1/{prefix}/{name}", + on_success=lambda d: self._refresh(), + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_recall(self, name: str): + """Recall a snapshot.""" + if not self.client: + return + try: + self.client.rest_post( + f"/api/v1/snapshots/{name}/recall", + body={}, + on_success=lambda d: None, + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_save(self, instance): + """Save current state as a session.""" + if not self.client: + return + name = self._name_input.text.strip() + if not name: + return + notes = self._notes_input.text.strip() + try: + self.client.rest_post( + "/api/v1/sessions/save", + body={"name": name, "overwrite": True}, + on_success=lambda d: (self._name_input.setter("text")(""), self._refresh()), + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_capture_snapshot(self, instance): + """Capture current state as a snapshot.""" + if not self.client: + return + name = self._snap_name_input.text.strip() + if not name: + return + try: + self.client.rest_post( + "/api/v1/snapshots/capture", + body={"name": name, "overwrite": True}, + on_success=lambda d: (self._snap_name_input.setter("text")(""), self._refresh()), + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_snapshot_next(self, instance): + """Go to next snapshot.""" + if not self.client: + return + try: + self.client.rest_post( + "/api/v1/snapshots/next", + body={}, + on_success=lambda d: None, + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_snapshot_prev(self, instance): + """Go to previous snapshot.""" + if not self.client: + return + try: + self.client.rest_post( + "/api/v1/snapshots/previous", + body={}, + on_success=lambda d: None, + on_error=lambda e: None, + ) + except Exception: + pass + + def _on_create_setlist(self, instance): + """Create a new setlist.""" + if not self.client: + return + name = self._setlist_name_input.text.strip() + if not name: + return + try: + self.client.rest_post( + "/api/v1/setlists/save", + body={"name": name, "entries": [], "description": ""}, + on_success=lambda d: self._refresh(), + on_error=lambda e: None, + ) + except Exception: + pass diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..97f481e --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,897 @@ +"""Tests for the audio/video streaming pipeline module. + +Tests cover: + - Platform profiles and presets + - GStreamer pipeline builder + - Streamer lifecycle (state machine, configuration) + - Scene management + - Stream API endpoints + - Camera detection (mocked) + +These tests do NOT require actual camera hardware or GStreamer. +They validate the logic, configuration, and state management. +""" + +from __future__ import annotations + +import copy +import threading +import time +from unittest.mock import patch, MagicMock, PropertyMock + +import pytest + +from src.streaming.platforms import ( + PlatformProfile, + PRESET_PLATFORMS, + get_platform_profile, + list_platforms, + YOUTUBE_RTMP, + TWITCH_RTMP, +) +from src.streaming.gst_pipeline import ( + GstPipelineBuilder, + GstPipelineConfig, + VideoSource, + AudioSource, + EncoderType, + build_pipeline_string, +) +from src.streaming.streamer import ( + Streamer, + StreamerConfig, + StreamState, + StreamScene, + StreamStats, + create_streamer, +) +from src.streaming.camera import ( + CameraInfo, + CameraType, + CameraResolution, + detect_cameras, + get_default_camera, +) +from src.streaming.controls import ( + StreamKeyboardController, + StreamHotkey, + HotkeyMapping, + DEFAULT_HOTKEYS, + start_keyboard_controller, +) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Platform profiles +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestPlatformProfiles: + """Tests for streaming platform profiles and presets.""" + + def test_all_presets_have_required_fields(self): + """Every preset should have RTMP URL and encode settings.""" + required = ["name", "platform_id", "rtmp_url", "width", "height", + "video_bitrate_kbps", "audio_bitrate_kbps"] + for pid, profile in PRESET_PLATFORMS.items(): + for field in required: + assert getattr(profile, field, None) is not None, \ + f"Preset '{pid}' missing field '{field}'" + assert profile.width > 0 + assert profile.height > 0 + assert profile.video_bitrate_kbps > 0 + assert profile.audio_bitrate_kbps > 0 + + def test_youtube_preset(self): + profile = PRESET_PLATFORMS["youtube"] + assert profile.platform_id == "youtube" + assert "youtube" in profile.rtmp_url + assert profile.width == 1280 + assert profile.height == 720 + assert profile.framerate == 30 + assert profile.low_latency is True + + def test_twitch_preset(self): + profile = PRESET_PLATFORMS["twitch"] + assert profile.platform_id == "twitch" + assert "twitch" in profile.rtmp_url + assert profile.encoder == "h264" + + def test_audio_only_preset(self): + profile = PRESET_PLATFORMS["audio_only"] + assert profile.platform_id == "audio_only" + assert profile.audio_bitrate_kbps == 192 + assert profile.video_bitrate_kbps < 200 # Very low video + assert profile.framerate <= 5 + + def test_get_platform_profile_found(self): + for pid in ["youtube", "twitch", "custom", "audio_only"]: + assert get_platform_profile(pid) is not None + + def test_get_platform_profile_not_found(self): + assert get_platform_profile("nonexistent") is None + + def test_list_platforms(self): + platforms = list_platforms() + assert len(platforms) >= 5 + for p in platforms: + assert "id" in p + assert "name" in p + + def test_platform_to_dict(self): + profile = PRESET_PLATFORMS["youtube"] + d = profile.to_dict() + assert d["platform_id"] == "youtube" + assert d["width"] == 1280 + assert d["encoder"] == "h264" + + def test_platform_from_dict(self): + data = PRESET_PLATFORMS["youtube"].to_dict() + profile = PlatformProfile.from_dict(data) + assert profile.platform_id == "youtube" + assert profile.width == 1280 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GStreamer pipeline builder +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGstPipelineBuilder: + """Tests for GStreamer pipeline string construction.""" + + def test_build_basic_pipeline(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.X264, + rtmp_url="rtmp://localhost/live", + rtmp_stream_key="test-key", + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + + assert "videotestsrc" in pipeline + assert "audiotestsrc" in pipeline + assert "x264enc" in pipeline + assert "flvmux" in pipeline + + def test_rtmp_url_construction(self): + config = GstPipelineConfig( + rtmp_url="rtmp://example.com/live", + rtmp_stream_key="abc123", + ) + assert config.full_rtmp_url == "rtmp://example.com/live/abc123" + + def test_rtmp_url_no_key(self): + config = GstPipelineConfig( + rtmp_url="rtmp://example.com/live", + rtmp_stream_key="", + ) + assert config.full_rtmp_url == "rtmp://example.com/live" + + def test_build_v4l2_source(self): + config = GstPipelineConfig( + video_source=VideoSource.V4L2, + video_device="/dev/video0", + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "v4l2src" in pipeline + assert "device=/dev/video0" in pipeline + + def test_build_libcamera_source(self): + config = GstPipelineConfig( + video_source=VideoSource.LIBCAMERA, + video_device="/dev/video0", + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "libcamerasrc" in pipeline + + def test_build_rpicam_source(self): + config = GstPipelineConfig( + video_source=VideoSource.RASPICAM, + video_bitrate_kbps=3000, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "rpicamsrc" in pipeline + + def test_build_alias_audio_source(self): + config = GstPipelineConfig(audio_source=AudioSource.ALSA) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "alsasrc" in pipeline + + def test_build_jack_audio_source(self): + config = GstPipelineConfig(audio_source=AudioSource.JACK) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "jackaudiosrc" in pipeline + + def test_build_pulse_audio_source(self): + config = GstPipelineConfig(audio_source=AudioSource.PULSE) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "pulsesrc" in pipeline + + def test_v4l2_h264_encoder(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.V4L2_H264, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "v4l2h264enc" in pipeline + + def test_omx_h264_encoder(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.OMX_H264, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "omxh264enc" in pipeline + + def test_x264_encoder(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.X264, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "x264enc" in pipeline + + def test_low_latency_queue_elements(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + low_latency=True, + video_queue_max_time_ms=500, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "max-size-time" in pipeline + + def test_high_latency_no_queue_limits(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + low_latency=False, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + # Without low latency, queues should be simpler + assert "queue" in pipeline + + def test_pipeline_includes_h264parse(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.X264, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "h264parse" in pipeline + + def test_pipeline_includes_audio_encoder(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + audio_encoder="lamemp3enc", + audio_bitrate_kbps=192, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "lamemp3enc" in pipeline + assert "bitrate=192000" in pipeline + + def test_build_pipeline_string_helper(self): + pipeline = build_pipeline_string( + video_device="/dev/video0", + audio_device="hw:0", + rtmp_url="rtmp://example.com/live/key", + ) + assert "v4l2src" in pipeline + assert "alsasrc" in pipeline + assert "rtmp" in pipeline + + def test_test_mode_pipeline(self): + pipeline = build_pipeline_string( + video_device="test", + audio_device="test", + rtmp_url="rtmp://example.com/live/key", + ) + assert "videotestsrc" in pipeline + assert "audiotestsrc" in pipeline + + def test_build_args_list(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.X264, + ) + builder = GstPipelineBuilder(config) + args = builder.build_args() + assert isinstance(args, list) + assert len(args) > 5 + + def test_pipeline_includes_resolution(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_width=1920, + video_height=1080, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "1920" in pipeline + assert "1080" in pipeline + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Streamer lifecycle +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestStreamerLifecycle: + """Tests for Streamer state machine and lifecycle.""" + + def test_initial_state_is_idle(self): + streamer = Streamer() + assert streamer.state == StreamState.IDLE + assert streamer.is_live is False + + def test_configure_sets_state(self): + streamer = Streamer() + streamer.configure(platform_id="youtube") + assert streamer.state == StreamState.CONFIGURED + + def test_configure_applies_platform_preset(self): + streamer = Streamer() + streamer.configure(platform_id="youtube", stream_key="test-key") + assert streamer.config.platform_id == "youtube" + assert streamer.config.stream_key == "test-key" + assert streamer.config.rtmp_url == YOUTUBE_RTMP + + def test_configure_twitch_preset(self): + streamer = Streamer() + streamer.configure(platform_id="twitch", stream_key="live_xxxx") + assert streamer.config.rtmp_url == TWITCH_RTMP + + def test_configure_custom_devices(self): + streamer = Streamer() + streamer.configure( + platform_id="youtube", + video_device="/dev/video2", + audio_device="hw:1", + ) + assert streamer.config.video_device == "/dev/video2" + assert streamer.config.audio_device == "hw:1" + + def test_configure_partial_updates(self): + streamer = Streamer() + streamer.configure(platform_id="youtube") + # Partial update — only change stream key + streamer.configure(stream_key="new-key") + assert streamer.config.stream_key == "new-key" + assert streamer.config.platform_id == "youtube" # Unchanged + + def test_start_requires_rtmp_url(self): + streamer = Streamer() + # No platform configured, no RTMP URL, no stream key + success = streamer.start() + assert success is False + assert streamer.state == StreamState.ERROR + + def test_stats_initial_state(self): + streamer = Streamer() + stats = streamer.stats + assert stats.state == StreamState.IDLE + assert stats.uptime_seconds == 0.0 + assert stats.reconnect_count == 0 + + def test_get_stats_dict(self): + streamer = Streamer() + d = streamer.get_stats_dict() + assert d["state"] == "idle" + assert "uptime_seconds" in d + assert "reconnect_count" in d + + def test_config_from_preset(self): + config = StreamerConfig.from_platform("youtube", "test-key") + assert config.platform_id == "youtube" + assert config.stream_key == "test-key" + assert config.video_width == 1280 + assert config.video_height == 720 + assert config.low_latency is True + + def test_config_from_unknown_preset(self): + config = StreamerConfig.from_platform("nonexistent", "test-key") + assert config.platform_id == "custom" # Falls back to custom + + def test_config_full_rtmp_url(self): + config = StreamerConfig( + rtmp_url=YOUTUBE_RTMP, + stream_key="abcd-efgh-ijkl", + ) + assert config.full_rtmp_url == f"{YOUTUBE_RTMP}/abcd-efgh-ijkl" + + def test_config_full_rtmp_url_no_key(self): + config = StreamerConfig(rtmp_url=YOUTUBE_RTMP, stream_key="") + assert config.full_rtmp_url == YOUTUBE_RTMP + + def test_config_full_rtmp_url_trailing_slash(self): + config = StreamerConfig( + rtmp_url="rtmp://example.com/live/", + stream_key="key123", + ) + assert config.full_rtmp_url == "rtmp://example.com/live/key123" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Scene management +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestSceneManagement: + """Tests for streaming scene management.""" + + def test_default_scene_exists(self): + streamer = Streamer() + scenes = streamer.get_scenes() + assert len(scenes) == 1 + assert scenes[0]["name"] == "default" + + def test_add_scene(self): + streamer = Streamer() + scene = StreamScene( + name="closeup", + camera_device="/dev/video1", + video_width=640, + video_height=480, + description="Close-up camera", + ) + streamer.add_scene(scene) + scenes = streamer.get_scenes() + assert len(scenes) == 2 + names = [s["name"] for s in scenes] + assert "closeup" in names + + def test_add_duplicate_scene_name(self): + streamer = Streamer() + scene = StreamScene(name="default") + streamer.add_scene(scene) + # Adding with same name should overwrite + scenes = streamer.get_scenes() + assert len(scenes) == 1 + + def test_remove_scene(self): + streamer = Streamer() + scene = StreamScene(name="test") + streamer.add_scene(scene) + assert streamer.remove_scene("test") is True + assert streamer.remove_scene("test") is False + + def test_cannot_remove_default_scene(self): + streamer = Streamer() + assert streamer.remove_scene("default") is False + + def test_switch_scene_not_found(self): + streamer = Streamer() + assert streamer.switch_scene("nonexistent") is False + + def test_switch_scene_updates_config(self): + streamer = Streamer() + scene = StreamScene( + name="wide", + camera_device="/dev/video2", + video_width=1920, + video_height=1080, + video_framerate=24, + ) + streamer.add_scene(scene) + streamer.switch_scene("wide") + assert streamer.config.video_device == "/dev/video2" + assert streamer.config.video_width == 1920 + assert streamer.config.video_height == 1080 + + def test_scene_to_dict(self): + scene = StreamScene( + name="test", + camera_device="/dev/video0", + video_width=1280, + video_height=720, + video_framerate=30, + audio_device="hw:0", + overlay_text="Live!", + description="Test scene", + ) + d = scene.to_dict() + assert d["name"] == "test" + assert d["camera_device"] == "/dev/video0" + assert d["overlay_text"] == "Live!" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Streamer configuration +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestStreamerConfig: + """Tests for StreamerConfig behavior.""" + + def test_auto_reconnect_defaults(self): + streamer = Streamer() + assert streamer.config.auto_reconnect is True + assert streamer.config.max_reconnect_attempts == 5 + assert streamer.config.reconnect_delay_seconds == 3.0 + + def test_encoder_type_default(self): + streamer = Streamer() + assert streamer.config.encoder_type == EncoderType.V4L2_H264 + + def test_low_latency_default(self): + streamer = Streamer() + assert streamer.config.low_latency is True + + def test_scenes_default(self): + streamer = Streamer() + assert streamer.config.active_scene == "default" + assert len(streamer.get_scenes()) == 1 + + def test_create_streamer_factory(self): + streamer = create_streamer( + platform_id="youtube", + stream_key="test-key", + video_device="/dev/video0", + audio_device="hw:0", + ) + assert streamer.state == StreamState.CONFIGURED + assert streamer.config.platform_id == "youtube" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Stream statistics +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestStreamStats: + """Tests for StreamStats data class.""" + + def test_stats_to_dict(self): + stats = StreamStats( + state=StreamState.LIVE, + uptime_seconds=120.5, + video_bitrate_kbps=2500.0, + camera_name="Test Cam", + platform_name="youtube", + ) + d = stats.to_dict() + assert d["state"] == "live" + assert d["uptime_seconds"] == 120.5 + assert d["video_bitrate_kbps"] == 2500.0 + assert d["camera_name"] == "Test Cam" + + def test_stats_copy_is_independent(self): + streamer = Streamer() + stats1 = streamer.stats + stats2 = streamer.stats + assert stats1 is not stats2 # Different objects + + def test_state_change_callback(self): + streamer = Streamer() + received_states = [] + + def callback(state, stats): + received_states.append(state) + + streamer.on_state_change(callback) + streamer.configure(platform_id="youtube") + assert len(received_states) >= 1 + assert StreamState.CONFIGURED in received_states + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Camera detection +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestCameraDetection: + """Tests for camera detection logic (mocked).""" + + def test_camera_info_to_dict(self): + cam = CameraInfo( + device_path="/dev/video0", + camera_type=CameraType.USB_WEBCAM, + name="Test Webcam", + driver="uvcvideo", + resolutions=[ + CameraResolution(640, 480, 30), + CameraResolution(1280, 720, 30), + ], + pixel_formats=["YUYV", "MJPG"], + ) + d = cam.to_dict() + assert d["device_path"] == "/dev/video0" + assert d["camera_type"] == "usb_webcam" + assert len(d["resolutions"]) == 2 + + def test_camera_best_resolution(self): + cam = CameraInfo( + device_path="/dev/video0", + camera_type=CameraType.USB_WEBCAM, + name="Test", + resolutions=[ + CameraResolution(640, 480, 30), + CameraResolution(1920, 1080, 30), + CameraResolution(1280, 720, 60), + ], + ) + best = cam.best_resolution + assert best is not None + assert best.width == 1920 + assert best.height == 1080 + + def test_camera_no_resolutions(self): + cam = CameraInfo( + device_path="/dev/video0", + camera_type=CameraType.USB_WEBCAM, + name="Test", + ) + assert cam.best_resolution is None + + def test_camera_supports_h264(self): + cam = CameraInfo( + device_path="/dev/video0", + camera_type=CameraType.PI_CAMERA, + name="Pi Cam", + pixel_formats=["YUYV", "H264"], + ) + assert cam.supports_h264 is True + + def test_camera_no_h264(self): + cam = CameraInfo( + device_path="/dev/video0", + camera_type=CameraType.USB_WEBCAM, + name="Webcam", + pixel_formats=["YUYV"], + ) + assert cam.supports_h264 is False + + def test_classify_pi_camera(self): + cam = CameraInfo( + device_path="/dev/video0", + camera_type=CameraType.PI_CAMERA, + name="Pi Camera (imx219)", + driver="unicam", + ) + assert cam.camera_type == CameraType.PI_CAMERA + + def test_classify_usb_webcam(self): + cam = CameraInfo( + device_path="/dev/video1", + camera_type=CameraType.USB_WEBCAM, + name="USB Camera", + driver="uvcvideo", + ) + assert cam.camera_type == CameraType.USB_WEBCAM + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Keyboard controls +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestKeyboardControls: + """Tests for keyboard controller logic.""" + + def test_default_hotkeys_defined(self): + assert len(DEFAULT_HOTKEYS) >= 10 + + def test_start_stop_hotkey_exists(self): + actions = [hk.hotkey for hk in DEFAULT_HOTKEYS] + assert StreamHotkey.START_STOP in actions + + def test_scene_hotkeys_exist(self): + actions = [hk.hotkey for hk in DEFAULT_HOTKEYS] + for i in range(1, 10): + scene_action = StreamHotkey(f"scene_{i}") + assert scene_action in actions, f"Missing scene hotkey: scene_{i}" + + def test_hotkey_descriptions(self): + for hk in DEFAULT_HOTKEYS: + assert hk.description, f"Hotkey {hk.hotkey} missing description" + assert hk.key + assert hk.modifiers + + def test_controller_init(self): + streamer = Streamer() + controller = StreamKeyboardController(streamer) + assert controller._running is False + + def test_controller_get_hotkeys(self): + streamer = Streamer() + controller = StreamKeyboardController(streamer) + hotkeys = controller.get_hotkeys() + assert len(hotkeys) == len(DEFAULT_HOTKEYS) + for hk in hotkeys: + assert "action" in hk + assert "modifiers" in hk + assert "key" in hk + assert "description" in hk + + def test_controller_custom_hotkeys(self): + streamer = Streamer() + custom = [ + HotkeyMapping( + StreamHotkey.START_STOP, + {"ctrl"}, "x", + "Custom start/stop", + ), + ] + controller = StreamKeyboardController(streamer, hotkeys=custom) + hotkeys = controller.get_hotkeys() + assert len(hotkeys) == 1 + assert hotkeys[0]["key"] == "x" + + def test_hotkey_mapping_to_dict(self): + hk = HotkeyMapping( + StreamHotkey.START_STOP, + {"ctrl", "shift"}, "s", + "Start/Stop", + ) + assert hk.hotkey == StreamHotkey.START_STOP + assert hk.key == "s" + assert "ctrl" in hk.modifiers + assert "shift" in hk.modifiers + + +# ═══════════════════════════════════════════════════════════════════════════════ +# API endpoint integration (unit-level) +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestStreamAPIProvider: + """Tests for StreamStateProvider and stream route adapter.""" + + def test_provider_initial_state(self): + from src.network.stream_routes import StreamStateProvider + sp = StreamStateProvider() + assert sp.get_status is None + assert sp.start_stream is None + assert sp.list_scenes is None + + def test_provider_can_be_wired(self): + from src.network.stream_routes import StreamStateProvider + sp = StreamStateProvider() + + def dummy_status() -> dict: + return {"state": "idle"} + + sp.get_status = dummy_status + assert sp.get_status() == {"state": "idle"} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Pipeline edge cases +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestPipelineEdgeCases: + """Edge case tests for GStreamer pipeline builder.""" + + def test_mono_audio_channel(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + audio_channels=1, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "channels=1" in pipeline + + def test_custom_audio_encoder(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + audio_encoder="voaacenc", + audio_bitrate_kbps=96, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "voaacenc" in pipeline + + def test_high_bitrate_config(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + video_bitrate_kbps=6000, + video_bitrate_max_kbps=10000, + ) + assert config.video_bitrate_kbps == 6000 + + def test_libcamera_h264_encoder(self): + config = GstPipelineConfig( + video_source=VideoSource.LIBCAMERA, + audio_source=AudioSource.TEST, + video_encoder=EncoderType.LIBCAMERA_H264, + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + # libcamera h264 encoder shouldn't add separate encoder element + assert "libcamerasrc" in pipeline + + def test_empty_rtmp_url_pipeline(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + audio_source=AudioSource.TEST, + rtmp_url="", + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "fakesink" in pipeline + + def test_full_rtmp_url_with_trailing_slash(self): + config = GstPipelineConfig( + rtmp_url="rtmp://example.com/live/", + rtmp_stream_key="stream-key", + ) + assert config.full_rtmp_url == "rtmp://example.com/live/stream-key" + + def test_video_bitrate_max(self): + config = GstPipelineConfig( + video_bitrate_kbps=3000, + video_bitrate_max_kbps=5000, + ) + assert config.video_bitrate_max_kbps == 5000 + + def test_keyframe_interval(self): + config = GstPipelineConfig(keyframe_interval=120) + assert config.keyframe_interval == 120 + + def test_h264_profile_and_level(self): + config = GstPipelineConfig( + h264_profile="high", + h264_level="4.1", + ) + assert config.h264_profile == "high" + assert config.h264_level == "4.1" + + def test_zero_latency_tuning(self): + config = GstPipelineConfig( + low_latency=True, + zerolatency_tune=True, + b_frames=0, + ) + assert config.b_frames == 0 + assert config.zerolatency_tune is True + + def test_audio_buffer_time(self): + config = GstPipelineConfig( + audio_source=AudioSource.ALSA, + audio_buffer_time_us=10000, + ) + assert config.audio_buffer_time_us == 10000 + + def test_test_video_pattern(self): + config = GstPipelineConfig( + video_source=VideoSource.TEST, + test_video_pattern=18, # SMPTE color bars + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "pattern=18" in pipeline + + def test_test_audio_frequency(self): + config = GstPipelineConfig( + audio_source=AudioSource.TEST, + test_audio_freq=1000.0, # 1kHz test tone + ) + builder = GstPipelineBuilder(config) + pipeline = builder.build() + assert "freq=1000.0" in pipeline diff --git a/web/app.js b/web/app.js index 00efbc8..d16e933 100644 --- a/web/app.js +++ b/web/app.js @@ -719,65 +719,515 @@ const App = { }, // ═════════════════════════════════════════════════════════════════════ - // Sessions View + // Sessions View (full session management) // ═════════════════════════════════════════════════════════════════════ + _sessionsData: null, + _setlistsData: null, + _snapshotsData: null, + _currentSetlist: null, + _sessionsSubtab: 'sessions', + buildSessionsView(scenes) { + // Kick off initial loads — scenes from WS are fallback + this.refreshSessions(); + this.refreshAutosaveStatus(); + }, + + refreshSessions() { + const filter = document.getElementById('session-filter')?.value || 'all'; + // Fetch sessions from REST API + fetch('/api/v1/sessions') + .then(r => r.json()) + .then(data => { + this._sessionsData = data.sessions || []; + if (filter === 'all' || filter === 'sessions') { + this._renderSessions(); + } + }) + .catch(e => this.log('Failed to list sessions: ' + e.message, 'log-error')); + + // Also fetch snapshots + fetch('/api/v1/snapshots') + .then(r => r.json()) + .then(data => { + this._snapshotsData = data.snapshots || []; + if (filter === 'all' || filter === 'snapshots') { + this._renderSessions(); + } + }) + .catch(e => this.log('Failed to list snapshots: ' + e.message, 'log-error')); + }, + + _renderSessions() { const container = document.getElementById('sessions-list'); - if (!scenes || scenes.length === 0) { + const filter = document.getElementById('session-filter')?.value || 'all'; + const items = []; + + if (filter === 'all' || filter === 'sessions') { + (this._sessionsData || []).forEach(s => { + items.push({ + type: 'session', + name: s.name, + filename: s.filename, + modified: s.modified, + size: s.size_bytes, + }); + }); + } + + if (filter === 'all' || filter === 'snapshots') { + (this._snapshotsData || []).forEach(s => { + items.push({ + type: 'snapshot', + name: s.name, + category: s.category || '', + isCurrent: s.is_current, + modified: s.created_at, + }); + }); + } + + if (items.length === 0) { container.innerHTML = '
No saved sessions. Save current state above.
'; return; } container.innerHTML = ''; - scenes.forEach(name => { + items.forEach(item => { const card = document.createElement('div'); card.className = 'session-card'; - card.innerHTML = - `
💾 ${this._escapeHTML(name)}
` + - `
` + - `` + - `` + - `
`; - card.addEventListener('click', (e) => { - if (e.target.tagName === 'BUTTON') return; - this.loadScene(name); - }); + if (item.type === 'snapshot') { + const catBadge = item.category ? ` ${this._escapeHTML(item.category)}` : ''; + const curBadge = item.isCurrent ? ' active' : ''; + card.innerHTML = + `
📸 ${this._escapeHTML(item.name)}${catBadge}${curBadge}
` + + `
` + + `` + + `` + + `
`; + } else { + const date = new Date(item.modified * 1000).toLocaleDateString(); + const size = item.size ? (item.size < 1024 ? item.size + 'B' : (item.size / 1024).toFixed(1) + 'KB') : ''; + card.innerHTML = + `
💾 ${this._escapeHTML(item.name)}
` + + `
${date} · ${size}
` + + `
` + + `` + + `` + + `` + + `` + + `
`; + card.addEventListener('click', (e) => { + if (e.target.tagName === 'BUTTON') return; + this.loadSession(item.name); + }); + } container.appendChild(card); }); }, - saveScene() { - const nameInput = document.getElementById('scene-name-input'); + saveSession() { + const nameInput = document.getElementById('session-name-input'); + const notesInput = document.getElementById('session-notes-input'); const name = nameInput.value.trim(); if (!name) { - this.log('Enter a scene name', 'log-error'); + this.log('Enter a session name', 'log-error'); return; } - this.sendParam('snapshot_save', 1, -1, {name: name}); - this.log('Saving scene: ' + name, 'log-state'); - nameInput.value = ''; + const notes = notesInput.value.trim(); + this.log('Saving session: ' + name, 'log-state'); + fetch('/api/v1/sessions/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, overwrite: true }), + }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Session saved: ' + name + ' (' + data.data.channels + ' channels)', 'log-state'); + nameInput.value = ''; + notesInput.value = ''; + this.refreshSessions(); + } else { + this.log('Save failed: ' + (data.error || data.detail), 'log-error'); + } + }) + .catch(e => this.log('Save error: ' + e.message, 'log-error')); }, - loadScene(name) { - this.log('Loading scene: ' + name, 'log-state'); - this.sendParam('snapshot_load', 1, -1, {name: name}); + loadSession(name) { + this.log('Loading session: ' + name, 'log-state'); + fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/load', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Session loaded: ' + name + ' — refresh to see changes', 'log-state'); + } else { + this.log('Load failed: ' + (data.detail || 'unknown'), 'log-error'); + } + }) + .catch(e => this.log('Load error: ' + e.message, 'log-error')); }, - deleteScene(name) { + deleteSession(name) { if (!confirm('Delete session "' + name + '"?')) return; - // Delete via REST API - fetch('/api/v1/scenes/' + encodeURIComponent(name), { method: 'DELETE' }) - .then(() => { - this.log('Deleted scene: ' + name, 'log-state'); - // Remove from local state - if (this.mixerState?.scenes) { - this.mixerState.scenes = this.mixerState.scenes.filter(s => s !== name); - this.buildSessionsView(this.mixerState.scenes); + fetch('/api/v1/sessions/' + encodeURIComponent(name), { method: 'DELETE' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Deleted: ' + name, 'log-state'); + this.refreshSessions(); } }) .catch(e => this.log('Delete failed: ' + e.message, 'log-error')); }, + duplicateSession(name) { + const newName = prompt('Duplicate "' + name + '" as:', name + ' (copy)'); + if (!newName) return; + fetch('/api/v1/sessions/' + encodeURIComponent(name) + '/duplicate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_name: newName }), + }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Duplicated: ' + name + ' → ' + newName, 'log-state'); + this.refreshSessions(); + } else { + this.log('Duplicate failed: ' + (data.detail || ''), 'log-error'); + } + }) + .catch(e => this.log('Duplicate error: ' + e.message, 'log-error')); + }, + + exportSession(name) { + fetch('/api/v1/sessions/export/' + encodeURIComponent(name), { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Exported: ' + data.data.export_path, 'log-state'); + } + }) + .catch(e => this.log('Export failed: ' + e.message, 'log-error')); + }, + + exportAllSessions() { + this.log('Exporting all sessions...', 'log-state'); + window.open('/api/v1/sessions/export-all-zip', '_blank'); + }, + + importSession() { + document.getElementById('session-import-file').click(); + }, + + handleImportFile(input) { + const file = input.files[0]; + if (!file) return; + const newName = prompt('Import as (leave empty to use file name):', ''); + // For local import, we send the filepath. In a real deployment, + // this would upload the file content. For now, work with local paths. + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target.result; + // Send content to server + fetch('/api/v1/sessions/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newName || file.name.replace('.json', ''), + overwrite: false, + }), + }).then(() => { + this.log('To import from file, copy it to ~/.config/rpi-mixer/sessions/ and refresh', 'log-info'); + this.refreshSessions(); + }); + }; + reader.readAsText(file); + input.value = ''; + }, + + // ── Setlists ─────────────────────────────────────────────────────── + + showSessionsSubtab(tab) { + this._sessionsSubtab = tab; + document.querySelectorAll('.subtab').forEach(b => b.classList.toggle('active', b.id === 'subtab-' + tab)); + document.getElementById('sessions-list').style.display = tab === 'sessions' ? '' : 'none'; + document.getElementById('setlists-view').style.display = tab === 'setlists' ? '' : 'none'; + document.getElementById('snapshots-view').style.display = tab === 'snapshots' ? '' : 'none'; + + if (tab === 'setlists') this.refreshSetlists(); + if (tab === 'snapshots') this.refreshSnapshotsUI(); + if (tab === 'sessions') this.refreshSessions(); + }, + + refreshSetlists() { + fetch('/api/v1/setlists') + .then(r => r.json()) + .then(data => { + this._setlistsData = data.setlists || []; + this._renderSetlists(); + }) + .catch(e => this.log('Failed to list setlists: ' + e.message, 'log-error')); + }, + + _renderSetlists() { + const container = document.getElementById('setlists-list'); + const setlists = this._setlistsData || []; + if (setlists.length === 0) { + container.innerHTML = '
No setlists. Create one below.
'; + return; + } + container.innerHTML = ''; + setlists.forEach(sl => { + const card = document.createElement('div'); + card.className = 'session-card'; + card.innerHTML = + `
📋 ${this._escapeHTML(sl.name)}
` + + `
${sl.entry_count || 0} entries
` + + `
` + + `` + + `` + + `` + + `
`; + container.appendChild(card); + }); + }, + + createSetlist() { + const nameInput = document.getElementById('setlist-name-input'); + const name = nameInput.value.trim(); + if (!name) { this.log('Enter setlist name', 'log-error'); return; } + this._currentSetlist = { name, entries: [], description: '' }; + document.getElementById('setlist-editor').style.display = ''; + document.getElementById('setlist-editor-title').textContent = name; + nameInput.value = ''; + this._renderSetlistEditor(); + }, + + editSetlist(name) { + // Find in data and render editor + const sl = (this._setlistsData || []).find(s => s.name === name); + if (!sl) return; + // Load full setlist + fetch('/api/v1/setlists/' + encodeURIComponent(sl.filename.replace('.json', '')) + '/load', { method: 'POST' }) + .then(r => r.json()) + .catch(() => null); + this._currentSetlist = { name: sl.name, entries: [], description: '' }; + document.getElementById('setlist-editor').style.display = ''; + document.getElementById('setlist-editor-title').textContent = sl.name; + this._renderSetlistEditor(); + }, + + _renderSetlistEditor() { + const container = document.getElementById('setlist-entries'); + if (!this._currentSetlist || !this._currentSetlist.entries.length) { + container.innerHTML = '
No entries. Add one below.
'; + return; + } + container.innerHTML = ''; + this._currentSetlist.entries.forEach((entry, i) => { + const row = document.createElement('div'); + row.className = 'setlist-entry-row'; + row.innerHTML = + `${i + 1}. ${this._escapeHTML(entry.session_name)}` + + `${entry.transition || 'cut'}` + + ``; + container.appendChild(row); + }); + }, + + addSetlistEntry() { + if (!this._currentSetlist) { this.log('Create a setlist first', 'log-error'); return; } + const sessionInput = document.getElementById('setlist-entry-session'); + const transitionSelect = document.getElementById('setlist-entry-transition'); + const sessionName = sessionInput.value.trim(); + if (!sessionName) { this.log('Enter session name', 'log-error'); return; } + this._currentSetlist.entries.push({ + session_name: sessionName, + display_name: sessionName, + transition: transitionSelect.value, + transition_duration: 2.0, + wait_for_trigger: false, + notes: '', + }); + sessionInput.value = ''; + this._renderSetlistEditor(); + }, + + removeSetlistEntry(index) { + if (!this._currentSetlist) return; + this._currentSetlist.entries.splice(index, 1); + this._renderSetlistEditor(); + }, + + saveSetlist() { + if (!this._currentSetlist) return; + fetch('/api/v1/setlists/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._currentSetlist), + }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Setlist saved: ' + data.data.name, 'log-state'); + document.getElementById('setlist-editor').style.display = 'none'; + this._currentSetlist = null; + this.refreshSetlists(); + } + }) + .catch(e => this.log('Save setlist failed: ' + e.message, 'log-error')); + }, + + loadSetlist(name) { + fetch('/api/v1/setlists/' + encodeURIComponent(name) + '/load', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Setlist activated: ' + name + ' (' + data.data.progress[0] + '/' + data.data.progress[1] + ')', 'log-state'); + } + }) + .catch(e => this.log('Load setlist failed: ' + e.message, 'log-error')); + }, + + activateSetlist() { + if (!this._currentSetlist) return; + this.loadSetlist(this._currentSetlist.name); + }, + + deleteSetlist(name) { + if (!confirm('Delete setlist "' + name + '"?')) return; + fetch('/api/v1/setlists/' + encodeURIComponent(name), { method: 'DELETE' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Deleted setlist: ' + name, 'log-state'); + this.refreshSetlists(); + } + }) + .catch(e => this.log('Delete setlist failed: ' + e.message, 'log-error')); + }, + + // ── Snapshots ────────────────────────────────────────────────────── + + refreshSnapshotsUI() { + fetch('/api/v1/snapshots') + .then(r => r.json()) + .then(data => { + this._snapshotsData = data.snapshots || []; + this._renderSnapshots(); + }) + .catch(e => this.log('Failed: ' + e.message, 'log-error')); + }, + + _renderSnapshots() { + const container = document.getElementById('snapshots-list'); + const snaps = this._snapshotsData || []; + if (snaps.length === 0) { + container.innerHTML = '
No snapshots. Capture one above.
'; + return; + } + container.innerHTML = ''; + snaps.forEach(snap => { + const card = document.createElement('div'); + card.className = 'session-card'; + if (snap.is_current) card.classList.add('current'); + const catBadge = snap.category ? ` ${this._escapeHTML(snap.category)}` : ''; + card.innerHTML = + `
📸 ${this._escapeHTML(snap.name)}${catBadge}
` + + `
` + + `` + + `` + + `
`; + container.appendChild(card); + }); + }, + + captureSnapshot() { + const nameInput = document.getElementById('snapshot-name-input'); + const catInput = document.getElementById('snapshot-category-input'); + const name = nameInput.value.trim(); + if (!name) { this.log('Enter snapshot name', 'log-error'); return; } + fetch('/api/v1/snapshots/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name, + category: catInput.value, + overwrite: true, + }), + }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Snapshot captured: ' + name, 'log-state'); + nameInput.value = ''; + this.refreshSnapshotsUI(); + } + }) + .catch(e => this.log('Capture failed: ' + e.message, 'log-error')); + }, + + recallSnapshot(name) { + fetch('/api/v1/snapshots/' + encodeURIComponent(name) + '/recall', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) this.log('Snapshot recalled: ' + name, 'log-state'); + }) + .catch(e => this.log('Recall failed: ' + e.message, 'log-error')); + }, + + deleteSnapshot(name) { + if (!confirm('Delete snapshot "' + name + '"?')) return; + fetch('/api/v1/snapshots/' + encodeURIComponent(name), { method: 'DELETE' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + this.log('Deleted snapshot: ' + name, 'log-state'); + this.refreshSnapshotsUI(); + } + }) + .catch(e => this.log('Delete failed: ' + e.message, 'log-error')); + }, + + snapshotNext() { + fetch('/api/v1/snapshots/next', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) this.log('Next snapshot: ' + data.data.name, 'log-state'); + }) + .catch(e => this.log('Next failed: ' + e.message, 'log-error')); + }, + + snapshotPrev() { + fetch('/api/v1/snapshots/previous', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) this.log('Previous snapshot: ' + data.data.name, 'log-state'); + }) + .catch(e => this.log('Prev failed: ' + e.message, 'log-error')); + }, + + // ── Auto-save ────────────────────────────────────────────────────── + + refreshAutosaveStatus() { + fetch('/api/v1/sessions/autosave/status') + .then(r => r.json()) + .then(data => { + const indicator = document.getElementById('autosave-indicator'); + if (data.data && data.data.auto_save_enabled) { + indicator.textContent = '● Auto-save active'; + indicator.style.color = 'var(--success-color)'; + } else { + indicator.textContent = '○ Auto-save off'; + indicator.style.color = 'var(--text-faint)'; + } + }) + .catch(() => {}); + }, + // ═════════════════════════════════════════════════════════════════════ // Sending // ═════════════════════════════════════════════════════════════════════ diff --git a/web/index.html b/web/index.html index adfa515..0a9110b 100644 --- a/web/index.html +++ b/web/index.html @@ -109,13 +109,72 @@
-

Saved Sessions

+

Mixer Sessions

- - + + + + +
+ +
● Auto-save active
+ + +
+ + + +
+ +
+ + + + + + + + +
+ + + +
diff --git a/web/style.css b/web/style.css index f451716..80613c1 100644 --- a/web/style.css +++ b/web/style.css @@ -797,6 +797,89 @@ input, button, select, textarea { .session-card-actions button.danger { color: var(--danger); } .session-card-actions button.danger:hover { background: var(--danger); color: var(--text); } +/* Session card info line */ +.session-card-info { + font-size: var(--font-size-xs); + color: var(--text-faint); + margin-top: 0.15rem; +} + +/* Session card badges */ +.badge { + display: inline-block; + padding: 0.1rem 0.3rem; + font-size: 0.55rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-dim); + vertical-align: middle; +} +.badge.active { + background: var(--connected); + color: var(--bg); + border-color: var(--connected); +} + +/* Current snapshot card highlight */ +.session-card.current { + border-color: var(--accent); + box-shadow: 0 0 4px var(--accent-dim); +} + +/* Sessions subtabs */ +.sessions-subtabs { + display: flex; + gap: 0px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.subtab { + padding: 0.35rem 0.75rem; + font-size: var(--font-size-xs); + background: none; + color: var(--text-dim); + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + min-height: var(--touch-min); + transition: color 0.15s, border-color 0.15s; +} +.subtab:hover { color: var(--text); } +.subtab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* Auto-save status */ +.autosave-status { + padding: 0.25rem 0.5rem; + font-size: var(--font-size-xs); + color: var(--text-faint); + flex-shrink: 0; +} + +/* Setlist entry row */ +.setlist-entry-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 0.3rem; + font-size: var(--font-size-sm); +} +.setlist-entry-row span { flex: 1; } +.setlist-entry-row button { + padding: 0.2rem 0.4rem; + background: var(--btn-bg); + color: var(--danger); + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; + min-height: 28px; + font-size: var(--font-size-xs); +} + /* ── Modal (EQ) ────────────────────────────────────────────────────────── */ .modal { position: fixed;