|
@@ -11,7 +11,7 @@ import asyncio
|
|
|
import logging
|
|
import logging
|
|
|
import re
|
|
import re
|
|
|
import shutil
|
|
import shutil
|
|
|
-from collections.abc import AsyncGenerator
|
|
|
|
|
|
|
+from collections.abc import AsyncGenerator, Callable
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
from urllib.parse import urlparse
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
@@ -592,13 +592,30 @@ async def test_connection(url: str, camera_type: str) -> dict:
|
|
|
return {"success": False, "error": f"Connection failed: {error_type}"}
|
|
return {"success": False, "error": f"Connection failed: {error_type}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
|
|
|
|
|
|
|
+async def generate_mjpeg_stream(
|
|
|
|
|
+ url: str,
|
|
|
|
|
+ camera_type: str,
|
|
|
|
|
+ fps: int = 10,
|
|
|
|
|
+ *,
|
|
|
|
|
+ on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
|
|
|
|
|
+ stop_event: asyncio.Event | None = None,
|
|
|
|
|
+) -> AsyncGenerator[bytes, None]:
|
|
|
"""Generator yielding MJPEG frames for streaming.
|
|
"""Generator yielding MJPEG frames for streaming.
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
|
url: Camera URL or USB device path
|
|
url: Camera URL or USB device path
|
|
|
camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
|
|
camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
|
|
|
fps: Target frames per second
|
|
fps: Target frames per second
|
|
|
|
|
+ on_process: Called with the spawned ffmpeg process for the ``usb`` and
|
|
|
|
|
+ ``rtsp`` paths so the route layer can register it into the shared
|
|
|
|
|
+ stream registries — that's what lets ``/camera/stop`` and the orphan
|
|
|
|
|
+ janitor find and kill a leaked ffmpeg that's holding a USB device
|
|
|
|
|
+ open (#2675). Without it the process is reachable only from this
|
|
|
|
|
+ generator's own ``finally``, which an abrupt client disconnect can
|
|
|
|
|
+ skip (same cancellation-timing class as #776).
|
|
|
|
|
+ stop_event: When set, the reconnect loops stop retrying — so an explicit
|
|
|
|
|
+ stop (which kills the current ffmpeg) doesn't immediately respawn a
|
|
|
|
|
+ new process and reacquire the device.
|
|
|
|
|
|
|
|
Yields:
|
|
Yields:
|
|
|
MJPEG frame data with HTTP multipart boundaries
|
|
MJPEG frame data with HTTP multipart boundaries
|
|
@@ -617,7 +634,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|
|
if current_time - last_frame_time >= frame_interval:
|
|
if current_time - last_frame_time >= frame_interval:
|
|
|
last_frame_time = current_time
|
|
last_frame_time = current_time
|
|
|
yield _format_mjpeg_frame(frame)
|
|
yield _format_mjpeg_frame(frame)
|
|
|
- if not frame_yielded or attempt == max_retries:
|
|
|
|
|
|
|
+ if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
|
|
|
break
|
|
break
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
"External MJPEG stream ended, reconnecting (attempt %d/%d)...",
|
|
"External MJPEG stream ended, reconnecting (attempt %d/%d)...",
|
|
@@ -631,10 +648,10 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|
|
max_retries = 3
|
|
max_retries = 3
|
|
|
for attempt in range(max_retries + 1):
|
|
for attempt in range(max_retries + 1):
|
|
|
frame_yielded = False
|
|
frame_yielded = False
|
|
|
- async for frame in _stream_rtsp(url, fps):
|
|
|
|
|
|
|
+ async for frame in _stream_rtsp(url, fps, on_process=on_process):
|
|
|
frame_yielded = True
|
|
frame_yielded = True
|
|
|
yield _format_mjpeg_frame(frame)
|
|
yield _format_mjpeg_frame(frame)
|
|
|
- if not frame_yielded or attempt == max_retries:
|
|
|
|
|
|
|
+ if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
|
|
|
break
|
|
break
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
"External RTSP stream ended, reconnecting (attempt %d/%d)...",
|
|
"External RTSP stream ended, reconnecting (attempt %d/%d)...",
|
|
@@ -645,7 +662,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|
|
|
|
|
|
|
elif camera_type == "usb":
|
|
elif camera_type == "usb":
|
|
|
# Use ffmpeg to stream from USB camera
|
|
# Use ffmpeg to stream from USB camera
|
|
|
- async for frame in _stream_usb(url, fps):
|
|
|
|
|
|
|
+ async for frame in _stream_usb(url, fps, on_process=on_process):
|
|
|
yield _format_mjpeg_frame(frame)
|
|
yield _format_mjpeg_frame(frame)
|
|
|
|
|
|
|
|
elif camera_type == "snapshot":
|
|
elif camera_type == "snapshot":
|
|
@@ -724,7 +741,12 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
|
|
|
logger.error("MJPEG stream error: %s", e)
|
|
logger.error("MJPEG stream error: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|
|
|
|
|
|
+async def _stream_rtsp(
|
|
|
|
|
+ url: str,
|
|
|
|
|
+ fps: int,
|
|
|
|
|
+ *,
|
|
|
|
|
+ on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
|
|
|
|
|
+) -> AsyncGenerator[bytes, None]:
|
|
|
"""Stream frames from RTSP URL via ffmpeg.
|
|
"""Stream frames from RTSP URL via ffmpeg.
|
|
|
|
|
|
|
|
For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
|
|
For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
|
|
@@ -805,6 +827,11 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
stderr=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
|
)
|
|
)
|
|
|
|
|
+ # Register immediately — before the startup probe below — so a process
|
|
|
|
|
+ # that hangs on connect (rather than exiting) is still reachable by the
|
|
|
|
|
+ # stop endpoint / orphan janitor (#2675).
|
|
|
|
|
+ if on_process is not None:
|
|
|
|
|
+ on_process(process)
|
|
|
|
|
|
|
|
# Brief check for immediate startup failures
|
|
# Brief check for immediate startup failures
|
|
|
await asyncio.sleep(0.1)
|
|
await asyncio.sleep(0.1)
|
|
@@ -865,7 +892,12 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|
|
await proxy_server.wait_closed()
|
|
await proxy_server.wait_closed()
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|
|
|
|
|
|
+async def _stream_usb(
|
|
|
|
|
+ device: str,
|
|
|
|
|
+ fps: int,
|
|
|
|
|
+ *,
|
|
|
|
|
+ on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
|
|
|
|
|
+) -> AsyncGenerator[bytes, None]:
|
|
|
"""Stream frames from USB camera via ffmpeg."""
|
|
"""Stream frames from USB camera via ffmpeg."""
|
|
|
ffmpeg = get_ffmpeg_path()
|
|
ffmpeg = get_ffmpeg_path()
|
|
|
if not ffmpeg:
|
|
if not ffmpeg:
|
|
@@ -907,6 +939,12 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
stderr=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
|
)
|
|
)
|
|
|
|
|
+ # Register immediately — before the startup probe below — so a process
|
|
|
|
|
+ # that hangs in open()/ioctl on a still-locked device (rather than
|
|
|
|
|
+ # exiting with a "busy" error) is still reachable by the stop endpoint /
|
|
|
|
|
+ # orphan janitor (#2675).
|
|
|
|
|
+ if on_process is not None:
|
|
|
|
|
+ on_process(process)
|
|
|
|
|
|
|
|
# Give ffmpeg a moment to start and check for immediate failures
|
|
# Give ffmpeg a moment to start and check for immediate failures
|
|
|
await asyncio.sleep(0.5)
|
|
await asyncio.sleep(0.5)
|