From e7684eddbf981e060b5fb1b0fc9a1a83eaf35635 Mon Sep 17 00:00:00 2001 From: 3ricj Date: Wed, 29 Apr 2026 19:05:59 -0700 Subject: [PATCH] Added substream support, dynamic substream creation, and playback methods for This change adds first-class adaptive recording playback using main and sub recording variants. Frigate can now store multiple recording variants per camera, expose those variants through the recordings API, and serve variant-specific VOD playlists through routes such as /vod/variant/sub/.... The UI now uses the available recording variants and browser playback capability to choose an appropriate playback source, with a user-selectable Auto, Main, and Sub preference. This is applied across timeline playback, export preview, and object detail playback. The backend also includes a fallback path for sub playback: when a native sub recording is not available for a requested time range, Frigate can generate a lower-resolution sub recording from the main segment, store it under the standard sub variant, and mark it with transcoded_from_main. Additional changes include recording metadata for codec, resolution, bitrate, and variant; database migrations for recording variants and generated-sub tracking; tests for variant VOD selection and fallback behavior; improved storage graph sorting; and a small MQTT TLS guard so tls_insecure is only applied when TLS is configured. Substream Configuration Examples Record the main stream as the normal full-resolution recording and also record the camera substream as the sub variant: cameras: front_door: ffmpeg: inputs: - path: rtsp://user:password@192.168.1.10:554/main roles: - record record_variant: main - path: rtsp://user:password@192.168.1.10:554/sub roles: - detect - record record_variant: sub detect: width: 640 height: 360 fps: 5 record: enabled: true Using go2rtc restreams: go2rtc: streams: front_door: - rtsp://user:password@192.168.1.10:554/main front_door_sub: - rtsp://user:password@192.168.1.10:554/sub cameras: front_door: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/front_door input_args: preset-rtsp-restream roles: - record record_variant: main - path: rtsp://127.0.0.1:8554/front_door_sub input_args: preset-rtsp-restream roles: - detect - record record_variant: sub detect: width: 640 height: 360 fps: 5 record: enabled: true If record_variant is omitted on a record input, it defaults to main. Each camera can only use a given recording variant once, so the main and sub recording inputs should use distinct variant names. --- .gitignore | 2 + frigate/api/media.py | 194 ++++++++--- frigate/api/record.py | 1 + frigate/comms/mqtt.py | 6 +- frigate/models.py | 1 + frigate/record/maintainer.py | 1 + frigate/record/subvariant.py | 303 ++++++++++++++++++ frigate/test/http_api/test_http_media.py | 298 ++++++++++++++++- ...037_add_recordings_transcoded_from_main.py | 29 ++ web/public/locales/en/components/player.json | 5 + web/public/locales/en/views/system.json | 5 + .../components/graph/CombinedStorageGraph.tsx | 189 +++++++++-- web/src/components/overlay/ExportDialog.tsx | 21 +- .../overlay/detail/SearchDetailDialog.tsx | 31 -- .../overlay/detail/TrackingDetails.tsx | 209 ++++-------- .../components/player/GenericVideoPlayer.tsx | 2 +- .../RecordingPlaybackPreferenceSelect.tsx | 36 +++ .../player/dynamic/DynamicVideoController.ts | 34 +- .../player/dynamic/DynamicVideoPlayer.tsx | 114 ++++--- .../hooks/use-recording-playback-source.ts | 69 +++- web/src/types/record.ts | 4 +- web/src/utils/recordingPlayback.test.ts | 123 +++++++ web/src/utils/recordingPlayback.ts | 198 +++++------- 23 files changed, 1421 insertions(+), 454 deletions(-) create mode 100644 frigate/record/subvariant.py create mode 100644 migrations/037_add_recordings_transcoded_from_main.py create mode 100644 web/src/components/player/RecordingPlaybackPreferenceSelect.tsx create mode 100644 web/src/utils/recordingPlayback.test.ts diff --git a/.gitignore b/.gitignore index c9db2929f..afdc4383a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ models frigate/version.py web/build web/node_modules +node_modules +**/.vite web/coverage web/.env core diff --git a/frigate/api/media.py b/frigate/api/media.py index fc422c9b3..69c5c95e0 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -44,6 +44,7 @@ from frigate.const import ( ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.output.preview import get_most_recent_preview_frame +from frigate.record.subvariant import ensure_subvariant_for_recording from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.file import get_event_thumbnail_bytes from frigate.util.image import get_image_from_recording @@ -53,6 +54,73 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.media]) +def _resolve_vod_variant(path_variant: str | None, variant: str) -> str: + return path_variant or variant + + +async def _resolve_sub_vod_recordings( + config: FrigateConfig, recordings_query +) -> list[Recordings]: + native_sub_recordings = list( + recordings_query.where(Recordings.variant == "sub") + .order_by(Recordings.start_time.asc()) + .iterator() + ) + main_recordings = list( + recordings_query.where(Recordings.variant == "main") + .order_by(Recordings.start_time.asc()) + .iterator() + ) + + if not main_recordings: + return native_sub_recordings + + def overlaps(left: Recordings, right: Recordings) -> bool: + return left.start_time < right.end_time and left.end_time > right.start_time + + main_windows = {(recording.start_time, recording.end_time) for recording in main_recordings} + + filtered_native_sub_recordings = [] + for recording in native_sub_recordings: + has_exact_main_window = (recording.start_time, recording.end_time) in main_windows + has_overlapping_sub_neighbor = any( + other.path != recording.path and overlaps(recording, other) + for other in native_sub_recordings + ) + + # If a sub row exactly mirrors a main segment while another overlapping + # sub row already exists, prefer the native sub timeline and ignore the + # exact-match segment that was likely synthesized from main. + if has_exact_main_window and has_overlapping_sub_neighbor: + continue + + filtered_native_sub_recordings.append(recording) + + resolved_recordings = list(filtered_native_sub_recordings) + + for main_recording in main_recordings: + if any( + overlaps(main_recording, sub_recording) + for sub_recording in filtered_native_sub_recordings + ): + continue + + recording = await ensure_subvariant_for_recording(config, main_recording) + if recording is not None: + resolved_recordings.append(recording) + + deduped_recordings = {} + for recording in resolved_recordings: + deduped_recordings[(recording.path, recording.start_time, recording.end_time)] = ( + recording + ) + + return sorted( + deduped_recordings.values(), + key=lambda recording: (recording.start_time, recording.end_time, recording.path), + ) + + @router.get("/{camera_name}", dependencies=[Depends(require_camera_access)]) async def mjpeg_feed( request: Request, @@ -526,43 +594,50 @@ async def recording_clip( ) +@router.get( + "/vod/variant/{path_variant}/{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.", +) @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( + request: Request, camera_name: str, start_ts: float, end_ts: float, force_discontinuity: bool = False, - variant: str = "main", + path_variant: str | None = None, + variant: str = Query("main", description="Recording variant to use for playback."), ): + selected_variant = _resolve_vod_variant(path_variant, variant) logger.debug( "VOD: Generating VOD for %s from %s to %s with force_discontinuity=%s variant=%s", camera_name, start_ts, end_ts, force_discontinuity, - variant, + selected_variant, ) - recordings = ( - Recordings.select( - Recordings.path, - Recordings.duration, - Recordings.end_time, - Recordings.start_time, + recordings_query = Recordings.select().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) + + if selected_variant == "sub": + recordings = await _resolve_sub_vod_recordings( + request.app.frigate_config, recordings_query ) - .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)) + else: + recordings = ( + recordings_query.where(Recordings.variant == selected_variant) + .order_by(Recordings.start_time.asc()) + .iterator() ) - .where(Recordings.camera == camera_name) - .where(Recordings.variant == variant) - .order_by(Recordings.start_time.asc()) - .iterator() - ) clips = [] durations = [] @@ -571,14 +646,6 @@ async def vod_ts( recording: Recordings for recording in recordings: - logger.debug( - "VOD: processing recording: %s start=%s end=%s duration=%s", - recording.path, - recording.start_time, - recording.end_time, - recording.duration, - ) - clip = {"type": "source", "path": recording.path} duration = int(recording.duration * 1000) @@ -587,11 +654,6 @@ async def vod_ts( 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: @@ -599,23 +661,12 @@ async def vod_ts( 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, - ) 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}") @@ -644,37 +695,57 @@ async def vod_ts( ) +@router.get( + "/vod/variant/{path_variant}/{year_month}/{day}/{hour}/{camera_name}", + dependencies=[Depends(require_camera_access)], + description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", +) @router.get( "/vod/{year_month}/{day}/{hour}/{camera_name}", dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) async def vod_hour_no_timezone( - year_month: str, day: int, hour: int, camera_name: str, variant: str = "main" + request: Request, + year_month: str, + day: int, + hour: int, + camera_name: str, + path_variant: str | None = None, + variant: str = Query("main", description="Recording variant to use for playback."), ): """VOD for specific hour. Uses the default timezone (UTC).""" return await vod_hour( + request, year_month, day, hour, camera_name, get_localzone_name().replace("/", ","), + path_variant, variant, ) +@router.get( + "/vod/variant/{path_variant}/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", + dependencies=[Depends(require_camera_access)], + description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", +) @router.get( "/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) async def vod_hour( + request: Request, year_month: str, day: int, hour: int, camera_name: str, tz_name: str, - variant: str = "main", + path_variant: str | None = None, + variant: str = Query("main", description="Recording variant to use for playback."), ): parts = year_month.split("-") start_date = ( @@ -685,9 +756,21 @@ async def vod_hour( start_ts = start_date.timestamp() end_ts = end_date.timestamp() - return await vod_ts(camera_name, start_ts, end_ts, variant=variant) + return await vod_ts( + request, + camera_name, + start_ts, + end_ts, + path_variant=path_variant, + variant=variant, + ) +@router.get( + "/vod/variant/{path_variant}/event/{event_id}", + dependencies=[Depends(allow_any_authenticated())], + description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.", +) @router.get( "/vod/event/{event_id}", dependencies=[Depends(allow_any_authenticated())], @@ -697,6 +780,7 @@ async def vod_event( request: Request, event_id: str, padding: int = Query(0, description="Padding to apply to the vod."), + path_variant: str | None = None, variant: str = Query("main", description="Recording variant to use for playback."), ): try: @@ -719,7 +803,12 @@ async def vod_event( else (event.end_time + padding) ) vod_response = await vod_ts( - event.camera, event.start_time - padding, end_ts, variant=variant + request, + event.camera, + event.start_time - padding, + end_ts, + path_variant=path_variant, + variant=variant, ) # If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false @@ -734,19 +823,32 @@ async def vod_event( return vod_response +@router.get( + "/vod/variant/{path_variant}/clip/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], + description="Returns an HLS playlist for a timestamp range with HLS discontinuity enabled. Append /master.m3u8 or /index.m3u8 for HLS playback.", +) @router.get( "/vod/clip/{camera_name}/start/{start_ts}/end/{end_ts}", dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for a timestamp range with HLS discontinuity enabled. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) async def vod_clip( + request: Request, camera_name: str, start_ts: float, end_ts: float, + path_variant: str | None = None, variant: str = Query("main", description="Recording variant to use for playback."), ): return await vod_ts( - camera_name, start_ts, end_ts, force_discontinuity=True, variant=variant + request, + camera_name, + start_ts, + end_ts, + force_discontinuity=True, + path_variant=path_variant, + variant=variant, ) diff --git a/frigate/api/record.py b/frigate/api/record.py index 6ca2a5542..d1d877d56 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -240,6 +240,7 @@ async def recordings( Recordings.end_time, Recordings.path, Recordings.variant, + Recordings.transcoded_from_main, Recordings.segment_size, Recordings.motion, Recordings.objects, diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 9279b4388..d99335b70 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -297,7 +297,9 @@ class MqttClient(Communicator): f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command ) - if self.mqtt_config.tls_ca_certs is not None: + tls_configured = self.mqtt_config.tls_ca_certs is not None + + if tls_configured: if ( self.mqtt_config.tls_client_cert is not None and self.mqtt_config.tls_client_key is not None @@ -309,7 +311,7 @@ class MqttClient(Communicator): ) else: self.client.tls_set(self.mqtt_config.tls_ca_certs) - if self.mqtt_config.tls_insecure is not None: + if self.mqtt_config.tls_insecure is not None and tls_configured: self.client.tls_insecure_set(self.mqtt_config.tls_insecure) if self.mqtt_config.user is not None: self.client.username_pw_set( diff --git a/frigate/models.py b/frigate/models.py index 92152a649..2a9d03c0c 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -71,6 +71,7 @@ class Recordings(Model): camera = CharField(index=True, max_length=20) path = CharField(unique=True) variant = CharField(default="main", index=True, max_length=20) + transcoded_from_main = BooleanField(default=False) start_time = DateTimeField() end_time = DateTimeField() duration = FloatField() diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 463d815f3..bc64a00bb 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -670,6 +670,7 @@ class RecordingMaintainer(threading.Thread): Recordings.camera.name: camera, Recordings.path.name: file_path, Recordings.variant.name: variant, + Recordings.transcoded_from_main.name: False, Recordings.start_time.name: start_time.timestamp(), Recordings.end_time.name: end_time.timestamp(), Recordings.duration.name: duration, diff --git a/frigate/record/subvariant.py b/frigate/record/subvariant.py new file mode 100644 index 000000000..7ee582953 --- /dev/null +++ b/frigate/record/subvariant.py @@ -0,0 +1,303 @@ +import asyncio +import logging +import os +from typing import Optional + +from peewee import DoesNotExist + +from frigate.config import FrigateConfig +from frigate.const import RECORD_DIR, FFMPEG_HWACCEL_NVIDIA +from frigate.models import Recordings +from frigate.util.services import get_video_properties, auto_detect_hwaccel + +logger = logging.getLogger(__name__) + +_subvariant_locks: dict[str, asyncio.Lock] = {} +SUB_VARIANT = "sub" + + +def _get_lock(key: str) -> asyncio.Lock: + lock = _subvariant_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _subvariant_locks[key] = lock + return lock + + +def _sub_path_for_main_path(main_path: str) -> str: + # main: /media/frigate/recordings/YYYY-MM-DD/HH/camera/main/MM.SS.mp4 + # generated sub fallback: /media/frigate/recordings/YYYY-MM-DD/HH/camera/sub/MM.SS.mp4 + parts = main_path.split(os.sep) + try: + idx = parts.index("main") + except ValueError: + # Fallback: just mirror under /sub/ next to main file + directory, filename = os.path.split(main_path) + return os.path.join(directory + "_sub", filename) + + parts[idx] = SUB_VARIANT + return os.sep.join(parts) + + +def _camera_name_for_recording(main_recording: Recordings) -> Optional[str]: + if main_recording.camera: + return main_recording.camera + + parts = main_recording.path.split(os.sep) + try: + idx = parts.index("main") + except ValueError: + return None + + if idx > 0: + return parts[idx - 1] + + return None + + +def _codec_matches_family(codec_name: Optional[str], desired_family: str) -> bool: + normalized = _normalize_codec_family(codec_name) + return bool(normalized and normalized == desired_family) + + +def _normalize_codec_family(codec_name: Optional[str]) -> Optional[str]: + if not codec_name: + return None + + normalized = codec_name.lower().strip() + if normalized in ("h264", "avc1"): + return "h264" + + if normalized in ("h265", "hevc", "hev1", "hvc1"): + return "hevc" + + return normalized + + +async def _existing_subvariant_matches( + config: FrigateConfig, path: str, desired_family: str, codec_name: Optional[str] +) -> bool: + if not os.path.exists(path): + return False + + if _codec_matches_family(codec_name, desired_family): + actual_codec = codec_name + else: + media_info = await get_video_properties(config.ffmpeg, path) + actual_codec = media_info.get("codec_name") + + return _codec_matches_family(actual_codec, desired_family) + + +def _select_hw_profile(config: FrigateConfig, desired_codec_family: str) -> list[str]: + """Return ffmpeg args that generate a standard `sub` fallback recording.""" + # Target bitrate: ~35% of original when known, otherwise a safe default. + target_bitrate = "350k" + + # Try to detect decode hwaccel that implies GPU type. + detected = auto_detect_hwaccel() + + if desired_codec_family == "hevc": + if detected == FFMPEG_HWACCEL_NVIDIA: + return [ + "-c:v", + "hevc_nvenc", + "-b:v", + target_bitrate, + "-maxrate", + target_bitrate, + "-bufsize", + "700k", + ] + + return [ + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-x265-params", + "log-level=error", + "-b:v", + target_bitrate, + "-maxrate", + target_bitrate, + "-bufsize", + "700k", + ] + + if detected == FFMPEG_HWACCEL_NVIDIA: + return [ + "-c:v", + "h264_nvenc", + "-b:v", + target_bitrate, + "-maxrate", + target_bitrate, + "-bufsize", + "700k", + ] + + return [ + "-c:v", + "libx264", + "-preset:v", + "ultrafast", + "-tune:v", + "zerolatency", + "-b:v", + target_bitrate, + "-maxrate", + target_bitrate, + "-bufsize", + "700k", + ] + + +async def ensure_subvariant_for_recording( + config: FrigateConfig, + main_recording: Recordings, + target_codec_family: Optional[str] = None, +) -> Optional[Recordings]: + """Ensure a standard `sub` file and Recordings row exist for a main recording. + + Returns the `sub` Recordings row or None on failure. + """ + if main_recording.variant == SUB_VARIANT and os.path.exists(main_recording.path): + return main_recording + + camera_name = _camera_name_for_recording(main_recording) + if not camera_name: + logger.error("Unable to determine camera for recording %s", main_recording.path) + return None + + desired_codec_family = ( + target_codec_family + or _normalize_codec_family(main_recording.codec_name) + or "h264" + ) + + sub_path = _sub_path_for_main_path(main_recording.path) + + # If a DB row already exists and the file is present, return it immediately. + try: + existing = Recordings.get( + (Recordings.camera == camera_name) + & (Recordings.variant == SUB_VARIANT) + & (Recordings.start_time == main_recording.start_time) + ) + if await _existing_subvariant_matches( + config, existing.path, desired_codec_family, existing.codec_name + ): + return existing + except DoesNotExist: + existing = None + + lock_key = f"{camera_name}:{main_recording.start_time}:sub" + lock = _get_lock(lock_key) + async with lock: + # Double-check inside the lock. + try: + existing = Recordings.get( + (Recordings.camera == camera_name) + & (Recordings.variant == SUB_VARIANT) + & (Recordings.start_time == main_recording.start_time) + ) + if await _existing_subvariant_matches( + config, existing.path, desired_codec_family, existing.codec_name + ): + return existing + except DoesNotExist: + existing = None + + if existing and existing.path: + sub_path = existing.path + + # Ensure directory exists. + sub_dir = os.path.dirname(sub_path) + os.makedirs(sub_dir, exist_ok=True) + + # Decide encoder profile. + extra_args = _select_hw_profile(config, desired_codec_family) + + ffmpeg_bin = config.ffmpeg.ffmpeg_path + + cmd = [ + ffmpeg_bin, + "-hide_banner", + "-y", + "-i", + main_recording.path, + "-vf", + "scale='min(640,iw)':'min(360,ih)':force_original_aspect_ratio=decrease", + ] + extra_args + [ + "-an", + sub_path, + ] + + logger.info( + "Generating sub fallback for %s at %s -> %s", + camera_name, + main_recording.path, + sub_path, + ) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + + if proc.returncode != 0: + logger.error( + "Sub fallback generation failed for %s: %s", + main_recording.path, + stderr.decode(errors="ignore"), + ) + return None + + # Probe the new file for metadata and size. + media_info = await get_video_properties(config.ffmpeg, sub_path, get_duration=True) + try: + segment_size_mb = round( + float(os.path.getsize(sub_path)) / (1024 * 1024), 2 + ) + except OSError: + segment_size_mb = 0.0 + + record_id = ( + existing.id + if existing is not None + else f"{camera_name}-{main_recording.start_time}-{SUB_VARIANT}" + ) + + # Upsert a Recordings row for the standard sub fallback. + data = { + Recordings.id.name: record_id, + Recordings.camera.name: camera_name, + Recordings.path.name: sub_path, + Recordings.variant.name: SUB_VARIANT, + Recordings.transcoded_from_main.name: True, + Recordings.start_time.name: main_recording.start_time, + Recordings.end_time.name: main_recording.end_time, + Recordings.duration.name: main_recording.duration, + Recordings.motion.name: main_recording.motion, + Recordings.objects.name: main_recording.objects, + Recordings.regions.name: main_recording.regions, + Recordings.dBFS.name: main_recording.dBFS, + Recordings.segment_size.name: segment_size_mb, + Recordings.codec_name.name: media_info.get("codec_name"), + Recordings.width.name: media_info.get("width"), + Recordings.height.name: media_info.get("height"), + Recordings.bitrate.name: ( + int((segment_size_mb * (1024 ** 2) * 8) / main_recording.duration) + if main_recording.duration and segment_size_mb > 0 + else None + ), + Recordings.motion_heatmap.name: main_recording.motion_heatmap, + } + + Recordings.insert(data).on_conflict_replace().execute() + + return Recordings.get(Recordings.id == data[Recordings.id.name]) + diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 6f0adc562..690f3dc4d 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -1,6 +1,7 @@ """Unit tests for recordings/media API endpoints.""" from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch import pytz from fastapi import Request @@ -88,14 +89,309 @@ class TestHttpMedia(BaseTestHttp): default_recordings = default_response.json() assert len(default_recordings) == 1 assert default_recordings[0]["variant"] == "main" + assert default_recordings[0]["transcoded_from_main"] is False all_response = client.get( "/front_door/recordings", params={"after": start_ts, "before": end_ts, "variant": "all"}, ) assert all_response.status_code == 200 - variants = {recording["variant"] for recording in all_response.json()} + all_recordings = all_response.json() + variants = {recording["variant"] for recording in all_recordings} assert variants == {"main", "sub"} + assert all(recording["transcoded_from_main"] is False for recording in all_recordings) + + def test_camera_recordings_exposes_transcoded_from_main(self): + start_ts = datetime(2024, 3, 9, 12, 0, 0, tzinfo=timezone.utc).timestamp() + end_ts = start_ts + 10 + + with AuthTestClient(self.app) as client: + Recordings.insert( + id="generated_sub_recording", + path="/media/recordings/front/generated-sub.mp4", + camera="front_door", + variant="sub", + transcoded_from_main=True, + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + codec_name="hevc", + width=640, + height=360, + ).execute() + + response = client.get( + "/front_door/recordings", + params={"after": start_ts, "before": end_ts, "variant": "all"}, + ) + assert response.status_code == 200 + recordings = response.json() + assert len(recordings) == 1 + assert recordings[0]["variant"] == "sub" + assert recordings[0]["transcoded_from_main"] is True + + def test_vod_variant_path_uses_requested_variant(self): + start_ts = datetime(2024, 3, 9, 12, 0, 0, tzinfo=timezone.utc).timestamp() + end_ts = start_ts + 10 + + with AuthTestClient(self.app) as client: + Recordings.insert( + id="vod_recording_main", + path="/media/recordings/front_door/main.mp4", + camera="front_door", + variant="main", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + ).execute() + Recordings.insert( + id="vod_recording_sub", + path="/media/recordings/front_door/sub.mp4", + camera="front_door", + variant="sub", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + ).execute() + + response = client.get( + f"/vod/variant/sub/front_door/start/{start_ts}/end/{end_ts}" + ) + assert response.status_code == 200 + clips = response.json()["sequences"][0]["clips"] + assert [clip["path"] for clip in clips] == [ + "/media/recordings/front_door/sub.mp4" + ] + + def test_vod_variant_path_uses_overlapping_native_sub_without_generation(self): + main_start_ts = datetime( + 2024, 3, 9, 12, 0, 9, tzinfo=timezone.utc + ).timestamp() + main_end_ts = main_start_ts + 9 + native_sub_start_ts = main_start_ts - 1 + native_sub_end_ts = main_end_ts - 1 + + with AuthTestClient(self.app) as client: + Recordings.insert( + id="vod_recording_main_offset", + path="/media/recordings/front_door/main-offset.mp4", + camera="front_door", + variant="main", + start_time=main_start_ts, + end_time=main_end_ts, + duration=9, + motion=100, + objects=5, + codec_name="hevc", + width=1920, + height=1080, + ).execute() + Recordings.insert( + id="vod_recording_sub_offset", + path="/media/recordings/front_door/sub-offset.mp4", + camera="front_door", + variant="sub", + start_time=native_sub_start_ts, + end_time=native_sub_end_ts, + duration=9, + motion=100, + objects=5, + codec_name="hevc", + width=640, + height=480, + ).execute() + + with patch( + "frigate.api.media.ensure_subvariant_for_recording", + new=AsyncMock(), + ) as ensure_subvariant: + response = client.get( + f"/vod/variant/sub/front_door/start/{main_start_ts}/end/{main_end_ts}" + ) + + assert response.status_code == 200 + clips = response.json()["sequences"][0]["clips"] + assert [clip["path"] for clip in clips] == [ + "/media/recordings/front_door/sub-offset.mp4" + ] + ensure_subvariant.assert_not_awaited() + + def test_vod_variant_path_generates_standard_sub_when_missing(self): + start_ts = datetime(2024, 3, 9, 12, 0, 0, tzinfo=timezone.utc).timestamp() + end_ts = start_ts + 10 + + generated_sub = Recordings( + id="generated_standard_sub", + path="/media/recordings/front_door/sub.mp4", + camera="front_door", + variant="sub", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + codec_name="h264", + ) + + with AuthTestClient(self.app) as client: + Recordings.insert( + id="vod_recording_main_missing_sub", + path="/media/recordings/front_door/main.mp4", + camera="front_door", + variant="main", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + codec_name="h264", + ).execute() + + with patch( + "frigate.api.media.ensure_subvariant_for_recording", + new=AsyncMock(return_value=generated_sub), + ) as ensure_subvariant: + response = client.get( + f"/vod/variant/sub/front_door/start/{start_ts}/end/{end_ts}" + ) + + assert response.status_code == 200 + clips = response.json()["sequences"][0]["clips"] + assert [clip["path"] for clip in clips] == [ + "/media/recordings/front_door/sub.mp4" + ] + ensure_subvariant.assert_awaited_once() + + def test_vod_variant_path_filters_exact_match_generated_sub_when_native_overlap_exists(self): + main_start_ts = datetime( + 2024, 3, 9, 12, 0, 9, tzinfo=timezone.utc + ).timestamp() + main_end_ts = main_start_ts + 9 + native_sub_start_ts = main_start_ts - 1 + native_sub_end_ts = main_end_ts - 1 + + with AuthTestClient(self.app) as client: + Recordings.insert( + id="vod_recording_main_generated_conflict", + path="/media/recordings/front_door/main-generated-conflict.mp4", + camera="front_door", + variant="main", + start_time=main_start_ts, + end_time=main_end_ts, + duration=9, + motion=100, + objects=5, + codec_name="hevc", + width=1920, + height=1080, + ).execute() + Recordings.insert( + id="vod_recording_sub_native_overlap", + path="/media/recordings/front_door/sub-native-overlap.mp4", + camera="front_door", + variant="sub", + start_time=native_sub_start_ts, + end_time=native_sub_end_ts, + duration=9, + motion=100, + objects=5, + codec_name="hevc", + width=640, + height=480, + ).execute() + Recordings.insert( + id="vod_recording_sub_generated_like", + path="/media/recordings/front_door/sub-generated-like.mp4", + camera="front_door", + variant="sub", + start_time=main_start_ts, + end_time=main_end_ts, + duration=9, + motion=100, + objects=5, + codec_name="hevc", + width=640, + height=360, + ).execute() + + with patch( + "frigate.api.media.ensure_subvariant_for_recording", + new=AsyncMock(), + ) as ensure_subvariant: + response = client.get( + f"/vod/variant/sub/front_door/start/{main_start_ts}/end/{main_end_ts}" + ) + + assert response.status_code == 200 + clips = response.json()["sequences"][0]["clips"] + assert [clip["path"] for clip in clips] == [ + "/media/recordings/front_door/sub-native-overlap.mp4" + ] + ensure_subvariant.assert_not_awaited() + + def test_vod_variant_path_ignores_legacy_sub_h264_rows(self): + start_ts = datetime(2024, 3, 9, 12, 0, 0, tzinfo=timezone.utc).timestamp() + end_ts = start_ts + 10 + + generated_sub = Recordings( + id="standard_sub_fallback", + path="/media/recordings/front_door/sub.mp4", + camera="front_door", + variant="sub", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + codec_name="h264", + ) + + with AuthTestClient(self.app) as client: + Recordings.insert( + id="vod_recording_main_with_legacy", + path="/media/recordings/front_door/main.mp4", + camera="front_door", + variant="main", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + codec_name="h264", + ).execute() + Recordings.insert( + id="legacy_sub_h264_row", + path="/media/recordings/front_door/sub_h264.mp4", + camera="front_door", + variant="sub_h264", + start_time=start_ts, + end_time=end_ts, + duration=10, + motion=100, + objects=5, + codec_name="h264", + ).execute() + + with patch( + "frigate.api.media.ensure_subvariant_for_recording", + new=AsyncMock(return_value=generated_sub), + ) as ensure_subvariant: + response = client.get( + f"/vod/variant/sub/front_door/start/{start_ts}/end/{end_ts}" + ) + + assert response.status_code == 200 + clips = response.json()["sequences"][0]["clips"] + assert [clip["path"] for clip in clips] == [ + "/media/recordings/front_door/sub.mp4" + ] + ensure_subvariant.assert_awaited_once() def test_recordings_summary_across_dst_spring_forward(self): """ diff --git a/migrations/037_add_recordings_transcoded_from_main.py b/migrations/037_add_recordings_transcoded_from_main.py new file mode 100644 index 000000000..c3741cb91 --- /dev/null +++ b/migrations/037_add_recordings_transcoded_from_main.py @@ -0,0 +1,29 @@ +"""Peewee migrations -- 037_add_recordings_transcoded_from_main.py.""" + +from peewee import OperationalError + + +def migrate(migrator, database, fake=False, **kwargs): + try: + database.execute_sql( + """ + ALTER TABLE "recordings" + ADD COLUMN "transcoded_from_main" INTEGER NOT NULL DEFAULT 0 + """ + ) + except OperationalError as exc: + if "duplicate column name" not in str(exc).lower(): + raise + + database.execute_sql( + """ + UPDATE recordings + SET transcoded_from_main = 1 + WHERE variant = 'sub_h264' + OR (variant = 'sub' AND id LIKE '%-sub') + """ + ) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index 3b50ff5ed..77858c16d 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -2,6 +2,11 @@ "noRecordingsFoundForThisTime": "No recordings found for this time", "noPreviewFound": "No Preview Found", "noPreviewFoundFor": "No Preview Found for {{cameraName}}", + "playbackPreference": { + "auto": "Auto", + "main": "Main", + "sub": "Sub" + }, "submitFrigatePlus": { "title": "Submit this frame to Frigate+?", "submit": "Submit" diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 460a1d337..87727fc28 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -148,6 +148,11 @@ "storageUsed": "Storage", "percentageOfTotalUsed": "Percentage of Total", "bandwidth": "Bandwidth", + "sort": { + "camera": "Sort by camera", + "storage": "Sort by storage", + "bandwidth": "Sort by bandwidth" + }, "unused": { "title": "Unused", "tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings." diff --git a/web/src/components/graph/CombinedStorageGraph.tsx b/web/src/components/graph/CombinedStorageGraph.tsx index 4279e73c2..6c85ee1a6 100644 --- a/web/src/components/graph/CombinedStorageGraph.tsx +++ b/web/src/components/graph/CombinedStorageGraph.tsx @@ -1,7 +1,8 @@ import { useTheme } from "@/context/theme-provider"; import { generateColors } from "@/utils/colorUtil"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Chart from "react-apexcharts"; +import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; import { Table, TableBody, @@ -39,6 +40,24 @@ type CombinedStorageGraphProps = { cameraStorage: CameraStorage; totalStorage: TotalStorage; }; + +type StorageSeries = { + name: string; + data: number[]; + usage: number; + bandwidth: number; + color: string; +}; + +type SortKey = "camera" | "usage" | "bandwidth"; +type SortDirection = "asc" | "desc"; + +const defaultSortDirections: Record = { + camera: "asc", + usage: "desc", + bandwidth: "desc", +}; + export function CombinedStorageGraph({ graphId, cameraStorage, @@ -47,38 +66,107 @@ export function CombinedStorageGraph({ const { t } = useTranslation(["views/system"]); const { theme, systemTheme } = useTheme(); - - const entities = Object.keys(cameraStorage); - const colors = generateColors(entities.length); - - const series = entities.map((entity, index) => ({ - name: entity, - data: [(cameraStorage[entity].usage / totalStorage.total) * 100], - usage: cameraStorage[entity].usage, - bandwidth: cameraStorage[entity].bandwidth, - color: colors[index], // Assign the corresponding color - })); - - // Add the unused percentage to the series - series.push({ - name: "Other", - data: [ - ((totalStorage.used - totalStorage.camera) / totalStorage.total) * 100, - ], - usage: totalStorage.used - totalStorage.camera, - bandwidth: 0, - color: (systemTheme || theme) == "dark" ? "#606060" : "#D5D5D5", - }); - series.push({ - name: "Unused", - data: [ - ((totalStorage.total - totalStorage.used) / totalStorage.total) * 100, - ], - usage: totalStorage.total - totalStorage.used, - bandwidth: 0, - color: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5", + const [sortConfig, setSortConfig] = useState<{ + key: SortKey; + direction: SortDirection; + }>({ + key: "camera", + direction: defaultSortDirections.camera, }); + const entities = useMemo(() => Object.keys(cameraStorage), [cameraStorage]); + + const handleSort = useCallback((key: SortKey) => { + setSortConfig((currentSort) => { + if (currentSort.key == key) { + return { + key, + direction: currentSort.direction == "asc" ? "desc" : "asc", + }; + } + + return { key, direction: defaultSortDirections[key] }; + }); + }, []); + + const getAriaSort = useCallback( + (key: SortKey) => { + if (sortConfig.key != key) { + return "none"; + } + + return sortConfig.direction == "asc" ? "ascending" : "descending"; + }, + [sortConfig], + ); + + const getSortIcon = useCallback( + (key: SortKey) => { + if (sortConfig.key != key) { + return ; + } + + return sortConfig.direction == "asc" ? ( + + ) : ( + + ); + }, + [sortConfig], + ); + + const series = useMemo(() => { + const colors = generateColors(entities.length); + + const cameraSeries = entities.map((entity, index) => ({ + name: entity, + data: [(cameraStorage[entity].usage / totalStorage.total) * 100], + usage: cameraStorage[entity].usage, + bandwidth: cameraStorage[entity].bandwidth, + color: colors[index], + })); + + cameraSeries.sort((left, right) => { + let comparison = 0; + + if (sortConfig.key == "camera") { + comparison = left.name + .replaceAll("_", " ") + .localeCompare(right.name.replaceAll("_", " "), undefined, { + numeric: true, + sensitivity: "base", + }); + } else { + comparison = left[sortConfig.key] - right[sortConfig.key]; + } + + return sortConfig.direction == "asc" ? comparison : -comparison; + }); + + return [ + ...cameraSeries, + { + name: "Other", + data: [ + ((totalStorage.used - totalStorage.camera) / totalStorage.total) * + 100, + ], + usage: totalStorage.used - totalStorage.camera, + bandwidth: 0, + color: (systemTheme || theme) == "dark" ? "#606060" : "#D5D5D5", + }, + { + name: "Unused", + data: [ + ((totalStorage.total - totalStorage.used) / totalStorage.total) * 100, + ], + usage: totalStorage.total - totalStorage.used, + bandwidth: 0, + color: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5", + }, + ]; + }, [cameraStorage, entities, sortConfig, systemTheme, theme, totalStorage]); + const options = useMemo(() => { return { chart: { @@ -185,6 +273,21 @@ export function CombinedStorageGraph({ [t], ); + const getSortHeader = useCallback( + (key: SortKey, label: string, ariaLabel: string) => ( + + ), + [getSortIcon, handleSort], + ); + return (
@@ -205,12 +308,30 @@ export function CombinedStorageGraph({ - {t("storage.cameraStorage.camera")} - {t("storage.cameraStorage.storageUsed")} + + {getSortHeader( + "camera", + t("storage.cameraStorage.camera"), + t("storage.cameraStorage.sort.camera"), + )} + + + {getSortHeader( + "usage", + t("storage.cameraStorage.storageUsed"), + t("storage.cameraStorage.sort.storage"), + )} + {t("storage.cameraStorage.percentageOfTotalUsed")} - {t("storage.cameraStorage.bandwidth")} + + {getSortHeader( + "bandwidth", + t("storage.cameraStorage.bandwidth"), + t("storage.cameraStorage.sort.bandwidth"), + )} + diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 8e81426d2..7e0fde201 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -29,13 +29,14 @@ import { import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; -import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { useTranslation } from "react-i18next"; import { ExportCase } from "@/types/export"; import { CustomTimeSelector } from "./CustomTimeSelector"; import useRecordingPlaybackSource from "@/hooks/use-recording-playback-source"; +import RecordingPlaybackPreferenceSelect from "../player/RecordingPlaybackPreferenceSelect"; +import ActivityIndicator from "../indicators/activity-indicator"; const EXPORT_OPTIONS = [ "1", @@ -444,8 +445,6 @@ export function ExportPreviewDialog({ return null; } - const source = playbackSource ?? `${baseUrl}${vodPath}`; - return ( - + {playbackSource ? ( + +
+ +
+
+ ) : ( +
+ +
+ )}
); diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 683eecb74..a20dfda96 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -55,7 +55,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { REVIEW_PADDING } from "@/types/review"; import { capitalizeAll } from "@/utils/stringUtil"; import useGlobalMutation from "@/hooks/use-global-mutate"; import DetailActionsMenu from "./DetailActionsMenu"; @@ -68,7 +67,6 @@ import { import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import useImageLoaded from "@/hooks/use-image-loaded"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; -import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer"; import { Popover, PopoverContent, @@ -80,7 +78,6 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; -import useRecordingPlaybackSource from "@/hooks/use-recording-playback-source"; import { LuInfo } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { FaPencilAlt } from "react-icons/fa"; @@ -1857,31 +1854,3 @@ export function ObjectSnapshotTab({ ); } -type VideoTabProps = { - search: SearchResult; -}; - -export function VideoTab({ search }: VideoTabProps) { - const clipTimeRange = useMemo(() => { - const startTime = search.start_time - REVIEW_PADDING; - const endTime = (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING; - return `start/${startTime}/end/${endTime}`; - }, [search]); - const startTime = search.start_time - REVIEW_PADDING; - const endTime = (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING; - const vodPath = `/vod/${search.camera}/${clipTimeRange}/index.m3u8`; - const playbackSource = useRecordingPlaybackSource({ - camera: search.camera, - after: startTime, - before: endTime, - vodPath, - }); - const source = playbackSource ?? `${baseUrl}${vodPath}`; - - return ( - <> - - - - ); -} diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 7d8d4c5b8..ae62d487d 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -8,10 +8,9 @@ import { TrackingDetailsSequence } from "@/types/timeline"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { getIconForLabel } from "@/utils/iconUtil"; -import { LuCircle, LuFolderX } from "react-icons/lu"; +import { LuCircle } from "react-icons/lu"; import { cn } from "@/lib/utils"; import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; -import { baseUrl } from "@/api/baseUrl"; import { REVIEW_PADDING } from "@/types/review"; import { ASPECT_PORTRAIT_LAYOUT, @@ -35,13 +34,11 @@ import { HiDotsHorizontal } from "react-icons/hi"; import axios from "axios"; import { toast } from "sonner"; import { useDetailStream } from "@/context/detail-stream-context"; -import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect"; -import { useApiHost } from "@/api"; -import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; -import ObjectTrackOverlay from "../ObjectTrackOverlay"; +import { isDesktop, isMobileOnly } from "react-device-detect"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { VideoResolutionType } from "@/types/live"; import useRecordingPlaybackSource from "@/hooks/use-recording-playback-source"; +import RecordingPlaybackPreferenceSelect from "@/components/player/RecordingPlaybackPreferenceSelect"; type TrackingDetailsProps = { className?: string; @@ -59,19 +56,9 @@ export function TrackingDetails({ }: TrackingDetailsProps) { const videoRef = useRef(null); const { t } = useTranslation(["views/explore"]); - const apiHost = useApiHost(); - const imgRef = useRef(null); - const [imgLoaded, setImgLoaded] = useState(false); const [isVideoLoading, setIsVideoLoading] = useState(true); - const [displaySource, _setDisplaySource] = useState<"video" | "image">( - "video", - ); const { setSelectedObjectIds, annotationOffset } = useDetailStream(); - // manualOverride holds a record-stream timestamp explicitly chosen by the - // user (eg, clicking a lifecycle row). When null we display `currentTime`. - const [manualOverride, setManualOverride] = useState(null); - // Capture the annotation offset used for building the video source URL. // This only updates when the event changes, NOT on every slider drag, // so the HLS player doesn't reload while the user is adjusting the offset. @@ -251,13 +238,9 @@ export function TrackingDetails({ }); }); - // Use manualOverride (set when seeking in image mode) if present so - // lifecycle rows and overlays follow image-mode seeks. Otherwise fall - // back to currentTime used for video mode. const effectiveTime = useMemo(() => { - const displayedRecordTime = manualOverride ?? currentTime; - return displayedRecordTime - annotationOffset / 1000; - }, [manualOverride, currentTime, annotationOffset]); + return currentTime - annotationOffset / 1000; + }, [currentTime, annotationOffset]); const containerRef = useRef(null); const { fullscreen, toggleFullscreen, supportsFullScreen } = @@ -326,7 +309,7 @@ export function TrackingDetails({ // On popover open: pause, pin first lifecycle item, and seek. useEffect(() => { if (isAnnotationSettingsOpen && !wasAnnotationOpenRef.current) { - if (videoRef.current && displaySource === "video") { + if (videoRef.current) { videoRef.current.pause(); } if (eventSequence && eventSequence.length > 0) { @@ -337,14 +320,14 @@ export function TrackingDetails({ pinnedDetectTimestampRef.current = null; } wasAnnotationOpenRef.current = isAnnotationSettingsOpen; - }, [isAnnotationSettingsOpen, displaySource, eventSequence]); + }, [isAnnotationSettingsOpen, eventSequence]); // When the pinned timestamp or offset changes, re-seek the video and // explicitly update currentTime so the overlay shows the pinned event's box. useEffect(() => { const pinned = pinnedDetectTimestampRef.current; if (!isAnnotationSettingsOpen || pinned == null) return; - if (!videoRef.current || displaySource !== "video") return; + if (!videoRef.current) return; const targetTimeRecord = pinned + annotationOffset / 1000; const relativeTime = timestampToVideoTime(targetTimeRecord); @@ -354,36 +337,21 @@ export function TrackingDetails({ // resolves back to the pinned detect timestamp: // effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned setCurrentTime(targetTimeRecord); - }, [ - isAnnotationSettingsOpen, - annotationOffset, - displaySource, - timestampToVideoTime, - ]); + }, [isAnnotationSettingsOpen, annotationOffset, timestampToVideoTime]); const handleLifecycleClick = useCallback( (item: TrackingDetailsSequence) => { - if (!videoRef.current && !imgRef.current) return; + if (!videoRef.current) return; // Convert lifecycle timestamp (detect stream) to record stream time const targetTimeRecord = item.timestamp + annotationOffset / 1000; - if (displaySource === "image") { - // For image mode: set a manual override timestamp and update - // currentTime so overlays render correctly. - setManualOverride(targetTimeRecord); - setCurrentTime(targetTimeRecord); - return; - } - - // For video mode: convert to video-relative time (accounting for motion-only gaps) + // Convert to video-relative time (accounting for motion-only gaps) const relativeTime = timestampToVideoTime(targetTimeRecord); - if (videoRef.current) { - videoRef.current.currentTime = relativeTime; - } + videoRef.current.currentTime = relativeTime; }, - [annotationOffset, displaySource, timestampToVideoTime], + [annotationOffset, timestampToVideoTime], ); const formattedStart = config @@ -427,14 +395,6 @@ export function TrackingDetails({ useEffect(() => { if (seekToTimestamp === null) return; - if (displaySource === "image") { - // For image mode, set the manual override so the snapshot updates to - // the exact record timestamp. - setManualOverride(seekToTimestamp); - setSeekToTimestamp(null); - return; - } - // seekToTimestamp is a record stream timestamp // Convert to video position (accounting for motion-only recording gaps) if (!videoRef.current) return; @@ -443,7 +403,7 @@ export function TrackingDetails({ videoRef.current.currentTime = relativeTime; } setSeekToTimestamp(null); - }, [seekToTimestamp, displaySource, timestampToVideoTime]); + }, [seekToTimestamp, timestampToVideoTime]); const isWithinEventRange = useMemo(() => { if (effectiveTime === undefined || event.start_time === undefined) { @@ -535,15 +495,22 @@ export function TrackingDetails({ before: videoWindow.endTime, vodPath: videoWindow.vodPath, }); + useEffect(() => { + if (playbackSource?.url) { + setIsVideoLoading(true); + } + }, [playbackSource?.url]); const videoSource = useMemo(() => { - const playlist = playbackSource ?? `${baseUrl}${videoWindow.vodPath}`; + if (!playbackSource) { + return undefined; + } return { - playlist, + playlist: playbackSource.url, startPosition: 0, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playbackSource, videoWindow]); + }, [playbackSource]); // Determine camera aspect ratio category const cameraAspect = useMemo(() => { @@ -574,27 +541,6 @@ export function TrackingDetails({ [videoTimeToTimestamp], ); - const [src, setSrc] = useState( - `${apiHost}api/${event.camera}/recordings/${currentTime + REVIEW_PADDING}/snapshot.jpg?height=500`, - ); - const [hasError, setHasError] = useState(false); - - // Derive the record timestamp to display: manualOverride if present, - // otherwise use currentTime. - const displayedRecordTime = manualOverride ?? currentTime; - - useEffect(() => { - if (displayedRecordTime) { - const newSrc = `${apiHost}api/${event.camera}/recordings/${displayedRecordTime}/snapshot.jpg?height=500`; - setSrc(newSrc); - } - setImgLoaded(false); - setHasError(false); - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [displayedRecordTime]); - const onUploadFrameToPlus = useCallback(() => { return axios.post(`/${event.camera}/plus/${currentTime}`); }, [event.camera, currentTime]); @@ -632,83 +578,40 @@ export function TrackingDetails({ cameraAspect === "tall" ? "h-full" : "w-full", )} > - {displaySource == "video" && ( - <> - setIsVideoLoading(false)} - setFullResolution={setFullResolution} - toggleFullscreen={toggleFullscreen} - isDetailMode={true} - camera={event.camera} - currentTimeOverride={currentTime} - /> - {isVideoLoading && ( - - )} - + {videoSource ? ( + setIsVideoLoading(false)} + setFullResolution={setFullResolution} + toggleFullscreen={toggleFullscreen} + isDetailMode={true} + camera={event.camera} + currentTimeOverride={currentTime} + /> + ) : ( + )} - {displaySource == "image" && ( - <> - + - {hasError && ( -
-
- - {t("objectLifecycle.noImageFound")} -
-
- )} -
-
- -
- setImgLoaded(true)} - onError={() => setHasError(true)} - /> -
- + + )} + {isVideoLoading && ( + )} diff --git a/web/src/components/player/GenericVideoPlayer.tsx b/web/src/components/player/GenericVideoPlayer.tsx index 25399771b..64634481f 100644 --- a/web/src/components/player/GenericVideoPlayer.tsx +++ b/web/src/components/player/GenericVideoPlayer.tsx @@ -133,7 +133,7 @@ export function GenericVideoPlayer({ }} setFullResolution={setVideoResolution} /> - {!isLoading && children} + {children} )} diff --git a/web/src/components/player/RecordingPlaybackPreferenceSelect.tsx b/web/src/components/player/RecordingPlaybackPreferenceSelect.tsx new file mode 100644 index 000000000..f3bfedb89 --- /dev/null +++ b/web/src/components/player/RecordingPlaybackPreferenceSelect.tsx @@ -0,0 +1,36 @@ +import { RecordingPlaybackPreference } from "@/types/record"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslation } from "react-i18next"; + +type RecordingPlaybackPreferenceSelectProps = { + className?: string; + onValueChange: (value: RecordingPlaybackPreference) => void; + value: RecordingPlaybackPreference; +}; + +export default function RecordingPlaybackPreferenceSelect({ + className, + onValueChange, + value, +}: RecordingPlaybackPreferenceSelectProps) { + const { t } = useTranslation(["components/player"]); + + return ( + + ); +} diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index e9da0064d..4540cabb0 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -8,6 +8,7 @@ import { } from "@/utils/videoUtil"; type PlayerMode = "playback" | "scrubbing"; +const RECORDING_SEEK_CLAMP_GAP_SECONDS = 45; export class DynamicVideoController { // main state @@ -79,8 +80,14 @@ export class DynamicVideoController { this.playerMode = "playback"; } + const playableTime = this.getPlayableTimestamp(time); + if (playableTime === undefined) { + this.setNoRecording(true); + return; + } + const seekSeconds = calculateSeekPosition( - time, + playableTime, this.recordings, this.inpointOffset, ); @@ -103,6 +110,29 @@ export class DynamicVideoController { } } + private getPlayableTimestamp(time: number): number | undefined { + if (!this.recordings.length) { + return undefined; + } + + const directSeek = calculateSeekPosition(time, this.recordings, this.inpointOffset); + if (directSeek !== undefined) { + return time; + } + + // Some review items start a few seconds before the first saved segment. + // Clamp short gaps to the next recording so playback still opens. + const nextRecording = this.recordings.find((segment) => segment.start_time > time); + if ( + nextRecording && + nextRecording.start_time - time <= RECORDING_SEEK_CLAMP_GAP_SECONDS + ) { + return nextRecording.start_time; + } + + return undefined; + } + waitAndPlay() { return new Promise((resolve) => { const onSeekedHandler = () => { @@ -166,5 +196,3 @@ export class DynamicVideoController { ); } } - -export default typeof DynamicVideoController; diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 6cae2ef5e..1b408d9aa 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -26,19 +26,16 @@ import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { useUserPersistence } from "@/hooks/use-user-persistence"; import usePlaybackCapabilities from "@/hooks/use-playback-capabilities"; -import { chooseRecordingPlayback } from "@/utils/recordingPlayback"; +import { + chooseRecordingPlayback, + getRecordingsForPlaybackVariant, +} from "@/utils/recordingPlayback"; import { calculateInpointOffset, calculateSeekPosition, } from "@/utils/videoUtil"; import { isFirefox } from "react-device-detect"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import RecordingPlaybackPreferenceSelect from "../RecordingPlaybackPreferenceSelect"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -212,17 +209,6 @@ export default function DynamicVideoPlayer({ [`${camera}/recordings`, { ...recordingParams, variant: "all" }], { revalidateOnFocus: false }, ); - const recordings = useMemo(() => { - if (!allRecordings?.length) { - return allRecordings; - } - - const mainRecordings = allRecordings.filter( - (recording) => (recording.variant || "main") === "main", - ); - - return mainRecordings.length > 0 ? mainRecordings : allRecordings; - }, [allRecordings]); const codecNames = useMemo( () => Array.from( @@ -231,22 +217,69 @@ export default function DynamicVideoPlayer({ [allRecordings], ); const playbackCapabilities = usePlaybackCapabilities(codecNames); + const playbackDecision = useMemo(() => { + if (!allRecordings?.length) { + return undefined; + } + + const vodPath = `/vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`; + + return chooseRecordingPlayback({ + apiHost, + recordings: allRecordings, + preference: playbackPreference ?? "sub", + vodPath, + capabilities: playbackCapabilities, + }); + }, [ + allRecordings, + apiHost, + camera, + playbackPreference, + playbackCapabilities, + recordingParams.after, + recordingParams.before, + ]); + const recordings = useMemo(() => { + if (!allRecordings?.length) { + return allRecordings; + } + + if (!playbackDecision || playbackDecision.variant === "main") { + return getRecordingsForPlaybackVariant(allRecordings, "main"); + } + + const selectedRecordings = getRecordingsForPlaybackVariant(allRecordings, "sub"); + + return selectedRecordings.length > 0 ? selectedRecordings : allRecordings; + }, [allRecordings, playbackDecision]); useEffect(() => { - if (!recordings?.length) { - if (recordings?.length == 0) { + if (!allRecordings?.length) { + if (allRecordings?.length == 0) { + if (loadingTimeout) { + clearTimeout(loadingTimeout); + } + + setIsLoading(false); + setIsBuffering(false); setNoRecording(true); + setSource(undefined); } return; } + if (!recordings?.length || !playbackDecision) { + return; + } + let startPosition = undefined; if (startTimestamp) { const inpointOffset = calculateInpointOffset( recordingParams.after, - (recordings || [])[0], + recordings[0], ); startPosition = calculateSeekPosition( @@ -256,33 +289,19 @@ export default function DynamicVideoPlayer({ ); } - const vodPath = `/vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`; - const decision = chooseRecordingPlayback({ - apiHost, - config, - recordings: allRecordings ?? recordings, - preference: playbackPreference ?? "sub", - vodPath, - capabilities: playbackCapabilities, - }); setSource({ - playlist: decision.url, + playlist: playbackDecision.url, startPosition, }); + setNoRecording(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - apiHost, - camera, - recordingParams.after, - recordingParams.before, allRecordings, recordings, startTimestamp, - playbackPreference, - playbackCapabilities, - config?.transcode_proxy?.enabled, - config?.transcode_proxy?.vod_proxy_url, + playbackDecision, + recordingParams.after, ]); useEffect(() => { @@ -384,22 +403,13 @@ export default function DynamicVideoPlayer({ )} {!isScrubbing && source && (
- + />
)} void; + url: string; + variant: string; +}; + export default function useRecordingPlaybackSource({ camera, after, @@ -28,11 +40,11 @@ export default function useRecordingPlaybackSource({ enabled = true, }: RecordingPlaybackSourceOptions) { const apiHost = useApiHost(); - const { data: config } = useSWR("config"); - const [storedPreference] = useUserPersistence( + const [storedPreference, setStoredPreference, preferenceLoaded] = + useUserPersistence( `${camera}-recording-playback-v2`, "sub", - ); + ); const { data: recordings } = useSWR( enabled ? [`${camera}/recordings`, { after, before, variant: "all" }] : null, { revalidateOnFocus: false }, @@ -46,27 +58,56 @@ export default function useRecordingPlaybackSource({ [recordings], ); const capabilities = usePlaybackCapabilities(codecNames); + const activePreference = preference ?? storedPreference ?? "sub"; + const setPreferenceValue = useCallback( + (value: RecordingPlaybackPreference) => { + if (preference !== undefined) { + return; + } + + setStoredPreference(value); + }, + [preference, setStoredPreference], + ); return useMemo(() => { - if (!recordings?.length) { + if (!preferenceLoaded) { return undefined; } - return chooseRecordingPlayback({ + if (!recordings?.length) { + const fallbackVariant = getFallbackVariantForPreference(activePreference); + + return { + preference: activePreference, + setPreference: setPreferenceValue, + url: buildDirectUrl(apiHost, vodPath, fallbackVariant), + variant: fallbackVariant, + }; + } + + const decision = chooseRecordingPlayback({ apiHost, - config, recordings, - preference: preference ?? storedPreference ?? "sub", + preference: activePreference, vodPath, capabilities, - }).url; + }); + + return { + decision, + preference: activePreference, + setPreference: setPreferenceValue, + url: decision.url, + variant: decision.variant, + }; }, [ + activePreference, apiHost, capabilities, - config, - preference, + preferenceLoaded, recordings, - storedPreference, + setPreferenceValue, vodPath, ]); } diff --git a/web/src/types/record.ts b/web/src/types/record.ts index af4f4c481..669636f11 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -8,6 +8,7 @@ export type Recording = { end_time: number; path: string; variant?: string; + transcoded_from_main?: boolean; segment_size: number; duration: number; motion: number; @@ -52,8 +53,7 @@ export type RecordingPlayerError = "stalled" | "startup"; export type RecordingPlaybackPreference = | "auto" | "main" - | "sub" - | "transcoded"; + | "sub"; export const ASPECT_VERTICAL_LAYOUT = 1.5; export const ASPECT_PORTRAIT_LAYOUT = 1.333; diff --git a/web/src/utils/recordingPlayback.test.ts b/web/src/utils/recordingPlayback.test.ts new file mode 100644 index 000000000..27faaa061 --- /dev/null +++ b/web/src/utils/recordingPlayback.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { Recording } from "@/types/record"; + +import { + buildVariantVodPath, + chooseRecordingPlayback, + getRecordingsForPlaybackVariant, + getFallbackVariantForPreference, +} from "./recordingPlayback"; + +const apiHost = "http://frigate.test/api"; +const vodPath = "/vod/front_door/start/10/end/20/index.m3u8"; + +const playbackCapabilities = { + estimatedBandwidthBps: 8_000_000, + saveData: false, + supports: { + h264: true, + hevc: true, + }, +}; + +function makeRecording( + variant: "main" | "sub", + overrides: Partial = {}, +): Recording { + return { + id: `${variant}-recording`, + camera: "front_door", + start_time: 10, + end_time: 20, + path: `/media/frigate/recordings/front_door/${variant}.mp4`, + variant, + segment_size: 4, + duration: 10, + motion: 100, + objects: 5, + dBFS: 0, + codec_name: "h264", + ...overrides, + }; +} + +describe("recordingPlayback", () => { + it("builds variant vod paths for sub recordings", () => { + expect(buildVariantVodPath(vodPath, "main")).toBe(vodPath); + expect(buildVariantVodPath(vodPath, "sub")).toBe( + "/vod/variant/sub/front_door/start/10/end/20/index.m3u8", + ); + }); + + it("uses the sub variant URL when sub is selected manually", () => { + const decision = chooseRecordingPlayback({ + apiHost, + recordings: [makeRecording("main"), makeRecording("sub")], + preference: "sub", + vodPath, + capabilities: playbackCapabilities, + }); + + expect(decision.variant).toBe("sub"); + expect(decision.reason).toBe("manual-sub"); + expect(decision.url).toBe( + "http://frigate.test/api/vod/variant/sub/front_door/start/10/end/20/index.m3u8", + ); + }); + + it("ignores legacy sub_h264 recordings for sub playback", () => { + const decision = chooseRecordingPlayback({ + apiHost, + recordings: [ + makeRecording("main"), + makeRecording("sub", { + id: "sub-h264-recording", + variant: "sub_h264", + }), + ], + preference: "sub", + vodPath, + capabilities: playbackCapabilities, + }); + + expect(decision.variant).toBe("main"); + expect(decision.reason).toBe("raw-main"); + }); + + it("ignores legacy sub_h264 rows for sub seek timelines", () => { + const subRecordings = getRecordingsForPlaybackVariant( + [ + makeRecording("sub", { id: "native-sub", path: "/native-sub.mp4" }), + makeRecording("sub", { + id: "legacy-generated-sub", + path: "/legacy-generated-sub.mp4", + variant: "sub_h264", + }), + ], + "sub", + ); + + expect(subRecordings).toHaveLength(1); + expect(subRecordings[0].id).toBe("native-sub"); + }); + + it("still prefers playable main in auto mode", () => { + const decision = chooseRecordingPlayback({ + apiHost, + recordings: [makeRecording("main"), makeRecording("sub")], + preference: "auto", + vodPath, + capabilities: playbackCapabilities, + }); + + expect(decision.variant).toBe("main"); + expect(decision.reason).toBe("raw-main"); + }); + + it("maps fallback variants from playback preferences", () => { + expect(getFallbackVariantForPreference("main")).toBe("main"); + expect(getFallbackVariantForPreference("auto")).toBe("main"); + expect(getFallbackVariantForPreference("sub")).toBe("sub"); + }); +}); diff --git a/web/src/utils/recordingPlayback.ts b/web/src/utils/recordingPlayback.ts index 42091ea77..cd0eb401f 100644 --- a/web/src/utils/recordingPlayback.ts +++ b/web/src/utils/recordingPlayback.ts @@ -1,4 +1,3 @@ -import { FrigateConfig } from "@/types/frigateConfig"; import { Recording, RecordingPlaybackPreference, @@ -11,15 +10,16 @@ export type PlaybackCapabilities = { }; export type RecordingPlaybackDecision = { - mode: "direct" | "transcoded"; + mode: "direct"; variant: string; url: string; reason: string; }; +export type PlaybackVariant = "main" | "sub"; + type DecisionOptions = { apiHost: string; - config?: FrigateConfig; recordings: Recording[]; preference: RecordingPlaybackPreference; vodPath: string; @@ -63,16 +63,6 @@ function trimTrailingSlash(value: string): string { return value.replace(/\/$/, ""); } -function appendQuery(url: string, params: Record): string { - const entries = Object.entries(params).filter(([, value]) => value); - if (entries.length === 0) { - return url; - } - - const search = new URLSearchParams(entries as [string, string][]); - return `${url}${url.includes("?") ? "&" : "?"}${search.toString()}`; -} - function average(values: number[]): number | undefined { if (!values.length) { return undefined; @@ -119,14 +109,70 @@ export function estimateRecordingBitrate(recordings: Recording[]): number | unde export function groupRecordingsByVariant( recordings: Recording[], ): Record { - return recordings.reduce>((acc, recording) => { - const variant = recording.variant || "main"; - if (!acc[variant]) { - acc[variant] = []; + return { + main: getRecordingsForPlaybackVariant(recordings, "main"), + sub: getRecordingsForPlaybackVariant(recordings, "sub"), + }; +} + +export function normalizePlaybackVariantFamily( + variant?: string | null, +): PlaybackVariant | undefined { + const normalized = variant?.toLowerCase().trim() || "main"; + + if (normalized === "main") { + return "main"; + } + + if (normalized === "sub") { + return "sub"; + } + + return undefined; +} + +function getVariantPriority(recording: Recording): number { + const normalized = recording.variant?.toLowerCase().trim(); + + if (normalized === "sub") { + return 1; + } + + if (normalized === "main") { + return 0; + } + + return -1; +} + +export function getRecordingsForPlaybackVariant( + recordings: Recording[], + variant: PlaybackVariant, +): Recording[] { + const selected = recordings + .filter((recording) => normalizePlaybackVariantFamily(recording.variant) === variant) + .sort((left, right) => { + if (left.start_time !== right.start_time) { + return left.start_time - right.start_time; + } + + return getVariantPriority(right) - getVariantPriority(left); + }); + + const deduped = new Map(); + + for (const recording of selected) { + const key = `${recording.start_time}:${recording.end_time}`; + const existing = deduped.get(key); + + if (!existing || getVariantPriority(recording) > getVariantPriority(existing)) { + deduped.set(key, recording); } - acc[variant].push(recording); - return acc; - }, {}); + } + + return Array.from(deduped.values()).sort( + (left, right) => left.start_time - right.start_time, + ); } function canDirectPlayVariant( @@ -145,65 +191,34 @@ function getDirectBaseUrl(apiHost: string): string { return trimTrailingSlash(apiHost); } -function getTranscodeBaseUrl(apiHost: string, config?: FrigateConfig): string | undefined { - if (!config?.transcode_proxy?.enabled) { - return undefined; +export function buildVariantVodPath(vodPath: string, variant: string): string { + if (variant === "main") { + return vodPath; } - if (config.transcode_proxy.vod_proxy_url?.trim()) { - return trimTrailingSlash(config.transcode_proxy.vod_proxy_url); - } - - return `${trimTrailingSlash(apiHost)}/vod-transcoded`; + return vodPath.replace(/^\/vod\//, `/vod/variant/${variant}/`); } -function getTranscodeProfile(estimatedBandwidthBps?: number, saveData = false) { - if (saveData || (estimatedBandwidthBps && estimatedBandwidthBps <= 1_500_000)) { - return { bitrate: "512k", maxWidth: "640", maxHeight: "360" }; - } - - if (estimatedBandwidthBps && estimatedBandwidthBps <= 3_000_000) { - return { bitrate: "1200k", maxWidth: "960", maxHeight: "540" }; - } - - return { bitrate: "2500k", maxWidth: "1280", maxHeight: "720" }; -} - -function buildDirectUrl(apiHost: string, vodPath: string, variant: string): string { - const baseUrl = `${getDirectBaseUrl(apiHost)}${vodPath}`; - return appendQuery(baseUrl, { - variant: variant !== "main" ? variant : undefined, - }); -} - -function buildTranscodeUrl( +export function buildDirectUrl( apiHost: string, - config: FrigateConfig | undefined, vodPath: string, variant: string, - capabilities: PlaybackCapabilities, ): string { - const transcodeBase = getTranscodeBaseUrl(apiHost, config); - if (!transcodeBase) { - return buildDirectUrl(apiHost, vodPath, variant); + return `${getDirectBaseUrl(apiHost)}${buildVariantVodPath(vodPath, variant)}`; +} + +export function getFallbackVariantForPreference( + preference: RecordingPlaybackPreference, +): "main" | "sub" { + if (preference === "sub") { + return "sub"; } - const profile = getTranscodeProfile( - capabilities.estimatedBandwidthBps, - capabilities.saveData, - ); - - return appendQuery(`${transcodeBase}${vodPath}`, { - variant, - bitrate: profile.bitrate, - max_width: profile.maxWidth, - max_height: profile.maxHeight, - }); + return "main"; } export function chooseRecordingPlayback({ apiHost, - config, recordings, preference, vodPath, @@ -212,7 +227,6 @@ export function chooseRecordingPlayback({ const recordingsByVariant = groupRecordingsByVariant(recordings); const mainRecordings = recordingsByVariant.main ?? []; const subRecordings = recordingsByVariant.sub ?? []; - const transcodeAvailable = !!getTranscodeBaseUrl(apiHost, config); const estimatedBandwidthBps = capabilities.estimatedBandwidthBps ?? (capabilities.saveData ? 1_000_000 : 6_000_000); @@ -251,39 +265,11 @@ export function chooseRecordingPlayback({ } if (preference === "sub" && candidates.sub.recordings.length > 0) { - if (candidates.sub.playable) { - return { - mode: "direct", - variant: "sub", - url: buildDirectUrl(apiHost, vodPath, "sub"), - reason: "manual-sub", - }; - } - return { - mode: "transcoded", + mode: "direct", variant: "sub", - url: buildTranscodeUrl(apiHost, config, vodPath, "sub", capabilities), - reason: "manual-sub-transcoded", - }; - } - - if (preference === "transcoded") { - const targetVariant = candidates.sub.recordings.length > 0 ? "sub" : "main"; - if (!transcodeAvailable) { - return { - mode: "direct", - variant: targetVariant, - url: buildDirectUrl(apiHost, vodPath, targetVariant), - reason: "manual-transcoded-unavailable", - }; - } - - return { - mode: "transcoded", - variant: targetVariant, - url: buildTranscodeUrl(apiHost, config, vodPath, targetVariant, capabilities), - reason: "manual-transcoded", + url: buildDirectUrl(apiHost, vodPath, "sub"), + reason: "manual-sub", }; } @@ -305,20 +291,10 @@ export function chooseRecordingPlayback({ }; } - const transcodeVariant = candidates.sub.recordings.length > 0 ? "sub" : "main"; - if (!transcodeAvailable) { - return { - mode: "direct", - variant: transcodeVariant, - url: buildDirectUrl(apiHost, vodPath, transcodeVariant), - reason: "direct-fallback", - }; - } - return { - mode: "transcoded", - variant: transcodeVariant, - url: buildTranscodeUrl(apiHost, config, vodPath, transcodeVariant, capabilities), - reason: "transcode-fallback", + mode: "direct", + variant: "main", + url: buildDirectUrl(apiHost, vodPath, "main"), + reason: "direct-fallback", }; }