mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-07 03:11:15 +03:00
Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
This commit is contained in:
+17
-3
@@ -321,7 +321,7 @@ def config_raw_paths(request: Request):
|
||||
return JSONResponse(content=raw_paths)
|
||||
|
||||
|
||||
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_raw():
|
||||
config_file = find_config_file()
|
||||
|
||||
@@ -1073,7 +1073,12 @@ def get_recognized_license_plates(
|
||||
|
||||
|
||||
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
|
||||
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
|
||||
def timeline(
|
||||
camera: str = "all",
|
||||
limit: int = 100,
|
||||
source_id: Optional[str] = None,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
clauses = []
|
||||
|
||||
selected_columns = [
|
||||
@@ -1095,6 +1100,9 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
else:
|
||||
clauses.append((Timeline.source_id.in_(source_ids)))
|
||||
|
||||
# Enforce per-camera access control
|
||||
clauses.append((Timeline.camera << allowed_cameras))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((True))
|
||||
|
||||
@@ -1110,7 +1118,10 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
|
||||
|
||||
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
||||
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
def hourly_timeline(
|
||||
params: AppTimelineHourlyQueryParameters = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get hourly summary for timeline."""
|
||||
cameras = params.cameras
|
||||
labels = params.labels
|
||||
@@ -1128,6 +1139,9 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Timeline.camera << camera_list))
|
||||
|
||||
# Enforce per-camera access control
|
||||
clauses.append((Timeline.camera << allowed_cameras))
|
||||
|
||||
if labels != "all":
|
||||
label_list = labels.split(",")
|
||||
clauses.append((Timeline.data["label"] << label_list))
|
||||
|
||||
+68
-2
@@ -73,7 +73,6 @@ def require_admin_by_default():
|
||||
"/stats",
|
||||
"/stats/history",
|
||||
"/config",
|
||||
"/config/raw",
|
||||
"/vainfo",
|
||||
"/nvinfo",
|
||||
"/labels",
|
||||
@@ -896,6 +895,7 @@ def create_user(
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
|
||||
@@ -913,6 +913,7 @@ def delete_user(request: Request, username: str):
|
||||
)
|
||||
|
||||
User.delete_by_id(username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
@@ -1032,6 +1033,7 @@ async def update_role(
|
||||
)
|
||||
|
||||
User.set_by_id(username, {User.role: body.role})
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
@@ -1045,7 +1047,16 @@ async def require_camera_access(
|
||||
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
detail = "Authentication required"
|
||||
try:
|
||||
error_payload = json.loads(current_user.body)
|
||||
detail = (
|
||||
error_payload.get("message") or error_payload.get("detail") or detail
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(status_code=current_user.status_code, detail=detail)
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
@@ -1063,6 +1074,61 @@ async def require_camera_access(
|
||||
)
|
||||
|
||||
|
||||
def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
|
||||
owner_cameras: set[str] = set()
|
||||
|
||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||
if stream_name == camera_name:
|
||||
owner_cameras.add(camera_name)
|
||||
continue
|
||||
|
||||
if stream_name in camera.live.streams.values():
|
||||
owner_cameras.add(camera_name)
|
||||
|
||||
return owner_cameras
|
||||
|
||||
|
||||
async def require_go2rtc_stream_access(
|
||||
stream_name: Optional[str] = None,
|
||||
request: Request = None,
|
||||
):
|
||||
"""Dependency to enforce go2rtc stream access based on owning camera access."""
|
||||
if stream_name is None:
|
||||
return
|
||||
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
detail = "Authentication required"
|
||||
try:
|
||||
error_payload = json.loads(current_user.body)
|
||||
detail = (
|
||||
error_payload.get("message") or error_payload.get("detail") or detail
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(status_code=current_user.status_code, detail=detail)
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
# Admin or full access bypasses
|
||||
if role == "admin" or not roles_dict.get(role):
|
||||
return
|
||||
|
||||
owner_cameras = _get_stream_owner_cameras(request, stream_name)
|
||||
|
||||
if owner_cameras & set(allowed_cameras):
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Access denied to camera '{stream_name}'. Allowed: {allowed_cameras}",
|
||||
)
|
||||
|
||||
|
||||
async def get_allowed_cameras_for_filter(request: Request):
|
||||
"""Dependency to get allowed_cameras for filtering lists."""
|
||||
current_user = await get_current_user(request)
|
||||
|
||||
+18
-5
@@ -20,7 +20,7 @@ from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
require_camera_access,
|
||||
require_go2rtc_stream_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.request.app_body import CameraSetBody
|
||||
@@ -81,14 +81,27 @@ def go2rtc_streams():
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
|
||||
"/go2rtc/streams/{stream_name}",
|
||||
dependencies=[Depends(require_go2rtc_stream_access)],
|
||||
)
|
||||
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
r = requests.get(
|
||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
params={
|
||||
"src": stream_name,
|
||||
"video": "all",
|
||||
"audio": "all",
|
||||
"microphone": "",
|
||||
},
|
||||
)
|
||||
if not r.ok:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
camera_config = request.app.frigate_config.cameras.get(stream_name)
|
||||
|
||||
if camera_config is None:
|
||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||
if stream_name in camera.live.streams.values():
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
break
|
||||
|
||||
if camera_config and camera_config.enabled:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
|
||||
+88
-38
@@ -52,9 +52,11 @@ from frigate.util.file import (
|
||||
load_event_snapshot_image,
|
||||
)
|
||||
from frigate.util.image import get_image_from_recording, get_image_quality_params
|
||||
from frigate.util.media import get_keyframe_before
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(tags=[Tags.media])
|
||||
|
||||
|
||||
@@ -608,6 +610,33 @@ async def vod_ts(
|
||||
if recording.end_time > end_ts:
|
||||
duration -= int((recording.end_time - end_ts) * 1000)
|
||||
|
||||
# nginx-vod-module pushes clipFrom forward to the next keyframe,
|
||||
# which can leave too few frames and produce an empty/unplayable
|
||||
# segment. Snap clipFrom back to the preceding keyframe so the
|
||||
# segment always starts with a decodable frame.
|
||||
if "clipFrom" in clip:
|
||||
keyframe_ms = get_keyframe_before(recording.path, clip["clipFrom"])
|
||||
if keyframe_ms is not None:
|
||||
gained = clip["clipFrom"] - keyframe_ms
|
||||
clip["clipFrom"] = keyframe_ms
|
||||
duration += gained
|
||||
logger.debug(
|
||||
"VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms",
|
||||
keyframe_ms,
|
||||
recording.path,
|
||||
duration,
|
||||
)
|
||||
else:
|
||||
# could not read keyframes, remove clipFrom to use full recording
|
||||
logger.debug(
|
||||
"VOD: no keyframe info for %s, removing clipFrom to use full recording",
|
||||
recording.path,
|
||||
)
|
||||
del clip["clipFrom"]
|
||||
duration = int(recording.duration * 1000)
|
||||
if recording.end_time > end_ts:
|
||||
duration -= int((recording.end_time - end_ts) * 1000)
|
||||
|
||||
if duration < min_duration_ms:
|
||||
# skip if the clip has no valid duration (too short to contain frames)
|
||||
logger.debug(
|
||||
@@ -837,7 +866,6 @@ async def event_snapshot(
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/thumbnail.{extension}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def event_thumbnail(
|
||||
request: Request,
|
||||
@@ -881,11 +909,12 @@ async def event_thumbnail(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_as_np, flags=1)
|
||||
|
||||
# android notifications prefer a 2:1 ratio
|
||||
if format == "android":
|
||||
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_as_np, flags=1)
|
||||
thumbnail = cv2.copyMakeBorder(
|
||||
img = cv2.copyMakeBorder(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
@@ -895,12 +924,14 @@ async def event_thumbnail(
|
||||
(0, 0, 0),
|
||||
)
|
||||
|
||||
_, img = cv2.imencode(
|
||||
f".{extension.value}",
|
||||
thumbnail,
|
||||
get_image_quality_params(extension.value, None),
|
||||
)
|
||||
thumbnail_bytes = img.tobytes()
|
||||
quality_params = None
|
||||
if extension in (Extension.jpg, Extension.jpeg):
|
||||
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
|
||||
elif extension == Extension.webp:
|
||||
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
|
||||
_, encoded = cv2.imencode(f".{extension.value}", img, quality_params)
|
||||
thumbnail_bytes = encoded.tobytes()
|
||||
|
||||
return Response(
|
||||
thumbnail_bytes,
|
||||
@@ -1053,14 +1084,14 @@ def clear_region_grid(request: Request, camera_name: str):
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/snapshot-clean.webp",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||
async def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||
webp_bytes = None
|
||||
event_complete = False
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
event_complete = event.end_time is not None
|
||||
await require_camera_access(event.camera, request=request)
|
||||
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
|
||||
if not (snapshot_config.enabled and event.has_snapshot):
|
||||
return JSONResponse(
|
||||
@@ -1165,7 +1196,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
|
||||
"/events/{event_id}/clip.mp4",
|
||||
)
|
||||
async def event_clip(
|
||||
request: Request,
|
||||
@@ -1179,6 +1210,8 @@ async def event_clip(
|
||||
content={"success": False, "message": "Event not found"}, status_code=404
|
||||
)
|
||||
|
||||
await require_camera_access(event.camera, request=request)
|
||||
|
||||
if not event.has_clip:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Clip not available"}, status_code=404
|
||||
@@ -1195,9 +1228,9 @@ async def event_clip(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
|
||||
"/events/{event_id}/preview.gif",
|
||||
)
|
||||
def event_preview(request: Request, event_id: str):
|
||||
async def event_preview(request: Request, event_id: str):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
@@ -1205,6 +1238,8 @@ def event_preview(request: Request, event_id: str):
|
||||
content={"success": False, "message": "Event not found"}, status_code=404
|
||||
)
|
||||
|
||||
await require_camera_access(event.camera, request=request)
|
||||
|
||||
start_ts = event.start_time
|
||||
end_ts = start_ts + (
|
||||
min(event.end_time - event.start_time, 20) if event.end_time else 20
|
||||
@@ -1227,25 +1262,25 @@ def preview_gif(
|
||||
):
|
||||
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
|
||||
# has preview mp4
|
||||
preview: Previews = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
Previews.duration,
|
||||
Previews.start_time,
|
||||
Previews.end_time,
|
||||
try:
|
||||
preview: Previews = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
Previews.duration,
|
||||
Previews.start_time,
|
||||
Previews.end_time,
|
||||
)
|
||||
.where(
|
||||
Previews.start_time.between(start_ts, end_ts)
|
||||
| Previews.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
.where(
|
||||
Previews.start_time.between(start_ts, end_ts)
|
||||
| Previews.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
|
||||
if not preview:
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
@@ -1563,8 +1598,8 @@ def preview_mp4(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
||||
def review_preview(
|
||||
@router.get("/review/{event_id}/preview")
|
||||
async def review_preview(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
format: str = Query(default="gif", enum=["gif", "mp4"]),
|
||||
@@ -1577,6 +1612,8 @@ def review_preview(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
padding = 8
|
||||
start_ts = review.start_time - padding
|
||||
end_ts = (
|
||||
@@ -1590,12 +1627,14 @@ def review_preview(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
|
||||
"/preview/{file_name}/thumbnail.jpg",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
|
||||
"/preview/{file_name}/thumbnail.webp",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
def preview_thumbnail(file_name: str):
|
||||
async def preview_thumbnail(request: Request, file_name: str):
|
||||
"""Get a thumbnail from the cached preview frames."""
|
||||
if len(file_name) > 1000:
|
||||
return JSONResponse(
|
||||
@@ -1605,6 +1644,17 @@ def preview_thumbnail(file_name: str):
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# Extract camera name from preview filename (format: preview_{camera}-{timestamp}.ext)
|
||||
if not file_name.startswith("preview_"):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid preview filename"},
|
||||
status_code=400,
|
||||
)
|
||||
# Use rsplit to handle camera names containing dashes (e.g. front-door)
|
||||
name_part = file_name[len("preview_") :].rsplit(".", 1)[0] # strip extension
|
||||
camera_name = name_part.rsplit("-", 1)[0] # split off timestamp
|
||||
await require_camera_access(camera_name, request=request)
|
||||
|
||||
safe_file_name_current = sanitize_filename(file_name)
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
|
||||
+59
-11
@@ -17,6 +17,7 @@ from titlecase import titlecase
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.auth import AuthConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -58,6 +59,7 @@ class WebPushClient(Communicator):
|
||||
for c in self.config.cameras.values()
|
||||
}
|
||||
self.last_notification_time: float = 0
|
||||
self.user_cameras: dict[str, set[str]] = {}
|
||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||
self.notification_thread = threading.Thread(
|
||||
target=self._process_notifications, daemon=True
|
||||
@@ -78,13 +80,12 @@ class WebPushClient(Communicator):
|
||||
for sub in user["notification_tokens"]:
|
||||
self.web_pushers[user["username"]].append(WebPusher(sub))
|
||||
|
||||
# notification config updater
|
||||
self.global_config_subscriber = ConfigSubscriber(
|
||||
"config/notifications", exact=True
|
||||
)
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
||||
)
|
||||
self._refresh_user_cameras()
|
||||
|
||||
def subscribe(self, receiver: Callable) -> None:
|
||||
"""Wrapper for allowing dispatcher to subscribe."""
|
||||
@@ -164,13 +165,19 @@ class WebPushClient(Communicator):
|
||||
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Wrapper for publishing when client is in valid state."""
|
||||
# check for updated notification config
|
||||
_, updated_notification_config = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if updated_notification_config:
|
||||
self.config.notifications = updated_notification_config
|
||||
# check for updated global config (notifications, auth)
|
||||
while True:
|
||||
config_topic, config_payload = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
if config_topic is None:
|
||||
break
|
||||
if config_topic == "config/notifications" and config_payload:
|
||||
self.config.notifications = config_payload
|
||||
elif config_topic == "config/auth":
|
||||
if isinstance(config_payload, AuthConfig):
|
||||
self.config.auth = config_payload
|
||||
self._refresh_user_cameras()
|
||||
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
@@ -300,6 +307,31 @@ class WebPushClient(Communicator):
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing notification: {str(e)}")
|
||||
|
||||
def _refresh_user_cameras(self) -> None:
|
||||
"""Rebuild the user-to-cameras access cache from the database."""
|
||||
all_camera_names = set(self.config.cameras.keys())
|
||||
roles_dict = self.config.auth.roles
|
||||
updated: dict[str, set[str]] = {}
|
||||
for user in User.select(User.username, User.role).dicts().iterator():
|
||||
allowed = User.get_allowed_cameras(
|
||||
user["role"], roles_dict, all_camera_names
|
||||
)
|
||||
updated[user["username"]] = set(allowed)
|
||||
logger.debug(
|
||||
"User %s has access to cameras: %s",
|
||||
user["username"],
|
||||
", ".join(allowed),
|
||||
)
|
||||
self.user_cameras = updated
|
||||
|
||||
def _user_has_camera_access(self, username: str, camera: str) -> bool:
|
||||
"""Check if a user has access to a specific camera based on cached roles."""
|
||||
allowed = self.user_cameras.get(username)
|
||||
if allowed is None:
|
||||
logger.debug(f"No camera access information found for user {username}")
|
||||
return False
|
||||
return camera in allowed
|
||||
|
||||
def _within_cooldown(self, camera: str) -> bool:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
if now - self.last_notification_time < self.config.notifications.cooldown:
|
||||
@@ -427,6 +459,14 @@ class WebPushClient(Communicator):
|
||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||
|
||||
for user in self.web_pushers:
|
||||
if not self._user_has_camera_access(user, camera):
|
||||
logger.debug(
|
||||
"Skipping notification for user %s - no access to camera %s",
|
||||
user,
|
||||
camera,
|
||||
)
|
||||
continue
|
||||
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
@@ -474,6 +514,14 @@ class WebPushClient(Communicator):
|
||||
)
|
||||
|
||||
for user in self.web_pushers:
|
||||
if not self._user_has_camera_access(user, camera):
|
||||
logger.debug(
|
||||
"Skipping notification for user %s - no access to camera %s",
|
||||
user,
|
||||
camera,
|
||||
)
|
||||
continue
|
||||
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
|
||||
@@ -92,7 +92,7 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class OnvifConfig(FrigateBaseModel):
|
||||
host: str = Field(
|
||||
host: EnvString = Field(
|
||||
default="",
|
||||
title="ONVIF host",
|
||||
description="Host (and optional scheme) for the ONVIF service for this camera.",
|
||||
|
||||
@@ -24,8 +24,10 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
|
||||
|
||||
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
||||
if isinstance(info.context, dict) and info.context.get("install", False):
|
||||
for k, v in v.items():
|
||||
os.environ[k] = v
|
||||
for k, val in v.items():
|
||||
os.environ[k] = val
|
||||
if k.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[k] = val
|
||||
|
||||
return v
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class MqttConfig(FrigateBaseModel):
|
||||
title="Enable MQTT",
|
||||
description="Enable or disable MQTT integration for state, events, and snapshots.",
|
||||
)
|
||||
host: str = Field(
|
||||
host: EnvString = Field(
|
||||
default="",
|
||||
title="MQTT host",
|
||||
description="Hostname or IP address of the MQTT broker.",
|
||||
|
||||
@@ -103,16 +103,19 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
logger.debug(f"{camera} sending early request to GenAI")
|
||||
|
||||
self.early_request_sent[data["id"]] = True
|
||||
# Copy thumbnails to avoid holding references after cleanup
|
||||
thumbnails_copy = [
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[data["id"]]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
threading.Thread(
|
||||
target=self._genai_embed_description,
|
||||
name=f"_genai_embed_description_{event.id}",
|
||||
daemon=True,
|
||||
args=(
|
||||
event,
|
||||
[
|
||||
data["thumbnail"]
|
||||
for data in self.tracked_events[data["id"]]
|
||||
],
|
||||
thumbnails_copy,
|
||||
),
|
||||
).start()
|
||||
|
||||
@@ -172,8 +175,13 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
embed_image = (
|
||||
[snapshot_image]
|
||||
if event.has_snapshot and source == "snapshot"
|
||||
# Copy thumbnails to avoid holding references
|
||||
else (
|
||||
[data["thumbnail"] for data in self.tracked_events[event_id]]
|
||||
[
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[event_id]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
if len(self.tracked_events.get(event_id, [])) > 0
|
||||
else [thumbnail]
|
||||
)
|
||||
@@ -265,8 +273,13 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
embed_image = (
|
||||
[snapshot_image]
|
||||
if event.has_snapshot and camera_config.objects.genai.use_snapshot
|
||||
# Copy thumbnails to avoid holding references after cleanup
|
||||
else (
|
||||
[data["thumbnail"] for data in self.tracked_events[event.id]]
|
||||
[
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[event.id]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
if num_thumbnails > 0
|
||||
else [thumbnail]
|
||||
)
|
||||
|
||||
@@ -463,6 +463,13 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
thumbs = []
|
||||
for idx, thumb_path in enumerate(frame_paths):
|
||||
thumb_data = cv2.imread(thumb_path)
|
||||
|
||||
if thumb_data is None:
|
||||
logger.warning(
|
||||
"Could not read preview frame at %s, skipping", thumb_path
|
||||
)
|
||||
continue
|
||||
|
||||
ret, jpg = cv2.imencode(
|
||||
".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100]
|
||||
)
|
||||
|
||||
@@ -527,6 +527,17 @@ class RKNNModelRunner(BaseModelRunner):
|
||||
# Transpose from NCHW to NHWC
|
||||
pixel_data = np.transpose(pixel_data, (0, 2, 3, 1))
|
||||
rknn_inputs.append(pixel_data)
|
||||
elif name == "data":
|
||||
# ArcFace: undo Python normalisation to uint8 [0,255]
|
||||
# RKNN runtime applies mean=127.5/std=127.5 internally before first layer
|
||||
face_data = inputs[name]
|
||||
if len(face_data.shape) == 4 and face_data.shape[1] == 3:
|
||||
# Transpose from NCHW to NHWC
|
||||
face_data = np.transpose(face_data, (0, 2, 3, 1))
|
||||
face_data = (
|
||||
((face_data + 1.0) * 127.5).clip(0, 255).astype(np.uint8)
|
||||
)
|
||||
rknn_inputs.append(face_data)
|
||||
else:
|
||||
rknn_inputs.append(inputs[name])
|
||||
|
||||
|
||||
@@ -705,4 +705,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if not self.config.semantic_search.enabled:
|
||||
return
|
||||
|
||||
self.embeddings.embed_thumbnail(event_id, thumbnail)
|
||||
try:
|
||||
self.embeddings.embed_thumbnail(event_id, thumbnail)
|
||||
except ValueError:
|
||||
logger.warning(f"Failed to embed thumbnail for event {event_id}")
|
||||
|
||||
@@ -321,6 +321,9 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
# check if there is an updated config
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
enabled = self.camera_config.enabled
|
||||
if enabled != self.was_enabled:
|
||||
if enabled:
|
||||
@@ -347,9 +350,6 @@ class AudioEventMaintainer(threading.Thread):
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# check if there is an updated config
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
self.read_audio()
|
||||
|
||||
if self.audio_listener:
|
||||
|
||||
@@ -326,6 +326,10 @@ class EventCleanup(threading.Thread):
|
||||
return events_to_update
|
||||
|
||||
def run(self) -> None:
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping event cleanup")
|
||||
return
|
||||
|
||||
# only expire events every 5 minutes
|
||||
while not self.stop_event.wait(300):
|
||||
events_with_expired_clips = self.expire_clips()
|
||||
|
||||
@@ -368,6 +368,11 @@ class RecordingCleanup(threading.Thread):
|
||||
return maybe_empty_dirs
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping recording cleanup")
|
||||
return
|
||||
|
||||
# Expire tmp clips every minute, recordings and clean directories every hour.
|
||||
for counter in itertools.cycle(range(self.config.record.expire_interval)):
|
||||
if self.stop_event.wait(60):
|
||||
|
||||
@@ -280,6 +280,10 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
def run(self):
|
||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping storage maintenance")
|
||||
return
|
||||
|
||||
self.calculate_camera_bandwidth()
|
||||
while not self.stop_event.wait(300):
|
||||
if not self.camera_storage_stats or True in [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
@@ -9,6 +10,33 @@ from frigate.api.auth import (
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
# Minimal multi-camera config used by go2rtc stream access tests.
|
||||
# front_door has a stream alias "front_door_main"; back_door uses its own name.
|
||||
# The "limited_user" role is restricted to front_door only.
|
||||
_MULTI_CAMERA_CONFIG = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {
|
||||
"roles": {
|
||||
"limited_user": ["front_door"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"live": {"streams": {"default": "front_door_main"}},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestCameraAccessEventReview(BaseTestHttp):
|
||||
def setUp(self):
|
||||
@@ -190,3 +218,179 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
resp = client.get("/events/summary")
|
||||
summary_list = resp.json()
|
||||
assert len(summary_list) == 2
|
||||
|
||||
|
||||
class TestGo2rtcStreamAccess(BaseTestHttp):
|
||||
"""Tests for require_go2rtc_stream_access — the auth dependency on
|
||||
GET /go2rtc/streams/{stream_name}.
|
||||
|
||||
go2rtc is not running in unit tests, so an authorized request returns
|
||||
500 (the proxy call fails), while an unauthorized request returns 401/403
|
||||
before the proxy is ever reached.
|
||||
"""
|
||||
|
||||
def _make_app(self, config_override: dict | None = None):
|
||||
"""Build a test app, optionally replacing self.minimal_config."""
|
||||
if config_override is not None:
|
||||
self.minimal_config = config_override
|
||||
app = super().create_app()
|
||||
|
||||
# Allow tests to control the current user via request headers.
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."},
|
||||
status_code=401,
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
return app
|
||||
|
||||
def setUp(self):
|
||||
super().setUp([Event, ReviewSegment, Recordings])
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_stream(
|
||||
self, app, stream_name: str, role: str = "admin", user: str = "test"
|
||||
):
|
||||
"""Issue GET /go2rtc/streams/{stream_name} with the given role."""
|
||||
with AuthTestClient(app) as client:
|
||||
return client.get(
|
||||
f"/go2rtc/streams/{stream_name}",
|
||||
headers={"remote-user": user, "remote-role": role},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_admin_can_access_any_stream(self):
|
||||
"""Admin role bypasses camera restrictions."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
# front_door stream — go2rtc is not running so expect 500, not 401/403
|
||||
resp = self._get_stream(app, "front_door", role="admin")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"Admin should not be blocked; got {resp.status_code}"
|
||||
)
|
||||
|
||||
# back_door stream
|
||||
resp = self._get_stream(app, "back_door", role="admin")
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_missing_auth_headers_returns_401(self):
|
||||
"""Requests without auth headers must be rejected with 401."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
# Use plain TestClient (not AuthTestClient) so no headers are injected.
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/go2rtc/streams/front_door")
|
||||
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}"
|
||||
|
||||
def test_unconfigured_role_can_access_any_stream(self):
|
||||
"""When no camera restrictions are configured for a role the user
|
||||
should have access to all streams (no roles_dict entry ⇒ no restriction)."""
|
||||
no_roles_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
app = self._make_app(no_roles_config)
|
||||
|
||||
# "myuser" role is not listed in roles_dict — should be allowed everywhere
|
||||
for stream in ("front_door", "back_door"):
|
||||
resp = self._get_stream(app, stream, role="myuser")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"Unconfigured role should not be blocked on '{stream}'; "
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_restricted_role_can_access_allowed_camera(self):
|
||||
"""limited_user role (restricted to front_door) can access front_door stream."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
resp = self._get_stream(app, "front_door", role="limited_user")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"limited_user should be allowed on front_door; got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_restricted_role_blocked_from_disallowed_camera(self):
|
||||
"""limited_user role (restricted to front_door) cannot access back_door stream."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
resp = self._get_stream(app, "back_door", role="limited_user")
|
||||
assert resp.status_code == 403, (
|
||||
f"limited_user should be denied on back_door; got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_stream_alias_allowed_for_owning_camera(self):
|
||||
"""Stream alias 'front_door_main' is owned by front_door; limited_user (who
|
||||
is allowed front_door) should be permitted."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
# front_door_main is the alias defined in live.streams for front_door
|
||||
resp = self._get_stream(app, "front_door_main", role="limited_user")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"limited_user should be allowed on alias front_door_main; "
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
||||
"""limited_user cannot access a stream alias that belongs to a camera they
|
||||
are not allowed to see."""
|
||||
# Give back_door a stream alias and restrict limited_user to front_door only
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {
|
||||
"roles": {
|
||||
"limited_user": ["front_door"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"live": {"streams": {"default": "back_door_main"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
app = self._make_app(config)
|
||||
resp = self._get_stream(app, "back_door_main", role="limited_user")
|
||||
assert resp.status_code == 403, (
|
||||
f"limited_user should be denied on alias back_door_main; "
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for environment variable handling."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from frigate.config.env import (
|
||||
FRIGATE_ENV_VARS,
|
||||
validate_env_string,
|
||||
validate_env_vars,
|
||||
)
|
||||
|
||||
|
||||
class TestEnvString(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
|
||||
def tearDown(self):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
|
||||
def test_substitution(self):
|
||||
"""EnvString substitutes FRIGATE_ env vars."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_TEST_HOST"] = "192.168.1.100"
|
||||
result = validate_env_string("{FRIGATE_TEST_HOST}")
|
||||
self.assertEqual(result, "192.168.1.100")
|
||||
|
||||
def test_substitution_in_url(self):
|
||||
"""EnvString substitutes vars embedded in a URL."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_CAM_USER"] = "admin"
|
||||
FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"] = "secret"
|
||||
result = validate_env_string(
|
||||
"rtsp://{FRIGATE_CAM_USER}:{FRIGATE_CAM_PASS}@10.0.0.1/stream"
|
||||
)
|
||||
self.assertEqual(result, "rtsp://admin:secret@10.0.0.1/stream")
|
||||
|
||||
def test_no_placeholder(self):
|
||||
"""Plain strings pass through unchanged."""
|
||||
result = validate_env_string("192.168.1.1")
|
||||
self.assertEqual(result, "192.168.1.1")
|
||||
|
||||
def test_unknown_var_raises(self):
|
||||
"""Referencing an unknown var raises KeyError."""
|
||||
with self.assertRaises(KeyError):
|
||||
validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
|
||||
|
||||
|
||||
class TestEnvVars(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
self._original_environ = os.environ.copy()
|
||||
|
||||
def tearDown(self):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
# Clean up any env vars we set
|
||||
for key in list(os.environ.keys()):
|
||||
if key not in self._original_environ:
|
||||
del os.environ[key]
|
||||
|
||||
def _make_context(self, install: bool):
|
||||
"""Create a mock ValidationInfo with the given install flag."""
|
||||
|
||||
class MockContext:
|
||||
def __init__(self, ctx):
|
||||
self.context = ctx
|
||||
|
||||
mock = MockContext({"install": install})
|
||||
return mock
|
||||
|
||||
def test_install_sets_os_environ(self):
|
||||
"""validate_env_vars with install=True sets os.environ."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"MY_CUSTOM_VAR": "value123"}, ctx)
|
||||
self.assertEqual(os.environ.get("MY_CUSTOM_VAR"), "value123")
|
||||
|
||||
def test_install_updates_frigate_env_vars(self):
|
||||
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"FRIGATE_MQTT_PASS": "secret"}, ctx)
|
||||
self.assertEqual(FRIGATE_ENV_VARS["FRIGATE_MQTT_PASS"], "secret")
|
||||
|
||||
def test_install_skips_non_frigate_in_env_vars_dict(self):
|
||||
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"OTHER_VAR": "value"}, ctx)
|
||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
||||
self.assertNotIn("OTHER_VAR", FRIGATE_ENV_VARS)
|
||||
|
||||
def test_no_install_does_not_set(self):
|
||||
"""validate_env_vars without install=True does not modify state."""
|
||||
ctx = self._make_context(install=False)
|
||||
validate_env_vars({"FRIGATE_SKIP": "nope"}, ctx)
|
||||
self.assertNotIn("FRIGATE_SKIP", FRIGATE_ENV_VARS)
|
||||
self.assertNotIn("FRIGATE_SKIP", os.environ)
|
||||
|
||||
def test_env_vars_available_for_env_string(self):
|
||||
"""Vars set via validate_env_vars are usable in validate_env_string."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"FRIGATE_BROKER": "mqtt.local"}, ctx)
|
||||
result = validate_env_string("{FRIGATE_BROKER}")
|
||||
self.assertEqual(result, "mqtt.local")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -55,6 +55,14 @@ DYNAMIC_OBJECT_THRESHOLDS = StationaryThresholds(
|
||||
motion_classifier_enabled=True,
|
||||
)
|
||||
|
||||
# Thresholds for objects that are not expected to be stationary
|
||||
NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
|
||||
objects=["license_plate"],
|
||||
known_active_iou=0.9,
|
||||
stationary_check_iou=0.9,
|
||||
max_stationary_history=4,
|
||||
)
|
||||
|
||||
|
||||
def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||
"""Get the stationary thresholds for a given object label."""
|
||||
@@ -65,6 +73,9 @@ def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||
if label in DYNAMIC_OBJECT_THRESHOLDS.objects:
|
||||
return DYNAMIC_OBJECT_THRESHOLDS
|
||||
|
||||
if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
|
||||
return NON_STATIONARY_OBJECT_THRESHOLDS
|
||||
|
||||
return StationaryThresholds()
|
||||
|
||||
|
||||
|
||||
+64
-1
@@ -4,13 +4,20 @@ import datetime
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import subprocess as sp
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from peewee import DatabaseError, chunked
|
||||
|
||||
from frigate.const import CLIPS_DIR, EXPORT_DIR, RECORD_DIR, THUMB_DIR
|
||||
from frigate.const import (
|
||||
CLIPS_DIR,
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
EXPORT_DIR,
|
||||
RECORD_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.models import (
|
||||
Event,
|
||||
Export,
|
||||
@@ -26,6 +33,12 @@ logger = logging.getLogger(__name__)
|
||||
# Safety threshold - abort if more than 50% of files would be deleted
|
||||
SAFETY_THRESHOLD = 0.5
|
||||
|
||||
FFPROBE_PATH = (
|
||||
f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe"
|
||||
if DEFAULT_FFMPEG_VERSION
|
||||
else "ffprobe"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
@@ -808,3 +821,53 @@ def sync_all_media(
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_keyframe_before(path: str, offset_ms: int) -> int | None:
|
||||
"""Get the timestamp (ms) of the last keyframe at or before offset_ms.
|
||||
|
||||
Uses ffprobe packet index to read keyframe positions from the mp4 file.
|
||||
Returns None if ffprobe fails or no keyframe is found before the offset.
|
||||
"""
|
||||
try:
|
||||
result = sp.run(
|
||||
[
|
||||
FFPROBE_PATH,
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"packet=pts_time,flags",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"-loglevel",
|
||||
"error",
|
||||
path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (sp.TimeoutExpired, FileNotFoundError):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
offset_s = offset_ms / 1000.0
|
||||
best_ms = None
|
||||
for line in result.stdout.decode().strip().splitlines():
|
||||
parts = line.strip().split(",")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
ts_str, flags = parts
|
||||
if "K" not in flags:
|
||||
continue
|
||||
try:
|
||||
ts = float(ts_str)
|
||||
except ValueError:
|
||||
continue
|
||||
if ts <= offset_s:
|
||||
best_ms = int(ts * 1000)
|
||||
else:
|
||||
break
|
||||
|
||||
return best_ms
|
||||
|
||||
+2
-2
@@ -515,7 +515,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role}", "offline"
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
continue
|
||||
@@ -528,7 +528,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role}", "offline"
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
p["logpipe"].dump()
|
||||
|
||||
Reference in New Issue
Block a user