Files
mitus/cht/stream/manager.py

225 lines
8.1 KiB
Python

"""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()