mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-02 20:00:51 +03:00
* miscellaneous fixes - keep base path in recording share links - reject non-positive scale on recordings/unavailable, which looped forever on the event loop - reject negative vod event padding, which cleared has_clip - clamp negative preview gif/mp4 offsets - don't delete exports that are still in progress - hash on unknown login usernames so timing doesn't reveal valid users - include the first minute of the events time-of-day filter - show an error when the push subscription is refused - don't wipe an unnamed known plates entry when Add is pressed again - use elapsed hours for export last N hours so DST doesn't shift the range * fix live audio settings and trigger highlights - start each camera group's audio from that group's saved settings instead of merging every group - don't clear the previous trigger's highlight timeout when another trigger fires * address review feedback - Discard the export row when an export fails before or during ffmpeg, so it doesn't stay in progress until a restart - Skip the empty thumbnail path when discarding a failed export - Measure preview clip duration from the clamped start - Default live player volume to 1 when the group has no saved setting - Clear the unsaved registration message when notification registration fails - Keep a highlight timer per trigger - Remove the severity order_by in /review, which the start_time order_by replaced * Hold pandas 3.1 until numpy 2 * fix two-way talk shortcut and detail stream active item after camera switch * show already running message when a media sync is in progress * pick the earliest overlapping preview for preview gif and mp4 * unmute camera audio when starting two-way talk from the shortcut * remove password strength meter and fix confirm password feedback The strength meter still scored against the old 8 character and character class rules, so any password with a digit, like test1234, showed a full green "Very Strong" bar next to the failing 12 character requirement. The meter is gone and the length checklist is the only feedback. The password field's form message duplicated the checklist, and editing the password never revalidated the confirm field, so a fixed mismatch kept showing "Passwords do not match". Editing the password now revalidates the confirm field once it has a value, and both dialogs show a live match/mismatch indicator in place of the confirm form message.
589 lines
22 KiB
Python
589 lines
22 KiB
Python
from datetime import datetime
|
|
from typing import Any
|
|
from unittest.mock import Mock
|
|
|
|
from playhouse.shortcuts import model_to_dict
|
|
|
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
|
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
|
from frigate.stats.emitter import StatsEmitter
|
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp, Request
|
|
from frigate.test.test_storage import _insert_mock_event
|
|
|
|
|
|
class TestHttpApp(BaseTestHttp):
|
|
def setUp(self):
|
|
super().setUp([Event, Recordings, ReviewSegment, Timeline])
|
|
self.app = super().create_app()
|
|
|
|
# Mock get_current_user for all tests
|
|
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}
|
|
|
|
self.app.dependency_overrides[get_current_user] = mock_get_current_user
|
|
|
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
|
return ["front_door"]
|
|
|
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
|
mock_get_allowed_cameras_for_filter
|
|
)
|
|
|
|
def tearDown(self):
|
|
self.app.dependency_overrides.clear()
|
|
super().tearDown()
|
|
|
|
####################################################################################################################
|
|
################################### GET /events Endpoint #########################################################
|
|
####################################################################################################################
|
|
def test_get_event_list_no_events(self):
|
|
with AuthTestClient(self.app) as client:
|
|
events = client.get("/events").json()
|
|
assert len(events) == 0
|
|
|
|
def test_get_event_list_no_match_event_id(self):
|
|
id = "123456.random"
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id)
|
|
events = client.get("/events", params={"event_id": "abc"}).json()
|
|
assert len(events) == 0
|
|
|
|
def test_get_event_list_match_event_id(self):
|
|
id = "123456.random"
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id)
|
|
events = client.get("/events", params={"event_id": id}).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == id
|
|
|
|
def test_get_event_list_match_length(self):
|
|
now = int(datetime.now().timestamp())
|
|
|
|
id = "123456.random"
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id, now, now + 1)
|
|
events = client.get(
|
|
"/events", params={"max_length": 1, "min_length": 1}
|
|
).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == id
|
|
|
|
def test_get_event_list_no_match_max_length(self):
|
|
now = int(datetime.now().timestamp())
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
id = "123456.random"
|
|
super().insert_mock_event(id, now, now + 2)
|
|
events = client.get("/events", params={"max_length": 1}).json()
|
|
assert len(events) == 0
|
|
|
|
def test_get_event_list_no_match_min_length(self):
|
|
now = int(datetime.now().timestamp())
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
id = "123456.random"
|
|
super().insert_mock_event(id, now, now + 2)
|
|
events = client.get("/events", params={"min_length": 3}).json()
|
|
assert len(events) == 0
|
|
|
|
def test_get_event_list_limit(self):
|
|
now = datetime.now().timestamp()
|
|
id = "123456.random"
|
|
id2 = "54321.random"
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id, start_time=now + 1)
|
|
events = client.get("/events").json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == id
|
|
|
|
super().insert_mock_event(id2, start_time=now)
|
|
events = client.get("/events").json()
|
|
assert len(events) == 2
|
|
|
|
events = client.get("/events", params={"limit": 1}).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == id
|
|
|
|
events = client.get("/events", params={"limit": 3}).json()
|
|
assert len(events) == 2
|
|
|
|
def test_get_event_list_no_match_has_clip(self):
|
|
now = int(datetime.now().timestamp())
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
id = "123456.random"
|
|
super().insert_mock_event(id, now, now + 2)
|
|
events = client.get("/events", params={"has_clip": 0}).json()
|
|
assert len(events) == 0
|
|
|
|
def test_get_event_list_has_clip(self):
|
|
with AuthTestClient(self.app) as client:
|
|
id = "123456.random"
|
|
super().insert_mock_event(id, has_clip=True)
|
|
events = client.get("/events", params={"has_clip": 1}).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == id
|
|
|
|
def test_get_event_list_sort_score(self):
|
|
with AuthTestClient(self.app) as client:
|
|
id = "123456.random"
|
|
id2 = "54321.random"
|
|
super().insert_mock_event(id, top_score=37, score=37, data={"score": 50})
|
|
super().insert_mock_event(id2, top_score=47, score=47, data={"score": 20})
|
|
events = client.get("/events", params={"sort": "score_asc"}).json()
|
|
assert len(events) == 2
|
|
assert events[0]["id"] == id2
|
|
assert events[1]["id"] == id
|
|
|
|
events = client.get("/events", params={"sort": "score_desc"}).json()
|
|
assert len(events) == 2
|
|
assert events[0]["id"] == id
|
|
assert events[1]["id"] == id2
|
|
|
|
def test_get_event_list_sort_start_time(self):
|
|
now = int(datetime.now().timestamp())
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
id = "123456.random"
|
|
id2 = "54321.random"
|
|
super().insert_mock_event(id, start_time=now + 3)
|
|
super().insert_mock_event(id2, start_time=now)
|
|
events = client.get("/events", params={"sort": "date_asc"}).json()
|
|
assert len(events) == 2
|
|
assert events[0]["id"] == id2
|
|
assert events[1]["id"] == id
|
|
|
|
events = client.get("/events", params={"sort": "date_desc"}).json()
|
|
assert len(events) == 2
|
|
assert events[0]["id"] == id
|
|
assert events[1]["id"] == id2
|
|
|
|
def test_get_event_list_offset_pages_score_sort(self):
|
|
now = datetime.now().timestamp()
|
|
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
for i, score in enumerate(scores):
|
|
super().insert_mock_event(
|
|
f"event-{i}", start_time=now + i, data={"score": score}
|
|
)
|
|
|
|
params = {"sort": "score_desc"}
|
|
full = [e["id"] for e in client.get("/events", params=params).json()]
|
|
paged = [
|
|
e["id"]
|
|
for offset in (0, 2, 4)
|
|
for e in client.get(
|
|
"/events", params={**params, "limit": 2, "offset": offset}
|
|
).json()
|
|
]
|
|
|
|
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
|
assert paged == full
|
|
|
|
def test_get_event_list_match_multilingual_attribute(self):
|
|
event_id = "123456.zh"
|
|
attribute = "中文标签"
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(event_id, data={"custom_attr": attribute})
|
|
|
|
events = client.get("/events", params={"attributes": attribute}).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == event_id
|
|
|
|
events = client.get(
|
|
"/events", params={"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE"}
|
|
).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == event_id
|
|
|
|
def test_events_search_match_multilingual_attribute(self):
|
|
event_id = "123456.zh.search"
|
|
attribute = "中文标签"
|
|
mock_embeddings = Mock()
|
|
mock_embeddings.search_thumbnail.return_value = [(event_id, 0.05)]
|
|
|
|
self.app.frigate_config.semantic_search.enabled = True
|
|
self.app.embeddings = mock_embeddings
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(event_id, data={"custom_attr": attribute})
|
|
|
|
events = client.get(
|
|
"/events/search",
|
|
params={
|
|
"search_type": "similarity",
|
|
"event_id": event_id,
|
|
"attributes": attribute,
|
|
},
|
|
).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == event_id
|
|
|
|
events = client.get(
|
|
"/events/search",
|
|
params={
|
|
"search_type": "similarity",
|
|
"event_id": event_id,
|
|
"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE",
|
|
},
|
|
).json()
|
|
assert len(events) == 1
|
|
assert events[0]["id"] == event_id
|
|
|
|
def test_events_search_offset_pages_score_sort(self):
|
|
now = datetime.now().timestamp()
|
|
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
|
ids = [f"event-{i}" for i in range(len(scores))]
|
|
mock_embeddings = Mock()
|
|
mock_embeddings.search_thumbnail.return_value = [
|
|
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
|
|
]
|
|
|
|
self.app.frigate_config.semantic_search.enabled = True
|
|
self.app.embeddings = mock_embeddings
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
for i, score in enumerate(scores):
|
|
super().insert_mock_event(
|
|
ids[i], start_time=now + i, data={"score": score}
|
|
)
|
|
|
|
params = {
|
|
"search_type": "similarity",
|
|
"event_id": ids[0],
|
|
"sort": "score_desc",
|
|
}
|
|
paged = [
|
|
e["id"]
|
|
for offset in (0, 2, 4)
|
|
for e in client.get(
|
|
"/events/search",
|
|
params={**params, "limit": 2, "offset": offset},
|
|
).json()
|
|
]
|
|
|
|
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
|
|
|
def test_events_search_offset_pages_orders_ties_by_id(self):
|
|
now = datetime.now().timestamp()
|
|
ids = ["event-c", "event-a", "event-b"]
|
|
mock_embeddings = Mock()
|
|
mock_embeddings.search_thumbnail.return_value = [
|
|
(event_id, 0.1) for event_id in ids
|
|
]
|
|
|
|
self.app.frigate_config.semantic_search.enabled = True
|
|
self.app.embeddings = mock_embeddings
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
for i, event_id in enumerate(ids):
|
|
super().insert_mock_event(
|
|
event_id, start_time=now + i, data={"score": 0.8}
|
|
)
|
|
|
|
for sort in ("score_desc", "relevance"):
|
|
params = {
|
|
"search_type": "similarity",
|
|
"event_id": ids[0],
|
|
"sort": sort,
|
|
}
|
|
paged = [
|
|
e["id"]
|
|
for offset in (0, 1, 2)
|
|
for e in client.get(
|
|
"/events/search",
|
|
params={**params, "limit": 1, "offset": offset},
|
|
).json()
|
|
]
|
|
|
|
assert paged == ["event-a", "event-b", "event-c"]
|
|
|
|
def test_event_list_rejects_negative_offset(self):
|
|
with AuthTestClient(self.app) as client:
|
|
response = client.get("/events", params={"offset": -5})
|
|
assert response.status_code == 422
|
|
|
|
response = client.get(
|
|
"/events/search",
|
|
params={"query": "car", "offset": -5},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
|
mock_embeddings = Mock()
|
|
self.app.frigate_config.semantic_search.enabled = True
|
|
self.app.embeddings = mock_embeddings
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event("hidden.anchor", camera="back_door")
|
|
response = client.get(
|
|
"/events/search",
|
|
params={
|
|
"search_type": "similarity",
|
|
"event_id": "hidden.anchor",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["message"] == "Event not found"
|
|
mock_embeddings.search_thumbnail.assert_not_called()
|
|
|
|
def test_get_good_event(self):
|
|
id = "123456.random"
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id)
|
|
event = client.get(f"/events/{id}").json()
|
|
|
|
assert event
|
|
assert event["id"] == id
|
|
assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"]
|
|
|
|
def test_get_bad_event(self):
|
|
id = "123456.random"
|
|
bad_id = "654321.other"
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id)
|
|
event_response = client.get(f"/events/{bad_id}")
|
|
assert event_response.status_code == 404
|
|
assert event_response.json() == "Event not found"
|
|
|
|
def test_delete_event(self):
|
|
id = "123456.random"
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id)
|
|
event = client.get(f"/events/{id}").json()
|
|
assert event
|
|
assert event["id"] == id
|
|
response = client.delete(f"/events/{id}", headers={"remote-role": "admin"})
|
|
assert response.status_code == 200
|
|
event_after_delete = client.get(f"/events/{id}")
|
|
assert event_after_delete.status_code == 404
|
|
|
|
def test_event_retention(self):
|
|
id = "123456.random"
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(id)
|
|
client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
|
event = client.get(f"/events/{id}").json()
|
|
assert event
|
|
assert event["id"] == id
|
|
assert event["retain_indefinitely"] is True
|
|
client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
|
event = client.get(f"/events/{id}").json()
|
|
assert event
|
|
assert event["id"] == id
|
|
assert event["retain_indefinitely"] is False
|
|
|
|
def test_event_time_filtering(self):
|
|
morning_id = "123456.random"
|
|
evening_id = "654321.random"
|
|
morning = 1656568800 # 06/30/2022 6 am (GMT)
|
|
evening = 1656612000 # 06/30/2022 6 pm (GMT)
|
|
|
|
with AuthTestClient(self.app) as client:
|
|
super().insert_mock_event(morning_id, morning)
|
|
super().insert_mock_event(evening_id, evening)
|
|
# both events come back
|
|
events = client.get("/events").json()
|
|
assert events
|
|
assert len(events) == 2
|
|
# morning event is excluded
|
|
events = client.get(
|
|
"/events",
|
|
params={"time_range": "07:00,24:00"},
|
|
).json()
|
|
assert events
|
|
assert len(events) == 1
|
|
# evening event is excluded
|
|
events = client.get(
|
|
"/events",
|
|
params={"time_range": "00:00,18:00"},
|
|
).json()
|
|
assert events
|
|
assert len(events) == 1
|
|
# an event in the first minute of the range is included
|
|
events = client.get(
|
|
"/events",
|
|
params={"time_range": "06:00,24:00"},
|
|
).json()
|
|
assert len(events) == 2
|
|
|
|
def test_set_delete_sub_label(self):
|
|
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
|
app = super().create_app(event_metadata_publisher=mock_event_updater)
|
|
id = "123456.random"
|
|
sub_label = "sub"
|
|
|
|
def update_event(payload: Any, topic: str):
|
|
event = Event.get(id=id)
|
|
event.sub_label = payload[1]
|
|
event.save()
|
|
|
|
mock_event_updater.publish.side_effect = update_event
|
|
|
|
with AuthTestClient(app) as client:
|
|
super().insert_mock_event(id)
|
|
new_sub_label_response = client.post(
|
|
f"/events/{id}/sub_label",
|
|
json={"subLabel": sub_label},
|
|
headers={"remote-role": "admin"},
|
|
)
|
|
assert new_sub_label_response.status_code == 200
|
|
event = client.get(f"/events/{id}").json()
|
|
assert event
|
|
assert event["id"] == id
|
|
assert event["sub_label"] == sub_label
|
|
empty_sub_label_response = client.post(
|
|
f"/events/{id}/sub_label",
|
|
json={"subLabel": ""},
|
|
headers={"remote-role": "admin"},
|
|
)
|
|
assert empty_sub_label_response.status_code == 200
|
|
event = client.get(f"/events/{id}").json()
|
|
assert event
|
|
assert event["id"] == id
|
|
assert event["sub_label"] == None
|
|
|
|
def test_sub_label_list(self):
|
|
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
|
app = super().create_app(event_metadata_publisher=mock_event_updater)
|
|
app.event_metadata_publisher = mock_event_updater
|
|
id = "123456.random"
|
|
sub_label = "sub"
|
|
|
|
def update_event(payload: Any, _: str):
|
|
event = Event.get(id=id)
|
|
event.sub_label = payload[1]
|
|
event.save()
|
|
|
|
mock_event_updater.publish.side_effect = update_event
|
|
|
|
with AuthTestClient(app) as client:
|
|
super().insert_mock_event(id)
|
|
client.post(
|
|
f"/events/{id}/sub_label",
|
|
json={"subLabel": sub_label},
|
|
headers={"remote-role": "admin"},
|
|
)
|
|
sub_labels = client.get("/sub_labels").json()
|
|
assert sub_labels
|
|
assert sub_labels == [sub_label]
|
|
|
|
####################################################################################################################
|
|
################################### GET /metrics Endpoint #########################################################
|
|
####################################################################################################################
|
|
def test_get_metrics(self):
|
|
"""ensure correct prometheus metrics api response"""
|
|
with AuthTestClient(self.app) as client:
|
|
ts_start = datetime.now().timestamp()
|
|
ts_end = ts_start + 30
|
|
_insert_mock_event(
|
|
id="abcde.random", start=ts_start, end=ts_end, retain=True
|
|
)
|
|
_insert_mock_event(
|
|
id="01234.random", start=ts_start, end=ts_end, retain=True
|
|
)
|
|
_insert_mock_event(
|
|
id="56789.random", start=ts_start, end=ts_end, retain=True
|
|
)
|
|
_insert_mock_event(
|
|
id="101112.random",
|
|
label="outside",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="131415.random",
|
|
label="outside",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="161718.random",
|
|
camera="porch",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="192021.random",
|
|
camera="porch",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="222324.random",
|
|
camera="porch",
|
|
label="inside",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="252627.random",
|
|
camera="porch",
|
|
label="inside",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="282930.random",
|
|
label="inside",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
_insert_mock_event(
|
|
id="313233.random",
|
|
label="inside",
|
|
start=ts_start,
|
|
end=ts_end,
|
|
retain=True,
|
|
)
|
|
|
|
stats_emitter = Mock(spec=StatsEmitter)
|
|
stats_emitter.get_latest_stats.return_value = self.test_stats
|
|
self.app.stats_emitter = stats_emitter
|
|
event = client.get("/metrics")
|
|
|
|
assert "# TYPE frigate_detection_total_fps gauge" in event.text
|
|
assert "frigate_detection_total_fps 13.7" in event.text
|
|
assert (
|
|
"# HELP frigate_camera_events Current number of events stored in the database per camera and label"
|
|
in event.text
|
|
)
|
|
assert "# TYPE frigate_camera_events gauge" in event.text
|
|
assert (
|
|
'frigate_camera_events{camera="front_door",label="Mock"} 3.0' in event.text
|
|
)
|
|
assert (
|
|
'frigate_camera_events{camera="front_door",label="inside"} 2.0'
|
|
in event.text
|
|
)
|
|
assert (
|
|
'frigate_camera_events{camera="front_door",label="outside"} 2.0'
|
|
in event.text
|
|
)
|
|
assert 'frigate_camera_events{camera="porch",label="Mock"} 2.0' in event.text
|
|
assert 'frigate_camera_events{camera="porch",label="inside"} 2.0' in event.text
|