From 96ce75cf98233cd05cd637b9c20021fa637d9c63 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:11:27 -0600 Subject: [PATCH] Miscellaneous fixes (#24846) * 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. --- .github/dependabot.yml | 3 + docs/static/frigate-api.yaml | 2 + frigate/api/auth.py | 4 ++ .../defs/query/recordings_query_parameters.py | 4 +- frigate/api/event.py | 8 +-- frigate/api/export.py | 2 +- frigate/api/media.py | 14 ++-- frigate/api/review.py | 3 +- frigate/record/export.py | 7 +- frigate/test/http_api/test_http_event.py | 10 ++- frigate/test/http_api/test_http_export.py | 24 +++++++ frigate/test/http_api/test_http_media.py | 4 ++ web/public/locales/en/views/settings.json | 7 -- .../NotificationsSettingsExtras.tsx | 11 ++- .../theme/fields/KnownPlatesField.tsx | 3 + .../components/overlay/CreateUserDialog.tsx | 37 ++-------- web/src/components/overlay/ExportDialog.tsx | 32 +-------- .../components/overlay/SetPasswordDialog.tsx | 67 +++++++------------ web/src/components/timeline/DetailStream.tsx | 13 +++- web/src/utils/passwordUtil.ts | 38 ----------- web/src/utils/recordingReviewUrl.ts | 3 +- web/src/views/live/DraggableGridLayout.tsx | 24 +++---- web/src/views/live/LiveCameraView.tsx | 18 ++--- web/src/views/live/LiveDashboardView.tsx | 22 +++--- .../views/settings/MediaSyncSettingsView.tsx | 22 +++--- web/src/views/settings/TriggerView.tsx | 13 ++-- 26 files changed, 177 insertions(+), 218 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 165bcb8d8f..bbb30a6911 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -55,6 +55,9 @@ updates: # Needs Python 3.12 and numpy 2 - dependency-name: "librosa" versions: [">=1.0"] + # pandas 3.1 requires numpy 2 + - dependency-name: "pandas" + versions: [">=3.1"] # Needs Python 3.12 - dependency-name: "scipy" versions: [">=1.18"] diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 4d61778cf8..ca73bf24f3 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -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: diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 49fd641c33..c712ec925e 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -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) diff --git a/frigate/api/defs/query/recordings_query_parameters.py b/frigate/api/defs/query/recordings_query_parameters.py index cd7b4221c2..d10db010fb 100644 --- a/frigate/api/defs/query/recordings_query_parameters.py +++ b/frigate/api/defs/query/recordings_query_parameters.py @@ -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): diff --git a/frigate/api/event.py b/frigate/api/event.py index 3137b124e6..7640e979c9 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -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 diff --git a/frigate/api/export.py b/frigate/api/export.py index 5873a565c8..4d1017fc88 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -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, diff --git a/frigate/api/media.py b/frigate/api/media.py index 34448bddc0..2af79cb816 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -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) @@ -1521,6 +1521,7 @@ async def preview_gif( | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) ) .where(Previews.camera == camera_name) + .order_by(Previews.start_time.asc()) .limit(1) .get() ) @@ -1530,7 +1531,8 @@ async def preview_gif( status_code=404, ) - diff = start_ts - preview.start_time + clip_start = max(start_ts, preview.start_time) + diff = clip_start - preview.start_time minutes = int(diff / 60) seconds = int(diff % 60) config: FrigateConfig = request.app.frigate_config @@ -1542,7 +1544,7 @@ async def preview_gif( "-ss", f"00:{minutes}:{seconds}", "-t", - f"{end_ts - start_ts}", + f"{end_ts - clip_start}", "-i", preview.path, "-r", @@ -1709,6 +1711,7 @@ async def preview_mp4( | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) ) .where(Previews.camera == camera_name) + .order_by(Previews.start_time.asc()) .limit(1) .get() ) @@ -1721,7 +1724,8 @@ async def preview_mp4( status_code=404, ) - diff = start_ts - preview.start_time + clip_start = max(start_ts, preview.start_time) + diff = clip_start - preview.start_time minutes = int(diff / 60) seconds = int(diff % 60) config: FrigateConfig = request.app.frigate_config @@ -1734,7 +1738,7 @@ async def preview_mp4( "-ss", f"00:{minutes}:{seconds}", "-t", - f"{end_ts - start_ts}", + f"{end_ts - clip_start}", "-i", preview.path, "-r", diff --git a/frigate/api/review.py b/frigate/api/review.py index 14f9bb169c..0a0c758436 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -163,8 +163,7 @@ async def review( # Apply ordering and limit review_query = ( - review_query.order_by(ReviewSegment.severity.asc()) - .order_by(ReviewSegment.start_time.desc()) + review_query.order_by(ReviewSegment.start_time.desc()) .limit(limit) .dicts() .iterator() diff --git a/frigate/record/export.py b/frigate/record/export.py index 5efa4914fa..9bfe64e4ac 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -1470,6 +1470,9 @@ class RecordingExporter(threading.Thread): try: self._run_export(video_path, thumb_path) + except Exception: + self._discard_failed_export(video_path, thumb_path) + raise finally: # staged runs hold a full copy of the export, so they must not # survive a failure the way the small chapter file could @@ -1479,7 +1482,8 @@ class RecordingExporter(threading.Thread): """Drop the partial output and the row that promised it.""" Path(video_path).unlink(missing_ok=True) Export.delete().where(Export.id == self.export_id).execute() - Path(thumb_path).unlink(missing_ok=True) + if thumb_path: + Path(thumb_path).unlink(missing_ok=True) def _run_export(self, video_path: str, thumb_path: str) -> None: try: @@ -1498,6 +1502,7 @@ class RecordingExporter(threading.Thread): else: ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path) except DoesNotExist: + self._discard_failed_export(video_path, thumb_path) return # When neither custom ffmpeg arg is set the default path uses diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index 8de5eb9dbc..f6b128b5fa 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -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) diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py index 13f9422610..ae46ce52be 100644 --- a/frigate/test/http_api/test_http_export.py +++ b/frigate/test/http_api/test_http_export.py @@ -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") diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 047d7de01a..ba7c7babae 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -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. diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index c6e483f4ee..a759f394c5 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1086,13 +1086,6 @@ "title": "Confirm Password", "placeholder": "Confirm Password" }, - "strength": { - "title": "Password strength: ", - "weak": "Weak", - "medium": "Medium", - "strong": "Strong", - "veryStrong": "Very Strong" - }, "requirements": { "title": "Password requirements:", "length": "At least 12 characters" diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index 3db2a8203b..6f38611742 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -415,10 +415,19 @@ export default function NotificationsSettingsExtras({ pushSubscription.unsubscribe(); workerRegistration.unregister(); setRegistration(null); + removeMessage("notification_settings", "registration"); }); + }) + .catch(() => { + toast.error(t("notification.toast.error.registerFailed"), { + position: "top-center", + }); + workerRegistration.unregister(); + setRegistration(null); + removeMessage("notification_settings", "registration"); }); }, - [addMessage, publicKey, t], + [addMessage, removeMessage, publicKey, t], ); useEffect(() => { diff --git a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx index 7b5d81cc8a..b0d753b462 100644 --- a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx +++ b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx @@ -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]); diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 2953a29cb9..d33b2117aa 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -37,12 +37,7 @@ import { useTranslation } from "react-i18next"; import { isDesktop, isMobile } from "react-device-detect"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; -import { - calculatePasswordStrength, - getPasswordRequirements, - getPasswordStrengthLabel, - getPasswordStrengthColor, -} from "@/utils/passwordUtil"; +import { getPasswordRequirements } from "@/utils/passwordUtil"; import { MobilePage, MobilePageContent, @@ -123,12 +118,6 @@ export default function CreateUserDialog({ const passwordsMatch = password === confirmPassword; const showMatchIndicator = password && confirmPassword; - // Password strength calculation - const passwordStrength = useMemo( - () => calculatePasswordStrength(password), - [password], - ); - const requirements = useMemo( () => getPasswordRequirements(password), [password], @@ -224,6 +213,12 @@ export default function CreateUserDialog({ type={showPasswordVisible ? "text" : "password"} className="h-10 pr-10" {...field} + onChange={(e) => { + field.onChange(e); + if (form.getValues("confirmPassword")) { + form.trigger("confirmPassword"); + } + }} />