Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 93701de7fa Update pandas requirement from ==3.0.* to ==3.1.* in /docker/main
Updates the requirements on [pandas](https://github.com/pandas-dev/pandas) to permit the latest version.
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v3.0.0.dev0...v3.1.0)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 3.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-01 13:43:49 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
689a5b59ac Update sherpa-onnx requirement from ==1.13.* to ==2.0.* in /docker/main (#24842)
---
updated-dependencies:
- dependency-name: sherpa-onnx
  dependency-version: 2.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:48 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
69061d17f1 Update transformers requirement in /docker/main (#24841)
Updates the requirements on [transformers](https://github.com/huggingface/transformers) to permit the latest version.
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.20.0...v5.21.1)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.21.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:40 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
efb890e6e0 Bump the pip-main-minor-patch group in /docker/main with 3 updates (#24840)
Bumps the pip-main-minor-patch group in /docker/main with 3 updates: [ruff](https://github.com/astral-sh/ruff), [mypy](https://github.com/python/mypy) and [tzdata](https://github.com/python/tzdata).


Updates `ruff` from 0.17.1 to 0.17.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.17.1...0.17.3)

Updates `mypy` from 2.4.0 to 2.4.1
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v2.4.0...v2.4.1)

Updates `tzdata` from 2026.5 to 2026.6
- [Release notes](https://github.com/python/tzdata/releases)
- [Changelog](https://github.com/python/tzdata/blob/master/NEWS.md)
- [Commits](https://github.com/python/tzdata/compare/2026.5...2026.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.17.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
- dependency-name: mypy
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
- dependency-name: tzdata
  dependency-version: '2026.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-main-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:30 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ea7b3e2336 Bump the actions group across 2 directories with 1 update (#24845)
Bumps the actions group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).
Bumps the actions group with 1 update in the /.github/actions/setup directory: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:08 -06:00
31 changed files with 227 additions and 186 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ runs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
-3
View File
@@ -55,9 +55,6 @@ 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"]
+1 -1
View File
@@ -590,7 +590,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.17.1
ruff == 0.17.3
# types
types-peewee == 4.0.*
+5 -5
View File
@@ -16,16 +16,16 @@ python-multipart == 0.0.32
tensorflow == 2.19.* ; platform_machine == 'aarch64'
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
# General
mypy == 2.4.0
mypy == 2.4.1
onvif-zeep-async == 4.3.*
paho-mqtt == 2.1.*
pandas == 3.0.*
pandas == 3.1.*
peewee == 4.5.*
peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.14.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
tzdata == 2026.5
tzdata == 2026.6
pyzmq == 27.2.*
ruamel.yaml == 0.19.*
tzlocal == 5.4.5
@@ -45,7 +45,7 @@ scipy == 1.17.*
openvino == 2026.4.*
onnxruntime == 1.31.*
# Embeddings
transformers == 5.20.*
transformers == 5.21.*
# Generative AI
google-genai == 2.32.*
ollama == 0.6.*
@@ -68,7 +68,7 @@ prometheus-client == 0.26.*
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
# audio transcription
sherpa-onnx==1.13.*
sherpa-onnx==2.0.*
faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.14.*
-2
View File
@@ -6371,7 +6371,6 @@ paths:
required: false
schema:
type: integer
minimum: 0
description: Padding to apply to the vod.
default: 0
title: Padding
@@ -7577,7 +7576,6 @@ paths:
required: false
schema:
type: integer
minimum: 1
default: 30
title: Scale
responses:
-4
View File
@@ -966,10 +966,6 @@ 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, Field
from pydantic import BaseModel
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 = Field(default=30, ge=1)
scale: int = 30
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.in_progress or export.video_path.split("/")[-1] in files_in_use:
if export.video_path.split("/")[-1] in files_in_use:
return JSONResponse(
content={
"success": False,
+5 -9
View File
@@ -862,7 +862,7 @@ async def vod_hour(
async def vod_event(
request: Request,
event_id: str,
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
padding: int = Query(0, description="Padding to apply to the vod."),
):
try:
event: Event = Event.get(Event.id == event_id)
@@ -1521,7 +1521,6 @@ 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()
)
@@ -1531,8 +1530,7 @@ async def preview_gif(
status_code=404,
)
clip_start = max(start_ts, preview.start_time)
diff = clip_start - preview.start_time
diff = start_ts - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1544,7 +1542,7 @@ async def preview_gif(
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - clip_start}",
f"{end_ts - start_ts}",
"-i",
preview.path,
"-r",
@@ -1711,7 +1709,6 @@ 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()
)
@@ -1724,8 +1721,7 @@ async def preview_mp4(
status_code=404,
)
clip_start = max(start_ts, preview.start_time)
diff = clip_start - preview.start_time
diff = start_ts - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1738,7 +1734,7 @@ async def preview_mp4(
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - clip_start}",
f"{end_ts - start_ts}",
"-i",
preview.path,
"-r",
+2 -1
View File
@@ -163,7 +163,8 @@ async def review(
# Apply ordering and limit
review_query = (
review_query.order_by(ReviewSegment.start_time.desc())
review_query.order_by(ReviewSegment.severity.asc())
.order_by(ReviewSegment.start_time.desc())
.limit(limit)
.dicts()
.iterator()
+1 -6
View File
@@ -1470,9 +1470,6 @@ 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
@@ -1482,8 +1479,7 @@ 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()
if thumb_path:
Path(thumb_path).unlink(missing_ok=True)
Path(thumb_path).unlink(missing_ok=True)
def _run_export(self, video_path: str, thumb_path: str) -> None:
try:
@@ -1502,7 +1498,6 @@ 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
+2 -8
View File
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
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)
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
with AuthTestClient(self.app) as client:
super().insert_mock_event(morning_id, morning)
@@ -417,12 +417,6 @@ 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,30 +427,6 @@ 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,10 +584,6 @@ 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.
@@ -1086,6 +1086,13 @@
"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"
@@ -415,19 +415,10 @@ 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, removeMessage, publicKey, t],
[addMessage, publicKey, t],
);
useEffect(() => {
@@ -307,9 +307,6 @@ export function KnownPlatesField(props: FieldProps) {
);
const handleAddEntry = useCallback(() => {
if ("" in data) {
return;
}
const next = { ...data, "": [""] };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
@@ -37,7 +37,12 @@ import { useTranslation } from "react-i18next";
import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { getPasswordRequirements } from "@/utils/passwordUtil";
import {
calculatePasswordStrength,
getPasswordRequirements,
getPasswordStrengthLabel,
getPasswordStrengthColor,
} from "@/utils/passwordUtil";
import {
MobilePage,
MobilePageContent,
@@ -118,6 +123,12 @@ 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],
@@ -213,12 +224,6 @@ 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");
}
}}
/>
<Button
type="button"
@@ -250,6 +255,21 @@ export default function CreateUserDialog({
{password && (
<div className="mt-2 space-y-2">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">
{getPasswordStrengthLabel(password, t)}
</span>
</p>
<div className="space-y-1 rounded-md bg-muted/50 p-2">
<p className="text-xs font-medium text-muted-foreground">
{t("users.dialog.form.password.requirements.title")}
@@ -277,6 +297,8 @@ export default function CreateUserDialog({
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
@@ -344,6 +366,7 @@ export default function CreateUserDialog({
)}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
+30 -2
View File
@@ -690,8 +690,36 @@ export function ExportContent({
(option: ExportOption) => {
setSelectedOption(option);
const hours = Number(option);
const start = latestTime - (Number.isNaN(hours) ? 1 : hours) * 3600;
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;
}
setRange({
before: latestTime,
@@ -28,7 +28,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { getPasswordRequirements } from "@/utils/passwordUtil";
import {
calculatePasswordStrength,
getPasswordRequirements,
getPasswordStrengthLabel,
getPasswordStrengthColor,
} from "@/utils/passwordUtil";
type SetPasswordProps = {
show: boolean;
@@ -119,6 +124,12 @@ export default function SetPasswordDialog({
const password = form.watch("password");
const confirmPassword = form.watch("confirmPassword");
// Password strength calculation
const passwordStrength = useMemo(
() => calculatePasswordStrength(password),
[password],
);
const requirements = useMemo(
() => getPasswordRequirements(password),
[password],
@@ -292,12 +303,6 @@ export default function SetPasswordDialog({
)}
className="h-10 pr-10"
autoFocus
onChange={(e) => {
field.onChange(e);
if (form.getValues("confirmPassword")) {
form.trigger("confirmPassword");
}
}}
/>
<Button
type="button"
@@ -329,6 +334,21 @@ export default function SetPasswordDialog({
{password && (
<div className="mt-2 space-y-2">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">
{getPasswordStrengthLabel(password, t)}
</span>
</p>
<div className="space-y-1 rounded-md bg-muted/50 p-2">
<p className="text-xs font-medium text-muted-foreground">
{t("users.dialog.form.password.requirements.title")}
@@ -356,6 +376,8 @@ export default function SetPasswordDialog({
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
@@ -406,25 +428,18 @@ export default function SetPasswordDialog({
</div>
</FormControl>
{password && confirmPassword && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
{password === confirmPassword ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
{t("users.dialog.form.password.notMatch")}
</span>
</>
)}
</div>
)}
{password &&
confirmPassword &&
password === confirmPassword && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
+1 -12
View File
@@ -147,18 +147,7 @@ export default function DetailStream({
// away instead of waiting for a future effectiveTime change.
useEffect(() => {
if (!reviewItems || reviewItems.length === 0) return;
// an id left over from another camera's items counts as unset
if (
activeReviewId &&
reviewItems.some(
(r) =>
`review-${r.id ?? r.start_time ?? Math.floor(r.start_time ?? 0)}` ===
activeReviewId,
)
) {
return;
}
if (activeReviewId) return;
let target: ReviewSegment | undefined;
let closest: { r: ReviewSegment; diff: number } | undefined;
+38
View File
@@ -1,3 +1,41 @@
import { TFunction } from "i18next";
export const calculatePasswordStrength = (password: string): number => {
if (!password) return 0;
let strength = 0;
if (password.length >= 8) strength += 1;
if (/\d/.test(password)) strength += 1;
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
if (/[A-Z]/.test(password)) strength += 1;
return strength;
};
export const getPasswordRequirements = (password: string) => ({
length: password?.length >= 12,
});
export const getPasswordStrengthLabel = (
password: string,
t: TFunction,
): string => {
const strength = calculatePasswordStrength(password);
if (!password) return "";
if (strength < 1)
return t("users.dialog.form.password.strength.weak", {
ns: "views/settings",
});
return t("users.dialog.form.password.strength.veryStrong", {
ns: "views/settings",
});
};
export const getPasswordStrengthColor = (password: string): string => {
const strength = calculatePasswordStrength(password);
if (!password) return "bg-gray-200";
if (strength === 0) return "bg-red-500";
return "bg-green-500";
};
+2 -1
View File
@@ -45,7 +45,8 @@ export function createRecordingReviewUrl(
pathname: string,
state: RecordingReviewLinkState,
): string {
const url = new URL(pathname.replace(/^\//, ""), baseUrl);
const url = new URL(baseUrl);
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
url.searchParams.set(
RECORDING_REVIEW_LINK_PARAM,
`${state.camera}_${Math.floor(state.timestamp)}`,
+15 -9
View File
@@ -575,19 +575,25 @@ export default function DraggableGridLayout({
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
},
);
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [currentGroupStreamingSettings, cameraGroup]);
}, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string) => {
setAudioStates((prev) => ({
@@ -794,7 +800,7 @@ export default function DraggableGridLayout({
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates((prev) => ({
...prev,
@@ -839,7 +845,7 @@ export default function DraggableGridLayout({
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name] ?? 1}
volume={volumeStates[camera.name]}
onLiveAspectChange={liveAspectHandlers[camera.name]}
/>
</GridLiveContextMenu>
+8 -10
View File
@@ -511,14 +511,7 @@ export default function LiveCameraView({
}
}, [webRTCVerdictPending]);
const toggleMic = () => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
};
useKeyboardListener(["m", "t", "Escape"], (key, modifiers) => {
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
if (!modifiers.down) {
return true;
}
@@ -532,7 +525,7 @@ export default function LiveCameraView({
break;
case "t":
if (supports2WayTalk && talkAvailable) {
toggleMic();
setMic(!mic);
return true;
}
break;
@@ -799,7 +792,12 @@ export default function LiveCameraView({
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
}
onClick={toggleMic}
onClick={() => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
}}
disabled={!cameraEnabled || debug || !talkAvailable}
/>
)}
+14 -8
View File
@@ -326,19 +326,25 @@ export default function LiveDashboardView({
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
},
);
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [currentGroupStreamingSettings, cameraGroup]);
}, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string): void => {
setAudioStates((prev) => ({
@@ -629,7 +635,7 @@ export default function LiveDashboardView({
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name] ?? 1}
volume={volumeStates[camera.name]}
/>
</LiveContextMenu>
);
@@ -86,21 +86,17 @@ export default function MediaSyncSettingsView() {
position: "top-center",
closeButton: true,
});
}
} catch (error) {
const alreadyRunning =
axios.isAxiosError(error) && error.response?.status === 409;
toast.error(
t(
alreadyRunning
? "maintenance.sync.alreadyRunning"
: "maintenance.sync.error",
),
{
} else if (response.status === 409) {
toast.error(t("maintenance.sync.alreadyRunning"), {
position: "top-center",
closeButton: true,
},
);
});
}
} catch {
toast.error(t("maintenance.sync.error"), {
position: "top-center",
closeButton: true,
});
} finally {
setIsSubmitting(false);
}
+5 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import useSWR from "swr";
@@ -133,9 +133,6 @@ export default function TriggerView({
// watch websocket for updates
const { payload: triggers_status_ws } = useTriggers();
const highlightTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>(
{},
);
useEffect(() => {
if (!triggers_status_ws) return;
@@ -151,13 +148,13 @@ export default function TriggerView({
return current;
});
const name = triggers_status_ws.name;
clearTimeout(highlightTimers.current[name]);
highlightTimers.current[name] = setTimeout(() => {
const timeout = setTimeout(() => {
setTriggeredTrigger((prev) =>
(prev || []).filter((triggered) => triggered !== name),
(prev || []).filter((name) => name !== triggers_status_ws.name),
);
}, 3000);
return () => clearTimeout(timeout);
}, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => {