mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-13 00:26:42 +03:00
Compare commits
No commits in common. "1a1ec8cf91c4cb8848b10e55c1a0a5c4e9725c46" and "9d85136f8f8c86e6eff2a9d3a3312fc749918ffd" have entirely different histories.
1a1ec8cf91
...
9d85136f8f
@ -21,7 +21,7 @@ Frigate autotracking functions with PTZ cameras capable of relative movement wit
|
||||
|
||||
Many cheaper or older PTZs may not support this standard. Frigate will report an error message in the log and disable autotracking if your PTZ is unsupported.
|
||||
|
||||
The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) can provide a starting point to determine a camera's compatibility with Frigate's autotracking. Look to see if a camera lists `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`. These features are required for autotracking, but some cameras still fail to respond even if they claim support.
|
||||
Alternatively, you can download and run [this simple Python script](https://gist.github.com/hawkeye217/152a1d4ba80760dac95d46e143d37112), replacing the details on line 4 with your camera's IP address, ONVIF port, username, and password to check your camera.
|
||||
|
||||
A growing list of cameras and brands that have been reported by users to work with Frigate's autotracking can be found [here](cameras.md).
|
||||
|
||||
|
||||
@ -91,33 +91,33 @@ An ONVIF-capable camera that supports relative movement within the field of view
|
||||
|
||||
This list of working and non-working PTZ cameras is based on user feedback. If you'd like to report specific quirks or issues with a manufacturer or camera that would be helpful for other users, open a pull request to add to this list.
|
||||
|
||||
The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) can provide a starting point to determine a camera's compatibility with Frigate's autotracking. Look to see if a camera lists `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`. These features are required for autotracking, but some cameras still fail to respond even if they claim support. If they are missing, autotracking will not work (though basic PTZ in the WebUI might). Avoid cameras with no database entry unless they are confirmed as working below.
|
||||
The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) can provide a starting point to determine a camera's compatibility with Frigate's autotracking. Look to see if a camera lists `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`, plus `PTZAuxiliary`. These features are required for autotracking, but some cameras still fail to respond even if they claim support. If they are missing, autotracking will not work (though basic PTZ in the WebUI might). Avoid cameras with no database entry unless they are confirmed as working below.
|
||||
|
||||
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
||||
| ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
|
||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
||||
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
||||
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
||||
| Annke CZ504 | ✅ | ✅ | Annke support provide specific firmware ([V5.7.1 build 250227](https://github.com/pierrepinon/annke_cz504/raw/refs/heads/main/digicap_V5-7-1_build_250227.dav)) to fix issue with ONVIF "TranslationSpaceFov" |
|
||||
| Ctronics PTZ | ✅ | ❌ | |
|
||||
| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). |
|
||||
| Dahua DH-SD2A500HB | ✅ | ❌ | |
|
||||
| Dahua DH-SD49825GB-HNR | ✅ | ✅ | |
|
||||
| Dahua DH-P5AE-PV | ❌ | ❌ | |
|
||||
| Foscam | ✅ | ❌ | In general support PTZ, but not relative move. There are no official ONVIF certifications and tests available on the ONVIF Conformant Products Database | |
|
||||
| Foscam R5 | ✅ | ❌ | |
|
||||
| Foscam SD4 | ✅ | ❌ | |
|
||||
| Hanwha XNP-6550RH | ✅ | ❌ | |
|
||||
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
|
||||
| Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | |
|
||||
| Reolink | ✅ | ❌ | |
|
||||
| Speco O8P32X | ✅ | ❌ | |
|
||||
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatable. |
|
||||
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
|
||||
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
|
||||
| Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. |
|
||||
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
||||
| ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
||||
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
||||
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
||||
| Annke CZ504 | ✅ | ✅ | Annke support provide specific firmware ([V5.7.1 build 250227](https://github.com/pierrepinon/annke_cz504/raw/refs/heads/main/digicap_V5-7-1_build_250227.dav)) to fix issue with ONVIF "TranslationSpaceFov" |
|
||||
| Ctronics PTZ | ✅ | ❌ | |
|
||||
| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). |
|
||||
| Dahua DH-SD2A500HB | ✅ | ❌ | |
|
||||
| Dahua DH-SD49825GB-HNR | ✅ | ✅ | |
|
||||
| Dahua DH-P5AE-PV | ❌ | ❌ | |
|
||||
| Foscam | ✅ | ❌ | In general support PTZ, but not relative move. There are no official ONVIF certifications and tests available on the ONVIF Conformant Products Database | |
|
||||
| Foscam R5 | ✅ | ❌ | |
|
||||
| Foscam SD4 | ✅ | ❌ | |
|
||||
| Hanwha XNP-6550RH | ✅ | ❌ | |
|
||||
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
|
||||
| Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | |
|
||||
| Reolink | ✅ | ❌ | |
|
||||
| Speco O8P32X | ✅ | ❌ | |
|
||||
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatable. |
|
||||
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
|
||||
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
|
||||
| Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. |
|
||||
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
|
||||
## Setting up camera groups
|
||||
|
||||
@ -140,5 +140,4 @@ camera_groups:
|
||||
```
|
||||
|
||||
## Two-Way Audio
|
||||
|
||||
See the guide [here](/configuration/live/#two-way-talk)
|
||||
|
||||
@ -14,6 +14,7 @@ from pathlib import Path as FilePath
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiofiles
|
||||
import requests
|
||||
import ruamel.yaml
|
||||
from fastapi import APIRouter, Body, Path, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
@ -42,7 +43,9 @@ 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,
|
||||
process_logs,
|
||||
restart_frigate,
|
||||
@ -68,6 +71,117 @@ def config_schema(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams")
|
||||
def go2rtc_streams():
|
||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||
if not r.ok:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error fetching stream data"}),
|
||||
status_code=500,
|
||||
)
|
||||
stream_data = r.json()
|
||||
for data in stream_data.values():
|
||||
for producer in data.get("producers") or []:
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
return JSONResponse(content=stream_data)
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams/{camera_name}")
|
||||
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
r = requests.get(
|
||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||
)
|
||||
if not r.ok:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
|
||||
if camera_config and camera_config.enabled:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error fetching stream data"}),
|
||||
status_code=500,
|
||||
)
|
||||
stream_data = r.json()
|
||||
for producer in stream_data.get("producers", []):
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
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
|
||||
@ -413,6 +527,172 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ffprobe")
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
|
||||
if not path_param:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Path needs to be provided."}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if path_param.startswith("camera"):
|
||||
camera = path_param[7:]
|
||||
|
||||
if camera not in request.app.frigate_config.cameras.keys():
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"{camera} is not a valid camera."}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if not request.app.frigate_config.cameras[camera].enabled:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": f"{camera} is not enabled."}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
paths = map(
|
||||
lambda input: input.path,
|
||||
request.app.frigate_config.cameras[camera].ffmpeg.inputs,
|
||||
)
|
||||
elif "," in clean_camera_user_pass(path_param):
|
||||
paths = path_param.split(",")
|
||||
else:
|
||||
paths = [path_param]
|
||||
|
||||
# user has multiple streams
|
||||
output = []
|
||||
|
||||
for path in paths:
|
||||
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()
|
||||
|
||||
@ -1,443 +0,0 @@
|
||||
"""Camera apis."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import ffprobe_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.camera])
|
||||
|
||||
|
||||
def _is_valid_host(host: str) -> bool:
|
||||
"""
|
||||
Validate that the host is in a valid format.
|
||||
Allows private IPs since cameras are typically on local networks.
|
||||
Only blocks obviously malicious input to prevent injection attacks.
|
||||
"""
|
||||
try:
|
||||
# Remove port if present
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
|
||||
# Block whitespace, newlines, and control characters
|
||||
if not host_without_port or re.search(r"[\s\x00-\x1f]", host_without_port):
|
||||
return False
|
||||
|
||||
# Allow standard hostname/IP characters: alphanumeric, dots, hyphens
|
||||
if not re.match(r"^[a-zA-Z0-9.-]+$", host_without_port):
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams")
|
||||
def go2rtc_streams():
|
||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||
if not r.ok:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error fetching stream data"}),
|
||||
status_code=500,
|
||||
)
|
||||
stream_data = r.json()
|
||||
for data in stream_data.values():
|
||||
for producer in data.get("producers") or []:
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
return JSONResponse(content=stream_data)
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams/{camera_name}")
|
||||
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
r = requests.get(
|
||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||
)
|
||||
if not r.ok:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
|
||||
if camera_config and camera_config.enabled:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error fetching stream data"}),
|
||||
status_code=500,
|
||||
)
|
||||
stream_data = r.json()
|
||||
for producer in stream_data.get("producers", []):
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
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("/ffprobe")
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
|
||||
if not path_param:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Path needs to be provided."}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if path_param.startswith("camera"):
|
||||
camera = path_param[7:]
|
||||
|
||||
if camera not in request.app.frigate_config.cameras.keys():
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"{camera} is not a valid camera."}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if not request.app.frigate_config.cameras[camera].enabled:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": f"{camera} is not enabled."}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
paths = map(
|
||||
lambda input: input.path,
|
||||
request.app.frigate_config.cameras[camera].ffmpeg.inputs,
|
||||
)
|
||||
elif "," in clean_camera_user_pass(path_param):
|
||||
paths = path_param.split(",")
|
||||
else:
|
||||
paths = [path_param]
|
||||
|
||||
# user has multiple streams
|
||||
output = []
|
||||
|
||||
for path in paths:
|
||||
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("avg_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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reolink/detect", dependencies=[Depends(require_role(["admin"]))])
|
||||
def reolink_detect(host: str = "", username: str = "", password: str = ""):
|
||||
"""
|
||||
Detect Reolink camera capabilities and recommend optimal protocol.
|
||||
|
||||
Queries the Reolink camera API to determine the camera's resolution
|
||||
and recommends either http-flv (for 5MP and below) or rtsp (for higher resolutions).
|
||||
"""
|
||||
if not host:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Host parameter is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Username parameter is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not password:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Password parameter is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate host format to prevent injection attacks
|
||||
if not _is_valid_host(host):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid host format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
# URL-encode credentials to prevent injection
|
||||
encoded_user = quote_plus(username)
|
||||
encoded_password = quote_plus(password)
|
||||
api_url = f"http://{host}/api.cgi?cmd=GetEnc&user={encoded_user}&password={encoded_password}"
|
||||
|
||||
response = requests.get(api_url, timeout=5)
|
||||
|
||||
if not response.ok:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"protocol": None,
|
||||
"message": f"Failed to connect to camera API: HTTP {response.status_code}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
enc_data = data[0] if isinstance(data, list) and len(data) > 0 else data
|
||||
|
||||
stream_info = None
|
||||
if isinstance(enc_data, dict):
|
||||
if enc_data.get("value", {}).get("Enc"):
|
||||
stream_info = enc_data["value"]["Enc"]
|
||||
elif enc_data.get("Enc"):
|
||||
stream_info = enc_data["Enc"]
|
||||
|
||||
if not stream_info or not stream_info.get("mainStream"):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"protocol": None,
|
||||
"message": "Could not find stream information in API response",
|
||||
}
|
||||
)
|
||||
|
||||
main_stream = stream_info["mainStream"]
|
||||
width = main_stream.get("width", 0)
|
||||
height = main_stream.get("height", 0)
|
||||
|
||||
if not width or not height:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"protocol": None,
|
||||
"message": "Could not determine camera resolution",
|
||||
}
|
||||
)
|
||||
|
||||
megapixels = (width * height) / 1_000_000
|
||||
protocol = "http-flv" if megapixels <= 5.0 else "rtsp"
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"protocol": protocol,
|
||||
"resolution": f"{width}x{height}",
|
||||
"megapixels": round(megapixels, 2),
|
||||
}
|
||||
)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"protocol": None,
|
||||
"message": "Connection timeout - camera did not respond",
|
||||
}
|
||||
)
|
||||
except requests.exceptions.RequestException:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"protocol": None,
|
||||
"message": "Failed to connect to camera",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error detecting Reolink camera at {host}")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"protocol": None,
|
||||
"message": "Unable to detect camera capabilities",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
"""Extract FPS from ffprobe avg_frame_rate / 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
|
||||
@ -3,7 +3,6 @@ from enum import Enum
|
||||
|
||||
class Tags(Enum):
|
||||
app = "App"
|
||||
camera = "Camera"
|
||||
preview = "Preview"
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
|
||||
@ -15,7 +15,6 @@ from starlette_context.plugins import Plugin
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
auth,
|
||||
camera,
|
||||
classification,
|
||||
event,
|
||||
export,
|
||||
@ -115,7 +114,6 @@ def create_fastapi_app(
|
||||
# Routes
|
||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||
app.include_router(auth.router)
|
||||
app.include_router(camera.router)
|
||||
app.include_router(classification.router)
|
||||
app.include_router(review.router)
|
||||
app.include_router(main_app.router)
|
||||
|
||||
@ -55,7 +55,7 @@ class LibvaGpuSelector:
|
||||
|
||||
def get_gpu_arg(self, preset: str, gpu: int) -> str:
|
||||
if "nvidia" in preset:
|
||||
return str(gpu)
|
||||
return f"-hwaccel_device {gpu}"
|
||||
|
||||
if self._valid_gpus is None:
|
||||
self.__get_valid_gpus()
|
||||
@ -64,10 +64,10 @@ class LibvaGpuSelector:
|
||||
return ""
|
||||
|
||||
if gpu <= len(self._valid_gpus):
|
||||
return self._valid_gpus[gpu]
|
||||
return f"-hwaccel_device {self._valid_gpus[gpu]}"
|
||||
else:
|
||||
logger.warning(f"Invalid GPU index {gpu}, using first valid GPU")
|
||||
return self._valid_gpus[0]
|
||||
return f"-hwaccel_device {self._valid_gpus[0]}"
|
||||
|
||||
|
||||
FPS_VFR_PARAM = "-fps_mode vfr" if LIBAVFORMAT_VERSION_MAJOR >= 59 else "-vsync 2"
|
||||
@ -87,7 +87,7 @@ PRESETS_HW_ACCEL_DECODE = {
|
||||
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
|
||||
"preset-intel-qsv-h264": "-hwaccel qsv -qsv_device {3} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||
"preset-intel-qsv-h265": "-load_plugin hevc_hw -hwaccel qsv -qsv_device {3} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||
FFMPEG_HWACCEL_NVIDIA: "-hwaccel_device {3} -hwaccel cuda -hwaccel_output_format cuda",
|
||||
FFMPEG_HWACCEL_NVIDIA: "{3} -hwaccel cuda -hwaccel_output_format cuda",
|
||||
"preset-jetson-h264": "-c:v h264_nvmpi -resize {1}x{2}",
|
||||
"preset-jetson-h265": "-c:v hevc_nvmpi -resize {1}x{2}",
|
||||
f"{FFMPEG_HWACCEL_RKMPP}-no-dump_extra": "-hwaccel rkmpp -hwaccel_output_format drm_prime",
|
||||
|
||||
@ -196,13 +196,7 @@
|
||||
"nameRequired": "Camera name is required",
|
||||
"nameLength": "Camera name must be 64 characters or less",
|
||||
"invalidCharacters": "Camera name contains invalid characters",
|
||||
"nameExists": "Camera name already exists",
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP is not recommended. It is recommended to enable http in the camera settings and restart the camera wizard."
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
||||
"nameExists": "Camera name already exists"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
|
||||
@ -33,9 +33,14 @@ function useValue(): useValueReturn {
|
||||
|
||||
// main state
|
||||
|
||||
const [hasCameraState, setHasCameraState] = useState(false);
|
||||
const [wsState, setWsState] = useState<WsState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCameraState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activityValue: string = wsState["camera_activity"] as string;
|
||||
|
||||
if (!activityValue) {
|
||||
@ -100,9 +105,12 @@ function useValue(): useValueReturn {
|
||||
...cameraStates,
|
||||
}));
|
||||
|
||||
if (Object.keys(cameraStates).length > 0) {
|
||||
setHasCameraState(true);
|
||||
}
|
||||
// we only want this to run initially when the config is loaded
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsState["camera_activity"]]);
|
||||
}, [wsState]);
|
||||
|
||||
// ws handler
|
||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
||||
@ -123,7 +131,9 @@ function useValue(): useValueReturn {
|
||||
retain: false,
|
||||
});
|
||||
},
|
||||
onClose: () => {},
|
||||
onClose: () => {
|
||||
setHasCameraState(false);
|
||||
},
|
||||
shouldReconnect: () => true,
|
||||
retryOnError: true,
|
||||
});
|
||||
|
||||
@ -62,10 +62,6 @@ export class DynamicVideoController {
|
||||
this.playerController.pause();
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return !this.playerController.paused && !this.playerController.ended;
|
||||
}
|
||||
|
||||
seekToTimestamp(time: number, play: boolean = false) {
|
||||
if (time < this.timeRange.after || time > this.timeRange.before) {
|
||||
this.timeToStart = time;
|
||||
|
||||
@ -45,7 +45,6 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type Step1NameCameraProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@ -135,44 +134,8 @@ export default function Step1NameCamera({
|
||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||
: !!(watchedHost && watchedHost.trim());
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Use detected protocol or fallback to rtsp
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
||||
(data: z.infer<typeof step1FormData>): string => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
}
|
||||
@ -180,27 +143,17 @@ export default function Step1NameCamera({
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
[],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
const streamUrl = generateStreamUrl(data);
|
||||
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
@ -255,16 +208,14 @@ export default function Step1NameCamera({
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "video" ||
|
||||
s.codec_name?.includes("h264") ||
|
||||
s.codec_name?.includes("hevc"),
|
||||
s.codec_name?.includes("h265"),
|
||||
);
|
||||
|
||||
const audioStream = streams.find(
|
||||
(s: FfprobeStream) =>
|
||||
s.codec_type === "audio" ||
|
||||
s.codec_name?.includes("aac") ||
|
||||
s.codec_name?.includes("mp3") ||
|
||||
s.codec_name?.includes("pcm_mulaw") ||
|
||||
s.codec_name?.includes("pcm_alaw"),
|
||||
s.codec_name?.includes("mp3"),
|
||||
);
|
||||
|
||||
const resolution = videoStream
|
||||
@ -272,9 +223,9 @@ export default function Step1NameCamera({
|
||||
: undefined;
|
||||
|
||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||
const fps = videoStream?.avg_frame_rate
|
||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||
const fps = videoStream?.r_frame_rate
|
||||
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
||||
parseFloat(videoStream.r_frame_rate.split("/")[1])
|
||||
: undefined;
|
||||
|
||||
// Convert snapshot blob to base64 if available
|
||||
@ -332,9 +283,9 @@ export default function Step1NameCamera({
|
||||
onUpdate(data);
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const handleContinue = useCallback(() => {
|
||||
const data = form.getValues();
|
||||
const streamUrl = await generateStreamUrl(data);
|
||||
const streamUrl = generateStreamUrl(data);
|
||||
const streamId = `stream_${Date.now()}`;
|
||||
|
||||
const streamConfig: StreamConfig = {
|
||||
@ -430,7 +381,7 @@ export default function Step1NameCamera({
|
||||
<h4 className="font-medium">
|
||||
{selectedBrand.label}
|
||||
</h4>
|
||||
<p className="break-all text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cameraWizard.step1.brandUrlFormat", {
|
||||
exampleUrl: selectedBrand.exampleUrl,
|
||||
})}
|
||||
|
||||
@ -431,16 +431,6 @@ function StreamIssues({
|
||||
message: string;
|
||||
}> = [];
|
||||
|
||||
if (wizardData.brandTemplate === "reolink") {
|
||||
const streamUrl = stream.url.toLowerCase();
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Video codec check
|
||||
if (stream.testResult?.videoCodec) {
|
||||
const videoCodec = stream.testResult.videoCodec.toLowerCase();
|
||||
|
||||
@ -484,7 +484,6 @@ export default function Events() {
|
||||
timeRange={selectedTimeRange}
|
||||
filter={reviewFilter}
|
||||
updateFilter={onUpdateFilter}
|
||||
refreshData={reloadData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ export const CAMERA_BRANDS = [
|
||||
"rtsp://{username}:{password}@{host}:554/cam/realmonitor?channel=1&subtype=0",
|
||||
exampleUrl:
|
||||
"rtsp://admin:password@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
{
|
||||
value: "hikvision" as const,
|
||||
@ -15,54 +14,42 @@ export const CAMERA_BRANDS = [
|
||||
template: "rtsp://{username}:{password}@{host}:554/Streaming/Channels/101",
|
||||
exampleUrl:
|
||||
"rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
{
|
||||
value: "ubiquiti" as const,
|
||||
label: "Ubiquiti",
|
||||
template: "rtsp://{username}:{password}@{host}:554/live/ch0",
|
||||
exampleUrl: "rtsp://ubnt:password@192.168.1.100:554/live/ch0",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
{
|
||||
value: "reolink" as const,
|
||||
label: "Reolink",
|
||||
template: "dynamic",
|
||||
dynamicTemplates: {
|
||||
"http-flv":
|
||||
"http://{host}/flv?port=1935&app=bcs&stream=channel0_main.bcs&user={username}&password={password}",
|
||||
rtsp: "rtsp://{username}:{password}@{host}:554/Preview_01_main",
|
||||
},
|
||||
exampleUrl:
|
||||
"http://192.168.1.100/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=admin&password=password or rtsp://admin:password@192.168.1.100:554/Preview_01_main",
|
||||
template: "rtsp://{username}:{password}@{host}:554/h264Preview_01_main",
|
||||
exampleUrl: "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main",
|
||||
},
|
||||
{
|
||||
value: "axis" as const,
|
||||
label: "Axis",
|
||||
template: "rtsp://{username}:{password}@{host}:554/axis-media/media.amp",
|
||||
exampleUrl: "rtsp://root:password@192.168.1.100:554/axis-media/media.amp",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
{
|
||||
value: "tplink" as const,
|
||||
label: "TP-Link",
|
||||
template: "rtsp://{username}:{password}@{host}:554/stream1",
|
||||
exampleUrl: "rtsp://admin:password@192.168.1.100:554/stream1",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
{
|
||||
value: "foscam" as const,
|
||||
label: "Foscam",
|
||||
template: "rtsp://{username}:{password}@{host}:88/videoMain",
|
||||
exampleUrl: "rtsp://admin:password@192.168.1.100:88/videoMain",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
{
|
||||
value: "other" as const,
|
||||
label: "Other",
|
||||
template: "",
|
||||
exampleUrl: "rtsp://username:password@host:port/path",
|
||||
dynamicTemplates: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
// ==================== Camera Name Processing ====================
|
||||
|
||||
/**
|
||||
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
||||
* Works safely with Unicode input while outputting Latin-only identifiers.
|
||||
@ -60,49 +58,3 @@ export function processCameraName(userInput: string): {
|
||||
friendlyName: userInput,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Reolink Camera Detection ====================
|
||||
|
||||
/**
|
||||
* Detect Reolink camera capabilities and recommend optimal protocol
|
||||
*
|
||||
* Calls the Frigate backend API which queries the Reolink camera to determine
|
||||
* its resolution and recommends either http-flv (for 5MP and below) or rtsp
|
||||
* (for higher resolutions).
|
||||
*
|
||||
* @param host - Camera IP address or hostname
|
||||
* @param username - Camera username
|
||||
* @param password - Camera password
|
||||
* @returns The recommended protocol key ("http-flv" or "rtsp"), or null if detection failed
|
||||
*/
|
||||
export async function detectReolinkCamera(
|
||||
host: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<"http-flv" | "rtsp" | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/reolink/detect?${params.toString()}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.protocol) {
|
||||
return data.protocol;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,8 +68,6 @@ import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
||||
|
||||
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
||||
|
||||
type RecordingViewProps = {
|
||||
startCamera: string;
|
||||
startTime: number;
|
||||
@ -80,7 +78,6 @@ type RecordingViewProps = {
|
||||
allPreviews?: Preview[];
|
||||
filter?: ReviewFilter;
|
||||
updateFilter: (newFilter: ReviewFilter) => void;
|
||||
refreshData?: () => void;
|
||||
};
|
||||
export function RecordingView({
|
||||
startCamera,
|
||||
@ -92,7 +89,6 @@ export function RecordingView({
|
||||
allPreviews,
|
||||
filter,
|
||||
updateFilter,
|
||||
refreshData,
|
||||
}: RecordingViewProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -196,40 +192,6 @@ export function RecordingView({
|
||||
}
|
||||
}, [selectedRangeIdx, chunkedTimeRange]);
|
||||
|
||||
// visibility tracking for refreshing stale data
|
||||
|
||||
const lastVisibilityTime = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const now = Date.now();
|
||||
const timeSinceLastVisible = now - lastVisibilityTime.current;
|
||||
|
||||
// Only refresh if user was away for a while
|
||||
// and the video is not currently playing
|
||||
if (
|
||||
timeSinceLastVisible >= DATA_REFRESH_TIME &&
|
||||
refreshData &&
|
||||
mainControllerRef.current &&
|
||||
!mainControllerRef.current.isPlaying()
|
||||
) {
|
||||
refreshData();
|
||||
}
|
||||
|
||||
lastVisibilityTime.current = now;
|
||||
} else {
|
||||
lastVisibilityTime.current = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshData]);
|
||||
|
||||
// scrubbing and timeline state
|
||||
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user