2024-03-02 15:10:37 -07:00
"""Image and video apis."""
2025-05-07 08:53:29 -05:00
import asyncio
2024-03-02 15:10:37 -07:00
import glob
import logging
2025-03-29 07:19:12 -05:00
import math
2024-03-02 15:10:37 -07:00
import os
import subprocess as sp
import time
from datetime import datetime , timedelta , timezone
2024-10-14 15:23:02 -06:00
from pathlib import Path as FilePath
2026-01-03 08:19:41 -07:00
from typing import Any
2024-03-02 15:10:37 -07:00
from urllib.parse import unquote
import cv2
import numpy as np
import pytz
2025-09-12 06:19:29 -05:00
from fastapi import APIRouter , Depends , Path , Query , Request , Response
2024-10-03 07:33:06 -06:00
from fastapi.responses import FileResponse , JSONResponse , StreamingResponse
2024-09-24 14:05:30 +01:00
from pathvalidate import sanitize_filename
2026-01-03 08:19:41 -07:00
from peewee import DoesNotExist , fn
2024-03-02 15:10:37 -07:00
from tzlocal import get_localzone_name
2025-11-26 15:07:28 -06:00
from frigate.api.auth import (
allow_any_authenticated ,
require_camera_access ,
2026-03-05 17:19:30 -06:00
require_role ,
2025-11-26 15:07:28 -06:00
)
2024-12-06 08:04:02 -06:00
from frigate.api.defs.query.media_query_parameters import (
2024-09-24 14:05:30 +01:00
Extension ,
MediaEventsSnapshotQueryParams ,
MediaLatestFrameQueryParams ,
MediaMjpegFeedQueryParams ,
)
from frigate.api.defs.tags import Tags
2025-03-14 07:10:47 -06:00
from frigate.camera.state import CameraState
2024-09-13 14:14:51 -06:00
from frigate.config import FrigateConfig
2026-03-22 19:33:04 +00:00
from frigate.config.camera.snapshots import SnapshotsConfig
2024-03-02 15:10:37 -07:00
from frigate.const import (
CACHE_DIR ,
2025-03-01 05:35:09 +01:00
INSTALL_DIR ,
2024-03-02 15:10:37 -07:00
MAX_SEGMENT_DURATION ,
2024-03-11 17:31:05 -06:00
PREVIEW_FRAME_TYPE ,
2024-03-02 15:10:37 -07:00
)
from frigate.models import Event , Previews , Recordings , Regions , ReviewSegment
2026-01-22 11:21:41 -06:00
from frigate.output.preview import get_most_recent_preview_frame
2025-03-11 21:31:05 -06:00
from frigate.track.object_processing import TrackedObjectProcessor
2026-03-22 19:33:04 +00:00
from frigate.util.file import (
get_event_snapshot_bytes ,
get_event_snapshot_path ,
get_event_thumbnail_bytes ,
load_event_snapshot_image ,
)
from frigate.util.image import get_image_from_recording , get_image_quality_params
2026-03-07 07:36:08 -06:00
from frigate.util.media import get_keyframe_before
2024-03-02 15:10:37 -07:00
logger = logging . getLogger ( __name__ )
2026-03-07 07:36:08 -06:00
2024-09-24 14:05:30 +01:00
router = APIRouter ( tags = [ Tags . media ])
2024-03-02 15:10:37 -07:00
2026-06-24 07:57:46 -05:00
def _resolve_cache_age ( max_cache_age : int ) -> int :
"""Return max_cache_age as an int.
When a media handler is invoked directly by another handler instead of
through its route, FastAPI doesn't resolve the Query() default and
max_cache_age arrives as the Query object; fall back to its int default.
"""
if isinstance ( max_cache_age , int ):
return max_cache_age
return max_cache_age . default
2025-09-12 06:19:29 -05:00
@router.get ( "/ {camera_name} " , dependencies = [ Depends ( require_camera_access )])
async def mjpeg_feed (
2024-09-24 14:05:30 +01:00
request : Request ,
camera_name : str ,
params : MediaMjpegFeedQueryParams = Depends (),
):
2024-03-02 15:10:37 -07:00
draw_options = {
2024-09-24 14:05:30 +01:00
"bounding_boxes" : params . bbox ,
"timestamp" : params . timestamp ,
"zones" : params . zones ,
"mask" : params . mask ,
"motion_boxes" : params . motion ,
"regions" : params . regions ,
2024-03-02 15:10:37 -07:00
}
2024-09-24 14:05:30 +01:00
if camera_name in request . app . frigate_config . cameras :
2024-03-02 15:10:37 -07:00
# return a multipart response
2024-10-03 07:33:06 -06:00
return StreamingResponse (
2024-03-02 15:10:37 -07:00
imagestream (
2024-09-24 14:05:30 +01:00
request . app . detected_frames_processor ,
2024-03-02 15:10:37 -07:00
camera_name ,
2024-09-24 14:05:30 +01:00
params . fps ,
params . height ,
2024-03-02 15:10:37 -07:00
draw_options ,
),
2024-09-24 14:05:30 +01:00
media_type = "multipart/x-mixed-replace;boundary=frame" ,
2024-03-02 15:10:37 -07:00
)
else :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2024-09-24 14:05:30 +01:00
def imagestream (
2024-11-30 18:22:36 -06:00
detected_frames_processor : TrackedObjectProcessor ,
camera_name : str ,
fps : int ,
height : int ,
2025-05-13 16:27:20 +02:00
draw_options : dict [ str , Any ],
2024-09-24 14:05:30 +01:00
):
2024-03-02 15:10:37 -07:00
while True :
# max out at specified FPS
time . sleep ( 1 / fps )
frame = detected_frames_processor . get_current_frame ( camera_name , draw_options )
if frame is None :
frame = np . zeros (( height , int ( height * 16 / 9 ), 3 ), np . uint8 )
width = int ( height * frame . shape [ 1 ] / frame . shape [ 0 ])
frame = cv2 . resize ( frame , dsize = ( width , height ), interpolation = cv2 . INTER_LINEAR )
ret , jpg = cv2 . imencode ( ".jpg" , frame , [ int ( cv2 . IMWRITE_JPEG_QUALITY ), 70 ])
yield (
b "--frame \r\n "
2024-09-24 14:05:30 +01:00
b "Content-Type: image/jpeg \r\n\r\n " + bytearray ( jpg . tobytes ()) + b " \r\n\r\n "
2024-03-02 15:10:37 -07:00
)
2026-03-22 19:33:04 +00:00
def _resolve_snapshot_settings (
snapshot_config : SnapshotsConfig , params : MediaEventsSnapshotQueryParams
) -> dict [ str , Any ]:
return {
"timestamp" : snapshot_config . timestamp
if params . timestamp is None
else bool ( params . timestamp ),
"bounding_box" : snapshot_config . bounding_box
if params . bbox is None
else bool ( params . bbox ),
"crop" : snapshot_config . crop if params . crop is None else bool ( params . crop ),
"height" : snapshot_config . height if params . height is None else params . height ,
"quality" : snapshot_config . quality
if params . quality is None
else params . quality ,
}
2025-09-12 06:19:29 -05:00
@router.get ( "/ {camera_name} /ptz/info" , dependencies = [ Depends ( require_camera_access )])
2025-03-14 07:25:48 -05:00
async def camera_ptz_info ( request : Request , camera_name : str ):
2024-09-24 14:05:30 +01:00
if camera_name in request . app . frigate_config . cameras :
2025-05-07 08:53:29 -05:00
# Schedule get_camera_info in the OnvifController's event loop
future = asyncio . run_coroutine_threadsafe (
request . app . onvif . get_camera_info ( camera_name ), request . app . onvif . loop
2024-09-24 14:05:30 +01:00
)
2025-05-07 08:53:29 -05:00
result = future . result ()
return JSONResponse ( content = result )
2024-03-02 15:10:37 -07:00
else :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2025-09-12 06:19:29 -05:00
@router.get (
2026-01-22 11:21:41 -06:00
"/ {camera_name} /latest. {extension} " ,
dependencies = [ Depends ( require_camera_access )],
description = "Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline." ,
2025-09-12 06:19:29 -05:00
)
async def latest_frame (
2024-09-24 14:05:30 +01:00
request : Request ,
camera_name : str ,
extension : Extension ,
params : MediaLatestFrameQueryParams = Depends (),
):
2024-11-30 18:22:36 -06:00
frame_processor : TrackedObjectProcessor = request . app . detected_frames_processor
2024-03-02 15:10:37 -07:00
draw_options = {
2024-09-24 14:05:30 +01:00
"bounding_boxes" : params . bbox ,
"timestamp" : params . timestamp ,
"zones" : params . zones ,
"mask" : params . mask ,
"motion_boxes" : params . motion ,
2025-07-07 09:33:19 -05:00
"paths" : params . paths ,
2024-09-24 14:05:30 +01:00
"regions" : params . regions ,
2024-03-02 15:10:37 -07:00
}
2026-03-22 19:33:04 +00:00
quality_params = get_image_quality_params ( extension . value , params . quality )
2024-03-02 15:10:37 -07:00
2026-05-03 15:54:20 -05:00
camera_config = request . app . frigate_config . cameras . get ( camera_name )
if camera_config is not None :
2024-11-30 18:22:36 -06:00
frame = frame_processor . get_current_frame ( camera_name , draw_options )
2026-05-03 15:54:20 -05:00
retry_interval = float ( camera_config . ffmpeg . retry_interval or 10 )
2024-03-02 15:10:37 -07:00
2026-01-22 11:21:41 -06:00
is_offline = False
2024-03-02 15:10:37 -07:00
if frame is None or datetime . now () . timestamp () > (
2024-11-30 18:22:36 -06:00
frame_processor . get_current_frame_time ( camera_name ) + retry_interval
2024-03-02 15:10:37 -07:00
):
2026-01-22 11:21:41 -06:00
last_frame_time = frame_processor . get_current_frame_time ( camera_name )
preview_path = get_most_recent_preview_frame (
camera_name , before = last_frame_time
)
2026-02-26 21:27:31 -07:00
2026-01-22 11:21:41 -06:00
if preview_path :
logger . debug ( f "Using most recent preview frame for { camera_name } " )
frame = cv2 . imread ( preview_path , cv2 . IMREAD_UNCHANGED )
if frame is not None :
is_offline = True
if frame is None or not is_offline :
logger . debug (
f "No live or preview frame available for { camera_name } . Using error image."
)
if request . app . camera_error_image is None :
error_image = glob . glob (
os . path . join ( INSTALL_DIR , "frigate/images/camera-error.jpg" )
2024-03-02 15:10:37 -07:00
)
2026-01-22 11:21:41 -06:00
if len ( error_image ) > 0 :
request . app . camera_error_image = cv2 . imread (
error_image [ 0 ], cv2 . IMREAD_UNCHANGED
)
frame = request . app . camera_error_image
2024-03-02 15:10:37 -07:00
2024-09-24 14:05:30 +01:00
height = int ( params . height or str ( frame . shape [ 0 ]))
2024-03-02 15:10:37 -07:00
width = int ( height * frame . shape [ 1 ] / frame . shape [ 0 ])
if frame is None :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to get valid frame" },
status_code = 500 ,
2024-03-02 15:10:37 -07:00
)
if height < 1 or width < 1 :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = "Invalid height / width requested :: {} / {} " . format (
height , width
),
status_code = 400 ,
2024-03-02 15:10:37 -07:00
)
frame = cv2 . resize ( frame , dsize = ( width , height ), interpolation = cv2 . INTER_AREA )
2025-08-16 22:20:21 -05:00
_ , img = cv2 . imencode ( f ". { extension . value } " , frame , quality_params )
2026-01-22 11:21:41 -06:00
headers = {
"Cache-Control" : "no-store" if not params . store else "private, max-age=60" ,
}
if is_offline :
headers [ "X-Frigate-Offline" ] = "true"
2024-09-24 08:50:20 -06:00
return Response (
content = img . tobytes (),
2025-08-22 07:04:30 -05:00
media_type = extension . get_mime_type (),
2026-01-22 11:21:41 -06:00
headers = headers ,
2024-09-24 14:05:30 +01:00
)
2025-08-17 18:26:18 -05:00
elif (
camera_name == "birdseye"
and request . app . frigate_config . birdseye . enabled
and request . app . frigate_config . birdseye . restream
):
2024-03-02 15:10:37 -07:00
frame = cv2 . cvtColor (
2024-11-30 18:22:36 -06:00
frame_processor . get_current_frame ( camera_name ),
2024-03-02 15:10:37 -07:00
cv2 . COLOR_YUV2BGR_I420 ,
)
2024-09-24 14:05:30 +01:00
height = int ( params . height or str ( frame . shape [ 0 ]))
2024-03-02 15:10:37 -07:00
width = int ( height * frame . shape [ 1 ] / frame . shape [ 0 ])
frame = cv2 . resize ( frame , dsize = ( width , height ), interpolation = cv2 . INTER_AREA )
2025-08-16 22:20:21 -05:00
_ , img = cv2 . imencode ( f ". { extension . value } " , frame , quality_params )
2024-09-24 08:50:20 -06:00
return Response (
content = img . tobytes (),
2025-08-22 07:04:30 -05:00
media_type = extension . get_mime_type (),
2024-12-20 08:17:51 -06:00
headers = {
"Cache-Control" : "no-store"
if not params . store
else "private, max-age=60" ,
},
2024-03-02 15:10:37 -07:00
)
else :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2025-09-12 06:19:29 -05:00
@router.get (
"/ {camera_name} /recordings/ {frame_time} /snapshot. {format} " ,
dependencies = [ Depends ( require_camera_access )],
)
async def get_snapshot_from_recording (
2024-09-24 14:05:30 +01:00
request : Request ,
camera_name : str ,
frame_time : float ,
format : str = Path ( enum = [ "png" , "jpg" ]),
height : int = None ,
):
if camera_name not in request . app . frigate_config . cameras :
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2025-03-28 20:37:11 -06:00
recording : Recordings | None = None
2024-03-02 15:10:37 -07:00
try :
2025-03-28 20:37:11 -06:00
recording = (
Recordings . select (
Recordings . path ,
Recordings . start_time ,
)
. where (
(
( frame_time >= Recordings . start_time )
& ( frame_time <= Recordings . end_time )
)
)
. where ( Recordings . camera == camera_name )
. order_by ( Recordings . start_time . desc ())
. limit ( 1 )
. get ()
)
except DoesNotExist :
# try again with a rounded frame time as it may be between
# the rounded segment start time
2025-03-29 07:19:12 -05:00
frame_time = math . ceil ( frame_time )
2025-03-28 20:37:11 -06:00
try :
recording = (
Recordings . select (
Recordings . path ,
Recordings . start_time ,
)
. where (
(
( frame_time >= Recordings . start_time )
& ( frame_time <= Recordings . end_time )
)
)
. where ( Recordings . camera == camera_name )
. order_by ( Recordings . start_time . desc ())
. limit ( 1 )
. get ()
)
except DoesNotExist :
pass
if recording is not None :
2024-03-02 15:10:37 -07:00
time_in_segment = frame_time - recording . start_time
2024-09-04 08:46:49 -05:00
codec = "png" if format == "png" else "mjpeg"
2025-01-13 14:46:46 +01:00
mime_type = "png" if format == "png" else "jpeg"
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-09-04 08:46:49 -05:00
image_data = get_image_from_recording (
2024-09-13 14:14:51 -06:00
config . ffmpeg , recording . path , time_in_segment , codec , height
2024-09-04 08:46:49 -05:00
)
2024-03-02 15:10:37 -07:00
2024-05-03 08:00:19 -06:00
if not image_data :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = (
2024-05-03 08:00:19 -06:00
{
"success" : False ,
"message" : f "Unable to parse frame at time { frame_time } " ,
}
),
2024-09-24 14:05:30 +01:00
status_code = 404 ,
2024-05-03 08:00:19 -06:00
)
2025-01-13 14:46:46 +01:00
return Response ( image_data , headers = { "Content-Type" : f "image/ { mime_type } " })
2025-03-28 20:37:11 -06:00
else :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
"message" : "Recording not found at {} " . format ( frame_time ),
},
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2024-05-03 08:00:19 -06:00
2025-09-12 06:19:29 -05:00
@router.post (
"/ {camera_name} /plus/ {frame_time} " , dependencies = [ Depends ( require_camera_access )]
)
async def submit_recording_snapshot_to_plus (
2024-09-24 14:05:30 +01:00
request : Request , camera_name : str , frame_time : str
):
if camera_name not in request . app . frigate_config . cameras :
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
2024-05-03 08:00:19 -06:00
)
frame_time = float ( frame_time )
recording_query = (
Recordings . select (
Recordings . path ,
Recordings . start_time ,
)
. where (
(
( frame_time >= Recordings . start_time )
& ( frame_time <= Recordings . end_time )
)
)
. where ( Recordings . camera == camera_name )
. order_by ( Recordings . start_time . desc ())
. limit ( 1 )
)
try :
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-05-03 08:00:19 -06:00
recording : Recordings = recording_query . get ()
time_in_segment = frame_time - recording . start_time
2024-09-13 14:14:51 -06:00
image_data = get_image_from_recording (
config . ffmpeg , recording . path , time_in_segment , "png"
)
2024-05-03 08:00:19 -06:00
if not image_data :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
"message" : f "Unable to parse frame at time { frame_time } " ,
},
status_code = 404 ,
2024-05-03 08:00:19 -06:00
)
nd = cv2 . imdecode ( np . frombuffer ( image_data , dtype = np . int8 ), cv2 . IMREAD_COLOR )
2026-06-24 07:17:23 -05:00
await asyncio . to_thread (
request . app . frigate_config . plus_api . upload_image , nd , camera_name
)
2024-05-03 08:00:19 -06:00
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : True ,
"message" : "Successfully submitted image." ,
},
status_code = 200 ,
2024-05-03 08:00:19 -06:00
)
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
"message" : "Recording not found at {} " . format ( frame_time ),
},
status_code = 404 ,
2024-05-03 08:00:19 -06:00
)
2024-03-02 15:10:37 -07:00
2025-05-07 17:31:24 -05:00
@router.get (
"/ {camera_name} /start/ {start_ts} /end/ {end_ts} /clip.mp4" ,
2025-09-12 06:19:29 -05:00
dependencies = [ Depends ( require_camera_access )],
2025-05-07 17:31:24 -05:00
description = "For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files." ,
)
2025-09-12 06:19:29 -05:00
async def recording_clip (
2024-09-24 14:05:30 +01:00
request : Request ,
camera_name : str ,
start_ts : float ,
end_ts : float ,
):
2024-10-14 15:23:02 -06:00
def run_download ( ffmpeg_cmd : list [ str ], file_path : str ):
with sp . Popen (
ffmpeg_cmd ,
stderr = sp . PIPE ,
stdout = sp . PIPE ,
text = False ,
) as ffmpeg :
while True :
2024-10-22 20:33:41 -06:00
data = ffmpeg . stdout . read ( 8192 )
2024-10-22 18:07:54 -06:00
if data is not None and len ( data ) > 0 :
2024-10-14 15:23:02 -06:00
yield data
else :
if ffmpeg . returncode and ffmpeg . returncode != 0 :
logger . error (
f "Failed to generate clip, ffmpeg logs: { ffmpeg . stderr . read () } "
)
else :
FilePath ( file_path ) . unlink ( missing_ok = True )
break
2024-03-02 15:10:37 -07:00
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 ())
)
2025-11-18 16:33:42 -06:00
if recordings . count () == 0 :
return JSONResponse (
content = {
"success" : False ,
"message" : "No recordings found for the specified time range" ,
},
status_code = 400 ,
)
2024-10-14 15:23:02 -06:00
file_name = sanitize_filename ( f "playlist_ { camera_name } _ { start_ts } - { end_ts } .txt" )
2025-03-01 05:35:09 +01:00
file_path = os . path . join ( CACHE_DIR , file_name )
2024-10-14 15:23:02 -06:00
with open ( file_path , "w" ) as file :
clip : Recordings
for clip in recordings :
file . write ( f "file ' { clip . path } ' \n " )
2025-05-14 16:44:06 -06:00
2024-10-14 15:23:02 -06:00
# if this is the starting clip, add an inpoint
if clip . start_time < start_ts :
file . write ( f "inpoint { int ( start_ts - clip . start_time ) } \n " )
2025-05-14 16:44:06 -06:00
2025-06-20 16:39:47 -06:00
# if this is the ending clip, add an outpoint
2024-10-14 15:23:02 -06:00
if clip . end_time > end_ts :
file . write ( f "outpoint { int ( end_ts - clip . start_time ) } \n " )
2024-04-27 10:27:23 -06:00
if len ( file_name ) > 1000 :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
"message" : "Filename exceeded max length of 1000" ,
},
status_code = 403 ,
2024-04-27 10:27:23 -06:00
)
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-09-13 14:14:51 -06:00
2024-10-14 15:23:02 -06:00
ffmpeg_cmd = [
config . ffmpeg . ffmpeg_path ,
"-hide_banner" ,
"-y" ,
"-protocol_whitelist" ,
"pipe,file" ,
"-f" ,
"concat" ,
"-safe" ,
"0" ,
"-i" ,
file_path ,
"-c" ,
"copy" ,
"-movflags" ,
"frag_keyframe+empty_moov" ,
"-f" ,
"mp4" ,
"pipe:" ,
]
2024-03-02 15:10:37 -07:00
2024-10-14 15:23:02 -06:00
return StreamingResponse (
run_download ( ffmpeg_cmd , file_path ),
2024-09-24 14:05:30 +01:00
media_type = "video/mp4" ,
2024-03-02 15:10:37 -07:00
)
2025-05-15 16:13:18 -06:00
@router.get (
"/vod/ {camera_name} /start/ {start_ts} /end/ {end_ts} " ,
2025-09-12 06:19:29 -05:00
dependencies = [ Depends ( require_camera_access )],
2025-05-15 16:13:18 -06:00
description = "Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback." ,
)
2025-12-07 12:58:33 -06:00
async def vod_ts (
camera_name : str ,
start_ts : float ,
end_ts : float ,
force_discontinuity : bool = False ,
):
logger . debug (
"VOD: Generating VOD for %s from %s to %s with force_discontinuity= %s " ,
camera_name ,
start_ts ,
end_ts ,
force_discontinuity ,
)
2024-03-02 15:10:37 -07:00
recordings = (
2025-05-14 16:44:06 -06:00
Recordings . select (
Recordings . path ,
Recordings . duration ,
Recordings . end_time ,
Recordings . start_time ,
)
2024-03-02 15:10:37 -07:00
. 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 ())
. iterator ()
)
clips = []
durations = []
2025-11-21 14:40:58 -07:00
min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame
2024-03-02 15:10:37 -07:00
max_duration_ms = MAX_SEGMENT_DURATION * 1000
recording : Recordings
for recording in recordings :
2025-12-07 12:58:33 -06:00
logger . debug (
"VOD: processing recording: %s start= %s end= %s duration= %s " ,
recording . path ,
recording . start_time ,
recording . end_time ,
recording . duration ,
)
2024-03-02 15:10:37 -07:00
clip = { "type" : "source" , "path" : recording . path }
duration = int ( recording . duration * 1000 )
2025-05-14 16:44:06 -06:00
# 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
2025-12-07 12:58:33 -06:00
logger . debug (
"VOD: applied clipFrom %s ms to %s " ,
inpoint ,
recording . path ,
)
2025-05-14 16:44:06 -06:00
# adjust end if recording.end_time is after end_ts
2024-03-02 15:10:37 -07:00
if recording . end_time > end_ts :
duration -= int (( recording . end_time - end_ts ) * 1000 )
2026-03-07 07:36:08 -06:00
# 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 %s ms for %s , duration now %s ms" ,
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 )
2025-11-21 14:40:58 -07:00
if duration < min_duration_ms :
# skip if the clip has no valid duration (too short to contain frames)
2025-12-07 12:58:33 -06:00
logger . debug (
"VOD: skipping recording %s - resulting duration %s ms too short" ,
recording . path ,
duration ,
)
2025-05-14 16:44:06 -06:00
continue
2024-08-11 07:32:17 -06:00
2025-11-21 14:40:58 -07:00
if min_duration_ms <= duration < max_duration_ms :
2024-03-02 15:10:37 -07:00
clip [ "keyFrameDurations" ] = [ duration ]
clips . append ( clip )
durations . append ( duration )
2025-12-07 12:58:33 -06:00
logger . debug (
"VOD: added clip %s duration_ms= %s clipFrom= %s " ,
recording . path ,
duration ,
clip . get ( "clipFrom" ),
)
2024-03-02 15:10:37 -07:00
else :
logger . warning ( f "Recording clip is missing or empty: { recording . path } " )
if not clips :
2024-07-16 10:56:09 -06:00
logger . error (
f "No recordings found for { camera_name } during the requested time range"
)
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
"message" : "No recordings found." ,
},
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
hour_ago = datetime . now () - timedelta ( hours = 1 )
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
2024-03-02 15:10:37 -07:00
"cache" : hour_ago . timestamp () > start_ts ,
2025-12-07 12:58:33 -06:00
"discontinuity" : force_discontinuity ,
2024-03-02 15:10:37 -07:00
"consistentSequenceMediaInfo" : True ,
"durations" : durations ,
"segment_duration" : max ( durations ),
"sequences" : [{ "clips" : clips }],
}
)
2025-05-15 16:13:18 -06:00
@router.get (
"/vod/ {year_month} / {day} / {hour} / {camera_name} " ,
2025-09-12 06:19:29 -05:00
dependencies = [ Depends ( require_camera_access )],
2025-05-15 16:13:18 -06:00
description = "Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback." ,
)
2025-09-28 08:08:52 -05:00
async def vod_hour_no_timezone ( year_month : str , day : int , hour : int , camera_name : str ):
2024-09-24 14:05:30 +01:00
"""VOD for specific hour. Uses the default timezone (UTC)."""
2025-09-28 08:08:52 -05:00
return await vod_hour (
2024-03-02 15:10:37 -07:00
year_month , day , hour , camera_name , get_localzone_name () . replace ( "/" , "," )
)
2025-05-15 16:13:18 -06:00
@router.get (
"/vod/ {year_month} / {day} / {hour} / {camera_name} / {tz_name} " ,
2025-09-12 06:19:29 -05:00
dependencies = [ Depends ( require_camera_access )],
2025-05-15 16:13:18 -06:00
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." ,
)
2025-09-28 08:08:52 -05:00
async def vod_hour (
year_month : str , day : int , hour : int , camera_name : str , tz_name : str
):
2024-03-02 15:10:37 -07:00
parts = year_month . split ( "-" )
start_date = (
2024-09-24 14:05:30 +01:00
datetime ( int ( parts [ 0 ]), int ( parts [ 1 ]), day , hour , tzinfo = timezone . utc )
2024-03-02 15:10:37 -07:00
- datetime . now ( pytz . timezone ( tz_name . replace ( "," , "/" ))) . utcoffset ()
)
end_date = start_date + timedelta ( hours = 1 ) - timedelta ( milliseconds = 1 )
start_ts = start_date . timestamp ()
end_ts = end_date . timestamp ()
2025-09-28 08:08:52 -05:00
return await vod_ts ( camera_name , start_ts , end_ts )
2024-03-02 15:10:37 -07:00
2025-05-15 16:13:18 -06:00
@router.get (
"/vod/event/ {event_id} " ,
2025-11-29 07:30:04 -06:00
dependencies = [ Depends ( allow_any_authenticated ())],
2025-05-15 16:13:18 -06:00
description = "Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback." ,
)
2025-09-12 06:19:29 -05:00
async def vod_event (
request : Request ,
2025-08-28 06:09:23 -06:00
event_id : str ,
padding : int = Query ( 0 , description = "Padding to apply to the vod." ),
):
2024-03-02 15:10:37 -07:00
try :
2024-09-24 14:05:30 +01:00
event : Event = Event . get ( Event . id == event_id )
2024-03-02 15:10:37 -07:00
except DoesNotExist :
2024-09-24 14:05:30 +01:00
logger . error ( f "Event not found: { event_id } " )
return JSONResponse (
content = {
"success" : False ,
"message" : "Event not found." ,
},
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2025-09-12 06:19:29 -05:00
await require_camera_access ( event . camera , request = request )
2024-03-02 15:10:37 -07:00
2025-08-28 06:09:23 -06:00
end_ts = (
datetime . now () . timestamp ()
if event . end_time is None
else ( event . end_time + padding )
2024-03-02 15:10:37 -07:00
)
2025-09-28 08:08:52 -05:00
vod_response = await vod_ts ( event . camera , event . start_time - padding , end_ts )
2025-08-28 06:09:23 -06:00
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
if (
event . start_time < datetime . now () . timestamp () - 300
and type ( vod_response ) is tuple
and len ( vod_response ) == 2
and vod_response [ 1 ] == 404
):
Event . update ( has_clip = False ) . where ( Event . id == event_id ) . execute ()
return vod_response
2024-03-02 15:10:37 -07:00
2025-12-07 12:58:33 -06:00
@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 (
camera_name : str ,
start_ts : float ,
end_ts : float ,
):
return await vod_ts ( camera_name , start_ts , end_ts , force_discontinuity = True )
2025-07-15 20:53:21 -06:00
@router.get (
"/events/ {event_id} /snapshot.jpg" ,
2026-03-22 19:33:04 +00:00
description = "Returns a snapshot image for the specified object id." ,
2025-07-15 20:53:21 -06:00
)
2025-09-12 06:19:29 -05:00
async def event_snapshot (
2024-09-24 14:05:30 +01:00
request : Request ,
event_id : str ,
params : MediaEventsSnapshotQueryParams = Depends (),
):
event_complete = False
jpg_bytes = None
2026-02-26 08:38:42 -07:00
frame_time = 0
2024-09-24 14:05:30 +01:00
try :
event = Event . get ( Event . id == event_id , Event . end_time != None )
event_complete = True
2025-09-12 06:19:29 -05:00
await require_camera_access ( event . camera , request = request )
2024-09-24 14:05:30 +01:00
if not event . has_snapshot :
return JSONResponse (
content = { "success" : False , "message" : "Snapshot not available" },
status_code = 404 ,
)
2026-03-22 19:33:04 +00:00
snapshot_settings = _resolve_snapshot_settings (
request . app . frigate_config . cameras [ event . camera ] . snapshots , params
)
jpg_bytes , frame_time = get_event_snapshot_bytes (
event ,
ext = "jpg" ,
timestamp = snapshot_settings [ "timestamp" ],
bounding_box = snapshot_settings [ "bounding_box" ],
crop = snapshot_settings [ "crop" ],
height = snapshot_settings [ "height" ],
quality = snapshot_settings [ "quality" ],
timestamp_style = request . app . frigate_config . cameras [
event . camera
] . timestamp_style ,
colormap = request . app . frigate_config . model . colormap ,
)
2024-09-24 14:05:30 +01:00
except DoesNotExist :
# see if the object is currently being tracked
try :
2025-03-14 07:10:47 -06:00
camera_states : list [ CameraState ] = (
request . app . detected_frames_processor . camera_states . values ()
)
2024-09-24 14:05:30 +01:00
for camera_state in camera_states :
if event_id in camera_state . tracked_objects :
tracked_obj = camera_state . tracked_objects . get ( event_id )
if tracked_obj is not None :
2026-03-22 19:33:04 +00:00
snapshot_settings = _resolve_snapshot_settings (
camera_state . camera_config . snapshots , params
)
2026-02-08 15:47:06 +01:00
jpg_bytes , frame_time = tracked_obj . get_img_bytes (
2025-03-14 07:10:47 -06:00
ext = "jpg" ,
2026-03-22 19:33:04 +00:00
timestamp = snapshot_settings [ "timestamp" ],
bounding_box = snapshot_settings [ "bounding_box" ],
crop = snapshot_settings [ "crop" ],
height = snapshot_settings [ "height" ],
quality = snapshot_settings [ "quality" ],
2024-09-24 14:05:30 +01:00
)
2025-09-12 06:19:29 -05:00
await require_camera_access ( camera_state . name , request = request )
2025-03-14 07:21:50 -06:00
except Exception :
2024-09-24 14:05:30 +01:00
return JSONResponse (
2025-03-14 07:21:50 -06:00
content = { "success" : False , "message" : "Ongoing event not found" },
2024-09-24 14:05:30 +01:00
status_code = 404 ,
)
2025-03-14 07:21:50 -06:00
except Exception :
2024-09-24 14:05:30 +01:00
return JSONResponse (
2025-03-14 07:21:50 -06:00
content = { "success" : False , "message" : "Unknown error occurred" },
2025-03-14 07:10:47 -06:00
status_code = 404 ,
2024-09-24 14:05:30 +01:00
)
if jpg_bytes is None :
return JSONResponse (
2025-03-14 07:10:47 -06:00
content = { "success" : False , "message" : "Live frame not available" },
status_code = 404 ,
2024-09-24 14:05:30 +01:00
)
headers = {
"Content-Type" : "image/jpeg" ,
"Cache-Control" : "private, max-age=31536000" if event_complete else "no-store" ,
2026-02-26 08:38:42 -07:00
"X-Frame-Time" : str ( frame_time ),
2024-09-24 14:05:30 +01:00
}
if params . download :
headers [ "Content-Disposition" ] = f "attachment; filename=snapshot- { event_id } .jpg"
2024-09-24 08:50:20 -06:00
return Response (
jpg_bytes ,
2024-09-24 14:05:30 +01:00
media_type = "image/jpeg" ,
headers = headers ,
)
2025-11-26 15:07:28 -06:00
@router.get (
"/events/ {event_id} /thumbnail. {extension} " ,
)
2025-09-12 06:19:29 -05:00
async def event_thumbnail (
2024-09-24 08:27:10 -06:00
request : Request ,
event_id : str ,
2025-08-22 07:04:30 -05:00
extension : Extension ,
2024-09-24 08:27:10 -06:00
max_cache_age : int = Query (
2592000 , description = "Max cache age in seconds. Default 30 days in seconds."
),
format : str = Query ( default = "ios" , enum = [ "ios" , "android" ]),
):
thumbnail_bytes = None
event_complete = False
try :
2025-02-18 07:46:29 -07:00
event : Event = Event . get ( Event . id == event_id )
2025-09-12 06:19:29 -05:00
await require_camera_access ( event . camera , request = request )
2024-09-24 08:27:10 -06:00
if event . end_time is not None :
event_complete = True
2025-02-18 07:46:29 -07:00
thumbnail_bytes = get_event_thumbnail_bytes ( event )
2024-09-24 08:27:10 -06:00
except DoesNotExist :
2025-02-18 07:46:29 -07:00
thumbnail_bytes = None
if thumbnail_bytes is None :
2024-09-24 08:27:10 -06:00
# see if the object is currently being tracked
try :
camera_states = request . app . detected_frames_processor . camera_states . values ()
for camera_state in camera_states :
if event_id in camera_state . tracked_objects :
tracked_obj = camera_state . tracked_objects . get ( event_id )
if tracked_obj is not None :
2026-03-31 13:45:04 -05:00
await require_camera_access ( camera_state . name , request = request )
2025-08-22 07:04:30 -05:00
thumbnail_bytes = tracked_obj . get_thumbnail ( extension . value )
2024-09-24 08:27:10 -06:00
except Exception :
return JSONResponse (
content = { "success" : False , "message" : "Event not found" },
status_code = 404 ,
)
if thumbnail_bytes is None :
return JSONResponse (
content = { "success" : False , "message" : "Event not found" },
status_code = 404 ,
)
2026-03-08 15:15:12 -05:00
img_as_np = np . frombuffer ( thumbnail_bytes , dtype = np . uint8 )
img = cv2 . imdecode ( img_as_np , flags = 1 )
2024-09-24 08:27:10 -06:00
# android notifications prefer a 2:1 ratio
if format == "android" :
2026-03-08 15:15:12 -05:00
img = cv2 . copyMakeBorder (
2024-09-24 08:27:10 -06:00
img ,
0 ,
0 ,
int ( img . shape [ 1 ] * 0.5 ),
int ( img . shape [ 1 ] * 0.5 ),
cv2 . BORDER_CONSTANT ,
( 0 , 0 , 0 ),
)
2025-02-18 07:46:29 -07:00
2026-03-08 15:15:12 -05:00
quality_params = None
if extension in ( Extension . jpg , Extension . jpeg ):
quality_params = [ int ( cv2 . IMWRITE_JPEG_QUALITY ), 70 ]
elif extension == Extension . webp :
quality_params = [ int ( cv2 . IMWRITE_WEBP_QUALITY ), 60 ]
2025-02-18 07:46:29 -07:00
2026-03-08 15:15:12 -05:00
_ , encoded = cv2 . imencode ( f ". { extension . value } " , img , quality_params )
thumbnail_bytes = encoded . tobytes ()
2024-09-24 08:27:10 -06:00
2024-09-24 08:50:20 -06:00
return Response (
thumbnail_bytes ,
2025-08-22 07:04:30 -05:00
media_type = extension . get_mime_type (),
2024-09-24 08:27:10 -06:00
headers = {
2026-06-24 07:57:46 -05:00
"Cache-Control" : f "private, max-age= { _resolve_cache_age ( max_cache_age ) } "
2024-09-24 08:27:10 -06:00
if event_complete
else "no-store" ,
},
)
2025-09-12 06:19:29 -05:00
@router.get ( "/ {camera_name} /grid.jpg" , dependencies = [ Depends ( require_camera_access )])
2024-09-24 14:05:30 +01:00
def grid_snapshot (
request : Request , camera_name : str , color : str = "green" , font_scale : float = 0.5
):
if camera_name in request . app . frigate_config . cameras :
detect = request . app . frigate_config . cameras [ camera_name ] . detect
2024-11-30 18:22:36 -06:00
frame_processor : TrackedObjectProcessor = request . app . detected_frames_processor
frame = frame_processor . get_current_frame ( camera_name , {})
2024-03-02 15:10:37 -07:00
retry_interval = float (
2024-09-24 14:05:30 +01:00
request . app . frigate_config . cameras . get ( camera_name ) . ffmpeg . retry_interval
2024-03-02 15:10:37 -07:00
or 10
)
if frame is None or datetime . now () . timestamp () > (
2024-11-30 18:22:36 -06:00
frame_processor . get_current_frame_time ( camera_name ) + retry_interval
2024-03-02 15:10:37 -07:00
):
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to get valid frame" },
status_code = 500 ,
2024-03-02 15:10:37 -07:00
)
try :
grid = (
Regions . select ( Regions . grid )
. where ( Regions . camera == camera_name )
. get ()
. grid
)
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to get region grid" },
status_code = 500 ,
2024-03-02 15:10:37 -07:00
)
2024-09-24 14:05:30 +01:00
color_arg = color . lower ()
2024-03-02 15:10:37 -07:00
if color_arg == "red" :
draw_color = ( 0 , 0 , 255 )
elif color_arg == "blue" :
draw_color = ( 255 , 0 , 0 )
elif color_arg == "black" :
draw_color = ( 0 , 0 , 0 )
elif color_arg == "white" :
draw_color = ( 255 , 255 , 255 )
else :
2024-09-24 14:05:30 +01:00
draw_color = ( 0 , 255 , 0 ) # green
2024-03-02 15:10:37 -07:00
grid_size = len ( grid )
grid_coef = 1.0 / grid_size
width = detect . width
height = detect . height
for x in range ( grid_size ):
for y in range ( grid_size ):
cell = grid [ x ][ y ]
if len ( cell [ "sizes" ]) == 0 :
continue
std_dev = round ( cell [ "std_dev" ] * width , 2 )
mean = round ( cell [ "mean" ] * width , 2 )
cv2 . rectangle (
frame ,
( int ( x * grid_coef * width ), int ( y * grid_coef * height )),
(
int (( x + 1 ) * grid_coef * width ),
int (( y + 1 ) * grid_coef * height ),
),
draw_color ,
2 ,
)
cv2 . putText (
frame ,
f "#: { len ( cell [ 'sizes' ]) } " ,
(
int ( x * grid_coef * width + 10 ),
int (( y * grid_coef + 0.02 ) * height ),
),
cv2 . FONT_HERSHEY_SIMPLEX ,
2024-09-24 14:05:30 +01:00
fontScale = font_scale ,
2024-03-02 15:10:37 -07:00
color = draw_color ,
thickness = 2 ,
)
cv2 . putText (
frame ,
f "std: { std_dev } " ,
(
int ( x * grid_coef * width + 10 ),
int (( y * grid_coef + 0.05 ) * height ),
),
cv2 . FONT_HERSHEY_SIMPLEX ,
2024-09-24 14:05:30 +01:00
fontScale = font_scale ,
2024-03-02 15:10:37 -07:00
color = draw_color ,
thickness = 2 ,
)
cv2 . putText (
frame ,
f "avg: { mean } " ,
(
int ( x * grid_coef * width + 10 ),
int (( y * grid_coef + 0.08 ) * height ),
),
cv2 . FONT_HERSHEY_SIMPLEX ,
2024-09-24 14:05:30 +01:00
fontScale = font_scale ,
2024-03-02 15:10:37 -07:00
color = draw_color ,
thickness = 2 ,
)
ret , jpg = cv2 . imencode ( ".jpg" , frame , [ int ( cv2 . IMWRITE_JPEG_QUALITY ), 70 ])
2024-09-24 14:05:30 +01:00
2024-09-24 08:50:20 -06:00
return Response (
2024-10-31 06:31:01 -06:00
jpg . tobytes (),
2024-09-24 14:05:30 +01:00
media_type = "image/jpeg" ,
headers = { "Cache-Control" : "no-store" },
)
2024-03-02 15:10:37 -07:00
else :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2026-03-05 17:19:30 -06:00
@router.delete (
2026-03-31 13:45:04 -05:00
"/ {camera_name} /region_grid" , dependencies = [ Depends ( require_role ([ "admin" ]))]
2026-03-05 17:19:30 -06:00
)
def clear_region_grid ( request : Request , camera_name : str ):
"""Clear the region grid for a camera."""
if camera_name not in request . app . frigate_config . cameras :
return JSONResponse (
content = { "success" : False , "message" : "Camera not found" },
status_code = 404 ,
)
Regions . delete () . where ( Regions . camera == camera_name ) . execute ()
return JSONResponse (
content = { "success" : True , "message" : "Region grid cleared" },
)
2025-11-26 15:07:28 -06:00
@router.get (
"/events/ {event_id} /snapshot-clean.webp" ,
)
2026-03-18 19:54:31 -05:00
async def event_snapshot_clean ( request : Request , event_id : str , download : bool = False ):
2025-10-14 08:08:41 -05:00
webp_bytes = None
2026-03-22 19:33:04 +00:00
event_complete = False
2024-03-19 07:54:25 -04:00
try :
2024-09-24 14:05:30 +01:00
event = Event . get ( Event . id == event_id )
2026-03-22 19:33:04 +00:00
event_complete = event . end_time is not None
2026-03-18 19:54:31 -05:00
await require_camera_access ( event . camera , request = request )
2024-09-24 14:05:30 +01:00
snapshot_config = request . app . frigate_config . cameras [ event . camera ] . snapshots
2024-03-19 07:54:25 -04:00
if not ( snapshot_config . enabled and event . has_snapshot ):
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
2026-03-22 19:33:04 +00:00
"message" : "Snapshots must be enabled in the config" ,
2024-09-24 14:05:30 +01:00
},
status_code = 404 ,
2024-03-19 07:54:25 -04:00
)
if event . end_time is None :
# see if the object is currently being tracked
try :
camera_states = (
2024-09-24 14:05:30 +01:00
request . app . detected_frames_processor . camera_states . values ()
2024-03-19 07:54:25 -04:00
)
for camera_state in camera_states :
2024-09-24 14:05:30 +01:00
if event_id in camera_state . tracked_objects :
tracked_obj = camera_state . tracked_objects . get ( event_id )
2024-03-19 07:54:25 -04:00
if tracked_obj is not None :
2025-10-14 08:08:41 -05:00
webp_bytes = tracked_obj . get_clean_webp ()
2024-03-19 07:54:25 -04:00
break
except Exception :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Event not found" },
status_code = 404 ,
2024-03-19 07:54:25 -04:00
)
elif not event . has_snapshot :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Snapshot not available" },
status_code = 404 ,
2024-03-19 07:54:25 -04:00
)
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Event not found" }, status_code = 404
2024-03-19 07:54:25 -04:00
)
2025-10-14 08:08:41 -05:00
if webp_bytes is None :
2024-03-19 07:54:25 -04:00
try :
2026-03-22 19:33:04 +00:00
image_path , is_clean_snapshot = get_event_snapshot_path (
event , clean_only = True
2025-10-14 08:08:41 -05:00
)
2026-03-22 19:33:04 +00:00
if not is_clean_snapshot or image_path is None :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
"message" : "Clean snapshot not available" ,
},
status_code = 404 ,
2024-03-19 07:54:25 -04:00
)
2026-03-22 19:33:04 +00:00
if image_path . endswith ( ".webp" ):
with open ( image_path , "rb" ) as image_file :
webp_bytes = image_file . read ()
else :
image = load_event_snapshot_image ( event , clean_only = True )[ 0 ]
if image is None :
return JSONResponse (
content = {
"success" : False ,
"message" : "Unable to load clean snapshot for event" ,
},
status_code = 400 ,
)
ret , webp_data = cv2 . imencode (
".webp" , image , get_image_quality_params ( "webp" , None )
)
if not ret :
return JSONResponse (
content = {
"success" : False ,
"message" : "Unable to convert snapshot to webp" ,
},
status_code = 400 ,
)
webp_bytes = webp_data . tobytes ()
2024-03-19 07:54:25 -04:00
except Exception :
2025-10-14 08:08:41 -05:00
logger . error ( f "Unable to load clean snapshot for event: { event . id } " )
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = {
"success" : False ,
2025-10-14 08:08:41 -05:00
"message" : "Unable to load clean snapshot for event" ,
2024-09-24 14:05:30 +01:00
},
status_code = 400 ,
2024-03-19 07:54:25 -04:00
)
2024-09-24 14:05:30 +01:00
headers = {
2025-10-14 08:08:41 -05:00
"Content-Type" : "image/webp" ,
2026-03-22 19:33:04 +00:00
"Cache-Control" : "private, max-age=31536000" if event_complete else "no-cache" ,
2024-09-24 14:05:30 +01:00
}
2024-03-19 07:54:25 -04:00
if download :
2024-09-24 14:05:30 +01:00
headers [ "Content-Disposition" ] = (
2025-10-14 08:08:41 -05:00
f "attachment; filename=snapshot- { event_id } -clean.webp"
2024-03-19 07:54:25 -04:00
)
2024-09-24 14:05:30 +01:00
2024-09-24 08:50:20 -06:00
return Response (
2025-10-14 08:08:41 -05:00
webp_bytes ,
media_type = "image/webp" ,
2024-09-24 14:05:30 +01:00
headers = headers ,
)
2024-03-19 07:54:25 -04:00
2025-11-26 15:07:28 -06:00
@router.get (
2026-03-18 19:54:31 -05:00
"/events/ {event_id} /clip.mp4" ,
2025-11-26 15:07:28 -06:00
)
2025-09-28 08:08:52 -05:00
async def event_clip (
2025-08-25 12:33:17 -06:00
request : Request ,
event_id : str ,
padding : int = Query ( 0 , description = "Padding to apply to clip." ),
):
2024-03-02 15:10:37 -07:00
try :
2024-09-24 14:05:30 +01:00
event : Event = Event . get ( Event . id == event_id )
2024-03-02 15:10:37 -07:00
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Event not found" }, status_code = 404
2024-03-02 15:10:37 -07:00
)
2026-03-18 19:54:31 -05:00
await require_camera_access ( event . camera , request = request )
2024-03-02 15:10:37 -07:00
if not event . has_clip :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Clip not available" }, status_code = 404
2024-03-02 15:10:37 -07:00
)
2025-08-25 12:33:17 -06:00
end_ts = (
datetime . now () . timestamp ()
if event . end_time is None
else event . end_time + padding
)
2025-09-28 08:08:52 -05:00
return await recording_clip (
request , event . camera , event . start_time - padding , end_ts
)
2024-03-02 15:10:37 -07:00
2026-03-23 15:49:41 -06:00
@router.get (
"/review/ {review_id} /clip.mp4" ,
)
async def review_clip (
request : Request ,
review_id : str ,
padding : int = Query ( 0 , description = "Padding to apply to clip." ),
):
try :
review : ReviewSegment = ReviewSegment . get ( ReviewSegment . id == review_id )
except DoesNotExist :
return JSONResponse (
content = { "success" : False , "message" : "Review not found" }, status_code = 404
)
await require_camera_access ( review . camera , request = request )
end_ts = (
datetime . now () . timestamp ()
if review . end_time is None
else review . end_time + padding
)
return await recording_clip (
request , review . camera , review . start_time - padding , end_ts
)
2025-11-26 15:07:28 -06:00
@router.get (
2026-03-18 19:54:31 -05:00
"/events/ {event_id} /preview.gif" ,
2025-11-26 15:07:28 -06:00
)
2026-03-18 19:54:31 -05:00
async def event_preview ( request : Request , event_id : str ):
2024-03-02 15:10:37 -07:00
try :
2024-09-24 14:05:30 +01:00
event : Event = Event . get ( Event . id == event_id )
2024-03-02 15:10:37 -07:00
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Event not found" }, status_code = 404
2024-03-02 15:10:37 -07:00
)
2026-03-18 19:54:31 -05:00
await require_camera_access ( event . camera , request = request )
2024-03-02 15:10:37 -07:00
start_ts = event . start_time
end_ts = start_ts + (
min ( event . end_time - event . start_time , 20 ) if event . end_time else 20
)
2026-06-24 07:17:23 -05:00
return await preview_gif ( request , event . camera , start_ts , end_ts )
2024-03-02 15:10:37 -07:00
2025-09-12 06:19:29 -05:00
@router.get (
"/ {camera_name} /start/ {start_ts} /end/ {end_ts} /preview.gif" ,
dependencies = [ Depends ( require_camera_access )],
)
2026-06-24 07:17:23 -05:00
async def preview_gif (
2024-09-24 14:05:30 +01:00
request : Request ,
camera_name : str ,
start_ts : float ,
end_ts : float ,
max_cache_age : int = Query (
2592000 , description = "Max cache age in seconds. Default 30 days in seconds."
),
):
2024-03-02 15:10:37 -07:00
if datetime . fromtimestamp ( start_ts ) < datetime . now () . replace ( minute = 0 , second = 0 ):
# has preview mp4
2026-03-08 15:14:44 -05:00
try :
preview : Previews = (
Previews . select (
Previews . camera ,
Previews . path ,
Previews . duration ,
Previews . start_time ,
Previews . end_time ,
)
. where (
Previews . start_time . between ( start_ts , end_ts )
| Previews . end_time . between ( start_ts , end_ts )
| (( start_ts > Previews . start_time ) & ( end_ts < Previews . end_time ))
)
. where ( Previews . camera == camera_name )
. limit ( 1 )
. get ()
2024-03-02 15:10:37 -07:00
)
2026-03-08 15:14:44 -05:00
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Preview not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
diff = start_ts - preview . start_time
minutes = int ( diff / 60 )
seconds = int ( diff % 60 )
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-03-02 15:10:37 -07:00
ffmpeg_cmd = [
2024-09-13 14:14:51 -06:00
config . ffmpeg . ffmpeg_path ,
2024-03-02 15:10:37 -07:00
"-hide_banner" ,
"-loglevel" ,
"warning" ,
"-ss" ,
f "00: { minutes } : { seconds } " ,
"-t" ,
f " { end_ts - start_ts } " ,
"-i" ,
preview . path ,
"-r" ,
"8" ,
"-vf" ,
"setpts=0.12*PTS" ,
"-loop" ,
"0" ,
"-c:v" ,
"gif" ,
"-f" ,
"gif" ,
"-" ,
]
2026-06-24 07:17:23 -05:00
process = await asyncio . to_thread (
sp . run ,
2024-03-02 15:10:37 -07:00
ffmpeg_cmd ,
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to create preview gif" },
status_code = 500 ,
2024-03-02 15:10:37 -07:00
)
gif_bytes = process . stdout
else :
# need to generate from existing images
preview_dir = os . path . join ( CACHE_DIR , "preview_frames" )
2026-03-06 14:45:39 -06:00
if not os . path . isdir ( preview_dir ):
return JSONResponse (
content = { "success" : False , "message" : "Preview not found" },
status_code = 404 ,
)
2026-03-23 10:22:52 -06:00
file_start = f "preview_ { camera_name } -"
start_file = f " { file_start }{ start_ts } . { PREVIEW_FRAME_TYPE } "
end_file = f " { file_start }{ end_ts } . { PREVIEW_FRAME_TYPE } "
2026-04-30 11:53:34 -06:00
camera_files = [
entry . name
for entry in os . scandir ( preview_dir )
if entry . name . startswith ( file_start )
]
camera_files . sort ()
2024-03-02 15:10:37 -07:00
selected_previews = []
2026-04-30 11:53:34 -06:00
for file in camera_files :
2024-03-02 15:10:37 -07:00
if file < start_file :
continue
if file > end_file :
break
selected_previews . append ( f "file ' { os . path . join ( preview_dir , file ) } '" )
selected_previews . append ( "duration 0.12" )
if not selected_previews :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Preview not found" },
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
last_file = selected_previews [ - 2 ]
selected_previews . append ( last_file )
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-03-02 15:10:37 -07:00
ffmpeg_cmd = [
2024-09-13 14:14:51 -06:00
config . ffmpeg . ffmpeg_path ,
2024-03-02 15:10:37 -07:00
"-hide_banner" ,
"-loglevel" ,
"warning" ,
"-f" ,
"concat" ,
"-y" ,
"-protocol_whitelist" ,
"pipe,file" ,
"-safe" ,
"0" ,
"-i" ,
"/dev/stdin" ,
"-loop" ,
"0" ,
"-c:v" ,
"gif" ,
"-f" ,
"gif" ,
"-" ,
]
2026-06-24 07:17:23 -05:00
process = await asyncio . to_thread (
sp . run ,
2024-03-02 15:10:37 -07:00
ffmpeg_cmd ,
input = str . encode ( " \n " . join ( selected_previews )),
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to create preview gif" },
status_code = 500 ,
2024-03-02 15:10:37 -07:00
)
gif_bytes = process . stdout
2024-09-24 08:50:20 -06:00
return Response (
gif_bytes ,
2024-09-24 14:05:30 +01:00
media_type = "image/gif" ,
headers = {
2026-06-24 07:57:46 -05:00
"Cache-Control" : f "private, max-age= { _resolve_cache_age ( max_cache_age ) } " ,
2024-09-24 14:05:30 +01:00
"Content-Type" : "image/gif" ,
},
)
2024-03-02 15:10:37 -07:00
2025-09-12 06:19:29 -05:00
@router.get (
"/ {camera_name} /start/ {start_ts} /end/ {end_ts} /preview.mp4" ,
dependencies = [ Depends ( require_camera_access )],
)
2026-06-24 07:17:23 -05:00
async def preview_mp4 (
2024-09-24 14:05:30 +01:00
request : Request ,
camera_name : str ,
start_ts : float ,
end_ts : float ,
max_cache_age : int = Query (
604800 , description = "Max cache age in seconds. Default 7 days in seconds."
),
):
file_name = sanitize_filename ( f "preview_ { camera_name } _ { start_ts } - { end_ts } .mp4" )
2024-04-27 10:27:23 -06:00
if len ( file_name ) > 1000 :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = (
2024-04-27 10:27:23 -06:00
{
"success" : False ,
"message" : "Filename exceeded max length of 1000 characters." ,
}
),
2024-09-24 14:05:30 +01:00
status_code = 403 ,
2024-04-27 10:27:23 -06:00
)
2024-04-19 16:11:41 -06:00
path = os . path . join ( CACHE_DIR , file_name )
2024-04-18 21:34:57 -06:00
if datetime . fromtimestamp ( start_ts ) < datetime . now () . replace ( minute = 0 , second = 0 ):
# has preview mp4
2024-04-19 16:11:41 -06:00
try :
preview : Previews = (
Previews . select (
Previews . camera ,
Previews . path ,
Previews . duration ,
Previews . start_time ,
Previews . end_time ,
)
. where (
Previews . start_time . between ( start_ts , end_ts )
| Previews . end_time . between ( start_ts , end_ts )
| (( start_ts > Previews . start_time ) & ( end_ts < Previews . end_time ))
)
. where ( Previews . camera == camera_name )
. limit ( 1 )
. get ()
2024-04-18 21:34:57 -06:00
)
2024-04-19 16:11:41 -06:00
except DoesNotExist :
preview = None
2024-04-18 21:34:57 -06:00
if not preview :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Preview not found" },
status_code = 404 ,
2024-04-18 21:34:57 -06:00
)
diff = start_ts - preview . start_time
minutes = int ( diff / 60 )
seconds = int ( diff % 60 )
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-04-18 21:34:57 -06:00
ffmpeg_cmd = [
2024-09-13 14:14:51 -06:00
config . ffmpeg . ffmpeg_path ,
2024-04-18 21:34:57 -06:00
"-hide_banner" ,
"-loglevel" ,
"warning" ,
2024-04-19 16:11:41 -06:00
"-y" ,
2024-04-18 21:34:57 -06:00
"-ss" ,
f "00: { minutes } : { seconds } " ,
"-t" ,
f " { end_ts - start_ts } " ,
"-i" ,
preview . path ,
"-r" ,
"8" ,
"-vf" ,
"setpts=0.12*PTS" ,
"-c:v" ,
2024-04-19 16:11:41 -06:00
"libx264" ,
"-movflags" ,
"+faststart" ,
path ,
2024-04-18 21:34:57 -06:00
]
2026-06-24 07:17:23 -05:00
process = await asyncio . to_thread (
sp . run ,
2024-04-18 21:34:57 -06:00
ffmpeg_cmd ,
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to create preview gif" },
status_code = 500 ,
2024-04-18 21:34:57 -06:00
)
else :
# need to generate from existing images
preview_dir = os . path . join ( CACHE_DIR , "preview_frames" )
2026-03-06 14:45:39 -06:00
if not os . path . isdir ( preview_dir ):
return JSONResponse (
content = { "success" : False , "message" : "Preview not found" },
status_code = 404 ,
)
2026-03-23 10:22:52 -06:00
file_start = f "preview_ { camera_name } -"
start_file = f " { file_start }{ start_ts } . { PREVIEW_FRAME_TYPE } "
end_file = f " { file_start }{ end_ts } . { PREVIEW_FRAME_TYPE } "
2026-04-30 11:53:34 -06:00
camera_files = [
entry . name
for entry in os . scandir ( preview_dir )
if entry . name . startswith ( file_start )
]
camera_files . sort ()
2024-04-18 21:34:57 -06:00
selected_previews = []
2026-04-30 11:53:34 -06:00
for file in camera_files :
2024-04-18 21:34:57 -06:00
if file < start_file :
continue
if file > end_file :
break
selected_previews . append ( f "file ' { os . path . join ( preview_dir , file ) } '" )
selected_previews . append ( "duration 0.12" )
if not selected_previews :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Preview not found" },
status_code = 404 ,
2024-04-18 21:34:57 -06:00
)
last_file = selected_previews [ - 2 ]
selected_previews . append ( last_file )
2024-09-24 14:05:30 +01:00
config : FrigateConfig = request . app . frigate_config
2024-04-18 21:34:57 -06:00
ffmpeg_cmd = [
2024-09-13 14:14:51 -06:00
config . ffmpeg . ffmpeg_path ,
2024-04-18 21:34:57 -06:00
"-hide_banner" ,
"-loglevel" ,
"warning" ,
"-f" ,
"concat" ,
"-y" ,
"-protocol_whitelist" ,
"pipe,file" ,
"-safe" ,
"0" ,
"-i" ,
"/dev/stdin" ,
"-c:v" ,
"libx264" ,
2024-04-19 16:11:41 -06:00
"-movflags" ,
"+faststart" ,
path ,
2024-04-18 21:34:57 -06:00
]
2026-06-24 07:17:23 -05:00
process = await asyncio . to_thread (
sp . run ,
2024-04-18 21:34:57 -06:00
ffmpeg_cmd ,
input = str . encode ( " \n " . join ( selected_previews )),
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = { "success" : False , "message" : "Unable to create preview gif" },
status_code = 500 ,
2024-04-18 21:34:57 -06:00
)
2024-09-24 14:05:30 +01:00
headers = {
"Content-Description" : "File Transfer" ,
2026-06-24 07:57:46 -05:00
"Cache-Control" : f "private, max-age= { _resolve_cache_age ( max_cache_age ) } " ,
2024-09-24 14:05:30 +01:00
"Content-Type" : "video/mp4" ,
"Content-Length" : str ( os . path . getsize ( path )),
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
"X-Accel-Redirect" : f "/cache/ { file_name } " ,
}
return FileResponse (
path ,
media_type = "video/mp4" ,
filename = file_name ,
headers = headers ,
2024-04-19 16:11:41 -06:00
)
2024-04-18 21:34:57 -06:00
2026-03-18 19:54:31 -05:00
@router.get ( "/review/ {event_id} /preview" )
async def review_preview (
2024-09-24 14:05:30 +01:00
request : Request ,
event_id : str ,
format : str = Query ( default = "gif" , enum = [ "gif" , "mp4" ]),
):
2024-03-02 15:10:37 -07:00
try :
2024-09-24 14:05:30 +01:00
review : ReviewSegment = ReviewSegment . get ( ReviewSegment . id == event_id )
2024-03-02 15:10:37 -07:00
except DoesNotExist :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = ({ "success" : False , "message" : "Review segment not found" }),
status_code = 404 ,
2024-03-02 15:10:37 -07:00
)
2026-03-18 19:54:31 -05:00
await require_camera_access ( review . camera , request = request )
2024-03-02 15:10:37 -07:00
padding = 8
start_ts = review . start_time - padding
2024-04-11 06:42:16 -06:00
end_ts = (
review . end_time + padding if review . end_time else datetime . now () . timestamp ()
)
2024-04-18 21:34:57 -06:00
if format == "gif" :
2026-06-24 07:17:23 -05:00
return await preview_gif ( request , review . camera , start_ts , end_ts )
2024-04-18 21:34:57 -06:00
else :
2026-06-24 07:17:23 -05:00
return await preview_mp4 ( request , review . camera , start_ts , end_ts )
2024-03-02 15:10:37 -07:00
2025-11-26 15:07:28 -06:00
@router.get (
2026-03-18 19:54:31 -05:00
"/preview/ {file_name} /thumbnail.jpg" ,
dependencies = [ Depends ( allow_any_authenticated ())],
2025-11-26 15:07:28 -06:00
)
@router.get (
2026-03-18 19:54:31 -05:00
"/preview/ {file_name} /thumbnail.webp" ,
dependencies = [ Depends ( allow_any_authenticated ())],
2025-11-26 15:07:28 -06:00
)
2026-03-19 09:42:33 -05:00
async def preview_thumbnail ( request : Request , file_name : str ):
2024-03-11 17:31:05 -06:00
"""Get a thumbnail from the cached preview frames."""
2024-04-27 10:27:23 -06:00
if len ( file_name ) > 1000 :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = (
2024-04-27 10:27:23 -06:00
{ "success" : False , "message" : "Filename exceeded max length of 1000" }
),
2024-09-24 14:05:30 +01:00
status_code = 403 ,
2024-04-27 10:27:23 -06:00
)
2026-03-19 09:42:33 -05:00
# Extract camera name from preview filename (format: preview_{camera}-{timestamp}.ext)
if not file_name . startswith ( "preview_" ):
return JSONResponse (
content = { "success" : False , "message" : "Invalid preview filename" },
status_code = 400 ,
)
# Use rsplit to handle camera names containing dashes (e.g. front-door)
name_part = file_name [ len ( "preview_" ) :] . rsplit ( "." , 1 )[ 0 ] # strip extension
camera_name = name_part . rsplit ( "-" , 1 )[ 0 ] # split off timestamp
await require_camera_access ( camera_name , request = request )
2024-09-24 14:05:30 +01:00
safe_file_name_current = sanitize_filename ( file_name )
2024-03-02 15:10:37 -07:00
preview_dir = os . path . join ( CACHE_DIR , "preview_frames" )
2024-04-11 06:42:16 -06:00
try :
with open (
os . path . join ( preview_dir , safe_file_name_current ), "rb"
) as image_file :
jpg_bytes = image_file . read ()
except FileNotFoundError :
2024-09-24 14:05:30 +01:00
return JSONResponse (
content = ({ "success" : False , "message" : "Image file not found" }),
status_code = 404 ,
2024-04-11 06:42:16 -06:00
)
2024-03-02 15:10:37 -07:00
2024-09-24 08:50:20 -06:00
return Response (
jpg_bytes ,
2024-09-24 14:05:30 +01:00
media_type = "image/webp" ,
headers = {
"Content-Type" : "image/webp" ,
"Cache-Control" : "private, max-age=31536000" ,
},
)
2024-09-27 12:09:53 -06:00
####################### dynamic routes ###########################
2025-09-12 06:19:29 -05:00
@router.get (
"/ {camera_name} / {label} /best.jpg" , dependencies = [ Depends ( require_camera_access )]
)
@router.get (
"/ {camera_name} / {label} /thumbnail.jpg" ,
dependencies = [ Depends ( require_camera_access )],
)
2025-09-28 08:08:52 -05:00
async def label_thumbnail ( request : Request , camera_name : str , label : str ):
2024-09-27 12:09:53 -06:00
label = unquote ( label )
event_query = Event . select ( fn . MAX ( Event . id )) . where ( Event . camera == camera_name )
if label != "any" :
event_query = event_query . where ( Event . label == label )
try :
event_id = event_query . scalar ()
2025-09-28 08:08:52 -05:00
return await event_thumbnail ( request , event_id , Extension . jpg , 60 )
2024-09-27 12:09:53 -06:00
except DoesNotExist :
frame = np . zeros (( 175 , 175 , 3 ), np . uint8 )
ret , jpg = cv2 . imencode ( ".jpg" , frame , [ int ( cv2 . IMWRITE_JPEG_QUALITY ), 70 ])
return Response (
2024-10-31 06:31:01 -06:00
jpg . tobytes (),
2024-09-27 12:09:53 -06:00
media_type = "image/jpeg" ,
headers = { "Cache-Control" : "no-store" },
)
2025-09-12 06:19:29 -05:00
@router.get (
"/ {camera_name} / {label} /clip.mp4" , dependencies = [ Depends ( require_camera_access )]
)
2025-09-28 08:08:52 -05:00
async def label_clip ( request : Request , camera_name : str , label : str ):
2024-09-27 12:09:53 -06:00
label = unquote ( label )
event_query = Event . select ( fn . MAX ( Event . id )) . where (
Event . camera == camera_name , Event . has_clip == True
)
if label != "any" :
event_query = event_query . where ( Event . label == label )
try :
event = event_query . get ()
2026-01-04 11:12:03 -07:00
return await event_clip ( request , event . id , 0 )
2024-09-27 12:09:53 -06:00
except DoesNotExist :
return JSONResponse (
content = { "success" : False , "message" : "Event not found" }, status_code = 404
)
2025-09-12 06:19:29 -05:00
@router.get (
"/ {camera_name} / {label} /snapshot.jpg" , dependencies = [ Depends ( require_camera_access )]
)
2025-09-28 08:08:52 -05:00
async def label_snapshot ( request : Request , camera_name : str , label : str ):
2024-09-27 12:09:53 -06:00
"""Returns the snapshot image from the latest event for the given camera and label combo"""
label = unquote ( label )
if label == "any" :
event_query = (
Event . select ( Event . id )
. where ( Event . camera == camera_name )
. where ( Event . has_snapshot == True )
. order_by ( Event . start_time . desc ())
)
else :
event_query = (
Event . select ( Event . id )
. where ( Event . camera == camera_name )
. where ( Event . label == label )
. where ( Event . has_snapshot == True )
. order_by ( Event . start_time . desc ())
)
try :
2024-10-19 13:11:49 -06:00
event : Event = event_query . get ()
2025-09-28 08:08:52 -05:00
return await event_snapshot ( request , event . id , MediaEventsSnapshotQueryParams ())
2024-09-27 12:09:53 -06:00
except DoesNotExist :
frame = np . zeros (( 720 , 1280 , 3 ), np . uint8 )
2024-10-19 13:11:49 -06:00
_ , jpg = cv2 . imencode ( ".jpg" , frame , [ int ( cv2 . IMWRITE_JPEG_QUALITY ), 70 ])
2024-09-27 12:09:53 -06:00
return Response (
2024-10-31 06:31:01 -06:00
jpg . tobytes (),
2024-09-27 12:09:53 -06:00
media_type = "image/jpeg" ,
)