From 17916571b3af736ad0aa7f6e3b17bdcaa5ecfb77 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Tue, 14 Apr 2026 16:20:57 -0400 Subject: [PATCH] fix: lowercase codec enum, recorder .done() bug, TrueNAS deploy override - 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. --- backend/app/recorders/recorder.py | 2 +- docker-compose.truenas.yml | 47 +++++++++++++++++++++++++ frontend/src/App.tsx | 15 ++++++-- frontend/src/components/ConfigPanel.tsx | 14 ++++---- frontend/src/hooks/useRecorder.ts | 21 ++++++++--- frontend/src/types.ts | 2 +- 6 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 docker-compose.truenas.yml diff --git a/backend/app/recorders/recorder.py b/backend/app/recorders/recorder.py index e5df2a6..e38f694 100644 --- a/backend/app/recorders/recorder.py +++ b/backend/app/recorders/recorder.py @@ -90,7 +90,7 @@ class PortRecorder: self._start_time = None def get_status(self) -> PortStatus: - is_recording = self._process is not None and not self._process.done() + is_recording = self._process is not None and self._process.returncode is None uptime_seconds = 0 if self._start_time is not None: diff --git a/docker-compose.truenas.yml b/docker-compose.truenas.yml new file mode 100644 index 0000000..7e9003f --- /dev/null +++ b/docker-compose.truenas.yml @@ -0,0 +1,47 @@ +version: '3.8' + +# TrueNAS (Wooglin) override — no physical Deltacast card. +# The backend auto-detects missing /dev/deltacast{n} and falls back to +# a lavfi testsrc2 signal (colour bars + clock + 1 kHz tone) per port. + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + # NOTE: no devices: block — lavfi fallback kicks in automatically + volumes: + - recordings:/recordings + - hls:/tmp/hls + environment: + - FFMPEG_PATH=/usr/bin/ffmpeg + - RECORDING_DIR=/recordings + - DELTACAST_PORT_COUNT=8 + - SRT_ENABLED=true + - SRT_LATENCY=5000 + - PORT=8000 + - LOG_LEVEL=INFO + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "8088:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + recordings: + driver: local + hls: + driver: local diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 231885b..d141b74 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ export default function App() { const wsUrl = `ws://${window.location.host}/ws`; const { isConnected, lastMessage } = useWebSocket(wsUrl); - const { startRecording, stopRecording, injectSCTE35, injectSCTE35ForPort } = useRecorder(); + const { startRecording, stopRecording, injectSCTE35, injectSCTE35ForPort, error: recorderError } = useRecorder(); // Clock useEffect(() => { @@ -88,6 +88,17 @@ export default function App() { return (
+ {recorderError && ( +
+ ⚠ {recorderError} +
+ )} + {/* ── HEADER ── */}
('PRORES'); + const [codec, setCodec] = useState('prores'); const [bitrate, setBitrate] = useState('185M'); const [qualityProfile, setQualityProfile] = useState('hq'); const [recordingPath, setRecordingPath] = useState(`/recordings/port_${portIndex}_{timestamp}.mxf`); @@ -136,13 +136,13 @@ export function ConfigPanel({ portIndex, currentConfig, onSave, onClose }: Confi
- {codec === 'PRORES' && ( + {codec === 'prores' && (
)} - {(codec === 'DNXHD' || codec === 'H264') && ( + {(codec === 'dnxhd' || codec === 'h264') && (
setBitrate(e.target.value)} placeholder="185M, 50M..." style={inputStyle} /> diff --git a/frontend/src/hooks/useRecorder.ts b/frontend/src/hooks/useRecorder.ts index 31025d0..7f41e15 100644 --- a/frontend/src/hooks/useRecorder.ts +++ b/frontend/src/hooks/useRecorder.ts @@ -3,6 +3,19 @@ import { RecorderConfig, SCTE35Marker } from '../types'; const API_BASE = '/api'; +async function readApiError(response: Response, fallback: string): Promise { + try { + const body = await response.json(); + if (body?.detail) { + if (Array.isArray(body.detail)) { + return body.detail.map((d: any) => `${(d.loc || []).join('.')}: ${d.msg}`).join('; '); + } + if (typeof body.detail === 'string') return body.detail; + } + } catch { /* fall through */ } + return `${fallback} (HTTP ${response.status} ${response.statusText || ''})`.trim(); +} + export function useRecorder() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -16,7 +29,7 @@ export function useRecorder() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }); - if (!response.ok) throw new Error(`Failed to start recording: ${response.statusText}`); + if (!response.ok) { const msg = await readApiError(response, `Failed to start recording`); throw new Error(msg); } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; setError(msg); @@ -34,7 +47,7 @@ export function useRecorder() { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); - if (!response.ok) throw new Error(`Failed to stop recording: ${response.statusText}`); + if (!response.ok) { const msg = await readApiError(response, `Failed to stop recording`); throw new Error(msg); } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; setError(msg); @@ -62,7 +75,7 @@ export function useRecorder() { webhook_url: webhookUrl || null, }), }); - if (!response.ok) throw new Error(`Failed to inject SCTE35: ${response.statusText}`); + if (!response.ok) { const msg = await readApiError(response, `Failed to inject SCTE35`); throw new Error(msg); } return await response.json() as SCTE35Marker; } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; @@ -94,7 +107,7 @@ export function useRecorder() { srt_destination_url: srtDestinationUrl || null, }), }); - if (!response.ok) throw new Error(`Failed to inject SCTE35 on port ${portIndex}: ${response.statusText}`); + if (!response.ok) { const msg = await readApiError(response, `Failed to inject SCTE35 on port ${portIndex}`); throw new Error(msg); } return await response.json() as SCTE35Marker; } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b24fdda..bbd7f89 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -10,7 +10,7 @@ export interface PortStatus { srt_destinations: string[]; } -export type CodecType = 'PRORES' | 'DNXHD' | 'UNCOMPRESSED' | 'H264'; +export type CodecType = 'prores' | 'dnxhd' | 'uncompressed' | 'h264'; export interface SRTDestination { url: string;