- frontend types & components now use lowercase codec values (prores/dnxhd/
h264/uncompressed) to match backend CodecType enum. This was the real
cause of the silent Start Recording 422.
- recorder.py: replaced last .done() call on asyncio.subprocess.Process
with returncode-is-None check (same bug previously fixed in hls.py).
This was causing /api/ports/{n} to 500 during active recording and
health checks to fail while any port was running.
- useRecorder now parses pydantic 422 error bodies so field-level detail
reaches the UI, and App.tsx shows a fixed error banner.
- Added docker-compose.truenas.yml: no deltacast device passthrough,
frontend on :8088. Lavfi testsrc2 fallback kicks in automatically and
feeds the full HLS preview + recording pipeline with a test signal
(1080p30 + 1kHz tone). Verified end-to-end on Wooglin: 442MB/1:56s
H.264 MP4 + working HLS playlist.
168 lines
6.1 KiB
Python
168 lines
6.1 KiB
Python
"""Recorder management for Deltacast SDI recording ports."""
|
|
|
|
import asyncio
|
|
import re
|
|
import time
|
|
import logging
|
|
from ..config import Settings
|
|
from ..models import RecorderConfig, PortStatus, CodecType
|
|
from ..utils.ffmpeg import FFmpegCommandBuilder
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PortRecorder:
|
|
"""Manages FFmpeg recording subprocess for a single Deltacast port."""
|
|
|
|
def __init__(self, port_index: int, settings: Settings):
|
|
self.port_index = port_index
|
|
self.settings = settings
|
|
self._process: asyncio.subprocess.Process | None = None
|
|
self._config: RecorderConfig | None = None
|
|
self._start_time: float | None = None
|
|
self._frame_count: int = 0
|
|
self._current_file: str = ""
|
|
self._monitor_task: asyncio.Task | None = None
|
|
self._command_builder = FFmpegCommandBuilder(settings)
|
|
|
|
async def start(self, config: RecorderConfig) -> None:
|
|
if self._process is not None:
|
|
raise RuntimeError(f"Port {self.port_index} is already recording")
|
|
|
|
self._config = config
|
|
self._current_file = config.recording_path
|
|
self._frame_count = 0
|
|
self._start_time = time.time()
|
|
|
|
command = self._command_builder.build_command(config)
|
|
logger.info(f"Port {self.port_index}: Starting FFmpeg: {' '.join(command)}")
|
|
|
|
self._process = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
self._monitor_task = asyncio.create_task(self._monitor_process())
|
|
logger.info(f"Port {self.port_index}: Recording started (PID: {self._process.pid})")
|
|
|
|
async def stop(self) -> None:
|
|
if self._process is None:
|
|
logger.info(f"Port {self.port_index}: Already stopped")
|
|
return
|
|
|
|
logger.info(f"Port {self.port_index}: Stopping recording (PID: {self._process.pid})")
|
|
|
|
try:
|
|
if self._process.stdin and not self._process.stdin.is_closing():
|
|
self._process.stdin.write(b"q\n")
|
|
await self._process.stdin.drain()
|
|
|
|
try:
|
|
await asyncio.wait_for(self._process.wait(), timeout=3.0)
|
|
self._process = None
|
|
return
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"Port {self.port_index}: Graceful quit timeout, sending SIGTERM")
|
|
|
|
self._process.terminate()
|
|
|
|
try:
|
|
await asyncio.wait_for(self._process.wait(), timeout=2.0)
|
|
self._process = None
|
|
return
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"Port {self.port_index}: SIGTERM timeout, sending SIGKILL")
|
|
|
|
self._process.kill()
|
|
await self._process.wait()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Port {self.port_index}: Error stopping process: {e}")
|
|
finally:
|
|
self._process = None
|
|
if self._monitor_task and not self._monitor_task.done():
|
|
self._monitor_task.cancel()
|
|
try:
|
|
await self._monitor_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._start_time = None
|
|
|
|
def get_status(self) -> PortStatus:
|
|
is_recording = self._process is not None and self._process.returncode is None
|
|
|
|
uptime_seconds = 0
|
|
if self._start_time is not None:
|
|
uptime_seconds = int(time.time() - self._start_time)
|
|
|
|
fps = 0.0
|
|
if uptime_seconds > 0:
|
|
fps = self._frame_count / uptime_seconds
|
|
|
|
bitrate_mbps = 0.0
|
|
codec = self._config.codec if self._config else CodecType.H264
|
|
|
|
return PortStatus(
|
|
port_index=self.port_index,
|
|
is_recording=is_recording,
|
|
frame_count=self._frame_count,
|
|
fps=fps,
|
|
bitrate_mbps=bitrate_mbps,
|
|
uptime_seconds=uptime_seconds,
|
|
current_file=self._current_file,
|
|
codec=codec,
|
|
)
|
|
|
|
async def _monitor_process(self) -> None:
|
|
if self._process is None or self._process.stderr is None:
|
|
return
|
|
|
|
try:
|
|
async for line in self._process.stderr:
|
|
try:
|
|
line_str = line.decode("utf-8", errors="ignore").strip()
|
|
if not line_str:
|
|
continue
|
|
match = re.search(r"frame=\s*(\d+)", line_str)
|
|
if match:
|
|
self._frame_count = int(match.group(1))
|
|
except Exception as e:
|
|
logger.error(f"Port {self.port_index}: Error parsing stderr: {e}")
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Port {self.port_index}: Monitoring process error: {e}")
|
|
|
|
|
|
class RecorderManager:
|
|
"""Manages recording on all Deltacast ports."""
|
|
|
|
def __init__(self, settings: Settings):
|
|
self.settings = settings
|
|
self._recorders: dict[int, PortRecorder] = {}
|
|
|
|
def initialize(self) -> None:
|
|
for port_index in range(self.settings.deltacast_port_count):
|
|
self._recorders[port_index] = PortRecorder(port_index, self.settings)
|
|
logger.info(f"Initialized {self.settings.deltacast_port_count} port recorders")
|
|
|
|
async def start_recording(self, port_index: int, config: RecorderConfig) -> None:
|
|
if port_index not in self._recorders:
|
|
raise ValueError(f"Invalid port index {port_index}.")
|
|
await self._recorders[port_index].start(config)
|
|
|
|
async def stop_recording(self, port_index: int) -> None:
|
|
if port_index not in self._recorders:
|
|
raise ValueError(f"Invalid port index {port_index}.")
|
|
await self._recorders[port_index].stop()
|
|
|
|
def get_status(self, port_index: int) -> PortStatus:
|
|
if port_index not in self._recorders:
|
|
raise ValueError(f"Invalid port index {port_index}.")
|
|
return self._recorders[port_index].get_status()
|
|
|
|
def get_all_status(self) -> list[PortStatus]:
|
|
return [self._recorders[i].get_status()
|
|
for i in range(self.settings.deltacast_port_count)]
|