mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 03:10:33 +03:00
Compare commits
4
Commits
d9cc632b9a
...
d8e40035c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e40035c4 | ||
|
|
c007661a71 | ||
|
|
9b02c7318d | ||
|
|
36c61a3607 |
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
+16
-8
@@ -254,7 +254,14 @@ rateLimiter = RateLimiter()
|
||||
|
||||
|
||||
def get_remote_addr(request: Request):
|
||||
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
|
||||
# fall back to the direct TCP peer when no proxy chain is present
|
||||
direct_addr = request.client.host if request.client else None
|
||||
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if not forwarded_for:
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
route = list(reversed(forwarded_for.split(",")))
|
||||
logger.debug(f"IP Route: {[r for r in route]}")
|
||||
trusted_proxies = []
|
||||
for proxy in request.app.frigate_config.auth.trusted_proxies:
|
||||
@@ -291,13 +298,8 @@ def get_remote_addr(request: Request):
|
||||
logger.debug(f"First untrusted IP: {str(ip)}")
|
||||
return str(ip)
|
||||
|
||||
# if there wasn't anything in the route, just return the default
|
||||
remote_addr = None
|
||||
|
||||
if hasattr(request, "remote_addr"):
|
||||
remote_addr = request.remote_addr
|
||||
|
||||
return remote_addr or "127.0.0.1"
|
||||
# every hop in the route was trusted, so fall back to the direct peer
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
|
||||
def _cleanup_first_load_seen() -> None:
|
||||
@@ -416,6 +418,12 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
|
||||
+9
-11
@@ -269,12 +269,12 @@ async def no_recordings(
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
cameras = ",".join(filtered)
|
||||
camera_list = list(requested.intersection(allowed_cameras))
|
||||
else:
|
||||
cameras = allowed_cameras
|
||||
camera_list = list(allowed_cameras)
|
||||
|
||||
if not camera_list:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
@@ -283,12 +283,10 @@ async def no_recordings(
|
||||
)
|
||||
scale = params.scale
|
||||
|
||||
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses = [
|
||||
(Recordings.end_time >= after) & (Recordings.start_time <= before),
|
||||
(Recordings.camera << camera_list),
|
||||
]
|
||||
|
||||
# Get recording start times
|
||||
data: list[Recordings] = (
|
||||
|
||||
@@ -13,6 +13,10 @@ from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +33,11 @@ class CameraActivityManager:
|
||||
self.zone_all_object_counts: dict[str, Counter] = {}
|
||||
self.zone_active_object_counts: dict[str, Counter] = {}
|
||||
self.all_zone_labels: dict[str, set[str]] = {}
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
config,
|
||||
config.cameras,
|
||||
[CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.objects],
|
||||
)
|
||||
|
||||
for camera_config in config.cameras.values():
|
||||
if not camera_config.enabled_in_config:
|
||||
@@ -56,7 +65,40 @@ class CameraActivityManager:
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
def __rebuild_zone_labels(self) -> None:
|
||||
"""Rebuild zone label tracking after a runtime zones/objects change."""
|
||||
new_zone_labels: dict[str, set[str]] = {}
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if not camera_config.enabled_in_config or camera_config.name is None:
|
||||
continue
|
||||
|
||||
for zone, zone_config in camera_config.zones.items():
|
||||
new_zone_labels.setdefault(zone, set()).update(
|
||||
zone_config.objects
|
||||
if zone_config.objects
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
# drop counters for zones that no longer exist
|
||||
for zone in list(self.zone_all_object_counts.keys()):
|
||||
if zone not in new_zone_labels:
|
||||
self.zone_all_object_counts.pop(zone, None)
|
||||
self.zone_active_object_counts.pop(zone, None)
|
||||
|
||||
# ensure counters exist for new zones so the first count is published
|
||||
for zone in new_zone_labels:
|
||||
self.zone_all_object_counts.setdefault(zone, Counter())
|
||||
self.zone_active_object_counts.setdefault(zone, Counter())
|
||||
|
||||
self.all_zone_labels = new_zone_labels
|
||||
|
||||
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
if "zones" in updated_topics or "objects" in updated_topics:
|
||||
self.__rebuild_zone_labels()
|
||||
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
@@ -161,6 +203,9 @@ class CameraActivityManager:
|
||||
self.publish(f"{camera}/all", sum(list(all_objects.values())))
|
||||
self.publish(f"{camera}/all/active", sum(list(active_objects.values())))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.config_subscriber.stop()
|
||||
|
||||
|
||||
class AudioActivityManager:
|
||||
def __init__(
|
||||
|
||||
@@ -397,6 +397,8 @@ class Dispatcher:
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.camera_activity.stop()
|
||||
|
||||
for comm in self.comms:
|
||||
comm.stop()
|
||||
|
||||
|
||||
@@ -475,3 +475,55 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_recordings_unavailable_cameras_all_scopes_to_allowed_cameras(self):
|
||||
"""cameras=all must not error and must only consider allowed cameras.
|
||||
|
||||
allowed_cameras is mocked to ["front_door"]. A back_door recording that
|
||||
would otherwise fill the gap must be ignored, and the request must not
|
||||
500 the way it did when cameras was reassigned to a list.
|
||||
"""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# front_door has a 20s gap (1010-1030).
|
||||
Recordings.insert(
|
||||
id="front_a",
|
||||
path="/media/recordings/front_a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1010,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="front_b",
|
||||
path="/media/recordings/front_b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1030,
|
||||
end_time=1040,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
# back_door is not in allowed_cameras; its full-window coverage must
|
||||
# not mask the front_door gap.
|
||||
Recordings.insert(
|
||||
id="back_a",
|
||||
path="/media/recordings/back_a.mp4",
|
||||
camera="back_door",
|
||||
start_time=1000,
|
||||
end_time=1040,
|
||||
duration=40,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1040,
|
||||
"scale": 5,
|
||||
"cameras": "all",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
|
||||
|
||||
+34
-1
@@ -9,6 +9,7 @@ from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from multiprocessing import Queue, Value
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from frigate.camera import CameraMetrics
|
||||
@@ -22,7 +23,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||
from frigate.const import CACHE_DIR, PROCESS_PRIORITY_HIGH
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
@@ -453,6 +454,38 @@ class CameraWatchdog(threading.Thread):
|
||||
invalid_stale = invalid_stale_condition
|
||||
|
||||
if cache_stale or valid_stale or invalid_stale:
|
||||
# The staleness above is measured from the recording
|
||||
# maintainer's IPC heartbeat, which lags whenever the
|
||||
# maintainer falls behind (e.g. "Unable to keep up with
|
||||
# recording segments in cache"). A late message is not
|
||||
# a dead recorder: corroborate against the cache dir
|
||||
# before restarting, otherwise every camera restarts
|
||||
# together on maintainer lag and the resulting segment
|
||||
# churn makes the overload worse.
|
||||
if not invalid_stale:
|
||||
newest_on_disk = max(
|
||||
(
|
||||
f.stat().st_mtime
|
||||
for f in Path(CACHE_DIR).glob(
|
||||
f"{self.config.name}@*"
|
||||
)
|
||||
),
|
||||
default=0.0,
|
||||
)
|
||||
if (
|
||||
newest_on_disk > 0
|
||||
and now_utc.timestamp() - newest_on_disk
|
||||
< self.record_stale_threshold
|
||||
):
|
||||
self.logger.warning(
|
||||
f"Recording heartbeat for {self.config.name} is stale but a cache "
|
||||
f"segment is only {now_utc.timestamp() - newest_on_disk:.0f}s old — "
|
||||
"skipping the record process restart (maintainer heartbeat lag, "
|
||||
"not a recording failure)."
|
||||
)
|
||||
self.latest_cache_segment_time = newest_on_disk
|
||||
continue
|
||||
|
||||
if cache_stale:
|
||||
reason = "No new recording segments were created"
|
||||
elif valid_stale:
|
||||
|
||||
@@ -1263,7 +1263,6 @@ function ObjectDetailsTab({
|
||||
}
|
||||
|
||||
setState("submitted");
|
||||
setSearch({ ...search, plus_id: "new_upload" });
|
||||
mutate(
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
@@ -1286,7 +1285,7 @@ function ObjectDetailsTab({
|
||||
);
|
||||
}
|
||||
},
|
||||
[search, mutate, mapSearchResults, setSearch, isEventsKey, t],
|
||||
[search, mutate, mapSearchResults, isEventsKey, t],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -47,6 +47,7 @@ type HlsVideoPlayerProps = {
|
||||
frigateControls?: boolean;
|
||||
inpointOffset?: number;
|
||||
onClipEnded?: (currentTime: number) => void;
|
||||
onClipPrevious?: (diff: number) => void;
|
||||
onPlayerLoaded?: () => void;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onPlaying?: () => void;
|
||||
@@ -74,6 +75,7 @@ export default function HlsVideoPlayer({
|
||||
frigateControls = true,
|
||||
inpointOffset = 0,
|
||||
onClipEnded,
|
||||
onClipPrevious,
|
||||
onPlayerLoaded,
|
||||
onTimeUpdate,
|
||||
onPlaying,
|
||||
@@ -339,11 +341,17 @@ export default function HlsVideoPlayer({
|
||||
onSeek={(diff) => {
|
||||
const currentTime = videoRef.current?.currentTime;
|
||||
|
||||
if (!videoRef.current || !currentTime) {
|
||||
if (!videoRef.current || currentTime == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
||||
const newTime = currentTime + diff;
|
||||
|
||||
if (newTime < 0 && onClipPrevious) {
|
||||
onClipPrevious(diff);
|
||||
} else {
|
||||
videoRef.current.currentTime = Math.max(0, newTime);
|
||||
}
|
||||
}}
|
||||
onSetPlaybackRate={(rate) => {
|
||||
setPlaybackRate(rate, true);
|
||||
|
||||
@@ -49,6 +49,7 @@ type DynamicVideoPlayerProps = {
|
||||
onControllerReady: (controller: DynamicVideoController) => void;
|
||||
onTimestampUpdate?: (timestamp: number) => void;
|
||||
onClipEnded?: () => void;
|
||||
onClipPrevious?: (diff: number) => void;
|
||||
onSeekToTime?: (timestamp: number, play?: boolean) => void;
|
||||
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
toggleFullscreen: () => void;
|
||||
@@ -68,6 +69,7 @@ export default function DynamicVideoPlayer({
|
||||
onControllerReady,
|
||||
onTimestampUpdate,
|
||||
onClipEnded,
|
||||
onClipPrevious,
|
||||
onSeekToTime,
|
||||
setFullResolution,
|
||||
toggleFullscreen,
|
||||
@@ -343,6 +345,7 @@ export default function DynamicVideoPlayer({
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlayerLoaded={onPlayerLoaded}
|
||||
onClipEnded={onValidateClipEnd}
|
||||
onClipPrevious={onClipPrevious}
|
||||
onSeekToTime={(timestamp, play) => {
|
||||
if (onSeekToTime) {
|
||||
onSeekToTime(timestamp, play);
|
||||
|
||||
@@ -336,6 +336,16 @@ export function RecordingView({
|
||||
[currentTimeRange, updateSelectedSegment],
|
||||
);
|
||||
|
||||
const onClipPrevious = useCallback(
|
||||
(diff: number) => {
|
||||
manuallySetCurrentTime(
|
||||
currentTime + diff,
|
||||
mainControllerRef.current?.isPlaying() ?? false,
|
||||
);
|
||||
},
|
||||
[currentTime, manuallySetCurrentTime],
|
||||
);
|
||||
|
||||
const onShareReviewLink = useCallback(
|
||||
(timestamp: number) => {
|
||||
const reviewUrl = createRecordingReviewUrl(location.pathname, {
|
||||
@@ -902,6 +912,7 @@ export function RecordingView({
|
||||
);
|
||||
}}
|
||||
onClipEnded={onClipEnded}
|
||||
onClipPrevious={onClipPrevious}
|
||||
onSeekToTime={manuallySetCurrentTime}
|
||||
onControllerReady={(controller) => {
|
||||
mainControllerRef.current = controller;
|
||||
|
||||
Reference in New Issue
Block a user