Compare commits

..

No commits in common. "e592c7044b5b95509eeffe6e85e6e2f526137b65" and "b02d45d3cb75c61840f117e120305ae8c77e0599" have entirely different histories.

9 changed files with 123 additions and 96 deletions

View File

@ -25,16 +25,16 @@ record:
alerts:
retain:
days: 30
mode: all
mode: motion
detections:
retain:
days: 30
mode: all
mode: motion
```
### Reduced storage: Only saving video when motion is detected
In order to reduce storage requirements, you can adjust your config to only retain video where motion / activity was detected.
In order to reduce storage requirements, you can adjust your config to only retain video where motion was detected.
```yaml
record:
@ -53,7 +53,7 @@ record:
### Minimum: Alerts only
If you only want to retain video that occurs during activity caused by tracked object(s), this config will discard video unless an alert is ongoing.
If you only want to retain video that occurs during a tracked object, this config will discard video unless an alert is ongoing.
```yaml
record:

View File

@ -537,7 +537,7 @@ record:
# Optional: Retention settings for recordings of alerts
retain:
# Required: Retention days (default: shown below)
days: 10
days: 14
# Optional: Mode for retention. (default: shown below)
# all - save all recording segments for alerts regardless of activity
# motion - save all recordings segments for alerts with any detected motion
@ -557,7 +557,7 @@ record:
# Optional: Retention settings for recordings of detections
retain:
# Required: Retention days (default: shown below)
days: 10
days: 14
# Optional: Mode for retention. (default: shown below)
# all - save all recording segments for detections regardless of activity
# motion - save all recordings segments for detections with any detected motion

View File

@ -180,11 +180,7 @@ class RecordingCleanup(threading.Thread):
# Delete recordings outside of the retention window or based on the retention mode
if (
not keep
or (
mode == RetainModeEnum.motion
and recording.motion == 0
and recording.objects == 0
)
or (mode == RetainModeEnum.motion and recording.motion == 0)
or (mode == RetainModeEnum.active_objects and recording.objects == 0)
):
Path(recording.path).unlink(missing_ok=True)

View File

@ -57,25 +57,14 @@ class SegmentInfo:
self.average_dBFS = average_dBFS
def should_discard_segment(self, retain_mode: RetainModeEnum) -> bool:
keep = False
# all mode should never discard
if retain_mode == RetainModeEnum.all:
keep = True
# motion mode should keep if motion or audio is detected
if (
not keep
and retain_mode == RetainModeEnum.motion
and (self.motion_count > 0 or self.average_dBFS > 0)
):
keep = True
# active objects mode should keep if any active objects are detected
if not keep and self.active_object_count > 0:
keep = True
return not keep
return (
retain_mode == RetainModeEnum.motion
and self.motion_count == 0
and self.average_dBFS == 0
) or (
retain_mode == RetainModeEnum.active_objects
and self.active_object_count == 0
)
class RecordingMaintainer(threading.Thread):
@ -359,33 +348,8 @@ class RecordingMaintainer(threading.Thread):
elif record_config.motion.days > 0:
highest = "motion"
# if we have continuous or motion recording enabled
# we should first just check if this segment matches that
# and avoid any DB calls
if highest is not None:
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
# ensure delayed segment info does not lead to lost segments
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.timezone.utc)
>= end_time
):
record_mode = (
RetainModeEnum.all
if highest == "continuous"
else RetainModeEnum.motion
)
return await self.move_segment(
camera, start_time, end_time, duration, cache_path, record_mode
)
# we fell through the continuous / motion check, so we need to check the review items
# continuous / motion recording is not enabled
if highest is None:
# if the cached segment overlaps with the review items:
overlaps = False
for review in reviews:
@ -403,7 +367,10 @@ class RecordingMaintainer(threading.Thread):
# and stop looking at review items
if (
review.end_time is None
or (review.end_time + record_config.get_review_post_capture(severity))
or (
review.end_time
+ record_config.get_review_post_capture(severity)
)
>= start_time.timestamp()
):
overlaps = True
@ -426,8 +393,7 @@ class RecordingMaintainer(threading.Thread):
)
# if it doesn't overlap with an review item, go ahead and drop the segment
# if it ends more than the configured pre_capture for the camera
# BUT only if continuous/motion is NOT enabled (otherwise wait for processing)
elif highest is None:
else:
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
@ -437,6 +403,29 @@ class RecordingMaintainer(threading.Thread):
).astimezone(datetime.timezone.utc)
if end_time < retain_cutoff:
self.drop_segment(cache_path)
# continuous / motion is enabled
else:
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
# ensure delayed segment info does not lead to lost segments
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.timezone.utc)
>= end_time
):
record_mode = (
RetainModeEnum.all
if highest == "continuous"
else RetainModeEnum.motion
)
return await self.move_segment(
camera, start_time, end_time, duration, cache_path, record_mode
)
def segment_stats(
self, camera: str, start_time: datetime.datetime, end_time: datetime.datetime

View File

@ -12,11 +12,11 @@ class TestRecordRetention(unittest.TestCase):
assert not segment_info.should_discard_segment(RetainModeEnum.motion)
assert segment_info.should_discard_segment(RetainModeEnum.active_objects)
def test_object_should_keep_object_when_motion(self):
def test_object_should_keep_object_not_motion(self):
segment_info = SegmentInfo(
motion_count=0, active_object_count=1, region_count=0, average_dBFS=0
)
assert not segment_info.should_discard_segment(RetainModeEnum.motion)
assert segment_info.should_discard_segment(RetainModeEnum.motion)
assert not segment_info.should_discard_segment(RetainModeEnum.active_objects)
def test_all_should_keep_all(self):

View File

@ -165,7 +165,8 @@
"all": "All",
"motion": "Motion",
"active_objects": "Active Objects"
}
},
"notAllTips": "Your {{source}} recording retention configuration is set to <code>mode: {{effectiveRetainMode}}</code>, so this on-demand recording will only keep segments with {{effectiveRetainModeName}}."
},
"editLayout": {
"label": "Edit Layout",

View File

@ -80,6 +80,10 @@ export default function Step1NameCamera({
.string()
.min(1, t("cameraWizard.step1.errors.nameRequired"))
.max(64, t("cameraWizard.step1.errors.nameLength"))
.regex(
/^[a-zA-Z0-9\s_-]+$/,
t("cameraWizard.step1.errors.invalidCharacters"),
)
.refine(
(value) => !existingCameraNames.includes(value),
t("cameraWizard.step1.errors.nameExists"),

View File

@ -108,7 +108,7 @@ import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView";
@ -712,6 +712,42 @@ export default function LiveCameraView({
);
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live", "views/events"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode;
return mode && mode in rankMap ? (mode as keyof typeof rankMap) : "all";
};
const recordRetainMode = getValidMode(camera.record.retain);
const alertsRetainMode = getValidMode(camera.review.alerts.retain);
const effectiveRetainMode =
rankMap[alertsRetainMode] < rankMap[recordRetainMode]
? recordRetainMode
: alertsRetainMode;
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
return effectiveRetainMode !== "all" ? (
<div>
<Trans
ns="views/live"
values={{
source,
effectiveRetainMode,
effectiveRetainModeName: t(
"effectiveRetainMode.modes." + effectiveRetainMode,
),
}}
>
effectiveRetainMode.notAllTips
</Trans>
</div>
) : null;
}
type FrigateCameraFeaturesProps = {
camera: CameraConfig;
recordingEnabled: boolean;
@ -805,10 +841,11 @@ function FrigateCameraFeatures({
const toastId = toast.success(
<div className="flex flex-col space-y-3">
<div className="font-semibold">{t("manualRecording.started")}</div>
{!camera.record.enabled ||
(camera.record.alerts.retain.days == 0 && (
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
<div>{t("manualRecording.recordDisabledTips")}</div>
))}
) : (
<OnDemandRetentionMessage camera={camera} />
)}
</div>,
{
position: "top-center",

View File

@ -162,7 +162,7 @@ export default function ObjectSettingsView({
}
return (
<div className="mt-1 flex size-full flex-col pb-2 md:flex-row">
<div className="mt-1 flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
<Heading as="h4" className="mb-2">