"""StreamManager: coordinates StreamRecorder and SessionProcessor. Thin facade that keeps the existing public API intact while delegating to two focused classes: StreamRecorder — ffmpeg network receiver + fMP4 recorder + scene detection (to be replaced by cht-server in Rust in a future phase) SessionProcessor — audio extraction from fMP4 (stays Python; reads files regardless of how they were written) Callers (lifecycle.py, window.py) use StreamManager as before — no changes needed there. """ import logging import time from pathlib import Path from cht.config import SCENE_THRESHOLD, SESSIONS_DIR from cht.stream.recorder import StreamRecorder from cht.stream.processor import SessionProcessor log = logging.getLogger(__name__) def list_sessions(): """Return list of (session_id, session_dir) sorted newest first.""" if not SESSIONS_DIR.exists(): return [] sessions = [] for d in sorted(SESSIONS_DIR.iterdir(), reverse=True): if d.is_dir() and (d / "frames").exists(): sessions.append((d.name, d)) return sessions def delete_sessions(session_ids): """Delete session directories by ID.""" import shutil for sid in session_ids: path = SESSIONS_DIR / sid if path.exists() and path.is_dir(): shutil.rmtree(path) log.info("Deleted session: %s", sid) class StreamManager: def __init__(self, session_id=None): if session_id is None: session_id = time.strftime("%Y%m%d_%H%M%S") self.session_id = session_id self.session_dir = SESSIONS_DIR / session_id self.stream_dir = self.session_dir / "stream" self.frames_dir = self.session_dir / "frames" self.transcript_dir = self.session_dir / "transcript" self.audio_dir = self.session_dir / "audio" self.agent_dir = self.session_dir / "agent" self.readonly = False self.telemetry = None self.recorder = StreamRecorder(self.session_dir) self.processor = SessionProcessor(self.session_dir) self.processor.attach( get_recording_path=lambda: self.recorder.recording_path, get_current_global_offset=lambda: self.recorder.current_global_offset, ) # Wire recorder pipe output → processor frame handling self.recorder.set_on_raw_frame(self.processor.on_raw_frame) log.info("Session: %s", session_id) @classmethod def from_rust_session(cls, session_dir: Path): """Attach to a live session being recorded by cht-server (Rust). No StreamRecorder is started — Rust owns the TCP + fMP4 + UDP relay. SessionProcessor handles audio extraction from the growing fMP4. Scene detection pipe is also skipped (Rust will handle it eventually). """ mgr = cls.__new__(cls) mgr.session_id = session_dir.name mgr.session_dir = session_dir mgr.stream_dir = session_dir / "stream" mgr.frames_dir = session_dir / "frames" mgr.transcript_dir = session_dir / "transcript" mgr.audio_dir = session_dir / "audio" mgr.agent_dir = session_dir / "agent" mgr.readonly = False mgr.telemetry = None # No recorder — Rust server owns transport + recording. mgr.recorder = None mgr.processor = SessionProcessor(session_dir) mgr.processor.attach( get_recording_path=lambda: next(iter(sorted(mgr.stream_dir.glob("recording_*.mp4"))), None) if mgr.stream_dir.exists() else None, get_current_global_offset=lambda: 0.0, ) for d in (mgr.stream_dir, mgr.frames_dir, mgr.transcript_dir, mgr.audio_dir, mgr.agent_dir): d.mkdir(parents=True, exist_ok=True) log.info("Attached to Rust session: %s", mgr.session_id) return mgr @classmethod def from_existing(cls, session_id): """Load an existing session without starting any ffmpeg processes.""" from cht.session import rebuild_manifest mgr = cls(session_id=session_id) if not mgr.session_dir.exists(): raise FileNotFoundError(f"Session not found: {session_id}") mgr.readonly = True mgr.recorder._segment = max(0, len(mgr.recorder.recording_segments) - 1) mgr.recorder._rebuild_offsets() rebuild_manifest(mgr.session_dir) log.info("Loaded existing session: %s (%d segments, %d frames)", session_id, len(mgr.recorder.recording_segments), mgr.frame_count) return mgr # -- Recorder delegation -- @property def scene_threshold(self) -> float: return self.recorder.scene_threshold if self.recorder else 0.10 @property def relay_url(self) -> str: return self.recorder.relay_url if self.recorder else "udp://127.0.0.1:4445" @property def recording_path(self) -> Path: if self.recorder: return self.recorder.recording_path return next(iter(sorted(self.stream_dir.glob("recording_*.mp4"))), None) @property def recording_segments(self) -> list[Path]: if self.recorder: return self.recorder.recording_segments return sorted(self.stream_dir.glob("recording_*.mp4")) @property def current_global_offset(self) -> float: return self.recorder.current_global_offset if self.recorder else 0.0 @property def frame_count(self) -> int: return self.processor.frame_count def setup_dirs(self): for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.audio_dir, self.agent_dir): d.mkdir(parents=True, exist_ok=True) def start_recorder(self): if self.recorder: self.recorder.start() def restart_recorder(self): if self.recorder: self.recorder.restart() def recorder_alive(self) -> bool: return self.recorder.alive() if self.recorder else True # Rust owns it def start_scene_detector(self, on_new_frames=None): # GUI callback always goes to the processor — it fires on_new_frames # after writing the JPEG to disk, regardless of how it got the frame. self.processor.set_on_new_frames(on_new_frames) if not self.recorder: # Rust transport: processor connects to scene.sock and runs its own ffmpeg. self.processor.start_scene_detector(threshold=SCENE_THRESHOLD) def capture_now(self, on_new_frames=None): self.processor.set_on_new_frames(on_new_frames) if self.recorder: self.recorder.capture_now(on_raw_frame=self.processor.on_captured_frame) else: # Rust mode: extract current frame directly from the growing fMP4. self.processor.capture_now_from_file() def update_scene_threshold(self, new_threshold: float): if self.recorder: self.recorder.update_scene_threshold(new_threshold) else: # Rust mode: restart scene detector with new threshold. self.processor.restart_scene_detector(threshold=new_threshold) # -- Processor delegation -- def start_audio_extractor(self, on_new_audio=None): self.processor.start_audio_extractor(on_new_audio=on_new_audio) # -- Session-level -- def total_duration(self) -> float: total = 0.0 for seg in self.recording_segments: try: import ffmpeg as ffmpeg_lib info = ffmpeg_lib.probe(str(seg)) dur = float(info.get("format", {}).get("duration", 0)) if dur <= 0: for s in info.get("streams", []): sdur = float(s.get("duration", 0)) if sdur > 0: dur = sdur break if dur <= 0: dur = seg.stat().st_size / 65_000 total += dur except Exception: total += seg.stat().st_size / 65_000 return total def stop_all(self): log.info("Stopping all...") self.processor.stop() if self.recorder: self.recorder.stop()