mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-02 20:00:51 +03:00
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.
This commit is contained in:
@@ -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"]
|
||||
|
||||
Vendored
+2
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -255,21 +250,6 @@ 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")}
|
||||
@@ -297,8 +277,6 @@ export default function CreateUserDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -366,7 +344,6 @@ export default function CreateUserDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,12 +28,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import {
|
||||
calculatePasswordStrength,
|
||||
getPasswordRequirements,
|
||||
getPasswordStrengthLabel,
|
||||
getPasswordStrengthColor,
|
||||
} from "@/utils/passwordUtil";
|
||||
import { getPasswordRequirements } from "@/utils/passwordUtil";
|
||||
|
||||
type SetPasswordProps = {
|
||||
show: boolean;
|
||||
@@ -124,12 +119,6 @@ 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],
|
||||
@@ -303,6 +292,12 @@ 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"
|
||||
@@ -334,21 +329,6 @@ 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")}
|
||||
@@ -376,8 +356,6 @@ export default function SetPasswordDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -428,18 +406,25 @@ export default function SetPasswordDialog({
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{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 />
|
||||
{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>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -147,7 +147,18 @@ export default function DetailStream({
|
||||
// away instead of waiting for a future effectiveTime change.
|
||||
useEffect(() => {
|
||||
if (!reviewItems || reviewItems.length === 0) return;
|
||||
if (activeReviewId) 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;
|
||||
}
|
||||
|
||||
let target: ReviewSegment | undefined;
|
||||
let closest: { r: ReviewSegment; diff: number } | undefined;
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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) => ({
|
||||
@@ -800,7 +794,7 @@ export default function DraggableGridLayout({
|
||||
toggleAudio={() => toggleAudio(camera.name)}
|
||||
statsState={statsStates[camera.name]}
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name]}
|
||||
volumeState={volumeStates[camera.name] ?? 1}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates((prev) => ({
|
||||
...prev,
|
||||
@@ -845,7 +839,7 @@ export default function DraggableGridLayout({
|
||||
onError={(e) => handleError(camera.name, e)}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name]}
|
||||
volume={volumeStates[camera.name]}
|
||||
volume={volumeStates[camera.name] ?? 1}
|
||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
||||
/>
|
||||
</GridLiveContextMenu>
|
||||
|
||||
@@ -511,7 +511,14 @@ export default function LiveCameraView({
|
||||
}
|
||||
}, [webRTCVerdictPending]);
|
||||
|
||||
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
|
||||
const toggleMic = () => {
|
||||
setMic(!mic);
|
||||
if (!mic && !audio) {
|
||||
setAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboardListener(["m", "t", "Escape"], (key, modifiers) => {
|
||||
if (!modifiers.down) {
|
||||
return true;
|
||||
}
|
||||
@@ -525,7 +532,7 @@ export default function LiveCameraView({
|
||||
break;
|
||||
case "t":
|
||||
if (supports2WayTalk && talkAvailable) {
|
||||
setMic(!mic);
|
||||
toggleMic();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
@@ -792,12 +799,7 @@ export default function LiveCameraView({
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
: t("twoWayTalk.enable", { ns: "views/live" })
|
||||
}
|
||||
onClick={() => {
|
||||
setMic(!mic);
|
||||
if (!mic && !audio) {
|
||||
setAudio(true);
|
||||
}
|
||||
}}
|
||||
onClick={toggleMic}
|
||||
disabled={!cameraEnabled || debug || !talkAvailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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) => ({
|
||||
@@ -635,7 +629,7 @@ export default function LiveDashboardView({
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
playAudio={audioStates[camera.name] ?? false}
|
||||
volume={volumeStates[camera.name]}
|
||||
volume={volumeStates[camera.name] ?? 1}
|
||||
/>
|
||||
</LiveContextMenu>
|
||||
);
|
||||
|
||||
@@ -86,17 +86,21 @@ export default function MediaSyncSettingsView() {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
} else if (response.status === 409) {
|
||||
toast.error(t("maintenance.sync.alreadyRunning"), {
|
||||
}
|
||||
} catch (error) {
|
||||
const alreadyRunning =
|
||||
axios.isAxiosError(error) && error.response?.status === 409;
|
||||
toast.error(
|
||||
t(
|
||||
alreadyRunning
|
||||
? "maintenance.sync.alreadyRunning"
|
||||
: "maintenance.sync.error",
|
||||
),
|
||||
{
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("maintenance.sync.error"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
@@ -133,6 +133,9 @@ 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;
|
||||
@@ -148,13 +151,13 @@ export default function TriggerView({
|
||||
return current;
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const name = triggers_status_ws.name;
|
||||
clearTimeout(highlightTimers.current[name]);
|
||||
highlightTimers.current[name] = setTimeout(() => {
|
||||
setTriggeredTrigger((prev) =>
|
||||
(prev || []).filter((name) => name !== triggers_status_ws.name),
|
||||
(prev || []).filter((triggered) => triggered !== name),
|
||||
);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [triggers_status_ws, selectedCamera, mutate]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user