diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 50108d8..de6244c 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -19,7 +19,19 @@ const GROWING_PATH = process.env.GROWING_PATH || '/growing'; // by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount // (the host-bound /growing volume is used instead, or S3 streaming if growing // is off). -const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || ''; +// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often +// store the share as an `smb://host/share` URL or a Windows `\\host\share` +// path; the kernel rejects those outright ("Mounting cifs URL not implemented +// yet"), which silently drops us back to S3. Normalize any of these forms to +// the `//host/share` UNC the mount helper accepts. +function toUncShare(raw) { + if (!raw) return ''; + let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share + s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share + if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share + return s; +} +const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || ''); const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || ''; const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || ''; const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0'; diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index c846483..abb3ba9 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -1,6 +1,6 @@ import { AmcpClient } from './amcp.js'; import { spawn } from 'node:child_process'; -import { mkdirSync } from 'node:fs'; +import { mkdirSync, readdirSync, unlinkSync } from 'node:fs'; // Playout manager — owns one CasparCG channel's lifecycle inside this sidecar. // @@ -211,6 +211,20 @@ export class PlayoutManager { _startHlsRemux() { if (!HLS_DIR) return; try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {} + // Purge stale HLS artifacts from any prior session before starting. The + // /media volume is a shared host bind, so a previous (or duplicate/failover) + // sidecar can leave orphaned index*.ts + an old index.m3u8 behind. ffmpeg's + // index%d.ts counter restarts at 0, so those leftovers collide with the new + // segment numbering and can briefly corrupt the live playlist hls.js reads + // (it sees a frozen / non-monotonic edge → monitor goes black). A clean dir + // per session guarantees a coherent live timeline. + try { + for (const f of readdirSync(HLS_DIR)) { + if (/\.ts$/.test(f) || /\.m3u8$/.test(f)) { + try { unlinkSync(`${HLS_DIR}/${f}`); } catch (_) {} + } + } + } catch (_) {} this._stopHlsRemux(); const out = `${HLS_DIR}/index.m3u8`; diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 9da9262..09c8369 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -997,6 +997,7 @@ function Capture({ navigate }) { /* ===== Monitors ===== */ function Monitors({ navigate }) { const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []); + const [channels, setChannels] = React.useState([]); const [grid, setGrid] = React.useState(4); React.useEffect(() => { @@ -1008,6 +1009,11 @@ function Monitors({ navigate }) { setRecorders(norm); }) .catch(() => {}); + // Playout channels surface here too so an operator can watch on-air + // output alongside ingest. Degrade silently if the endpoint is absent. + window.ZAMPP_API.fetch('/playout/channels') + .then(raw => setChannels(Array.isArray(raw) ? raw : [])) + .catch(() => setChannels([])); }; refresh(); const id = setInterval(refresh, 5000); @@ -1032,18 +1038,87 @@ function Monitors({ navigate }) {
- {feeds.length === 0 ? ( -
No active feeds. Start a recorder to see live video here.
+ {feeds.length === 0 && channels.length === 0 ? ( +
No active feeds. Start a recorder or playout channel to see live video here.
) : ( -
- {feeds.map((f, i) => )} -
+ + {feeds.length > 0 && ( + +
Ingest
+
+ {feeds.map((f, i) => )} +
+
+ )} + {channels.length > 0 && ( + +
Playout
+
+ {channels.slice(0, grid * grid).map(c => )} +
+
+ )} +
)}
); } +function PlayoutMonitorTile({ channel }) { + const videoRef = React.useRef(null); + const hlsRef = React.useRef(null); + const onAir = channel.status === 'running'; + const previewUrl = '/api/v1/playout/channels/' + channel.id + '/hls/index.m3u8'; + + React.useEffect(() => { + const vid = videoRef.current; + if (!vid) return; + if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; } + if (!onAir) { vid.src = ''; return; } + + if (window.Hls && window.Hls.isSupported()) { + const hls = new window.Hls({ + liveSyncDurationCount: 3, + liveMaxLatencyDurationCount: 6, + xhrSetup: (xhr) => { xhr.withCredentials = true; }, + }); + hlsRef.current = hls; + hls.loadSource(previewUrl); + hls.attachMedia(vid); + hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {})); + } else if (vid.canPlayType('application/vnd.apple.mpegurl')) { + vid.src = previewUrl; + vid.play().catch(() => {}); + } + return () => { + if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; } + }; + }, [onAir, channel.id]); + + return ( +
+ {onAir ? ( +