Miscellaneous fixes (#24528)

* revert disable save buttons when there are no changes in config editor

* pass migrated config to each step in the config migration chain

* use 150 as the default max when typing a min speed in the search filter

* fix preview export outpoint to be relative to the start of the preview file

* don't crash on invalid trusted proxy entries or non-ip forwarded hops

* translate the camera count badge in the roles table

* run every batch through the lpr recognition model

* log rejected motion and notification mqtt payloads

* log invalid addresses in x-forwarded-for

* add test

* remove unused autotracked_object_region

* remove unreachable autotracker setup call in camera maintenance

* apply onvif retry limit when initialization fails

* fix reindex progress overcounting when there are fewer events than a batch

* match the register device button aria label to its text

* reset the add profile form on cancel

* ignore case when filtering search suggestions

* tweak comment
This commit is contained in:
Josh Hawkins
2026-10-01 07:19:31 -06:00
committed by GitHub
parent 16eac66463
commit 24a236a152
16 changed files with 83 additions and 35 deletions
+12 -7
View File
@@ -324,11 +324,17 @@ def get_remote_addr(request: Request):
network = ipaddress.ip_network(proxy)
except ValueError:
logger.warning(f"Unable to parse trusted network: {proxy}")
continue
trusted_proxies.append(network)
# return the first remote address that is not trusted
for addr in route:
ip = ipaddress.ip_address(addr.strip())
try:
ip = ipaddress.ip_address(addr.strip())
except ValueError:
logger.debug("Invalid address in X-Forwarded-For header")
return direct_addr or "127.0.0.1"
logger.debug(f"Checking {ip} (v{ip.version})")
trusted = False
for trusted_proxy in trusted_proxies:
@@ -473,12 +479,11 @@ def create_encoded_jwt(user, role, expiration, secret):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, 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).
# Starlette sets SameSite=Lax by default. The cookie is still sent to
# same-site iframes (e.g. Home Assistant on the same host or domain), but
# not to cross-site ones. CSRF is also 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 -3
View File
@@ -782,7 +782,9 @@ class Dispatcher:
try:
payload = int(payload)
except ValueError:
f"Received unsupported value for motion contour area: {payload}"
logger.warning(
f"Received unsupported value for motion contour area: {payload}"
)
return
motion_settings = self.config.cameras[camera_name].motion
@@ -799,7 +801,9 @@ class Dispatcher:
try:
payload = int(payload)
except ValueError:
f"Received unsupported value for motion threshold: {payload}"
logger.warning(
f"Received unsupported value for motion threshold: {payload}"
)
return
motion_settings = self.config.cameras[camera_name].motion
@@ -814,7 +818,9 @@ class Dispatcher:
def _on_global_notification_command(self, payload: str) -> None:
"""Callback for global notification topic."""
if payload != "ON" and payload != "OFF":
f"Received unsupported value for all notification: {payload}"
logger.warning(
f"Received unsupported value for all notification: {payload}"
)
return
notification_settings = self.config.notifications
@@ -176,6 +176,7 @@ class LicensePlateProcessingMixin:
"""
input_shape = [3, 48, 320]
num_images = len(images)
outputs: list[np.ndarray] = []
for index in range(0, num_images, self.batch_size):
input_h, input_w = input_shape[1], input_shape[2]
@@ -195,11 +196,11 @@ class LicensePlateProcessingMixin:
norm_image = norm_image[np.newaxis, :]
norm_images.append(norm_image)
try:
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
except Exception as e:
logger.warning(f"Error running LPR recognition model: {e}")
return [], []
try:
outputs.extend(self.model_runner.recognition_model(norm_images)) # type: ignore[arg-type]
except Exception as e:
logger.warning(f"Error running LPR recognition model: {e}")
return [], []
return self.ctc_decoder(outputs)
+3
View File
@@ -357,6 +357,9 @@ class Embeddings:
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
# a single batch sends no progress, so the first message shows it nearly done
totals["processed_objects"] = 0
events = (
Event.select()
.order_by(Event.start_time.desc())
-5
View File
@@ -1397,9 +1397,6 @@ class PtzAutoTracker:
def is_autotracking(self, camera: str):
return self.tracked_object[camera] is not None
def autotracked_object_region(self, camera: str):
return self.tracked_object[camera]["region"]
def autotrack_object(self, camera: str, obj: TrackedObject):
if camera not in self.config.cameras:
return
@@ -1538,8 +1535,6 @@ class PtzAutoTracker:
# returns camera to preset after timeout when tracking is over
autotracker_config = self.config.cameras[camera].onvif.autotracking
if not self.autotracker_init[camera]:
self._autotracker_setup(self.config.cameras[camera], camera)
# regularly update camera status
if not self.ptz_metrics[camera].motor_stopped.is_set():
await self.onvif.get_camera_status(camera)
+4
View File
@@ -966,6 +966,10 @@ class OnvifController:
}
else:
logger.warning(f"ONVIF initialization failed for {camera_name}")
self.failed_cams[camera_name] = {
"retry_attempts": attempts + 1,
"last_attempt": time.time(),
}
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
+1 -1
View File
@@ -1375,7 +1375,7 @@ class RecordingExporter(threading.Thread):
if preview.end_time > self.end_time:
playlist_lines.append(
f"outpoint {int(preview.end_time - self.end_time)}"
f"outpoint {int(self.end_time - preview.start_time)}"
)
ffmpeg_input = (
+25
View File
@@ -257,6 +257,31 @@ class TestMigrateConfigFile(unittest.TestCase):
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU"])
self.assertNotIn("detectors", migrated)
def test_top_level_changes_survive_later_steps(self):
# 0.16 adds detect and 0.17 splits genai, both at the top level
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"genai:\n"
" provider: ollama\n"
" model: llava\n"
" prompt: describe it\n"
"cameras: {}\n"
"version: 0.15-1\n"
)
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
self.assertTrue(migrated["detect"]["enabled"])
self.assertEqual(migrated["objects"]["genai"], {"prompt": "describe it"})
self.assertEqual(
migrated["genai"]["default"],
{
"provider": "ollama",
"model": "llava",
"roles": ["descriptions", "chat"],
},
)
def test_a_migrated_config_is_left_alone(self):
migrated = self._migrate(
"mqtt:\n"
+6 -6
View File
@@ -191,7 +191,7 @@ def migrate_frigate_config(config_file: str):
if previous_version < "0.14":
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
new_config = migrate_014(config)
new_config = migrate_014(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.14"
@@ -209,35 +209,35 @@ def migrate_frigate_config(config_file: str):
if previous_version < "0.15-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
new_config = migrate_015_0(config)
new_config = migrate_015_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.15-0"
if previous_version < "0.15-1":
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
new_config = migrate_015_1(config)
new_config = migrate_015_1(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.15-1"
if previous_version < "0.16-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
new_config = migrate_016_0(config)
new_config = migrate_016_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.16-0"
if previous_version < "0.17-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
new_config = migrate_017_0(config)
new_config = migrate_017_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.17-0"
if previous_version < "0.18-0":
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
new_config = migrate_018_0(config)
new_config = migrate_018_0(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
previous_version = "0.18-0"
+3 -1
View File
@@ -1162,7 +1162,9 @@
"actions": "Actions",
"noRoles": "No custom roles found.",
"editCameras": "Edit Cameras",
"deleteRole": "Delete Role"
"deleteRole": "Delete Role",
"cameraCount_one": "{{count}} camera",
"cameraCount_other": "{{count}} cameras"
},
"toast": {
"success": {
@@ -627,7 +627,11 @@ export default function NotificationsSettingsExtras({
<SettingsGroupCard title={t("notification.deviceSpecific")}>
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
<Button
aria-label={t("notification.registerDevice")}
aria-label={
registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")
}
className="w-full md:w-auto"
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
+1 -1
View File
@@ -218,7 +218,7 @@ export default function InputWithTags({
}
return current_suggestions.filter((suggestion) =>
suggestion.toLowerCase().startsWith(currentWord),
suggestion.toLowerCase().startsWith(currentWord.toLowerCase()),
);
},
[inputValue, suggestions, currentFilterType],
@@ -631,7 +631,7 @@ export function SpeedFilterContent({
const value = e.target.value;
if (value) {
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
setSpeedRange(parseInt(value), maxSpeed ?? 150);
}
}}
/>
-2
View File
@@ -287,7 +287,6 @@ function ConfigEditor() {
</Button>
<Button
size="sm"
disabled={!hasChanges}
className="flex items-center gap-2"
aria-label={t("saveAndRestart")}
onClick={handleSaveAndRestart}
@@ -300,7 +299,6 @@ function ConfigEditor() {
</Button>
<Button
size="sm"
disabled={!hasChanges}
className="flex items-center gap-2"
aria-label={t("saveOnly")}
onClick={() => onHandleSaveConfig("saveonly")}
@@ -657,7 +657,9 @@ export default function AuthenticationView({
</Badge>
) : roleData.cameras.length > 5 ? (
<Badge variant="outline" className="text-xs">
{roleData.cameras.length} cameras
{t("roles.table.cameraCount", {
count: roleData.cameras.length,
})}
</Badge>
) : (
<div className="flex flex-wrap gap-1">
+4 -1
View File
@@ -661,7 +661,10 @@ export default function ProfilesView({
<DialogFooter>
<Button
type="button"
onClick={() => setAddDialogOpen(false)}
onClick={() => {
setAddDialogOpen(false);
addForm.reset();
}}
disabled={addingProfile}
>
{t("button.cancel", { ns: "common" })}