mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
fix(camera): stream an external RTSP camera that describes itself late (issue #3082)
An external camera could pass the connection test, play in VLC, and show a black live view that gave up after a few seconds. The two RTSP paths were not asking ffmpeg for the same thing. The one-shot _capture_rtsp_frame passed no probe settings and got ffmpeg's defaults; _stream_rtsp hard-coded -probesize 32 -analyzeduration 0. Thirty-two bytes is enough for a camera that puts its H.264 parameters in the SDP, and not enough for one that sends them in-band a moment later -- a WebRTC source republished through go2rtc, in the reporter's case. ffmpeg then starts no decoder and yields nothing at all, which is why the test button kept passing while the live view stayed black. Those settings were never chosen for external cameras: they arrived with the P2S TLS proxy (#661) as fast-start tuning for the printer camera path, where the source is a known Bambu model, and were copied here in the same commit. This path has no model to tune against and belongs on the defaults, which are a ceiling rather than a wait -- a camera that announces itself in the first packet still starts as fast as it did. Drop the probe cap from _stream_rtsp. Keep -fflags nobuffer and -flags low_delay, which bear on how long ffmpeg sits on frames it already has rather than how long it may look before it has any. Leave _capture_rtsp_frame and the per-model printer profiles alone. Tests pin the absence of both flags, the presence of the low-latency ones, and the property underneath: both RTSP paths must probe alike, or passing the test button again means nothing about the live view. The ffmpeg subprocess fakes move to backend/tests/_fixtures/external_camera.py so the SSRF suite and this one share one definition.
This commit is contained in:
@@ -14,7 +14,7 @@ recognises a destination however it is written, and a real camera — which
|
||||
usually means an authenticated one — still works.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -25,6 +25,7 @@ from backend.app.services.external_camera import (
|
||||
_sanitize_camera_url,
|
||||
_stream_rtsp,
|
||||
)
|
||||
from backend.tests._fixtures.external_camera import fake_ffmpeg, spawn_spy
|
||||
|
||||
RTSP_SCHEMES = ("rtsp", "rtsps")
|
||||
HTTP_SCHEMES = ("http", "https")
|
||||
@@ -155,31 +156,6 @@ class TestSchemeAllowlist:
|
||||
assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
|
||||
|
||||
|
||||
def _fake_ffmpeg():
|
||||
return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
|
||||
|
||||
|
||||
def _spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
|
||||
"""Stand in for the ffmpeg subprocess, recording the argv it was handed.
|
||||
|
||||
The streaming path reads until EOF, so stdout.read returns b"" and the
|
||||
generator finishes immediately — these tests are about whether ffmpeg was
|
||||
launched and with what, not about frame extraction.
|
||||
"""
|
||||
process = MagicMock()
|
||||
process.returncode = returncode
|
||||
process.communicate = AsyncMock(return_value=(stdout, b""))
|
||||
process.stdout.read = AsyncMock(return_value=b"")
|
||||
process.stderr.read = AsyncMock(return_value=b"")
|
||||
process.wait = AsyncMock(return_value=returncode)
|
||||
process.kill = MagicMock()
|
||||
process.terminate = MagicMock()
|
||||
return patch(
|
||||
"backend.app.services.external_camera.asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=process),
|
||||
)
|
||||
|
||||
|
||||
class TestRtspCaptureRefusesUnsafeUrls:
|
||||
"""`_capture_rtsp_frame` — the one-shot path behind the test-connection
|
||||
endpoint, which takes url and camera_type straight off the query string."""
|
||||
@@ -197,13 +173,13 @@ class TestRtspCaptureRefusesUnsafeUrls:
|
||||
],
|
||||
)
|
||||
async def test_no_process_is_spawned(self, url):
|
||||
with _fake_ffmpeg(), _spawn_spy() as spawn:
|
||||
with fake_ffmpeg(), spawn_spy() as spawn:
|
||||
assert await _capture_rtsp_frame(url, timeout=5) is None
|
||||
spawn.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_real_camera_still_captures(self):
|
||||
with _fake_ffmpeg(), _spawn_spy() as spawn:
|
||||
with fake_ffmpeg(), spawn_spy() as spawn:
|
||||
frame = await _capture_rtsp_frame("rtsp://admin:hunter2@192.168.1.50:554/live", timeout=5)
|
||||
|
||||
assert frame is not None
|
||||
@@ -216,7 +192,7 @@ class TestRtspCaptureRefusesUnsafeUrls:
|
||||
async def test_ffmpeg_is_confined_to_rtsp_protocols(self):
|
||||
"""Belt and braces behind the scheme check: a stream that references
|
||||
something outside itself must not be able to pull it in."""
|
||||
with _fake_ffmpeg(), _spawn_spy() as spawn:
|
||||
with fake_ffmpeg(), spawn_spy() as spawn:
|
||||
await _capture_rtsp_frame("rtsp://192.168.1.50:554/live", timeout=5)
|
||||
|
||||
cmd = spawn.await_args.args
|
||||
@@ -240,7 +216,7 @@ class TestRtspStreamRefusesUnsafeUrls:
|
||||
],
|
||||
)
|
||||
async def test_no_process_is_spawned(self, url):
|
||||
with _fake_ffmpeg(), _spawn_spy() as spawn:
|
||||
with fake_ffmpeg(), spawn_spy() as spawn:
|
||||
frames = [frame async for frame in _stream_rtsp(url, fps=5)]
|
||||
|
||||
assert frames == []
|
||||
@@ -248,7 +224,7 @@ class TestRtspStreamRefusesUnsafeUrls:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_real_camera_still_reaches_ffmpeg(self):
|
||||
with _fake_ffmpeg(), _spawn_spy(returncode=None) as spawn:
|
||||
with fake_ffmpeg(), spawn_spy(returncode=None) as spawn:
|
||||
[frame async for frame in _stream_rtsp("rtsp://admin:hunter2@192.168.1.50:554/live", fps=5)]
|
||||
|
||||
spawn.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user