diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 95bf2c2a72..6d914fe300 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -293,6 +293,8 @@ ffmpeg: detect: -threads 2 -f rawvideo -pix_fmt yuv420p # Optional: output args for record streams (default: shown below) record: preset-record-generic + # Optional: output args for sub stream record streams (default: the record output args above) + # record_sub: preset-record-generic # Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below) # If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once # If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage @@ -643,6 +645,42 @@ record: # For example, if the camera retain mode is "motion", the segments without motion are # never stored, so setting the mode to "all" here won't bring them back. mode: motion + # Optional: Sub stream recording settings + # Records a second, lower quality stream for quality selection during playback + # and extended low quality retention. Requires the record_sub role to be assigned + # to one of the camera's inputs. + sub: + # Optional: Enable sub stream recording (default: shown below) + # NOTE: Recording must also be enabled for sub stream recording to run. + enabled: False + # Optional: Continuous retention settings for sub stream recordings + continuous: + # Optional: Number of days to retain sub stream recordings regardless of tracked objects or motion (default: shown below) + days: 0 + # Optional: Motion retention settings for sub stream recordings + motion: + # Optional: Number of days to retain sub stream recordings triggered by motion (default: shown below) + days: 0 + # Optional: Retention settings for sub stream recordings of alerts + # NOTE: Pre and post capture windows are taken from the main alerts config above. + alerts: + # Required: Retention days (default: shown below) + days: 10 + # Optional: Mode for retention. (default: shown below) + # all - save all sub stream recording segments for alerts regardless of activity + # motion - save all sub stream recording segments for alerts with any detected motion + # active_objects - save all sub stream recording segments for alerts with active/moving objects + mode: motion + # Optional: Retention settings for sub stream recordings of detections + # NOTE: Pre and post capture windows are taken from the main detections config above. + detections: + # Required: Retention days (default: shown below) + days: 10 + # Optional: Mode for retention. (default: shown below) + # all - save all sub stream recording segments for detections regardless of activity + # motion - save all sub stream recording segments for detections with any detected motion + # active_objects - save all sub stream recording segments for detections with active/moving objects + mode: motion # Optional: Configuration for the snapshots written to the clips directory for each tracked object # Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots. @@ -894,7 +932,7 @@ cameras: # Required: the path to the stream # NOTE: path may include environment variables or docker secrets, which must begin with 'FRIGATE_' and be referenced in {} - path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 - # Required: list of roles for this stream. valid values are: audio,detect,record + # Required: list of roles for this stream. valid values are: audio,detect,record,record_sub # NOTICE: In addition to assigning the audio, detect, and record roles # they must also be enabled in the camera config. roles: diff --git a/docs/docs/configuration/ffmpeg_presets.md b/docs/docs/configuration/ffmpeg_presets.md index 5c1d0fc3b1..50236d0a97 100644 --- a/docs/docs/configuration/ffmpeg_presets.md +++ b/docs/docs/configuration/ffmpeg_presets.md @@ -106,3 +106,5 @@ Output arguments are passed to FFmpeg after your camera source and control how r | preset-record-mjpeg | Record - MJPEG Cameras | Record an MJPEG stream | Restreaming the MJPEG stream is recommended instead | | preset-record-jpeg | Record - JPEG Cameras | Record a live JPEG | Restreaming the live JPEG is recommended instead | | preset-record-ubiquiti | Record - Ubiquiti Cameras | Record a Ubiquiti stream with audio | Handles Ubiquiti's non-standard audio format | + +These presets apply to the `record` output args. If [sub stream recording](/configuration/record#sub-stream-recording) is enabled, the same args are used for the `record_sub` role unless `output_args.record_sub` is set, which accepts the same presets and manual args. diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 49abd75e06..532e9e3eae 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -275,6 +275,163 @@ record: This configuration will retain recording segments that overlap with alerts and detections for 10 days. Because multiple tracked objects can reference the same recording segments, this avoids storing duplicate footage for overlapping tracked objects and reduces overall storage needs. +## Sub Stream Recording + +In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes: + +- **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details. +- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. + +### Configuring sub stream recording + +Sub stream recording uses the `record_sub` input role. This role can be assigned to the same input as `detect`, so in the common case where detect already uses the camera's sub stream, no additional camera connection is needed. Like the main recording stream, sub stream segments are copied directly from the camera stream without re-encoding, so the recording quality is determined by the source stream. + +The following examples keep 7 days of full quality continuous recordings and 60 days of low quality continuous recordings: + + + + +Navigate to and select the camera. + +- In **Camera inputs**, enable the **Record (Sub Stream)** role on the stream you want to record at low quality, commonly the same stream that has the **Detect** role. Only one stream may have this role, and it cannot be assigned to the same stream as the **Record** role. + +Navigate to and select the camera. + +- Set **Enable recording** to on +- Set **Continuous retention > Retention days** to `7` +- Set **Sub stream recording > Enable sub stream recording** to on +- Set **Sub stream recording > Sub stream continuous retention > Retention days** to `60` + +The camera setup wizard also offers the **Record (Sub Stream)** role when assigning stream roles for a newly added camera. + + + + +```yaml +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://camera/main + roles: + - record + - path: rtsp://camera/sub + roles: + - detect + - record_sub + record: + enabled: true + continuous: + days: 7 + sub: + enabled: true + continuous: + days: 60 +``` + +If your camera does not provide a suitable sub stream (or the sub stream is already used at a resolution you don't want to record), you can use a go2rtc transcode as the source for `record_sub` instead: + +```yaml +go2rtc: + streams: + front_door: rtsp://camera/main + front_door_lq: ffmpeg:front_door#video=h264#width=854#hardware + +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_door + input_args: preset-rtsp-restream + roles: + - detect + - record + - path: rtsp://127.0.0.1:8554/front_door_lq + input_args: preset-rtsp-restream + roles: + - record_sub + record: + enabled: true + continuous: + days: 7 + sub: + enabled: true + continuous: + days: 60 +``` + + + + +The `record.sub` config supports the same retention structure as the main recording config: `continuous`, `motion`, `alerts`, and `detections` each with their own `days` (and `mode` for alerts and detections). The pre-capture and post-capture windows for alerts and detections are taken from the main `record.alerts` and `record.detections` config. Extending `sub.alerts.days` or `sub.detections.days` beyond the main values also keeps those review items visible in the review timeline for the longer window, with playback falling back to the low quality stream once the main recordings expire. + +:::note + +Recording must be enabled (`record.enabled`) for sub stream recording to run, and Frigate will fail to start if `record.sub.enabled` is set without a `record_sub` role assigned to one of the camera's inputs. + +::: + +### How Auto picks a quality + +`Auto` measures throughput on every segment download and compares it against the original stream's bitrate (computed from the recorded footage itself). Playback drops to the low quality stream when any of these happen: + +- A freeze lasts 4 seconds (10 seconds when it starts within 2 seconds of a seek, since the seek target is rarely buffered), or freezes total 7 seconds within the last minute. +- 3 downloads in a row measure below the original bitrate plus 10%, dropping quality before a stall ever becomes visible. +- No first frame appears within 10 seconds, or loading fails outright. + +Playback returns to full quality only when measured throughput exceeds the original bitrate by 50%, checked continuously while playing the low quality stream and again at each new hour. The asymmetric thresholds (1.1x to drop, 1.5x to return) keep a borderline connection from switching back and forth. + +The most recent measurement is remembered on the device: a connection last measured below the original bitrate (or below 3 Mbps when the bitrate is not yet known) starts playback on the low quality stream so a first frame appears immediately, then upgrades within a few segments if the speed allows. + +The quality selector shows which stream Auto is currently playing and why. A browser with Data Saver enabled stays on the low quality stream, a browser that cannot decode the original stream's codec (for example H.265 without HEVC support) plays the low quality stream for that camera, and pinning `Original` or `Low` bypasses Auto entirely. + +### Sub stream output args + +By default the sub stream is recorded with the same [output args](/configuration/ffmpeg_presets#output-args-presets) as the main recording stream, so it inherits any customization made to `ffmpeg.output_args.record`. Setting `ffmpeg.output_args.record_sub` gives the sub stream its own args instead. Like all `ffmpeg` config, this can be set globally or per camera. + +The most common reason to set this is a pair of streams whose audio differs. Many cameras send AAC on the main stream but PCM on the sub stream, and PCM cannot be copied into an mp4 recording. Copying the main stream's audio avoids re-encoding audio that is already AAC, while the sub stream still needs to be transcoded: + +```yaml +ffmpeg: + output_args: + # main stream audio is already AAC, so copy it + record: preset-record-generic-audio-copy + # sub stream audio is PCM, so transcode it to AAC + record_sub: preset-record-generic-audio-aac +``` + +Other reasons to set this are recording a sub stream whose codec needs a different preset than the main stream, such as `preset-record-mjpeg`, or forcing a matching audio sample rate across the two streams with manual args ending in `-c:a aac -ar 16000`. + +:::warning + +Avoid removing audio from only one of the two streams (for example with `-an`). When one stream has audio and the other does not, playback of time ranges that combine both qualities is silent, so stripping audio from the sub stream also silences the merged timeline. + +::: + +### Which stream do features use? + +As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream. + +| Feature | Stream used | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually | +| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired | +| Exports and clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) | +| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback | +| Audio extraction (e.g., transcription) | Main preferred, sub fallback | +| Motion search | Main only | +| Review timeline motion data | Main only | +| Storage usage statistics | Both streams counted | + +This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them. + +### Trade-offs + +- Recording a second stream increases overall storage use. The increase is typically small relative to the main recordings, since the low quality stream is much smaller. +- The go2rtc transcode approach continuously encodes the low quality stream, which uses CPU or GPU resources. This cost only applies to the transcode path; recording the camera's native sub stream does not re-encode. See the [go2rtc hardware acceleration documentation](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) for accelerating the transcode. +- Many camera sub streams do not include audio. If the source stream has no audio, the low quality recordings will not have audio. +- **Matching video codecs and audio settings between the two streams gives the smoothest playback.** When playback combines both qualities on one timeline (the default `Auto` behavior: for example original quality during events with low quality in between, or low quality history after the original recordings expire) and the streams use different video codecs or audio settings, for example H.265 on the main stream and H.264 on the sub stream, or 16 kHz audio on one and 8 kHz on the other, playback still works: Frigate inserts a decoder reset at each quality transition, which can cause a barely-perceptible pause there. Configuring both streams in the camera's firmware to use the same video codec, audio codec, and sample rate makes transitions fully seamless, and a mismatched audio sample rate can also be corrected with [sub stream output args](#sub-stream-output-args). If one stream has audio and the other does not, combined time ranges play **without audio**; selecting a single quality with the playback selector always keeps that stream's audio. + ## Can I have "continuous" recordings, but only at certain times? Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times. diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index dcbb2964aa..3d206d677a 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -6022,6 +6022,65 @@ paths: security: - frigateUserAuth: [] x-required-role: camera + /vod/{camera_name}/{stream}/start/{start_ts}/end/{end_ts}: + get: + tags: + - Media + summary: Vod Ts Stream + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist pinned to one stream type (main or sub) for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_ts_stream_vod__camera_name___stream__start__start_ts__end__end_ts__get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: stream + in: path + required: true + schema: + $ref: '#/components/schemas/VodStreamPreference' + - name: start_ts + in: path + required: true + schema: + type: number + title: Start Ts + - name: end_ts + in: path + required: true + schema: + type: number + title: End Ts + - name: force_discontinuity + in: query + required: false + schema: + type: boolean + default: false + title: Force Discontinuity + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /events/{event_id}/snapshot.jpg: get: tags: @@ -6960,6 +7019,63 @@ paths: security: - frigateUserAuth: [] x-required-role: camera + /{camera_name}/recordings/coverage: + get: + tags: + - Recordings + summary: Recordings Coverage + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns merged recording coverage spans plus codec compatibility. + + codecs_compatible is false only when more than one known video codec + appears across the range's rows, the case where the merged vod route + degrades to a single-stream manifest. + operationId: recordings_coverage__camera_name__recordings_coverage_get + parameters: + - name: camera_name + in: path + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Camera Name + - name: after + in: query + required: true + schema: + type: number + title: After + - name: before + in: query + required: true + schema: + type: number + title: Before + - name: timelines + in: query + required: false + schema: + type: boolean + default: false + title: Timelines + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /{camera_name}/recordings: get: tags: @@ -8943,6 +9059,17 @@ components: - msg - type title: ValidationError + VodStreamPreference: + type: string + enum: + - main + - sub + title: VodStreamPreference + description: |- + Stream pin for the path-segment VOD route. + + nginx-vod derives its mapping fetch URI from the playlist URL path + (query params are dropped), so the preference must be a path segment. securitySchemes: frigateAdminAuth: type: apiKey diff --git a/frigate/api/media.py b/frigate/api/media.py index 2f4454f2c0..95b7f924a7 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -8,6 +8,7 @@ import os import subprocess as sp import time from datetime import UTC, datetime, timedelta +from enum import Enum from pathlib import Path as FilePath from typing import Any from urllib.parse import unquote @@ -39,8 +40,9 @@ from frigate.config.camera.snapshots import SnapshotsConfig from frigate.const import ( CACHE_DIR, INSTALL_DIR, - MAX_SEGMENT_DURATION, PREVIEW_FRAME_TYPE, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.output.preview import get_most_recent_preview_frame @@ -52,12 +54,34 @@ from frigate.util.file import ( load_event_snapshot_image, ) from frigate.util.image import get_image_from_recording, get_image_quality_params -from frigate.util.media import get_keyframe_before from frigate.util.object import create_empty_regions_grid +from frigate.util.recording_coverage import ( + build_spans, + null_audio_glitches, + plan_clip, + resolve_coverage, + stream_has_audio, +) logger = logging.getLogger(__name__) +# must match the patched MAX_CLIPS in docker/main/build_nginx.sh; a +# normal hour needs ~360, one clip per recording file +NGINX_VOD_MAX_CLIPS = 1080 + + +class VodStreamPreference(str, Enum): + """Stream pin for the path-segment VOD route. + + nginx-vod derives its mapping fetch URI from the playlist URL path + (query params are dropped), so the preference must be a path segment. + """ + + main = STREAM_TYPE_MAIN + sub = STREAM_TYPE_SUB + + router = APIRouter(tags=[Tags.media]) @@ -319,7 +343,7 @@ async def get_snapshot_from_recording( & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) - .order_by(Recordings.start_time.desc()) + .order_by(Recordings.stream_type.asc(), Recordings.start_time.desc()) .limit(1) .get() ) @@ -338,7 +362,7 @@ async def get_snapshot_from_recording( & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) - .order_by(Recordings.start_time.desc()) + .order_by(Recordings.stream_type.asc(), Recordings.start_time.desc()) .limit(1) .get() ) @@ -398,7 +422,7 @@ async def submit_recording_snapshot_to_plus( (frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time) ) .where(Recordings.camera == camera_name) - .order_by(Recordings.start_time.desc()) + .order_by(Recordings.stream_type.asc(), Recordings.start_time.desc()) .limit(1) ) @@ -472,20 +496,29 @@ async def recording_clip( FilePath(file_path).unlink(missing_ok=True) break - recordings = ( - Recordings.select( - Recordings.path, - Recordings.start_time, - Recordings.end_time, + def get_clip_query(stream_type: str): + return ( + Recordings.select( + Recordings.path, + Recordings.start_time, + Recordings.end_time, + ) + .where( + (Recordings.start_time.between(start_ts, end_ts)) + | (Recordings.end_time.between(start_ts, end_ts)) + | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) + ) + .where(Recordings.camera == camera_name) + .where(Recordings.stream_type == stream_type) + .order_by(Recordings.start_time.asc()) ) - .where( - (Recordings.start_time.between(start_ts, end_ts)) - | (Recordings.end_time.between(start_ts, end_ts)) - | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) - ) - .where(Recordings.camera == camera_name) - .order_by(Recordings.start_time.asc()) - ) + + # never mix streams in one concat; use main when available and + # fall back to sub for expired-main history + recordings = get_clip_query(STREAM_TYPE_MAIN) + + if recordings.count() == 0: + recordings = get_clip_query(STREAM_TYPE_SUB) if recordings.count() == 0: return JSONResponse( @@ -549,17 +582,52 @@ async def recording_clip( ) -@router.get( - "/vod/{camera_name}/start/{start_ts}/end/{end_ts}", - dependencies=[Depends(require_camera_access)], - description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", -) -async def vod_ts( +def _build_vod_clip( + row: Any, start: float, end: float +) -> tuple[dict[str, Any], int] | None: + """Build one nginx-vod clip dict + duration (ms) for a recording row trimmed to [start, end). + + Realization comes entirely from the shared plan_clip, so the coverage + endpoint's realized timelines match this manifest by construction. + """ + plan = plan_clip(row, start, end) + + if plan.skipped: + return None + + clip: dict[str, Any] = {"type": "source", "path": row.path} + if plan.clip_from_ms is not None: + clip["clipFrom"] = plan.clip_from_ms + clip["keyFrameDurations"] = [plan.duration_ms] + logger.debug( + "VOD: added clip %s duration_ms=%s clipFrom=%s", + row.path, + plan.duration_ms, + clip.get("clipFrom"), + ) + return clip, plan.duration_ms + + +async def _vod_response( camera_name: str, start_ts: float, end_ts: float, force_discontinuity: bool = False, -): + stream_preference: str | None = None, +) -> JSONResponse: + """Build an nginx-vod mapping JSON for a camera over a timestamp range. + + Always a single-sequence mapping; quality selection happens in the + frontend by choosing between this route and the stream-pinned routes. + + Args: + camera_name: The camera to build the mapping for + start_ts: Range start as a unix timestamp + end_ts: Range end as a unix timestamp + force_discontinuity: Emit HLS discontinuity markers between clips + stream_preference: Pin the manifest to one stream type ("main" or + "sub"), serving only that stream's recordings + """ logger.debug( "VOD: Generating VOD for %s from %s to %s with force_discontinuity=%s", camera_name, @@ -567,104 +635,85 @@ async def vod_ts( end_ts, force_discontinuity, ) - recordings = ( - Recordings.select( - Recordings.path, - Recordings.duration, - Recordings.end_time, - Recordings.start_time, - ) - .where( - Recordings.camera == camera_name, - Recordings.start_time >= start_ts - MAX_SEGMENT_DURATION, - Recordings.start_time <= end_ts, - Recordings.end_time >= start_ts, - ) - .order_by(Recordings.start_time.asc()) - .iterator() + intervals = resolve_coverage(camera_name, start_ts, end_ts) + + # rows contradicting their stream's audio composition are + # truncated-shutdown glitches + main_audio = stream_has_audio(intervals, main=True) + sub_audio = stream_has_audio(intervals, main=False) + + spans = build_spans( + null_audio_glitches(intervals, main_audio, sub_audio), + stream_preference, ) - clips = [] - durations = [] - min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame - max_duration_ms = MAX_SEGMENT_DURATION * 1000 - - recording: Recordings - for recording in recordings: + durations: list[int] = [] + clips: list[dict[str, Any]] = [] + # gathered after glitch-nulling and span building, so the policy + # decisions below reflect the manifest's real contents + video_codecs: set[str] = set() + audio_presence: set[bool] = set() + audio_params: set[tuple[str | None, int | None]] = set() + span_streams: set[bool] = set() + for row, span_start, span_end, span_is_main in spans: logger.debug( "VOD: processing recording: %s start=%s end=%s duration=%s", - recording.path, - recording.start_time, - recording.end_time, - recording.duration, + row.path, + row.start_time, + row.end_time, + row.duration, ) + built = _build_vod_clip(row, span_start, span_end) - clip = {"type": "source", "path": recording.path} - duration = int(recording.duration * 1000) - - # adjust start offset if start_ts is after recording.start_time - if start_ts > recording.start_time: - inpoint = int((start_ts - recording.start_time) * 1000) - clip["clipFrom"] = inpoint - duration -= inpoint - logger.debug( - "VOD: applied clipFrom %sms to %s", - inpoint, - recording.path, - ) - - # adjust end if recording.end_time is after end_ts - if recording.end_time > end_ts: - duration -= int((recording.end_time - end_ts) * 1000) - - # nginx-vod-module pushes clipFrom forward to the next keyframe, - # which can leave too few frames and produce an empty/unplayable - # segment. Snap clipFrom back to the preceding keyframe so the - # segment always starts with a decodable frame. - if "clipFrom" in clip: - keyframe_ms = get_keyframe_before(recording.path, clip["clipFrom"]) - if keyframe_ms is not None: - gained = clip["clipFrom"] - keyframe_ms - clip["clipFrom"] = keyframe_ms - duration += gained - logger.debug( - "VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms", - keyframe_ms, - recording.path, - duration, - ) - else: - # could not read keyframes, remove clipFrom to use full recording - logger.debug( - "VOD: no keyframe info for %s, removing clipFrom to use full recording", - recording.path, - ) - del clip["clipFrom"] - duration = int(recording.duration * 1000) - if recording.end_time > end_ts: - duration -= int((recording.end_time - end_ts) * 1000) - - if duration < min_duration_ms: - # skip if the clip has no valid duration (too short to contain frames) - logger.debug( - "VOD: skipping recording %s - resulting duration %sms too short", - recording.path, - duration, - ) + if built is None: continue - if min_duration_ms <= duration < max_duration_ms: - clip["keyFrameDurations"] = [duration] - clips.append(clip) - durations.append(duration) - logger.debug( - "VOD: added clip %s duration_ms=%s clipFrom=%s", - recording.path, - duration, - clip.get("clipFrom"), - ) - else: - logger.warning(f"Recording clip is missing or empty: {recording.path}") + clips.append(built[0]) + durations.append(built[1]) + span_streams.add(span_is_main) + if row.video_codec is not None: + video_codecs.add(row.video_codec) + audio_presence.add(row.has_audio is not False) + # legacy rows contribute no signature, so uniformly-unknown + # history keeps the legacy shape + if row.has_audio is not False and ( + row.audio_codec is not None or row.audio_rate is not None + ): + audio_params.add((row.audio_codec, row.audio_rate)) + + # nginx-vod requires a uniform track count per sequence, and adding or + # removing an audio track across an MSE discontinuity is unproven + if len(audio_presence) > 1: + logger.debug( + "VOD: %s mixes audio-bearing and audio-less recordings between " + "%s and %s; serving the range without audio", + camera_name, + start_ts, + end_ts, + ) + for clip in clips: + clip["tracks"] = "v" + + # discontinuity mode emits per-clip init segments, letting the decoder + # reconfigure at each boundary. Stream type counts as a signature of + # its own: the two encoders differ in SPS/PPS even when codec name and + # audio params match, and a single-init manifest then decode-fails on + # players that only configure from the init segment (iOS) + use_discontinuity = ( + len(video_codecs) > 1 or len(audio_params) > 1 or len(span_streams) > 1 + ) + if use_discontinuity: + logger.debug( + "VOD: %s mixes media signatures between %s and %s (video codecs " + "%s, audio params %s, streams %s); serving a discontinuity " + "manifest with per-clip init segments", + camera_name, + start_ts, + end_ts, + sorted(video_codecs), + sorted(audio_params, key=str), + sorted(span_streams), + ) if not clips: logger.error( @@ -678,16 +727,49 @@ async def vod_ts( status_code=404, ) + if len(clips) > NGINX_VOD_MAX_CLIPS: + logger.warning( + "VOD: %s needs %d clips between %s and %s, exceeding nginx's " + "limit of %d; playback of this range will fail. This usually " + "means the camera produced abnormally short recording segments " + "(check the stream's timestamps)", + camera_name, + len(clips), + start_ts, + end_ts, + NGINX_VOD_MAX_CLIPS, + ) + hour_ago = datetime.now() - timedelta(hours=1) - return JSONResponse( - content={ - "cache": hour_ago.timestamp() > start_ts, - "discontinuity": force_discontinuity, - "consistentSequenceMediaInfo": True, - "durations": durations, - "segment_duration": max(durations), - "sequences": [{"clips": clips}], - } + content = { + "cache": hour_ago.timestamp() > start_ts, + "discontinuity": force_discontinuity or use_discontinuity, + "consistentSequenceMediaInfo": True, + "durations": durations, + # aligns segments to recording file boundaries + "segment_duration": max(durations), + "sequences": [{"clips": clips}], + } + if use_discontinuity: + # clip-indexed naming is what makes nginx-vod emit per-clip + # EXT-X-MAP outside of its live mode + content["initialClipIndex"] = 1 + return JSONResponse(content=content) + + +@router.get( + "/vod/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], + description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", +) +async def vod_ts( + camera_name: str, + start_ts: float, + end_ts: float, + force_discontinuity: bool = False, +): + return await _vod_response( + camera_name, start_ts, end_ts, force_discontinuity=force_discontinuity ) @@ -776,7 +858,43 @@ async def vod_clip( start_ts: float, end_ts: float, ): - return await vod_ts(camera_name, start_ts, end_ts, force_discontinuity=True) + # the tracking-details player corrects its timeline from + # sequences[0].clips[0].clipFrom + return await _vod_response( + camera_name, + start_ts, + end_ts, + force_discontinuity=True, + ) + + +# registered after /vod/clip/... on purpose: both routes are six path +# segments, Starlette matches structurally in registration order, and the +# enum validation on {stream} would otherwise 422 every /vod/clip request +@router.get( + "/vod/{camera_name}/{stream}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], + description="Returns an HLS playlist pinned to one stream type (main or sub) for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", +) +async def vod_ts_stream( + camera_name: str, + stream: VodStreamPreference, + start_ts: float, + end_ts: float, + force_discontinuity: bool = False, +): + """VOD for a timestamp range pinned to one stream type. + + How the frontend selects quality, now that mappings are always + single-sequence. + """ + return await _vod_response( + camera_name, + start_ts, + end_ts, + force_discontinuity=force_discontinuity, + stream_preference=stream.value, + ) @router.get( diff --git a/frigate/api/record.py b/frigate/api/record.py index 4913223bf3..451dd2bf1d 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -25,8 +25,20 @@ from frigate.api.defs.query.recordings_query_parameters import ( ) from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.tags import Tags -from frigate.const import MAX_SEGMENT_DURATION, RECORD_DIR +from frigate.const import ( + MAX_SEGMENT_DURATION, + RECORD_DIR, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, +) from frigate.models import Event, Recordings +from frigate.util.recording_coverage import ( + coverage_spans, + known_video_codecs, + realized_timelines, + resolve_coverage, + stream_media_summary, +) from frigate.util.time import get_dst_transitions logger = logging.getLogger(__name__) @@ -149,23 +161,28 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"): period_hour_modifier = f"{hours_offset} hour" period_minute_modifier = f"{minutes_offset} minute" + hour_expression = fn.strftime( + "%Y-%m-%d %H", + fn.datetime( + Recordings.start_time, + "unixepoch", + period_hour_modifier, + period_minute_modifier, + ), + ) + + # sub rows duplicate the camera's motion/object stats, so + # aggregating them too would double-count recording_groups = ( Recordings.select( - fn.strftime( - "%Y-%m-%d %H", - fn.datetime( - Recordings.start_time, - "unixepoch", - period_hour_modifier, - period_minute_modifier, - ), - ).alias("hour"), + hour_expression.alias("hour"), fn.SUM(Recordings.duration).alias("duration"), fn.SUM(Recordings.motion).alias("motion"), fn.SUM(Recordings.objects).alias("objects"), ) .where( (Recordings.camera == camera_name) + & (Recordings.stream_type == STREAM_TYPE_MAIN) & (Recordings.end_time >= period_start) & (Recordings.start_time <= period_end) ) @@ -174,6 +191,23 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"): .namedtuples() ) + # sub recordings can outlive main, so hours covered only by sub + # rows are reported too, flagged as sub_only + sub_groups = ( + Recordings.select( + hour_expression.alias("hour"), + fn.SUM(Recordings.duration).alias("duration"), + ) + .where( + (Recordings.camera == camera_name) + & (Recordings.stream_type == STREAM_TYPE_SUB) + & (Recordings.end_time >= period_start) + & (Recordings.start_time <= period_end) + ) + .group_by((Recordings.start_time + period_offset).cast("int") / 3600) + .namedtuples() + ) + event_groups = ( Event.select( fn.strftime( @@ -197,17 +231,43 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"): event_map = {g.hour: g.count for g in event_groups} - for recording_group in recording_groups: - parts = recording_group.hour.split() + hour_stats = [ + ( + g.hour, + { + "motion": g.motion, + "objects": g.objects, + "duration": round(g.duration), + }, + ) + for g in recording_groups + ] + main_hours = {group_hour for group_hour, _ in hour_stats} + hour_stats.extend( + ( + g.hour, + { + "motion": 0, + "objects": 0, + "duration": round(g.duration), + "sub_only": True, + }, + ) + for g in sub_groups + if g.hour not in main_hours + ) + # restore the most-recent-first ordering after merging in sub hours + hour_stats.sort(key=lambda entry: entry[0], reverse=True) + + for group_hour, stats in hour_stats: + parts = group_hour.split() hour = parts[1] day = parts[0] - events_count = event_map.get(recording_group.hour, 0) + events_count = event_map.get(group_hour, 0) hour_data = { "hour": hour, "events": events_count, - "motion": recording_group.motion, - "objects": recording_group.objects, - "duration": round(recording_group.duration), + **stats, } if day in days: # merge counts if already present (edge-case at DST boundary) @@ -223,6 +283,35 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"): return JSONResponse(content=list(days.values())) +@router.get( + "/{camera_name}/recordings/coverage", + dependencies=[Depends(require_camera_access)], +) +async def recordings_coverage( + camera_name: str, after: float, before: float, timelines: bool = False +): + """Returns merged recording coverage spans plus codec compatibility. + + codecs_compatible is false only when more than one known video codec + appears across the range's rows, the case where the merged vod route + degrades to a single-stream manifest. + """ + intervals = resolve_coverage(camera_name, after, before) + + content = { + "spans": coverage_spans(intervals), + "codecs_compatible": len(known_video_codecs(intervals)) <= 1, + "streams": stream_media_summary(intervals), + } + + # pure computation (shared plan_clip, record-time keyframe index), but + # opt-in for payload hygiene: day-level requests need only the spans + if timelines: + content["timelines"] = realized_timelines(intervals) + + return JSONResponse(content=content) + + @router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)]) async def recordings( camera_name: str, @@ -243,6 +332,7 @@ async def recordings( ) .where( Recordings.camera == camera_name, + Recordings.stream_type == STREAM_TYPE_MAIN, Recordings.start_time >= after - MAX_SEGMENT_DURATION, Recordings.end_time >= after, Recordings.start_time <= before, diff --git a/frigate/api/review.py b/frigate/api/review.py index 2194c7c2fb..44f677d9ee 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -33,6 +33,7 @@ from frigate.api.defs.response.review_response import ( ReviewSummaryResponse, ) from frigate.api.defs.tags import Tags +from frigate.const import STREAM_TYPE_MAIN from frigate.embeddings import EmbeddingsContext from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum @@ -598,6 +599,8 @@ def motion_activity( clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] clauses.append(Recordings.motion > 0) + # sub rows duplicate the camera's motion stats, so only count main rows + clauses.append(Recordings.stream_type == STREAM_TYPE_MAIN) if cameras != "all": requested = set(cameras.split(",")) diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index b9d2cef727..a278c5af8d 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -3,7 +3,12 @@ from enum import Enum from pydantic import Field, PrivateAttr, model_validator -from frigate.const import CACHE_DIR, CACHE_SEGMENT_FORMAT, REGEX_CAMERA_NAME +from frigate.const import ( + CACHE_DIR, + CACHE_SEGMENT_FORMAT, + REGEX_CAMERA_NAME, + SUB_CACHE_TAG, +) from frigate.ffmpeg_presets import ( parse_preset_hardware_acceleration_decode, parse_preset_hardware_acceleration_scale, @@ -294,6 +299,28 @@ class CameraConfig(FrigateBaseModel): + ffmpeg_output_args ) + if ( + "record_sub" in ffmpeg_input.roles + and self.record.enabled + and self.record.sub.enabled + ): + sub_output_args = self.ffmpeg.output_args.effective_record_sub + record_args = get_ffmpeg_arg_list( + parse_preset_output_record( + sub_output_args, + self.ffmpeg.apple_compatibility, + ) + or sub_output_args + ) + + ffmpeg_output_args = ( + record_args + + [ + f"{os.path.join(CACHE_DIR, self.name)}{SUB_CACHE_TAG}@{CACHE_SEGMENT_FORMAT}.mp4" + ] + + ffmpeg_output_args + ) + # if there aren't any outputs enabled for this input if len(ffmpeg_output_args) == 0: return None diff --git a/frigate/config/camera/ffmpeg.py b/frigate/config/camera/ffmpeg.py index ad7cbc8aa1..fa690df237 100644 --- a/frigate/config/camera/ffmpeg.py +++ b/frigate/config/camera/ffmpeg.py @@ -42,6 +42,20 @@ class FfmpegOutputArgsConfig(FrigateBaseModel): title="Record output arguments", description="Default output arguments for record role streams.", ) + record_sub: str | list[str] = Field( + default_factory=list, + title="Sub stream record output arguments", + description="Output arguments for record_sub role streams. The record output arguments are used when this is not set.", + ) + + @property + def effective_record_sub(self) -> str | list[str]: + """Output arguments used for the record_sub role. + + Falls back to the record arguments rather than to the stock preset so + that a customized record value keeps applying to both recorded streams. + """ + return self.record_sub or self.record class FfmpegConfig(FrigateBaseModel): @@ -99,6 +113,7 @@ class FfmpegConfig(FrigateBaseModel): class CameraRoleEnum(str, Enum): audio = "audio" record = "record" + record_sub = "record_sub" detect = "detect" diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 44a71c9cb9..341299113d 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -13,6 +13,7 @@ __all__ = [ "RecordExportConfig", "RecordPreviewConfig", "RecordQualityEnum", + "RecordSubConfig", "EventsConfig", "ReviewRetainConfig", "RecordRetainConfig", @@ -110,6 +111,34 @@ class RecordExportConfig(FrigateBaseModel): ) +class RecordSubConfig(FrigateBaseModel): + enabled: bool = Field( + default=False, + title="Enable sub stream recording", + description="Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.", + ) + continuous: RecordRetainConfig = Field( + default_factory=RecordRetainConfig, + title="Sub stream continuous retention", + description="Number of days to retain sub stream recordings regardless of tracked objects or motion.", + ) + motion: RecordRetainConfig = Field( + default_factory=RecordRetainConfig, + title="Sub stream motion retention", + description="Number of days to retain sub stream recordings triggered by motion.", + ) + alerts: ReviewRetainConfig = Field( + default_factory=ReviewRetainConfig, + title="Sub stream alert retention", + description="Retention settings for sub stream recordings of alerts.", + ) + detections: ReviewRetainConfig = Field( + default_factory=ReviewRetainConfig, + title="Sub stream detection retention", + description="Retention settings for sub stream recordings of detections.", + ) + + class RecordConfig(FrigateBaseModel): enabled: bool = Field( default=False, @@ -151,12 +180,35 @@ class RecordConfig(FrigateBaseModel): title="Preview config", description="Settings controlling the quality of recording previews shown in the UI.", ) + sub: RecordSubConfig = Field( + default_factory=RecordSubConfig, + title="Sub stream recording", + description="Settings for recording a second, lower quality stream.", + ) enabled_in_config: bool | None = Field( default=None, title="Original recording state", description="Indicates whether recording was enabled in the original static configuration.", ) + @property + def effective_alert_days(self) -> float: + """Alert retention extended to the sub stream window when sub is enabled. + + Review items and tracked objects must stay visible for as long as + either stream still has recordings. + """ + if self.sub.enabled: + return max(self.alerts.retain.days, self.sub.alerts.days) + return self.alerts.retain.days + + @property + def effective_detection_days(self) -> float: + """Detection retention extended to the sub window when sub is enabled.""" + if self.sub.enabled: + return max(self.detections.retain.days, self.sub.detections.days) + return self.detections.retain.days + @property def event_pre_capture(self) -> int: return max( diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index c0b9260873..1667bcc8d9 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -129,8 +129,13 @@ class CameraConfigUpdateSubscriber: config.objects = updated_config elif update_type == CameraConfigUpdateEnum.record: old_enabled_in_config = config.record.enabled_in_config + old_sub_enabled = config.record.sub.enabled config.record = updated_config - if old_enabled_in_config != updated_config.enabled_in_config: + # the record and record_sub ffmpeg outputs are gated on these + if ( + old_enabled_in_config != updated_config.enabled_in_config + or old_sub_enabled != updated_config.sub.enabled + ): config.recreate_ffmpeg_cmds() elif update_type == CameraConfigUpdateEnum.review: config.review = updated_config diff --git a/frigate/config/config.py b/frigate/config/config.py index 355cac6406..78611eac74 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -255,6 +255,15 @@ def verify_config_roles(camera_config: CameraConfig) -> None: f"Camera {camera_config.name} has record enabled, but record is not assigned to an input." ) + if ( + camera_config.record.enabled + and camera_config.record.sub.enabled + and "record_sub" not in assigned_roles + ): + raise ValueError( + f"Camera {camera_config.name} has sub stream recording enabled, but record_sub is not assigned to an input." + ) + if camera_config.audio.enabled and "audio" not in assigned_roles: raise ValueError( f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input." @@ -275,13 +284,11 @@ def verify_valid_live_stream_names( ) -def verify_recording_segments_setup_with_reasonable_time( - camera_config: CameraConfig, +def verify_record_output_args_segment_time( + camera_config: CameraConfig, output_args: str | list[str], role: str ) -> None: - """Verify that recording segments are setup and segment time is not greater than 60.""" - record_args: list[str] = get_ffmpeg_arg_list( - camera_config.ffmpeg.output_args.record - ) + """Verify that a recording role's output args segment at a reasonable time.""" + record_args: list[str] = get_ffmpeg_arg_list(output_args) if record_args[0].startswith("preset"): return @@ -291,16 +298,32 @@ def verify_recording_segments_setup_with_reasonable_time( except ValueError: raise ValueError( f"Camera {camera_config.name} has no segment_time in \ - recording output args, segment args are required for record." + {role} output args, segment args are required for record." ) from None if int(record_args[seg_arg_index + 1]) > 60: raise ValueError( - f"Camera {camera_config.name} has invalid segment_time output arg, \ + f"Camera {camera_config.name} has invalid segment_time in {role} output args, \ segment_time must be 60 or less." ) +def verify_recording_segments_setup_with_reasonable_time( + camera_config: CameraConfig, +) -> None: + """Verify that recording segments are setup and segment time is not greater than 60.""" + verify_record_output_args_segment_time( + camera_config, camera_config.ffmpeg.output_args.record, "recording" + ) + + if camera_config.record.sub.enabled: + verify_record_output_args_segment_time( + camera_config, + camera_config.ffmpeg.output_args.effective_record_sub, + "sub stream recording", + ) + + def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None: """Verify that user has not entered zone objects that are not in the tracking config.""" for zone_name, zone in camera_config.zones.items(): diff --git a/frigate/const.py b/frigate/const.py index 5ca1b2d3f0..909e21c4a8 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -23,6 +23,12 @@ SHM_FRAMES_VAR = "SHM_MAX_FRAMES" REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__" +# Stream type constants + +STREAM_TYPE_MAIN = "main" +STREAM_TYPE_SUB = "sub" +SUB_CACHE_TAG = "@sub" + # Attribute & Object constants DEFAULT_ATTRIBUTE_LABEL_MAP = { diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 88b6a9eda5..b7c98bfdac 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -197,9 +197,11 @@ class EventCleanup(threading.Thread): def expire_clips(self) -> list[str]: ## Expire events from unlisted cameras based on the global config + # effective days cover the sub window, keeping tracked objects in + # Explore while sub recordings and review items still exist expire_days = max( - self.config.record.alerts.retain.days, - self.config.record.detections.retain.days, + self.config.record.effective_alert_days, + self.config.record.effective_detection_days, ) file_extension = None # mp4 clips are no longer stored in /clips update_params = {"has_clip": False} @@ -278,15 +280,13 @@ class EventCleanup(threading.Thread): ## Expire events from cameras based on the camera config for name, camera in self.config.cameras.items(): - expire_days = max( - camera.record.alerts.retain.days, - camera.record.detections.retain.days, - ) + # effective days cover the sub window, keeping tracked objects + # in Explore while sub recordings and review items still exist alert_expire_date = ( - now - datetime.timedelta(days=camera.record.alerts.retain.days) + now - datetime.timedelta(days=camera.record.effective_alert_days) ).timestamp() detection_expire_date = ( - now - datetime.timedelta(days=camera.record.detections.retain.days) + now - datetime.timedelta(days=camera.record.effective_detection_days) ).timestamp() # grab all events after specific time expired_events = ( diff --git a/frigate/jobs/motion_search.py b/frigate/jobs/motion_search.py index 15cd104f7b..5944a9e60b 100644 --- a/frigate/jobs/motion_search.py +++ b/frigate/jobs/motion_search.py @@ -15,7 +15,7 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig -from frigate.const import UPDATE_JOB_STATE +from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE from frigate.jobs.job import Job from frigate.jobs.manager import ( get_job_by_id, @@ -485,6 +485,7 @@ class MotionSearchRunner(threading.Thread): ) ) .where(Recordings.camera == camera_name) + .where(Recordings.stream_type == STREAM_TYPE_MAIN) .order_by(Recordings.start_time.asc()) ) diff --git a/frigate/models.py b/frigate/models.py index d927a12c83..eba076ed9a 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -79,6 +79,12 @@ class Recordings(Model): segment_size = FloatField(default=0) # this should be stored as MB regions = IntegerField(null=True) motion_heatmap = JSONField(null=True) # 16x16 grid, 256 values (0-255) + keyframes = JSONField(null=True) # ms offsets; NULL = unprobed (legacy rows) + stream_type = CharField(default="main", max_length=8) + has_audio = BooleanField(null=True) # NULL = unknown (legacy rows) + audio_rate = IntegerField(null=True) # Hz; NULL = unknown (legacy rows) + audio_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows) + video_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows) class ExportCase(Model): diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 71097f1d95..1874e0ed33 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -12,7 +12,14 @@ from typing import Any from playhouse.sqlite_ext import SqliteExtDatabase from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum -from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR +from frigate.const import ( + CACHE_DIR, + CLIPS_DIR, + MAX_WAL_SIZE, + RECORD_DIR, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, +) from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.util.builtin import clear_and_unlink from frigate.util.media import remove_empty_directories @@ -20,6 +27,29 @@ from frigate.util.media import remove_empty_directories logger = logging.getLogger(__name__) +def _filter_reviews_for_pass( + reviews: list[Any], + now: datetime.datetime, + alerts_days: float, + detections_days: float, +) -> list[Any]: + """Limit reviews to those still within this pass's per-severity retention window. + + Review rows survive to the longer of the main and sub retention windows, + so a pass that honored all of them would let extended sub retention keep + main recordings alive too. Filtering preserves sort order for the overlap + loop in expire_existing_camera_recordings. + """ + alert_cutoff = (now - datetime.timedelta(days=alerts_days)).timestamp() + detection_cutoff = (now - datetime.timedelta(days=detections_days)).timestamp() + return [ + r + for r in reviews + if r.end_time is None + or (r.end_time >= (alert_cutoff if r.severity == "alert" else detection_cutoff)) + ] + + class RecordingCleanup(threading.Thread): """Cleanup existing recordings based on retention config.""" @@ -65,11 +95,14 @@ class RecordingCleanup(threading.Thread): self, config: CameraConfig, now: datetime.datetime ) -> set[Path]: """Delete review segments that are expired""" - alert_expire_date = ( - now - datetime.timedelta(days=config.record.alerts.retain.days) - ).timestamp() + # review rows survive to the longer of the main and sub windows so + # they stay visible while either stream still has recordings + alert_days = config.record.effective_alert_days + detection_days = config.record.effective_detection_days + + alert_expire_date = (now - datetime.timedelta(days=alert_days)).timestamp() detection_expire_date = ( - now - datetime.timedelta(days=config.record.detections.retain.days) + now - datetime.timedelta(days=detection_days) ).timestamp() expired_reviews = ( ReviewSegment.select(ReviewSegment.id, ReviewSegment.thumb_path) @@ -109,8 +142,11 @@ class RecordingCleanup(threading.Thread): def expire_existing_camera_recordings( self, + stream_type: str, continuous_expire_date: float, motion_expire_date: float, + alerts_retain_mode: RetainModeEnum, + detections_retain_mode: RetainModeEnum, config: CameraConfig, reviews: list[Any], ) -> set[Path]: @@ -130,6 +166,7 @@ class RecordingCleanup(threading.Thread): ) .where( (Recordings.camera == config.name) + & (Recordings.stream_type == stream_type) & ( ( (Recordings.end_time < continuous_expire_date) @@ -175,9 +212,9 @@ class RecordingCleanup(threading.Thread): ): keep = True mode = ( - config.record.alerts.retain.mode + alerts_retain_mode if review.severity == "alert" - else config.record.detections.retain.mode + else detections_retain_mode ) break @@ -216,6 +253,10 @@ class RecordingCleanup(threading.Thread): Recordings.id << deleted_recordings_list[i : i + max_deletes] ).execute() + # previews follow main retention, so only the main pass expires them + if stream_type != STREAM_TYPE_MAIN: + return maybe_empty_dirs + previews = ( Previews.select( Previews.id, @@ -342,6 +383,20 @@ class RecordingCleanup(threading.Thread): ) ).timestamp() + # computed here so the reviews window below covers both passes + sub_continuous_expire_date = ( + now - datetime.timedelta(days=config.record.sub.continuous.days) + ).timestamp() + sub_motion_expire_date = ( + now + - datetime.timedelta( + days=max( + config.record.sub.motion.days, + config.record.sub.continuous.days, + ) # can't keep motion for less than continuous + ) + ).timestamp() + # Get all the reviews to check against reviews = ( ReviewSegment.select( @@ -351,18 +406,46 @@ class RecordingCleanup(threading.Thread): ) .where( ReviewSegment.camera == camera, - # candidate recordings can extend up to continuous_expire_date - # (the no-motion no-audio branch of the recordings query), - # so reviews must cover that full range to avoid deleting - # segments that overlap recent alerts/detections. - ReviewSegment.start_time < continuous_expire_date, + # candidate recordings reach the later of the two passes' + # continuous cutoffs, so reviews must cover that whole + # range or segments overlapping recent alerts get deleted + ReviewSegment.start_time + < max(continuous_expire_date, sub_continuous_expire_date), ) .order_by(ReviewSegment.start_time) .namedtuples() ) maybe_empty_dirs |= self.expire_existing_camera_recordings( - continuous_expire_date, motion_expire_date, config, reviews + STREAM_TYPE_MAIN, + continuous_expire_date, + motion_expire_date, + config.record.alerts.retain.mode, + config.record.detections.retain.mode, + config, + _filter_reviews_for_pass( + reviews, + now, + config.record.alerts.retain.days, + config.record.detections.retain.days, + ), + ) + + # runs even when sub recording is disabled so old rows still + # expire + maybe_empty_dirs |= self.expire_existing_camera_recordings( + STREAM_TYPE_SUB, + sub_continuous_expire_date, + sub_motion_expire_date, + config.record.sub.alerts.mode, + config.record.sub.detections.mode, + config, + _filter_reviews_for_pass( + reviews, + now, + config.record.sub.alerts.days, + config.record.sub.detections.days, + ), ) logger.debug(f"End camera: {camera}.") diff --git a/frigate/record/export.py b/frigate/record/export.py index 5d307077c9..3f68a7e1b1 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -12,6 +12,7 @@ import threading from collections.abc import Callable from enum import Enum from pathlib import Path +from typing import Any import pytz # type: ignore[import-untyped] from peewee import DoesNotExist @@ -24,6 +25,8 @@ from frigate.const import ( EXPORT_DIR, MAX_PLAYLIST_SECONDS, PREVIEW_FRAME_TYPE, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, ) from frigate.ffmpeg_presets import ( EncodeTypeEnum, @@ -283,6 +286,29 @@ class RecordingExporter(threading.Thread): return input_duration * factor + def _get_recordings_for_range(self, stream_type: str) -> list[Any]: + """Fetch one stream type's recording rows overlapping the export range.""" + return list( + Recordings.select( + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(self.start_time, self.end_time) + | Recordings.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Recordings.start_time) + & (self.end_time < Recordings.end_time) + ) + ) + .where( + (Recordings.camera == self.camera) + & (Recordings.stream_type == stream_type) + ) + .order_by(Recordings.start_time.asc()) + .iterator() + ) + def _sum_source_duration_seconds(self) -> float | None: """Sum saved-video seconds inside [start_time, end_time]. @@ -293,19 +319,12 @@ class RecordingExporter(threading.Thread): """ try: if self.playback_source == PlaybackSourceEnum.recordings: - rows = ( - Recordings.select(Recordings.start_time, Recordings.end_time) - .where( - Recordings.start_time.between(self.start_time, self.end_time) - | Recordings.end_time.between(self.start_time, self.end_time) - | ( - (self.start_time > Recordings.start_time) - & (self.end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == self.camera) - .iterator() - ) + # never mix streams in one estimate; use main when available + # and fall back to sub for expired-main history + rows = self._get_recordings_for_range(STREAM_TYPE_MAIN) + + if not rows: + rows = self._get_recordings_for_range(STREAM_TYPE_SUB) else: rows = ( Previews.select(Previews.start_time, Previews.end_time) @@ -691,23 +710,12 @@ class RecordingExporter(threading.Thread): if type(internal_port) is str: internal_port = int(internal_port.split(":")[-1]) - recordings = list( - Recordings.select( - Recordings.start_time, - Recordings.end_time, - ) - .where( - Recordings.start_time.between(self.start_time, self.end_time) - | Recordings.end_time.between(self.start_time, self.end_time) - | ( - (self.start_time > Recordings.start_time) - & (self.end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == self.camera) - .order_by(Recordings.start_time.asc()) - .iterator() - ) + # never mix streams in one playlist; use main when available and + # fall back to sub for expired-main history + recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN) + + if not recordings: + recordings = self._get_recordings_for_range(STREAM_TYPE_SUB) playlist_lines: list[str] = [] if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 50a24a85fb..c347566b07 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -15,6 +15,7 @@ from typing import Any import numpy as np import psutil +from peewee import fn from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.inter_process import InterProcessRequestor @@ -35,15 +36,48 @@ from frigate.const import ( MAX_SEGMENT_DURATION, MAX_SEGMENTS_IN_CACHE, RECORD_DIR, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, + SUB_CACHE_TAG, ) from frigate.models import Recordings, ReviewSegment from frigate.review.types import SeverityEnum +from frigate.util.media import get_keyframe_offsets from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2 +# cache filenames have whole-second resolution, so a contiguous segment's +# parsed start lands up to 1s before the previous segment's true end +SEGMENT_CHAIN_TOLERANCE_S = 1.0 + +# against an mtime-measured start, disagreement beyond this means +# accumulated probe-duration error and the chain re-anchors on the mtime +SEGMENT_CHAIN_DRIFT_LIMIT_S = 0.5 + +# probing every cached segment at once starves the camera and detection +# processes, and the probes then blow their own timeouts together, so +# segments get discarded as corrupt and the record watchdog restarts ffmpeg +MAX_CONCURRENT_SEGMENT_PROBES = 4 + + +def parse_cache_segment_name(basename: str) -> tuple[str, str, str] | None: + """Parse a cache segment basename into (camera, stream_type, date). + + Main segments are named {camera}@{date}; sub segments {camera}@sub@{date}. + """ + try: + prefix, date = basename.rsplit("@", maxsplit=1) + except ValueError: + return None + + if prefix.endswith(SUB_CACHE_TAG): + return (prefix[: -len(SUB_CACHE_TAG)], STREAM_TYPE_SUB, date) + + return (prefix, STREAM_TYPE_MAIN, date) + class SegmentInfo: def __init__( @@ -83,6 +117,10 @@ class SegmentInfo: class RecordingMaintainer(threading.Thread): + # move_files replaces this per cycle: an asyncio primitive binds to the + # first event loop that contends it, and every cycle runs in a new loop + probe_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENT_PROBES) + def __init__(self, config: FrigateConfig, stop_event: MpEvent): super().__init__(name="recording_maintainer") self.config = config @@ -100,10 +138,101 @@ class RecordingMaintainer(threading.Thread): self.stop_event = stop_event self.object_recordings_info: dict[str, list] = defaultdict(list) self.audio_recordings_info: dict[str, list] = defaultdict(list) - self.end_time_cache: dict[str, tuple[datetime.datetime, float]] = {} + # cache_path -> (end_time, duration, has_audio, audio_rate, + # audio_codec, video_codec, keyframes) + self.end_time_cache: dict[ + str, + tuple[ + datetime.datetime, + float, + bool | None, + int | None, + str | None, + str | None, + list[int] | None, + ], + ] = {} + # last known capture end per (camera, stream_type); 0.0 marks a key + # whose DB seed found no rows + self.last_segment_end: dict[tuple[str, str], float] = {} self.unexpected_cache_files_logged: bool = False + def _get_last_segment_end(self, camera: str, stream_type: str) -> float | None: + """Return the last known capture end time for a camera stream. + + Lazily seeds from the most recent stored recording so start-time + chains survive restarts. + """ + key = (camera, stream_type) + + if key not in self.last_segment_end: + last_db_end = ( + Recordings.select(fn.MAX(Recordings.end_time)) + .where( + Recordings.camera == camera, + Recordings.stream_type == stream_type, + ) + .scalar() + ) + # the 0.0 sentinel keeps the seed query from repeating + self.last_segment_end[key] = last_db_end if last_db_end is not None else 0.0 + + return self.last_segment_end[key] or None + + def _resolve_segment_start( + self, + camera: str, + stream_type: str, + filename_start: datetime.datetime, + duration: float, + cache_path: str, + ) -> datetime.datetime: + """Resolve a segment's true start time from its cache file. + + Cache filenames carry whole-second resolution, so the parsed start + sits up to 1s early. The cache file's mtime is the wall clock when + ffmpeg rolled the segment, so mtime minus the probed duration + restores the fractional start. Contiguous segments still chain to + the previous segment's end so rows stay exactly adjacent. + """ + filename_ts = filename_start.timestamp() + + measured: float | None = None + try: + mtime = os.path.getmtime(cache_path) + except OSError: + mtime = None + if mtime is not None: + candidate = mtime - duration + # media shorter than its wall span (a stalled stream, an early + # close) derives a start past the truncation window, where the + # floored filename start is safer + if 0 <= candidate - filename_ts < SEGMENT_CHAIN_TOLERANCE_S: + measured = candidate + + last_end = self._get_last_segment_end(camera, stream_type) + + if measured is not None: + if ( + last_end is not None + and abs(last_end - measured) < SEGMENT_CHAIN_DRIFT_LIMIT_S + ): + return datetime.datetime.fromtimestamp(last_end, tz=datetime.UTC) + return datetime.datetime.fromtimestamp(measured, tz=datetime.UTC) + + # no usable mtime: capture is continuous within a run, so a + # filename start just before the previous end chains to that end + if ( + last_end is not None + and 0 <= last_end - filename_ts < SEGMENT_CHAIN_TOLERANCE_S + ): + return datetime.datetime.fromtimestamp(last_end, tz=datetime.UTC) + + return filename_start + async def move_files(self) -> None: + self.probe_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENT_PROBES) + cache_files = [ d for d in os.listdir(CACHE_DIR) @@ -117,13 +246,17 @@ class RecordingMaintainer(threading.Thread): for cache in cache_files: cache_path = os.path.join(CACHE_DIR, cache) basename = os.path.splitext(cache)[0] - try: - camera, date = basename.rsplit("@", maxsplit=1) - except ValueError: + parsed = parse_cache_segment_name(basename) + if parsed is None: if not self.unexpected_cache_files_logged: logger.warning("Skipping unexpected files in cache") self.unexpected_cache_files_logged = True continue + camera, stream_type, date = parsed + + # this topic feeds main-stream health/sync consumers only + if stream_type == STREAM_TYPE_SUB: + continue start_time = datetime.datetime.strptime( date, CACHE_SEGMENT_FORMAT @@ -167,8 +300,10 @@ class RecordingMaintainer(threading.Thread): except psutil.Error: continue - # group recordings by camera (skip in-use for validation/moving) - grouped_recordings: defaultdict[str, list[dict[str, Any]]] = defaultdict(list) + # group recordings by camera and stream type (skip in-use for validation/moving) + grouped_recordings: defaultdict[tuple[str, str], list[dict[str, Any]]] = ( + defaultdict(list) + ) for cache in cache_files: # Skip files currently in use if cache in files_in_use: @@ -176,32 +311,35 @@ class RecordingMaintainer(threading.Thread): cache_path = os.path.join(CACHE_DIR, cache) basename = os.path.splitext(cache)[0] - try: - camera, date = basename.rsplit("@", maxsplit=1) - except ValueError: + parsed = parse_cache_segment_name(basename) + if parsed is None: if not self.unexpected_cache_files_logged: logger.warning("Skipping unexpected files in cache") self.unexpected_cache_files_logged = True continue + camera, stream_type, date = parsed # important that start_time is utc because recordings are stored and compared in utc start_time = datetime.datetime.strptime( date, CACHE_SEGMENT_FORMAT ).astimezone(datetime.UTC) - grouped_recordings[camera].append( + grouped_recordings[(camera, stream_type)].append( { "cache_path": cache_path, "start_time": start_time, + "stream_type": stream_type, } ) # delete all cached files past the most recent MAX_SEGMENTS_IN_CACHE keep_count = MAX_SEGMENTS_IN_CACHE - for camera in grouped_recordings.keys(): + for key in grouped_recordings.keys(): + camera, stream_type = key + # sort based on start time - grouped_recordings[camera] = sorted( - grouped_recordings[camera], key=lambda s: s["start_time"] + grouped_recordings[key] = sorted( + grouped_recordings[key], key=lambda s: s["start_time"] ) camera_info = self.object_recordings_info[camera] @@ -216,7 +354,7 @@ class RecordingMaintainer(threading.Thread): r["start_time"].timestamp() < most_recently_processed_frame_time ), - grouped_recordings[camera], + grouped_recordings[key], ) ) ) @@ -226,103 +364,133 @@ class RecordingMaintainer(threading.Thread): logger.warning( f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..." ) - to_remove = grouped_recordings[camera][:-keep_count] + to_remove = grouped_recordings[key][:-keep_count] for rec in to_remove: cache_path = rec["cache_path"] Path(cache_path).unlink(missing_ok=True) self.end_time_cache.pop(cache_path, None) - grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] + grouped_recordings[key] = grouped_recordings[key][-keep_count:] # see if detection has failed and unprocessed segments need to be deleted unprocessed_segment_count = ( - len(grouped_recordings[camera]) - processed_segment_count + len(grouped_recordings[key]) - processed_segment_count ) if unprocessed_segment_count > keep_count: logger.warning( f"Too many unprocessed recording segments in cache for {camera}. This likely indicates an issue with the detect stream, keeping the {keep_count} most recent segments out of {unprocessed_segment_count} and discarding the rest..." ) - to_remove = grouped_recordings[camera][:-keep_count] + to_remove = grouped_recordings[key][:-keep_count] for rec in to_remove: cache_path = rec["cache_path"] Path(cache_path).unlink(missing_ok=True) self.end_time_cache.pop(cache_path, None) - grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] + grouped_recordings[key] = grouped_recordings[key][-keep_count:] - tasks = [] - for camera, recordings in grouped_recordings.items(): + # frame stats are shared per camera across stream types, so trimming + # to one stream's oldest cache would pop frames the other still needs + min_start_per_camera: dict[str, float] = {} + for key, recordings in grouped_recordings.items(): + camera, _ = key + oldest_start = recordings[0]["start_time"].timestamp() + if ( + camera not in min_start_per_camera + or oldest_start < min_start_per_camera[camera] + ): + min_start_per_camera[camera] = oldest_start + + for camera, min_start in min_start_per_camera.items(): # clear out all the object recording info for old frames while ( len(self.object_recordings_info[camera]) > 0 - and self.object_recordings_info[camera][0][0] - < recordings[0]["start_time"].timestamp() + and self.object_recordings_info[camera][0][0] < min_start ): self.object_recordings_info[camera].pop(0) # clear out all the audio recording info for old frames while ( len(self.audio_recordings_info[camera]) > 0 - and self.audio_recordings_info[camera][0][0] - < recordings[0]["start_time"].timestamp() + and self.audio_recordings_info[camera][0][0] < min_start ): self.audio_recordings_info[camera].pop(0) - # get all reviews with the end time after the start of the oldest cache file - # or with end_time None - reviews = ( - ReviewSegment.select( - ReviewSegment.start_time, - ReviewSegment.end_time, - ReviewSegment.severity, - ReviewSegment.data, + tasks = [] + reviews_by_camera: dict[str, Any] = {} + for key, recordings in grouped_recordings.items(): + camera, stream_type = key + + # get all reviews with the end time after the start of the oldest + # cache file or with end_time None; shared across stream types + if camera not in reviews_by_camera: + reviews_by_camera[camera] = ( + ReviewSegment.select( + ReviewSegment.start_time, + ReviewSegment.end_time, + ReviewSegment.severity, + ReviewSegment.data, + ) + .where( + ReviewSegment.camera == camera, + (ReviewSegment.end_time == None) + | (ReviewSegment.end_time >= min_start_per_camera[camera]), + ) + .order_by(ReviewSegment.start_time) ) - .where( - ReviewSegment.camera == camera, - (ReviewSegment.end_time == None) - | ( - ReviewSegment.end_time - >= recordings[0]["start_time"].timestamp() - ), - ) - .order_by(ReviewSegment.start_time) - ) + reviews = reviews_by_camera[camera] tasks.extend( [self.validate_and_move_segment(camera, reviews, r) for r in recordings] ) # publish most recently available recording time and None if disabled - camera_cfg = self.config.cameras.get(camera) - self.recordings_publisher.publish( - ( - camera, - recordings[0]["start_time"].timestamp() - if camera_cfg and camera_cfg.record.enabled - else None, - None, - ), - RecordingsDataTypeEnum.saved.value, - ) + if stream_type == STREAM_TYPE_MAIN: + camera_cfg = self.config.cameras.get(camera) + self.recordings_publisher.publish( + ( + camera, + recordings[0]["start_time"].timestamp() + if camera_cfg and camera_cfg.record.enabled + else None, + None, + ), + RecordingsDataTypeEnum.saved.value, + ) self._expire_stale_recordings_info(grouped_recordings) - recordings_to_insert: list[dict[str, Any] | None] = await asyncio.gather(*tasks) - - # fire and forget recordings entries - self.requestor.send_data( - INSERT_MANY_RECORDINGS, - [r for r in recordings_to_insert if r is not None], + # one segment must not abort the cycle: an exception propagating out + # of gather would abandon the other segments' in-flight probes + results: list[dict[str, Any] | None | BaseException] = await asyncio.gather( + *tasks, return_exceptions=True ) + recordings_to_insert: list[dict[str, Any]] = [] + + for result in results: + if isinstance(result, BaseException): + logger.error( + "Failed to validate and move a recording segment", exc_info=result + ) + continue + + if result is not None: + recordings_to_insert.append(result) + + # fire and forget recordings entries + self.requestor.send_data(INSERT_MANY_RECORDINGS, recordings_to_insert) + def _expire_stale_recordings_info( - self, grouped_recordings: defaultdict[str, list[dict[str, Any]]] + self, grouped_recordings: defaultdict[tuple[str, str], list[dict[str, Any]]] ) -> None: expire_before = datetime.datetime.now().timestamp() - STALE_RECORDINGS_INFO_TTL + # a camera is still active when any of its streams cached segments + cameras_with_cache = {camera for camera, _ in grouped_recordings} + for recordings_info in ( self.object_recordings_info, self.audio_recordings_info, ): for camera in list(recordings_info.keys()): - if camera in grouped_recordings: + if camera in cameras_with_cache: continue info = recordings_info[camera] while info and info[0][0] < expire_before: @@ -337,64 +505,121 @@ class RecordingMaintainer(threading.Thread): ) -> dict[str, Any] | None: cache_path: str = recording["cache_path"] start_time: datetime.datetime = recording["start_time"] + stream_type: str = recording["stream_type"] # Just delete files if camera removed or recordings are turned off if ( camera not in self.config.cameras or not self.config.cameras[camera].record.enabled + or ( + stream_type == STREAM_TYPE_SUB + and not self.config.cameras[camera].record.sub.enabled + ) ): self.drop_segment(cache_path) return None if cache_path in self.end_time_cache: - end_time, duration = self.end_time_cache[cache_path] + ( + end_time, + duration, + has_audio, + audio_rate, + audio_codec, + video_codec, + keyframes, + ) = self.end_time_cache[cache_path] + # recover the resolved start rather than reusing the truncated + # filename timestamp + start_time = end_time - datetime.timedelta(seconds=duration) else: - segment_info = await get_video_properties( - self.config.ffmpeg, cache_path, get_duration=True - ) + async with self.probe_semaphore: + segment_info = await get_video_properties( + self.config.ffmpeg, cache_path, get_duration=True + ) if not segment_info.get("has_valid_video", False): logger.warning( f"Invalid or missing video stream in segment {cache_path}. Discarding." ) - self.recordings_publisher.publish( - (camera, start_time.timestamp(), cache_path), - RecordingsDataTypeEnum.invalid.value, - ) + if stream_type == STREAM_TYPE_MAIN: + self.recordings_publisher.publish( + (camera, start_time.timestamp(), cache_path), + RecordingsDataTypeEnum.invalid.value, + ) self.drop_segment(cache_path) return None duration = float(segment_info.get("duration", -1)) + has_audio = segment_info.get("has_audio") + audio_rate = segment_info.get("audio_rate") + audio_codec = segment_info.get("audio_codec") + video_codec = segment_info.get("video_codec") # ensure duration is within expected length if 0 < duration < MAX_SEGMENT_DURATION: + # playback snaps mid-file entry points against these offsets + # instead of probing files on demand + async with self.probe_semaphore: + keyframes = await get_keyframe_offsets(cache_path) + + start_time = self._resolve_segment_start( + camera, stream_type, start_time, duration, cache_path + ) end_time = start_time + datetime.timedelta(seconds=duration) - self.end_time_cache[cache_path] = (end_time, duration) + self.end_time_cache[cache_path] = ( + end_time, + duration, + has_audio, + audio_rate, + audio_codec, + video_codec, + keyframes, + ) + # segments later discarded by retention still advance the + # chain for the next kept segment + self.last_segment_end[(camera, stream_type)] = end_time.timestamp() else: if duration == -1: logger.warning(f"Failed to probe corrupt segment {cache_path}") logger.warning(f"Discarding a corrupt recording segment: {cache_path}") - self.recordings_publisher.publish( - (camera, start_time.timestamp(), cache_path), - RecordingsDataTypeEnum.invalid.value, - ) + if stream_type == STREAM_TYPE_MAIN: + self.recordings_publisher.publish( + (camera, start_time.timestamp(), cache_path), + RecordingsDataTypeEnum.invalid.value, + ) self.drop_segment(cache_path) return None # this segment has a valid duration and has video data, so publish an update - self.recordings_publisher.publish( - (camera, start_time.timestamp(), cache_path), - RecordingsDataTypeEnum.valid.value, - ) + if stream_type == STREAM_TYPE_MAIN: + self.recordings_publisher.publish( + (camera, start_time.timestamp(), cache_path), + RecordingsDataTypeEnum.valid.value, + ) record_config = self.config.cameras[camera].record + + # sub's alerts/detections carry the retain mode directly, unlike + # main's nested retain config + if stream_type == STREAM_TYPE_SUB: + continuous_days = record_config.sub.continuous.days + motion_days = record_config.sub.motion.days + alerts_retain_mode = record_config.sub.alerts.mode + detections_retain_mode = record_config.sub.detections.mode + else: + continuous_days = record_config.continuous.days + motion_days = record_config.motion.days + alerts_retain_mode = record_config.alerts.retain.mode + detections_retain_mode = record_config.detections.retain.mode + segment_stats: SegmentInfo | None = None highest = None - if record_config.continuous.days > 0: + if continuous_days > 0: highest = "continuous" - elif record_config.motion.days > 0: + elif motion_days > 0: highest = "motion" # if we have continuous or motion recording enabled @@ -426,11 +651,17 @@ class RecordingMaintainer(threading.Thread): if not segment_stats.should_discard_segment(record_mode): return await self.move_segment( camera, + stream_type, start_time, end_time, duration, cache_path, segment_stats, + has_audio, + audio_rate, + audio_codec, + video_codec, + keyframes, ) # we fell through the continuous / motion check, so we need to check the review items @@ -459,9 +690,9 @@ class RecordingMaintainer(threading.Thread): if overlaps: record_mode = ( - record_config.alerts.retain.mode + alerts_retain_mode if review.severity == "alert" - else record_config.detections.retain.mode + else detections_retain_mode ) if segment_stats is None: @@ -471,11 +702,17 @@ class RecordingMaintainer(threading.Thread): # move from cache to recordings immediately return await self.move_segment( camera, + stream_type, start_time, end_time, duration, cache_path, segment_stats, + has_audio, + audio_rate, + audio_codec, + video_codec, + keyframes, ) else: self.drop_segment(cache_path) @@ -614,17 +851,24 @@ class RecordingMaintainer(threading.Thread): async def move_segment( self, camera: str, + stream_type: str, start_time: datetime.datetime, end_time: datetime.datetime, duration: float, cache_path: str, segment_info: SegmentInfo, + has_audio: bool | None = None, + audio_rate: int | None = None, + audio_codec: str | None = None, + video_codec: str | None = None, + keyframes: list[int] | None = None, ) -> dict[str, Any] | None: # directory will be in utc due to start_time being in utc + # sub segments get a tagged directory to avoid filename collisions directory = os.path.join( RECORD_DIR, start_time.strftime("%Y-%m-%d/%H"), - camera, + camera if stream_type == STREAM_TYPE_MAIN else f"{camera}{SUB_CACHE_TAG}", ) os.makedirs(directory, exist_ok=True) @@ -684,6 +928,7 @@ class RecordingMaintainer(threading.Thread): return { Recordings.id.name: f"{start_time.timestamp()}-{rand_id}", Recordings.camera.name: camera, + Recordings.stream_type.name: stream_type, Recordings.path.name: file_path, Recordings.start_time.name: start_time.timestamp(), Recordings.end_time.name: end_time.timestamp(), @@ -695,6 +940,11 @@ class RecordingMaintainer(threading.Thread): Recordings.dBFS.name: segment_info.average_dBFS, Recordings.segment_size.name: segment_size, Recordings.motion_heatmap.name: segment_info.motion_heatmap, + Recordings.has_audio.name: has_audio, + Recordings.audio_rate.name: audio_rate, + Recordings.audio_codec.name: audio_codec, + Recordings.video_codec.name: video_codec, + Recordings.keyframes.name: keyframes, } except Exception as e: logger.error(f"Unable to store recording segment {cache_path}") @@ -788,11 +1038,10 @@ class RecordingMaintainer(threading.Thread): try: asyncio.run(self.move_files()) - except Exception as e: - logger.error( + except Exception: + logger.exception( "Error occurred when attempting to maintain recording cache" ) - logger.error(e) duration = datetime.datetime.now().timestamp() - run_start wait_time = max(0, 5 - duration) diff --git a/frigate/storage.py b/frigate/storage.py index 585a5d87f1..5e7c46957e 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -9,7 +9,12 @@ from pathlib import Path from peewee import SQL, fn from frigate.config import FrigateConfig -from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX +from frigate.const import ( + RECORD_DIR, + REPLAY_CAMERA_PREFIX, + STREAM_TYPE_MAIN, + STREAM_TYPE_SUB, +) from frigate.models import Event, Recordings from frigate.util.builtin import clear_and_unlink @@ -49,30 +54,43 @@ class StorageMaintainer(threading.Thread): ) } - # calculate MB/hr from last 100 segments - try: - # Subquery to get last 100 segments, then average their bandwidth - last_100 = ( - Recordings.select(bandwidth_equation.alias("bw")) - .where(Recordings.camera == camera, Recordings.segment_size > 0) - .order_by(Recordings.start_time.desc()) - .limit(100) - .alias("recent") - ) - - bandwidth = round( - Recordings.select(fn.AVG(SQL("bw"))).from_(last_100).scalar() - * 3600, - 2, - ) - - if bandwidth > MAX_CALCULATED_BANDWIDTH: - logger.warning( - f"{camera} has a bandwidth of {bandwidth} MB/hr which exceeds the expected maximum. This typically indicates an issue with the cameras recordings." + # calculate MB/hr from the last 100 segments of each stream + # type and sum the rates; mixing streams would average small + # sub segments against large main segments and underestimate + # the true write rate + bandwidth = 0 + for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB): + try: + # Subquery to get last 100 segments, then average their bandwidth + last_100 = ( + Recordings.select(bandwidth_equation.alias("bw")) + .where( + Recordings.camera == camera, + Recordings.segment_size > 0, + Recordings.stream_type == stream_type, + ) + .order_by(Recordings.start_time.desc()) + .limit(100) + .alias("recent") ) - bandwidth = MAX_CALCULATED_BANDWIDTH - except TypeError: - bandwidth = 0 + + bandwidth += round( + Recordings.select(fn.AVG(SQL("bw"))) + .from_(last_100) + .scalar() + * 3600, + 2, + ) + except TypeError: + pass + + bandwidth = round(bandwidth, 2) + + if bandwidth > MAX_CALCULATED_BANDWIDTH: + logger.warning( + f"{camera} has a bandwidth of {bandwidth} MB/hr which exceeds the expected maximum. This typically indicates an issue with the cameras recordings." + ) + bandwidth = MAX_CALCULATED_BANDWIDTH self.camera_storage_stats[camera]["bandwidth"] = bandwidth logger.debug(f"{camera} has a bandwidth of {bandwidth} MiB/hr.") diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 878b1765ef..400ef7098f 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -2,14 +2,13 @@ from dataclasses import dataclass from datetime import UTC, datetime -from unittest.mock import patch import pytz from fastapi import Request from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.const import MAX_SEGMENT_DURATION -from frigate.models import Recordings +from frigate.models import Event, Recordings from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -73,7 +72,7 @@ class TestHttpMedia(BaseTestHttp): def setUp(self): """Set up test fixtures.""" - super().setUp([Recordings]) + super().setUp([Event, Recordings]) self.app = super().create_app() # Mock get_current_user for all tests @@ -482,13 +481,1226 @@ class TestHttpMedia(BaseTestHttp): assert "2024-03-10" in summary assert summary["2024-03-10"] is True + def _insert_recording( + self, + id: str, + start_time: float, + end_time: float, + stream_type: str = "main", + motion: int = 0, + objects: int = 0, + has_audio: bool | None = None, + audio_rate: int | None = None, + audio_codec: str | None = None, + video_codec: str | None = None, + keyframes: list[int] | None = None, + ) -> None: + """Insert a recording row with an explicit stream type.""" + Recordings.insert( + id=id, + path=f"/media/recordings/{id}.mp4", + camera="front_door", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + motion=motion, + objects=objects, + stream_type=stream_type, + has_audio=has_audio, + audio_rate=audio_rate, + audio_codec=audio_codec, + video_codec=video_codec, + keyframes=keyframes, + ).execute() + + @staticmethod + def _sequence_paths(sequence: dict) -> list[str]: + """Collect a sequence's source clip file paths.""" + return [clip["path"] for clip in sequence["clips"]] + + def test_coverage_timelines_match_vod_mapping_durations(self): + """The realized timelines equal the vod mapping's durations exactly. + + This is the anti-drift invariant: the frontend maps playhead to + wall clock by walking the coverage timelines, so any clip the + manifest realizes differently (keyframe back-snap lead-ins, + whole-file fallbacks, skipped clips) would reappear as playhead + drift. The mid-file sub resume at 1020 exercises the snap + lead-in path through its stored keyframe index, while sub_3 + (no stored index) exercises the whole-file fallback. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main", keyframes=[0]) + self._insert_recording("main_2", 1010, 1020, "main", keyframes=[0]) + self._insert_recording("sub_1", 1003, 1013, "sub", keyframes=[0]) + self._insert_recording( + "sub_2", 1013, 1023, "sub", keyframes=[0, 3000, 6000, 9000] + ) + self._insert_recording("sub_3", 1023, 1033, "sub") + + coverage = client.get( + "/front_door/recordings/coverage", + params={"after": 1000, "before": 1033, "timelines": True}, + ).json() + + for variant, route in ( + ("auto", "/vod/front_door/start/1000/end/1033"), + ("main", "/vod/front_door/main/start/1000/end/1033"), + ("sub", "/vod/front_door/sub/start/1000/end/1033"), + ): + with self.subTest(variant=variant): + mapping = client.get(route).json() + realized = [ + t["duration"] + for t in coverage["timelines"][variant] + if t["duration"] > 0 + ] + assert realized == mapping["durations"] + + # the [1020,1023) span (7000ms into sub_2) snaps back to the + # stored 6000ms keyframe, serving 4000ms for a 3000ms span + auto = coverage["timelines"]["auto"] + resume = next(t for t in auto if t["start_time"] == 1020) + assert resume["end_time"] == 1023 + assert resume["duration"] == 4000 + + def test_vod_dual_coverage_serves_merged_single_sequence(self): + """Full dual coverage on the default route is a merged main-preferred + single sequence. In-manifest ABR was removed deliberately (see + config/superpowers/specs/2026-06-11-sub-stream-recording-playback-issues.md).""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("main_2", 1010, 1020, "main") + self._insert_recording("sub_1", 1000, 1010, "sub") + self._insert_recording("sub_2", 1010, 1020, "sub") + + response = client.get("/vod/front_door/start/1000/end/1020") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert body["consistentSequenceMediaInfo"] is True + assert body["durations"] == [10000, 10000] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + ] + assert body["segment_duration"] == 10000 + + def test_vod_merges_intervals_split_by_other_stream_boundaries(self): + """A recording spanning several coverage intervals stays one clip. + + Coverage intervals split at BOTH streams' file edges, so a main row + is routinely cut by sub boundaries it has nothing to do with; the + span builder must merge those cuts back into a single clip. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("sub_1", 1000, 1004, "sub") + self._insert_recording("sub_2", 1004, 1010, "sub") + + # default route: one main clip despite the sub edge at 1004 + response = client.get("/vod/front_door/start/1000/end/1010") + assert response.status_code == 200 + body = response.json() + assert body["durations"] == [10000] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4" + ] + + # pinned sub: two clips (two real files), no main leakage + response = client.get("/vod/front_door/sub/start/1000/end/1010") + assert response.status_code == 200 + body = response.json() + assert body["durations"] == [4000, 6000] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/sub_1.mp4", + "/media/recordings/sub_2.mp4", + ] + + def test_vod_sub_minimum_interval_overlaps_no_clipfrom(self): + """Overlaps below the resolver's minimum interval produce no clipFrom. + + Millisecond-scale overlaps produce coverage intervals too short to + survive, leaving the previous span ending exactly at the next + row's start. The hand-off must still fire there, or every clip + gets a no-op mid-file entry that repeats no content but still + snaps. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("sub_1", 1000, 1010.001, "sub", keyframes=[0]) + self._insert_recording("sub_2", 1010, 1020.0004, "sub", keyframes=[0]) + self._insert_recording("sub_3", 1020, 1029.997, "sub", keyframes=[0]) + + response = client.get("/vod/front_door/sub/start/1000/end/1029.997") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert len(clips) == 3 + for clip in clips: + assert "clipFrom" not in clip + + def test_vod_overlapping_rows_no_clipfrom(self): + """Systematic sliver overlaps between adjacent rows produce no clipFrom. + + Recording start times are integer-truncated while end times are + fractional, so nearly every adjacent same-stream row pair overlaps + by tens to hundreds of ms. The span builder hands the overlap to + the later row (end-trimming the earlier clip), so no clip starts + mid-file and no content repeats. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010.5, "main", keyframes=[0]) + self._insert_recording("main_2", 1010, 1020.4, "main", keyframes=[0]) + self._insert_recording("main_3", 1020, 1030.3, "main", keyframes=[0]) + self._insert_recording("main_4", 1030, 1040.2, "main", keyframes=[0]) + + response = client.get("/vod/front_door/start/1000/end/1040.2") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/main_3.mp4", + "/media/recordings/main_4.mp4", + ] + for clip in clips: + assert "clipFrom" not in clip + # 1000 -> 1040.2 with no repeated content; each end-trim + # truncates at most 1ms via int() + assert abs(sum(body["durations"]) - 40200) <= 4 + + def test_vod_request_boundary_keeps_clipfrom(self): + """A request starting mid-file keeps the legitimate boundary clipFrom. + + Only the FIRST clip starts at the request-clamped boundary; the + overlap hand-off must not disturb it, and every later clip still + starts at its own file start with no keyframe probe. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", 1000, 1010.5, "main", keyframes=[0, 2000, 4000] + ) + self._insert_recording("main_2", 1010, 1020.4, "main", keyframes=[0]) + self._insert_recording("main_3", 1020, 1030.3, "main", keyframes=[0]) + self._insert_recording("main_4", 1030, 1040.2, "main", keyframes=[0]) + + response = client.get("/vod/front_door/start/1005/end/1040") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + # the 5000ms boundary inpoint snaps back to the stored 4000ms + # keyframe + assert clips[0]["clipFrom"] == 4000 + for clip in clips[1:]: + assert "clipFrom" not in clip + + def test_vod_large_overlap_handed_to_later_row(self): + """A multi-second overlap (double-recorder incident) is handed off too. + + The earlier clip is end-trimmed back to the later row's start, so + the later clip plays its full file with no clipFrom keyframe probe + and no content repeats. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010.0, "main", keyframes=[0]) + self._insert_recording("main_2", 1005, 1015.0, "main", keyframes=[0]) + + response = client.get("/vod/front_door/start/1000/end/1015") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + ] + for clip in clips: + assert "clipFrom" not in clip + # the first clip is end-trimmed to the second row's start + assert body["durations"] == [5000, 10000] + assert sum(body["durations"]) == 15000 + + def test_vod_cross_stream_resume_keeps_clipfrom(self): + """A sub row resuming after a mid-file main burst keeps clipFrom. + + On the merged route, main is preferred mid-window for an event + burst; the sub row that resumes afterwards started long before the + hand-back point, so its clip must start mid-file with the keyframe + snap to stay decodable. The overlap hand-off only applies to + same-stream overlaps and must not trim the main burst. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("sub_1", 1000, 1010, "sub", keyframes=[0]) + self._insert_recording("sub_2", 1010, 1020, "sub", keyframes=[0, 500]) + self._insert_recording("sub_3", 1020, 1030, "sub", keyframes=[0]) + self._insert_recording("main_1", 1008, 1018, "main", keyframes=[0]) + + response = client.get("/vod/front_door/start/1000/end/1030") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/sub_1.mp4", + "/media/recordings/main_1.mp4", + "/media/recordings/sub_2.mp4", + "/media/recordings/sub_3.mp4", + ] + # only the resumed sub clip starts mid-file + assert "clipFrom" not in clips[0] + assert "clipFrom" not in clips[1] + assert "clipFrom" in clips[2] + assert "clipFrom" not in clips[3] + + def test_vod_single_sequence_sub_only(self): + """A sub-only range serves a single sequence of sub clips.""" + with AuthTestClient(self.app) as client: + self._insert_recording("sub_1", 1000, 1010, "sub") + self._insert_recording("sub_2", 1010, 1020, "sub") + + response = client.get("/vod/front_door/start/1000/end/1020") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert body["consistentSequenceMediaInfo"] is True + assert body["durations"] == [10000, 10000] + assert [c["path"] for c in body["sequences"][0]["clips"]] == [ + "/media/recordings/sub_1.mp4", + "/media/recordings/sub_2.mp4", + ] + + def test_vod_single_main_unchanged(self): + """Main-only recordings (the pre-feature case) keep the legacy contract.""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("main_2", 1010, 1020, "main") + + response = client.get("/vod/front_door/start/1000/end/1020") + + assert response.status_code == 200 + body = response.json() + assert body["cache"] is True + assert body["discontinuity"] is False + assert "initialClipIndex" not in body + assert body["consistentSequenceMediaInfo"] is True + assert body["durations"] == [10000, 10000] + assert body["segment_duration"] == 10000 + assert len(body["sequences"]) == 1 + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + ] + for clip in clips: + assert clip["type"] == "source" + assert clip["keyFrameDurations"] == [10000] + assert "clipFrom" not in clip + + def test_vod_no_recordings_returns_404(self): + """No recordings in range preserves the legacy 404 response.""" + with AuthTestClient(self.app) as client: + response = client.get("/vod/front_door/start/1000/end/1020") + + assert response.status_code == 404 + body = response.json() + assert body["success"] is False + assert body["message"] == "No recordings found." + + def test_vod_clip_route_always_single_sequence(self): + """The tracking-details clip route stays single-sequence with dual coverage. + + The explore player reads sequences[0].clips[0].clipFrom to correct its + timeline, so /vod/clip relies on the single-sequence keyframe back-snap + path. With an offset start the keyframe probe runs and fails (paths + don't exist on disk), which strips clipFrom and falls back to the full + recording - exactly the legacy behavior the frontend reasons about. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("main_2", 1010, 1020, "main") + self._insert_recording("sub_1", 1000, 1010, "sub") + self._insert_recording("sub_2", 1010, 1020, "sub") + + response = client.get("/vod/clip/front_door/start/1003/end/1020") + + assert response.status_code == 200 + body = response.json() + assert body["discontinuity"] is True + # uniform single-stream clips: forced discontinuity alone must + # not switch to clip-indexed naming / per-clip init segments + assert "initialClipIndex" not in body + assert len(body["sequences"]) == 1 + assert body["consistentSequenceMediaInfo"] is True + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + ] + # the failed keyframe probe removed clipFrom and restored the + # full recording duration (legacy single-sequence fallback) + assert "clipFrom" not in clips[0] + assert body["durations"] == [10000, 10000] + + def test_vod_merged_fallback_mixed_composition_video_only(self): + """The merged sequence goes video-only when rows mix compositions. + + A long sub-only stretch means the merged sequence mixes muxed + main files and audio-less sub files. Track-PRESENCE mixing + across discontinuities is unproven in MSE, so every clip is + stripped to video tracks rather than the tracks being carried + through the discontinuity the stream mix already forces. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main", has_audio=True) + self._insert_recording("main_2", 1010, 1020, "main", has_audio=True) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", 1000 + i * 10, 1010 + i * 10, "sub", has_audio=False + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert body["consistentSequenceMediaInfo"] is True + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert clip["tracks"] == "v" + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_fallback_glitch_row_skipped(self): + """The merged sequence skips video-only glitch rows entirely. + + A truncated shutdown segment with no audio on an otherwise + audio-bearing stream is served by neither stream; the timeline + compresses over it like any recording gap because a small hole + beats a manifest nginx-vod rejects for track count mismatch. + """ + with AuthTestClient(self.app) as client: + # main covers 1000-1050 plus a sub-second video-only glitch; + # a 40s sub outage leaves main serving most of the range + for i in range(5): + self._insert_recording( + f"main_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "main", + has_audio=True, + ) + self._insert_recording("main_glitch", 1050, 1050.4, "main", has_audio=False) + self._insert_recording("sub_1", 1000, 1010, "sub", has_audio=True) + + response = client.get("/vod/front_door/start/1000/end/1051") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + f"/media/recordings/main_{i + 1}.mp4" for i in range(5) + ] + # surviving rows are uniformly muxed: nothing is stripped + for clip in clips: + assert "tracks" not in clip + + def test_vod_merged_fallback_uniform_audio_no_tracks(self): + """The merged sequence leaves uniform-composition clips untouched.""" + with AuthTestClient(self.app) as client: + for i in range(5): + self._insert_recording( + f"main_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "main", + has_audio=True, + ) + self._insert_recording("sub_1", 1000, 1010, "sub", has_audio=True) + + response = client.get("/vod/front_door/start/1000/end/1050") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + f"/media/recordings/main_{i + 1}.mp4" for i in range(5) + ] + for clip in clips: + assert "tracks" not in clip + + def test_vod_merged_mismatched_audio_rates_discontinuity(self): + """Mixed audio sample rates serve a discontinuity manifest with audio. + + Clips whose AAC sample rates differ (main 16kHz, sub 8kHz) need a + fresh decoder config at each transition; discontinuity mode with + per-clip init segments (initialClipIndex) lets the decoder + reconfigure there, so audio is kept instead of stripped. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", 1000, 1010, "main", has_audio=True, audio_rate=16000 + ) + self._insert_recording( + "main_2", 1010, 1020, "main", has_audio=True, audio_rate=16000 + ) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + has_audio=True, + audio_rate=8000, + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_matching_audio_rates_keep_audio(self): + """Matching audio parameters across streams keep audio intact. + + When main and sub both carry 16kHz audio no clip is stripped; + the manifest still runs in discontinuity mode because the two + encoders differ in SPS/PPS regardless of matching parameters. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", 1000, 1010, "main", has_audio=True, audio_rate=16000 + ) + self._insert_recording( + "main_2", 1010, 1020, "main", has_audio=True, audio_rate=16000 + ) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + has_audio=True, + audio_rate=16000, + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_known_and_unknown_rate_keeps_legacy(self): + """Fully-unknown audio params next to a known rate stay legacy. + + One stream's rate is known, the other's audio params are entirely + NULL (legacy rows probed before the columns existed); rows with + no known params contribute no signature, so audio survives + instead of being stripped for a parameter mismatch. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", 1000, 1010, "main", has_audio=True, audio_rate=16000 + ) + self._insert_recording( + "main_2", 1010, 1020, "main", has_audio=True, audio_rate=16000 + ) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + has_audio=True, + audio_rate=None, + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_mismatched_audio_codecs_discontinuity(self): + """Mixed audio codecs serve a discontinuity manifest with audio. + + Clips whose audio codecs differ (main AAC, sub G.711 a-law) need + a fresh decoder config at each transition even with matching + sample rates; per-clip init segments provide it. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", + 1000, + 1010, + "main", + has_audio=True, + audio_rate=8000, + audio_codec="aac", + ) + self._insert_recording( + "main_2", + 1010, + 1020, + "main", + has_audio=True, + audio_rate=8000, + audio_codec="aac", + ) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + has_audio=True, + audio_rate=8000, + audio_codec="pcm_alaw", + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_matching_audio_codecs_keep_audio(self): + """Matching audio codec and rate across streams keep audio intact.""" + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", + 1000, + 1010, + "main", + has_audio=True, + audio_rate=16000, + audio_codec="aac", + ) + self._insert_recording( + "main_2", + 1010, + 1020, + "main", + has_audio=True, + audio_rate=16000, + audio_codec="aac", + ) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + has_audio=True, + audio_rate=16000, + audio_codec="aac", + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_known_and_unknown_audio_codec_discontinuity(self): + """A partially-known audio signature next to a known one differs. + + One stream's audio codec is known, the other's is NULL with a + known rate; both rows carry a (partially) known signature and the + signatures differ, so the manifest plays through discontinuity + mode rather than risking a mid-sequence decoder mismatch. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "main_1", + 1000, + 1010, + "main", + has_audio=True, + audio_rate=16000, + audio_codec="aac", + ) + self._insert_recording( + "main_2", + 1010, + 1020, + "main", + has_audio=True, + audio_rate=16000, + audio_codec="aac", + ) + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + has_audio=True, + audio_rate=16000, + audio_codec=None, + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_all_unknown_audio_keeps_audio(self): + """Uniformly-unknown rows (the pre-feature case) are never stripped. + + Legacy rows have has_audio NULL and audio_rate NULL; they share a + single signature, so no audio policy fires and audio plays. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("main_2", 1010, 1020, "main") + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", 1000 + i * 10, 1010 + i * 10, "sub" + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + clips = body["sequences"][0]["clips"] + assert [c["path"] for c in clips] == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + for clip in clips: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_pinned_sub_serves_only_sub(self): + """A pinned manifest contains only the pinned stream's recordings. + + Time covered only by the other stream is omitted entirely; the + timeline compresses there like any recording gap. + """ + with AuthTestClient(self.app) as client: + # sub covers 0-10 and 20-30; main covers everything including + # the 10-20 hole + self._insert_recording("sub_1", 1000, 1010, "sub") + self._insert_recording("sub_2", 1020, 1030, "sub") + for i in range(3): + self._insert_recording( + f"main_{i + 1}", 1000 + i * 10, 1010 + i * 10, "main" + ) + + response = client.get("/vod/front_door/sub/start/1000/end/1030") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert body["consistentSequenceMediaInfo"] is True + # the main-only 10s hole is omitted, not filled + assert body["durations"] == [10000, 10000] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/sub_1.mp4", + "/media/recordings/sub_2.mp4", + ] + + def test_vod_pinned_main_serves_only_main(self): + """Pinned main on event-style history serves only the main bursts.""" + with AuthTestClient(self.app) as client: + # continuous sub, main only for one 10s burst in the middle + for i in range(3): + self._insert_recording( + f"sub_{i + 1}", 1000 + i * 10, 1010 + i * 10, "sub" + ) + self._insert_recording("main_1", 1010, 1020, "main") + + response = client.get("/vod/front_door/main/start/1000/end/1030") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert body["durations"] == [10000] + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4" + ] + + def test_vod_pinned_stream_with_no_rows_returns_404(self): + """Pinning a stream with no recordings in range is a 404, not a fill.""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + + response = client.get("/vod/front_door/sub/start/1000/end/1010") + + assert response.status_code == 404 + + def test_vod_pinned_sub_glitch_row_skipped(self): + """A video-only glitch row on an audio-bearing pinned stream is omitted. + + Serving it would change the track count mid-sequence and break + audio decode in players; a sub-second hole is the lesser evil. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("sub_1", 1000, 1010, "sub", has_audio=True) + self._insert_recording("sub_glitch", 1010, 1011, "sub", has_audio=False) + self._insert_recording("sub_3", 1011, 1021, "sub", has_audio=True) + + response = client.get("/vod/front_door/sub/start/1000/end/1021") + + assert response.status_code == 200 + body = response.json() + paths = self._sequence_paths(body["sequences"][0]) + assert "/media/recordings/sub_glitch.mp4" not in paths + assert len(paths) == 2 + # no composition mixing remains, so no tracks strip + for clip in body["sequences"][0]["clips"]: + assert "tracks" not in clip + + def test_vod_pinned_invalid_stream_rejected(self): + """An unknown stream segment fails path validation.""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + + response = client.get("/vod/front_door/bogus/start/1000/end/1010") + + assert response.status_code == 422 + + def test_vod_merged_mixed_codecs_discontinuity(self): + """Mismatched video codecs serve a merged discontinuity manifest. + + A merged HEVC main + H264 sub sequence plays through discontinuity + mode with per-clip init segments (initialClipIndex): the decoder + reconfigures at each codec transition instead of the range being + pinned to a pure main manifest. + """ + with AuthTestClient(self.app) as client: + # main covers 0-20, sub covers the full hour-style 0-60 range; + # the merge fills 20-60 with h264 sub clips + self._insert_recording("main_1", 1000, 1010, "main", video_codec="hevc") + self._insert_recording("main_2", 1010, 1020, "main", video_codec="hevc") + for i in range(6): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + video_codec="h264", + ) + + response = client.get("/vod/front_door/start/1000/end/1060") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4", + "/media/recordings/main_2.mp4", + "/media/recordings/sub_3.mp4", + "/media/recordings/sub_4.mp4", + "/media/recordings/sub_5.mp4", + "/media/recordings/sub_6.mp4", + ] + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_mixed_codecs_sub_only_range_serves_sub(self): + """A mixed-codec camera's sub-only history serves a plain sub manifest. + + Rows outside the requested range never influence the policy: the + range itself is uniformly h264 sub, so the manifest keeps the + legacy shape with no discontinuity. + """ + with AuthTestClient(self.app) as client: + # mixed-codec camera: hevc main exists before the range, + # the requested range is covered only by h264 sub rows + self._insert_recording("main_1", 1000, 1010, "main", video_codec="hevc") + self._insert_recording("sub_1", 1030, 1040, "sub", video_codec="h264") + self._insert_recording("sub_2", 1040, 1050, "sub", video_codec="h264") + + response = client.get("/vod/front_door/start/1030/end/1050") + + assert response.status_code == 200 + body = response.json() + assert len(body["sequences"]) == 1 + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/sub_1.mp4", + "/media/recordings/sub_2.mp4", + ] + assert body["discontinuity"] is False + assert "initialClipIndex" not in body + + def test_vod_merged_same_codec_keeps_merging(self): + """Matching known codecs across streams keep the merged fill. + + Isolates the stream-type trigger: with codec and audio params + identical on both sides, the main/sub mix is the only remaining + signature, and it alone puts the manifest in discontinuity mode. + """ + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main", video_codec="h264") + for i in range(2): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + video_codec="h264", + ) + + response = client.get("/vod/front_door/start/1000/end/1020") + + assert response.status_code == 200 + body = response.json() + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4", + "/media/recordings/sub_2.mp4", + ] + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_merged_known_and_unknown_codec_keeps_merging(self): + """An unknown codec next to a known one is tolerated (legacy rows).""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main", video_codec="h264") + for i in range(2): + self._insert_recording( + f"sub_{i + 1}", + 1000 + i * 10, + 1010 + i * 10, + "sub", + video_codec=None, + ) + + response = client.get("/vod/front_door/start/1000/end/1020") + + assert response.status_code == 200 + body = response.json() + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/main_1.mp4", + "/media/recordings/sub_2.mp4", + ] + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_vod_pinned_stream_mixed_rates_discontinuity(self): + """A pinned stream mixing audio rates across history gets discontinuity. + + A camera settings change can leave one stream with 8kHz rows + followed by 16kHz rows, so pinned routes need the same signature + policy as the default route. + """ + with AuthTestClient(self.app) as client: + self._insert_recording( + "sub_1", 1000, 1010, "sub", has_audio=True, audio_rate=8000 + ) + self._insert_recording( + "sub_2", 1010, 1020, "sub", has_audio=True, audio_rate=16000 + ) + + response = client.get("/vod/front_door/sub/start/1000/end/1020") + + assert response.status_code == 200 + body = response.json() + assert self._sequence_paths(body["sequences"][0]) == [ + "/media/recordings/sub_1.mp4", + "/media/recordings/sub_2.mp4", + ] + for clip in body["sequences"][0]["clips"]: + assert "tracks" not in clip + assert body["discontinuity"] is True + assert body["initialClipIndex"] == 1 + + def test_recordings_coverage_merged_spans(self): + """Coverage returns merged spans with per-span stream availability.""" + with AuthTestClient(self.app) as client: + # main-only, then both streams, then sub-only + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("main_2", 1010, 1020, "main") + self._insert_recording("sub_1", 1010, 1020, "sub") + self._insert_recording("sub_2", 1020, 1030, "sub") + + response = client.get( + "/front_door/recordings/coverage", + params={"after": 1000, "before": 1030}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["spans"] == [ + {"start_time": 1000, "end_time": 1010, "streams": ["main"]}, + {"start_time": 1010, "end_time": 1020, "streams": ["main", "sub"]}, + {"start_time": 1020, "end_time": 1030, "streams": ["sub"]}, + ] + # all rows have unknown codecs, which counts as compatible + assert body["codecs_compatible"] is True + + def test_recordings_coverage_codec_compatibility_flag(self): + """codecs_compatible is false only when known video codecs differ.""" + cases = [ + ("hevc", "h264", False), + ("h264", "h264", True), + ("h264", None, True), + ] + with AuthTestClient(self.app) as client: + # each case in its own time window so rows don't cross-pollute + for idx, (main_codec, sub_codec, compatible) in enumerate(cases): + with self.subTest(main=main_codec, sub=sub_codec): + base = 1000 + idx * 100 + self._insert_recording( + f"main_{idx}", + base, + base + 10, + "main", + video_codec=main_codec, + ) + self._insert_recording( + f"sub_{idx}", + base + 10, + base + 20, + "sub", + video_codec=sub_codec, + ) + + response = client.get( + "/front_door/recordings/coverage", + params={"after": base, "before": base + 20}, + ) + + assert response.status_code == 200 + assert response.json()["codecs_compatible"] is compatible + + def test_recordings_coverage_stream_media_summary(self): + """Coverage reports per-stream media details, newest known value per field.""" + with AuthTestClient(self.app) as client: + # older main row knows the codecs; the newer row's NULL codecs + # must not mask them, while the newer row's audio fields win + self._insert_recording( + "main_1", + 1000, + 1010, + "main", + video_codec="hevc", + audio_rate=16000, + audio_codec="aac", + has_audio=True, + ) + self._insert_recording( + "main_2", + 1010, + 1020, + "main", + video_codec=None, + audio_rate=8000, + has_audio=True, + ) + self._insert_recording("sub_1", 1000, 1010, "sub", video_codec="h264") + + response = client.get( + "/front_door/recordings/coverage", + params={"after": 1000, "before": 1020}, + ) + + assert response.status_code == 200 + streams = response.json()["streams"] + assert streams == { + "main": { + "video_codec": "hevc", + "audio_rate": 8000, + "audio_codec": "aac", + "has_audio": True, + "bitrate": None, + }, + # sub's audio fields were never known + "sub": { + "video_codec": "h264", + "audio_rate": None, + "audio_codec": None, + "has_audio": None, + "bitrate": None, + }, + } + + def test_recordings_coverage_stream_media_summary_omits_absent_stream(self): + """A stream with no rows in range is omitted from the summary.""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main", video_codec="h264") + + response = client.get( + "/front_door/recordings/coverage", + params={"after": 1000, "before": 1010}, + ) + + assert response.status_code == 200 + streams = response.json()["streams"] + assert list(streams.keys()) == ["main"] + + def test_recordings_summary_sub_only_hours(self): + """Hours with only sub recordings are flagged and main stats not double-counted.""" + hour_a = datetime(2024, 3, 10, 12, 0, 0, tzinfo=UTC).timestamp() + hour_b = datetime(2024, 3, 10, 13, 0, 0, tzinfo=UTC).timestamp() + + with AuthTestClient(self.app) as client: + # hour A has both streams (sub duplicates main's stats), + # hour B has only sub recordings + self._insert_recording( + "main_a", hour_a, hour_a + 600, "main", motion=100, objects=5 + ) + self._insert_recording( + "sub_a", hour_a, hour_a + 600, "sub", motion=100, objects=5 + ) + self._insert_recording( + "sub_b", hour_b, hour_b + 300, "sub", motion=50, objects=2 + ) + + response = client.get( + "/front_door/recordings/summary", params={"timezone": "utc"} + ) + + assert response.status_code == 200 + summary = response.json() + assert len(summary) == 1 + day = summary[0] + assert day["day"] == "2024-03-10" + assert len(day["hours"]) == 2 + + # hours are ordered most recent first + sub_only_hour = day["hours"][0] + assert sub_only_hour == { + "hour": "13", + "events": 0, + "motion": 0, + "objects": 0, + "duration": 300, + "sub_only": True, + } + + main_hour = day["hours"][1] + assert "sub_only" not in main_hour + assert main_hour["hour"] == "12" + # sub rows duplicate main stats and must not be double-counted + assert main_hour["motion"] == 100 + assert main_hour["objects"] == 5 + assert main_hour["duration"] == 600 + + def test_recordings_unavailable_sub_covers_main_gap(self): + """A gap in main recordings covered by sub rows is not reported unavailable.""" + with AuthTestClient(self.app) as client: + self._insert_recording("main_1", 1000, 1010, "main") + self._insert_recording("sub_1", 1010, 1030, "sub") + self._insert_recording("main_2", 1030, 1040, "main") + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1040, + "scale": 5, + "cameras": "front_door", + }, + ) + + assert response.status_code == 200 + assert response.json() == [] + def test_recordings_handles_all_range_relations(self): """Recordings return every interval relation that touches the range.""" with AuthTestClient(self.app) as client: for case in RANGE_CASES: with self.subTest(case=case.name): Recordings.delete().execute() - super().insert_mock_recording( + self._insert_recording( case.name, REQUEST_START + case.start_offset, REQUEST_START + case.end_offset, @@ -507,20 +1719,22 @@ class TestHttpMedia(BaseTestHttp): def test_vod_handles_all_range_relations(self): """VOD clips every interval relation with positive playback duration.""" - with ( - AuthTestClient(self.app) as client, - patch( - "frigate.api.media.get_keyframe_before", - side_effect=lambda _path, offset: offset, - ), - ): + with AuthTestClient(self.app) as client: for case in RANGE_CASES: with self.subTest(case=case.name): Recordings.delete().execute() - super().insert_mock_recording( + # a stored keyframe sitting exactly on the expected + # clipFrom makes the back-snap a no-op, isolating range + # handling from keyframe snapping + self._insert_recording( case.name, REQUEST_START + case.start_offset, REQUEST_START + case.end_offset, + keyframes=( + None + if case.vod_clip_from_ms is None + else [case.vod_clip_from_ms] + ), ) response = client.get( @@ -535,49 +1749,13 @@ class TestHttpMedia(BaseTestHttp): response, [ ( - case.name, + f"/media/recordings/{case.name}.mp4", case.vod_clip_from_ms, case.vod_duration_ms, ) ], ) - def test_vod_handles_segment_ending_at_start_with_keyframe_fallbacks(self): - """VOD keeps a boundary segment when keyframe lookup extends it.""" - - def keyframe_before(path: str, offset: int) -> int | None: - return offset - 1000 if path == "previous_keyframe" else None - - with ( - AuthTestClient(self.app) as client, - patch( - "frigate.api.media.get_keyframe_before", - side_effect=keyframe_before, - ), - ): - super().insert_mock_recording( - "previous_keyframe", - REQUEST_START - 10, - REQUEST_START, - ) - super().insert_mock_recording( - "missing_keyframe", - REQUEST_START - 5, - REQUEST_START, - ) - - response = client.get( - f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}" - ) - - self._assert_vod_response( - response, - [ - ("previous_keyframe", 9000, 1000), - ("missing_keyframe", None, 5000), - ], - ) - def test_recordings_unavailable_reports_gap_between_recordings(self): """A gap between two recordings is reported as an unavailable segment.""" with AuthTestClient(self.app) as client: diff --git a/frigate/test/test_camera_config_updater.py b/frigate/test/test_camera_config_updater.py new file mode 100644 index 0000000000..1fbf03f0e5 --- /dev/null +++ b/frigate/test/test_camera_config_updater.py @@ -0,0 +1,102 @@ +"""Tests for dynamic camera config updates recreating ffmpeg commands.""" + +import unittest +from unittest.mock import patch + +from frigate.config import CameraConfig, FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) +from frigate.const import SUB_CACHE_TAG + + +def _build_camera_config(sub_enabled: bool) -> CameraConfig: + config = FrigateConfig( + **{ + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect", "record"], + }, + { + "path": "rtsp://10.0.0.1:554/video2", + "roles": ["record_sub"], + }, + ] + }, + "record": {"enabled": True, "sub": {"enabled": sub_enabled}}, + } + }, + } + ) + return config.cameras["front_door"] + + +def _has_sub_output(camera_config: CameraConfig) -> bool: + return any( + SUB_CACHE_TAG in part for c in camera_config.ffmpeg_cmds for part in c["cmd"] + ) + + +class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase): + def setUp(self): + # avoid binding a real ZMQ socket; updates are fed directly through + # the mocked subscriber below + patcher = patch("frigate.config.camera.updater.ConfigSubscriber") + patcher.start() + self.addCleanup(patcher.stop) + + def _push_record_update( + self, subscriber: CameraConfigUpdateSubscriber, record_config + ) -> None: + subscriber.subscriber.check_for_update.side_effect = [ + ("config/cameras/front_door/record", record_config), + (None, None), + ] + subscriber.check_for_updates() + + def test_enabling_sub_recreates_ffmpeg_cmds(self): + camera_config = _build_camera_config(sub_enabled=False) + subscriber = CameraConfigUpdateSubscriber( + None, {"front_door": camera_config}, [CameraConfigUpdateEnum.record] + ) + assert not _has_sub_output(camera_config) + + self._push_record_update( + subscriber, _build_camera_config(sub_enabled=True).record + ) + + assert _has_sub_output(camera_config) + + def test_disabling_sub_recreates_ffmpeg_cmds(self): + camera_config = _build_camera_config(sub_enabled=True) + subscriber = CameraConfigUpdateSubscriber( + None, {"front_door": camera_config}, [CameraConfigUpdateEnum.record] + ) + assert _has_sub_output(camera_config) + + self._push_record_update( + subscriber, _build_camera_config(sub_enabled=False).record + ) + + assert not _has_sub_output(camera_config) + + def test_unchanged_record_update_keeps_existing_cmds(self): + camera_config = _build_camera_config(sub_enabled=False) + subscriber = CameraConfigUpdateSubscriber( + None, {"front_door": camera_config}, [CameraConfigUpdateEnum.record] + ) + cmds_before = camera_config.ffmpeg_cmds + + # neither enabled_in_config nor sub.enabled changed, so the + # commands should not be rebuilt + self._push_record_update( + subscriber, _build_camera_config(sub_enabled=False).record + ) + + assert camera_config.ffmpeg_cmds is cmds_before diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index fe3017d9b2..31e283eae2 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -1,13 +1,14 @@ import json import os import unittest +from copy import deepcopy from unittest.mock import patch import numpy as np from pydantic import ValidationError from ruamel.yaml.constructor import DuplicateKeyError -from frigate.config import FrigateConfig +from frigate.config import FrigateConfig, RetainModeEnum from frigate.const import MODEL_CACHE_DIR from frigate.detectors import DetectorTypeEnum from frigate.util.builtin import deep_merge @@ -968,6 +969,162 @@ class TestConfig(unittest.TestCase): assert len(ffmpeg_cmds) == 1 assert "clips" not in ffmpeg_cmds[0]["roles"] + def test_record_sub_cmd_writes_sub_cache_path(self): + config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect", "record"], + }, + { + "path": "rtsp://10.0.0.1:554/video2", + "roles": ["record_sub"], + }, + ] + }, + "record": {"enabled": True, "sub": {"enabled": True}}, + } + }, + } + + frigate_config = FrigateConfig(**config) + cmds = frigate_config.cameras["back"].ffmpeg_cmds + sub_cmds = [c for c in cmds if "record_sub" in c["roles"]] + assert len(sub_cmds) == 1 + joined = " ".join(sub_cmds[0]["cmd"]) + assert "back@sub@" in joined + + def test_record_sub_disabled_no_sub_cache_path(self): + config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect", "record"], + }, + ] + }, + "record": {"enabled": True, "sub": {"enabled": False}}, + } + }, + } + + frigate_config = FrigateConfig(**config) + cmds = frigate_config.cameras["back"].ffmpeg_cmds + assert all("@sub@" not in " ".join(c["cmd"]) for c in cmds) + + def _sub_record_config(self, ffmpeg_extra: dict | None = None) -> dict: + return { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect", "record"], + }, + { + "path": "rtsp://10.0.0.1:554/video2", + "roles": ["record_sub"], + }, + ], + **(ffmpeg_extra or {}), + }, + "record": {"enabled": True, "sub": {"enabled": True}}, + } + }, + } + + def _sub_record_cmd(self, config: dict) -> str: + cmds = FrigateConfig(**config).cameras["back"].ffmpeg_cmds + sub_cmds = [c for c in cmds if "record_sub" in c["roles"]] + assert len(sub_cmds) == 1 + return " ".join(sub_cmds[0]["cmd"]) + + def test_record_sub_output_args_inherit_record(self): + config = self._sub_record_config( + {"output_args": {"record": "preset-record-generic-audio-copy"}} + ) + + cmd = self._sub_record_cmd(config) + # the customized record args, not the stock aac default + assert "-c copy" in cmd + assert "-c:a aac" not in cmd + + def test_record_sub_output_args_override_record(self): + config = self._sub_record_config( + { + "output_args": { + "record": "preset-record-generic-audio-aac", + "record_sub": "preset-record-generic", + } + } + ) + + cmd = self._sub_record_cmd(config) + assert "-c copy -an" in cmd + assert "-c:a aac" not in cmd + + def test_record_output_args_unaffected_by_record_sub(self): + config = self._sub_record_config( + { + "output_args": { + "record": "preset-record-generic-audio-aac", + "record_sub": "preset-record-generic", + } + } + ) + + cmds = FrigateConfig(**config).cameras["back"].ffmpeg_cmds + record_cmd = " ".join(next(c for c in cmds if "record" in c["roles"])["cmd"]) + assert "-c:a aac" in record_cmd + + def test_record_sub_manual_output_args(self): + config = self._sub_record_config( + { + "output_args": { + "record_sub": "-f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c:v copy -c:a aac -ar 16000" + } + } + ) + + assert "-ar 16000" in self._sub_record_cmd(config) + + def test_fails_on_bad_record_sub_segment_time(self): + config = self._sub_record_config( + { + "output_args": { + "record_sub": "-f segment -segment_time 70 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an" + } + } + ) + + self.assertRaisesRegex( + ValueError, + "segment_time", + lambda: FrigateConfig(**config).cameras, + ) + + def test_record_sub_segment_time_not_checked_when_disabled(self): + config = self._sub_record_config( + { + "output_args": { + "record_sub": "-f segment -segment_time 70 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an" + } + } + ) + config["cameras"]["back"]["record"]["sub"]["enabled"] = False + + FrigateConfig(**config).cameras + def test_max_disappeared_default(self): config = { "mqtt": {"host": "mqtt"}, @@ -1210,6 +1367,44 @@ class TestConfig(unittest.TestCase): self.assertRaises(ValueError, lambda: FrigateConfig(**config)) + def test_record_sub_config_defaults(self): + config = FrigateConfig(**self.minimal) + record = config.cameras["back"].record + assert record.sub.enabled is False + assert record.sub.continuous.days == 0 + assert record.sub.alerts.mode == RetainModeEnum.motion + + def test_record_sub_enabled_requires_role(self): + config = deepcopy(self.minimal) + config["cameras"]["back"]["ffmpeg"]["inputs"] = [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record"]}, + ] + config["cameras"]["back"]["record"] = { + "enabled": True, + "sub": {"enabled": True}, + } + + # no record_sub role assigned -> must raise + self.assertRaisesRegex( + ValueError, + "record_sub is not assigned", + lambda: FrigateConfig(**config), + ) + + def test_record_sub_role_accepted(self): + config = deepcopy(self.minimal) + config["cameras"]["back"]["ffmpeg"]["inputs"] = [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record"]}, + {"path": "rtsp://10.0.0.1:554/video2", "roles": ["record_sub"]}, + ] + config["cameras"]["back"]["record"] = { + "enabled": True, + "sub": {"enabled": True, "continuous": {"days": 30}}, + } + + parsed = FrigateConfig(**config) + assert parsed.cameras["back"].record.sub.continuous.days == 30 + def test_works_on_missing_role_multiple_cams(self): config = { "mqtt": {"host": "mqtt"}, diff --git a/frigate/test/test_event_cleanup_sub.py b/frigate/test/test_event_cleanup_sub.py new file mode 100644 index 0000000000..1f613eddbb --- /dev/null +++ b/frigate/test/test_event_cleanup_sub.py @@ -0,0 +1,127 @@ +"""Tests for sub stream retention extending event clip lifetimes.""" + +import datetime +import unittest +from unittest.mock import MagicMock + +from playhouse.sqlite_ext import SqliteExtDatabase + +from frigate.config import FrigateConfig +from frigate.events.cleanup import EventCleanup +from frigate.models import Event, Timeline + + +class TestEventCleanupSubRetention(unittest.TestCase): + def setUp(self): + # in-memory database keeps these tests isolated from the shared + # on-disk test.db used by the http api tests + self.db = SqliteExtDatabase(":memory:") + models = [Event, Timeline] + self.db.bind(models) + self.db.create_tables(models) + + def tearDown(self): + self.db.close() + + def _build_cleanup(self, record_config: dict) -> EventCleanup: + config = FrigateConfig( + **{ + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect", "record"], + }, + { + "path": "rtsp://10.0.0.1:554/video2", + "roles": ["record_sub"], + }, + ] + }, + "record": record_config, + } + }, + } + ) + return EventCleanup(config, MagicMock(), MagicMock()) + + def _insert_event(self, id: str, age_days: float, severity: str = "alert") -> None: + end_time = ( + datetime.datetime.now() - datetime.timedelta(days=age_days) + ).timestamp() + Event.create( + id=id, + label="person", + camera="front_door", + start_time=end_time - 10, + end_time=end_time, + top_score=0.9, + score=0.9, + false_positive=False, + zones=[], + thumbnail="", + has_clip=True, + has_snapshot=False, + region=[], + box=[], + area=0, + retain_indefinitely=False, + plus_id="", + model_hash="", + detector_type="cpu", + model_type="ssd", + data={"max_severity": severity}, + ) + + def test_sub_alerts_days_extends_event_clip_retention(self): + # a 20-day-old alert event keeps its clip for the 60 day sub window + # so Explore stays coherent with the surviving sub recordings + cleanup = self._build_cleanup( + { + "enabled": True, + "alerts": {"retain": {"days": 10}}, + "sub": {"enabled": True, "alerts": {"days": 60}}, + } + ) + self._insert_event("e1", 20) + + expired = cleanup.expire_clips() + + assert "e1" not in expired + assert Event.get(Event.id == "e1").has_clip is True + + def test_event_clip_expires_when_sub_disabled(self): + # with sub recording disabled, the 20-day-old alert event expires + # under the 10 day main alerts retention exactly as before + cleanup = self._build_cleanup( + { + "enabled": True, + "alerts": {"retain": {"days": 10}}, + "sub": {"enabled": False, "alerts": {"days": 60}}, + } + ) + self._insert_event("e1", 20) + + expired = cleanup.expire_clips() + + assert "e1" in expired + assert Event.get(Event.id == "e1").has_clip is False + + def test_sub_detections_days_extends_event_clip_retention(self): + # detection severity uses the sub detections window + cleanup = self._build_cleanup( + { + "enabled": True, + "detections": {"retain": {"days": 10}}, + "sub": {"enabled": True, "detections": {"days": 60}}, + } + ) + self._insert_event("e1", 20, severity="detection") + + expired = cleanup.expire_clips() + + assert "e1" not in expired + assert Event.get(Event.id == "e1").has_clip is True diff --git a/frigate/test/test_maintainer.py b/frigate/test/test_maintainer.py index 715cd5a1a1..ab7f7608df 100644 --- a/frigate/test/test_maintainer.py +++ b/frigate/test/test_maintainer.py @@ -98,7 +98,9 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase): end_time = now - datetime.timedelta(seconds=10) cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4" - maintainer.end_time_cache = {cache_path: (end_time, 10.0)} + maintainer.end_time_cache = { + cache_path: (end_time, 10.0, None, None, None, None, None) + } # Single processed frame well past end_time with no motion/objects. maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])] maintainer.audio_recordings_info["test_cam"] = [] @@ -109,7 +111,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase): result = await maintainer.validate_and_move_segment( "test_cam", reviews=[], - recording={"start_time": start_time, "cache_path": cache_path}, + recording={ + "start_time": start_time, + "cache_path": cache_path, + "stream_type": "main", + }, ) self.assertIsNone(result) @@ -137,7 +143,8 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase): (recent, 0, []), ] - grouped_recordings = {"present_cam": [{"start_time": ancient}]} + # keyed by (camera, stream_type), matching what move_files passes + grouped_recordings = {("present_cam", "main"): [{"start_time": ancient}]} maintainer._expire_stale_recordings_info(grouped_recordings) diff --git a/frigate/test/test_record_cleanup_sub.py b/frigate/test/test_record_cleanup_sub.py new file mode 100644 index 0000000000..0ae8caa270 --- /dev/null +++ b/frigate/test/test_record_cleanup_sub.py @@ -0,0 +1,210 @@ +"""Tests for independent sub stream retention in recording cleanup.""" + +import datetime +import unittest +from unittest.mock import MagicMock + +from playhouse.sqlite_ext import SqliteExtDatabase + +from frigate.config import FrigateConfig +from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus +from frigate.record.cleanup import RecordingCleanup + + +class TestRecordingCleanupSubRetention(unittest.TestCase): + def setUp(self): + # in-memory database keeps these tests isolated from the shared + # on-disk test.db used by the http api tests + self.db = SqliteExtDatabase(":memory:") + models = [Previews, Recordings, ReviewSegment, UserReviewStatus] + self.db.bind(models) + self.db.create_tables(models) + + def tearDown(self): + self.db.close() + + def _build_cleanup(self, record_config: dict) -> RecordingCleanup: + config = FrigateConfig( + **{ + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + { + "path": "rtsp://10.0.0.1:554/video", + "roles": ["detect", "record"], + }, + { + "path": "rtsp://10.0.0.1:554/video2", + "roles": ["record_sub"], + }, + ] + }, + "record": record_config, + } + }, + } + ) + return RecordingCleanup(config, MagicMock()) + + def _insert_recording( + self, id: str, stream_type: str, age_days: float, motion: int = 0 + ) -> None: + end_time = ( + datetime.datetime.now() - datetime.timedelta(days=age_days) + ).timestamp() + Recordings.create( + id=id, + camera="front_door", + path=f"/media/frigate/recordings/{id}.mp4", + start_time=end_time - 10, + end_time=end_time, + duration=10, + motion=motion, + objects=0, + dBFS=0, + segment_size=0, + stream_type=stream_type, + ) + + def test_sub_recordings_expire_independently(self): + # main retention 7 days, sub retention 30 days; rows 10 days old + # -> main row deleted, sub row kept + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": True, "continuous": {"days": 30}}, + } + ) + self._insert_recording("m1", "main", 10) + self._insert_recording("s1", "sub", 10) + + cleanup.expire_recordings() + + assert Recordings.get_or_none(Recordings.id == "m1") is None + assert Recordings.get_or_none(Recordings.id == "s1") is not None + + def test_sub_recordings_expire_after_sub_retention(self): + # sub retention 30 days; sub row 40 days old -> deleted + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": True, "continuous": {"days": 30}}, + } + ) + self._insert_recording("s_old", "sub", 40) + self._insert_recording("s_new", "sub", 10) + + cleanup.expire_recordings() + + assert Recordings.get_or_none(Recordings.id == "s_old") is None + assert Recordings.get_or_none(Recordings.id == "s_new") is not None + + def test_sub_recordings_overlapping_recent_reviews_survive(self): + # sub retention shorter than main: the reviews window must reach the + # sub pass cutoff or sub segments overlapping recent alerts are + # deleted on the first cleanup after being stored + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": True, "alerts": {"mode": "motion"}}, + } + ) + self._insert_recording("m1", "main", 1, motion=10) + self._insert_recording("s1", "sub", 1, motion=10) + + # alert review covering the same time window as the recordings + recording = Recordings.get(Recordings.id == "s1") + ReviewSegment.create( + id="r1", + camera="front_door", + start_time=recording.start_time, + end_time=recording.end_time, + severity="alert", + thumb_path="/media/frigate/clips/review/thumb-r1.webp", + data={}, + ) + + cleanup.expire_recordings() + + assert Recordings.get_or_none(Recordings.id == "m1") is not None + assert Recordings.get_or_none(Recordings.id == "s1") is not None + + def test_sub_alerts_days_extends_review_and_sub_retention(self): + # a 20-day-old alert review survives for the 60 day sub window and + # keeps the sub row alive, but must not hold the main row past the + # 10 day main alerts window + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": True, "alerts": {"days": 60}}, + } + ) + self._insert_recording("m1", "main", 20, motion=10) + self._insert_recording("s1", "sub", 20, motion=10) + + # alert review covering the same time window as the recordings + recording = Recordings.get(Recordings.id == "s1") + ReviewSegment.create( + id="r1", + camera="front_door", + start_time=recording.start_time, + end_time=recording.end_time, + severity="alert", + thumb_path="/media/frigate/clips/review/thumb-r1.webp", + data={}, + ) + + cleanup.expire_recordings() + + assert ReviewSegment.get_or_none(ReviewSegment.id == "r1") is not None + assert Recordings.get_or_none(Recordings.id == "m1") is None + assert Recordings.get_or_none(Recordings.id == "s1") is not None + + def test_main_review_lifetime_unchanged_when_sub_disabled(self): + # with sub recording disabled, the 20-day-old alert review expires + # under the 10 day main alerts retention exactly as before + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": False, "alerts": {"days": 60}}, + } + ) + end_time = (datetime.datetime.now() - datetime.timedelta(days=20)).timestamp() + ReviewSegment.create( + id="r1", + camera="front_door", + start_time=end_time - 10, + end_time=end_time, + severity="alert", + thumb_path="/media/frigate/clips/review/thumb-r1.webp", + data={}, + ) + + cleanup.expire_recordings() + + assert ReviewSegment.get_or_none(ReviewSegment.id == "r1") is None + + def test_disabled_sub_still_expires_old_sub_rows(self): + # sub disabled but old sub rows remain: the sub pass still runs and + # expires them by the sub config dates + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": False, "continuous": {"days": 30}}, + } + ) + self._insert_recording("s_old", "sub", 40) + self._insert_recording("s_new", "sub", 10) + + cleanup.expire_recordings() + + assert Recordings.get_or_none(Recordings.id == "s_old") is None + assert Recordings.get_or_none(Recordings.id == "s_new") is not None diff --git a/frigate/test/test_record_sub_maintainer.py b/frigate/test/test_record_sub_maintainer.py new file mode 100644 index 0000000000..070e2f4680 --- /dev/null +++ b/frigate/test/test_record_sub_maintainer.py @@ -0,0 +1,544 @@ +"""Tests for sub stream cache segment handling in the recording maintainer.""" + +import datetime +import os +import tempfile +import unittest +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock, patch + +from playhouse.sqlite_ext import SqliteExtDatabase + +from frigate.config import FrigateConfig +from frigate.models import Recordings +from frigate.record.maintainer import ( + RecordingMaintainer, + SegmentInfo, + parse_cache_segment_name, +) + + +def _build_chaining_maintainer( + t0: float, retention: str = "continuous" +) -> RecordingMaintainer: + """Build a maintainer for start-time chaining tests. + + Built without __init__ to avoid the IPC scaffolding; move_segment and + drop_segment are mocked so the times passed downstream are observable. + """ + camera_config = MagicMock() + camera_config.record.enabled = True + camera_config.record.event_pre_capture = 0 + camera_config.detect.width = 1920 + camera_config.detect.height = 1080 + camera_config.record.continuous.days = 1 if retention == "continuous" else 0 + camera_config.record.motion.days = 0 if retention == "continuous" else 1 + + # no spec: pydantic fields like config.ffmpeg are not visible to + # spec'd mocks, and the probe path needs them + config = MagicMock() + config.cameras = {"test_cam": camera_config} + + maintainer = RecordingMaintainer.__new__(RecordingMaintainer) + maintainer.config = config + maintainer.end_time_cache = {} + maintainer.object_recordings_info = defaultdict(list) + maintainer.audio_recordings_info = defaultdict(list) + maintainer.recordings_publisher = MagicMock() + maintainer.drop_segment = MagicMock() + maintainer.move_segment = AsyncMock(return_value=None) + # pre-seed the chain state so the lazy DB seed query is not attempted + maintainer.last_segment_end = {("test_cam", "main"): 0.0} + # a processed frame far past any segment end marks segments ready + maintainer.object_recordings_info["test_cam"] = [(t0 + 1000, [], [], [])] + return maintainer + + +async def _validate_segment( + maintainer: RecordingMaintainer, + t0: float, + offset: float, + duration: float, + mtime: float | None = None, +): + """Run validate_and_move_segment for a segment named t0+offset. + + mtime injects the cache file's close time; None simulates a missing file. + """ + start = datetime.datetime.fromtimestamp(t0 + offset, tz=datetime.UTC) + probe = AsyncMock(return_value={"has_valid_video": True, "duration": duration}) + getmtime = ( + MagicMock(side_effect=OSError("missing")) + if mtime is None + else MagicMock(return_value=mtime) + ) + with ( + patch("frigate.record.maintainer.get_video_properties", probe), + patch( + "frigate.record.maintainer.get_keyframe_offsets", + AsyncMock(return_value=[0]), + ), + patch("frigate.record.maintainer.os.path.getmtime", getmtime), + ): + return await maintainer.validate_and_move_segment( + "test_cam", + reviews=[], + recording={ + "start_time": start, + "cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4", + "stream_type": "main", + }, + ) + + +class TestParseCacheSegmentName(unittest.TestCase): + def test_parses_main_segment(self): + camera, stream_type, date = parse_cache_segment_name( + "front_door@20260610143022+0000" + ) + assert camera == "front_door" + assert stream_type == "main" + assert date == "20260610143022+0000" + + def test_parses_sub_segment(self): + camera, stream_type, date = parse_cache_segment_name( + "front_door@sub@20260610143022+0000" + ) + assert camera == "front_door" + assert stream_type == "sub" + assert date == "20260610143022+0000" + + def test_returns_none_for_unexpected_name(self): + assert parse_cache_segment_name("no_at_sign_here") is None + + +class TestValidateAndMoveSubSegment(unittest.IsolatedAsyncioTestCase): + """Behavioral tests for sub stream segments in validate_and_move_segment. + + The maintainer is built without __init__ to avoid the ZMQ scaffolding + the real constructor sets up. + """ + + def _build_maintainer(self, camera_config: MagicMock) -> RecordingMaintainer: + config = MagicMock(spec=FrigateConfig) + config.cameras = {"test_cam": camera_config} + + maintainer = RecordingMaintainer.__new__(RecordingMaintainer) + maintainer.config = config + maintainer.end_time_cache = {} + maintainer.object_recordings_info = defaultdict(list) + maintainer.audio_recordings_info = defaultdict(list) + maintainer.drop_segment = MagicMock() + maintainer.recordings_publisher = MagicMock() + maintainer.move_segment = AsyncMock(return_value=None) + return maintainer + + async def test_drops_sub_segment_when_sub_recording_disabled(self): + camera_config = MagicMock() + camera_config.record.enabled = True + camera_config.record.sub.enabled = False + + maintainer = self._build_maintainer(camera_config) + cache_path = "/tmp/cache/test_cam@sub@20260610143022+0000.mp4" + + result = await maintainer.validate_and_move_segment( + "test_cam", + reviews=[], + recording={ + "start_time": datetime.datetime.now(datetime.UTC), + "cache_path": cache_path, + "stream_type": "sub", + }, + ) + + self.assertIsNone(result) + maintainer.drop_segment.assert_called_once_with(cache_path) + maintainer.move_segment.assert_not_awaited() + + async def test_sub_segment_uses_sub_continuous_retention(self): + # main continuous/motion are disabled, but sub continuous is enabled; + # the sub segment must be kept based on the sub retention config + camera_config = MagicMock() + camera_config.record.enabled = True + camera_config.record.sub.enabled = True + camera_config.record.continuous.days = 0 + camera_config.record.motion.days = 0 + camera_config.record.sub.continuous.days = 1 + camera_config.record.sub.motion.days = 0 + + maintainer = self._build_maintainer(camera_config) + + now = datetime.datetime.now(datetime.UTC) + start_time = now - datetime.timedelta(seconds=20) + end_time = now - datetime.timedelta(seconds=10) + cache_path = "/tmp/cache/test_cam@sub@20260610143022+0000.mp4" + + # pre-fill the end time cache so no ffprobe is attempted; the + # audio and codec fields stay unknown without one + maintainer.end_time_cache = { + cache_path: (end_time, 10.0, None, None, None, None, None) + } + # a processed frame past end_time so the segment is considered ready + maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])] + + result = await maintainer.validate_and_move_segment( + "test_cam", + reviews=[], + recording={ + "start_time": start_time, + "cache_path": cache_path, + "stream_type": "sub", + }, + ) + + self.assertIsNone(result) + maintainer.move_segment.assert_awaited_once() + call_args = maintainer.move_segment.await_args.args + self.assertEqual(call_args[0], "test_cam") + self.assertEqual(call_args[1], "sub") + maintainer.drop_segment.assert_not_called() + + +class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase): + """Audio presence must flow from the segment probe into the DB insert.""" + + def _build_maintainer(self) -> RecordingMaintainer: + camera_config = MagicMock() + camera_config.record.enabled = True + camera_config.record.continuous.days = 1 + camera_config.record.motion.days = 0 + + # no spec: pydantic fields like config.ffmpeg are not visible to + # spec'd mocks, and the probe/move paths need them + config = MagicMock() + config.cameras = {"test_cam": camera_config} + + maintainer = RecordingMaintainer.__new__(RecordingMaintainer) + maintainer.config = config + maintainer.end_time_cache = {} + maintainer.object_recordings_info = defaultdict(list) + maintainer.audio_recordings_info = defaultdict(list) + maintainer.recordings_publisher = MagicMock() + # pre-seed the chain state so the lazy DB seed query is not attempted + maintainer.last_segment_end = {("test_cam", "main"): 0.0} + return maintainer + + async def test_probe_audio_presence_reaches_move_segment(self): + for has_audio, audio_rate, audio_codec, video_codec in ( + (True, 16000, "aac", "hevc"), + (True, 8000, "pcm_alaw", "h264"), + (False, None, None, "h264"), + (False, None, None, None), + ): + with self.subTest( + has_audio=has_audio, + audio_rate=audio_rate, + audio_codec=audio_codec, + video_codec=video_codec, + ): + maintainer = self._build_maintainer() + maintainer.move_segment = AsyncMock(return_value=None) + + now = datetime.datetime.now(datetime.UTC) + start_time = now - datetime.timedelta(seconds=20) + cache_path = "/tmp/cache/test_cam@20260610143022+0000.mp4" + # a processed frame past the segment end marks it ready + maintainer.object_recordings_info["test_cam"] = [ + (now.timestamp(), [], [], []) + ] + + probe = AsyncMock( + return_value={ + "has_valid_video": True, + "width": 1920, + "height": 1080, + "duration": 10.0, + "has_audio": has_audio, + "audio_rate": audio_rate, + "audio_codec": audio_codec, + "video_codec": video_codec, + } + ) + with ( + patch("frigate.record.maintainer.get_video_properties", probe), + patch( + "frigate.record.maintainer.get_keyframe_offsets", + AsyncMock(return_value=[0, 2000]), + ), + ): + await maintainer.validate_and_move_segment( + "test_cam", + reviews=[], + recording={ + "start_time": start_time, + "cache_path": cache_path, + "stream_type": "main", + }, + ) + + maintainer.move_segment.assert_awaited_once() + call_args = maintainer.move_segment.await_args.args + self.assertEqual(call_args[7], has_audio) + self.assertEqual(call_args[8], audio_rate) + self.assertEqual(call_args[9], audio_codec) + self.assertEqual(call_args[11], [0, 2000]) + self.assertEqual(call_args[10], video_codec) + # the probe result is cached alongside the end time so the + # cached path stays as informed as the probed path + self.assertEqual(maintainer.end_time_cache[cache_path][2], has_audio) + self.assertEqual(maintainer.end_time_cache[cache_path][3], audio_rate) + self.assertEqual(maintainer.end_time_cache[cache_path][4], audio_codec) + self.assertEqual(maintainer.end_time_cache[cache_path][5], video_codec) + + async def test_move_segment_insert_includes_has_audio(self): + for has_audio, audio_rate, audio_codec, video_codec in ( + (True, 16000, "aac", "hevc"), + (False, None, None, None), + ): + with self.subTest( + has_audio=has_audio, + audio_rate=audio_rate, + audio_codec=audio_codec, + video_codec=video_codec, + ): + maintainer = self._build_maintainer() + maintainer.config.ffmpeg.ffmpeg_path = "ffmpeg" + + start_time = datetime.datetime.now(datetime.UTC) + end_time = start_time + datetime.timedelta(seconds=10) + + proc = MagicMock() + proc.returncode = 0 + proc.wait = AsyncMock(return_value=0) + + with tempfile.TemporaryDirectory() as tmpdir: + cache_path = os.path.join( + tmpdir, "test_cam@20260610143022+0000.mp4" + ) + with open(cache_path, "wb") as f: + f.write(b"\x00" * 16) + + with ( + patch( + "frigate.record.maintainer.RECORD_DIR", + os.path.join(tmpdir, "recordings"), + ), + patch( + "frigate.record.maintainer.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + ): + result = await maintainer.move_segment( + "test_cam", + "main", + start_time, + end_time, + 10.0, + cache_path, + SegmentInfo(0, 0, 0, 0), + has_audio, + audio_rate, + audio_codec, + video_codec, + ) + + self.assertIsNotNone(result) + self.assertEqual(result[Recordings.has_audio.name], has_audio) + self.assertEqual(result[Recordings.audio_rate.name], audio_rate) + self.assertEqual(result[Recordings.audio_codec.name], audio_codec) + self.assertEqual(result[Recordings.video_codec.name], video_codec) + + +class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase): + """Contiguous segments must chain start times across filename truncation. + + A contiguous segment's parsed start lands up to 1s before the previous + segment's fractional end, and must snap to it without ever snapping + across a genuine gap. + """ + + T0 = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC).timestamp() + + async def test_contiguous_segment_snaps_to_previous_end(self): + maintainer = _build_chaining_maintainer(self.T0) + + # segment A named at a whole second with a fractional duration + await _validate_segment(maintainer, self.T0, 0, 10.4) + # segment B's filename truncates its true start (T0 + 10.4) to T0 + 10 + await _validate_segment(maintainer, self.T0, 10, 10.4) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0].args[2].timestamp(), self.T0) + self.assertAlmostEqual(calls[0].args[3].timestamp(), self.T0 + 10.4, places=3) + self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3) + self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3) + + async def test_genuine_gap_is_not_snapped(self): + maintainer = _build_chaining_maintainer(self.T0) + + await _validate_segment(maintainer, self.T0, 0, 10.4) + # a segment starting well after the previous end is a genuine gap + await _validate_segment(maintainer, self.T0, 30, 10.4) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 2) + self.assertEqual(calls[1].args[2].timestamp(), self.T0 + 30) + self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 40.4, places=3) + + async def test_difference_over_tolerance_is_not_snapped(self): + # a last end far ahead of the parsed start (dual recorders or + # cache-pressure deletions) must never pull the start forward + maintainer = _build_chaining_maintainer(self.T0) + maintainer.last_segment_end = {("test_cam", "main"): self.T0 + 5.0} + + await _validate_segment(maintainer, self.T0, 0, 10.4) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].args[2].timestamp(), self.T0) + + async def test_start_after_last_end_within_tolerance_is_not_snapped(self): + maintainer = _build_chaining_maintainer(self.T0) + + await _validate_segment(maintainer, self.T0, 0, 10.4) + # parsed start T0 + 11 is after the last end (T0 + 10.4): a real + # sub-second gap, never snapped backwards + await _validate_segment(maintainer, self.T0, 11, 10.4) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 2) + self.assertEqual(calls[1].args[2].timestamp(), self.T0 + 11) + + async def test_chain_survives_retention_discarded_segment(self): + # motion retention: A and C overlap motion frames, B has none and is + # discarded, but capture continued through B so C chains to B's end + maintainer = _build_chaining_maintainer(self.T0, retention="motion") + maintainer.object_recordings_info["test_cam"] = [ + (self.T0 + 5, [], [(0, 0, 10, 10)], []), + (self.T0 + 25, [], [(0, 0, 10, 10)], []), + (self.T0 + 1000, [], [], []), + ] + + await _validate_segment(maintainer, self.T0, 0, 10.4) + await _validate_segment(maintainer, self.T0, 10, 10.4) + await _validate_segment(maintainer, self.T0, 20, 10.4) + + calls = maintainer.move_segment.await_args_list + # B was discarded by motion retention, only A and C moved + self.assertEqual(len(calls), 2) + maintainer.drop_segment.assert_called_once_with( + "/tmp/cache/test_cam@chain10.mp4" + ) + # C snaps to B's end (T0 + 20.8), not A's end (T0 + 10.4) + self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 20.8, places=3) + self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 31.2, places=3) + + +class TestSegmentStartMtimeAnchoring(unittest.IsolatedAsyncioTestCase): + """Segment starts must anchor to the cache file's close time. + + mtime is the wall clock when ffmpeg rolled the segment, so mtime minus + the probed duration restores the fractional start the filename floored + away. Contiguous segments still chain within probe jitter. + """ + + T0 = datetime.datetime(2026, 8, 7, 9, 15, 42, tzinfo=datetime.UTC).timestamp() + + async def test_mtime_restores_fractional_start(self): + maintainer = _build_chaining_maintainer(self.T0) + + # true start T0 + 0.437; the filename floored it to T0 + await _validate_segment( + maintainer, self.T0, 0, 10.0, mtime=self.T0 + 0.437 + 10.0 + ) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 1) + self.assertAlmostEqual(calls[0].args[2].timestamp(), self.T0 + 0.437, places=3) + self.assertAlmostEqual(calls[0].args[3].timestamp(), self.T0 + 10.437, places=3) + + async def test_chain_wins_within_probe_jitter(self): + maintainer = _build_chaining_maintainer(self.T0) + + await _validate_segment( + maintainer, self.T0, 0, 10.0, mtime=self.T0 + 0.437 + 10.0 + ) + # B measures T0 + 10.420, a 17ms disagreement with A's chained end + # (T0 + 10.437): the chain wins so the rows stay exactly adjacent + await _validate_segment( + maintainer, self.T0, 10, 10.0, mtime=self.T0 + 10.420 + 10.0 + ) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 2) + self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.437, places=3) + self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.437, places=3) + + async def test_chain_reanchors_when_drifted(self): + maintainer = _build_chaining_maintainer(self.T0) + # accumulated probe error left the chain 0.7s past the measured start + maintainer.last_segment_end = {("test_cam", "main"): self.T0 + 0.9} + + await _validate_segment( + maintainer, self.T0, 0, 10.0, mtime=self.T0 + 0.2 + 10.0 + ) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 1) + self.assertAlmostEqual(calls[0].args[2].timestamp(), self.T0 + 0.2, places=3) + + async def test_implausible_mtime_falls_back_to_filename_chain(self): + maintainer = _build_chaining_maintainer(self.T0) + maintainer.last_segment_end = {("test_cam", "main"): self.T0 + 0.4} + + # a stalled stream: media (8s) is shorter than the wall span to the + # close time (9.5s), so mtime - duration lands outside the window + await _validate_segment(maintainer, self.T0, 0, 8.0, mtime=self.T0 + 9.5) + + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 1) + self.assertAlmostEqual(calls[0].args[2].timestamp(), self.T0 + 0.4, places=3) + + +class TestSegmentChainSeeding(unittest.IsolatedAsyncioTestCase): + """Chain state must lazily seed from the DB so chains survive restarts.""" + + T0 = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC).timestamp() + + def setUp(self): + self.db = SqliteExtDatabase(":memory:") + self.db.bind([Recordings]) + self.db.create_tables([Recordings]) + + def tearDown(self): + self.db.close() + + async def test_seeds_chain_from_db_once(self): + maintainer = _build_chaining_maintainer(self.T0) + # empty chain state forces the lazy DB seed on first encounter + maintainer.last_segment_end = {} + + Recordings.create( + id="seed-row", + camera="test_cam", + path="/recordings/seed.mp4", + start_time=self.T0 - 10, + end_time=self.T0 + 0.35, + duration=10.35, + stream_type="main", + ) + + with patch.object(Recordings, "select", wraps=Recordings.select) as select_spy: + await _validate_segment(maintainer, self.T0, 0, 10.4) + await _validate_segment(maintainer, self.T0, 10, 10.4) + + # the seed query runs once; the second segment uses in-memory state + self.assertEqual(select_spy.call_count, 1) + calls = maintainer.move_segment.await_args_list + self.assertEqual(len(calls), 2) + # first segment snaps to the stored fractional end_time + self.assertAlmostEqual(calls[0].args[2].timestamp(), self.T0 + 0.35, places=3) + self.assertAlmostEqual(calls[0].args[3].timestamp(), self.T0 + 10.75, places=3) + # second segment chains off the first's in-memory end + self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.75, places=3) diff --git a/frigate/test/test_recording_coverage.py b/frigate/test/test_recording_coverage.py new file mode 100644 index 0000000000..1ade707b50 --- /dev/null +++ b/frigate/test/test_recording_coverage.py @@ -0,0 +1,305 @@ +"""Tests for the unified recording coverage resolver.""" + +import asyncio +import json +import unittest +from types import SimpleNamespace + +from playhouse.sqlite_ext import SqliteExtDatabase + +from frigate.api.media import _vod_response +from frigate.const import MAX_SEGMENT_DURATION +from frigate.models import Recordings +from frigate.util.recording_coverage import ( + _rows_query, + coverage_spans, + plan_clip, + realized_timeline, + resolve_coverage, + stream_media_summary, +) + + +class CoverageDbTestCase(unittest.TestCase): + def setUp(self): + # in-memory database keeps these tests isolated from the shared + # on-disk test.db used by the http api tests + self.db = SqliteExtDatabase(":memory:") + models = [Recordings] + self.db.bind(models) + self.db.create_tables(models) + + def tearDown(self): + self.db.close() + + def _insert( + self, + id, + start, + end, + stream_type, + camera="front_door", + video_codec=None, + audio_rate=None, + audio_codec=None, + has_audio=None, + segment_size=0, + keyframes=None, + ): + Recordings.create( + id=id, + camera=camera, + path=f"/tmp/{id}.mp4", + start_time=start, + end_time=end, + duration=end - start, + stream_type=stream_type, + video_codec=video_codec, + audio_rate=audio_rate, + audio_codec=audio_codec, + has_audio=has_audio, + segment_size=segment_size, + keyframes=keyframes, + ) + + +class TestRecordingCoverage(CoverageDbTestCase): + def test_both_streams_offset_boundaries(self): + # main: [1000,1010) [1010,1020) sub: [1003,1013) [1013,1023) + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("m2", 1010.0, 1020.0, "main") + self._insert("s1", 1003.0, 1013.0, "sub") + self._insert("s2", 1013.0, 1023.0, "sub") + intervals = resolve_coverage("front_door", 1000.0, 1020.0) + # boundaries: 1000,1003,1010,1013,1020 + assert [round(i.start_time) for i in intervals] == [1000, 1003, 1010, 1013] + assert intervals[0].main is not None and intervals[0].sub is None + assert intervals[1].main is not None and intervals[1].sub is not None + + def test_sub_only_when_main_expired(self): + self._insert("s1", 1000.0, 1010.0, "sub") + intervals = resolve_coverage("front_door", 1000.0, 1010.0) + assert len(intervals) == 1 + assert intervals[0].main is None and intervals[0].sub is not None + + def test_gap_when_neither_exists(self): + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("m2", 1030.0, 1040.0, "main") + intervals = resolve_coverage("front_door", 1000.0, 1040.0) + assert len(intervals) == 2 # the [1010,1030) gap produces no interval + + def test_spans_merge_same_availability(self): + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("m2", 1010.0, 1020.0, "main") + spans = coverage_spans(resolve_coverage("front_door", 1000.0, 1020.0)) + assert spans == [ + {"start_time": 1000.0, "end_time": 1020.0, "streams": ["main"]} + ] + + def test_requested_range_clamps_intervals(self): + # query range partially overlaps the rows; the first and last + # intervals must be clamped to the requested [after, before] + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("m2", 1010.0, 1020.0, "main") + intervals = resolve_coverage("front_door", 1005.0, 1015.0) + assert intervals[0].start_time == 1005.0 + assert intervals[-1].end_time == 1015.0 + spans = coverage_spans(intervals) + assert spans == [ + {"start_time": 1005.0, "end_time": 1015.0, "streams": ["main"]} + ] + + def test_stream_media_summary_newest_known_wins(self): + # the newer main row's NULL codecs must not mask the older row's + # known codecs, while its known audio fields take precedence + self._insert( + "m1", + 1000.0, + 1010.0, + "main", + video_codec="hevc", + audio_rate=16000, + audio_codec="aac", + ) + self._insert("m2", 1010.0, 1020.0, "main", audio_rate=8000, has_audio=True) + self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False) + summary = stream_media_summary(resolve_coverage("front_door", 1000.0, 1020.0)) + assert summary == { + "main": { + "video_codec": "hevc", + "audio_rate": 8000, + "audio_codec": "aac", + "has_audio": True, + "bitrate": None, + }, + "sub": { + "video_codec": None, + "audio_rate": None, + "audio_codec": None, + "has_audio": False, + "bitrate": None, + }, + } + + def test_stream_media_summary_omits_absent_stream(self): + self._insert("m1", 1000.0, 1010.0, "main", video_codec="h264") + summary = stream_media_summary(resolve_coverage("front_door", 1000.0, 1010.0)) + assert list(summary.keys()) == ["main"] + assert stream_media_summary([]) == {} + + def test_stream_media_summary_bitrate_weighted_by_duration(self): + # main: 10 MiB over 10s + 30 MiB over 20s -> 40 MiB / 30s + self._insert("m1", 1000.0, 1010.0, "main", segment_size=10) + self._insert("m2", 1010.0, 1030.0, "main", segment_size=30) + # sub rows without a stored size (legacy default 0) report None + self._insert("s1", 1000.0, 1030.0, "sub") + summary = stream_media_summary(resolve_coverage("front_door", 1000.0, 1030.0)) + expected = int(40 * 1024 * 1024 * 8 / 30) + assert summary["main"]["bitrate"] == expected + assert summary["sub"]["bitrate"] is None + + def test_stream_media_summary_bitrate_skips_zero_size_rows(self): + # the zero-size glitch row contributes neither bytes nor seconds + self._insert("m1", 1000.0, 1010.0, "main", segment_size=5) + self._insert("m2", 1010.0, 1020.0, "main", segment_size=0) + summary = stream_media_summary(resolve_coverage("front_door", 1000.0, 1020.0)) + expected = int(5 * 1024 * 1024 * 8 / 10) + assert summary["main"]["bitrate"] == expected + + def test_other_camera_rows_excluded(self): + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard") + self._insert("o2", 1000.0, 1010.0, "sub", camera="back_yard") + intervals = resolve_coverage("front_door", 1000.0, 1010.0) + assert len(intervals) == 1 + assert intervals[0].main is not None and intervals[0].sub is None + assert resolve_coverage("side_gate", 1000.0, 1010.0) == [] + + def test_realized_timeline_snap_lead_in(self): + """A mid-file resume's back-snap lead-in appears in the realized duration. + + Sub resumes at 1010 with a 7000ms inpoint; the stored keyframe + index snaps back to 6000ms, so the clip serves 1000ms of pre-span + content on top of the 3000ms span. + """ + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("s1", 1003.0, 1013.0, "sub", keyframes=[0, 2000, 4000, 6000, 8000]) + timeline = realized_timeline( + resolve_coverage("front_door", 1000.0, 1013.0), None + ) + assert timeline == [ + {"start_time": 1000.0, "end_time": 1010.0, "duration": 10000}, + {"start_time": 1010.0, "end_time": 1013.0, "duration": 4000}, + ] + + def test_realized_timeline_no_keyframe_index_serves_whole_file(self): + """A row without a stored keyframe index serves the whole file. + + NULL keyframes mean the record-time probe failed, so a mid-file + entry cannot be snapped to a decodable frame. + """ + self._insert("m1", 1000.0, 1010.0, "main") + self._insert("s1", 1003.0, 1013.0, "sub") + timeline = realized_timeline( + resolve_coverage("front_door", 1000.0, 1013.0), None + ) + # the resume clip serves all 10000ms of the sub file for a 3000ms span + assert timeline[1] == { + "start_time": 1010.0, + "end_time": 1013.0, + "duration": 10000, + } + + def test_realized_timeline_pinned_stream_no_mid_range_lead_in(self): + """A pinned stream's spans start at its own file starts: no lead-in.""" + self._insert("s1", 1000.0, 1010.0, "sub", keyframes=[0, 5000]) + self._insert("s2", 1010.0, 1020.0, "sub", keyframes=[0, 5000]) + timeline = realized_timeline( + resolve_coverage("front_door", 1000.0, 1020.0), "sub" + ) + assert [t["duration"] for t in timeline] == [10000, 10000] + + def test_rows_query_scan_is_bounded_below(self): + """The row query must give SQLite a lower bound on start_time. + + Without one the best index range is (camera=? AND start_time?" in plan and "start_time list[Recordings]: + """Fetch one stream type's recording rows overlapping the requested range.""" + return list( + Recordings.select( + Recordings.path, + Recordings.start_time, + Recordings.end_time, + ) + .where( + (Recordings.start_time.between(start_ts, end_ts)) + | (Recordings.end_time.between(start_ts, end_ts)) + | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) + ) + .where(Recordings.camera == camera_name) + .where(Recordings.stream_type == stream_type) + .order_by(Recordings.start_time.asc()) + ) + + def get_audio_from_recording( ffmpeg, camera_name: str, @@ -31,22 +52,17 @@ def get_audio_from_recording( Returns: Bytes of WAV audio data or None if extraction failed """ - # Fetch all relevant recording segments - recordings = ( - Recordings.select( - Recordings.path, - Recordings.start_time, - Recordings.end_time, - ) - .where( - (Recordings.start_time.between(start_ts, end_ts)) - | (Recordings.end_time.between(start_ts, end_ts)) - | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) - ) - .where(Recordings.camera == camera_name) - .order_by(Recordings.start_time.asc()) + # Fetch all relevant recording segments; never mix streams in one + # concat, so prefer main and fall back to sub for expired-main history + recordings = _get_recordings_for_range( + camera_name, start_ts, end_ts, STREAM_TYPE_MAIN ) + if not recordings: + recordings = _get_recordings_for_range( + camera_name, start_ts, end_ts, STREAM_TYPE_SUB + ) + if not recordings: logger.debug( f"No recordings found for {camera_name} between {start_ts} and {end_ts}" diff --git a/frigate/util/classification.py b/frigate/util/classification.py index a9345bbc56..a375ee8c1c 100644 --- a/frigate/util/classification.py +++ b/frigate/util/classification.py @@ -18,6 +18,7 @@ from frigate.const import ( CLIPS_DIR, MODEL_CACHE_DIR, PROCESS_PRIORITY_LOW, + STREAM_TYPE_MAIN, UPDATE_MODEL_STATE, ) from frigate.log import redirect_output_to_logger, suppress_stderr_during @@ -555,6 +556,7 @@ def _extract_keyframes( (timestamp >= Recordings.start_time) & (timestamp <= Recordings.end_time) & (Recordings.camera == camera) + & (Recordings.stream_type == STREAM_TYPE_MAIN) ) .order_by(Recordings.start_time.desc()) .limit(1) diff --git a/frigate/util/media.py b/frigate/util/media.py index e4e84e9149..7a7292e5d4 100644 --- a/frigate/util/media.py +++ b/frigate/util/media.py @@ -1,10 +1,11 @@ """Recordings Utilities.""" +import asyncio +import contextlib import datetime import errno import logging import os -import subprocess as sp from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path @@ -879,38 +880,43 @@ def sync_all_media( return results -def get_keyframe_before(path: str, offset_ms: int) -> int | None: - """Get the timestamp (ms) of the last keyframe at or before offset_ms. +async def get_keyframe_offsets(path: str) -> list[int] | None: + """Get every video keyframe offset (ms from segment start) in an mp4. - Uses ffprobe packet index to read keyframe positions from the mp4 file. - Returns None if ffprobe fails or no keyframe is found before the offset. + Runs at record time so playback never needs to probe. Returns None if + ffprobe fails, so the caller stores NULL and playback falls back to + serving whole files. """ + proc = None try: - result = sp.run( - [ - FFPROBE_PATH, - "-select_streams", - "v:0", - "-show_entries", - "packet=pts_time,flags", - "-of", - "csv=p=0", - "-loglevel", - "error", - path, - ], - capture_output=True, - timeout=5, + proc = await asyncio.create_subprocess_exec( + FFPROBE_PATH, + "-select_streams", + "v:0", + "-show_entries", + "packet=pts_time,flags", + "-of", + "csv=p=0", + "-loglevel", + "error", + path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, ) - except (sp.TimeoutExpired, FileNotFoundError): + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5) + except (TimeoutError, FileNotFoundError): + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.kill() + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(proc.communicate(), timeout=2) return None - if result.returncode != 0: + if proc.returncode != 0: return None - offset_s = offset_ms / 1000.0 - best_ms = None - for line in result.stdout.decode().strip().splitlines(): + offsets: list[int] = [] + for line in stdout.decode().strip().splitlines(): parts = line.strip().split(",") if len(parts) != 2: continue @@ -918,12 +924,8 @@ def get_keyframe_before(path: str, offset_ms: int) -> int | None: if "K" not in flags: continue try: - ts = float(ts_str) + offsets.append(int(float(ts_str) * 1000)) except ValueError: continue - if ts <= offset_s: - best_ms = int(ts * 1000) - else: - break - return best_ms + return offsets diff --git a/frigate/util/recording_coverage.py b/frigate/util/recording_coverage.py new file mode 100644 index 0000000000..f34f8a95e6 --- /dev/null +++ b/frigate/util/recording_coverage.py @@ -0,0 +1,422 @@ +"""Merge main and sub stream recording rows into a unified coverage timeline.""" + +import logging +from dataclasses import dataclass +from typing import Any + +from frigate.const import MAX_SEGMENT_DURATION, STREAM_TYPE_MAIN, STREAM_TYPE_SUB +from frigate.models import Recordings + +logger = logging.getLogger(__name__) + +# intervals shorter than this are boundary artifacts, not playable content +MIN_INTERVAL_S = 0.1 + + +@dataclass +class CoverageInterval: + """A time span annotated with the recording row covering it per stream.""" + + start_time: float + end_time: float + main: Any | None + sub: Any | None + + +def _rows_query(camera: str, after: float, before: float, stream_type: str) -> Any: + return ( + Recordings.select( + Recordings.path, + Recordings.start_time, + Recordings.end_time, + Recordings.duration, + Recordings.has_audio, + Recordings.audio_rate, + Recordings.audio_codec, + Recordings.video_codec, + Recordings.segment_size, + Recordings.keyframes, + ) + .where( + (Recordings.camera == camera) + & (Recordings.stream_type == stream_type) + & (Recordings.end_time > after) + & (Recordings.start_time < before) + & (Recordings.start_time > after - MAX_SEGMENT_DURATION) + ) + .order_by(Recordings.start_time.asc()) + .namedtuples() + ) + + +def _get_rows(camera: str, after: float, before: float, stream_type: str) -> list[Any]: + return list(_rows_query(camera, after, before, stream_type)) + + +def _covering( + rows: list[Any], idx: int, start: float, end: float +) -> tuple[Any | None, int]: + """Find the row covering [start, end), advancing idx (rows are sorted, non-overlapping).""" + while idx < len(rows) and rows[idx].end_time <= start: + idx += 1 + if idx < len(rows) and rows[idx].start_time <= start and rows[idx].end_time >= end: + return rows[idx], idx + return None, idx + + +def resolve_coverage( + camera: str, after: float, before: float +) -> list[CoverageInterval]: + """Resolve the unified coverage timeline for a camera and time range. + + Returns intervals bounded by the union of both streams' segment edges, + clamped to [after, before]. Ranges covered by neither stream produce no + interval (a gap). + """ + main_rows = _get_rows(camera, after, before, STREAM_TYPE_MAIN) + sub_rows = _get_rows(camera, after, before, STREAM_TYPE_SUB) + + boundaries: set[float] = set() + for row in main_rows + sub_rows: + boundaries.add(max(row.start_time, after)) + boundaries.add(min(row.end_time, before)) + + ordered = sorted(boundaries) + intervals: list[CoverageInterval] = [] + main_idx = 0 + sub_idx = 0 + + for i in range(len(ordered) - 1): + start, end = ordered[i], ordered[i + 1] + + if end - start < MIN_INTERVAL_S: + continue + + main_row, main_idx = _covering(main_rows, main_idx, start, end) + sub_row, sub_idx = _covering(sub_rows, sub_idx, start, end) + + if main_row is None and sub_row is None: + continue + + intervals.append(CoverageInterval(start, end, main_row, sub_row)) + + return intervals + + +def known_video_codecs(intervals: list[CoverageInterval]) -> set[str]: + """Collect the known video codecs across all rows in a coverage window. + + NULL codecs (legacy rows probed before the column existed) are + excluded, so uniformly-unknown data reports an empty set. + """ + return { + row.video_codec + for interval in intervals + for row in (interval.main, interval.sub) + if row is not None and row.video_codec is not None + } + + +def stream_media_summary( + intervals: list[CoverageInterval], +) -> dict[str, dict[str, Any]]: + """Summarize the most recently known media details per stream. + + Every field reports the newest non-NULL value across that stream's + rows, so an older row can still supply a value a legacy newer row + lacks. A stream with no rows is omitted entirely. + """ + fields = ("video_codec", "audio_rate", "audio_codec", "has_audio") + summary: dict[str, dict[str, Any]] = {} + + for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB): + # the same row can back many intervals, so dedupe by path + rows = { + row.path: row + for interval in intervals + if (row := getattr(interval, stream_type)) is not None + } + + if not rows: + continue + + newest_first = sorted(rows.values(), key=lambda r: r.start_time, reverse=True) + stream_summary: dict[str, Any] = {field: None for field in fields} + + for field in fields: + for row in newest_first: + value = getattr(row, field) + if value is not None: + stream_summary[field] = value + break + + # segment_size is stored in MiB; totalling bytes and seconds + # weights by duration, unlike averaging per-row ratios + total_bytes = 0.0 + total_seconds = 0.0 + for row in rows.values(): + size = row.segment_size + if size is None or size <= 0 or row.duration is None or row.duration <= 0: + continue + total_bytes += size * 1024 * 1024 + total_seconds += row.duration + + stream_summary["bitrate"] = ( + int(total_bytes * 8 / total_seconds) if total_seconds > 0 else None + ) + + summary[stream_type] = stream_summary + + return summary + + +def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]: + """Collapse intervals into contiguous spans of identical stream availability.""" + spans: list[dict[str, Any]] = [] + + for interval in intervals: + streams = [ + t + for t, row in ( + (STREAM_TYPE_MAIN, interval.main), + (STREAM_TYPE_SUB, interval.sub), + ) + if row is not None + ] + if ( + spans + and spans[-1]["end_time"] == interval.start_time + and spans[-1]["streams"] == streams + ): + spans[-1]["end_time"] = interval.end_time + else: + spans.append( + { + "start_time": interval.start_time, + "end_time": interval.end_time, + "streams": streams, + } + ) + + return spans + + +def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool: + """Whether a stream is audio-bearing over a coverage window. + + A stream counts as audio-bearing unless EVERY one of its rows reports + has_audio False; NULL (legacy or undetermined) counts as audio. + """ + return any( + row is not None and row.has_audio is not False + for row in ((interval.main if main else interval.sub) for interval in intervals) + ) + + +def null_audio_glitches( + intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool +) -> list[CoverageInterval]: + """Treat video-only glitch rows on audio-bearing streams as no recording. + + nginx-vod requires every clip in a sequence to carry the same track + count, so a truncated video-only segment (a backend restart can flush + a sub-second file before any audio packet landed) poisons every + manifest that includes it. Nulling the row turns the glitch into a + hole the span builder skips like any recording gap. + """ + result: list[CoverageInterval] = [] + for interval in intervals: + main = interval.main + sub = interval.sub + if main is not None and main_audio and main.has_audio is False: + main = None + if sub is not None and sub_audio and sub.has_audio is False: + sub = None + if main is None and sub is None: + continue + if main is interval.main and sub is interval.sub: + result.append(interval) + else: + result.append( + CoverageInterval(interval.start_time, interval.end_time, main, sub) + ) + return result + + +def build_spans( + intervals: list[CoverageInterval], stream: str | None +) -> list[list[Any]]: + """Merge coverage intervals into single-sequence spans of one row each. + + Each span is [row, start, end, is_main]; is_main lets the manifest + builder detect cross-stream hand-offs. A pinned stream serves only + its own rows; otherwise main is preferred and sub fills the gaps. + Intervals served by the same row merge on row identity alone, since + splitting a row mid-file would re-snap to a keyframe and repeat + content. + """ + spans: list[list[Any]] = [] + last_is_main: bool | None = None + for interval in intervals: + if stream == STREAM_TYPE_MAIN: + row, is_main = interval.main, True + elif stream == STREAM_TYPE_SUB: + row, is_main = interval.sub, False + elif interval.main is not None: + row, is_main = interval.main, True + else: + row, is_main = interval.sub, False + if row is None: + continue + if spans and spans[-1][0] == row: + spans[-1][2] = interval.end_time + else: + start = interval.start_time + # adjacent same-stream rows routinely overlap; trimming the + # previous span's end is free, where starting this span + # mid-file would cost a clipFrom keyframe snap. Inclusive on + # the span end, since sub-MIN_INTERVAL_S overlaps are dropped + # by the resolver and leave the previous span ending a hair + # past this row's start + if ( + spans + and is_main == last_is_main + and spans[-1][1] < row.start_time <= spans[-1][2] + ): + spans[-1][2] = row.start_time + start = row.start_time + spans.append([row, start, interval.end_time, is_main]) + last_is_main = is_main + return spans + + +def _keyframe_before(keyframes: Any, offset_ms: int) -> int | None: + """Last stored keyframe offset at or before offset_ms. + + keyframes is the row's record-time keyframe index (ms from segment + start). Returns None when the row has no usable index, which callers + treat as "serve the whole file". + """ + if not keyframes: + return None + + candidates = [k for k in keyframes if k <= offset_ms] + return max(candidates) if candidates else None + + +@dataclass +class ClipPlan: + """The exact playlist realization of one span. + + clip_from_ms is the keyframe-snapped clipFrom, or None when the whole + file is served. skipped means the clip is omitted from the manifest. + """ + + clip_from_ms: int | None + duration_ms: int + skipped: bool + + +def plan_clip(row: Any, start: float, end: float) -> ClipPlan: + """Plan one nginx-vod clip for a recording row trimmed to [start, end). + + The single source of truth for clip realization: the vod mapping + builder emits exactly this plan and the coverage timelines report it + to the frontend, so the playhead model matches the playlist by + construction rather than accumulating drift at each hand-off. + """ + min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame + max_duration_ms = MAX_SEGMENT_DURATION * 1000 + + clip_from: int | None = None + duration = int(row.duration * 1000) + + # adjust start offset if start is after the recording start + inpoint = int((start - row.start_time) * 1000) if start > row.start_time else 0 + if inpoint > 0: + clip_from = inpoint + duration -= inpoint + + # adjust end if the recording ends after the requested end + if row.end_time > end: + duration -= int((row.end_time - end) * 1000) + + # nginx-vod-module pushes clipFrom forward to the next keyframe, + # which can leave too few frames for a playable segment; snapping + # back to the preceding keyframe always starts on a decodable frame + if clip_from is not None: + keyframe_ms = _keyframe_before(row.keyframes, clip_from) + if keyframe_ms is not None: + gained = clip_from - keyframe_ms + clip_from = keyframe_ms + duration += gained + logger.debug( + "VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms", + keyframe_ms, + row.path, + duration, + ) + else: + logger.debug( + "VOD: no keyframe index for %s, removing clipFrom to use full recording", + row.path, + ) + clip_from = None + duration = int(row.duration * 1000) + if row.end_time > end: + duration -= int((row.end_time - end) * 1000) + + if duration < min_duration_ms: + logger.debug( + "VOD: skipping recording %s - resulting duration %sms too short", + row.path, + duration, + ) + return ClipPlan(None, 0, True) + + if duration >= max_duration_ms: + logger.warning(f"Recording clip is missing or empty: {row.path}") + return ClipPlan(None, 0, True) + + return ClipPlan(clip_from, duration, False) + + +def realized_timeline( + intervals: list[CoverageInterval], stream: str | None +) -> list[dict[str, Any]]: + """The exact playlist timeline a vod route will realize for a range. + + Each item pairs a span's wall-clock bounds with the duration (ms) of + the manifest clip serving it, including keyframe back-snap lead-in, + so summing durations reproduces playlist time exactly. A span whose + clip is skipped reports duration 0. + """ + return [ + { + "start_time": span_start, + "end_time": span_end, + "duration": plan.duration_ms, + } + for row, span_start, span_end, _ in build_spans(intervals, stream) + for plan in (plan_clip(row, span_start, span_end),) + ] + + +def realized_timelines( + intervals: list[CoverageInterval], +) -> dict[str, list[dict[str, Any]]]: + """All three variant timelines for a coverage window. + + Applies the same glitch-nulling as the manifest builder, then + assembles each variant's realized spans. Keyframe snapping reads the + per-row index stored at record time, so no file is touched. + """ + main_audio = stream_has_audio(intervals, main=True) + sub_audio = stream_has_audio(intervals, main=False) + nulled = null_audio_glitches(intervals, main_audio, sub_audio) + + return { + "auto": realized_timeline(nulled, None), + "main": realized_timeline(nulled, STREAM_TYPE_MAIN), + "sub": realized_timeline(nulled, STREAM_TYPE_SUB), + } diff --git a/frigate/util/services.py b/frigate/util/services.py index 395d30c801..2c13b00b98 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -1,6 +1,7 @@ """Utilities for services.""" import asyncio +import contextlib import glob import json import logging @@ -1256,8 +1257,22 @@ async def get_video_properties( async def probe_with_ffprobe( url: str, rtsp_transport: str | None = None, - ) -> tuple[bool, int, int, str | None, float]: - """Fallback using ffprobe: returns (valid, width, height, codec, duration).""" + ) -> tuple[ + bool, + int, + int, + str | None, + str | None, + float, + bool | None, + int | None, + str | None, + ]: + """Probe using ffprobe: returns (valid, width, height, fourcc, video_codec, duration, has_audio, audio_rate, audio_codec). + + ffprobe reports the codec name directly, so fourcc and + video_codec are the same value on this path. + """ cmd = [ffmpeg.ffprobe_path] if rtsp_transport: cmd += ["-rtsp_transport", rtsp_transport] @@ -1285,19 +1300,17 @@ async def get_video_properties( clean_camera_user_pass(url), rtsp_transport or "default", ) - proc.kill() - await proc.wait() - return False, 0, 0, None, -1 + return False, 0, 0, None, None, -1, None, None, None if proc.returncode != 0: - return False, 0, 0, None, -1 + return False, 0, 0, None, None, -1, None, None, None data = json.loads(stdout.decode()) video_streams = [ s for s in data.get("streams", []) if s.get("codec_type") == "video" ] if not video_streams: - return False, 0, 0, None, -1 + return False, 0, 0, None, None, -1, None, None, None v = video_streams[0] width = int(v.get("width", 0)) @@ -1307,16 +1320,70 @@ async def get_video_properties( duration_str = data.get("format", {}).get("duration") duration = float(duration_str) if duration_str else -1.0 - return True, width, height, codec, duration - except (json.JSONDecodeError, ValueError, KeyError, sp.SubprocessError): - return False, 0, 0, None, -1 + audio_streams = [ + s for s in data.get("streams", []) if s.get("codec_type") == "audio" + ] + has_audio = bool(audio_streams) - def probe_with_cv2(url: str) -> tuple[bool, int, int, str | None, float]: - """Primary attempt using cv2: returns (valid, width, height, fourcc, duration).""" + # codec and sample rate distinguish audio tracks whose decoder + # configs cannot share an HLS sequence + audio_rate: int | None = None + audio_codec: str | None = None + if audio_streams: + try: + audio_rate = int(audio_streams[0]["sample_rate"]) + except (KeyError, TypeError, ValueError): + audio_rate = None + audio_codec = audio_streams[0].get("codec_name") + + return ( + True, + width, + height, + codec, + codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) + except (json.JSONDecodeError, ValueError, KeyError, sp.SubprocessError): + return False, 0, 0, None, None, -1, None, None, None + finally: + # callers run in a per-cycle event loop, and an ffprobe still + # running when that loop closes is finalized against a dead loop + # ("Event loop is closed"). Draining after the kill is what + # closes the pipes and their transports + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.kill() + + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(proc.communicate(), timeout=2) + + def probe_with_cv2( + url: str, + ) -> tuple[ + bool, + int, + int, + str | None, + str | None, + float, + bool | None, + int | None, + str | None, + ]: + """Probe using cv2: returns (valid, width, height, fourcc, video_codec, duration, has_audio, audio_rate, audio_codec). + + cv2 cannot report audio streams or a normalized codec name, so + has_audio, audio_rate, audio_codec, and video_codec are always + None (unknown) on this path. + """ cap = cv2.VideoCapture(url) if not cap.isOpened(): cap.release() - return False, 0, 0, None, -1 + return False, 0, 0, None, None, -1, None, None, None width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) @@ -1335,7 +1402,7 @@ async def get_video_properties( duration = total_frames / fps cap.release() - return valid, width, height, fourcc, duration + return valid, width, height, fourcc, None, duration, None, None, None is_rtsp = url.startswith("rtsp://") @@ -1343,24 +1410,99 @@ async def get_video_properties( # skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal # timeout that cannot be shortened per-call, and ffprobe bounded by # -rw_timeout handles RTSP probing reliably - has_video, width, height, fourcc, duration = await probe_with_ffprobe(url) + ( + has_video, + width, + height, + fourcc, + video_codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) = await probe_with_ffprobe(url) + elif get_duration: + # ffprobe first: segment validation also needs audio presence, + # which cv2 cannot report + ( + has_video, + width, + height, + fourcc, + video_codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) = await probe_with_ffprobe(url) + + # fallback to cv2 if needed; audio stays unknown there + if not has_video or duration < 0: + ( + has_video, + width, + height, + fourcc, + video_codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) = probe_with_cv2(url) else: # try cv2 first for local files, HTTP, RTMP - has_video, width, height, fourcc, duration = probe_with_cv2(url) + ( + has_video, + width, + height, + fourcc, + video_codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) = probe_with_cv2(url) # fallback to ffprobe if needed - if not has_video or (get_duration and duration < 0): - has_video, width, height, fourcc, duration = await probe_with_ffprobe(url) + if not has_video: + ( + has_video, + width, + height, + fourcc, + video_codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) = await probe_with_ffprobe(url) # last resort for RTSP: try TCP transport, since default UDP may be blocked if (not has_video or (get_duration and duration < 0)) and is_rtsp: - has_video, width, height, fourcc, duration = await probe_with_ffprobe( - url, rtsp_transport="tcp" - ) + ( + has_video, + width, + height, + fourcc, + video_codec, + duration, + has_audio, + audio_rate, + audio_codec, + ) = await probe_with_ffprobe(url, rtsp_transport="tcp") result: dict[str, Any] = {"has_valid_video": has_video} if has_video: - result.update({"width": width, "height": height}) + result.update( + { + "width": width, + "height": height, + "has_audio": has_audio, + "audio_rate": audio_rate, + "audio_codec": audio_codec, + "video_codec": video_codec, + } + ) if fourcc: result["fourcc"] = fourcc if get_duration: diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index 482e9da6bf..d4645efc78 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -147,6 +147,7 @@ class CameraWatchdog(threading.Thread): self.requestor = InterProcessRequestor() self.was_enabled = self.config.enabled self.was_record_enabled_in_config = self.config.record.enabled_in_config + self.was_record_sub_enabled = self.config.record.sub.enabled self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all) self.latest_valid_segment_time: float = 0 @@ -312,6 +313,24 @@ class CameraWatchdog(threading.Thread): self.was_record_enabled_in_config = record_enabled_in_config continue + record_sub_enabled = self.config.record.sub.enabled + if record_sub_enabled != self.was_record_sub_enabled: + # adding and removing the record_sub output both require a + # restart, unlike the main record toggle + if record_enabled_in_config and enabled: + self.logger.debug( + f"Sub stream recording toggled in config for {self.config.name}, restarting ffmpeg" + ) + self.stop_all_ffmpeg() + self.start_all_ffmpeg() + self.latest_valid_segment_time = 0 + self.latest_invalid_segment_time = 0 + self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(UTC) + last_restart_time = datetime.now().timestamp() + self.was_record_sub_enabled = record_sub_enabled + continue + if not enabled: continue diff --git a/migrations/036_add_recordings_stream_metadata.py b/migrations/036_add_recordings_stream_metadata.py new file mode 100644 index 0000000000..5974aa0f9d --- /dev/null +++ b/migrations/036_add_recordings_stream_metadata.py @@ -0,0 +1,49 @@ +"""Peewee migrations -- 036_add_recordings_stream_metadata.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + 'ALTER TABLE "recordings" ADD COLUMN "stream_type" VARCHAR(8) NOT NULL DEFAULT \'main\'' + ) + migrator.sql( + 'CREATE INDEX IF NOT EXISTS "recordings_camera_stream_type" ON "recordings" ("camera", "stream_type")' + ) + + # nullable so legacy rows stay NULL, meaning unknown + migrator.sql('ALTER TABLE "recordings" ADD COLUMN "has_audio" INTEGER') + migrator.sql('ALTER TABLE "recordings" ADD COLUMN "audio_rate" INTEGER') + migrator.sql('ALTER TABLE "recordings" ADD COLUMN "audio_codec" TEXT') + migrator.sql('ALTER TABLE "recordings" ADD COLUMN "video_codec" TEXT') + + # keyframe offsets in ms from segment start; nullable so legacy rows + # stay NULL and playback serves whole files for them + migrator.sql('ALTER TABLE "recordings" ADD COLUMN "keyframes" TEXT') + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/web/e2e/fixtures/mock-data/config-schema.json b/web/e2e/fixtures/mock-data/config-schema.json index a4956b0e84..fcf280374f 100644 --- a/web/e2e/fixtures/mock-data/config-schema.json +++ b/web/e2e/fixtures/mock-data/config-schema.json @@ -1 +1 @@ -{"$defs": {"AlertsConfig": {"additionalProperties": false, "description": "Configure alerts", "properties": {"enabled": {"default": true, "description": "Enable or disable alert generation for all cameras; can be overridden per-camera.", "title": "Enable alerts", "type": "boolean"}, "labels": {"default": ["person", "car"], "description": "List of object labels that qualify as alerts (for example: car, person).", "items": {"type": "string"}, "title": "Alert labels", "type": "array"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered an alert; leave empty to allow any zone.", "title": "Required zones"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether alerts were originally enabled in the static configuration.", "title": "Original alerts state"}, "cutoff_time": {"default": 40, "description": "Seconds to wait after no alert-causing activity before cutting off an alert.", "title": "Alerts cutoff time", "type": "integer"}}, "title": "AlertsConfig", "type": "object"}, "AudioConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable audio event detection for all cameras; can be overridden per-camera.", "title": "Enable audio detection", "type": "boolean"}, "max_not_heard": {"default": 30, "description": "Amount of seconds without the configured audio type before the audio event is ended.", "title": "End timeout", "type": "integer"}, "min_volume": {"default": 500, "description": "Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", "title": "Minimum volume", "type": "integer"}, "listen": {"default": ["bark", "fire_alarm", "speech", "yell"], "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", "items": {"type": "string"}, "title": "Listen types", "type": "array"}, "filters": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/AudioFilterConfig"}, "type": "object"}, {"type": "null"}], "default": null, "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", "title": "Audio filters"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether audio detection was originally enabled in the static config file.", "title": "Original audio state"}, "num_threads": {"default": 2, "description": "Number of threads to use for audio detection processing.", "minimum": 1, "title": "Detection threads", "type": "integer"}}, "title": "AudioConfig", "type": "object"}, "AudioFilterConfig": {"additionalProperties": false, "properties": {"threshold": {"default": 0.8, "description": "Minimum confidence threshold for the audio event to be counted.", "exclusiveMaximum": 1.0, "minimum": 0.5, "title": "Minimum audio confidence", "type": "number"}}, "title": "AudioFilterConfig", "type": "object"}, "AudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", "title": "Enable audio transcription", "type": "boolean"}, "language": {"default": "en", "description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "device": {"$ref": "#/$defs/EnrichmentsDeviceEnum", "default": "CPU", "description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", "title": "Transcription device"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for offline audio event transcription.", "title": "Model size"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "AudioTranscriptionConfig", "type": "object"}, "AuthConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable native authentication for the Frigate UI.", "title": "Enable authentication", "type": "boolean"}, "reset_admin_password": {"default": false, "description": "If true, reset the admin user's password on startup and print the new password in logs.", "title": "Reset admin password", "type": "boolean"}, "cookie_name": {"default": "frigate_token", "description": "Name of the cookie used to store the JWT token for native authentication.", "pattern": "^[a-z_]+$", "title": "JWT cookie name", "type": "string"}, "cookie_secure": {"default": false, "description": "Set the secure flag on the auth cookie; should be true when using TLS.", "title": "Secure cookie flag", "type": "boolean"}, "session_length": {"default": 86400, "description": "Session duration in seconds for JWT-based sessions.", "minimum": 60, "title": "Session length", "type": "integer"}, "refresh_time": {"default": 1800, "description": "When a session is within this many seconds of expiring, refresh it back to full length.", "minimum": 30, "title": "Session refresh window", "type": "integer"}, "failed_login_rate_limit": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Rate limiting rules for failed login attempts to reduce brute-force attacks.", "title": "Failed login limits"}, "trusted_proxies": {"default": [], "description": "List of trusted proxy IPs used when determining client IP for rate limiting.", "items": {"type": "string"}, "title": "Trusted proxies", "type": "array"}, "hash_iterations": {"default": 600000, "description": "Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", "title": "Hash iterations", "type": "integer"}, "roles": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "description": "Map roles to camera lists. An empty list grants access to all cameras for the role.", "title": "Role mappings", "type": "object"}, "admin_first_time_login": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. ", "title": "First-time admin flag"}}, "title": "AuthConfig", "type": "object"}, "BaseDetectorConfig": {"additionalProperties": true, "properties": {"type": {"default": "cpu", "description": "Type of detector to use for object detection (for example 'cpu', 'edgetpu', 'openvino').", "title": "Detector Type", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/ModelConfig"}, {"type": "null"}], "default": null, "description": "Detector-specific model configuration options (path, input size, etc.).", "title": "Detector specific model configuration"}, "model_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "File path to the detector model binary if required by the chosen detector.", "title": "Detector specific model path"}}, "title": "BaseDetectorConfig", "type": "object"}, "BirdClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable bird classification.", "title": "Bird classification", "type": "boolean"}, "threshold": {"default": 0.9, "description": "Minimum classification score required to accept a bird classification.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Minimum score", "type": "number"}}, "title": "BirdClassificationConfig", "type": "object"}, "BirdseyeCameraConfig": {"properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeConfig", "description": "Activity types that include cameras in Birdseye.", "title": "Activity types"}, "order": {"default": 0, "description": "Numeric position controlling the camera's ordering in the Birdseye layout.", "title": "Position", "type": "integer"}}, "title": "BirdseyeCameraConfig", "type": "object"}, "BirdseyeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeConfig", "description": "Activity types that include cameras in Birdseye.", "title": "Activity types"}, "restream": {"default": false, "description": "Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", "title": "Restream RTSP", "type": "boolean"}, "width": {"default": 1280, "description": "Output width (pixels) of the composed Birdseye frame.", "title": "Width", "type": "integer"}, "height": {"default": 720, "description": "Output height (pixels) of the composed Birdseye frame.", "title": "Height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Encoding quality", "type": "integer"}, "inactivity_threshold": {"default": 30, "description": "Seconds of inactivity after which a camera will stop being shown in Birdseye.", "exclusiveMinimum": 0, "title": "Inactivity threshold", "type": "integer"}, "layout": {"$ref": "#/$defs/BirdseyeLayoutConfig", "description": "Layout options for the Birdseye composition.", "title": "Layout"}, "idle_heartbeat_fps": {"default": 0.0, "description": "Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", "maximum": 10.0, "minimum": 0.0, "title": "Idle heartbeat FPS", "type": "number"}}, "title": "BirdseyeConfig", "type": "object"}, "BirdseyeLayoutConfig": {"additionalProperties": false, "properties": {"scaling_factor": {"default": 2.0, "description": "Scaling factor used by the layout calculator (range 1.0 to 5.0).", "maximum": 5.0, "minimum": 1.0, "title": "Scaling factor", "type": "number"}, "max_cameras": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", "title": "Max cameras"}}, "title": "BirdseyeLayoutConfig", "type": "object"}, "CameraAudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable manually triggered audio event transcription.", "title": "Enable transcription", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Original transcription state"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "CameraAudioTranscriptionConfig", "type": "object"}, "CameraConfig": {"additionalProperties": false, "properties": {"name": {"anyOf": [{"pattern": "^[a-zA-Z0-9_-]+$", "type": "string"}, {"type": "null"}], "default": null, "description": "Camera name is required", "title": "Camera name"}, "friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Camera friendly name used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enabled", "title": "Enabled", "type": "boolean"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for this camera.", "title": "Audio detection"}, "audio_transcription": {"$ref": "#/$defs/CameraAudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "birdseye": {"$ref": "#/$defs/BirdseyeCameraConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "face_recognition": {"$ref": "#/$defs/CameraFaceRecognitionConfig", "description": "Settings for face detection and recognition for this camera.", "title": "Face recognition"}, "ffmpeg": {"$ref": "#/$defs/CameraFfmpegConfig", "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "title": "Streams (FFmpeg)"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings used by the Web UI to control live stream selection, resolution and quality.", "title": "Live playback"}, "lpr": {"$ref": "#/$defs/CameraLicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "motion": {"$ref": "#/$defs/MotionConfig", "default": null, "description": "Default motion detection settings for this camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings for this camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", "title": "Review"}, "semantic_search": {"$ref": "#/$defs/CameraSemanticSearchConfig", "description": "Settings for semantic search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for this camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for timestamps applied to snapshots and Debug view.", "title": "Timestamp style"}, "best_image_timeout": {"default": 60, "description": "How long to wait for the image with the highest confidence score.", "title": "Best image timeout", "type": "integer"}, "mqtt": {"$ref": "#/$defs/CameraMqttConfig", "description": "MQTT image publishing settings.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for this camera.", "title": "Notifications"}, "onvif": {"$ref": "#/$defs/OnvifConfig", "description": "ONVIF connection and PTZ autotracking settings for this camera.", "title": "ONVIF"}, "type": {"$ref": "#/$defs/CameraTypeEnum", "default": "generic", "description": "Camera Type", "title": "Camera type"}, "ui": {"$ref": "#/$defs/CameraUiConfig", "description": "Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", "title": "Camera UI"}, "webui_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to visit the camera directly from system page", "title": "Camera URL"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/CameraProfileConfig"}, "description": "Named config profiles with partial overrides that can be activated at runtime.", "title": "Profiles", "type": "object"}, "zones": {"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", "title": "Zones", "type": "object"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Keep track of original state of camera.", "title": "Original camera state"}}, "required": ["ffmpeg"], "title": "CameraConfig", "type": "object"}, "CameraFaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition.", "title": "Enable face recognition", "type": "boolean"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}}, "title": "CameraFaceRecognitionConfig", "type": "object"}, "CameraFfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}, "inputs": {"description": "List of input stream definitions (paths and roles) for this camera.", "items": {"$ref": "#/$defs/CameraInput"}, "title": "Camera inputs", "type": "array"}}, "required": ["inputs"], "title": "CameraFfmpegConfig", "type": "object"}, "CameraGroupConfig": {"additionalProperties": false, "properties": {"cameras": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Array of camera names included in this group.", "title": "Camera list"}, "icon": {"default": "generic", "description": "Icon used to represent the camera group in the UI.", "title": "Group icon", "type": "string"}, "order": {"default": 0, "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later.", "title": "Sort order", "type": "integer"}}, "title": "CameraGroupConfig", "type": "object"}, "CameraInput": {"additionalProperties": false, "properties": {"path": {"description": "Camera input stream URL or path.", "title": "Input path", "type": "string"}, "roles": {"description": "Roles for this input stream.", "items": {"$ref": "#/$defs/CameraRoleEnum"}, "title": "Input roles", "type": "array"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "FFmpeg global arguments for this input stream.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Hardware acceleration arguments for this input stream.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Input arguments specific to this stream.", "title": "Input arguments"}}, "required": ["path", "roles"], "title": "CameraInput", "type": "object"}, "CameraLicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable LPR on this camera.", "title": "Enable LPR", "type": "boolean"}, "expire_time": {"default": 3, "description": "Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", "exclusiveMinimum": 0, "title": "Expire seconds", "type": "integer"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}}, "title": "CameraLicensePlateRecognitionConfig", "type": "object"}, "CameraLiveConfig": {"additionalProperties": false, "properties": {"streams": {"additionalProperties": {"type": "string"}, "description": "Mapping of configured stream names to restream/go2rtc names used for live playback.", "title": "Live stream names", "type": "object"}, "height": {"default": 720, "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", "title": "Live height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Live quality", "type": "integer"}}, "title": "CameraLiveConfig", "type": "object"}, "CameraMqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable publishing image snapshots for objects to MQTT topics for this camera.", "title": "Send image", "type": "boolean"}, "timestamp": {"default": true, "description": "Overlay a timestamp on images published to MQTT.", "title": "Add timestamp", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes on images published over MQTT.", "title": "Add bounding box", "type": "boolean"}, "crop": {"default": true, "description": "Crop images published to MQTT to the detected object's bounding box.", "title": "Crop image", "type": "boolean"}, "height": {"default": 270, "description": "Height (pixels) to resize images published over MQTT.", "title": "Image height", "type": "integer"}, "required_zones": {"description": "Zones that an object must enter for an MQTT image to be published.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "quality": {"default": 70, "description": "JPEG quality for images published to MQTT (0-100).", "maximum": 100, "minimum": 0, "title": "JPEG quality", "type": "integer"}}, "title": "CameraMqttConfig", "type": "object"}, "CameraProfileConfig": {"additionalProperties": false, "description": "A named profile containing partial camera config overrides.\n\nSections set to None inherit from the camera's base config.\nSections that are defined get Pydantic-validated, then only\nexplicitly-set fields are used as overrides via exclude_unset.", "properties": {"enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Enabled"}, "audio": {"anyOf": [{"$ref": "#/$defs/AudioConfig"}, {"type": "null"}], "default": null}, "birdseye": {"anyOf": [{"$ref": "#/$defs/BirdseyeCameraConfig"}, {"type": "null"}], "default": null}, "detect": {"anyOf": [{"$ref": "#/$defs/DetectConfig"}, {"type": "null"}], "default": null}, "face_recognition": {"anyOf": [{"$ref": "#/$defs/CameraFaceRecognitionConfig"}, {"type": "null"}], "default": null}, "lpr": {"anyOf": [{"$ref": "#/$defs/CameraLicensePlateRecognitionConfig"}, {"type": "null"}], "default": null}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null}, "notifications": {"anyOf": [{"$ref": "#/$defs/NotificationConfig"}, {"type": "null"}], "default": null}, "objects": {"anyOf": [{"$ref": "#/$defs/ObjectConfig"}, {"type": "null"}], "default": null}, "record": {"anyOf": [{"$ref": "#/$defs/RecordConfig"}, {"type": "null"}], "default": null}, "review": {"anyOf": [{"$ref": "#/$defs/ReviewConfig"}, {"type": "null"}], "default": null}, "snapshots": {"anyOf": [{"$ref": "#/$defs/SnapshotsConfig"}, {"type": "null"}], "default": null}, "zones": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Zones"}}, "title": "CameraProfileConfig", "type": "object"}, "CameraRoleEnum": {"enum": ["audio", "record", "detect"], "title": "CameraRoleEnum", "type": "string"}, "CameraSemanticSearchConfig": {"additionalProperties": false, "properties": {"triggers": {"additionalProperties": {"$ref": "#/$defs/TriggerConfig"}, "default": {}, "description": "Actions and matching criteria for camera-specific semantic search triggers.", "title": "Triggers", "type": "object"}}, "title": "CameraSemanticSearchConfig", "type": "object"}, "CameraTypeEnum": {"enum": ["generic", "lpr"], "title": "CameraTypeEnum", "type": "string"}, "CameraUiConfig": {"additionalProperties": false, "properties": {"order": {"default": 0, "description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", "title": "UI order", "type": "integer"}, "dashboard": {"default": true, "description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.", "title": "Show in UI", "type": "boolean"}, "review": {"default": true, "description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", "title": "Show in review", "type": "boolean"}}, "title": "CameraUiConfig", "type": "object"}, "ClassificationConfig": {"additionalProperties": false, "properties": {"bird": {"$ref": "#/$defs/BirdClassificationConfig", "description": "Settings specific to bird classification models.", "title": "Bird classification config"}, "custom": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationConfig"}, "default": {}, "description": "Configuration for custom classification models used for objects or state detection.", "title": "Custom Classification Models", "type": "object"}}, "title": "ClassificationConfig", "type": "object"}, "ColorConfig": {"additionalProperties": false, "properties": {"red": {"default": 255, "description": "Red component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Red", "type": "integer"}, "green": {"default": 255, "description": "Green component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Green", "type": "integer"}, "blue": {"default": 255, "description": "Blue component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Blue", "type": "integer"}}, "title": "ColorConfig", "type": "object"}, "CustomClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the custom classification model.", "title": "Enable model", "type": "boolean"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Identifier for the custom classification model to use.", "title": "Model name"}, "threshold": {"default": 0.8, "description": "Score threshold used to change the classification state.", "title": "Score threshold", "type": "number"}, "save_attempts": {"anyOf": [{"minimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How many classification attempts to save for recent classifications UI.", "title": "Save attempts"}, "object_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationObjectConfig"}, {"type": "null"}], "default": null}, "state_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationStateConfig"}, {"type": "null"}], "default": null}}, "title": "CustomClassificationConfig", "type": "object"}, "CustomClassificationObjectConfig": {"additionalProperties": false, "properties": {"objects": {"description": "List of object types to run object classification on.", "items": {"type": "string"}, "title": "Classify objects", "type": "array"}, "classification_type": {"$ref": "#/$defs/ObjectClassificationType", "default": "sub_label", "description": "Classification type applied: 'sub_label' (adds sub_label) or other supported types.", "title": "Classification type"}}, "title": "CustomClassificationObjectConfig", "type": "object"}, "CustomClassificationStateCameraConfig": {"additionalProperties": false, "properties": {"crop": {"description": "Crop coordinates to use for running classification on this camera.", "items": {"type": "number"}, "title": "Classification crop", "type": "array"}}, "required": ["crop"], "title": "CustomClassificationStateCameraConfig", "type": "object"}, "CustomClassificationStateConfig": {"additionalProperties": false, "properties": {"cameras": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationStateCameraConfig"}, "description": "Per-camera crop and settings for running state classification.", "title": "Classification cameras", "type": "object"}, "motion": {"default": false, "description": "If true, run classification when motion is detected within the specified crop.", "title": "Run on motion", "type": "boolean"}, "interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "Interval (seconds) between periodic classification runs for state classification.", "title": "Classification interval"}}, "required": ["cameras"], "title": "CustomClassificationStateConfig", "type": "object"}, "DatabaseConfig": {"additionalProperties": false, "properties": {"path": {"default": "/config/frigate.db", "description": "Filesystem path where the Frigate SQLite database file will be stored.", "title": "Database path", "type": "string"}}, "title": "DatabaseConfig", "type": "object"}, "DetectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable object detection for all cameras; can be overridden per-camera.", "title": "Enable object detection", "type": "boolean"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect height"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect width"}, "fps": {"default": 5, "description": "Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", "title": "Detect FPS", "type": "integer"}, "min_initialized": {"anyOf": [{"minimum": 2, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", "title": "Minimum initialization frames"}, "max_disappeared": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames without a detection before a tracked object is considered gone.", "title": "Maximum disappeared frames"}, "stationary": {"$ref": "#/$defs/StationaryConfig", "description": "Settings to detect and manage objects that remain stationary for a period of time.", "title": "Stationary objects config"}, "annotation_offset": {"default": 0, "description": "Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", "title": "Annotation offset", "type": "integer"}}, "title": "DetectConfig", "type": "object"}, "DetectionsConfig": {"additionalProperties": false, "description": "Configure detections", "properties": {"enabled": {"default": true, "description": "Enable or disable detection events for all cameras; can be overridden per-camera.", "title": "Enable detections", "type": "boolean"}, "labels": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "List of object labels that qualify as detection events.", "title": "Detection labels"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered a detection; leave empty to allow any zone.", "title": "Required zones"}, "cutoff_time": {"default": 30, "description": "Seconds to wait after no detection-causing activity before cutting off a detection.", "title": "Detections cutoff time", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether detections were originally enabled in the static configuration.", "title": "Original detections state"}}, "title": "DetectionsConfig", "type": "object"}, "EnrichmentsDeviceEnum": {"enum": ["GPU", "CPU"], "title": "EnrichmentsDeviceEnum", "type": "string"}, "EventsConfig": {"additionalProperties": false, "properties": {"pre_capture": {"default": 5, "description": "Number of seconds before the detection event to include in the recording.", "maximum": 60, "minimum": 0, "title": "Pre-capture seconds", "type": "integer"}, "post_capture": {"default": 5, "description": "Number of seconds after the detection event to include in the recording.", "minimum": 0, "title": "Post-capture seconds", "type": "integer"}, "retain": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for recordings of detection events.", "title": "Event retention"}}, "title": "EventsConfig", "type": "object"}, "FaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition for all cameras; can be overridden per-camera.", "title": "Enable face recognition", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for face embeddings (small/large); larger may require GPU.", "title": "Model size"}, "unknown_score": {"default": 0.8, "description": "Distance threshold below which a face is considered a potential match (higher = stricter).", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Unknown score threshold", "type": "number"}, "detection_threshold": {"default": 0.7, "description": "Minimum detection confidence required to consider a face detection valid.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "recognition_threshold": {"default": 0.9, "description": "Face embedding distance threshold to consider two faces a match.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}, "min_faces": {"default": 1, "description": "Minimum number of face recognitions required before applying a recognized sub-label to a person.", "exclusiveMinimum": 0, "maximum": 6, "title": "Minimum faces", "type": "integer"}, "save_attempts": {"default": 200, "description": "Number of face recognition attempts to retain for recent recognition UI.", "minimum": 0, "title": "Save attempts", "type": "integer"}, "blur_confidence_filter": {"default": true, "description": "Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", "title": "Blur confidence filter", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "FaceRecognitionConfig", "type": "object"}, "FfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}}, "title": "FfmpegConfig", "type": "object"}, "FfmpegOutputArgsConfig": {"additionalProperties": false, "properties": {"detect": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "description": "Default output arguments for detect role streams.", "title": "Detect output arguments"}, "record": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-record-generic-audio-aac", "description": "Default output arguments for record role streams.", "title": "Record output arguments"}}, "title": "FfmpegOutputArgsConfig", "type": "object"}, "FilterConfig": {"additionalProperties": false, "properties": {"min_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 0, "description": "Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Minimum object area"}, "max_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 24000000, "description": "Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Maximum object area"}, "min_ratio": {"default": 0, "description": "Minimum width/height ratio required for the bounding box to qualify.", "title": "Minimum aspect ratio", "type": "number"}, "max_ratio": {"default": 24000000, "description": "Maximum width/height ratio allowed for the bounding box to qualify.", "title": "Maximum aspect ratio", "type": "number"}, "threshold": {"default": 0.7, "description": "Average detection confidence threshold required for the object to be considered a true positive.", "title": "Confidence threshold", "type": "number"}, "min_score": {"default": 0.5, "description": "Minimum single-frame detection confidence required for the object to be counted.", "title": "Minimum confidence", "type": "number"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Polygon coordinates defining where this filter applies within the frame.", "title": "Filter mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "FilterConfig", "type": "object"}, "GenAIConfig": {"additionalProperties": false, "description": "Primary GenAI Config to define GenAI Provider.", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "API key required by some providers (can also be set via environment variables).", "title": "API key"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Base URL for self-hosted or compatible providers (for example an Ollama instance).", "title": "Base URL"}, "model": {"default": "", "description": "The model to use from the provider for generating descriptions or summaries.", "title": "Model", "type": "string"}, "provider": {"$ref": "#/$defs/GenAIProviderEnum", "description": "The GenAI provider to use (for example: ollama, gemini, openai).", "title": "Provider"}, "roles": {"description": "GenAI roles (chat, descriptions, embeddings); one provider per role.", "items": {"$ref": "#/$defs/GenAIRoleEnum"}, "title": "Roles", "type": "array"}, "provider_options": {"additionalProperties": {}, "default": {}, "description": "Additional provider-specific options to pass to the GenAI client.", "title": "Provider options", "type": "object"}, "runtime_options": {"additionalProperties": {}, "default": {}, "description": "Runtime options passed to the provider for each inference call.", "title": "Runtime options", "type": "object"}}, "required": ["provider"], "title": "GenAIConfig", "type": "object"}, "GenAIObjectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable GenAI generation of descriptions for tracked objects by default.", "title": "Enable GenAI", "type": "boolean"}, "use_snapshot": {"default": false, "description": "Use object snapshots instead of thumbnails for GenAI description generation.", "title": "Use snapshots", "type": "boolean"}, "prompt": {"default": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "description": "Default prompt template used when generating descriptions with GenAI.", "title": "Caption prompt", "type": "string"}, "object_prompts": {"additionalProperties": {"type": "string"}, "description": "Per-object prompts to customize GenAI outputs for specific labels.", "title": "Object prompts", "type": "object"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object labels to send to GenAI by default.", "title": "GenAI objects"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that must be entered for objects to qualify for GenAI description generation.", "title": "Required zones"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails sent to GenAI for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "send_triggers": {"$ref": "#/$defs/GenAIObjectTriggerConfig", "description": "Defines when frames should be sent to GenAI (on end, after updates, etc.).", "title": "GenAI triggers"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether GenAI was enabled in the original static config.", "title": "Original GenAI state"}}, "title": "GenAIObjectConfig", "type": "object"}, "GenAIObjectTriggerConfig": {"additionalProperties": false, "properties": {"tracked_object_end": {"default": true, "description": "Send a request to GenAI when the tracked object ends.", "title": "Send on end", "type": "boolean"}, "after_significant_updates": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Send a request to GenAI after a specified number of significant updates for the tracked object.", "title": "Early GenAI trigger"}}, "title": "GenAIObjectTriggerConfig", "type": "object"}, "GenAIProviderEnum": {"enum": ["openai", "azure_openai", "gemini", "ollama", "llamacpp"], "title": "GenAIProviderEnum", "type": "string"}, "GenAIReviewConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable GenAI-generated descriptions and summaries for review items.", "title": "Enable GenAI descriptions", "type": "boolean"}, "alerts": {"default": true, "description": "Use GenAI to generate descriptions for alert items.", "title": "Enable GenAI for alerts", "type": "boolean"}, "detections": {"default": false, "description": "Use GenAI to generate descriptions for detection items.", "title": "Enable GenAI for detections", "type": "boolean"}, "image_source": {"$ref": "#/$defs/ImageSourceEnum", "default": "preview", "description": "Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", "title": "Review image source"}, "additional_concerns": {"default": [], "description": "A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", "items": {"type": "string"}, "title": "Additional concerns", "type": "array"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails that are sent to the GenAI provider for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether GenAI review was originally enabled in the static configuration.", "title": "Original GenAI state"}, "preferred_language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Preferred language to request from the GenAI provider for generated responses.", "title": "Preferred language"}, "activity_context_prompt": {"default": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.", "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", "title": "Activity context prompt", "type": "string"}}, "title": "GenAIReviewConfig", "type": "object"}, "GenAIRoleEnum": {"enum": ["chat", "descriptions", "embeddings"], "title": "GenAIRoleEnum", "type": "string"}, "HeaderMappingConfig": {"additionalProperties": false, "properties": {"user": {"default": null, "description": "Header containing the authenticated username provided by the upstream proxy.", "title": "User header", "type": "string"}, "role": {"default": null, "description": "Header containing the authenticated user's role or groups from the upstream proxy.", "title": "Role header", "type": "string"}, "role_map": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "description": "Map upstream group values to Frigate roles (for example map admin groups to the admin role).", "title": "Role mapping"}}, "title": "HeaderMappingConfig", "type": "object"}, "IPv6Config": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable IPv6 support for Frigate services (API and UI) where applicable.", "title": "Enable IPv6", "type": "boolean"}}, "title": "IPv6Config", "type": "object"}, "ImageSourceEnum": {"description": "Image source options for GenAI Review.", "enum": ["preview", "recordings"], "title": "ImageSourceEnum", "type": "string"}, "InputDTypeEnum": {"enum": ["float", "float_denorm", "int"], "title": "InputDTypeEnum", "type": "string"}, "InputTensorEnum": {"enum": ["nchw", "nhwc", "hwnc", "hwcn"], "title": "InputTensorEnum", "type": "string"}, "LicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable license plate recognition for all cameras; can be overridden per-camera.", "title": "Enable LPR", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size used for text detection/recognition. Most users should use 'small'.", "title": "Model size"}, "detection_threshold": {"default": 0.7, "description": "Detection confidence threshold to begin running OCR on a suspected plate.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "recognition_threshold": {"default": 0.9, "description": "Confidence threshold required for recognized plate text to be attached as a sub-label.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_plate_length": {"default": 4, "description": "Minimum number of characters a recognized plate must contain to be considered valid.", "title": "Min plate length", "type": "integer"}, "format": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional regex to validate recognized plate strings against an expected format.", "title": "Plate format regex"}, "match_distance": {"default": 1, "description": "Number of character mismatches allowed when comparing detected plates to known plates.", "minimum": 0, "title": "Match distance", "type": "integer"}, "known_plates": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "default": {}, "description": "List of plates or regexes to specially track or alert on.", "title": "Known plates"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}, "debug_save_plates": {"default": false, "description": "Save plate crop images for debugging LPR performance.", "title": "Save debug plates", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}, "replace_rules": {"description": "Regex replacement rules used to normalize detected plate strings before matching.", "items": {"$ref": "#/$defs/ReplaceRule"}, "title": "Replacement rules", "type": "array"}}, "title": "LicensePlateRecognitionConfig", "type": "object"}, "ListenConfig": {"additionalProperties": false, "properties": {"internal": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 5000, "description": "Internal listening port for Frigate (default 5000).", "title": "Internal port"}, "external": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 8971, "description": "External listening port for Frigate (default 8971).", "title": "External port"}}, "title": "ListenConfig", "type": "object"}, "LogLevel": {"enum": ["debug", "info", "warning", "error", "critical"], "title": "LogLevel", "type": "string"}, "LoggerConfig": {"additionalProperties": false, "properties": {"default": {"$ref": "#/$defs/LogLevel", "default": "info", "title": "Logging level", "description": "Default global log verbosity (debug, info, warning, error)."}, "logs": {"additionalProperties": {"$ref": "#/$defs/LogLevel"}, "description": "Per-component log level overrides to increase or decrease verbosity for specific modules.", "title": "Per-process log level", "type": "object"}}, "title": "LoggerConfig", "type": "object"}, "ModelConfig": {"additionalProperties": false, "properties": {"path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a custom detection model file (or plus:// for Frigate+ models).", "title": "Custom object detector model path"}, "labelmap_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a labelmap file that maps numeric classes to string labels for the detector.", "title": "Label map for custom object detector"}, "width": {"default": 320, "description": "Width of the model input tensor in pixels.", "title": "Object detection model input width", "type": "integer"}, "height": {"default": 320, "description": "Height of the model input tensor in pixels.", "title": "Object detection model input height", "type": "integer"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard labelmap.", "title": "Labelmap customization", "type": "object"}, "attributes_map": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "default": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", "title": "Map of object labels to their attribute labels", "type": "object"}, "input_tensor": {"$ref": "#/$defs/InputTensorEnum", "default": "nhwc", "description": "Tensor format expected by the model: 'nhwc' or 'nchw'.", "title": "Model Input Tensor Shape"}, "input_pixel_format": {"$ref": "#/$defs/PixelFormatEnum", "default": "rgb", "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", "title": "Model Input Pixel Color Format"}, "input_dtype": {"$ref": "#/$defs/InputDTypeEnum", "default": "int", "description": "Data type of the model input tensor (for example 'float32').", "title": "Model Input D Type"}, "model_type": {"$ref": "#/$defs/ModelTypeEnum", "default": "ssd", "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization.", "title": "Object Detection Model Type"}}, "title": "ModelConfig", "type": "object"}, "ModelSizeEnum": {"enum": ["small", "large"], "title": "ModelSizeEnum", "type": "string"}, "ModelTypeEnum": {"enum": ["dfine", "rfdetr", "ssd", "yolox", "yolonas", "yolo-generic"], "title": "ModelTypeEnum", "type": "string"}, "MotionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable motion detection for all cameras; can be overridden per-camera.", "title": "Enable motion detection", "type": "boolean"}, "threshold": {"default": 30, "description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", "maximum": 255, "minimum": 1, "title": "Motion threshold", "type": "integer"}, "lightning_threshold": {"default": 0.8, "description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", "maximum": 1.0, "minimum": 0.3, "title": "Lightning threshold", "type": "number"}, "skip_motion_threshold": {"anyOf": [{"maximum": 1.0, "minimum": 0.0, "type": "number"}, {"type": "null"}], "default": null, "description": "If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto\u2011tracking an object. The trade\u2011off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", "title": "Skip motion threshold"}, "improve_contrast": {"default": true, "description": "Apply contrast improvement to frames before motion analysis to help detection.", "title": "Improve contrast", "type": "boolean"}, "contour_area": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 10, "description": "Minimum contour area in pixels required for a motion contour to be counted.", "title": "Contour area"}, "delta_alpha": {"default": 0.2, "description": "Alpha blending factor used in frame differencing for motion calculation.", "title": "Delta alpha", "type": "number"}, "frame_alpha": {"default": 0.01, "description": "Alpha value used when blending frames for motion preprocessing.", "title": "Frame alpha", "type": "number"}, "frame_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 100, "description": "Height in pixels to scale frames to when computing motion.", "title": "Frame height"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Mask coordinates", "type": "object"}, "mqtt_off_delay": {"default": 30, "description": "Seconds to wait after last motion before publishing an MQTT 'off' state.", "title": "MQTT off delay", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether motion detection was enabled in the original static configuration.", "title": "Original motion state"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "MotionConfig", "type": "object"}, "MotionMaskConfig": {"additionalProperties": false, "description": "Configuration for a single motion mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this motion mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this motion mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of motion mask."}}, "title": "MotionMaskConfig", "type": "object"}, "MqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable MQTT integration for state, events, and snapshots.", "title": "Enable MQTT", "type": "boolean"}, "host": {"default": "", "description": "Hostname or IP address of the MQTT broker.", "title": "MQTT host", "type": "string"}, "port": {"default": 1883, "description": "Port of the MQTT broker (usually 1883 for plain MQTT).", "title": "MQTT port", "type": "integer"}, "topic_prefix": {"default": "frigate", "description": "MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", "title": "Topic prefix", "type": "string"}, "client_id": {"default": "frigate", "description": "Client identifier used when connecting to the MQTT broker; should be unique per instance.", "title": "Client ID", "type": "string"}, "stats_interval": {"default": 60, "description": "Interval in seconds for publishing system and camera stats to MQTT.", "minimum": 15, "title": "Stats interval", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT username; can be provided via environment variables or secrets.", "title": "MQTT username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT password; can be provided via environment variables or secrets.", "title": "MQTT password"}, "tls_ca_certs": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to CA certificate for TLS connections to the broker (for self-signed certs).", "title": "TLS CA certs"}, "tls_client_cert": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", "title": "Client cert"}, "tls_client_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Private key path for the client certificate.", "title": "Client key"}, "tls_insecure": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Allow insecure TLS connections by skipping hostname verification (not recommended).", "title": "TLS insecure"}, "qos": {"default": 0, "description": "Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", "title": "MQTT QoS", "type": "integer"}}, "title": "MqttConfig", "type": "object"}, "NetworkingConfig": {"additionalProperties": false, "properties": {"ipv6": {"$ref": "#/$defs/IPv6Config", "description": "IPv6-specific settings for Frigate network services.", "title": "IPv6 configuration"}, "listen": {"$ref": "#/$defs/ListenConfig", "description": "Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", "title": "Listening ports configuration"}}, "title": "NetworkingConfig", "type": "object"}, "NotificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable notifications for all cameras; can be overridden per-camera.", "title": "Enable notifications", "type": "boolean"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Email address used for push notifications or required by certain notification providers.", "title": "Notification email"}, "cooldown": {"default": 0, "description": "Cooldown (seconds) between notifications to avoid spamming recipients.", "minimum": 0, "title": "Cooldown period", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether notifications were enabled in the original static configuration.", "title": "Original notifications state"}}, "title": "NotificationConfig", "type": "object"}, "ObjectClassificationType": {"enum": ["sub_label", "attribute"], "title": "ObjectClassificationType", "type": "string"}, "ObjectConfig": {"additionalProperties": false, "properties": {"track": {"default": ["person"], "description": "List of object labels to track for all cameras; can be overridden per-camera.", "items": {"type": "string"}, "title": "Objects to track", "type": "array"}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters applied to detected objects to reduce false positives (area, ratio, confidence).", "title": "Object filters", "type": "object"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Mask polygon used to prevent object detection in specified areas.", "title": "Object mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}, "genai": {"$ref": "#/$defs/GenAIObjectConfig", "description": "GenAI options for describing tracked objects and sending frames for generation.", "title": "GenAI object config"}}, "title": "ObjectConfig", "type": "object"}, "ObjectMaskConfig": {"additionalProperties": false, "description": "Configuration for a single object mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this object mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this object mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of object mask."}}, "title": "ObjectMaskConfig", "type": "object"}, "OnvifConfig": {"additionalProperties": false, "properties": {"host": {"default": "", "description": "Host (and optional scheme) for the ONVIF service for this camera.", "title": "ONVIF host", "type": "string"}, "port": {"default": 8000, "description": "Port number for the ONVIF service.", "title": "ONVIF port", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Username for ONVIF authentication; some devices require admin user for ONVIF.", "title": "ONVIF username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Password for ONVIF authentication.", "title": "ONVIF password"}, "tls_insecure": {"default": false, "description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", "title": "Disable TLS verify", "type": "boolean"}, "profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", "title": "ONVIF profile"}, "autotracking": {"$ref": "#/$defs/PtzAutotrackConfig", "description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", "title": "Autotracking"}, "ignore_time_mismatch": {"default": false, "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", "title": "Ignore time mismatch", "type": "boolean"}}, "title": "OnvifConfig", "type": "object"}, "PixelFormatEnum": {"enum": ["rgb", "bgr", "yuv"], "title": "PixelFormatEnum", "type": "string"}, "ProfileDefinitionConfig": {"additionalProperties": false, "description": "Defines a named profile with a human-readable display name.\n\nThe dict key is the machine name used internally; friendly_name\nis the label shown in the UI and API responses.", "properties": {"friendly_name": {"description": "Display name for this profile shown in the UI.", "title": "Friendly name", "type": "string"}}, "required": ["friendly_name"], "title": "ProfileDefinitionConfig", "type": "object"}, "ProxyConfig": {"additionalProperties": false, "properties": {"header_map": {"$ref": "#/$defs/HeaderMappingConfig", "description": "Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", "title": "Header mapping"}, "logout_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to redirect users to when logging out via the proxy.", "title": "Logout URL"}, "auth_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", "title": "Proxy secret"}, "default_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": "viewer", "description": "Default role assigned to proxy-authenticated users when no role mapping applies.", "title": "Default role"}, "separator": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": ",", "description": "Character used to split multiple values provided in proxy headers.", "title": "Separator character"}}, "title": "ProxyConfig", "type": "object"}, "PtzAutotrackConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic PTZ camera tracking of detected objects.", "title": "Enable Autotracking", "type": "boolean"}, "calibrate_on_startup": {"default": false, "description": "Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", "title": "Calibrate on start", "type": "boolean"}, "zooming": {"$ref": "#/$defs/ZoomingModeEnum", "default": "disabled", "description": "Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", "title": "Zoom mode"}, "zoom_factor": {"default": 0.3, "description": "Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", "maximum": 0.75, "minimum": 0.1, "title": "Zoom factor", "type": "number"}, "track": {"default": ["person"], "description": "List of object types that should trigger autotracking.", "items": {"type": "string"}, "title": "Tracked objects", "type": "array"}, "required_zones": {"description": "Objects must enter one of these zones before autotracking begins.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "return_preset": {"default": "home", "description": "ONVIF preset name configured in camera firmware to return to after tracking ends.", "title": "Return preset", "type": "string"}, "timeout": {"default": 10, "description": "Wait this many seconds after losing tracking before returning camera to preset position.", "title": "Return timeout", "type": "integer"}, "movement_weights": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Calibration values automatically generated by camera calibration. Do not modify manually.", "title": "Movement weights"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Internal field to track whether autotracking was enabled in configuration.", "title": "Original autotrack state"}}, "title": "PtzAutotrackConfig", "type": "object"}, "RecordConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable recording for all cameras; can be overridden per-camera.", "title": "Enable recording", "type": "boolean"}, "expire_interval": {"default": 60, "description": "Minutes between cleanup passes that remove expired recording segments.", "title": "Record cleanup interval", "type": "integer"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Motion retention"}, "detections": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for detection events including pre/post capture durations.", "title": "Detection retention"}, "alerts": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for alert events including pre/post capture durations.", "title": "Alert retention"}, "export": {"$ref": "#/$defs/RecordExportConfig", "description": "Settings used when exporting recordings such as timelapse and hardware acceleration.", "title": "Export config"}, "preview": {"$ref": "#/$defs/RecordPreviewConfig", "description": "Settings controlling the quality of recording previews shown in the UI.", "title": "Preview config"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether recording was enabled in the original static configuration.", "title": "Original recording state"}}, "title": "RecordConfig", "type": "object"}, "RecordExportConfig": {"additionalProperties": false, "properties": {"hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration args to use for export/transcode operations.", "title": "Export hwaccel args"}, "max_concurrent": {"default": 3, "description": "Maximum number of export jobs to process at the same time.", "minimum": 1, "title": "Maximum concurrent exports", "type": "integer"}}, "title": "RecordExportConfig", "type": "object"}, "RecordPreviewConfig": {"additionalProperties": false, "properties": {"quality": {"$ref": "#/$defs/RecordQualityEnum", "default": "medium", "description": "Preview quality level (very_low, low, medium, high, very_high).", "title": "Preview quality"}}, "title": "RecordPreviewConfig", "type": "object"}, "RecordQualityEnum": {"enum": ["very_low", "low", "medium", "high", "very_high"], "title": "RecordQualityEnum", "type": "string"}, "RecordRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 0, "description": "Days to retain recordings.", "minimum": 0.0, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "ReplaceRule": {"additionalProperties": false, "properties": {"pattern": {"title": "Regex pattern", "type": "string"}, "replacement": {"title": "Replacement string", "type": "string"}}, "required": ["pattern", "replacement"], "title": "ReplaceRule", "type": "object"}, "RestreamConfig": {"additionalProperties": true, "properties": {}, "title": "RestreamConfig", "type": "object"}, "RetainConfig": {"additionalProperties": false, "properties": {"default": {"type": "number", "default": 10, "title": "Default retention", "description": "Default number of days to retain snapshots."}, "objects": {"additionalProperties": {"type": "number"}, "description": "Per-object overrides for snapshot retention days.", "title": "Object retention", "type": "object"}}, "title": "RetainConfig", "type": "object"}, "RetainModeEnum": {"enum": ["all", "motion", "active_objects"], "title": "RetainModeEnum", "type": "string"}, "ReviewConfig": {"additionalProperties": false, "properties": {"alerts": {"$ref": "#/$defs/AlertsConfig", "description": "Settings for which tracked objects generate alerts and how alerts are retained.", "title": "Alerts config"}, "detections": {"$ref": "#/$defs/DetectionsConfig", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "title": "Detections config"}, "genai": {"$ref": "#/$defs/GenAIReviewConfig", "description": "Controls use of generative AI for producing descriptions and summaries of review items.", "title": "GenAI config"}}, "title": "ReviewConfig", "type": "object"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 0.0, "title": "Retention days", "type": "number"}, "mode": {"$ref": "#/$defs/RetainModeEnum", "default": "motion", "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", "title": "Retention mode"}}, "title": "ReviewRetainConfig", "type": "object"}, "SemanticSearchConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable the semantic search feature.", "title": "Enable semantic search", "type": "boolean"}, "reindex": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Trigger a full reindex of historical tracked objects into the embeddings database.", "title": "Reindex on startup"}, "model": {"anyOf": [{"$ref": "#/$defs/SemanticSearchModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "jinav1", "description": "The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", "title": "Semantic search model or GenAI provider name"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Select model size; 'small' runs on CPU and 'large' typically requires GPU.", "title": "Model size"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "SemanticSearchConfig", "type": "object"}, "SemanticSearchModelEnum": {"enum": ["jinav1", "jinav2"], "title": "SemanticSearchModelEnum", "type": "string"}, "SnapshotsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera.", "title": "Enable snapshots", "type": "boolean"}, "timestamp": {"default": false, "description": "Overlay a timestamp on snapshots from API.", "title": "Timestamp overlay", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes for tracked objects on snapshots from API.", "title": "Bounding box overlay", "type": "boolean"}, "crop": {"default": false, "description": "Crop snapshots from API to the detected object's bounding box.", "title": "Crop snapshot", "type": "boolean"}, "required_zones": {"description": "Zones an object must enter for a snapshot to be saved.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", "title": "Snapshot height"}, "retain": {"$ref": "#/$defs/RetainConfig", "description": "Retention settings for snapshots including default days and per-object overrides.", "title": "Snapshot retention"}, "quality": {"default": 60, "description": "Encode quality for saved snapshots (0-100).", "maximum": 100, "minimum": 0, "title": "Snapshot quality", "type": "integer"}}, "title": "SnapshotsConfig", "type": "object"}, "StationaryConfig": {"additionalProperties": false, "properties": {"interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How often (in frames) to run a detection check to confirm a stationary object.", "title": "Stationary interval"}, "threshold": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames with no position change required to mark an object as stationary.", "title": "Stationary threshold"}, "max_frames": {"$ref": "#/$defs/StationaryMaxFramesConfig", "description": "Limits how long stationary objects are tracked before being discarded.", "title": "Max frames"}, "classifier": {"default": true, "description": "Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", "title": "Enable visual classifier", "type": "boolean"}}, "title": "StationaryConfig", "type": "object"}, "StationaryMaxFramesConfig": {"additionalProperties": false, "properties": {"default": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "title": "Default max frames", "description": "Default maximum frames to track a stationary object before stopping."}, "objects": {"additionalProperties": {"type": "integer"}, "description": "Per-object overrides for maximum frames to track stationary objects.", "title": "Object max frames", "type": "object"}}, "title": "StationaryMaxFramesConfig", "type": "object"}, "StatsConfig": {"additionalProperties": false, "properties": {"amd_gpu_stats": {"default": true, "description": "Enable collection of AMD GPU statistics if an AMD GPU is present.", "title": "AMD GPU stats", "type": "boolean"}, "intel_gpu_stats": {"default": true, "description": "Enable collection of Intel GPU statistics if an Intel GPU is present.", "title": "Intel GPU stats", "type": "boolean"}, "network_bandwidth": {"default": false, "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", "title": "Network bandwidth", "type": "boolean"}, "intel_gpu_device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", "title": "Intel GPU device"}}, "title": "StatsConfig", "type": "object"}, "TelemetryConfig": {"additionalProperties": false, "properties": {"network_interfaces": {"default": [], "description": "List of network interface name prefixes to monitor for bandwidth statistics.", "items": {"type": "string"}, "title": "Network interfaces", "type": "array"}, "stats": {"$ref": "#/$defs/StatsConfig", "description": "Options to enable/disable collection of various system and GPU statistics.", "title": "System stats"}, "version_check": {"default": true, "description": "Enable an outbound check to detect if a newer Frigate version is available.", "title": "Version check", "type": "boolean"}}, "title": "TelemetryConfig", "type": "object"}, "TimeFormatEnum": {"enum": ["browser", "12hour", "24hour"], "title": "TimeFormatEnum", "type": "string"}, "TimestampEffectEnum": {"enum": ["solid", "shadow"], "title": "TimestampEffectEnum", "type": "string"}, "TimestampPositionEnum": {"enum": ["tl", "tr", "bl", "br"], "title": "TimestampPositionEnum", "type": "string"}, "TimestampStyleConfig": {"additionalProperties": false, "properties": {"position": {"$ref": "#/$defs/TimestampPositionEnum", "default": "tl", "description": "Position of the timestamp on the image (tl/tr/bl/br).", "title": "Timestamp position"}, "format": {"default": "%m/%d/%Y %H:%M:%S", "description": "Datetime format string used for timestamps (Python datetime format codes).", "title": "Timestamp format", "type": "string"}, "color": {"$ref": "#/$defs/ColorConfig", "description": "RGB color values for the timestamp text (all values 0-255).", "title": "Timestamp color"}, "thickness": {"default": 2, "description": "Line thickness of the timestamp text.", "title": "Timestamp thickness", "type": "integer"}, "effect": {"anyOf": [{"$ref": "#/$defs/TimestampEffectEnum"}, {"type": "null"}], "default": null, "description": "Visual effect for the timestamp text (none, solid, shadow).", "title": "Timestamp effect"}}, "title": "TimestampStyleConfig", "type": "object"}, "TlsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable TLS for Frigate's web UI and API on the configured TLS port.", "title": "Enable TLS", "type": "boolean"}}, "title": "TlsConfig", "type": "object"}, "TriggerAction": {"enum": ["notification", "sub_label", "attribute"], "title": "TriggerAction", "type": "string"}, "TriggerConfig": {"additionalProperties": false, "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional friendly name displayed in the UI for this trigger.", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this semantic search trigger.", "title": "Enable this trigger", "type": "boolean"}, "type": {"$ref": "#/$defs/TriggerType", "default": "description", "description": "Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", "title": "Trigger type"}, "data": {"description": "Text phrase or thumbnail ID to match against tracked objects.", "title": "Trigger content", "type": "string"}, "threshold": {"default": 0.8, "description": "Minimum similarity score (0-1) required to activate this trigger.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Trigger threshold", "type": "number"}, "actions": {"default": [], "description": "List of actions to execute when trigger matches (notification, sub_label, attribute).", "items": {"$ref": "#/$defs/TriggerAction"}, "title": "Trigger actions", "type": "array"}}, "required": ["data"], "title": "TriggerConfig", "type": "object"}, "TriggerType": {"enum": ["thumbnail", "description"], "title": "TriggerType", "type": "string"}, "UIConfig": {"additionalProperties": false, "properties": {"timezone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional timezone to display across the UI (defaults to browser local time if unset).", "title": "Timezone"}, "time_format": {"$ref": "#/$defs/TimeFormatEnum", "default": "browser", "description": "Time format to use in the UI (browser, 12hour, or 24hour).", "title": "Time format"}, "unit_system": {"$ref": "#/$defs/UnitSystemEnum", "default": "metric", "description": "Unit system for display (metric or imperial) used in the UI and MQTT.", "title": "Unit system"}}, "title": "UIConfig", "type": "object"}, "UnitSystemEnum": {"enum": ["imperial", "metric"], "title": "UnitSystemEnum", "type": "string"}, "ZoneConfig": {"properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", "title": "Zone name"}, "enabled": {"default": true, "description": "Enable or disable this zone. Disabled zones are ignored at runtime.", "title": "Enabled", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of zone."}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", "title": "Zone filters", "type": "object"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", "title": "Coordinates"}, "distances": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", "title": "Real-world distances"}, "inertia": {"default": 3, "description": "Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", "exclusiveMinimum": 0, "title": "Inertia frames", "type": "integer"}, "loitering_time": {"default": 0, "description": "Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", "minimum": 0, "title": "Loitering seconds", "type": "integer"}, "speed_threshold": {"anyOf": [{"minimum": 0.1, "type": "number"}, {"type": "null"}], "default": null, "description": "Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", "title": "Minimum speed"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", "title": "Trigger objects"}}, "required": ["coordinates"], "title": "ZoneConfig", "type": "object"}, "ZoomingModeEnum": {"enum": ["disabled", "absolute", "relative"], "title": "ZoomingModeEnum", "type": "string"}, "BirdseyeModeConfig": {"additionalProperties": false, "properties": {"continuous": {"default": false, "description": "Always include the camera in Birdseye.", "title": "Continuous", "type": "boolean"}, "motion": {"default": false, "description": "Include the camera in Birdseye when motion is detected.", "title": "Motion", "type": "boolean"}, "objects": {"default": false, "description": "Include the camera in Birdseye while an active object is tracked.", "title": "Active objects", "type": "boolean"}, "stationary_objects": {"default": false, "description": "Include the camera in Birdseye while a stationary object is tracked.", "title": "Stationary objects", "type": "boolean"}}, "title": "BirdseyeModeConfig", "type": "object"}}, "additionalProperties": false, "properties": {"version": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Numeric or string version of the active configuration to help detect migrations or format changes.", "title": "Current config version"}, "safe_mode": {"default": false, "description": "When enabled, start Frigate in safe mode with reduced features for troubleshooting.", "title": "Safe mode", "type": "boolean"}, "environment_vars": {"additionalProperties": {"type": "string"}, "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", "title": "Environment variables", "type": "object"}, "logger": {"$ref": "#/$defs/LoggerConfig", "description": "Controls default log verbosity and per-component log level overrides.", "title": "Logging"}, "auth": {"$ref": "#/$defs/AuthConfig", "description": "Authentication and session-related settings including cookie and rate limit options.", "title": "Authentication"}, "database": {"$ref": "#/$defs/DatabaseConfig", "description": "Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", "title": "Database"}, "go2rtc": {"$ref": "#/$defs/RestreamConfig", "description": "Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", "title": "go2rtc"}, "mqtt": {"$ref": "#/$defs/MqttConfig", "description": "Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for all cameras; can be overridden per-camera.", "title": "Notifications"}, "networking": {"$ref": "#/$defs/NetworkingConfig", "description": "Network-related settings such as IPv6 enablement for Frigate endpoints.", "title": "Networking"}, "proxy": {"$ref": "#/$defs/ProxyConfig", "description": "Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", "title": "Proxy"}, "telemetry": {"$ref": "#/$defs/TelemetryConfig", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "title": "Telemetry"}, "tls": {"$ref": "#/$defs/TlsConfig", "description": "TLS settings for Frigate's web endpoints (port 8971).", "title": "TLS"}, "ui": {"$ref": "#/$defs/UIConfig", "description": "User interface preferences such as timezone, time/date formatting, and units.", "title": "UI"}, "detectors": {"additionalProperties": {"$ref": "#/$defs/BaseDetectorConfig"}, "default": {"cpu": {"type": "cpu"}}, "description": "Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.", "title": "Detector hardware", "type": "object"}, "model": {"$ref": "#/$defs/ModelConfig", "description": "Settings to configure a custom object detection model and its input shape.", "title": "Detection model"}, "genai": {"additionalProperties": {"$ref": "#/$defs/GenAIConfig"}, "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "title": "Generative AI configuration", "type": "object"}, "cameras": {"additionalProperties": {"$ref": "#/$defs/CameraConfig"}, "description": "Cameras", "title": "Cameras", "type": "object"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "title": "Audio detection"}, "birdseye": {"$ref": "#/$defs/BirdseyeConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "ffmpeg": {"$ref": "#/$defs/FfmpegConfig", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "title": "FFmpeg"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", "title": "Live playback"}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null, "description": "Default motion detection settings applied to cameras unless overridden per-camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings applied to cameras unless overridden per-camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", "title": "Review"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for in-feed timestamps applied to debug view and snapshots.", "title": "Timestamp style"}, "audio_transcription": {"$ref": "#/$defs/AudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "classification": {"$ref": "#/$defs/ClassificationConfig", "description": "Settings for classification models used to refine object labels or state classification.", "title": "Object classification"}, "semantic_search": {"$ref": "#/$defs/SemanticSearchConfig", "description": "Settings for Semantic Search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "face_recognition": {"$ref": "#/$defs/FaceRecognitionConfig", "description": "Settings for face detection and recognition for all cameras; can be overridden per-camera.", "title": "Face recognition"}, "lpr": {"$ref": "#/$defs/LicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "camera_groups": {"additionalProperties": {"$ref": "#/$defs/CameraGroupConfig"}, "description": "Configuration for named camera groups used to organize cameras in the UI.", "title": "Camera groups", "type": "object"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/ProfileDefinitionConfig"}, "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", "title": "Profiles", "type": "object"}, "active_profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Currently active profile name. Runtime-only, not persisted in YAML.", "title": "Active profile"}}, "required": ["mqtt", "cameras"], "title": "FrigateConfig", "type": "object"} \ No newline at end of file +{"$defs": {"AlertsConfig": {"additionalProperties": false, "description": "Configure alerts", "properties": {"enabled": {"default": true, "description": "Enable or disable alert generation for all cameras; can be overridden per-camera.", "title": "Enable alerts", "type": "boolean"}, "labels": {"default": ["person", "car"], "description": "List of object labels that qualify as alerts (for example: car, person).", "items": {"type": "string"}, "title": "Alert labels", "type": "array"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered an alert; leave empty to allow any zone.", "title": "Required zones"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether alerts were originally enabled in the static configuration.", "title": "Original alerts state"}, "cutoff_time": {"default": 40, "description": "Seconds to wait after no alert-causing activity before cutting off an alert.", "title": "Alerts cutoff time", "type": "integer"}}, "title": "AlertsConfig", "type": "object"}, "AudioConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable audio event detection for all cameras; can be overridden per-camera.", "title": "Enable audio detection", "type": "boolean"}, "max_not_heard": {"default": 30, "description": "Amount of seconds without the configured audio type before the audio event is ended.", "title": "End timeout", "type": "integer"}, "min_volume": {"default": 500, "description": "Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", "title": "Minimum volume", "type": "integer"}, "listen": {"default": ["bark", "fire_alarm", "speech", "yell"], "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", "items": {"type": "string"}, "title": "Listen types", "type": "array"}, "filters": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/AudioFilterConfig"}, "type": "object"}, {"type": "null"}], "default": null, "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", "title": "Audio filters"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether audio detection was originally enabled in the static config file.", "title": "Original audio state"}, "num_threads": {"default": 2, "description": "Number of threads to use for audio detection processing.", "minimum": 1, "title": "Detection threads", "type": "integer"}}, "title": "AudioConfig", "type": "object"}, "AudioFilterConfig": {"additionalProperties": false, "properties": {"threshold": {"default": 0.8, "description": "Minimum confidence threshold for the audio event to be counted.", "exclusiveMaximum": 1.0, "minimum": 0.5, "title": "Minimum audio confidence", "type": "number"}}, "title": "AudioFilterConfig", "type": "object"}, "AudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", "title": "Enable audio transcription", "type": "boolean"}, "language": {"default": "en", "description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "device": {"$ref": "#/$defs/EnrichmentsDeviceEnum", "default": "CPU", "description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", "title": "Transcription device"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for offline audio event transcription.", "title": "Model size"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "AudioTranscriptionConfig", "type": "object"}, "AuthConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable native authentication for the Frigate UI.", "title": "Enable authentication", "type": "boolean"}, "reset_admin_password": {"default": false, "description": "If true, reset the admin user's password on startup and print the new password in logs.", "title": "Reset admin password", "type": "boolean"}, "cookie_name": {"default": "frigate_token", "description": "Name of the cookie used to store the JWT token for native authentication.", "pattern": "^[a-z_]+$", "title": "JWT cookie name", "type": "string"}, "cookie_secure": {"default": false, "description": "Set the secure flag on the auth cookie; should be true when using TLS.", "title": "Secure cookie flag", "type": "boolean"}, "session_length": {"default": 86400, "description": "Session duration in seconds for JWT-based sessions.", "minimum": 60, "title": "Session length", "type": "integer"}, "refresh_time": {"default": 1800, "description": "When a session is within this many seconds of expiring, refresh it back to full length.", "minimum": 30, "title": "Session refresh window", "type": "integer"}, "failed_login_rate_limit": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Rate limiting rules for failed login attempts to reduce brute-force attacks.", "title": "Failed login limits"}, "trusted_proxies": {"default": [], "description": "List of trusted proxy IPs used when determining client IP for rate limiting.", "items": {"type": "string"}, "title": "Trusted proxies", "type": "array"}, "hash_iterations": {"default": 600000, "description": "Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", "title": "Hash iterations", "type": "integer"}, "roles": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "description": "Map roles to camera lists. An empty list grants access to all cameras for the role.", "title": "Role mappings", "type": "object"}, "admin_first_time_login": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. ", "title": "First-time admin flag"}}, "title": "AuthConfig", "type": "object"}, "BaseDetectorConfig": {"additionalProperties": true, "properties": {"type": {"default": "cpu", "description": "Type of detector to use for object detection (for example 'cpu', 'edgetpu', 'openvino').", "title": "Detector Type", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/ModelConfig"}, {"type": "null"}], "default": null, "description": "Detector-specific model configuration options (path, input size, etc.).", "title": "Detector specific model configuration"}, "model_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "File path to the detector model binary if required by the chosen detector.", "title": "Detector specific model path"}}, "title": "BaseDetectorConfig", "type": "object"}, "BirdClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable bird classification.", "title": "Bird classification", "type": "boolean"}, "threshold": {"default": 0.9, "description": "Minimum classification score required to accept a bird classification.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Minimum score", "type": "number"}}, "title": "BirdClassificationConfig", "type": "object"}, "BirdseyeCameraConfig": {"properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeConfig", "description": "Activity types that include cameras in Birdseye.", "title": "Activity types"}, "order": {"default": 0, "description": "Numeric position controlling the camera's ordering in the Birdseye layout.", "title": "Position", "type": "integer"}}, "title": "BirdseyeCameraConfig", "type": "object"}, "BirdseyeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeConfig", "description": "Activity types that include cameras in Birdseye.", "title": "Activity types"}, "restream": {"default": false, "description": "Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", "title": "Restream RTSP", "type": "boolean"}, "width": {"default": 1280, "description": "Output width (pixels) of the composed Birdseye frame.", "title": "Width", "type": "integer"}, "height": {"default": 720, "description": "Output height (pixels) of the composed Birdseye frame.", "title": "Height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Encoding quality", "type": "integer"}, "inactivity_threshold": {"default": 30, "description": "Seconds of inactivity after which a camera will stop being shown in Birdseye.", "exclusiveMinimum": 0, "title": "Inactivity threshold", "type": "integer"}, "layout": {"$ref": "#/$defs/BirdseyeLayoutConfig", "description": "Layout options for the Birdseye composition.", "title": "Layout"}, "idle_heartbeat_fps": {"default": 0.0, "description": "Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", "maximum": 10.0, "minimum": 0.0, "title": "Idle heartbeat FPS", "type": "number"}}, "title": "BirdseyeConfig", "type": "object"}, "BirdseyeLayoutConfig": {"additionalProperties": false, "properties": {"scaling_factor": {"default": 2.0, "description": "Scaling factor used by the layout calculator (range 1.0 to 5.0).", "maximum": 5.0, "minimum": 1.0, "title": "Scaling factor", "type": "number"}, "max_cameras": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", "title": "Max cameras"}}, "title": "BirdseyeLayoutConfig", "type": "object"}, "BirdseyeModeConfig": {"additionalProperties": false, "properties": {"continuous": {"default": false, "description": "Always include the camera in Birdseye.", "title": "Continuous", "type": "boolean"}, "motion": {"default": false, "description": "Include the camera in Birdseye when motion is detected.", "title": "Motion", "type": "boolean"}, "objects": {"default": false, "description": "Include the camera in Birdseye while an active object is tracked.", "title": "Active objects", "type": "boolean"}, "stationary_objects": {"default": false, "description": "Include the camera in Birdseye while a stationary object is tracked.", "title": "Stationary objects", "type": "boolean"}}, "title": "BirdseyeModeConfig", "type": "object"}, "CameraAudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable manually triggered audio event transcription.", "title": "Enable transcription", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Original transcription state"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "CameraAudioTranscriptionConfig", "type": "object"}, "CameraConfig": {"additionalProperties": false, "properties": {"name": {"anyOf": [{"pattern": "^[a-zA-Z0-9_-]+$", "type": "string"}, {"type": "null"}], "default": null, "description": "Camera name is required", "title": "Camera name"}, "friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Camera friendly name used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enabled", "title": "Enabled", "type": "boolean"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for this camera.", "title": "Audio detection"}, "audio_transcription": {"$ref": "#/$defs/CameraAudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "birdseye": {"$ref": "#/$defs/BirdseyeCameraConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "face_recognition": {"$ref": "#/$defs/CameraFaceRecognitionConfig", "description": "Settings for face detection and recognition for this camera.", "title": "Face recognition"}, "ffmpeg": {"$ref": "#/$defs/CameraFfmpegConfig", "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "title": "Streams (FFmpeg)"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings used by the Web UI to control live stream selection, resolution and quality.", "title": "Live playback"}, "lpr": {"$ref": "#/$defs/CameraLicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "motion": {"$ref": "#/$defs/MotionConfig", "default": null, "description": "Default motion detection settings for this camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings for this camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", "title": "Review"}, "semantic_search": {"$ref": "#/$defs/CameraSemanticSearchConfig", "description": "Settings for semantic search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for this camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for timestamps applied to snapshots and Debug view.", "title": "Timestamp style"}, "best_image_timeout": {"default": 60, "description": "How long to wait for the image with the highest confidence score.", "title": "Best image timeout", "type": "integer"}, "mqtt": {"$ref": "#/$defs/CameraMqttConfig", "description": "MQTT image publishing settings.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for this camera.", "title": "Notifications"}, "onvif": {"$ref": "#/$defs/OnvifConfig", "description": "ONVIF connection and PTZ autotracking settings for this camera.", "title": "ONVIF"}, "type": {"$ref": "#/$defs/CameraTypeEnum", "default": "generic", "description": "Camera Type", "title": "Camera type"}, "ui": {"$ref": "#/$defs/CameraUiConfig", "description": "Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", "title": "Camera UI"}, "webui_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to visit the camera directly from system page", "title": "Camera URL"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/CameraProfileConfig"}, "description": "Named config profiles with partial overrides that can be activated at runtime.", "title": "Profiles", "type": "object"}, "zones": {"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", "title": "Zones", "type": "object"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Keep track of original state of camera.", "title": "Original camera state"}}, "required": ["ffmpeg"], "title": "CameraConfig", "type": "object"}, "CameraFaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition.", "title": "Enable face recognition", "type": "boolean"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}}, "title": "CameraFaceRecognitionConfig", "type": "object"}, "CameraFfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}, "inputs": {"description": "List of input stream definitions (paths and roles) for this camera.", "items": {"$ref": "#/$defs/CameraInput"}, "title": "Camera inputs", "type": "array"}}, "required": ["inputs"], "title": "CameraFfmpegConfig", "type": "object"}, "CameraGroupConfig": {"additionalProperties": false, "properties": {"cameras": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Array of camera names included in this group.", "title": "Camera list"}, "icon": {"default": "generic", "description": "Icon used to represent the camera group in the UI.", "title": "Group icon", "type": "string"}, "order": {"default": 0, "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later.", "title": "Sort order", "type": "integer"}}, "title": "CameraGroupConfig", "type": "object"}, "CameraInput": {"additionalProperties": false, "properties": {"path": {"description": "Camera input stream URL or path.", "title": "Input path", "type": "string"}, "roles": {"description": "Roles for this input stream.", "items": {"$ref": "#/$defs/CameraRoleEnum"}, "title": "Input roles", "type": "array"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "FFmpeg global arguments for this input stream.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Hardware acceleration arguments for this input stream.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Input arguments specific to this stream.", "title": "Input arguments"}}, "required": ["path", "roles"], "title": "CameraInput", "type": "object"}, "CameraLicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable LPR on this camera.", "title": "Enable LPR", "type": "boolean"}, "expire_time": {"default": 3, "description": "Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", "exclusiveMinimum": 0, "title": "Expire seconds", "type": "integer"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}}, "title": "CameraLicensePlateRecognitionConfig", "type": "object"}, "CameraLiveConfig": {"additionalProperties": false, "properties": {"streams": {"additionalProperties": {"type": "string"}, "description": "Mapping of configured stream names to restream/go2rtc names used for live playback.", "title": "Live stream names", "type": "object"}, "height": {"default": 720, "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", "title": "Live height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Live quality", "type": "integer"}}, "title": "CameraLiveConfig", "type": "object"}, "CameraMqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable publishing image snapshots for objects to MQTT topics for this camera.", "title": "Send image", "type": "boolean"}, "timestamp": {"default": true, "description": "Overlay a timestamp on images published to MQTT.", "title": "Add timestamp", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes on images published over MQTT.", "title": "Add bounding box", "type": "boolean"}, "crop": {"default": true, "description": "Crop images published to MQTT to the detected object's bounding box.", "title": "Crop image", "type": "boolean"}, "height": {"default": 270, "description": "Height (pixels) to resize images published over MQTT.", "title": "Image height", "type": "integer"}, "required_zones": {"description": "Zones that an object must enter for an MQTT image to be published.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "quality": {"default": 70, "description": "JPEG quality for images published to MQTT (0-100).", "maximum": 100, "minimum": 0, "title": "JPEG quality", "type": "integer"}}, "title": "CameraMqttConfig", "type": "object"}, "CameraProfileConfig": {"additionalProperties": false, "description": "A named profile containing partial camera config overrides.\n\nSections set to None inherit from the camera's base config.\nSections that are defined get Pydantic-validated, then only\nexplicitly-set fields are used as overrides via exclude_unset.", "properties": {"enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Enabled"}, "audio": {"anyOf": [{"$ref": "#/$defs/AudioConfig"}, {"type": "null"}], "default": null}, "birdseye": {"anyOf": [{"$ref": "#/$defs/BirdseyeCameraConfig"}, {"type": "null"}], "default": null}, "detect": {"anyOf": [{"$ref": "#/$defs/DetectConfig"}, {"type": "null"}], "default": null}, "face_recognition": {"anyOf": [{"$ref": "#/$defs/CameraFaceRecognitionConfig"}, {"type": "null"}], "default": null}, "lpr": {"anyOf": [{"$ref": "#/$defs/CameraLicensePlateRecognitionConfig"}, {"type": "null"}], "default": null}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null}, "notifications": {"anyOf": [{"$ref": "#/$defs/NotificationConfig"}, {"type": "null"}], "default": null}, "objects": {"anyOf": [{"$ref": "#/$defs/ObjectConfig"}, {"type": "null"}], "default": null}, "record": {"anyOf": [{"$ref": "#/$defs/RecordConfig"}, {"type": "null"}], "default": null}, "review": {"anyOf": [{"$ref": "#/$defs/ReviewConfig"}, {"type": "null"}], "default": null}, "snapshots": {"anyOf": [{"$ref": "#/$defs/SnapshotsConfig"}, {"type": "null"}], "default": null}, "zones": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Zones"}}, "title": "CameraProfileConfig", "type": "object"}, "CameraRoleEnum": {"enum": ["audio", "record", "record_sub", "detect"], "title": "CameraRoleEnum", "type": "string"}, "CameraSemanticSearchConfig": {"additionalProperties": false, "properties": {"triggers": {"additionalProperties": {"$ref": "#/$defs/TriggerConfig"}, "default": {}, "description": "Actions and matching criteria for camera-specific semantic search triggers.", "title": "Triggers", "type": "object"}}, "title": "CameraSemanticSearchConfig", "type": "object"}, "CameraTypeEnum": {"enum": ["generic", "lpr"], "title": "CameraTypeEnum", "type": "string"}, "CameraUiConfig": {"additionalProperties": false, "properties": {"order": {"default": 0, "description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", "title": "UI order", "type": "integer"}, "dashboard": {"default": true, "description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.", "title": "Show on Live dashboard", "type": "boolean"}, "review": {"default": true, "description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", "title": "Show in review", "type": "boolean"}}, "title": "CameraUiConfig", "type": "object"}, "ChaptersEnum": {"enum": ["none", "recording_segments", "review_items"], "title": "ChaptersEnum", "type": "string"}, "ClassificationConfig": {"additionalProperties": false, "properties": {"bird": {"$ref": "#/$defs/BirdClassificationConfig", "description": "Settings specific to bird classification models.", "title": "Bird classification config"}, "custom": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationConfig"}, "default": {}, "description": "Configuration for custom classification models used for objects or state detection.", "title": "Custom Classification Models", "type": "object"}}, "title": "ClassificationConfig", "type": "object"}, "ColorConfig": {"additionalProperties": false, "properties": {"red": {"default": 255, "description": "Red component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Red", "type": "integer"}, "green": {"default": 255, "description": "Green component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Green", "type": "integer"}, "blue": {"default": 255, "description": "Blue component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Blue", "type": "integer"}}, "title": "ColorConfig", "type": "object"}, "CustomClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the custom classification model.", "title": "Enable model", "type": "boolean"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Identifier for the custom classification model to use.", "title": "Model name"}, "threshold": {"default": 0.8, "description": "Score threshold used to change the classification state.", "title": "Score threshold", "type": "number"}, "save_attempts": {"anyOf": [{"minimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How many classification attempts to save for recent classifications UI.", "title": "Save attempts"}, "object_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationObjectConfig"}, {"type": "null"}], "default": null}, "state_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationStateConfig"}, {"type": "null"}], "default": null}}, "title": "CustomClassificationConfig", "type": "object"}, "CustomClassificationObjectConfig": {"additionalProperties": false, "properties": {"objects": {"description": "List of object types to run object classification on.", "items": {"type": "string"}, "title": "Classify objects", "type": "array"}, "classification_type": {"$ref": "#/$defs/ObjectClassificationType", "default": "sub_label", "description": "Classification type applied: 'sub_label' (adds sub_label) or other supported types.", "title": "Classification type"}}, "title": "CustomClassificationObjectConfig", "type": "object"}, "CustomClassificationStateCameraConfig": {"additionalProperties": false, "properties": {"crop": {"description": "Crop coordinates to use for running classification on this camera.", "items": {"type": "number"}, "title": "Classification crop", "type": "array"}}, "required": ["crop"], "title": "CustomClassificationStateCameraConfig", "type": "object"}, "CustomClassificationStateConfig": {"additionalProperties": false, "properties": {"cameras": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationStateCameraConfig"}, "description": "Per-camera crop and settings for running state classification.", "title": "Classification cameras", "type": "object"}, "motion": {"default": false, "description": "If true, run classification when motion is detected within the specified crop.", "title": "Run on motion", "type": "boolean"}, "interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "Interval (seconds) between periodic classification runs for state classification.", "title": "Classification interval"}}, "required": ["cameras"], "title": "CustomClassificationStateConfig", "type": "object"}, "DatabaseConfig": {"additionalProperties": false, "properties": {"path": {"default": "/config/frigate.db", "description": "Filesystem path where the Frigate SQLite database file will be stored.", "title": "Database path", "type": "string"}}, "title": "DatabaseConfig", "type": "object"}, "DetectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable object detection for all cameras; can be overridden per-camera.", "title": "Enable object detection", "type": "boolean"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect height"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect width"}, "fps": {"default": 5, "description": "Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", "title": "Detect FPS", "type": "integer"}, "min_initialized": {"anyOf": [{"minimum": 2, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", "title": "Minimum initialization frames"}, "max_disappeared": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames without a detection before a tracked object is considered gone.", "title": "Maximum disappeared frames"}, "stationary": {"$ref": "#/$defs/StationaryConfig", "description": "Settings to detect and manage objects that remain stationary for a period of time.", "title": "Stationary objects config"}, "annotation_offset": {"default": 0, "description": "Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", "title": "Annotation offset", "type": "integer"}}, "title": "DetectConfig", "type": "object"}, "DetectionsConfig": {"additionalProperties": false, "description": "Configure detections", "properties": {"enabled": {"default": true, "description": "Enable or disable detection events for all cameras; can be overridden per-camera.", "title": "Enable detections", "type": "boolean"}, "labels": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "List of object labels that qualify as detection events.", "title": "Detection labels"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered a detection; leave empty to allow any zone.", "title": "Required zones"}, "cutoff_time": {"default": 30, "description": "Seconds to wait after no detection-causing activity before cutting off a detection.", "title": "Detections cutoff time", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether detections were originally enabled in the static configuration.", "title": "Original detections state"}}, "title": "DetectionsConfig", "type": "object"}, "EnrichmentsDeviceEnum": {"enum": ["GPU", "CPU"], "title": "EnrichmentsDeviceEnum", "type": "string"}, "EventsConfig": {"additionalProperties": false, "properties": {"pre_capture": {"default": 5, "description": "Number of seconds before the detection event to include in the recording.", "maximum": 60, "minimum": 0, "title": "Pre-capture seconds", "type": "integer"}, "post_capture": {"default": 5, "description": "Number of seconds after the detection event to include in the recording.", "minimum": 0, "title": "Post-capture seconds", "type": "integer"}, "retain": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for recordings of detection events.", "title": "Event retention"}}, "title": "EventsConfig", "type": "object"}, "FaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition for all cameras; can be overridden per-camera.", "title": "Enable face recognition", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for face embeddings (small/large); larger may require GPU.", "title": "Model size"}, "unknown_score": {"default": 0.8, "description": "Distance threshold below which a face is considered a potential match (higher = stricter).", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Unknown score threshold", "type": "number"}, "detection_threshold": {"default": 0.7, "description": "Minimum detection confidence required to consider a face detection valid.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "recognition_threshold": {"default": 0.9, "description": "Face embedding distance threshold to consider two faces a match.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}, "min_faces": {"default": 1, "description": "Minimum number of face recognitions required before applying a recognized sub-label to a person.", "exclusiveMinimum": 0, "maximum": 6, "title": "Minimum faces", "type": "integer"}, "save_attempts": {"default": 200, "description": "Number of face recognition attempts to retain for recent recognition UI.", "minimum": 0, "title": "Save attempts", "type": "integer"}, "blur_confidence_filter": {"default": true, "description": "Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", "title": "Blur confidence filter", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "FaceRecognitionConfig", "type": "object"}, "FfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}}, "title": "FfmpegConfig", "type": "object"}, "FfmpegOutputArgsConfig": {"additionalProperties": false, "properties": {"detect": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "description": "Default output arguments for detect role streams.", "title": "Detect output arguments"}, "record": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-record-generic-audio-aac", "description": "Default output arguments for record role streams.", "title": "Record output arguments"}, "record_sub": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set.", "title": "Sub stream record output arguments"}}, "title": "FfmpegOutputArgsConfig", "type": "object"}, "FilterConfig": {"additionalProperties": false, "properties": {"min_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 0, "description": "Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Minimum object area"}, "max_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 24000000, "description": "Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Maximum object area"}, "min_ratio": {"default": 0, "description": "Minimum width/height ratio required for the bounding box to qualify.", "title": "Minimum aspect ratio", "type": "number"}, "max_ratio": {"default": 24000000, "description": "Maximum width/height ratio allowed for the bounding box to qualify.", "title": "Maximum aspect ratio", "type": "number"}, "threshold": {"default": 0.7, "description": "Average detection confidence threshold required for the object to be considered a true positive.", "title": "Confidence threshold", "type": "number"}, "min_score": {"default": 0.5, "description": "Minimum single-frame detection confidence required for the object to be counted.", "title": "Minimum confidence", "type": "number"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Polygon coordinates defining where this filter applies within the frame.", "title": "Filter mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "FilterConfig", "type": "object"}, "GenAIConfig": {"additionalProperties": false, "description": "Primary GenAI Config to define GenAI Provider.", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "API key required by some providers (can also be set via environment variables).", "title": "API key"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Base URL for self-hosted or compatible providers (for example an Ollama instance).", "title": "Base URL"}, "model": {"default": "", "description": "The model to use from the provider for generating descriptions or summaries.", "title": "Model", "type": "string"}, "provider": {"$ref": "#/$defs/GenAIProviderEnum", "description": "The GenAI provider to use (for example: ollama, gemini, openai).", "title": "Provider"}, "roles": {"description": "GenAI roles (chat, descriptions, embeddings); one provider per role.", "items": {"$ref": "#/$defs/GenAIRoleEnum"}, "title": "Roles", "type": "array"}, "provider_options": {"additionalProperties": {}, "default": {}, "description": "Additional provider-specific options to pass to the GenAI client.", "title": "Provider options", "type": "object"}, "runtime_options": {"additionalProperties": {}, "default": {}, "description": "Runtime options passed to the provider for each inference call.", "title": "Runtime options", "type": "object"}}, "required": ["provider"], "title": "GenAIConfig", "type": "object"}, "GenAIObjectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable GenAI generation of descriptions for tracked objects by default.", "title": "Enable GenAI", "type": "boolean"}, "use_snapshot": {"default": false, "description": "Use object snapshots instead of thumbnails for GenAI description generation.", "title": "Use snapshots", "type": "boolean"}, "prompt": {"default": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "description": "Default prompt template used when generating descriptions with GenAI.", "title": "Caption prompt", "type": "string"}, "object_prompts": {"additionalProperties": {"type": "string"}, "description": "Per-object prompts to customize GenAI outputs for specific labels.", "title": "Object prompts", "type": "object"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object labels to send to GenAI by default.", "title": "GenAI objects"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that must be entered for objects to qualify for GenAI description generation.", "title": "Required zones"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails sent to GenAI for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "send_triggers": {"$ref": "#/$defs/GenAIObjectTriggerConfig", "description": "Defines when frames should be sent to GenAI (on end, after updates, etc.).", "title": "GenAI triggers"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether GenAI was enabled in the original static config.", "title": "Original GenAI state"}}, "title": "GenAIObjectConfig", "type": "object"}, "GenAIObjectTriggerConfig": {"additionalProperties": false, "properties": {"tracked_object_end": {"default": true, "description": "Send a request to GenAI when the tracked object ends.", "title": "Send on end", "type": "boolean"}, "after_significant_updates": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Send a request to GenAI after a specified number of significant updates for the tracked object.", "title": "Early GenAI trigger"}}, "title": "GenAIObjectTriggerConfig", "type": "object"}, "GenAIProviderEnum": {"enum": ["openai", "azure_openai", "gemini", "ollama", "llamacpp"], "title": "GenAIProviderEnum", "type": "string"}, "GenAIReviewConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable GenAI-generated descriptions and summaries for review items.", "title": "Enable GenAI descriptions", "type": "boolean"}, "alerts": {"default": true, "description": "Use GenAI to generate descriptions for alert items.", "title": "Enable GenAI for alerts", "type": "boolean"}, "detections": {"default": false, "description": "Use GenAI to generate descriptions for detection items.", "title": "Enable GenAI for detections", "type": "boolean"}, "image_source": {"$ref": "#/$defs/ImageSourceEnum", "default": "preview", "description": "Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", "title": "Review image source"}, "additional_concerns": {"default": [], "description": "A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", "items": {"type": "string"}, "title": "Additional concerns", "type": "array"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails that are sent to the GenAI provider for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether GenAI review was originally enabled in the static configuration.", "title": "Original GenAI state"}, "preferred_language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Preferred language to request from the GenAI provider for generated responses.", "title": "Preferred language"}, "activity_context_prompt": {"default": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.", "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", "title": "Activity context prompt", "type": "string"}}, "title": "GenAIReviewConfig", "type": "object"}, "GenAIRoleEnum": {"enum": ["chat", "descriptions", "embeddings"], "title": "GenAIRoleEnum", "type": "string"}, "HeaderMappingConfig": {"additionalProperties": false, "properties": {"user": {"default": null, "description": "Header containing the authenticated username provided by the upstream proxy.", "title": "User header", "type": "string"}, "role": {"default": null, "description": "Header containing the authenticated user's role or groups from the upstream proxy.", "title": "Role header", "type": "string"}, "role_map": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "description": "Map upstream group values to Frigate roles (for example map admin groups to the admin role).", "title": "Role mapping"}}, "title": "HeaderMappingConfig", "type": "object"}, "IPv6Config": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable IPv6 support for Frigate services (API and UI) where applicable.", "title": "Enable IPv6", "type": "boolean"}}, "title": "IPv6Config", "type": "object"}, "ImageSourceEnum": {"description": "Image source options for GenAI Review.", "enum": ["preview", "recordings"], "title": "ImageSourceEnum", "type": "string"}, "InputDTypeEnum": {"enum": ["float", "float_denorm", "int"], "title": "InputDTypeEnum", "type": "string"}, "InputTensorEnum": {"enum": ["nchw", "nhwc", "hwnc", "hwcn"], "title": "InputTensorEnum", "type": "string"}, "LicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable license plate recognition for all cameras; can be overridden per-camera.", "title": "Enable LPR", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size used for text detection/recognition. Most users should use 'small'.", "title": "Model size"}, "detection_threshold": {"default": 0.7, "description": "Detection confidence threshold to begin running OCR on a suspected plate.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "recognition_threshold": {"default": 0.9, "description": "Confidence threshold required for recognized plate text to be attached as a sub-label.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_plate_length": {"default": 4, "description": "Minimum number of characters a recognized plate must contain to be considered valid.", "title": "Min plate length", "type": "integer"}, "format": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional regex to validate recognized plate strings against an expected format.", "title": "Plate format regex"}, "match_distance": {"default": 1, "description": "Number of character mismatches allowed when comparing detected plates to known plates.", "minimum": 0, "title": "Match distance", "type": "integer"}, "known_plates": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "default": {}, "description": "List of plates or regexes to specially track or alert on.", "title": "Known plates"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}, "debug_save_plates": {"default": false, "description": "Save plate crop images for debugging LPR performance.", "title": "Save debug plates", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}, "replace_rules": {"description": "Regex replacement rules used to normalize detected plate strings before matching.", "items": {"$ref": "#/$defs/ReplaceRule"}, "title": "Replacement rules", "type": "array"}}, "title": "LicensePlateRecognitionConfig", "type": "object"}, "ListenConfig": {"additionalProperties": false, "properties": {"internal": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 5000, "description": "Internal listening port for Frigate (default 5000).", "title": "Internal port"}, "external": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 8971, "description": "External listening port for Frigate (default 8971).", "title": "External port"}}, "title": "ListenConfig", "type": "object"}, "LogLevel": {"enum": ["debug", "info", "warning", "error", "critical"], "title": "LogLevel", "type": "string"}, "LoggerConfig": {"additionalProperties": false, "properties": {"default": {"$ref": "#/$defs/LogLevel", "default": "info", "title": "Logging level", "description": "Default global log verbosity (debug, info, warning, error)."}, "logs": {"additionalProperties": {"$ref": "#/$defs/LogLevel"}, "description": "Per-component log level overrides to increase or decrease verbosity for specific modules.", "title": "Per-process log level", "type": "object"}}, "title": "LoggerConfig", "type": "object"}, "ModelConfig": {"additionalProperties": false, "properties": {"path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a custom detection model file (or plus:// for Frigate+ models).", "title": "Custom object detector model path"}, "labelmap_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a labelmap file that maps numeric classes to string labels for the detector.", "title": "Label map for custom object detector"}, "width": {"default": 320, "description": "Width of the model input tensor in pixels.", "title": "Object detection model input width", "type": "integer"}, "height": {"default": 320, "description": "Height of the model input tensor in pixels.", "title": "Object detection model input height", "type": "integer"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard labelmap.", "title": "Labelmap customization", "type": "object"}, "attributes_map": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "default": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", "title": "Map of object labels to their attribute labels", "type": "object"}, "input_tensor": {"$ref": "#/$defs/InputTensorEnum", "default": "nhwc", "description": "Tensor format expected by the model: 'nhwc' or 'nchw'.", "title": "Model Input Tensor Shape"}, "input_pixel_format": {"$ref": "#/$defs/PixelFormatEnum", "default": "rgb", "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", "title": "Model Input Pixel Color Format"}, "input_dtype": {"$ref": "#/$defs/InputDTypeEnum", "default": "int", "description": "Data type of the model input tensor (for example 'float32').", "title": "Model Input D Type"}, "model_type": {"$ref": "#/$defs/ModelTypeEnum", "default": "ssd", "description": "Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization.", "title": "Object Detection Model Type"}}, "title": "ModelConfig", "type": "object"}, "ModelSizeEnum": {"enum": ["small", "large"], "title": "ModelSizeEnum", "type": "string"}, "ModelTypeEnum": {"enum": ["dfine", "rfdetr", "ssd", "yolox", "yolonas", "yolo-generic"], "title": "ModelTypeEnum", "type": "string"}, "MotionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable motion detection for all cameras; can be overridden per-camera.", "title": "Enable motion detection", "type": "boolean"}, "threshold": {"default": 30, "description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", "maximum": 255, "minimum": 1, "title": "Motion threshold", "type": "integer"}, "lightning_threshold": {"default": 0.8, "description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", "maximum": 1.0, "minimum": 0.3, "title": "Lightning threshold", "type": "number"}, "skip_motion_threshold": {"anyOf": [{"maximum": 1.0, "minimum": 0.0, "type": "number"}, {"type": "null"}], "default": null, "description": "If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto\u2011tracking an object. The trade\u2011off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", "title": "Skip motion threshold"}, "improve_contrast": {"default": true, "description": "Apply contrast improvement to frames before motion analysis to help detection.", "title": "Improve contrast", "type": "boolean"}, "contour_area": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 10, "description": "Minimum contour area in pixels required for a motion contour to be counted.", "title": "Contour area"}, "delta_alpha": {"default": 0.2, "description": "Alpha blending factor used in frame differencing for motion calculation.", "title": "Delta alpha", "type": "number"}, "frame_alpha": {"default": 0.01, "description": "Alpha value used when blending frames for motion preprocessing.", "title": "Frame alpha", "type": "number"}, "frame_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 100, "description": "Height in pixels to scale frames to when computing motion.", "title": "Frame height"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Mask coordinates", "type": "object"}, "mqtt_off_delay": {"default": 30, "description": "Seconds to wait after last motion before publishing an MQTT 'off' state.", "title": "MQTT off delay", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether motion detection was enabled in the original static configuration.", "title": "Original motion state"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "MotionConfig", "type": "object"}, "MotionMaskConfig": {"additionalProperties": false, "description": "Configuration for a single motion mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this motion mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this motion mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of motion mask."}}, "title": "MotionMaskConfig", "type": "object"}, "MqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable MQTT integration for state, events, and snapshots.", "title": "Enable MQTT", "type": "boolean"}, "host": {"default": "", "description": "Hostname or IP address of the MQTT broker.", "title": "MQTT host", "type": "string"}, "port": {"default": 1883, "description": "Port of the MQTT broker (usually 1883 for plain MQTT).", "title": "MQTT port", "type": "integer"}, "topic_prefix": {"default": "frigate", "description": "MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", "title": "Topic prefix", "type": "string"}, "client_id": {"default": "frigate", "description": "Client identifier used when connecting to the MQTT broker; should be unique per instance.", "title": "Client ID", "type": "string"}, "stats_interval": {"default": 60, "description": "Interval in seconds for publishing system and camera stats to MQTT.", "minimum": 15, "title": "Stats interval", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT username; can be provided via environment variables or secrets.", "title": "MQTT username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT password; can be provided via environment variables or secrets.", "title": "MQTT password"}, "tls_ca_certs": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to CA certificate for TLS connections to the broker (for self-signed certs).", "title": "TLS CA certs"}, "tls_client_cert": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", "title": "Client cert"}, "tls_client_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Private key path for the client certificate.", "title": "Client key"}, "tls_insecure": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Allow insecure TLS connections by skipping hostname verification (not recommended).", "title": "TLS insecure"}, "qos": {"default": 0, "description": "Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", "title": "MQTT QoS", "type": "integer"}}, "title": "MqttConfig", "type": "object"}, "NetworkingConfig": {"additionalProperties": false, "properties": {"ipv6": {"$ref": "#/$defs/IPv6Config", "description": "IPv6-specific settings for Frigate network services.", "title": "IPv6 configuration"}, "listen": {"$ref": "#/$defs/ListenConfig", "description": "Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", "title": "Listening ports configuration"}}, "title": "NetworkingConfig", "type": "object"}, "NotificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable notifications for all cameras; can be overridden per-camera.", "title": "Enable notifications", "type": "boolean"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Email address used for push notifications or required by certain notification providers.", "title": "Notification email"}, "cooldown": {"default": 0, "description": "Cooldown (seconds) between notifications to avoid spamming recipients.", "minimum": 0, "title": "Cooldown period", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether notifications were enabled in the original static configuration.", "title": "Original notifications state"}}, "title": "NotificationConfig", "type": "object"}, "ObjectClassificationType": {"enum": ["sub_label", "attribute"], "title": "ObjectClassificationType", "type": "string"}, "ObjectConfig": {"additionalProperties": false, "properties": {"track": {"default": ["person"], "description": "List of object labels to track for all cameras; can be overridden per-camera.", "items": {"type": "string"}, "title": "Objects to track", "type": "array"}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters applied to detected objects to reduce false positives (area, ratio, confidence).", "title": "Object filters", "type": "object"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Mask polygon used to prevent object detection in specified areas.", "title": "Object mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}, "genai": {"$ref": "#/$defs/GenAIObjectConfig", "description": "GenAI options for describing tracked objects and sending frames for generation.", "title": "GenAI object config"}}, "title": "ObjectConfig", "type": "object"}, "ObjectMaskConfig": {"additionalProperties": false, "description": "Configuration for a single object mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this object mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this object mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of object mask."}}, "title": "ObjectMaskConfig", "type": "object"}, "OnvifConfig": {"additionalProperties": false, "properties": {"host": {"default": "", "description": "Host (and optional scheme) for the ONVIF service for this camera.", "title": "ONVIF host", "type": "string"}, "port": {"default": 8000, "description": "Port number for the ONVIF service.", "title": "ONVIF port", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Username for ONVIF authentication; some devices require admin user for ONVIF.", "title": "ONVIF username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Password for ONVIF authentication.", "title": "ONVIF password"}, "tls_insecure": {"default": false, "description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", "title": "Disable TLS verify", "type": "boolean"}, "profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", "title": "ONVIF profile"}, "autotracking": {"$ref": "#/$defs/PtzAutotrackConfig", "description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", "title": "Autotracking"}, "ignore_time_mismatch": {"default": false, "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", "title": "Ignore time mismatch", "type": "boolean"}}, "title": "OnvifConfig", "type": "object"}, "PixelFormatEnum": {"enum": ["rgb", "bgr", "yuv"], "title": "PixelFormatEnum", "type": "string"}, "ProfileDefinitionConfig": {"additionalProperties": false, "description": "Defines a named profile with a human-readable display name.\n\nThe dict key is the machine name used internally; friendly_name\nis the label shown in the UI and API responses.", "properties": {"friendly_name": {"description": "Display name for this profile shown in the UI.", "title": "Friendly name", "type": "string"}}, "required": ["friendly_name"], "title": "ProfileDefinitionConfig", "type": "object"}, "ProxyConfig": {"additionalProperties": false, "properties": {"header_map": {"$ref": "#/$defs/HeaderMappingConfig", "description": "Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", "title": "Header mapping"}, "logout_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to redirect users to when logging out via the proxy.", "title": "Logout URL"}, "auth_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", "title": "Proxy secret"}, "default_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": "viewer", "description": "Default role assigned to proxy-authenticated users when no role mapping applies.", "title": "Default role"}, "separator": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": ",", "description": "Character used to split multiple values provided in proxy headers.", "title": "Separator character"}}, "title": "ProxyConfig", "type": "object"}, "PtzAutotrackConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic PTZ camera tracking of detected objects.", "title": "Enable Autotracking", "type": "boolean"}, "calibrate_on_startup": {"default": false, "description": "Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", "title": "Calibrate on start", "type": "boolean"}, "zooming": {"$ref": "#/$defs/ZoomingModeEnum", "default": "disabled", "description": "Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", "title": "Zoom mode"}, "zoom_factor": {"default": 0.3, "description": "Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", "maximum": 0.75, "minimum": 0.1, "title": "Zoom factor", "type": "number"}, "track": {"default": ["person"], "description": "List of object types that should trigger autotracking.", "items": {"type": "string"}, "title": "Tracked objects", "type": "array"}, "required_zones": {"description": "Objects must enter one of these zones before autotracking begins.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "return_preset": {"default": "home", "description": "ONVIF preset name configured in camera firmware to return to after tracking ends.", "title": "Return preset", "type": "string"}, "timeout": {"default": 10, "description": "Wait this many seconds after losing tracking before returning camera to preset position.", "title": "Return timeout", "type": "integer"}, "movement_weights": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Calibration values automatically generated by camera calibration. Do not modify manually.", "title": "Movement weights"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Internal field to track whether autotracking was enabled in configuration.", "title": "Original autotrack state"}}, "title": "PtzAutotrackConfig", "type": "object"}, "RecordConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable recording for all cameras; can be overridden per-camera.", "title": "Enable recording", "type": "boolean"}, "expire_interval": {"default": 60, "description": "Minutes between cleanup passes that remove expired recording segments.", "title": "Record cleanup interval", "type": "integer"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Motion retention"}, "detections": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for detection events including pre/post capture durations.", "title": "Detection retention"}, "alerts": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for alert events including pre/post capture durations.", "title": "Alert retention"}, "export": {"$ref": "#/$defs/RecordExportConfig", "description": "Settings used when exporting recordings such as timelapse and hardware acceleration.", "title": "Export config"}, "preview": {"$ref": "#/$defs/RecordPreviewConfig", "description": "Settings controlling the quality of recording previews shown in the UI.", "title": "Preview config"}, "sub": {"$ref": "#/$defs/RecordSubConfig", "description": "Settings for recording a second, lower quality stream.", "title": "Sub stream recording"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether recording was enabled in the original static configuration.", "title": "Original recording state"}}, "title": "RecordConfig", "type": "object"}, "RecordExportConfig": {"additionalProperties": false, "properties": {"hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration args to use for export/transcode operations.", "title": "Export hwaccel args"}, "max_concurrent": {"default": 3, "description": "Maximum number of export jobs to process at the same time.", "minimum": 1, "title": "Maximum concurrent exports", "type": "integer"}, "chapters": {"$ref": "#/$defs/ChaptersEnum", "default": "review_items", "title": "Chapter metadata to embed in exported recordings"}}, "title": "RecordExportConfig", "type": "object"}, "RecordPreviewConfig": {"additionalProperties": false, "properties": {"quality": {"$ref": "#/$defs/RecordQualityEnum", "default": "medium", "description": "Preview quality level (very_low, low, medium, high, very_high).", "title": "Preview quality"}}, "title": "RecordPreviewConfig", "type": "object"}, "RecordQualityEnum": {"enum": ["very_low", "low", "medium", "high", "very_high"], "title": "RecordQualityEnum", "type": "string"}, "RecordRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 0, "description": "Days to retain recordings.", "minimum": 0.0, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "RecordSubConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.", "title": "Enable sub stream recording", "type": "boolean"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", "title": "Sub stream continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings triggered by motion.", "title": "Sub stream motion retention"}, "alerts": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of alerts.", "title": "Sub stream alert retention"}, "detections": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of detections.", "title": "Sub stream detection retention"}}, "title": "RecordSubConfig", "type": "object"}, "ReplaceRule": {"additionalProperties": false, "properties": {"pattern": {"title": "Regex pattern", "type": "string"}, "replacement": {"title": "Replacement string", "type": "string"}}, "required": ["pattern", "replacement"], "title": "ReplaceRule", "type": "object"}, "RestreamConfig": {"additionalProperties": true, "properties": {}, "title": "RestreamConfig", "type": "object"}, "RetainConfig": {"additionalProperties": false, "properties": {"default": {"type": "number", "default": 10, "title": "Default retention", "description": "Default number of days to retain snapshots."}, "objects": {"additionalProperties": {"type": "number"}, "description": "Per-object overrides for snapshot retention days.", "title": "Object retention", "type": "object"}}, "title": "RetainConfig", "type": "object"}, "RetainModeEnum": {"enum": ["all", "motion", "active_objects"], "title": "RetainModeEnum", "type": "string"}, "ReviewConfig": {"additionalProperties": false, "properties": {"alerts": {"$ref": "#/$defs/AlertsConfig", "description": "Settings for which tracked objects generate alerts and how alerts are retained.", "title": "Alerts config"}, "detections": {"$ref": "#/$defs/DetectionsConfig", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "title": "Detections config"}, "genai": {"$ref": "#/$defs/GenAIReviewConfig", "description": "Controls use of generative AI for producing descriptions and summaries of review items.", "title": "GenAI config"}}, "title": "ReviewConfig", "type": "object"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 0.0, "title": "Retention days", "type": "number"}, "mode": {"$ref": "#/$defs/RetainModeEnum", "default": "motion", "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", "title": "Retention mode"}}, "title": "ReviewRetainConfig", "type": "object"}, "SemanticSearchConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable the semantic search feature.", "title": "Enable semantic search", "type": "boolean"}, "reindex": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Trigger a full reindex of historical tracked objects into the embeddings database.", "title": "Reindex on startup"}, "model": {"anyOf": [{"$ref": "#/$defs/SemanticSearchModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "jinav1", "description": "The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", "title": "Semantic search model or GenAI provider name"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Select model size; 'small' runs on CPU and 'large' typically requires GPU.", "title": "Model size"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "SemanticSearchConfig", "type": "object"}, "SemanticSearchModelEnum": {"enum": ["jinav1", "jinav2"], "title": "SemanticSearchModelEnum", "type": "string"}, "SnapshotsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera.", "title": "Enable snapshots", "type": "boolean"}, "timestamp": {"default": false, "description": "Overlay a timestamp on snapshots from API.", "title": "Timestamp overlay", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes for tracked objects on snapshots from API.", "title": "Bounding box overlay", "type": "boolean"}, "crop": {"default": false, "description": "Crop snapshots from API to the detected object's bounding box.", "title": "Crop snapshot", "type": "boolean"}, "required_zones": {"description": "Zones an object must enter for a snapshot to be saved.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", "title": "Snapshot height"}, "retain": {"$ref": "#/$defs/RetainConfig", "description": "Retention settings for snapshots including default days and per-object overrides.", "title": "Snapshot retention"}, "quality": {"default": 60, "description": "Encode quality for saved snapshots (0-100).", "maximum": 100, "minimum": 0, "title": "Snapshot quality", "type": "integer"}}, "title": "SnapshotsConfig", "type": "object"}, "StationaryConfig": {"additionalProperties": false, "properties": {"interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How often (in frames) to run a detection check to confirm a stationary object.", "title": "Stationary interval"}, "threshold": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames with no position change required to mark an object as stationary.", "title": "Stationary threshold"}, "max_frames": {"$ref": "#/$defs/StationaryMaxFramesConfig", "description": "Limits how long stationary objects are tracked before being discarded.", "title": "Max frames"}, "classifier": {"default": true, "description": "Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", "title": "Enable visual classifier", "type": "boolean"}}, "title": "StationaryConfig", "type": "object"}, "StationaryMaxFramesConfig": {"additionalProperties": false, "properties": {"default": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "title": "Default max frames", "description": "Default maximum frames to track a stationary object before stopping."}, "objects": {"additionalProperties": {"type": "integer"}, "description": "Per-object overrides for maximum frames to track stationary objects.", "title": "Object max frames", "type": "object"}}, "title": "StationaryMaxFramesConfig", "type": "object"}, "StatsConfig": {"additionalProperties": false, "properties": {"amd_gpu_stats": {"default": true, "description": "Enable collection of AMD GPU statistics if an AMD GPU is present.", "title": "AMD GPU stats", "type": "boolean"}, "intel_gpu_stats": {"default": true, "description": "Enable collection of Intel GPU statistics if an Intel GPU is present.", "title": "Intel GPU stats", "type": "boolean"}, "network_bandwidth": {"default": false, "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", "title": "Network bandwidth", "type": "boolean"}, "intel_gpu_device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", "title": "Intel GPU device"}}, "title": "StatsConfig", "type": "object"}, "TelemetryConfig": {"additionalProperties": false, "properties": {"network_interfaces": {"default": [], "description": "List of network interface name prefixes to monitor for bandwidth statistics.", "items": {"type": "string"}, "title": "Network interfaces", "type": "array"}, "stats": {"$ref": "#/$defs/StatsConfig", "description": "Options to enable/disable collection of various system and GPU statistics.", "title": "System stats"}, "version_check": {"default": true, "description": "Enable an outbound check to detect if a newer Frigate version is available.", "title": "Version check", "type": "boolean"}}, "title": "TelemetryConfig", "type": "object"}, "TimeFormatEnum": {"enum": ["browser", "12hour", "24hour"], "title": "TimeFormatEnum", "type": "string"}, "TimestampEffectEnum": {"enum": ["solid", "shadow"], "title": "TimestampEffectEnum", "type": "string"}, "TimestampPositionEnum": {"enum": ["tl", "tr", "bl", "br"], "title": "TimestampPositionEnum", "type": "string"}, "TimestampStyleConfig": {"additionalProperties": false, "properties": {"position": {"$ref": "#/$defs/TimestampPositionEnum", "default": "tl", "description": "Position of the timestamp on the image (tl/tr/bl/br).", "title": "Timestamp position"}, "format": {"default": "%m/%d/%Y %H:%M:%S", "description": "Datetime format string used for timestamps (Python datetime format codes).", "title": "Timestamp format", "type": "string"}, "color": {"$ref": "#/$defs/ColorConfig", "description": "RGB color values for the timestamp text (all values 0-255).", "title": "Timestamp color"}, "thickness": {"default": 2, "description": "Line thickness of the timestamp text.", "title": "Timestamp thickness", "type": "integer"}, "effect": {"anyOf": [{"$ref": "#/$defs/TimestampEffectEnum"}, {"type": "null"}], "default": null, "description": "Visual effect for the timestamp text (none, solid, shadow).", "title": "Timestamp effect"}}, "title": "TimestampStyleConfig", "type": "object"}, "TlsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable TLS for Frigate's web UI and API on the configured TLS port.", "title": "Enable TLS", "type": "boolean"}}, "title": "TlsConfig", "type": "object"}, "TriggerAction": {"enum": ["notification", "sub_label", "attribute"], "title": "TriggerAction", "type": "string"}, "TriggerConfig": {"additionalProperties": false, "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional friendly name displayed in the UI for this trigger.", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this semantic search trigger.", "title": "Enable this trigger", "type": "boolean"}, "type": {"$ref": "#/$defs/TriggerType", "default": "description", "description": "Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", "title": "Trigger type"}, "data": {"description": "Text phrase or thumbnail ID to match against tracked objects.", "title": "Trigger content", "type": "string"}, "threshold": {"default": 0.8, "description": "Minimum similarity score (0-1) required to activate this trigger.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Trigger threshold", "type": "number"}, "actions": {"default": [], "description": "List of actions to execute when trigger matches (notification, sub_label, attribute).", "items": {"$ref": "#/$defs/TriggerAction"}, "title": "Trigger actions", "type": "array"}}, "required": ["data"], "title": "TriggerConfig", "type": "object"}, "TriggerType": {"enum": ["thumbnail", "description"], "title": "TriggerType", "type": "string"}, "UIConfig": {"additionalProperties": false, "properties": {"timezone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional timezone to display across the UI (defaults to browser local time if unset).", "title": "Timezone"}, "time_format": {"$ref": "#/$defs/TimeFormatEnum", "default": "browser", "description": "Time format to use in the UI (browser, 12hour, or 24hour).", "title": "Time format"}, "unit_system": {"$ref": "#/$defs/UnitSystemEnum", "default": "metric", "description": "Unit system for display (metric or imperial) used in the UI and MQTT.", "title": "Unit system"}}, "title": "UIConfig", "type": "object"}, "UnitSystemEnum": {"enum": ["imperial", "metric"], "title": "UnitSystemEnum", "type": "string"}, "ZoneConfig": {"properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", "title": "Zone name"}, "enabled": {"default": true, "description": "Enable or disable this zone. Disabled zones are ignored at runtime.", "title": "Enabled", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of zone."}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", "title": "Zone filters", "type": "object"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", "title": "Coordinates"}, "distances": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", "title": "Real-world distances"}, "inertia": {"default": 3, "description": "Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", "exclusiveMinimum": 0, "title": "Inertia frames", "type": "integer"}, "loitering_time": {"default": 0, "description": "Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", "minimum": 0, "title": "Loitering seconds", "type": "integer"}, "speed_threshold": {"anyOf": [{"minimum": 0.1, "type": "number"}, {"type": "null"}], "default": null, "description": "Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", "title": "Minimum speed"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", "title": "Trigger objects"}}, "required": ["coordinates"], "title": "ZoneConfig", "type": "object"}, "ZoomingModeEnum": {"enum": ["disabled", "absolute", "relative"], "title": "ZoomingModeEnum", "type": "string"}}, "additionalProperties": false, "properties": {"version": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Numeric or string version of the active configuration to help detect migrations or format changes.", "title": "Current config version"}, "safe_mode": {"default": false, "description": "When enabled, start Frigate in safe mode with reduced features for troubleshooting.", "title": "Safe mode", "type": "boolean"}, "environment_vars": {"additionalProperties": {"type": "string"}, "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", "title": "Environment variables", "type": "object"}, "logger": {"$ref": "#/$defs/LoggerConfig", "description": "Controls default log verbosity and per-component log level overrides.", "title": "Logging"}, "auth": {"$ref": "#/$defs/AuthConfig", "description": "Authentication and session-related settings including cookie and rate limit options.", "title": "Authentication"}, "database": {"$ref": "#/$defs/DatabaseConfig", "description": "Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", "title": "Database"}, "go2rtc": {"$ref": "#/$defs/RestreamConfig", "description": "Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", "title": "go2rtc"}, "mqtt": {"$ref": "#/$defs/MqttConfig", "description": "Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for all cameras; can be overridden per-camera.", "title": "Notifications"}, "networking": {"$ref": "#/$defs/NetworkingConfig", "description": "Network-related settings such as IPv6 enablement for Frigate endpoints.", "title": "Networking"}, "proxy": {"$ref": "#/$defs/ProxyConfig", "description": "Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", "title": "Proxy"}, "telemetry": {"$ref": "#/$defs/TelemetryConfig", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "title": "Telemetry"}, "tls": {"$ref": "#/$defs/TlsConfig", "description": "TLS settings for Frigate's web endpoints (port 8971).", "title": "TLS"}, "ui": {"$ref": "#/$defs/UIConfig", "description": "User interface preferences such as timezone, time/date formatting, and units.", "title": "UI"}, "detectors": {"additionalProperties": {"$ref": "#/$defs/BaseDetectorConfig"}, "default": {"cpu": {"type": "cpu"}}, "description": "Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.", "title": "Detector hardware", "type": "object"}, "model": {"$ref": "#/$defs/ModelConfig", "description": "Settings to configure a custom object detection model and its input shape.", "title": "Detection model"}, "genai": {"additionalProperties": {"$ref": "#/$defs/GenAIConfig"}, "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "title": "Generative AI configuration", "type": "object"}, "cameras": {"additionalProperties": {"$ref": "#/$defs/CameraConfig"}, "description": "Cameras", "title": "Cameras", "type": "object"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "title": "Audio detection"}, "birdseye": {"$ref": "#/$defs/BirdseyeConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "ffmpeg": {"$ref": "#/$defs/FfmpegConfig", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "title": "FFmpeg"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", "title": "Live playback"}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null, "description": "Default motion detection settings applied to cameras unless overridden per-camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings applied to cameras unless overridden per-camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", "title": "Review"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for in-feed timestamps applied to debug view and snapshots.", "title": "Timestamp style"}, "audio_transcription": {"$ref": "#/$defs/AudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "classification": {"$ref": "#/$defs/ClassificationConfig", "description": "Settings for classification models used to refine object labels or state classification.", "title": "Object classification"}, "semantic_search": {"$ref": "#/$defs/SemanticSearchConfig", "description": "Settings for Semantic Search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "face_recognition": {"$ref": "#/$defs/FaceRecognitionConfig", "description": "Settings for face detection and recognition for all cameras; can be overridden per-camera.", "title": "Face recognition"}, "lpr": {"$ref": "#/$defs/LicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "camera_groups": {"additionalProperties": {"$ref": "#/$defs/CameraGroupConfig"}, "description": "Configuration for named camera groups used to organize cameras in the UI.", "title": "Camera groups", "type": "object"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/ProfileDefinitionConfig"}, "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", "title": "Profiles", "type": "object"}, "active_profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Currently active profile name. Runtime-only, not persisted in YAML.", "title": "Active profile"}}, "required": ["mqtt", "cameras"], "title": "FrigateConfig", "type": "object"} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/config-snapshot.json b/web/e2e/fixtures/mock-data/config-snapshot.json index 2df402bc99..f8b5eb8f3b 100644 --- a/web/e2e/fixtures/mock-data/config-snapshot.json +++ b/web/e2e/fixtures/mock-data/config-snapshot.json @@ -1 +1 @@ -{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "unit_system": "metric"}, "detectors": {"cpu": {"type": "cpu", "model": {"path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd"}, "model_path": null}}, "model": {"path": null, "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}, "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "mode": "objects", "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file +{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "unit_system": "metric"}, "detectors": {"cpu": {"type": "cpu", "model": {"path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd"}, "model_path": null}}, "model": {"path": null, "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}, "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": {"continuous": false, "motion": false, "objects": true, "stationary_objects": false}, "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": {"continuous": false, "motion": false, "objects": true, "stationary_objects": false}, "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": {"continuous": false, "motion": false, "objects": true, "stationary_objects": false}, "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "mode": {"continuous": false, "motion": false, "objects": true, "stationary_objects": false}, "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 83061ee899..9418da63cc 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -867,6 +867,31 @@ test.describe("Multi-Camera Export from History @high", () => { await frigateApp.page.route("**/api/recordings/unavailable**", (route) => route.fulfill({ json: [] }), ); + // registered after the broad recordings route so it wins the match; + // coverage is an object, and the array above crashes the player + await frigateApp.page.route("**/api/*/recordings/coverage**", (route) => + route.fulfill({ + json: { + spans: [ + { + start_time: playbackTime - 3600, + end_time: playbackTime + 600, + streams: ["main"], + }, + ], + codecs_compatible: true, + streams: { + main: { + video_codec: "h264", + audio_rate: null, + audio_codec: null, + has_audio: false, + bitrate: 2_000_000, + }, + }, + }, + }), + ); await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`); } diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index 011baaa1bf..de67ea156b 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -13,6 +13,20 @@ "desc": "No frames have been received on the {{cameraName}} detect stream, check error logs" }, "cameraOff": "Camera is off", + "quality": { + "auto": "Auto", + "autoLow": "Playing low quality (limited bandwidth)", + "autoLowCodec": "Playing low quality (Original not supported by this browser)", + "autoLowSaveData": "Playing low quality (data saver)", + "main": "Original", + "sub": "Low", + "notSupportedBrowser": "Not supported by this browser", + "label": "Quality", + "noAudio": "No audio", + "audioRate": "{{rate}} kHz audio", + "audioCodecRate": "{{codec}} {{rate}} kHz", + "noRecordings": "No recordings in this time range" + }, "stats": { "streamType": { "title": "Stream Type:", @@ -46,7 +60,8 @@ "submittedFrigatePlus": "Successfully submitted frame to Frigate+" }, "error": { - "submitFrigatePlusFailed": "Failed to submit frame to Frigate+" + "submitFrigatePlusFailed": "Failed to submit frame to Frigate+", + "playRecordingsFailed": "Failed to play recordings (error {{code}}): {{message}}" } } } diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index bf4be32637..5f9d9249b7 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -196,6 +196,10 @@ "record": { "label": "Record output arguments", "description": "Default output arguments for record role streams." + }, + "record_sub": { + "label": "Sub stream record output arguments", + "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set." } }, "retry_interval": { @@ -522,6 +526,54 @@ "description": "Preview quality level (very_low, low, medium, high, very_high)." } }, + "sub": { + "label": "Sub stream recording", + "description": "Settings for recording a second, lower quality stream.", + "enabled": { + "label": "Enable sub stream recording", + "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention." + }, + "continuous": { + "label": "Sub stream continuous retention", + "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", + "days": { + "label": "Retention days", + "description": "Days to retain recordings." + } + }, + "motion": { + "label": "Sub stream motion retention", + "description": "Number of days to retain sub stream recordings triggered by motion.", + "days": { + "label": "Retention days", + "description": "Days to retain recordings." + } + }, + "alerts": { + "label": "Sub stream alert retention", + "description": "Retention settings for sub stream recordings of alerts.", + "days": { + "label": "Retention days", + "description": "Number of days to retain recordings of detection events." + }, + "mode": { + "label": "Retention mode", + "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)." + } + }, + "detections": { + "label": "Sub stream detection retention", + "description": "Retention settings for sub stream recordings of detections.", + "days": { + "label": "Retention days", + "description": "Number of days to retain recordings of detection events." + }, + "mode": { + "label": "Retention mode", + "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)." + } + } + }, "enabled_in_config": { "label": "Original recording state", "description": "Indicates whether recording was enabled in the original static configuration." diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 32aa603902..058ea26fa4 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -707,6 +707,10 @@ "record": { "label": "Record output arguments", "description": "Default output arguments for record role streams." + }, + "record_sub": { + "label": "Sub stream record output arguments", + "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set." } }, "retry_interval": { @@ -1048,6 +1052,54 @@ "description": "Preview quality level (very_low, low, medium, high, very_high)." } }, + "sub": { + "label": "Sub stream recording", + "description": "Settings for recording a second, lower quality stream.", + "enabled": { + "label": "Enable sub stream recording", + "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention." + }, + "continuous": { + "label": "Sub stream continuous retention", + "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", + "days": { + "label": "Retention days", + "description": "Days to retain recordings." + } + }, + "motion": { + "label": "Sub stream motion retention", + "description": "Number of days to retain sub stream recordings triggered by motion.", + "days": { + "label": "Retention days", + "description": "Days to retain recordings." + } + }, + "alerts": { + "label": "Sub stream alert retention", + "description": "Retention settings for sub stream recordings of alerts.", + "days": { + "label": "Retention days", + "description": "Number of days to retain recordings of detection events." + }, + "mode": { + "label": "Retention mode", + "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)." + } + }, + "detections": { + "label": "Sub stream detection retention", + "description": "Retention settings for sub stream recordings of detections.", + "days": { + "label": "Retention days", + "description": "Number of days to retain recordings of detection events." + }, + "mode": { + "label": "Retention mode", + "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)." + } + } + }, "enabled_in_config": { "label": "Original recording state", "description": "Indicates whether recording was enabled in the original static configuration." diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index f895d8f5e6..b2c79a2daf 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -21,6 +21,7 @@ }, "zoomIn": "Zoom In", "zoomOut": "Zoom Out", + "subOnlyQuality": "Only low quality recordings are available in this time range", "events": { "label": "Events", "aria": "Select events", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9cd1cfe8f0..6a45380413 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -398,6 +398,7 @@ "title": "Stream Roles", "detect": "Main feed for object detection.", "record": "Saves segments of the video feed based on configuration settings.", + "record_sub": "Lower quality recordings for adaptive playback and extended retention (typically the camera sub stream).", "audio": "Feed for audio based detection." }, "featuresPopover": { @@ -1528,6 +1529,7 @@ "manual": "Manual arguments", "inherit": "Inherit from camera setting", "none": "None", + "sameAsRecord": "Same as record output arguments", "useGlobalSetting": "Inherit from global setting", "selectPreset": "Select preset", "manualPlaceholder": "Enter FFmpeg arguments", @@ -1648,9 +1650,12 @@ "inputRoles": { "summary": "{{count}} roles selected", "empty": "No roles available", + "roleInUse": "Already assigned to another stream", + "recordSubConflict": "A stream cannot have both record and record_sub roles", "options": { "detect": "Detect", "record": "Record", + "record_sub": "Record (Sub Stream)", "audio": "Audio" } }, @@ -1952,7 +1957,8 @@ "modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats." }, "record": { - "noRecordRole": "No streams have the record role defined. Recording will not function." + "noRecordRole": "No streams have the record role defined. Recording will not function.", + "noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function." }, "birdseye": { "objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye." diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index 62117d1b4b..5fd7b8c1cc 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -20,6 +20,13 @@ const ffmpegArgsWidget = ( }, }); +const recordSubArgsWidget = () => + ffmpegArgsWidget("output_args.record_sub", { + allowInherit: true, + forceSplitLayout: true, + unsetLabelKey: "configForm.ffmpegArgs.sameAsRecord", + }); + const ffmpeg: SectionConfigOverrides = { base: { sectionDocs: "/configuration/ffmpeg_presets", @@ -75,6 +82,8 @@ const ffmpeg: SectionConfigOverrides = { output_args: "/configuration/ffmpeg_presets#output-args-presets", "inputs.output_args": "/configuration/ffmpeg_presets#output-args-presets", "output_args.record": "/configuration/ffmpeg_presets#output-args-presets", + "output_args.record_sub": + "/configuration/ffmpeg_presets#output-args-presets", "inputs.roles": "/configuration/cameras/#setting-up-camera-inputs", apple_compatibility: "/configuration/camera_specific#h265-cameras-via-safari", @@ -112,9 +121,11 @@ const ffmpeg: SectionConfigOverrides = { output_args: { detect: arrayAsTextWidget, record: ffmpegArgsWidget("output_args.record"), + record_sub: recordSubArgsWidget(), items: { detect: arrayAsTextWidget, record: ffmpegArgsWidget("output_args.record"), + record_sub: recordSubArgsWidget(), }, }, inputs: { @@ -148,6 +159,7 @@ const ffmpeg: SectionConfigOverrides = { items: { detect: arrayAsTextWidget, record: ffmpegArgsWidget("output_args.record"), + record_sub: recordSubArgsWidget(), }, }, }, @@ -176,6 +188,7 @@ const ffmpeg: SectionConfigOverrides = { output_args: { detect: arrayAsTextWidget, record: ffmpegArgsWidget("output_args.record"), + record_sub: recordSubArgsWidget(), }, }, }, diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 9271e025fb..a8a9a05347 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -15,6 +15,19 @@ const record: SectionConfigOverrides = { ); }, }, + { + key: "no-record-sub-role", + messageKey: "configMessages.record.noRecordSubRole", + severity: "warning", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + const sub = ctx.formData?.sub as Record | undefined; + if (!sub?.enabled) return false; + return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((i) => + i.roles?.includes("record_sub"), + ); + }, + }, ], fieldDocs: { "alerts.pre_capture": @@ -34,6 +47,7 @@ const record: SectionConfigOverrides = { "motion", "alerts", "detections", + "sub", "preview", "export", ], @@ -67,6 +81,12 @@ const record: SectionConfigOverrides = { "detections.retain.mode": { "ui:options": { enumI18nPrefix: "retainMode" }, }, + "sub.alerts.mode": { + "ui:options": { enumI18nPrefix: "retainMode" }, + }, + "sub.detections.mode": { + "ui:options": { enumI18nPrefix: "retainMode" }, + }, preview: { "ui:options": { defaultOpen: true, disableCollapsible: true }, quality: { diff --git a/web/src/components/config-form/theme/fields/CameraInputsField.tsx b/web/src/components/config-form/theme/fields/CameraInputsField.tsx index 6d42de613d..67dd3b1dd5 100644 --- a/web/src/components/config-form/theme/fields/CameraInputsField.tsx +++ b/web/src/components/config-form/theme/fields/CameraInputsField.tsx @@ -193,6 +193,25 @@ export function CameraInputsField(props: FieldProps) { } }, [fieldPathId.path, inputs, onChange]); + const getRolesUsedByOtherInputs = useCallback( + (index: number): string[] => { + const used = new Set(); + inputs.forEach((input, currentIndex) => { + if (currentIndex === index || !Array.isArray(input.roles)) { + return; + } + + input.roles.forEach((role) => { + if (typeof role === "string") { + used.add(role); + } + }); + }); + return [...used]; + }, + [inputs], + ); + const handleFieldValueChange = useCallback( (index: number, fieldName: string, nextValue: unknown) => { const nextInputs = cloneDeep(inputs); @@ -466,7 +485,16 @@ export function CameraInputsField(props: FieldProps) { /> -
{renderField(index, "roles")}
+
+ {renderField(index, "roles", { + extraUiSchema: { + "ui:options": { + rolesUsedByOtherInputs: + getRolesUsedByOtherInputs(index), + }, + }, + })} +
{renderField(index, "input_args")} diff --git a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx index 527789c814..e519d373dd 100644 --- a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx +++ b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx @@ -30,6 +30,7 @@ type PresetField = | "hwaccel_args" | "input_args" | "output_args.record" + | "output_args.record_sub" | "output_args.detect"; const getPresetOptions = ( @@ -49,7 +50,10 @@ const getPresetOptions = ( } if (field.startsWith("output_args.")) { - const key = field.split(".")[1] as "record" | "detect"; + const key = + field === "output_args.record_sub" + ? "record" + : (field.split(".")[1] as "record" | "detect"); return data.output_args?.[key] ?? []; } @@ -127,6 +131,7 @@ export function FfmpegArgsWidget(props: WidgetProps) { const globalFieldPath = (options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField; const allowInherit = options?.allowInherit === true; + const unsetLabelKey = options?.unsetLabelKey as string | undefined; const hideDescription = options?.hideDescription === true; const useSplitLayout = options?.splitLayout !== false; @@ -287,6 +292,12 @@ export function FfmpegArgsWidget(props: WidgetProps) { : "ffmpeg.output_args.record.description"; } + if (presetField === "output_args.record_sub") { + return isInputScoped + ? "ffmpeg.inputs.output_args.record_sub.description" + : "ffmpeg.output_args.record_sub.description"; + } + if (presetField === "output_args.detect") { return isInputScoped ? "ffmpeg.inputs.output_args.detect.description" @@ -345,7 +356,9 @@ export function FfmpegArgsWidget(props: WidgetProps) { } /> ) : ( @@ -361,7 +374,9 @@ export function FfmpegArgsWidget(props: WidgetProps) { } /> )} diff --git a/web/src/components/config-form/theme/widgets/InputRolesWidget.tsx b/web/src/components/config-form/theme/widgets/InputRolesWidget.tsx index c50cf76521..79dc75ac3c 100644 --- a/web/src/components/config-form/theme/widgets/InputRolesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/InputRolesWidget.tsx @@ -3,7 +3,14 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Switch } from "@/components/ui/switch"; -const INPUT_ROLES = ["detect", "record", "audio"] as const; +const INPUT_ROLES = ["detect", "record", "record_sub", "audio"] as const; + +// Recording the sub stream from the same input as record would just +// re-record the main stream, so the two roles are mutually exclusive. +const CONFLICTING_ROLES: Partial> = { + record: "record_sub", + record_sub: "record", +}; function normalizeValue(value: unknown): string[] { if (Array.isArray(value)) { @@ -18,11 +25,18 @@ function normalizeValue(value: unknown): string[] { } export function InputRolesWidget(props: WidgetProps) { - const { id, value, disabled, readonly, onChange } = props; + const { id, value, disabled, readonly, onChange, options } = props; const { t } = useTranslation(["views/settings"]); const selectedRoles = useMemo(() => normalizeValue(value), [value]); + // Each role may only be assigned to a single input, so roles already + // used by sibling inputs are locked. + const rolesUsedByOtherInputs = useMemo( + () => normalizeValue(options?.rolesUsedByOtherInputs), + [options], + ); + const toggleRole = (role: string, enabled: boolean) => { if (enabled) { if (!selectedRoles.includes(role)) { @@ -39,6 +53,20 @@ export function InputRolesWidget(props: WidgetProps) {
{INPUT_ROLES.map((role) => { const checked = selectedRoles.includes(role); + const usedByOtherInput = + !checked && rolesUsedByOtherInputs.includes(role); + const conflictingRole = CONFLICTING_ROLES[role]; + const hasConflict = + !checked && + conflictingRole !== undefined && + selectedRoles.includes(conflictingRole); + const hint = usedByOtherInput + ? t("configForm.inputRoles.roleInUse", { ns: "views/settings" }) + : hasConflict + ? t("configForm.inputRoles.recordSubConflict", { + ns: "views/settings", + }) + : undefined; const label = t(`configForm.inputRoles.options.${role}`, { ns: "views/settings", defaultValue: role, @@ -49,13 +77,20 @@ export function InputRolesWidget(props: WidgetProps) { key={role} className="flex items-center justify-between rounded-md px-3 py-0" > - +
+ + {hint ? ( + {hint} + ) : null} +
toggleRole(role, !!enabled)} />
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index c5c1a1c5b0..bebfec8ac3 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -14,12 +14,10 @@ import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa"; import { isDesktop, isMobile } from "react-device-detect"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import MobileReviewSettingsDrawer, { - DrawerFeatures, -} from "../overlay/MobileReviewSettingsDrawer"; +import MobileReviewSettingsDrawer from "../overlay/MobileReviewSettingsDrawer"; import useOptimisticState from "@/hooks/use-optimistic-state"; import FilterSwitch from "./FilterSwitch"; -import { FilterList, GeneralFilter } from "@/types/filter"; +import { DrawerFeatures, FilterList, GeneralFilter } from "@/types/filter"; import CalendarFilterButton from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 47a5236618..96eba6be6e 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -10,7 +10,12 @@ import { DebugReplayContent, SaveDebugReplayOverlay, } from "./DebugReplayDialog"; -import { ExportMode, GeneralFilter } from "@/types/filter"; +import { + DEFAULT_DRAWER_FEATURES, + DrawerFeatures, + ExportMode, + GeneralFilter, +} from "@/types/filter"; import ReviewActivityCalendar from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; import { @@ -31,6 +36,14 @@ import { StartExportResponse } from "@/types/export"; import { ShareTimestampContent } from "./ShareTimestampDialog"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { cn } from "@/lib/utils"; +import { FaTriangleExclamation } from "react-icons/fa6"; +import { MdHighQuality } from "react-icons/md"; +import { QualitySelectorContent } from "../player/QualitySelector"; +import { + AutoQualityReason, + PlaybackQuality, + RecordingCoverage, +} from "@/types/record"; type DrawerMode = | "none" @@ -39,25 +52,8 @@ type DrawerMode = | "calendar" | "filter" | "debug-replay" - | "share-timestamp"; - -const DRAWER_FEATURES = [ - "export", - "calendar", - "filter", - "debug-replay", - "share-timestamp", - "motion-search", -] as const; -export type DrawerFeatures = (typeof DRAWER_FEATURES)[number]; -const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ - "export", - "calendar", - "filter", - "debug-replay", - "share-timestamp", - "motion-search", -]; + | "share-timestamp" + | "quality"; type MobileReviewSettingsDrawerProps = { features?: DrawerFeatures[]; @@ -84,6 +80,12 @@ type MobileReviewSettingsDrawerProps = { setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; setShowExportPreview: (showPreview: boolean) => void; + quality?: PlaybackQuality; + onSetQuality?: (quality: PlaybackQuality) => void; + qualityStreams?: RecordingCoverage["streams"]; + qualityAutoLow?: boolean; + qualityAutoLowReason?: AutoQualityReason; + qualityMainUnsupported?: boolean; }; export default function MobileReviewSettingsDrawer({ features = DEFAULT_DRAWER_FEATURES, @@ -110,12 +112,19 @@ export default function MobileReviewSettingsDrawer({ setRange, setMode, setShowExportPreview, + quality, + onSetQuality, + qualityStreams, + qualityAutoLow, + qualityAutoLowReason, + qualityMainUnsupported, }: MobileReviewSettingsDrawerProps) { const { t } = useTranslation([ "views/recording", "components/dialog", "views/replay", "views/events", + "components/player", "common", ]); const isAdmin = useIsAdmin(); @@ -395,6 +404,21 @@ export default function MobileReviewSettingsDrawer({ {t("filter")} )} + {features.includes("quality") && onSetQuality && ( + + )} {features.includes("share-timestamp") && ( + ); + + return ( + { + if (setControlsOpen) { + setControlsOpen(open); + } + }} + > + {trigger} + + onSetQuality(value as PlaybackQuality)} + > + {PLAYBACK_QUALITIES.map((q) => ( + + {itemContent(q)} + + ))} + + + + ); +} + +type QualitySelectorContentProps = QualitySubtitleProps & { + quality: PlaybackQuality; + onSetQuality: (quality: PlaybackQuality) => void; +}; + +// drawer-friendly variant of the selector for the mobile settings drawer +export function QualitySelectorContent({ + quality, + onSetQuality, + streams, + autoLow, + autoLowReason, + mainUnsupported, +}: QualitySelectorContentProps) { + const { t } = useTranslation(["components/player"]); + const subtitles = useQualitySubtitles({ + streams, + autoLow, + autoLowReason, + mainUnsupported, + }); + + return ( +
+ {PLAYBACK_QUALITIES.map((q) => ( +
onSetQuality(q)} + > +
{t(`quality.${q}`)}
+ {subtitles[q] && ( +
{subtitles[q]}
+ )} +
+ ))} +
+ ); +} diff --git a/web/src/components/player/dynamic/AutoQualityGovernor.ts b/web/src/components/player/dynamic/AutoQualityGovernor.ts new file mode 100644 index 0000000000..81cf9edc71 --- /dev/null +++ b/web/src/components/player/dynamic/AutoQualityGovernor.ts @@ -0,0 +1,372 @@ +/** + * Policy engine for auto playback quality downswitching. + * + * Stall time is measured rather than counted: hls.js reports + * BUFFER_STALLED_ERROR only once per episode (the flag resets only when + * playback resumes), so counting events makes the worst networks, where + * one freeze never resolves, the least likely to ever downswitch. + */ + +export type DownswitchReason = + | "stall" + | "bandwidth" + | "fatal-error" + | "startup" + | "codec"; + +// stalls just after a seek are expected on any network (the target +// position is rarely buffered), so they get a longer budget and are +// kept out of the cumulative window +const SEEK_GRACE_MS = 2000; +// a single unresolved stall episode this long triggers a downswitch +const SINGLE_STALL_DOWNSWITCH_MS = 4000; +// seek-adjacent episodes only trigger once clearly beyond load latency +const GRACED_STALL_DOWNSWITCH_MS = 10000; +// total (non-graced) stall time within the rolling window that triggers +const CUMULATIVE_STALL_DOWNSWITCH_MS = 7000; +// rolling window for cumulative stall accounting; long enough to catch +// chronic short stalls, short enough that ancient history ages out +const STALL_WINDOW_MS = 60000; +// a throughput sample below bitrate * margin counts as evidence the +// connection cannot sustain the stream +const PREDICTIVE_BANDWIDTH_MARGIN = 1.1; +// consecutive low samples required for a predictive (pre-stall) downswitch +const PREDICTIVE_SAMPLE_COUNT = 3; +// measured throughput must clear the original stream's bitrate by this +// margin before a downswitched player retries full quality +const RETRY_BANDWIDTH_MARGIN = 1.5; +// the stall clock is blind before playback starts (the player is still +// paused), so the initial load needs its own budget +const STARTUP_DOWNSWITCH_MS = 10000; +// no realistic original recording stream plays comfortably below this, +// so a camera whose bitrate is not yet known starts low +const KNOWN_SLOW_START_FLOOR_BPS = 3_000_000; +// the first sample is biased toward the seeded default estimate +const PROBE_MIN_SUB_SAMPLES = 2; + +type StallEpisode = { + start: number; + end: number; +}; + +export class AutoQualityGovernor { + // returns false when quality is pinned, sub is unavailable, or the + // player is already low + private requestDownswitch: (reason: DownswitchReason) => boolean; + private requestUpswitch: (() => void) | undefined; + + private episodes: StallEpisode[] = []; + private openEpisode: { start: number; graced: boolean } | null = null; + private stallTimer: ReturnType | undefined; + private startupTimer: ReturnType | undefined; + private lastSeekTs = 0; + private consecutiveLowSamples = 0; + private upswitchProbeArmed = false; + private probeSampleCount = 0; + private mainUnplayable = false; + private holdLow = false; + + // network facts survive stall-history resets: a manual pin or camera + // switch does not change what the connection can carry + private bandwidthEstimateBps: number | undefined; + private mainBitrateBps: number | undefined; + + constructor( + requestDownswitch: (reason: DownswitchReason) => boolean, + requestUpswitch?: () => void, + ) { + this.requestDownswitch = requestDownswitch; + this.requestUpswitch = requestUpswitch; + } + + get bandwidthEstimate(): number | undefined { + return this.bandwidthEstimateBps; + } + + /** Seed the connection estimate persisted from earlier sessions. */ + seed(bandwidthEstimateBps: number | undefined) { + if (this.bandwidthEstimateBps === undefined) { + this.bandwidthEstimateBps = bandwidthEstimateBps; + } + } + + /** + * Suppresses every path that would route playback back onto the + * original stream. + */ + markMainUnplayable() { + this.mainUnplayable = true; + } + + get isMainUnplayable(): boolean { + return this.mainUnplayable; + } + + /** + * Hold playback on the low stream regardless of measured headroom + * (user preference such as data saver, not a bandwidth fact). + */ + setHoldLow(hold: boolean) { + this.holdLow = hold; + } + + /** + * Record the original stream's advertised bitrate learned outside of + * playback (e.g. parsed from its master playlist). Live measurements + * take precedence. + */ + learnMainBitrate(bitrateBps: number) { + if (this.mainBitrateBps === undefined && bitrateBps > 0) { + this.mainBitrateBps = bitrateBps; + } + } + + /** + * Starts the time-to-first-frame budget: no stall episode can exist + * before playback starts, so a first segment too large for the + * connection would otherwise spin forever. + */ + sourceLoadStarted() { + clearTimeout(this.startupTimer); + this.startupTimer = setTimeout( + () => this.triggerDownswitch("startup"), + STARTUP_DOWNSWITCH_MS, + ); + } + + sourceLoadEnded() { + clearTimeout(this.startupTimer); + this.startupTimer = undefined; + } + + /** + * Arm the one-shot upswitch probe after a conservative low start. + * Stays armed until it fires or a manual pin resets it, so a + * connection that improves later still recovers mid-chunk. + */ + armUpswitchProbe() { + this.upswitchProbeArmed = true; + this.probeSampleCount = 0; + } + + noteSeek() { + this.lastSeekTs = Date.now(); + } + + /** + * A stall episode began (hls.js BUFFER_STALLED_ERROR or a video + * element waiting event). Idempotent while an episode is open, so the + * two signal sources need no cross-coordination. + */ + stallStarted() { + if (this.openEpisode) { + return; + } + + const now = Date.now(); + const graced = now - this.lastSeekTs < SEEK_GRACE_MS; + this.openEpisode = { start: now, graced }; + + // fire mid-stall: either this episode alone exceeds its budget, or + // it pushes the window's cumulative stall time over the threshold + const singleBudget = graced + ? GRACED_STALL_DOWNSWITCH_MS + : SINGLE_STALL_DOWNSWITCH_MS; + const cumulativeBudget = graced + ? Number.POSITIVE_INFINITY + : Math.max(0, CUMULATIVE_STALL_DOWNSWITCH_MS - this.windowStallMs(now)); + this.stallTimer = setTimeout( + () => this.triggerDownswitch("stall"), + Math.min(singleBudget, cumulativeBudget), + ); + } + + /** + * Playback resumed (STALL_RESOLVED, playing, timeupdate) or paused. + * Closes any open episode; graced episodes never enter the window. + */ + stallEnded() { + if (!this.openEpisode) { + return; + } + + clearTimeout(this.stallTimer); + this.stallTimer = undefined; + + const now = Date.now(); + if (!this.openEpisode.graced && now > this.openEpisode.start) { + this.episodes.push({ start: this.openEpisode.start, end: now }); + } + this.openEpisode = null; + this.pruneEpisodes(now); + } + + /** + * A segment finished loading. Records throughput, refreshes the + * original stream's bitrate while playing it, and downswitches + * predictively when sustained throughput cannot carry the stream. + */ + bandwidthSample( + estimateBps: number, + levelBitrateBps: number | undefined, + playingMain: boolean, + ) { + if (!Number.isFinite(estimateBps) || estimateBps <= 0) { + return; + } + + this.bandwidthEstimateBps = estimateBps; + + if (!playingMain) { + this.consecutiveLowSamples = 0; + this.probeSampleCount += 1; + if ( + this.upswitchProbeArmed && + !this.mainUnplayable && + !this.holdLow && + this.probeSampleCount >= PROBE_MIN_SUB_SAMPLES && + this.mainBitrateBps !== undefined && + estimateBps > this.mainBitrateBps * RETRY_BANDWIDTH_MARGIN + ) { + this.upswitchProbeArmed = false; + this.requestUpswitch?.(); + } + return; + } + + if (levelBitrateBps === undefined || levelBitrateBps <= 0) { + return; + } + + this.mainBitrateBps = levelBitrateBps; + + if (estimateBps < levelBitrateBps * PREDICTIVE_BANDWIDTH_MARGIN) { + this.consecutiveLowSamples += 1; + if (this.consecutiveLowSamples >= PREDICTIVE_SAMPLE_COUNT) { + this.consecutiveLowSamples = 0; + this.triggerDownswitch("bandwidth"); + } + } else { + this.consecutiveLowSamples = 0; + } + } + + /** + * hls.js gave up loading (retries exhausted). Returns whether a + * downswitch happened so the player knows to attempt recovery instead. + */ + fatalNetworkError(): boolean { + return this.triggerDownswitch("fatal-error"); + } + + /** + * Unlike bandwidth signals a codec failure is proof, so the original + * stream is marked unplayable before the downswitch. Returns whether + * a downswitch happened. + */ + fatalCodecError(): boolean { + this.mainUnplayable = true; + return this.triggerDownswitch("codec"); + } + + /** + * Whether a downswitched player should retry full quality at the next + * chunk boundary. Native HLS playback reports no segment stats, so + * without bandwidth evidence this falls back to a clean stall window. + */ + shouldRetryMain(): boolean { + if (this.mainUnplayable || this.holdLow) { + return false; + } + + if ( + this.bandwidthEstimateBps !== undefined && + this.mainBitrateBps !== undefined + ) { + return ( + this.bandwidthEstimateBps > this.mainBitrateBps * RETRY_BANDWIDTH_MARGIN + ); + } + + return this.windowStallMs(Date.now()) === 0; + } + + /** + * Whether playback should begin on the low quality stream based on + * persisted network knowledge. A fully-cold device returns false; the + * owner handles that case with a conservative start plus the probe. + */ + shouldStartLow(): boolean { + if (this.bandwidthEstimateBps === undefined) { + return false; + } + + if (this.mainBitrateBps !== undefined) { + return ( + this.bandwidthEstimateBps < + this.mainBitrateBps * PREDICTIVE_BANDWIDTH_MARGIN + ); + } + + // unknown camera bitrate: above the floor, start on the original + // and let the startup budget correct a wrong guess + return this.bandwidthEstimateBps < KNOWN_SLOW_START_FLOOR_BPS; + } + + /** A manual pin invalidates stall history but not network facts. */ + resetStallHistory() { + clearTimeout(this.stallTimer); + this.stallTimer = undefined; + clearTimeout(this.startupTimer); + this.startupTimer = undefined; + this.openEpisode = null; + this.episodes = []; + this.consecutiveLowSamples = 0; + this.upswitchProbeArmed = false; + this.probeSampleCount = 0; + } + + /** + * A camera switch additionally invalidates the per-camera facts: the + * stream bitrate and codec playability. The holdLow preference is + * device-level and survives. + */ + resetForCamera() { + this.resetStallHistory(); + this.mainBitrateBps = undefined; + this.mainUnplayable = false; + } + + destroy() { + this.resetStallHistory(); + } + + private triggerDownswitch(reason: DownswitchReason): boolean { + const handled = this.requestDownswitch(reason); + if (handled) { + // the low stream starts with a clean record + this.resetStallHistory(); + } + return handled; + } + + private windowStallMs(now: number): number { + this.pruneEpisodes(now); + const windowStart = now - STALL_WINDOW_MS; + let total = 0; + for (const episode of this.episodes) { + total += episode.end - Math.max(episode.start, windowStart); + } + if (this.openEpisode && !this.openEpisode.graced) { + total += now - Math.max(this.openEpisode.start, windowStart); + } + return total; + } + + private pruneEpisodes(now: number) { + const windowStart = now - STALL_WINDOW_MS; + this.episodes = this.episodes.filter( + (episode) => episode.end > windowStart, + ); + } +} diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index 151ea4022f..3e48e008e3 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -10,6 +10,10 @@ import { playWithTemporaryMuteFallback } from "@/utils/videoUtil.ts"; type PlayerMode = "playback" | "scrubbing"; +// how long a seek may wait for its `seeked` event before playback starts +// anyway; long enough that a normally completing seek always wins +const SEEK_PLAY_FALLBACK_MS = 1000; + export class DynamicVideoController { // main state public camera = ""; @@ -24,7 +28,6 @@ export class DynamicVideoController { private timeRange: TimeRange = { after: 0, before: 0 }; private inpointOffset: number = 0; private annotationOffset: number; - private timeToStart: number | undefined = undefined; constructor( camera: string, @@ -51,11 +54,6 @@ export class DynamicVideoController { this.timeRange.after, this.recordings[0], ); - - if (this.timeToStart) { - this.seekToTimestamp(this.timeToStart); - this.timeToStart = undefined; - } } play() { @@ -71,8 +69,11 @@ export class DynamicVideoController { } seekToTimestamp(time: number, play: boolean = false) { + // a seek outside the current playback window is a no-op: the view + // moves its anchor and chunk on such seeks, and the rebuilt source + // resumes at the anchor (startPosition plus the post-load seek). + // Seeking here would only reposition the outgoing source's media if (time < this.timeRange.after || time > this.timeRange.before) { - this.timeToStart = time; return; } @@ -91,22 +92,33 @@ export class DynamicVideoController { return; } - if (seekSeconds != 0) { - this.playerController.currentTime = seekSeconds; - + if (this.playerController.currentTime === seekSeconds) { + // seeking to the current position fires no seeked event, so apply + // the play intent directly (this includes position 0, which the + // player sits at before its first seek) if (play) { - this.waitAndPlay(); + playWithTemporaryMuteFallback(this.playerController); } else { this.playerController.pause(); } + return; + } + + this.playerController.currentTime = seekSeconds; + + if (play) { + this.waitAndPlay(); } else { - // no op + this.playerController.pause(); } } waitAndPlay() { return new Promise((resolve) => { + let fallback: NodeJS.Timeout | undefined; + const onSeekedHandler = () => { + clearTimeout(fallback); this.playerController.removeEventListener("seeked", onSeekedHandler); playWithTemporaryMuteFallback(this.playerController); resolve(undefined); @@ -115,6 +127,12 @@ export class DynamicVideoController { this.playerController.addEventListener("seeked", onSeekedHandler, { once: true, }); + + // iOS ManagedMediaSource pauses hls.js buffering, so `seeked` may + // never fire; playing is what prompts WebKit to resume streaming + if ("ManagedMediaSource" in window) { + fallback = setTimeout(onSeekedHandler, SEEK_PLAY_FALLBACK_MS); + } }); } @@ -126,20 +144,26 @@ export class DynamicVideoController { getProgress(playerTime: number): number { // take a player time in seconds and convert to timestamp in timeline - let timestamp = 0; + const recordings = this.recordings || []; let totalTime = 0; - (this.recordings || []).every((segment) => { + for (const segment of recordings) { if (totalTime + segment.duration > playerTime) { - // segment is here - timestamp = segment.start_time + (playerTime - totalTime); - return false; - } else { - totalTime += segment.duration; - return true; + // playlist media from before the span's wall start (keyframe + // back-snap lead-in) clamps to the span start + const wallLength = segment.end_time - segment.start_time; + const leadIn = Math.max(0, segment.duration - wallLength); + return ( + segment.start_time + Math.max(0, playerTime - totalTime - leadIn) + ); } - }); + totalTime += segment.duration; + } - return timestamp; + // past the modeled total: clamp to the covered end rather than + // reporting wall-clock zero + return recordings.length > 0 + ? recordings[recordings.length - 1].end_time + : 0; } scrubToTimestamp(time: number, saveIfNotReady: boolean = false) { @@ -149,7 +173,10 @@ export class DynamicVideoController { this.previewController.setNewPreviewStartTime(time); } - if (scrubResult && this.playerMode != "scrubbing") { + // pause even when no preview can render this range: a hidden player + // left running reports stale times once the drag releases, bouncing + // the handlebar back and sometimes swallowing the release seek + if (this.playerMode != "scrubbing") { this.playerMode = "scrubbing"; this.playerController.pause(); } diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 8998be7a60..a046235b47 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -9,7 +9,12 @@ import { import { useApiHost } from "@/api"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import { Recording } from "@/types/record"; +import { + AutoQualityReason, + PlaybackQuality, + Recording, + RecordingCoverage, +} from "@/types/record"; import { Preview } from "@/types/preview"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import { DynamicVideoController } from "./DynamicVideoController"; @@ -32,6 +37,13 @@ import { grabVideoSnapshot, } from "@/utils/snapshotUtil"; import { isFirefox } from "react-device-detect"; +import { AutoQualityGovernor } from "./AutoQualityGovernor"; +import { isCodecFamilySupported } from "@/utils/codecSupport"; +import { useUserPersistence } from "@/hooks/use-user-persistence"; + +// forward buffer while playing the low quality stream; low bitrate makes +// a longer buffer cheap and it rides out connection variance better +const SUB_STREAM_BUFFER_LENGTH_S = 30; /** * Dynamically switches between video playback and scrubbing preview player. @@ -55,6 +67,11 @@ type DynamicVideoPlayerProps = { toggleFullscreen: () => void; containerRef?: React.MutableRefObject; transformedOverlay?: ReactNode; + quality?: PlaybackQuality; + onAutoQualityChange?: ( + lowQuality: boolean, + reason: AutoQualityReason | undefined, + ) => void; }; export default function DynamicVideoPlayer({ className, @@ -75,6 +92,8 @@ export default function DynamicVideoPlayer({ toggleFullscreen, containerRef, transformedOverlay, + quality, + onAutoQualityChange, }: DynamicVideoPlayerProps) { const { t } = useTranslation(["components/player", "views/live"]); const apiHost = useApiHost(); @@ -128,7 +147,7 @@ export default function DynamicVideoPlayer({ const [isLoading, setIsLoading] = useState(false); const [isBuffering, setIsBuffering] = useState(false); - const [loadingTimeout, setLoadingTimeout] = useState(); + const loadingTimeoutRef = useRef(undefined); // Don't set source until recordings load - we need accurate startPosition // to avoid hls.js clamping to video end when startPosition exceeds duration @@ -138,32 +157,80 @@ export default function DynamicVideoPlayer({ useEffect(() => { if (!isScrubbing) { - setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); + loadingTimeoutRef.current = setTimeout(() => setIsLoading(true), 1000); } return () => { - if (loadingTimeout) { - clearTimeout(loadingTimeout); + if (loadingTimeoutRef.current) { + clearTimeout(loadingTimeoutRef.current); } }; - // we only want trigger when scrubbing state changes - // eslint-disable-next-line react-hooks/exhaustive-deps }, [camera, isScrubbing]); + // wall-clock position to resume from once the current source finishes + // loading. A seek landing mid-load must win over the position the + // source was built around, or the post-load seek drags playback back + const sourceAnchorRef = useRef(undefined); + + useEffect(() => { + sourceAnchorRef.current = startTimestamp; + }, [startTimestamp]); + // a recordings change refined the seek model without changing the + // playlist, so the playback effect skips its loading indicator + const modelOnlyUpdateRef = useRef(false); + const onPlayerLoaded = useCallback(() => { - if (!controller || !startTimestamp) { + sourceLoadedRef.current = true; + governorRef.current?.sourceLoadEnded(); + + const anchor = sourceAnchorRef.current; + + if (!controller || !anchor) { return; } - controller.seekToTimestamp(startTimestamp, true); - }, [startTimestamp, controller]); + // an anchor outside this chunk is stale (e.g. a natural clip + // advance); the playlist already starts where playback should + if (anchor < timeRange.after || anchor > timeRange.before) { + return; + } + + // while the handlebar is down only position the hidden player, never + // start it: a mid-drag chunk prefetch can audibly blip before + // onPlaying pauses it. The release seek starts playback + controller.seekToTimestamp(anchor, !isScrubbing); + }, [controller, timeRange, isScrubbing]); + + // used to re-anchor the source when an auto quality switch rebuilds + // the playlist mid-playback + const lastPlayedTimestampRef = useRef(undefined); + + // the range the controller's playback model was last built for; while + // a chunk change awaits its coverage, the outgoing source reports + // times that would map through the stale model + const modelTimeRangeRef = useRef(undefined); const onTimeUpdate = useCallback( (time: number) => { + // safety net for stall or startup signals the player missed + governorRef.current?.stallEnded(); + if (!sourceLoadedRef.current) { + sourceLoadedRef.current = true; + governorRef.current?.sourceLoadEnded(); + } + if (isScrubbing || !controller || !onTimestampUpdate || time == 0) { return; } + // drop reports until the controller's model matches this chunk + if ( + modelTimeRangeRef.current?.after !== timeRange.after || + modelTimeRangeRef.current?.before !== timeRange.before + ) { + return; + } + if (isLoading) { setIsLoading(false); } @@ -172,9 +239,18 @@ export default function DynamicVideoPlayer({ setIsBuffering(false); } - onTimestampUpdate(controller.getProgress(time)); + const progress = controller.getProgress(time); + lastPlayedTimestampRef.current = progress; + onTimestampUpdate(progress); }, - [controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing], + [ + controller, + onTimestampUpdate, + isBuffering, + isLoading, + isScrubbing, + timeRange, + ], ); const onUploadFrameToPlus = useCallback( @@ -238,45 +314,350 @@ export default function DynamicVideoPlayer({ () => ({ before: timeRange.before, after: timeRange.after, + timelines: true, }), [timeRange], ); - const { data: recordings } = useSWR( - [`${camera}/recordings`, recordingParams], + const { data: coverage } = useSWR( + [`${camera}/recordings/coverage`, recordingParams], { revalidateOnFocus: false }, ); + // auto quality plays the default route until the governor downswitches + // to the pinned sub route; manual pins bypass this entirely + const [autoLowQuality, setAutoLowQuality] = useState(false); + const [autoLowReason, setAutoLowReason] = useState< + AutoQualityReason | undefined + >(undefined); + const autoLowQualityRef = useRef(false); + + const subAvailable = useMemo( + () => + coverage?.spans?.some((span) => span.streams.includes("sub")) ?? false, + [coverage], + ); + + const resolvedQuality = quality ?? "auto"; + + // the ref indirection keeps these reading fresh state while the + // governor stays a single instance for the component's lifetime + const tryDownswitchRef = useRef<(reason: string) => boolean>(() => false); + const tryUpswitchRef = useRef<() => void>(() => {}); + const governorRef = useRef(null); + if (governorRef.current === null) { + governorRef.current = new AutoQualityGovernor( + (reason) => tryDownswitchRef.current(reason), + () => tryUpswitchRef.current(), + ); + } + const governor = governorRef.current; + + // callers pass an inline callback, so keeping it out of the notify + // effect's deps stops the notification's re-render from re-firing it + const onAutoQualityChangeRef = useRef(onAutoQualityChange); + useEffect(() => { + onAutoQualityChangeRef.current = onAutoQualityChange; + }, [onAutoQualityChange]); + + useEffect(() => { + autoLowQualityRef.current = autoLowQuality; + onAutoQualityChangeRef.current?.( + autoLowQuality, + autoLowQuality ? autoLowReason : undefined, + ); + }, [autoLowQuality, autoLowReason]); + + useEffect(() => { + tryDownswitchRef.current = (reason: string) => { + if ( + resolvedQuality !== "auto" || + !subAvailable || + autoLowQualityRef.current + ) { + return false; + } + setAutoLowQuality(true); + setAutoLowReason(reason === "codec" ? "codec" : "bandwidth"); + // so a recovered connection (or a wrong downswitch) returns to + // full quality mid-chunk rather than at the next boundary + governor.armUpswitchProbe(); + return true; + }; + tryUpswitchRef.current = () => { + if (resolvedQuality === "auto" && autoLowQualityRef.current) { + setAutoLowQuality(false); + setAutoLowReason(undefined); + } + }; + }, [resolvedQuality, subAvailable, governor]); + + // persisted across sessions so a device on a known-slow connection + // starts low instead of paying the first stall to find out + const [persistedEstimate, setPersistedEstimate, estimateLoaded] = + useUserPersistence("playbackBandwidthEstimate"); + + const persistGovernor = useCallback(() => { + const estimate = governor.bandwidthEstimate; + if (estimate !== undefined) { + setPersistedEstimate(Math.round(estimate)); + } + }, [governor, setPersistedEstimate]); + const persistGovernorRef = useRef(persistGovernor); + + useEffect(() => { + persistGovernorRef.current = persistGovernor; + }, [persistGovernor]); + + useEffect(() => { + // returning to auto starts fresh on the default route, except when + // this browser already proved it cannot decode the original stream + governor.resetStallHistory(); + setAutoLowQuality(governor.isMainUnplayable); + setAutoLowReason(governor.isMainUnplayable ? "codec" : undefined); + // we only want to reset when the pinned quality changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [quality]); + + useEffect(() => { + // measured connection throughput carries over across cameras + governor.resetForCamera(); + setAutoLowQuality(false); + setAutoLowReason(undefined); + // we only want to reset when the camera changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera]); + + // seed the governor once per camera, then decide the starting quality + const seededCameraRef = useRef(null); + useEffect(() => { + if (seededCameraRef.current === camera || !estimateLoaded || !coverage) { + return; + } + seededCameraRef.current = camera; + + const mainSummary = coverage.streams?.main; + if (mainSummary?.bitrate) { + governor.learnMainBitrate(mainSummary.bitrate); + } + governor.seed(persistedEstimate); + + if (resolvedQuality !== "auto" || !subAvailable) { + return; + } + + // data saver is a user preference, not a bandwidth fact: hold the + // low stream and never auto-upswitch against it (a manual pin to + // Original still wins as an explicit action) + const saveData = + (navigator as Navigator & { connection?: { saveData?: boolean } }) + .connection?.saveData === true; + if (saveData) { + governor.setHoldLow(true); + } + + // a browser that cannot decode the original codec can never play + // the merged route. This probe fails open (unknown codecs count as + // supported); the reactive fatal-codec path is the real authority + const mainSupported = isCodecFamilySupported(mainSummary?.video_codec); + const subSupported = isCodecFamilySupported( + coverage.streams?.sub?.video_codec, + ); + if (!mainSupported && subSupported) { + governor.markMainUnplayable(); + setAutoLowQuality(true); + setAutoLowReason("codec"); + return; + } + + if (saveData) { + setAutoLowQuality(true); + setAutoLowReason("saveData"); + return; + } + + // a fully cold device also starts low: the conservative start shows + // a first frame in seconds and the armed probe recovers full + // quality within a few segment loads on connections that allow it + const coldStart = governor.bandwidthEstimate === undefined; + if (!coldStart && !governor.shouldStartLow()) { + return; + } + + setAutoLowQuality(true); + setAutoLowReason("bandwidth"); + governor.armUpswitchProbe(); + }, [ + camera, + coverage, + estimateLoaded, + persistedEstimate, + resolvedQuality, + subAvailable, + governor, + ]); + + // time-to-first-frame budget; the stall clock is blind before + // playback starts, so an oversized first segment would spin forever + const sourceLoadedRef = useRef(false); + useEffect(() => { + sourceLoadedRef.current = false; + }, [source]); + useEffect(() => { + if (!source || isScrubbing || sourceLoadedRef.current) { + governor.sourceLoadEnded(); + return; + } + governor.sourceLoadStarted(); + }, [source, isScrubbing, governor]); + + useEffect(() => { + // a chunk boundary is where full quality may be retried, and a + // natural point to persist what the governor has learned + setAutoLowQuality((prev) => prev && !governor.shouldRetryMain()); + persistGovernorRef.current(); + // we only want to re-evaluate when the playback chunk changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange]); + + useEffect(() => { + return () => { + persistGovernorRef.current(); + governor.destroy(); + }; + // governor is a stable per-mount instance + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const effectiveQuality: PlaybackQuality = + resolvedQuality === "auto" && autoLowQuality ? "sub" : resolvedQuality; + + const onStallStart = useCallback(() => governor.stallStarted(), [governor]); + const onStallEnd = useCallback(() => governor.stallEnded(), [governor]); + const onSeekStart = useCallback(() => governor.noteSeek(), [governor]); + const onFatalNetworkError = useCallback( + () => governor.fatalNetworkError(), + [governor], + ); + const onFatalCodecError = useCallback( + () => governor.fatalCodecError(), + [governor], + ); + const onBandwidthSample = useCallback( + (estimateBps: number, levelBitrateBps?: number) => + governor.bandwidthSample( + estimateBps, + levelBitrateBps, + // the merged default route leads with the original stream, so + // its samples measure original-quality sustainability + effectiveQuality !== "sub", + ), + [governor, effectiveQuality], + ); + + // the realized timelines mirror the vod manifests exactly, including + // keyframe back-snap lead-in at cross-stream hand-offs. Walking wall + // lengths instead drifts ~0.5s per hand-off, since the playlist + // contains lead-in media the model never knew about + const recordings = useMemo(() => { + const timeline = + coverage?.timelines?.[ + effectiveQuality === "main" || effectiveQuality === "sub" + ? effectiveQuality + : "auto" + ]; + + if (!timeline) { + return undefined; + } + + return timeline.map((span) => ({ + start_time: span.start_time, + end_time: span.end_time, + duration: span.duration / 1000, + })) as Recording[]; + }, [coverage, effectiveQuality]); + + // lets the effect below tell quality rebuilds apart from chunk changes + const prevEffectiveQualityRef = useRef(effectiveQuality); + + useEffect(() => { + const qualityChanged = prevEffectiveQualityRef.current !== effectiveQuality; + prevEffectiveQualityRef.current = effectiveQuality; + if (!recordings?.length) { if (recordings?.length == 0) { + // drop any stale source so the previous playlist unmounts + // instead of playing under the no-recording state + setSource(undefined); setNoRecording(true); + // with no source nothing will play to clear a pending + // camera-switch load, hiding the message behind a preview frame + if (loadingTimeoutRef.current) { + clearTimeout(loadingTimeoutRef.current); + } + setIsLoading(false); } return; } + // an identical playlist means coverage only refined the seek model; + // skip the rebuild so the player is not torn down + const streamPath = + effectiveQuality === "main" || effectiveQuality === "sub" + ? `/${effectiveQuality}` + : ""; + const playlist = `${apiHost}vod/${camera}${streamPath}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`; + if (!qualityChanged && source?.playlist === playlist) { + modelOnlyUpdateRef.current = true; + return; + } + + // a quality switch rebuilds mid-playback, so anchor to the live + // playhead rather than the chunk-stale startTimestamp prop. The + // controller still holds the OUTGOING timeline here (newPlayback + // runs in a later effect), and the timeupdate-throttled lastPlayed + // ref lags the frame on screen by up to ~250ms + const liveTime = playerRef.current?.currentTime; + const livePlayed = + qualityChanged && controller && liveTime !== undefined && liveTime > 0 + ? controller.getProgress(liveTime) + : undefined; + const lastPlayed = livePlayed ?? lastPlayedTimestampRef.current; + const anchorTimestamp = + qualityChanged && + lastPlayed !== undefined && + lastPlayed >= timeRange.after && + lastPlayed <= timeRange.before + ? lastPlayed + : startTimestamp; + sourceAnchorRef.current = anchorTimestamp; + let startPosition = undefined; - if (startTimestamp) { + if (anchorTimestamp) { const inpointOffset = calculateInpointOffset( recordingParams.after, (recordings || [])[0], ); startPosition = calculateSeekPosition( - startTimestamp, + anchorTimestamp, recordings, inpointOffset, ); } setSource({ - playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`, + playlist, startPosition, }); + // we only want to rebuild the source when the playlist itself changes; + // startTimestamp, timeRange, and the anchor refs are read as-of-rebuild // eslint-disable-next-line react-hooks/exhaustive-deps - }, [recordings]); + }, [recordings, effectiveQuality]); useEffect(() => { if (!controller || !recordings?.length) { @@ -287,12 +668,28 @@ export default function DynamicVideoPlayer({ playerRef.current.autoplay = !isScrubbing; } - setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000)); + const modelOnlyUpdate = modelOnlyUpdateRef.current; + modelOnlyUpdateRef.current = false; + + // on a source swap the element already has a decoded frame; keep it + // visible under the buffering indicator rather than hiding it + // behind the preview player like the initial load does + const hasDecodedFrame = + (playerRef.current?.readyState ?? 0) >= + HTMLMediaElement.HAVE_CURRENT_DATA; + + if (!modelOnlyUpdate) { + loadingTimeoutRef.current = setTimeout( + () => (hasDecodedFrame ? setIsBuffering(true) : setIsLoading(true)), + 1000, + ); + } controller.newPlayback({ recordings: recordings ?? [], timeRange, }); + modelTimeRangeRef.current = timeRange; // we only want this to change when controller or recordings update // eslint-disable-next-line react-hooks/exhaustive-deps @@ -356,8 +753,8 @@ export default function DynamicVideoPlayer({ playerRef.current?.pause(); } - if (loadingTimeout) { - clearTimeout(loadingTimeout); + if (loadingTimeoutRef.current) { + clearTimeout(loadingTimeoutRef.current); } setNoRecording(false); @@ -372,6 +769,16 @@ export default function DynamicVideoPlayer({ setIsBuffering(true); } }} + onStallStart={onStallStart} + onStallEnd={onStallEnd} + onSeekStart={onSeekStart} + onBandwidthSample={onBandwidthSample} + onFatalNetworkError={onFatalNetworkError} + onFatalCodecError={onFatalCodecError} + initialBandwidthEstimate={governor.bandwidthEstimate} + bufferLength={ + effectiveQuality === "sub" ? SUB_STREAM_BUFFER_LENGTH_S : undefined + } isDetailMode={isDetailMode} camera={contextCamera || camera} currentTimeOverride={currentTime} diff --git a/web/src/components/settings/wizard/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx index 7b5c558a8f..7ba5089aa5 100644 --- a/web/src/components/settings/wizard/Step3StreamConfig.tsx +++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx @@ -45,6 +45,13 @@ import { CommandList, } from "@/components/ui/command"; +// Recording the sub stream from the same stream as record would just +// re-record the main stream, so the two roles are mutually exclusive. +const CONFLICTING_ROLES: Partial> = { + record: "record_sub", + record_sub: "record", +}; + type Step3StreamConfigProps = { wizardData: Partial; onUpdate: (data: Partial) => void; @@ -163,9 +170,12 @@ export default function Step3StreamConfig({ const newRoles = stream.roles.filter((r) => r !== role); updateStream(streamId, { roles: newRoles }); } else { - // Check if role is already used in another stream const usedRoles = getUsedRolesExcludingStream(streamId); - if (!usedRoles.has(role)) { + const conflictingRole = CONFLICTING_ROLES[role]; + const hasConflict = conflictingRole + ? stream.roles.includes(conflictingRole) + : false; + if (!usedRoles.has(role) && !hasConflict) { // Allow adding the role const newRoles = [...stream.roles, role]; updateStream(streamId, { roles: newRoles }); @@ -617,6 +627,10 @@ export default function Step3StreamConfig({ record -{" "} {t("cameraWizard.step3.rolesPopover.record")} +
+ record_sub -{" "} + {t("cameraWizard.step3.rolesPopover.record_sub")} +
audio -{" "} {t("cameraWizard.step3.rolesPopover.audio")} @@ -639,25 +653,35 @@ export default function Step3StreamConfig({
- {(["detect", "record", "audio"] as const).map((role) => { - const isUsedElsewhere = getUsedRolesExcludingStream( - stream.id, - ).has(role); - const isChecked = stream.roles.includes(role); - return ( -
- {role} - toggleRole(stream.id, role)} - disabled={!isChecked && isUsedElsewhere} - /> -
- ); - })} + {(["detect", "record", "record_sub", "audio"] as const).map( + (role) => { + const isUsedElsewhere = getUsedRolesExcludingStream( + stream.id, + ).has(role); + const conflictingRole = CONFLICTING_ROLES[role]; + const hasConflict = conflictingRole + ? stream.roles.includes(conflictingRole) + : false; + const isChecked = stream.roles.includes(role); + return ( +
+ {role} + + toggleRole(stream.id, role) + } + disabled={ + !isChecked && (isUsedElsewhere || hasConflict) + } + /> +
+ ); + }, + )}
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 2796bc968a..382ad03171 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -42,6 +42,7 @@ export type MotionReviewTimelineProps = { events: ReviewSegment[]; motion_events: MotionData[]; noRecordingRanges?: RecordingSegment[]; + subOnlyRanges?: Pick[]; contentRef: RefObject; timelineRef?: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; @@ -76,6 +77,7 @@ export function MotionReviewTimeline({ events, motion_events, noRecordingRanges, + subOnlyRanges, contentRef, timelineRef, onHandlebarDraggingChange, @@ -122,6 +124,17 @@ export function MotionReviewTimeline({ [noRecordingRanges], ); + const getIsSubOnly = useCallback( + (time: number): boolean => { + if (subOnlyRanges == undefined) return false; + + return subOnlyRanges.some( + (range) => time >= range.start_time && time < range.end_time, + ); + }, + [subOnlyRanges], + ); + const segmentTimes = useMemo(() => { const segments = []; let segmentTime = timelineStartAligned; @@ -245,6 +258,7 @@ export function MotionReviewTimeline({ motionOnly={motionOnly} getMotionSegmentValue={getMotionSegmentValue} getRecordingAvailability={getRecordingAvailability} + getIsSubOnly={getIsSubOnly} alwaysShowMotionLine={alwaysShowMotionLine} /> diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 90ce5e1a56..3eef9db1b5 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { isMobile } from "react-device-detect"; +import { useTranslation } from "react-i18next"; import useTapUtils from "@/hooks/use-tap-utils"; import { cn } from "@/lib/utils"; @@ -16,6 +17,7 @@ type MotionSegmentProps = { firstHalfMotionValue: number; secondHalfMotionValue: number; hasRecording?: boolean; + isSubOnly?: boolean; prevIsNoRecording?: boolean; nextIsNoRecording?: boolean; motionOnly: boolean; @@ -36,6 +38,7 @@ export function MotionSegment({ firstHalfMotionValue, secondHalfMotionValue, hasRecording, + isSubOnly, prevIsNoRecording, nextIsNoRecording, motionOnly, @@ -47,6 +50,7 @@ export function MotionSegment({ dense, alwaysShowMotionLine = false, }: MotionSegmentProps) { + const { t } = useTranslation("views/events"); const severityType = "all"; const { getSeverity, getReviewed, displaySeverityType } = useEventSegmentUtils(segmentDuration, events, severityType); @@ -194,8 +198,10 @@ export function MotionSegment({ segmentClasses, severity[0] && "bg-gradient-to-r", severity[0] && severityColorsBg[severity[0]], + isSubOnly && "bg-background/50", hasRecording == false && "bg-background", )} + title={isSubOnly ? t("subOnlyQuality") : undefined} onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > diff --git a/web/src/components/timeline/VirtualizedMotionSegments.tsx b/web/src/components/timeline/VirtualizedMotionSegments.tsx index a98593d893..cd29fce066 100644 --- a/web/src/components/timeline/VirtualizedMotionSegments.tsx +++ b/web/src/components/timeline/VirtualizedMotionSegments.tsx @@ -25,6 +25,7 @@ type VirtualizedMotionSegmentsProps = { motionOnly: boolean; getMotionSegmentValue: (timestamp: number) => number; getRecordingAvailability: (timestamp: number) => boolean | undefined; + getIsSubOnly: (timestamp: number) => boolean; alwaysShowMotionLine: boolean; }; @@ -58,6 +59,7 @@ export const VirtualizedMotionSegments = forwardRef< motionOnly, getMotionSegmentValue, getRecordingAvailability, + getIsSubOnly, alwaysShowMotionLine, }, ref, @@ -161,6 +163,7 @@ export const VirtualizedMotionSegments = forwardRef< ); const hasRecording = getRecordingAvailability(segmentTime); + const isSubOnly = getIsSubOnly(segmentTime); // Check if previous and next segments have recordings // This is important because in motionOnly mode, the segments array is filtered @@ -195,6 +198,7 @@ export const VirtualizedMotionSegments = forwardRef< firstHalfMotionValue={firstHalfMotionValue} secondHalfMotionValue={secondHalfMotionValue} hasRecording={hasRecording} + isSubOnly={isSubOnly} prevIsNoRecording={prevIsNoRecording} nextIsNoRecording={nextIsNoRecording} segmentDuration={segmentDuration} @@ -216,6 +220,7 @@ export const VirtualizedMotionSegments = forwardRef< events, getMotionSegmentValue, getRecordingAvailability, + getIsSubOnly, motionOnly, segmentDuration, showMinimap, diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 1a64419bf3..66747ab8d6 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isIOS } from "react-device-detect"; import { useTimelineUtils } from "./use-timeline-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -10,6 +11,11 @@ import useUserInteraction from "./use-user-interaction"; const DRAG_STATE_COMMIT_MS = 100; +// iOS Safari synthesizes a click shortly after a drag's touchend even +// though the touchend handler calls preventDefault; clicks observed in +// traces arrive ~50ms after release +const GHOST_CLICK_WINDOW_MS = 400; + type DraggableElementProps = { contentRef: React.RefObject; timelineRef: React.RefObject; @@ -164,9 +170,28 @@ function useDraggableElement({ setDraggableElementTime(pendingDragTimeRef.current); pendingDragTimeRef.current = null; } + + // iOS Safari synthesizes a click after touchend despite the + // preventDefault, hit-tested at the drag origin where a segment + // now sits; its onClick would yank the handlebar back + if (isIOS && "TouchEvent" in window && e instanceof TouchEvent) { + const swallow = (clickEvent: MouseEvent) => { + cleanup(); + if (timelineRef.current?.contains(clickEvent.target as Node)) { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + } + }; + const cleanup = () => { + document.removeEventListener("click", swallow, true); + window.clearTimeout(timer); + }; + const timer = window.setTimeout(cleanup, GHOST_CLICK_WINDOW_MS); + document.addEventListener("click", swallow, true); + } } }, - [isDragging, setIsDragging, setDraggableElementTime], + [isDragging, setIsDragging, setDraggableElementTime, timelineRef], ); const timestampToPixels = useCallback( diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts index 20e8436359..6a7a9fc90a 100644 --- a/web/src/types/cameraWizard.ts +++ b/web/src/types/cameraWizard.ts @@ -75,7 +75,7 @@ export const CAMERA_BRAND_VALUES = CAMERA_BRANDS.map( export type CameraBrand = (typeof CAMERA_BRANDS)[number]["value"]; -export type StreamRole = "detect" | "record" | "audio"; +export type StreamRole = "detect" | "record" | "record_sub" | "audio"; export type StreamConfig = { id: string; diff --git a/web/src/types/filter.ts b/web/src/types/filter.ts index c38e823deb..28ddb0f9d0 100644 --- a/web/src/types/filter.ts +++ b/web/src/types/filter.ts @@ -11,6 +11,25 @@ export type FilterList = { export const LAST_24_HOURS_KEY = "last24Hours"; +const DRAWER_FEATURES = [ + "export", + "calendar", + "filter", + "debug-replay", + "share-timestamp", + "motion-search", + "quality", +] as const; +export type DrawerFeatures = (typeof DRAWER_FEATURES)[number]; +export const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ + "export", + "calendar", + "filter", + "debug-replay", + "share-timestamp", + "motion-search", +]; + export type GeneralFilter = { showAll?: boolean; labels?: string[]; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 6308266859..e554d10e6f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -96,6 +96,7 @@ export interface CameraConfig { output_args: { detect: string[]; record: string; + record_sub: string | string[]; rtmp: string; }; retry_interval: number; @@ -235,6 +236,9 @@ export interface CameraConfig { days: number; mode: string; }; + sub: { + enabled: boolean; + }; }; review: { alerts: { @@ -492,6 +496,7 @@ export interface FrigateConfig { output_args: { detect: string[]; record: string; + record_sub: string | string[]; rtmp: string; }; retry_interval: number; diff --git a/web/src/types/record.ts b/web/src/types/record.ts index d8fd163bf9..00fff522ed 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -45,6 +45,48 @@ export type RecordingStartingPoint = { export type RecordingPlayerError = "stalled" | "startup"; +export type RecordingCoverageSpan = { + start_time: number; + end_time: number; + streams: ("main" | "sub")[]; +}; + +export type StreamMediaSummary = { + video_codec: string | null; + audio_rate: number | null; + audio_codec: string | null; + has_audio: boolean | null; + bitrate: number | null; +}; + +// why auto quality is currently resolved to the low stream +export type AutoQualityReason = "bandwidth" | "codec" | "saveData"; + +// one span of a vod route's realized playlist. duration is in ms and +// exceeds the wall length when the clip carries keyframe back-snap +// lead-in; 0 means the clip is omitted from the playlist entirely +export type PlaybackTimelineSpan = { + start_time: number; + end_time: number; + duration: number; +}; + +export type RecordingCoverage = { + spans: RecordingCoverageSpan[]; + // informational, kept for API consumers; the UI no longer gates on it + codecs_compatible: boolean; + streams: { main?: StreamMediaSummary; sub?: StreamMediaSummary }; + // opt-in (?timelines=true); absent on requests that skip them + timelines?: { + auto: PlaybackTimelineSpan[]; + main: PlaybackTimelineSpan[]; + sub: PlaybackTimelineSpan[]; + }; +}; + +export type PlaybackQuality = "auto" | "main" | "sub"; +export const PLAYBACK_QUALITIES: PlaybackQuality[] = ["auto", "main", "sub"]; + export const ASPECT_VERTICAL_LAYOUT = 1.5; export const ASPECT_PORTRAIT_LAYOUT = 1.333; export const ASPECT_WIDE_LAYOUT = 2; diff --git a/web/src/utils/codecSupport.ts b/web/src/utils/codecSupport.ts new file mode 100644 index 0000000000..913f8ed3c7 --- /dev/null +++ b/web/src/utils/codecSupport.ts @@ -0,0 +1,73 @@ +/** + * Best-effort probe for whether this browser can decode a video codec + * family. + * + * Frigate only stores the codec family from ffprobe (no profile or + * level), so the probe tests representative MIME samples per family. + * The result is a hint, not proof: a 10-bit stream can fail on a + * browser that passes the Main-profile sample, so callers must keep a + * reactive fallback for fatal codec errors. Unknown or NULL codecs + * (legacy rows) always report supported - a wrong "unsupported" answer + * silently degrades quality, which is worse than a failed attempt the + * reactive path recovers from. + */ + +declare global { + interface Window { + ManagedMediaSource?: typeof MediaSource; + } +} + +// any one supported sample marks the family playable +const CODEC_MIME_SAMPLES: Record = { + h264: ['video/mp4; codecs="avc1.42E01E"', 'video/mp4; codecs="avc1.64001F"'], + hevc: [ + 'video/mp4; codecs="hvc1.1.6.L120.90"', + 'video/mp4; codecs="hev1.1.6.L120.90"', + ], + av1: ['video/mp4; codecs="av01.0.05M.08"'], +}; + +const FAMILY_ALIASES: Record = { + avc: "h264", + avc1: "h264", + h265: "hevc", + hev1: "hevc", + hvc1: "hevc", + av01: "av1", +}; + +function canPlayMimeType(mimeType: string): boolean { + if (window.ManagedMediaSource?.isTypeSupported(mimeType)) { + return true; + } + + if (window.MediaSource?.isTypeSupported(mimeType)) { + return true; + } + + // native playback fallback for browsers without MSE + return document.createElement("video").canPlayType(mimeType) !== ""; +} + +/** + * Fails open: unknown codecs and NULL (legacy) codecs report supported, + * since wrongly downgrading quality is worse than a recoverable failure. + */ +export function isCodecFamilySupported( + codecName: string | null | undefined, +): boolean { + if (!codecName) { + return true; + } + + const normalized = codecName.toLowerCase().trim(); + const family = FAMILY_ALIASES[normalized] ?? normalized; + const samples = CODEC_MIME_SAMPLES[family]; + + if (!samples) { + return true; + } + + return samples.some(canPlayMimeType); +} diff --git a/web/src/utils/videoUtil.ts b/web/src/utils/videoUtil.ts index d6ab203e93..6b471665fe 100644 --- a/web/src/utils/videoUtil.ts +++ b/web/src/utils/videoUtil.ts @@ -61,15 +61,18 @@ export function calculateSeekPosition( return false; } + // playlist duration exceeds wall length when the clip carries + // keyframe back-snap lead-in + const wallLength = segment.end_time - segment.start_time; + const leadIn = Math.max(0, segment.duration - wallLength); + if (segment.end_time < timestamp) { - // Add the full duration of this segment - seekSeconds += segment.end_time - segment.start_time; + seekSeconds += segment.duration; return true; } // We're in this segment - calculate position within it - seekSeconds += - segment.end_time - segment.start_time - (segment.end_time - timestamp); + seekSeconds += leadIn + (timestamp - segment.start_time); return true; }); diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index 5b3b9283e0..2a4a8dafd2 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -615,16 +615,16 @@ export default function MotionSearchView({ }, [selectedRangeIdx, chunkedTimeRange]); const updateSelectedSegment = useCallback( - (nextTime: number, updateStartTime: boolean) => { + (nextTime: number) => { const index = chunkedTimeRange.findIndex( (segment) => segment.after <= nextTime && segment.before >= nextTime, ); if (index != -1) { - if (updateStartTime) { - setPlaybackStart(nextTime); - } - + setPlaybackStart(nextTime); + // the outgoing chunk's player runs until the new source replaces + // it, reporting old positions while the new chunk loads + mainControllerRef.current?.pause(); setSelectedRangeIdx(index); } }, @@ -638,7 +638,10 @@ export default function MotionSearchView({ currentTime > currentTimeRange.before + 60 || currentTime < currentTimeRange.after - 60 ) { - updateSelectedSegment(currentTime, false); + // the player rebuilds its source against playbackStart, and a + // stale anchor resolves to no startPosition, dropping playback + // at the start of the hour instead of the drag target + updateSelectedSegment(currentTime); return; } @@ -678,9 +681,12 @@ export default function MotionSearchView({ currentTimeRange.after <= currentTime && currentTimeRange.before >= currentTime ) { + // a source reload mid-seek resumes from playbackStart, so the + // anchor has to follow explicit seeks + setPlaybackStart(currentTime); mainControllerRef.current?.seekToTimestamp(currentTime, true); } else { - updateSelectedSegment(currentTime, true); + updateSelectedSegment(currentTime); } } else if (playerTime != currentTime) { mainControllerRef.current?.play(); @@ -700,9 +706,12 @@ export default function MotionSearchView({ setCurrentTime(time); if (currentTimeRange.after <= time && currentTimeRange.before >= time) { + // a source reload mid-seek resumes from playbackStart, so the + // anchor has to follow explicit seeks + setPlaybackStart(time); mainControllerRef.current?.seekToTimestamp(time, play); } else { - updateSelectedSegment(time, true); + updateSelectedSegment(time); } }, [currentTimeRange, updateSelectedSegment], diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 1595e315a9..2172489cc5 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -8,13 +8,15 @@ import PreviewPlayer, { } from "@/components/player/PreviewPlayer"; import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController"; import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer"; +import QualitySelector from "@/components/player/QualitySelector"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import DetailStream from "@/components/timeline/DetailStream"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useOverlayState } from "@/hooks/use-overlay-state"; +import { usePersistence } from "@/hooks/use-persistence"; import { useResizeObserver } from "@/hooks/resize-observer"; -import { ExportMode } from "@/types/filter"; +import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { @@ -57,9 +59,13 @@ import { VideoResolutionType } from "@/types/live"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT, + AutoQualityReason, + PlaybackQuality, + RecordingCoverage, RecordingSegment, RecordingStartingPoint, } from "@/types/record"; +import { isCodecFamilySupported } from "@/utils/codecSupport"; import { cn } from "@/lib/utils"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { useTimezone } from "@/hooks/use-date-utils"; @@ -141,6 +147,15 @@ export function RecordingView({ }, ]); + // feeds the quality selector's per-stream subtitles + const { data: coverage } = useSWR([ + `${mainCamera}/recordings/coverage`, + { + before: timeRange.before, + after: timeRange.after, + }, + ]); + // controller state const mainControllerRef = useRef(null); @@ -280,14 +295,14 @@ export function RecordingView({ const [playerTime, setPlayerTime] = useState(startTime); const updateSelectedSegment = useCallback( - (currentTime: number, updateStartTime: boolean) => { + (currentTime: number) => { const index = findChunkIndex(chunkedTimeRange, currentTime); if (index != -1) { - if (updateStartTime) { - setPlaybackStart(currentTime); - } - + setPlaybackStart(currentTime); + // the outgoing chunk's player runs until the new source replaces + // it, reporting old positions while the new chunk loads + mainControllerRef.current?.pause(); setSelectedRangeIdx(index); } }, @@ -300,7 +315,10 @@ export function RecordingView({ currentTime > currentTimeRange.before + 60 || currentTime < currentTimeRange.after - 60 ) { - updateSelectedSegment(currentTime, false); + // the player rebuilds its source against playbackStart, and a + // stale anchor resolves to no startPosition, dropping playback + // at the start of the hour instead of the drag target + updateSelectedSegment(currentTime); return; } @@ -328,9 +346,12 @@ export function RecordingView({ setCurrentTime(time); if (currentTimeRange.after <= time && currentTimeRange.before >= time) { + // a source reload mid-seek resumes from playbackStart, so the + // anchor has to follow explicit seeks + setPlaybackStart(time); mainControllerRef.current?.seekToTimestamp(time, play); } else { - updateSelectedSegment(time, true); + updateSelectedSegment(time); } }, [currentTimeRange, updateSelectedSegment], @@ -388,13 +409,15 @@ export function RecordingView({ shouldPlayback = mainControllerRef.current.isPlaying(); } + // see manuallySetCurrentTime + setPlaybackStart(currentTime); mainControllerRef.current.seekToTimestamp( currentTime, shouldPlayback, ); } } else { - updateSelectedSegment(currentTime, true); + updateSelectedSegment(currentTime); } } else if (playerTime != currentTime && timelineType != "detail") { mainControllerRef.current?.play(); @@ -409,6 +432,58 @@ export function RecordingView({ height: 0, }); + // playback quality + + const [quality, setQuality] = usePersistence( + "recordingQuality", + "auto", + ); + + // lets the selector surface a downswitch instead of a mysterious drop + const [autoQualityLow, setAutoQualityLow] = useState<{ + low: boolean; + reason?: AutoQualityReason; + }>({ low: false }); + + // the player re-notifies on every mount and quality reset, so keep the + // same state object when nothing changed + const onAutoQualityChange = useCallback( + (low: boolean, reason: AutoQualityReason | undefined) => + setAutoQualityLow((prev) => + prev.low === low && prev.reason === reason ? prev : { low, reason }, + ), + [], + ); + + // shown on the Original pin so a doomed selection is labeled + const mainCodecUnsupported = useMemo( + () => + coverage?.streams?.main + ? !isCodecFamilySupported(coverage.streams.main.video_codec) + : false, + [coverage], + ); + + // the pin is persisted globally, but the selector is hidden on cameras + // without a sub stream, leaving an inherited "sub" pin unable to unpin + const playerQuality = useMemo( + () => + config && !config.cameras[mainCamera]?.record.sub.enabled + ? "auto" + : quality, + [config, mainCamera, quality], + ); + + // a quality change swaps the playlist source, so anchor playback start + // the same way camera switching does to resume in place + const onSetQuality = useCallback( + (newQuality: PlaybackQuality) => { + setPlaybackStart(currentTime); + setQuality(newQuality); + }, + [currentTime, setQuality], + ); + const onSelectCamera = useCallback( (newCam: string) => { if (allowedCameras.includes(newCam)) { @@ -768,6 +843,18 @@ export function RecordingView({ }} /> )} + {!isMobileOnly && + config?.cameras[mainCamera]?.record.enabled && + config?.cameras[mainCamera]?.record.sub.enabled && ( + + )} {isDesktop ? ( )} {isDesktop && effectiveCameras.length > 1 && ( @@ -1131,6 +1237,20 @@ function Timeline({ }, ]); + const { data: coverage } = useSWR([ + `${mainCamera}/recordings/coverage`, + { + before: alignedBefore, + after: alignedAfter, + }, + ]); + + const subOnlyRanges = useMemo( + () => + coverage?.spans?.filter((span) => !span.streams.includes("main")) ?? [], + [coverage], + ); + const [exportStart, setExportStartTime] = useState(0); const [exportEnd, setExportEndTime] = useState(0); @@ -1200,6 +1320,7 @@ function Timeline({ events={mainCameraReviewItems} motion_events={motionData ?? []} noRecordingRanges={noRecordings ?? []} + subOnlyRanges={subOnlyRanges} contentRef={contentRef} onHandlebarDraggingChange={setScrubbing} isZooming={isZooming}