Compare commits

..
2 Commits
Author SHA1 Message Date
Josh Hawkins f68f360386 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
2026-10-31 22:45:47 -05:00
Josh Hawkins 5764eaddaa 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
2026-10-31 21:11:15 -05:00
16 changed files with 82 additions and 73 deletions
+2
View File
@@ -6371,6 +6371,7 @@ paths:
required: false
schema:
type: integer
minimum: 0
description: Padding to apply to the vod.
default: 0
title: Padding
@@ -7576,6 +7577,7 @@ paths:
required: false
schema:
type: integer
minimum: 1
default: 30
title: Scale
responses:
+4
View File
@@ -966,6 +966,10 @@ def login(request: Request, body: AppPostLoginBody):
try:
db_user: User = User.get_by_id(user)
except DoesNotExist:
# Hash anyway so response timing doesn't reveal which usernames exist
hash_password(
password, iterations=request.app.frigate_config.auth.hash_iterations
)
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
failed_logins.record(user, time.time())
return JSONResponse(content={"message": "Login failed"}, status_code=401)
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
@@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all"
before: float | SkipJsonSchema[None] = None
after: float | SkipJsonSchema[None] = None
scale: int = 30
scale: int = Field(default=30, ge=1)
class RecordingsDeleteQueryParams(BaseModel):
+4 -4
View File
@@ -291,12 +291,12 @@ def events(
clauses.append(
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
)
)
# all other cases should be and operator
else:
clauses.append(start_hour_fun > time_after)
clauses.append(start_hour_fun >= time_after)
clauses.append(start_hour_fun < time_before)
if has_clip is not None:
@@ -749,12 +749,12 @@ def events_search(
event_filters.append(
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
)
)
# all other cases should be and operator
else:
event_filters.append(start_hour_fun > time_after)
event_filters.append(start_hour_fun >= time_after)
event_filters.append(start_hour_fun < time_before)
# Perform semantic search
+1 -1
View File
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
files_in_use = _get_files_in_use()
for export in exports:
if export.video_path.split("/")[-1] in files_in_use:
if export.in_progress or export.video_path.split("/")[-1] in files_in_use:
return JSONResponse(
content={
"success": False,
+3 -3
View File
@@ -862,7 +862,7 @@ async def vod_hour(
async def vod_event(
request: Request,
event_id: str,
padding: int = Query(0, description="Padding to apply to the vod."),
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
):
try:
event: Event = Event.get(Event.id == event_id)
@@ -1530,7 +1530,7 @@ async def preview_gif(
status_code=404,
)
diff = start_ts - preview.start_time
diff = max(start_ts - preview.start_time, 0)
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1721,7 +1721,7 @@ async def preview_mp4(
status_code=404,
)
diff = start_ts - preview.start_time
diff = max(start_ts - preview.start_time, 0)
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
+8 -2
View File
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
def test_event_time_filtering(self):
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
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)
@@ -417,6 +417,12 @@ class TestHttpApp(BaseTestHttp):
).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)
+24
View File
@@ -427,6 +427,30 @@ class TestHttpExport(BaseTestHttp):
assert response.status_code == 400
assert Export.get(Export.id == "front_door_running").video_path == video
def test_delete_export_rejected_while_in_progress(self):
with tempfile.TemporaryDirectory() as tmpdir:
video = os.path.join(tmpdir, "front_door_abc123.mp4")
with open(video, "wb") as handle:
handle.write(b"video")
Export.create(
id="front_door_running",
camera="front_door",
name="front door export",
date=100,
video_path=video,
thumb_path=os.path.join(tmpdir, "t.webp"),
in_progress=True,
)
with AuthTestClient(self.app) as client:
response = client.post(
"/exports/delete", json={"ids": ["front_door_running"]}
)
assert response.status_code == 400
assert os.path.exists(video)
def test_rename_export_missing_file_leaves_the_row_alone(self):
with tempfile.TemporaryDirectory() as tmpdir:
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
+4
View File
@@ -584,6 +584,10 @@ class TestHttpMedia(BaseTestHttp):
{"start_time": 1160, "end_time": 1300},
]
# a non-positive scale would never advance the gap scan
response = client.get("/recordings/unavailable", params={"scale": 0})
assert response.status_code == 422
def test_recordings_summary_day_attribution_by_start_time(self):
"""
A recording spanning midnight marks only its start day.
@@ -416,6 +416,13 @@ export default function NotificationsSettingsExtras({
workerRegistration.unregister();
setRegistration(null);
});
})
.catch(() => {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
workerRegistration.unregister();
setRegistration(null);
});
},
[addMessage, publicKey, t],
@@ -307,6 +307,9 @@ export function KnownPlatesField(props: FieldProps) {
);
const handleAddEntry = useCallback(() => {
if ("" in data) {
return;
}
const next = { ...data, "": [""] };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
+2 -30
View File
@@ -690,36 +690,8 @@ export function ExportContent({
(option: ExportOption) => {
setSelectedOption(option);
const now = new Date(latestTime * 1000);
let start: number;
switch (option) {
case "1":
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
break;
case "4":
now.setHours(now.getHours() - 4);
start = now.getTime() / 1000;
break;
case "8":
now.setHours(now.getHours() - 8);
start = now.getTime() / 1000;
break;
case "12":
now.setHours(now.getHours() - 12);
start = now.getTime() / 1000;
break;
case "24":
now.setHours(now.getHours() - 24);
start = now.getTime() / 1000;
break;
case "custom":
start = latestTime - 3600;
break;
default:
start = latestTime - 3600;
}
const hours = Number(option);
const start = latestTime - (Number.isNaN(hours) ? 1 : hours) * 3600;
setRange({
before: latestTime,
+1 -2
View File
@@ -45,8 +45,7 @@ export function createRecordingReviewUrl(
pathname: string,
state: RecordingReviewLinkState,
): string {
const url = new URL(baseUrl);
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const url = new URL(pathname.replace(/^\//, ""), baseUrl);
url.searchParams.set(
RECORDING_REVIEW_LINK_PARAM,
`${state.camera}_${Math.floor(state.timestamp)}`,
+7 -13
View File
@@ -575,25 +575,19 @@ export default function DraggableGridLayout({
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
},
);
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [allGroupsStreamingSettings]);
}, [currentGroupStreamingSettings, cameraGroup]);
const toggleAudio = (cameraName: string) => {
setAudioStates((prev) => ({
+7 -13
View File
@@ -326,25 +326,19 @@ export default function LiveDashboardView({
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
},
);
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [allGroupsStreamingSettings]);
}, [currentGroupStreamingSettings, cameraGroup]);
const toggleAudio = (cameraName: string): void => {
setAudioStates((prev) => ({
+3 -3
View File
@@ -148,13 +148,13 @@ export default function TriggerView({
return current;
});
const timeout = setTimeout(() => {
// not cleared on the next payload, or a second trigger firing within
// 3s would leave this one highlighted
setTimeout(() => {
setTriggeredTrigger((prev) =>
(prev || []).filter((name) => name !== triggers_status_ws.name),
);
}, 3000);
return () => clearTimeout(timeout);
}, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => {