mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-28 23:06:13 +02:00
Add Camera Wizard (#20461)
* fetch more from ffprobe * add detailed param to ffprobe endpoint * add dots variant to step indicator * add classname * tweak colors for dark mode to match figma * add step 1 form * add helper function for ffmpeg snapshot * add go2rtc stream add and ffprobe snapshot endpoints * add camera image and stream details on successful test * step 1 tweaks * step 2 and i18n * types * step 1 and 2 tweaks * add wizard to camera settings view * add data unit i18n keys * restream tweak * fix type * implement rough idea for step 3 * add api endpoint to delete stream from go2rtc * add main wizard dialog component * extract logic for friendly_name and use in wizard * add i18n and popover for brand url * add camera name to top * consolidate validation logic * prevent dialog from closing when clicking outside * center camera name on mobile * add help/docs link popovers * keep spaces in friendly name * add stream details to overlay like stats in liveplayer * add validation results pane to step 3 * ensure test is invalidated if stream is changed * only display validation results and enable save button if all streams have been tested * tweaks * normalize camera name to lower case and improve hash generation * move wizard to subfolder * tweaks * match look of camera edit form to wizard * move wizard and edit form to its own component * move enabled/disabled switch to management section * clean up * fixes * fix mobile
This commit is contained in:
@@ -43,6 +43,7 @@ from frigate.util.builtin import (
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import (
|
||||
ffprobe_stream,
|
||||
get_nvidia_driver_info,
|
||||
@@ -107,6 +108,80 @@ def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
return JSONResponse(content=stream_data)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
"""Add or update a go2rtc stream configuration."""
|
||||
try:
|
||||
params = {"name": stream_name}
|
||||
if src:
|
||||
params["src"] = src
|
||||
|
||||
r = requests.put(
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
if not r.ok:
|
||||
logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"Failed to add stream: {r.text}"}
|
||||
),
|
||||
status_code=r.status_code,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Stream added successfully"}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error communicating with go2rtc: {e}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Error communicating with go2rtc",
|
||||
}
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def go2rtc_delete_stream(stream_name: str):
|
||||
"""Delete a go2rtc stream."""
|
||||
try:
|
||||
r = requests.delete(
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
params={"src": stream_name},
|
||||
timeout=10,
|
||||
)
|
||||
if not r.ok:
|
||||
logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"Failed to delete stream: {r.text}"}
|
||||
),
|
||||
status_code=r.status_code,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Stream deleted successfully"}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error communicating with go2rtc: {e}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Error communicating with go2rtc",
|
||||
}
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version", response_class=PlainTextResponse)
|
||||
def version():
|
||||
return VERSION
|
||||
@@ -453,7 +528,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
|
||||
@router.get("/ffprobe")
|
||||
def ffprobe(request: Request, paths: str = ""):
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
|
||||
if not path_param:
|
||||
@@ -492,26 +567,132 @@ def ffprobe(request: Request, paths: str = ""):
|
||||
output = []
|
||||
|
||||
for path in paths:
|
||||
ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip())
|
||||
output.append(
|
||||
{
|
||||
"return_code": ffprobe.returncode,
|
||||
"stderr": (
|
||||
ffprobe.stderr.decode("unicode_escape").strip()
|
||||
if ffprobe.returncode != 0
|
||||
else ""
|
||||
),
|
||||
"stdout": (
|
||||
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
||||
if ffprobe.returncode == 0
|
||||
else ""
|
||||
),
|
||||
}
|
||||
ffprobe = ffprobe_stream(
|
||||
request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed
|
||||
)
|
||||
|
||||
result = {
|
||||
"return_code": ffprobe.returncode,
|
||||
"stderr": (
|
||||
ffprobe.stderr.decode("unicode_escape").strip()
|
||||
if ffprobe.returncode != 0
|
||||
else ""
|
||||
),
|
||||
"stdout": (
|
||||
json.loads(ffprobe.stdout.decode("unicode_escape").strip())
|
||||
if ffprobe.returncode == 0
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
# Add detailed metadata if requested and probe was successful
|
||||
if detailed and ffprobe.returncode == 0 and result["stdout"]:
|
||||
try:
|
||||
probe_data = result["stdout"]
|
||||
metadata = {}
|
||||
|
||||
# Extract video stream information
|
||||
video_stream = None
|
||||
audio_stream = None
|
||||
|
||||
for stream in probe_data.get("streams", []):
|
||||
if stream.get("codec_type") == "video":
|
||||
video_stream = stream
|
||||
elif stream.get("codec_type") == "audio":
|
||||
audio_stream = stream
|
||||
|
||||
# Video metadata
|
||||
if video_stream:
|
||||
metadata["video"] = {
|
||||
"codec": video_stream.get("codec_name"),
|
||||
"width": video_stream.get("width"),
|
||||
"height": video_stream.get("height"),
|
||||
"fps": _extract_fps(video_stream.get("r_frame_rate")),
|
||||
"pixel_format": video_stream.get("pix_fmt"),
|
||||
"profile": video_stream.get("profile"),
|
||||
"level": video_stream.get("level"),
|
||||
}
|
||||
|
||||
# Calculate resolution string
|
||||
if video_stream.get("width") and video_stream.get("height"):
|
||||
metadata["video"]["resolution"] = (
|
||||
f"{video_stream['width']}x{video_stream['height']}"
|
||||
)
|
||||
|
||||
# Audio metadata
|
||||
if audio_stream:
|
||||
metadata["audio"] = {
|
||||
"codec": audio_stream.get("codec_name"),
|
||||
"channels": audio_stream.get("channels"),
|
||||
"sample_rate": audio_stream.get("sample_rate"),
|
||||
"channel_layout": audio_stream.get("channel_layout"),
|
||||
}
|
||||
|
||||
# Container/format metadata
|
||||
if probe_data.get("format"):
|
||||
format_info = probe_data["format"]
|
||||
metadata["container"] = {
|
||||
"format": format_info.get("format_name"),
|
||||
"duration": format_info.get("duration"),
|
||||
"size": format_info.get("size"),
|
||||
}
|
||||
|
||||
result["metadata"] = metadata
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract detailed metadata: {e}")
|
||||
# Continue without metadata if parsing fails
|
||||
|
||||
output.append(result)
|
||||
|
||||
return JSONResponse(content=output)
|
||||
|
||||
|
||||
@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
|
||||
"""Get a snapshot from a stream URL using ffmpeg."""
|
||||
if not url:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "URL parameter is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
image_data, error = run_ffmpeg_snapshot(
|
||||
config.ffmpeg, url, "mjpeg", timeout=timeout
|
||||
)
|
||||
|
||||
if image_data:
|
||||
return Response(
|
||||
image_data,
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
elif error == "timeout":
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Timeout capturing snapshot"},
|
||||
status_code=408,
|
||||
)
|
||||
else:
|
||||
logger.error(f"ffmpeg failed: {error}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Failed to capture snapshot"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
"""Extract FPS from ffprobe r_frame_rate string (e.g., '30/1' -> 30.0)"""
|
||||
if not r_frame_rate:
|
||||
return None
|
||||
try:
|
||||
num, den = r_frame_rate.split("/")
|
||||
return round(float(num) / float(den), 2)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/vainfo")
|
||||
def vainfo():
|
||||
vainfo = vainfo_hwaccel()
|
||||
|
||||
Reference in New Issue
Block a user