mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 21:44:13 +03:00
Add dynamic Reolink stream configuration for stream URL (#20469)
* Migrate camera APIs to separate tag * Implement reolink detection to handle dynamic URL assignment * Cleanup codec handling * Use average framerate not relative framerate * Add reolink rtsp warning * Don't return exception * Use avg_frame_rate in final info * Clenaup * Validate host * Fix overlap
This commit is contained in:
parent
3c3e11ecaf
commit
6a031eb9ee
@ -14,7 +14,6 @@ from pathlib import Path as FilePath
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import requests
|
|
||||||
import ruamel.yaml
|
import ruamel.yaml
|
||||||
from fastapi import APIRouter, Body, Path, Request, Response
|
from fastapi import APIRouter, Body, Path, Request, Response
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
@ -43,9 +42,7 @@ from frigate.util.builtin import (
|
|||||||
update_yaml_file_bulk,
|
update_yaml_file_bulk,
|
||||||
)
|
)
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
from frigate.util.image import run_ffmpeg_snapshot
|
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
ffprobe_stream,
|
|
||||||
get_nvidia_driver_info,
|
get_nvidia_driver_info,
|
||||||
process_logs,
|
process_logs,
|
||||||
restart_frigate,
|
restart_frigate,
|
||||||
@ -71,117 +68,6 @@ 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)
|
@router.get("/version", response_class=PlainTextResponse)
|
||||||
def version():
|
def version():
|
||||||
return VERSION
|
return VERSION
|
||||||
@ -527,172 +413,6 @@ 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")
|
@router.get("/vainfo")
|
||||||
def vainfo():
|
def vainfo():
|
||||||
vainfo = vainfo_hwaccel()
|
vainfo = vainfo_hwaccel()
|
||||||
|
|||||||
443
frigate/api/camera.py
Normal file
443
frigate/api/camera.py
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
"""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,6 +3,7 @@ from enum import Enum
|
|||||||
|
|
||||||
class Tags(Enum):
|
class Tags(Enum):
|
||||||
app = "App"
|
app = "App"
|
||||||
|
camera = "Camera"
|
||||||
preview = "Preview"
|
preview = "Preview"
|
||||||
logs = "Logs"
|
logs = "Logs"
|
||||||
media = "Media"
|
media = "Media"
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from starlette_context.plugins import Plugin
|
|||||||
from frigate.api import app as main_app
|
from frigate.api import app as main_app
|
||||||
from frigate.api import (
|
from frigate.api import (
|
||||||
auth,
|
auth,
|
||||||
|
camera,
|
||||||
classification,
|
classification,
|
||||||
event,
|
event,
|
||||||
export,
|
export,
|
||||||
@ -114,6 +115,7 @@ def create_fastapi_app(
|
|||||||
# Routes
|
# Routes
|
||||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(camera.router)
|
||||||
app.include_router(classification.router)
|
app.include_router(classification.router)
|
||||||
app.include_router(review.router)
|
app.include_router(review.router)
|
||||||
app.include_router(main_app.router)
|
app.include_router(main_app.router)
|
||||||
|
|||||||
@ -196,7 +196,13 @@
|
|||||||
"nameRequired": "Camera name is required",
|
"nameRequired": "Camera name is required",
|
||||||
"nameLength": "Camera name must be 64 characters or less",
|
"nameLength": "Camera name must be 64 characters or less",
|
||||||
"invalidCharacters": "Camera name contains invalid characters",
|
"invalidCharacters": "Camera name contains invalid characters",
|
||||||
"nameExists": "Camera name already exists"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { LuInfo } from "react-icons/lu";
|
import { LuInfo } from "react-icons/lu";
|
||||||
|
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
type Step1NameCameraProps = {
|
type Step1NameCameraProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -134,8 +135,44 @@ export default function Step1NameCamera({
|
|||||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
||||||
: !!(watchedHost && watchedHost.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(
|
const generateStreamUrl = useCallback(
|
||||||
(data: z.infer<typeof step1FormData>): string => {
|
async (data: z.infer<typeof step1FormData>): Promise<string> => {
|
||||||
if (data.brandTemplate === "other") {
|
if (data.brandTemplate === "other") {
|
||||||
return data.customUrl || "";
|
return data.customUrl || "";
|
||||||
}
|
}
|
||||||
@ -143,17 +180,27 @@ export default function Step1NameCamera({
|
|||||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||||
if (!brand || !data.host) return "";
|
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
|
return brand.template
|
||||||
.replace("{username}", data.username || "")
|
.replace("{username}", data.username || "")
|
||||||
.replace("{password}", data.password || "")
|
.replace("{password}", data.password || "")
|
||||||
.replace("{host}", data.host);
|
.replace("{host}", data.host);
|
||||||
},
|
},
|
||||||
[],
|
[generateDynamicStreamUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testConnection = useCallback(async () => {
|
const testConnection = useCallback(async () => {
|
||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
const streamUrl = generateStreamUrl(data);
|
const streamUrl = await generateStreamUrl(data);
|
||||||
|
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
@ -208,14 +255,16 @@ export default function Step1NameCamera({
|
|||||||
(s: FfprobeStream) =>
|
(s: FfprobeStream) =>
|
||||||
s.codec_type === "video" ||
|
s.codec_type === "video" ||
|
||||||
s.codec_name?.includes("h264") ||
|
s.codec_name?.includes("h264") ||
|
||||||
s.codec_name?.includes("h265"),
|
s.codec_name?.includes("hevc"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const audioStream = streams.find(
|
const audioStream = streams.find(
|
||||||
(s: FfprobeStream) =>
|
(s: FfprobeStream) =>
|
||||||
s.codec_type === "audio" ||
|
s.codec_type === "audio" ||
|
||||||
s.codec_name?.includes("aac") ||
|
s.codec_name?.includes("aac") ||
|
||||||
s.codec_name?.includes("mp3"),
|
s.codec_name?.includes("mp3") ||
|
||||||
|
s.codec_name?.includes("pcm_mulaw") ||
|
||||||
|
s.codec_name?.includes("pcm_alaw"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolution = videoStream
|
const resolution = videoStream
|
||||||
@ -223,9 +272,9 @@ export default function Step1NameCamera({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
// Extract FPS from rational (e.g., "15/1" -> 15)
|
||||||
const fps = videoStream?.r_frame_rate
|
const fps = videoStream?.avg_frame_rate
|
||||||
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
|
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||||
parseFloat(videoStream.r_frame_rate.split("/")[1])
|
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Convert snapshot blob to base64 if available
|
// Convert snapshot blob to base64 if available
|
||||||
@ -283,9 +332,9 @@ export default function Step1NameCamera({
|
|||||||
onUpdate(data);
|
onUpdate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = useCallback(() => {
|
const handleContinue = useCallback(async () => {
|
||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
const streamUrl = generateStreamUrl(data);
|
const streamUrl = await generateStreamUrl(data);
|
||||||
const streamId = `stream_${Date.now()}`;
|
const streamId = `stream_${Date.now()}`;
|
||||||
|
|
||||||
const streamConfig: StreamConfig = {
|
const streamConfig: StreamConfig = {
|
||||||
@ -381,7 +430,7 @@ export default function Step1NameCamera({
|
|||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
{selectedBrand.label}
|
{selectedBrand.label}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="break-all text-sm text-muted-foreground">
|
||||||
{t("cameraWizard.step1.brandUrlFormat", {
|
{t("cameraWizard.step1.brandUrlFormat", {
|
||||||
exampleUrl: selectedBrand.exampleUrl,
|
exampleUrl: selectedBrand.exampleUrl,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -431,6 +431,16 @@ function StreamIssues({
|
|||||||
message: string;
|
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
|
// Video codec check
|
||||||
if (stream.testResult?.videoCodec) {
|
if (stream.testResult?.videoCodec) {
|
||||||
const videoCodec = stream.testResult.videoCodec.toLowerCase();
|
const videoCodec = stream.testResult.videoCodec.toLowerCase();
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const CAMERA_BRANDS = [
|
|||||||
"rtsp://{username}:{password}@{host}:554/cam/realmonitor?channel=1&subtype=0",
|
"rtsp://{username}:{password}@{host}:554/cam/realmonitor?channel=1&subtype=0",
|
||||||
exampleUrl:
|
exampleUrl:
|
||||||
"rtsp://admin:password@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
|
"rtsp://admin:password@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "hikvision" as const,
|
value: "hikvision" as const,
|
||||||
@ -14,42 +15,54 @@ export const CAMERA_BRANDS = [
|
|||||||
template: "rtsp://{username}:{password}@{host}:554/Streaming/Channels/101",
|
template: "rtsp://{username}:{password}@{host}:554/Streaming/Channels/101",
|
||||||
exampleUrl:
|
exampleUrl:
|
||||||
"rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
|
"rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "ubiquiti" as const,
|
value: "ubiquiti" as const,
|
||||||
label: "Ubiquiti",
|
label: "Ubiquiti",
|
||||||
template: "rtsp://{username}:{password}@{host}:554/live/ch0",
|
template: "rtsp://{username}:{password}@{host}:554/live/ch0",
|
||||||
exampleUrl: "rtsp://ubnt:password@192.168.1.100:554/live/ch0",
|
exampleUrl: "rtsp://ubnt:password@192.168.1.100:554/live/ch0",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "reolink" as const,
|
value: "reolink" as const,
|
||||||
label: "Reolink",
|
label: "Reolink",
|
||||||
template: "rtsp://{username}:{password}@{host}:554/h264Preview_01_main",
|
template: "dynamic",
|
||||||
exampleUrl: "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main",
|
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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "axis" as const,
|
value: "axis" as const,
|
||||||
label: "Axis",
|
label: "Axis",
|
||||||
template: "rtsp://{username}:{password}@{host}:554/axis-media/media.amp",
|
template: "rtsp://{username}:{password}@{host}:554/axis-media/media.amp",
|
||||||
exampleUrl: "rtsp://root:password@192.168.1.100:554/axis-media/media.amp",
|
exampleUrl: "rtsp://root:password@192.168.1.100:554/axis-media/media.amp",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "tplink" as const,
|
value: "tplink" as const,
|
||||||
label: "TP-Link",
|
label: "TP-Link",
|
||||||
template: "rtsp://{username}:{password}@{host}:554/stream1",
|
template: "rtsp://{username}:{password}@{host}:554/stream1",
|
||||||
exampleUrl: "rtsp://admin:password@192.168.1.100:554/stream1",
|
exampleUrl: "rtsp://admin:password@192.168.1.100:554/stream1",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "foscam" as const,
|
value: "foscam" as const,
|
||||||
label: "Foscam",
|
label: "Foscam",
|
||||||
template: "rtsp://{username}:{password}@{host}:88/videoMain",
|
template: "rtsp://{username}:{password}@{host}:88/videoMain",
|
||||||
exampleUrl: "rtsp://admin:password@192.168.1.100:88/videoMain",
|
exampleUrl: "rtsp://admin:password@192.168.1.100:88/videoMain",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "other" as const,
|
value: "other" as const,
|
||||||
label: "Other",
|
label: "Other",
|
||||||
template: "",
|
template: "",
|
||||||
exampleUrl: "rtsp://username:password@host:port/path",
|
exampleUrl: "rtsp://username:password@host:port/path",
|
||||||
|
dynamicTemplates: undefined,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// ==================== Camera Name Processing ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
* 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.
|
* Works safely with Unicode input while outputting Latin-only identifiers.
|
||||||
@ -58,3 +60,49 @@ export function processCameraName(userInput: string): {
|
|||||||
friendlyName: userInput,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user