From e7a86d6f2d77d4d148d6b512eda95c6a9633e52f Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 18 Sep 2026 14:27:38 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20go2rtc=20mjpeg=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=EB=8D=94=20=EB=A9=88=EC=B6=A4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B0=90=EC=A7=80/=EB=B3=B5=EA=B5=AC=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프린터실 카메라의 go2rtc mjpeg 가상 스트림이 카메라 wifi 순단 등으로 멈추면 뷰어가 재연결해도 같은 멈춘 프로세스에 붙어 계속 안 나오는 문제가 있었음. /api/nvr/mjpeg-health가 컨트롤 탭 로드 시 go2rtc 스트림 상태를 확인해 정체된 트랜스코더만 재시작한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu --- src/gateway/routes/nvr.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/gateway/routes/nvr.ts b/src/gateway/routes/nvr.ts index 5dee0a1..a142476 100644 --- a/src/gateway/routes/nvr.ts +++ b/src/gateway/routes/nvr.ts @@ -835,4 +835,42 @@ export function registerNvrRoutes(app: Express): void { res.status(502).json({ error: String(e?.message || e) }); } }); + + // go2rtc의 ffmpeg mjpeg 트랜스코더가 업스트림(카메라 와이파이) 순단으로 멈춰버리는 경우가 + // 있어서, 뷰어가 페이지를 열 때(웹캠 이미지 로드 직전) 이 엔드포인트로 살아있는지 확인하고 + // 멈춰있으면 그 프로세스만 죽여서 go2rtc가 새로 띄우게 한다. 아무도 안 보고 있어서 프로듀서가 + // 아예 없으면 아무 것도 안 함(불필요하게 인코딩을 깨우지 않음). 이미 정상 수신 중이면 그대로 둠 + // (여러 명이 동시에 봐도 공유 스트림이라 매번 재시작하지 않음). + app.post('/api/nvr/mjpeg-health', async (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + try { + const src = String(req.body?.src || 'cam_mjpeg').replace(/[^a-zA-Z0-9_-]/g, ''); + const container = String(req.body?.container || 'go2rtc-printroom').replace(/[^a-zA-Z0-9_-]/g, ''); + const sample = async () => { + const r = await fetch('http://127.0.0.1:1985/api/streams'); + const data = (await r.json()) as Record; + const producer = data?.[src]?.producers?.[0]; + const bytes = producer?.receivers?.[0]?.bytes ?? null; + return { active: !!producer, bytes }; + }; + const before = await sample(); + if (!before.active) { + return res.json({ ok: true, action: 'none', reason: 'no viewer, producer not running' }); + } + await new Promise((r) => setTimeout(r, 400)); + const after = await sample(); + if (after.bytes == null || before.bytes == null || after.bytes > before.bytes) { + return res.json({ ok: true, action: 'none', reason: 'healthy' }); + } + await new Promise((resolve) => { + const p = spawn('docker', ['exec', container, 'sh', '-c', `pkill -9 -f 'video%3Dmjpeg'`]); + p.on('close', () => resolve()); + p.on('error', () => resolve()); + }); + res.json({ ok: true, action: 'restarted', reason: 'stalled transcoder killed' }); + } catch (e: any) { + res.status(502).json({ error: String(e?.message || e) }); + } + }); }