2024-03-03 01:10:37 +03:00
""" Image and video apis. """
2025-05-07 16:53:29 +03:00
import asyncio
2024-03-03 01:10:37 +03:00
import glob
import logging
2025-03-29 15:19:12 +03:00
import math
2024-03-03 01:10:37 +03:00
import os
import subprocess as sp
import time
from datetime import datetime , timedelta , timezone
2025-05-23 17:55:48 +03:00
from functools import reduce
2024-10-15 00:23:02 +03:00
from pathlib import Path as FilePath
2025-09-12 14:19:29 +03:00
from typing import Any , List
2024-03-03 01:10:37 +03:00
from urllib . parse import unquote
import cv2
import numpy as np
import pytz
2025-09-12 14:19:29 +03:00
from fastapi import APIRouter , Depends , Path , Query , Request , Response
2024-10-03 16:33:06 +03:00
from fastapi . responses import FileResponse , JSONResponse , StreamingResponse
2024-09-24 16:05:30 +03:00
from pathvalidate import sanitize_filename
2025-05-23 17:55:48 +03:00
from peewee import DoesNotExist , fn , operator
2024-03-03 01:10:37 +03:00
from tzlocal import get_localzone_name
2025-11-27 00:07:28 +03:00
from frigate . api . auth import (
allow_any_authenticated ,
get_allowed_cameras_for_filter ,
require_camera_access ,
)
2024-12-06 17:04:02 +03:00
from frigate . api . defs . query . media_query_parameters import (
2024-09-24 16:05:30 +03:00
Extension ,
MediaEventsSnapshotQueryParams ,
MediaLatestFrameQueryParams ,
MediaMjpegFeedQueryParams ,
2025-05-23 17:55:48 +03:00
MediaRecordingsAvailabilityQueryParams ,
2025-02-10 02:02:36 +03:00
MediaRecordingsSummaryQueryParams ,
2024-09-24 16:05:30 +03:00
)
from frigate . api . defs . tags import Tags
2025-03-14 16:10:47 +03:00
from frigate . camera . state import CameraState
2024-09-13 23:14:51 +03:00
from frigate . config import FrigateConfig
2024-03-03 01:10:37 +03:00
from frigate . const import (
CACHE_DIR ,
CLIPS_DIR ,
2025-03-01 07:35:09 +03:00
INSTALL_DIR ,
2024-03-03 01:10:37 +03:00
MAX_SEGMENT_DURATION ,
2024-03-12 02:31:05 +03:00
PREVIEW_FRAME_TYPE ,
2024-03-03 01:10:37 +03:00
RECORD_DIR ,
)
from frigate . models import Event , Previews , Recordings , Regions , ReviewSegment
2025-03-12 06:31:05 +03:00
from frigate . track . object_processing import TrackedObjectProcessor
2025-11-05 02:06:14 +03:00
from frigate . util . file import get_event_thumbnail_bytes
2024-05-03 17:00:19 +03:00
from frigate . util . image import get_image_from_recording
2025-11-04 03:30:56 +03:00
from frigate . util . time import get_dst_transitions
2024-03-03 01:10:37 +03:00
logger = logging . getLogger ( __name__ )
2024-09-24 16:05:30 +03:00
router = APIRouter ( tags = [ Tags . media ] )
2024-03-03 01:10:37 +03:00
2025-09-12 14:19:29 +03:00
@router.get ( " / {camera_name} " , dependencies = [ Depends ( require_camera_access ) ] )
async def mjpeg_feed (
2024-09-24 16:05:30 +03:00
request : Request ,
camera_name : str ,
params : MediaMjpegFeedQueryParams = Depends ( ) ,
) :
2024-03-03 01:10:37 +03:00
draw_options = {
2024-09-24 16:05:30 +03:00
" bounding_boxes " : params . bbox ,
" timestamp " : params . timestamp ,
" zones " : params . zones ,
" mask " : params . mask ,
" motion_boxes " : params . motion ,
" regions " : params . regions ,
2024-03-03 01:10:37 +03:00
}
2024-09-24 16:05:30 +03:00
if camera_name in request . app . frigate_config . cameras :
2024-03-03 01:10:37 +03:00
# return a multipart response
2024-10-03 16:33:06 +03:00
return StreamingResponse (
2024-03-03 01:10:37 +03:00
imagestream (
2024-09-24 16:05:30 +03:00
request . app . detected_frames_processor ,
2024-03-03 01:10:37 +03:00
camera_name ,
2024-09-24 16:05:30 +03:00
params . fps ,
params . height ,
2024-03-03 01:10:37 +03:00
draw_options ,
) ,
2024-09-24 16:05:30 +03:00
media_type = " multipart/x-mixed-replace;boundary=frame " ,
2024-03-03 01:10:37 +03:00
)
else :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Camera not found " } ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
2024-09-24 16:05:30 +03:00
def imagestream (
2024-12-01 03:22:36 +03:00
detected_frames_processor : TrackedObjectProcessor ,
camera_name : str ,
fps : int ,
height : int ,
2025-05-13 17:27:20 +03:00
draw_options : dict [ str , Any ] ,
2024-09-24 16:05:30 +03:00
) :
2024-03-03 01:10:37 +03: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 16:05:30 +03:00
b " Content-Type: image/jpeg \r \n \r \n " + bytearray ( jpg . tobytes ( ) ) + b " \r \n \r \n "
2024-03-03 01:10:37 +03:00
)
2025-09-12 14:19:29 +03:00
@router.get ( " / {camera_name} /ptz/info " , dependencies = [ Depends ( require_camera_access ) ] )
2025-03-14 15:25:48 +03:00
async def camera_ptz_info ( request : Request , camera_name : str ) :
2024-09-24 16:05:30 +03:00
if camera_name in request . app . frigate_config . cameras :
2025-05-07 16:53:29 +03: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 16:05:30 +03:00
)
2025-05-07 16:53:29 +03:00
result = future . result ( )
return JSONResponse ( content = result )
2024-03-03 01:10:37 +03:00
else :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Camera not found " } ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} /latest. {extension} " , dependencies = [ Depends ( require_camera_access ) ]
)
async def latest_frame (
2024-09-24 16:05:30 +03:00
request : Request ,
camera_name : str ,
extension : Extension ,
params : MediaLatestFrameQueryParams = Depends ( ) ,
) :
2024-12-01 03:22:36 +03:00
frame_processor : TrackedObjectProcessor = request . app . detected_frames_processor
2024-03-03 01:10:37 +03:00
draw_options = {
2024-09-24 16:05:30 +03:00
" bounding_boxes " : params . bbox ,
" timestamp " : params . timestamp ,
" zones " : params . zones ,
" mask " : params . mask ,
" motion_boxes " : params . motion ,
2025-07-07 17:33:19 +03:00
" paths " : params . paths ,
2024-09-24 16:05:30 +03:00
" regions " : params . regions ,
2024-03-03 01:10:37 +03:00
}
2024-09-24 16:05:30 +03:00
quality = params . quality
2025-01-13 16:46:46 +03:00
2025-08-17 06:20:21 +03:00
if extension == Extension . png :
2025-01-13 16:46:46 +03:00
quality_params = None
2025-08-17 06:20:21 +03:00
elif extension == Extension . webp :
2025-01-13 16:46:46 +03:00
quality_params = [ int ( cv2 . IMWRITE_WEBP_QUALITY ) , quality ]
2025-08-17 06:20:21 +03:00
else : # jpg or jpeg
2025-01-13 16:46:46 +03:00
quality_params = [ int ( cv2 . IMWRITE_JPEG_QUALITY ) , quality ]
2024-03-03 01:10:37 +03:00
2024-09-24 16:05:30 +03:00
if camera_name in request . app . frigate_config . cameras :
2024-12-01 03:22:36 +03:00
frame = frame_processor . get_current_frame ( camera_name , draw_options )
2024-03-03 01:10:37 +03:00
retry_interval = float (
2024-09-24 16:05:30 +03:00
request . app . frigate_config . cameras . get ( camera_name ) . ffmpeg . retry_interval
2024-03-03 01:10:37 +03:00
or 10
)
if frame is None or datetime . now ( ) . timestamp ( ) > (
2024-12-01 03:22:36 +03:00
frame_processor . get_current_frame_time ( camera_name ) + retry_interval
2024-03-03 01:10:37 +03:00
) :
2024-09-24 16:05:30 +03:00
if request . app . camera_error_image is None :
2025-03-01 07:35:09 +03:00
error_image = glob . glob (
os . path . join ( INSTALL_DIR , " frigate/images/camera-error.jpg " )
)
2024-03-03 01:10:37 +03:00
if len ( error_image ) > 0 :
2024-09-24 16:05:30 +03:00
request . app . camera_error_image = cv2 . imread (
2024-03-03 01:10:37 +03:00
error_image [ 0 ] , cv2 . IMREAD_UNCHANGED
)
2024-09-24 16:05:30 +03:00
frame = request . app . camera_error_image
2024-03-03 01:10:37 +03:00
2024-09-24 16:05:30 +03:00
height = int ( params . height or str ( frame . shape [ 0 ] ) )
2024-03-03 01:10:37 +03:00
width = int ( height * frame . shape [ 1 ] / frame . shape [ 0 ] )
if frame is None :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to get valid frame " } ,
status_code = 500 ,
2024-03-03 01:10:37 +03:00
)
if height < 1 or width < 1 :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = " Invalid height / width requested :: {} / {} " . format (
height , width
) ,
status_code = 400 ,
2024-03-03 01:10:37 +03:00
)
frame = cv2 . resize ( frame , dsize = ( width , height ) , interpolation = cv2 . INTER_AREA )
2025-08-17 06:20:21 +03:00
_ , img = cv2 . imencode ( f " . { extension . value } " , frame , quality_params )
2024-09-24 17:50:20 +03:00
return Response (
content = img . tobytes ( ) ,
2025-08-22 15:04:30 +03:00
media_type = extension . get_mime_type ( ) ,
2024-12-20 17:17:51 +03:00
headers = {
" Cache-Control " : " no-store "
if not params . store
else " private, max-age=60 " ,
} ,
2024-03-03 01:10:37 +03:00
)
2025-08-18 02:26:18 +03:00
elif (
camera_name == " birdseye "
and request . app . frigate_config . birdseye . enabled
and request . app . frigate_config . birdseye . restream
) :
2024-03-03 01:10:37 +03:00
frame = cv2 . cvtColor (
2024-12-01 03:22:36 +03:00
frame_processor . get_current_frame ( camera_name ) ,
2024-03-03 01:10:37 +03:00
cv2 . COLOR_YUV2BGR_I420 ,
)
2024-09-24 16:05:30 +03:00
height = int ( params . height or str ( frame . shape [ 0 ] ) )
2024-03-03 01:10:37 +03:00
width = int ( height * frame . shape [ 1 ] / frame . shape [ 0 ] )
frame = cv2 . resize ( frame , dsize = ( width , height ) , interpolation = cv2 . INTER_AREA )
2025-08-17 06:20:21 +03:00
_ , img = cv2 . imencode ( f " . { extension . value } " , frame , quality_params )
2024-09-24 17:50:20 +03:00
return Response (
content = img . tobytes ( ) ,
2025-08-22 15:04:30 +03:00
media_type = extension . get_mime_type ( ) ,
2024-12-20 17:17:51 +03:00
headers = {
" Cache-Control " : " no-store "
if not params . store
else " private, max-age=60 " ,
} ,
2024-03-03 01:10:37 +03:00
)
else :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Camera not found " } ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} /recordings/ {frame_time} /snapshot. {format} " ,
dependencies = [ Depends ( require_camera_access ) ] ,
)
async def get_snapshot_from_recording (
2024-09-24 16:05:30 +03: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-09-04 16:46:49 +03:00
)
2025-03-29 05:37:11 +03:00
recording : Recordings | None = None
2024-09-04 16:46:49 +03:00
2025-03-29 05:37:11 +03:00
try :
recording = (
Recordings . select (
Recordings . path ,
Recordings . start_time ,
)
. where (
(
( frame_time > = Recordings . start_time )
& ( frame_time < = Recordings . end_time )
)
2024-03-03 01:10:37 +03:00
)
2025-03-29 05:37:11 +03:00
. where ( Recordings . camera == camera_name )
. order_by ( Recordings . start_time . desc ( ) )
. limit ( 1 )
. get ( )
2024-03-03 01:10:37 +03:00
)
2025-03-29 05:37:11 +03:00
except DoesNotExist :
# try again with a rounded frame time as it may be between
# the rounded segment start time
2025-03-29 15:19:12 +03:00
frame_time = math . ceil ( frame_time )
2025-03-29 05:37:11 +03: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
2024-03-03 01:10:37 +03:00
2025-03-29 05:37:11 +03:00
if recording is not None :
2024-03-03 01:10:37 +03:00
time_in_segment = frame_time - recording . start_time
2024-09-04 16:46:49 +03:00
codec = " png " if format == " png " else " mjpeg "
2025-01-13 16:46:46 +03:00
mime_type = " png " if format == " png " else " jpeg "
2024-09-24 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-09-04 16:46:49 +03:00
image_data = get_image_from_recording (
2024-09-13 23:14:51 +03:00
config . ffmpeg , recording . path , time_in_segment , codec , height
2024-09-04 16:46:49 +03:00
)
2024-03-03 01:10:37 +03:00
2024-05-03 17:00:19 +03:00
if not image_data :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = (
2024-05-03 17:00:19 +03:00
{
" success " : False ,
" message " : f " Unable to parse frame at time { frame_time } " ,
}
) ,
2024-09-24 16:05:30 +03:00
status_code = 404 ,
2024-05-03 17:00:19 +03:00
)
2025-01-13 16:46:46 +03:00
return Response ( image_data , headers = { " Content-Type " : f " image/ { mime_type } " } )
2025-03-29 05:37:11 +03:00
else :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : " Recording not found at {} " . format ( frame_time ) ,
} ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
2024-05-03 17:00:19 +03:00
2025-09-12 14:19:29 +03:00
@router.post (
" / {camera_name} /plus/ {frame_time} " , dependencies = [ Depends ( require_camera_access ) ]
)
async def submit_recording_snapshot_to_plus (
2024-09-24 16:05:30 +03: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 17:00:19 +03: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 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-05-03 17:00:19 +03:00
recording : Recordings = recording_query . get ( )
time_in_segment = frame_time - recording . start_time
2024-09-13 23:14:51 +03:00
image_data = get_image_from_recording (
config . ffmpeg , recording . path , time_in_segment , " png "
)
2024-05-03 17:00:19 +03:00
if not image_data :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : f " Unable to parse frame at time { frame_time } " ,
} ,
status_code = 404 ,
2024-05-03 17:00:19 +03:00
)
nd = cv2 . imdecode ( np . frombuffer ( image_data , dtype = np . int8 ) , cv2 . IMREAD_COLOR )
2024-09-24 16:05:30 +03:00
request . app . frigate_config . plus_api . upload_image ( nd , camera_name )
return JSONResponse (
content = {
" success " : True ,
" message " : " Successfully submitted image. " ,
} ,
status_code = 200 ,
2024-05-03 17:00:19 +03:00
)
except DoesNotExist :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : " Recording not found at {} " . format ( frame_time ) ,
} ,
status_code = 404 ,
2024-05-03 17:00:19 +03:00
)
2024-03-03 01:10:37 +03:00
2025-11-27 00:07:28 +03:00
@router.get ( " /recordings/storage " , dependencies = [ Depends ( allow_any_authenticated ( ) ) ] )
2024-09-24 16:05:30 +03:00
def get_recordings_storage_usage ( request : Request ) :
recording_stats = request . app . stats_emitter . get_latest_stats ( ) [ " service " ] [
2024-03-03 01:10:37 +03:00
" storage "
] [ RECORD_DIR ]
if not recording_stats :
2024-09-24 16:05:30 +03:00
return JSONResponse ( { } )
2024-03-03 01:10:37 +03:00
total_mb = recording_stats [ " total " ]
camera_usages : dict [ str , dict ] = (
2024-09-24 16:05:30 +03:00
request . app . storage_maintainer . calculate_camera_usages ( )
2024-03-03 01:10:37 +03:00
)
for camera_name in camera_usages . keys ( ) :
if camera_usages . get ( camera_name , { } ) . get ( " usage " ) :
camera_usages [ camera_name ] [ " usage_percent " ] = (
camera_usages . get ( camera_name , { } ) . get ( " usage " , 0 ) / total_mb
) * 100
2024-09-24 16:05:30 +03:00
return JSONResponse ( content = camera_usages )
2024-03-03 01:10:37 +03:00
2025-11-27 00:07:28 +03:00
@router.get ( " /recordings/summary " , dependencies = [ Depends ( allow_any_authenticated ( ) ) ] )
2025-09-12 14:19:29 +03:00
def all_recordings_summary (
request : Request ,
params : MediaRecordingsSummaryQueryParams = Depends ( ) ,
allowed_cameras : List [ str ] = Depends ( get_allowed_cameras_for_filter ) ,
) :
2025-02-10 02:02:36 +03:00
""" Returns true/false by day indicating if recordings exist """
cameras = params . cameras
2025-09-12 14:19:29 +03:00
if cameras != " all " :
requested = set ( unquote ( cameras ) . split ( " , " ) )
filtered = requested . intersection ( allowed_cameras )
if not filtered :
return JSONResponse ( content = { } )
2025-11-04 03:30:56 +03:00
camera_list = list ( filtered )
2025-09-12 14:19:29 +03:00
else :
2025-11-04 03:30:56 +03:00
camera_list = allowed_cameras
2025-02-10 02:02:36 +03:00
2025-11-04 03:30:56 +03:00
time_range_query = (
2025-02-10 02:02:36 +03:00
Recordings . select (
2025-11-04 03:30:56 +03:00
fn . MIN ( Recordings . start_time ) . alias ( " min_time " ) ,
fn . MAX ( Recordings . start_time ) . alias ( " max_time " ) ,
2025-02-10 02:02:36 +03:00
)
2025-11-04 03:30:56 +03:00
. where ( Recordings . camera << camera_list )
. dicts ( )
. get ( )
2025-02-10 02:02:36 +03:00
)
2025-11-04 03:30:56 +03:00
min_time = time_range_query . get ( " min_time " )
max_time = time_range_query . get ( " max_time " )
2025-02-10 02:02:36 +03:00
2025-11-04 03:30:56 +03:00
if min_time is None or max_time is None :
return JSONResponse ( content = { } )
dst_periods = get_dst_transitions ( params . timezone , min_time , max_time )
days : dict [ str , bool ] = { }
for period_start , period_end , period_offset in dst_periods :
hours_offset = int ( period_offset / 60 / 60 )
minutes_offset = int ( period_offset / 60 - hours_offset * 60 )
period_hour_modifier = f " { hours_offset } hour "
period_minute_modifier = f " { minutes_offset } minute "
period_query = (
Recordings . select (
fn . strftime (
" % Y- % m- %d " ,
fn . datetime (
Recordings . start_time ,
" unixepoch " ,
period_hour_modifier ,
period_minute_modifier ,
) ,
) . alias ( " day " )
)
. where (
( Recordings . camera << camera_list )
& ( Recordings . end_time > = period_start )
& ( Recordings . start_time < = period_end )
)
. group_by (
fn . strftime (
" % Y- % m- %d " ,
fn . datetime (
Recordings . start_time ,
" unixepoch " ,
period_hour_modifier ,
period_minute_modifier ,
) ,
)
)
. order_by ( Recordings . start_time . desc ( ) )
. namedtuples ( )
)
for g in period_query :
days [ g . day ] = True
2025-02-10 02:02:36 +03:00
2025-11-06 17:21:07 +03:00
return JSONResponse ( content = dict ( sorted ( days . items ( ) ) ) )
2025-02-10 02:02:36 +03:00
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} /recordings/summary " , dependencies = [ Depends ( require_camera_access ) ]
)
async def recordings_summary ( camera_name : str , timezone : str = " utc " ) :
2024-09-24 16:05:30 +03:00
""" Returns hourly summary for recordings of given camera """
2025-11-04 03:30:56 +03:00
time_range_query = (
2024-03-03 01:10:37 +03:00
Recordings . select (
2025-11-04 03:30:56 +03:00
fn . MIN ( Recordings . start_time ) . alias ( " min_time " ) ,
fn . MAX ( Recordings . start_time ) . alias ( " max_time " ) ,
2024-03-03 01:10:37 +03:00
)
. where ( Recordings . camera == camera_name )
2025-11-04 03:30:56 +03:00
. dicts ( )
. get ( )
2024-03-03 01:10:37 +03:00
)
2025-11-04 03:30:56 +03:00
min_time = time_range_query . get ( " min_time " )
max_time = time_range_query . get ( " max_time " )
days : dict [ str , dict ] = { }
if min_time is None or max_time is None :
return JSONResponse ( content = list ( days . values ( ) ) )
dst_periods = get_dst_transitions ( timezone , min_time , max_time )
for period_start , period_end , period_offset in dst_periods :
hours_offset = int ( period_offset / 60 / 60 )
minutes_offset = int ( period_offset / 60 - hours_offset * 60 )
period_hour_modifier = f " { hours_offset } hour "
period_minute_modifier = f " { minutes_offset } minute "
recording_groups = (
Recordings . select (
fn . strftime (
" % Y- % m- %d % H " ,
fn . datetime (
Recordings . start_time ,
" unixepoch " ,
period_hour_modifier ,
period_minute_modifier ,
) ,
) . alias ( " hour " ) ,
fn . SUM ( Recordings . duration ) . alias ( " duration " ) ,
fn . SUM ( Recordings . motion ) . alias ( " motion " ) ,
fn . SUM ( Recordings . objects ) . alias ( " objects " ) ,
)
. where (
( Recordings . camera == camera_name )
& ( Recordings . end_time > = period_start )
& ( Recordings . start_time < = period_end )
)
. group_by ( ( Recordings . start_time + period_offset ) . cast ( " int " ) / 3600 )
. order_by ( Recordings . start_time . desc ( ) )
. namedtuples ( )
)
event_groups = (
Event . select (
fn . strftime (
" % Y- % m- %d % H " ,
fn . datetime (
Event . start_time ,
" unixepoch " ,
period_hour_modifier ,
period_minute_modifier ,
) ,
) . alias ( " hour " ) ,
fn . COUNT ( Event . id ) . alias ( " count " ) ,
)
. where ( Event . camera == camera_name , Event . has_clip )
. where (
( Event . start_time > = period_start ) & ( Event . start_time < = period_end )
)
. group_by ( ( Event . start_time + period_offset ) . cast ( " int " ) / 3600 )
. namedtuples ( )
2024-03-03 01:10:37 +03:00
)
2025-11-04 03:30:56 +03:00
event_map = { g . hour : g . count for g in event_groups }
for recording_group in recording_groups :
parts = recording_group . hour . split ( )
hour = parts [ 1 ]
day = parts [ 0 ]
events_count = event_map . get ( recording_group . hour , 0 )
hour_data = {
" hour " : hour ,
" events " : events_count ,
" motion " : recording_group . motion ,
" objects " : recording_group . objects ,
" duration " : round ( recording_group . duration ) ,
}
if day in days :
# merge counts if already present (edge-case at DST boundary)
days [ day ] [ " events " ] + = events_count or 0
days [ day ] [ " hours " ] . append ( hour_data )
else :
days [ day ] = {
" events " : events_count or 0 ,
" hours " : [ hour_data ] ,
" day " : day ,
}
2024-03-03 01:10:37 +03:00
2024-09-24 16:05:30 +03:00
return JSONResponse ( content = list ( days . values ( ) ) )
2024-03-03 01:10:37 +03:00
2025-09-12 14:19:29 +03:00
@router.get ( " / {camera_name} /recordings " , dependencies = [ Depends ( require_camera_access ) ] )
async def recordings (
2024-09-24 16:05:30 +03:00
camera_name : str ,
after : float = ( datetime . now ( ) - timedelta ( hours = 1 ) ) . timestamp ( ) ,
before : float = datetime . now ( ) . timestamp ( ) ,
) :
""" Return specific camera recordings between the given ' after ' / ' end ' times. If not provided the last hour will be used """
2024-03-03 01:10:37 +03:00
recordings = (
Recordings . select (
Recordings . id ,
Recordings . start_time ,
Recordings . end_time ,
Recordings . segment_size ,
Recordings . motion ,
Recordings . objects ,
Recordings . duration ,
)
. where (
Recordings . camera == camera_name ,
Recordings . end_time > = after ,
Recordings . start_time < = before ,
)
. order_by ( Recordings . start_time )
. dicts ( )
. iterator ( )
)
2024-09-24 16:05:30 +03:00
return JSONResponse ( content = list ( recordings ) )
2024-03-03 01:10:37 +03:00
2025-11-27 00:07:28 +03:00
@router.get (
" /recordings/unavailable " ,
response_model = list [ dict ] ,
dependencies = [ Depends ( allow_any_authenticated ( ) ) ] ,
)
2025-09-12 14:19:29 +03:00
async def no_recordings (
request : Request ,
params : MediaRecordingsAvailabilityQueryParams = Depends ( ) ,
allowed_cameras : List [ str ] = Depends ( get_allowed_cameras_for_filter ) ,
) :
2025-05-23 17:55:48 +03:00
""" Get time ranges with no recordings. """
cameras = params . cameras
2025-09-12 14:19:29 +03:00
if cameras != " all " :
requested = set ( unquote ( cameras ) . split ( " , " ) )
filtered = requested . intersection ( allowed_cameras )
if not filtered :
return JSONResponse ( content = [ ] )
cameras = " , " . join ( filtered )
else :
cameras = allowed_cameras
2025-05-23 17:55:48 +03:00
before = params . before or datetime . datetime . now ( ) . timestamp ( )
after = (
params . after
or ( datetime . datetime . now ( ) - datetime . timedelta ( hours = 1 ) ) . timestamp ( )
)
scale = params . scale
2025-10-29 17:39:07 +03:00
clauses = [ ( Recordings . end_time > = after ) & ( Recordings . start_time < = before ) ]
2025-05-23 17:55:48 +03:00
if cameras != " all " :
camera_list = cameras . split ( " , " )
clauses . append ( ( Recordings . camera << camera_list ) )
2025-09-12 14:19:29 +03:00
else :
camera_list = allowed_cameras
2025-05-23 17:55:48 +03:00
# Get recording start times
data : list [ Recordings ] = (
Recordings . select ( Recordings . start_time , Recordings . end_time )
. where ( reduce ( operator . and_ , clauses ) )
. order_by ( Recordings . start_time . asc ( ) )
. dicts ( )
. iterator ( )
)
# Convert recordings to list of (start, end) tuples
recordings = [ ( r [ " start_time " ] , r [ " end_time " ] ) for r in data ]
2025-10-29 17:39:07 +03:00
# Iterate through time segments and check if each has any recording
2025-05-23 17:55:48 +03:00
no_recording_segments = [ ]
2025-10-29 17:39:07 +03:00
current = after
current_gap_start = None
2025-05-23 17:55:48 +03:00
while current < before :
2025-10-29 17:39:07 +03:00
segment_end = min ( current + scale , before )
# Check if this segment overlaps with any recording
2025-05-23 17:55:48 +03:00
has_recording = any (
2025-10-29 17:39:07 +03:00
rec_start < segment_end and rec_end > current
for rec_start , rec_end in recordings
2025-05-23 17:55:48 +03:00
)
2025-10-29 17:39:07 +03:00
2025-05-23 17:55:48 +03:00
if not has_recording :
2025-10-29 17:39:07 +03:00
# This segment has no recordings
if current_gap_start is None :
current_gap_start = current # Start a new gap
2025-05-23 17:55:48 +03:00
else :
2025-10-29 17:39:07 +03:00
# This segment has recordings
if current_gap_start is not None :
2025-05-23 17:55:48 +03:00
# End the current gap and append it
no_recording_segments . append (
2025-10-29 17:39:07 +03:00
{ " start_time " : int ( current_gap_start ) , " end_time " : int ( current ) }
2025-05-23 17:55:48 +03:00
)
2025-10-29 17:39:07 +03:00
current_gap_start = None
2025-05-23 17:55:48 +03:00
current = segment_end
# Append the last gap if it exists
2025-10-29 17:39:07 +03:00
if current_gap_start is not None :
2025-05-23 17:55:48 +03:00
no_recording_segments . append (
2025-10-29 17:39:07 +03:00
{ " start_time " : int ( current_gap_start ) , " end_time " : int ( before ) }
2025-05-23 17:55:48 +03:00
)
return JSONResponse ( content = no_recording_segments )
2025-05-08 01:31:24 +03:00
@router.get (
" / {camera_name} /start/ {start_ts} /end/ {end_ts} /clip.mp4 " ,
2025-09-12 14:19:29 +03:00
dependencies = [ Depends ( require_camera_access ) ] ,
2025-05-08 01:31:24 +03: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 14:19:29 +03:00
async def recording_clip (
2024-09-24 16:05:30 +03:00
request : Request ,
camera_name : str ,
start_ts : float ,
end_ts : float ,
) :
2024-10-15 00:23:02 +03: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-23 05:33:41 +03:00
data = ffmpeg . stdout . read ( 8192 )
2024-10-23 03:07:54 +03:00
if data is not None and len ( data ) > 0 :
2024-10-15 00:23:02 +03: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-03 01:10:37 +03: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-19 01:33:42 +03:00
if recordings . count ( ) == 0 :
return JSONResponse (
content = {
" success " : False ,
" message " : " No recordings found for the specified time range " ,
} ,
status_code = 400 ,
)
2024-10-15 00:23:02 +03:00
file_name = sanitize_filename ( f " playlist_ { camera_name } _ { start_ts } - { end_ts } .txt " )
2025-03-01 07:35:09 +03:00
file_path = os . path . join ( CACHE_DIR , file_name )
2024-10-15 00:23:02 +03:00
with open ( file_path , " w " ) as file :
clip : Recordings
for clip in recordings :
file . write ( f " file ' { clip . path } ' \n " )
2025-05-15 01:44:06 +03:00
2024-10-15 00:23:02 +03: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-15 01:44:06 +03:00
2025-06-21 01:39:47 +03:00
# if this is the ending clip, add an outpoint
2024-10-15 00:23:02 +03:00
if clip . end_time > end_ts :
file . write ( f " outpoint { int ( end_ts - clip . start_time ) } \n " )
2024-04-27 19:27:23 +03:00
if len ( file_name ) > 1000 :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : " Filename exceeded max length of 1000 " ,
} ,
status_code = 403 ,
2024-04-27 19:27:23 +03:00
)
2024-09-24 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-09-13 23:14:51 +03:00
2024-10-15 00:23:02 +03: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: " ,
]
return StreamingResponse (
run_download ( ffmpeg_cmd , file_path ) ,
2024-09-24 16:05:30 +03:00
media_type = " video/mp4 " ,
)
2024-03-03 01:10:37 +03:00
2025-05-16 01:13:18 +03:00
@router.get (
" /vod/ {camera_name} /start/ {start_ts} /end/ {end_ts} " ,
2025-09-12 14:19:29 +03:00
dependencies = [ Depends ( require_camera_access ) ] ,
2025-05-16 01:13:18 +03: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-09-12 14:19:29 +03:00
async def vod_ts ( camera_name : str , start_ts : float , end_ts : float ) :
2024-03-03 01:10:37 +03:00
recordings = (
2025-05-15 01:44:06 +03:00
Recordings . select (
Recordings . path ,
Recordings . duration ,
Recordings . end_time ,
Recordings . start_time ,
)
2024-03-03 01:10:37 +03: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-22 00:40:58 +03:00
min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame
2024-03-03 01:10:37 +03:00
max_duration_ms = MAX_SEGMENT_DURATION * 1000
recording : Recordings
for recording in recordings :
clip = { " type " : " source " , " path " : recording . path }
duration = int ( recording . duration * 1000 )
2025-05-15 01:44:06 +03: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
# adjust end if recording.end_time is after end_ts
2024-03-03 01:10:37 +03:00
if recording . end_time > end_ts :
duration - = int ( ( recording . end_time - end_ts ) * 1000 )
2025-11-22 00:40:58 +03:00
if duration < min_duration_ms :
# skip if the clip has no valid duration (too short to contain frames)
2025-05-15 01:44:06 +03:00
continue
2024-08-11 16:32:17 +03:00
2025-11-22 00:40:58 +03:00
if min_duration_ms < = duration < max_duration_ms :
2024-03-03 01:10:37 +03:00
clip [ " keyFrameDurations " ] = [ duration ]
clips . append ( clip )
durations . append ( duration )
else :
logger . warning ( f " Recording clip is missing or empty: { recording . path } " )
if not clips :
2024-07-16 19:56:09 +03:00
logger . error (
f " No recordings found for { camera_name } during the requested time range "
)
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : " No recordings found. " ,
} ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
hour_ago = datetime . now ( ) - timedelta ( hours = 1 )
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
2024-03-03 01:10:37 +03:00
" cache " : hour_ago . timestamp ( ) > start_ts ,
" discontinuity " : False ,
" consistentSequenceMediaInfo " : True ,
" durations " : durations ,
" segment_duration " : max ( durations ) ,
" sequences " : [ { " clips " : clips } ] ,
}
)
2025-05-16 01:13:18 +03:00
@router.get (
" /vod/ {year_month} / {day} / {hour} / {camera_name} " ,
2025-09-12 14:19:29 +03:00
dependencies = [ Depends ( require_camera_access ) ] ,
2025-05-16 01:13:18 +03: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 16:08:52 +03:00
async def vod_hour_no_timezone ( year_month : str , day : int , hour : int , camera_name : str ) :
2024-09-24 16:05:30 +03:00
""" VOD for specific hour. Uses the default timezone (UTC). """
2025-09-28 16:08:52 +03:00
return await vod_hour (
2024-03-03 01:10:37 +03:00
year_month , day , hour , camera_name , get_localzone_name ( ) . replace ( " / " , " , " )
)
2025-05-16 01:13:18 +03:00
@router.get (
" /vod/ {year_month} / {day} / {hour} / {camera_name} / {tz_name} " ,
2025-09-12 14:19:29 +03:00
dependencies = [ Depends ( require_camera_access ) ] ,
2025-05-16 01:13:18 +03: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 16:08:52 +03:00
async def vod_hour (
year_month : str , day : int , hour : int , camera_name : str , tz_name : str
) :
2024-03-03 01:10:37 +03:00
parts = year_month . split ( " - " )
start_date = (
2024-09-24 16:05:30 +03:00
datetime ( int ( parts [ 0 ] ) , int ( parts [ 1 ] ) , day , hour , tzinfo = timezone . utc )
2024-03-03 01:10:37 +03: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 16:08:52 +03:00
return await vod_ts ( camera_name , start_ts , end_ts )
2024-03-03 01:10:37 +03:00
2025-05-16 01:13:18 +03:00
@router.get (
" /vod/event/ {event_id} " ,
2025-11-29 16:30:04 +03:00
dependencies = [ Depends ( allow_any_authenticated ( ) ) ] ,
2025-05-16 01:13:18 +03:00
description = " Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback. " ,
)
2025-09-12 14:19:29 +03:00
async def vod_event (
request : Request ,
2025-08-28 15:09:23 +03:00
event_id : str ,
padding : int = Query ( 0 , description = " Padding to apply to the vod. " ) ,
) :
2024-03-03 01:10:37 +03:00
try :
2024-09-24 16:05:30 +03:00
event : Event = Event . get ( Event . id == event_id )
2024-03-03 01:10:37 +03:00
except DoesNotExist :
2024-09-24 16:05:30 +03:00
logger . error ( f " Event not found: { event_id } " )
return JSONResponse (
content = {
" success " : False ,
" message " : " Event not found. " ,
} ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
2025-09-12 14:19:29 +03:00
await require_camera_access ( event . camera , request = request )
2024-03-03 01:10:37 +03:00
2025-08-28 15:09:23 +03:00
end_ts = (
datetime . now ( ) . timestamp ( )
if event . end_time is None
else ( event . end_time + padding )
2024-03-03 01:10:37 +03:00
)
2025-09-28 16:08:52 +03:00
vod_response = await vod_ts ( event . camera , event . start_time - padding , end_ts )
2024-03-03 01:10:37 +03:00
2025-08-28 15:09:23 +03: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 ( )
2024-03-03 01:10:37 +03:00
2025-08-28 15:09:23 +03:00
return vod_response
2024-03-03 01:10:37 +03:00
2025-07-16 05:53:21 +03:00
@router.get (
" /events/ {event_id} /snapshot.jpg " ,
description = " Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used. " ,
)
2025-09-12 14:19:29 +03:00
async def event_snapshot (
2024-09-24 16:05:30 +03:00
request : Request ,
event_id : str ,
params : MediaEventsSnapshotQueryParams = Depends ( ) ,
) :
event_complete = False
jpg_bytes = None
try :
event = Event . get ( Event . id == event_id , Event . end_time != None )
event_complete = True
2025-09-12 14:19:29 +03:00
await require_camera_access ( event . camera , request = request )
2024-09-24 16:05:30 +03:00
if not event . has_snapshot :
return JSONResponse (
content = { " success " : False , " message " : " Snapshot not available " } ,
status_code = 404 ,
)
# read snapshot from disk
with open (
os . path . join ( CLIPS_DIR , f " { event . camera } - { event . id } .jpg " ) , " rb "
) as image_file :
jpg_bytes = image_file . read ( )
except DoesNotExist :
# see if the object is currently being tracked
try :
2025-03-14 16:10:47 +03:00
camera_states : list [ CameraState ] = (
request . app . detected_frames_processor . camera_states . values ( )
)
2024-09-24 16:05:30 +03: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 :
2025-03-14 16:10:47 +03:00
jpg_bytes = tracked_obj . get_img_bytes (
ext = " jpg " ,
2024-09-24 16:05:30 +03:00
timestamp = params . timestamp ,
bounding_box = params . bbox ,
crop = params . crop ,
height = params . height ,
quality = params . quality ,
)
2025-09-12 14:19:29 +03:00
await require_camera_access ( camera_state . name , request = request )
2025-03-14 16:21:50 +03:00
except Exception :
2024-09-24 16:05:30 +03:00
return JSONResponse (
2025-03-14 16:21:50 +03:00
content = { " success " : False , " message " : " Ongoing event not found " } ,
2024-09-24 16:05:30 +03:00
status_code = 404 ,
)
2025-03-14 16:21:50 +03:00
except Exception :
2024-09-24 16:05:30 +03:00
return JSONResponse (
2025-03-14 16:21:50 +03:00
content = { " success " : False , " message " : " Unknown error occurred " } ,
2025-03-14 16:10:47 +03:00
status_code = 404 ,
2024-09-24 16:05:30 +03:00
)
if jpg_bytes is None :
return JSONResponse (
2025-03-14 16:10:47 +03:00
content = { " success " : False , " message " : " Live frame not available " } ,
status_code = 404 ,
2024-09-24 16:05:30 +03:00
)
headers = {
" Content-Type " : " image/jpeg " ,
" Cache-Control " : " private, max-age=31536000 " if event_complete else " no-store " ,
}
if params . download :
headers [ " Content-Disposition " ] = f " attachment; filename=snapshot- { event_id } .jpg "
2024-09-24 17:50:20 +03:00
return Response (
jpg_bytes ,
2024-09-24 16:05:30 +03:00
media_type = " image/jpeg " ,
headers = headers ,
)
2025-11-27 00:07:28 +03:00
@router.get (
" /events/ {event_id} /thumbnail. {extension} " ,
dependencies = [ Depends ( require_camera_access ) ] ,
)
2025-09-12 14:19:29 +03:00
async def event_thumbnail (
2024-09-24 17:27:10 +03:00
request : Request ,
event_id : str ,
2025-08-22 15:04:30 +03:00
extension : Extension ,
2024-09-24 17:27:10 +03: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 17:46:29 +03:00
event : Event = Event . get ( Event . id == event_id )
2025-09-12 14:19:29 +03:00
await require_camera_access ( event . camera , request = request )
2024-09-24 17:27:10 +03:00
if event . end_time is not None :
event_complete = True
2025-02-18 17:46:29 +03:00
thumbnail_bytes = get_event_thumbnail_bytes ( event )
2024-09-24 17:27:10 +03:00
except DoesNotExist :
2025-02-18 17:46:29 +03:00
thumbnail_bytes = None
if thumbnail_bytes is None :
2024-09-24 17:27:10 +03: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 :
2025-08-22 15:04:30 +03:00
thumbnail_bytes = tracked_obj . get_thumbnail ( extension . value )
2024-09-24 17:27:10 +03: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 ,
)
# android notifications prefer a 2:1 ratio
if format == " android " :
2025-02-18 17:46:29 +03:00
img_as_np = np . frombuffer ( thumbnail_bytes , dtype = np . uint8 )
img = cv2 . imdecode ( img_as_np , flags = 1 )
2024-09-24 17:27:10 +03:00
thumbnail = cv2 . copyMakeBorder (
img ,
0 ,
0 ,
int ( img . shape [ 1 ] * 0.5 ) ,
int ( img . shape [ 1 ] * 0.5 ) ,
cv2 . BORDER_CONSTANT ,
( 0 , 0 , 0 ) ,
)
2025-02-18 17:46:29 +03:00
quality_params = None
2025-08-22 15:04:30 +03:00
if extension in ( Extension . jpg , Extension . jpeg ) :
2025-02-18 17:46:29 +03:00
quality_params = [ int ( cv2 . IMWRITE_JPEG_QUALITY ) , 70 ]
2025-08-22 15:04:30 +03:00
elif extension == Extension . webp :
2025-02-18 17:46:29 +03:00
quality_params = [ int ( cv2 . IMWRITE_WEBP_QUALITY ) , 60 ]
2025-08-22 15:04:30 +03:00
_ , img = cv2 . imencode ( f " . { extension . value } " , thumbnail , quality_params )
2025-02-18 17:46:29 +03:00
thumbnail_bytes = img . tobytes ( )
2024-09-24 17:27:10 +03:00
2024-09-24 17:50:20 +03:00
return Response (
thumbnail_bytes ,
2025-08-22 15:04:30 +03:00
media_type = extension . get_mime_type ( ) ,
2024-09-24 17:27:10 +03:00
headers = {
" Cache-Control " : f " private, max-age= { max_cache_age } "
if event_complete
else " no-store " ,
} ,
)
2025-09-12 14:19:29 +03:00
@router.get ( " / {camera_name} /grid.jpg " , dependencies = [ Depends ( require_camera_access ) ] )
2024-09-24 16:05:30 +03: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-12-01 03:22:36 +03:00
frame_processor : TrackedObjectProcessor = request . app . detected_frames_processor
frame = frame_processor . get_current_frame ( camera_name , { } )
2024-03-03 01:10:37 +03:00
retry_interval = float (
2024-09-24 16:05:30 +03:00
request . app . frigate_config . cameras . get ( camera_name ) . ffmpeg . retry_interval
2024-03-03 01:10:37 +03:00
or 10
)
if frame is None or datetime . now ( ) . timestamp ( ) > (
2024-12-01 03:22:36 +03:00
frame_processor . get_current_frame_time ( camera_name ) + retry_interval
2024-03-03 01:10:37 +03:00
) :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to get valid frame " } ,
status_code = 500 ,
2024-03-03 01:10:37 +03:00
)
try :
grid = (
Regions . select ( Regions . grid )
. where ( Regions . camera == camera_name )
. get ( )
. grid
)
except DoesNotExist :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to get region grid " } ,
status_code = 500 ,
2024-03-03 01:10:37 +03:00
)
2024-09-24 16:05:30 +03:00
color_arg = color . lower ( )
2024-03-03 01:10:37 +03: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 16:05:30 +03:00
draw_color = ( 0 , 255 , 0 ) # green
2024-03-03 01:10:37 +03: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 16:05:30 +03:00
fontScale = font_scale ,
2024-03-03 01:10:37 +03: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 16:05:30 +03:00
fontScale = font_scale ,
2024-03-03 01:10:37 +03: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 16:05:30 +03:00
fontScale = font_scale ,
2024-03-03 01:10:37 +03:00
color = draw_color ,
thickness = 2 ,
)
ret , jpg = cv2 . imencode ( " .jpg " , frame , [ int ( cv2 . IMWRITE_JPEG_QUALITY ) , 70 ] )
2024-09-24 16:05:30 +03:00
2024-09-24 17:50:20 +03:00
return Response (
2024-10-31 15:31:01 +03:00
jpg . tobytes ( ) ,
2024-09-24 16:05:30 +03:00
media_type = " image/jpeg " ,
headers = { " Cache-Control " : " no-store " } ,
)
2024-03-03 01:10:37 +03:00
else :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Camera not found " } ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
2025-11-27 00:07:28 +03:00
@router.get (
" /events/ {event_id} /snapshot-clean.webp " ,
dependencies = [ Depends ( require_camera_access ) ] ,
)
2024-09-24 16:05:30 +03:00
def event_snapshot_clean ( request : Request , event_id : str , download : bool = False ) :
2025-10-14 16:08:41 +03:00
webp_bytes = None
2024-03-19 14:54:25 +03:00
try :
2024-09-24 16:05:30 +03:00
event = Event . get ( Event . id == event_id )
snapshot_config = request . app . frigate_config . cameras [ event . camera ] . snapshots
2024-03-19 14:54:25 +03:00
if not ( snapshot_config . enabled and event . has_snapshot ) :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : " Snapshots and clean_copy must be enabled in the config " ,
} ,
status_code = 404 ,
2024-03-19 14:54:25 +03:00
)
if event . end_time is None :
# see if the object is currently being tracked
try :
camera_states = (
2024-09-24 16:05:30 +03:00
request . app . detected_frames_processor . camera_states . values ( )
2024-03-19 14:54:25 +03:00
)
for camera_state in camera_states :
2024-09-24 16:05:30 +03:00
if event_id in camera_state . tracked_objects :
tracked_obj = camera_state . tracked_objects . get ( event_id )
2024-03-19 14:54:25 +03:00
if tracked_obj is not None :
2025-10-14 16:08:41 +03:00
webp_bytes = tracked_obj . get_clean_webp ( )
2024-03-19 14:54:25 +03:00
break
except Exception :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Event not found " } ,
status_code = 404 ,
2024-03-19 14:54:25 +03:00
)
elif not event . has_snapshot :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Snapshot not available " } ,
status_code = 404 ,
2024-03-19 14:54:25 +03:00
)
except DoesNotExist :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Event not found " } , status_code = 404
2024-03-19 14:54:25 +03:00
)
2025-10-14 16:08:41 +03:00
if webp_bytes is None :
2024-03-19 14:54:25 +03:00
try :
2025-10-14 16:08:41 +03:00
# webp
clean_snapshot_path_webp = os . path . join (
CLIPS_DIR , f " { event . camera } - { event . id } -clean.webp "
)
# png (legacy)
clean_snapshot_path_png = os . path . join (
2024-03-19 14:54:25 +03:00
CLIPS_DIR , f " { event . camera } - { event . id } -clean.png "
)
2025-10-14 16:08:41 +03:00
if os . path . exists ( clean_snapshot_path_webp ) :
with open ( clean_snapshot_path_webp , " rb " ) as image_file :
webp_bytes = image_file . read ( )
elif os . path . exists ( clean_snapshot_path_png ) :
# convert png to webp and save for future use
png_image = cv2 . imread ( clean_snapshot_path_png , cv2 . IMREAD_UNCHANGED )
if png_image is None :
return JSONResponse (
content = {
" success " : False ,
" message " : " Invalid png snapshot " ,
} ,
status_code = 400 ,
)
ret , webp_data = cv2 . imencode (
" .webp " , png_image , [ int ( cv2 . IMWRITE_WEBP_QUALITY ) , 60 ]
)
if not ret :
return JSONResponse (
content = {
" success " : False ,
" message " : " Unable to convert png to webp " ,
} ,
status_code = 400 ,
)
webp_bytes = webp_data . tobytes ( )
# save the converted webp for future requests
try :
with open ( clean_snapshot_path_webp , " wb " ) as f :
f . write ( webp_bytes )
except Exception as e :
logger . warning (
f " Failed to save converted webp for event { event . id } : { e } "
)
# continue since we now have the data to return
else :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
" message " : " Clean snapshot not available " ,
} ,
status_code = 404 ,
2024-03-19 14:54:25 +03:00
)
except Exception :
2025-10-14 16:08:41 +03:00
logger . error ( f " Unable to load clean snapshot for event: { event . id } " )
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = {
" success " : False ,
2025-10-14 16:08:41 +03:00
" message " : " Unable to load clean snapshot for event " ,
2024-09-24 16:05:30 +03:00
} ,
status_code = 400 ,
2024-03-03 01:10:37 +03:00
)
2024-09-24 16:05:30 +03:00
headers = {
2025-10-14 16:08:41 +03:00
" Content-Type " : " image/webp " ,
2024-09-24 16:05:30 +03:00
" Cache-Control " : " private, max-age=31536000 " ,
}
2024-03-03 01:10:37 +03:00
if download :
2024-09-24 16:05:30 +03:00
headers [ " Content-Disposition " ] = (
2025-10-14 16:08:41 +03:00
f " attachment; filename=snapshot- { event_id } -clean.webp "
2024-03-03 01:10:37 +03:00
)
2024-09-24 17:50:20 +03:00
return Response (
2025-10-14 16:08:41 +03:00
webp_bytes ,
media_type = " image/webp " ,
2024-09-24 16:05:30 +03:00
headers = headers ,
)
2024-03-03 01:10:37 +03:00
2025-11-27 00:07:28 +03:00
@router.get (
" /events/ {event_id} /clip.mp4 " , dependencies = [ Depends ( require_camera_access ) ]
)
2025-09-28 16:08:52 +03:00
async def event_clip (
2025-08-25 21:33:17 +03:00
request : Request ,
event_id : str ,
padding : int = Query ( 0 , description = " Padding to apply to clip. " ) ,
) :
2024-03-03 01:10:37 +03:00
try :
2024-09-24 16:05:30 +03:00
event : Event = Event . get ( Event . id == event_id )
2024-03-03 01:10:37 +03:00
except DoesNotExist :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Event not found " } , status_code = 404
2024-03-03 01:10:37 +03:00
)
if not event . has_clip :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Clip not available " } , status_code = 404
2024-03-03 01:10:37 +03:00
)
2025-08-25 21:33:17 +03:00
end_ts = (
datetime . now ( ) . timestamp ( )
if event . end_time is None
else event . end_time + padding
)
2025-09-28 16:08:52 +03:00
return await recording_clip (
request , event . camera , event . start_time - padding , end_ts
)
2024-03-03 01:10:37 +03:00
2025-11-27 00:07:28 +03:00
@router.get (
" /events/ {event_id} /preview.gif " , dependencies = [ Depends ( require_camera_access ) ]
)
2024-09-24 16:05:30 +03:00
def event_preview ( request : Request , event_id : str ) :
2024-03-03 01:10:37 +03:00
try :
2024-09-24 16:05:30 +03:00
event : Event = Event . get ( Event . id == event_id )
2024-03-03 01:10:37 +03:00
except DoesNotExist :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Event not found " } , status_code = 404
2024-03-03 01:10:37 +03:00
)
start_ts = event . start_time
end_ts = start_ts + (
min ( event . end_time - event . start_time , 20 ) if event . end_time else 20
)
2024-09-24 16:05:30 +03:00
return preview_gif ( request , event . camera , start_ts , end_ts )
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} /start/ {start_ts} /end/ {end_ts} /preview.gif " ,
dependencies = [ Depends ( require_camera_access ) ] ,
)
2024-09-24 16:05:30 +03:00
def preview_gif (
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-03 01:10:37 +03:00
if datetime . fromtimestamp ( start_ts ) < datetime . now ( ) . replace ( minute = 0 , second = 0 ) :
# has preview mp4
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 ( )
)
if not preview :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Preview not found " } ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
diff = start_ts - preview . start_time
minutes = int ( diff / 60 )
seconds = int ( diff % 60 )
2024-09-24 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-03-03 01:10:37 +03:00
ffmpeg_cmd = [
2024-09-13 23:14:51 +03:00
config . ffmpeg . ffmpeg_path ,
2024-03-03 01:10:37 +03: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 " ,
" - " ,
]
process = sp . run (
ffmpeg_cmd ,
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to create preview gif " } ,
status_code = 500 ,
2024-03-03 01:10:37 +03:00
)
gif_bytes = process . stdout
else :
# need to generate from existing images
preview_dir = os . path . join ( CACHE_DIR , " preview_frames " )
file_start = f " preview_ { camera_name } "
2024-03-12 02:31:05 +03:00
start_file = f " { file_start } - { start_ts } . { PREVIEW_FRAME_TYPE } "
end_file = f " { file_start } - { end_ts } . { PREVIEW_FRAME_TYPE } "
2024-03-03 01:10:37 +03:00
selected_previews = [ ]
for file in sorted ( os . listdir ( preview_dir ) ) :
if not file . startswith ( file_start ) :
continue
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 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Preview not found " } ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
last_file = selected_previews [ - 2 ]
selected_previews . append ( last_file )
2024-09-24 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-03-03 01:10:37 +03:00
ffmpeg_cmd = [
2024-09-13 23:14:51 +03:00
config . ffmpeg . ffmpeg_path ,
2024-03-03 01:10:37 +03:00
" -hide_banner " ,
" -loglevel " ,
" warning " ,
" -f " ,
" concat " ,
" -y " ,
" -protocol_whitelist " ,
" pipe,file " ,
" -safe " ,
" 0 " ,
" -i " ,
" /dev/stdin " ,
" -loop " ,
" 0 " ,
" -c:v " ,
" gif " ,
" -f " ,
" gif " ,
" - " ,
]
process = sp . run (
ffmpeg_cmd ,
input = str . encode ( " \n " . join ( selected_previews ) ) ,
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to create preview gif " } ,
status_code = 500 ,
2024-03-03 01:10:37 +03:00
)
gif_bytes = process . stdout
2024-09-24 17:50:20 +03:00
return Response (
gif_bytes ,
2024-09-24 16:05:30 +03:00
media_type = " image/gif " ,
headers = {
" Cache-Control " : f " private, max-age= { max_cache_age } " ,
" Content-Type " : " image/gif " ,
} ,
)
2024-03-03 01:10:37 +03:00
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} /start/ {start_ts} /end/ {end_ts} /preview.mp4 " ,
dependencies = [ Depends ( require_camera_access ) ] ,
)
2024-09-24 16:05:30 +03:00
def preview_mp4 (
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 19:27:23 +03:00
if len ( file_name ) > 1000 :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = (
2024-04-27 19:27:23 +03:00
{
" success " : False ,
" message " : " Filename exceeded max length of 1000 characters. " ,
}
) ,
2024-09-24 16:05:30 +03:00
status_code = 403 ,
2024-04-27 19:27:23 +03:00
)
2024-04-20 01:11:41 +03:00
path = os . path . join ( CACHE_DIR , file_name )
2024-04-19 06:34:57 +03:00
if datetime . fromtimestamp ( start_ts ) < datetime . now ( ) . replace ( minute = 0 , second = 0 ) :
# has preview mp4
2024-04-20 01:11:41 +03: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-19 06:34:57 +03:00
)
2024-04-20 01:11:41 +03:00
except DoesNotExist :
preview = None
2024-04-19 06:34:57 +03:00
if not preview :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Preview not found " } ,
status_code = 404 ,
2024-04-19 06:34:57 +03:00
)
diff = start_ts - preview . start_time
minutes = int ( diff / 60 )
seconds = int ( diff % 60 )
2024-09-24 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-04-19 06:34:57 +03:00
ffmpeg_cmd = [
2024-09-13 23:14:51 +03:00
config . ffmpeg . ffmpeg_path ,
2024-04-19 06:34:57 +03:00
" -hide_banner " ,
" -loglevel " ,
" warning " ,
2024-04-20 01:11:41 +03:00
" -y " ,
2024-04-19 06:34:57 +03: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-20 01:11:41 +03:00
" libx264 " ,
" -movflags " ,
" +faststart " ,
path ,
2024-04-19 06:34:57 +03:00
]
process = sp . run (
ffmpeg_cmd ,
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to create preview gif " } ,
status_code = 500 ,
2024-04-19 06:34:57 +03:00
)
else :
# need to generate from existing images
preview_dir = os . path . join ( CACHE_DIR , " preview_frames " )
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 } "
selected_previews = [ ]
for file in sorted ( os . listdir ( preview_dir ) ) :
if not file . startswith ( file_start ) :
continue
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 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Preview not found " } ,
status_code = 404 ,
2024-04-19 06:34:57 +03:00
)
last_file = selected_previews [ - 2 ]
selected_previews . append ( last_file )
2024-09-24 16:05:30 +03:00
config : FrigateConfig = request . app . frigate_config
2024-04-19 06:34:57 +03:00
ffmpeg_cmd = [
2024-09-13 23:14:51 +03:00
config . ffmpeg . ffmpeg_path ,
2024-04-19 06:34:57 +03:00
" -hide_banner " ,
" -loglevel " ,
" warning " ,
" -f " ,
" concat " ,
" -y " ,
" -protocol_whitelist " ,
" pipe,file " ,
" -safe " ,
" 0 " ,
" -i " ,
" /dev/stdin " ,
" -c:v " ,
" libx264 " ,
2024-04-20 01:11:41 +03:00
" -movflags " ,
" +faststart " ,
path ,
2024-04-19 06:34:57 +03:00
]
process = sp . run (
ffmpeg_cmd ,
input = str . encode ( " \n " . join ( selected_previews ) ) ,
capture_output = True ,
)
if process . returncode != 0 :
logger . error ( process . stderr )
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = { " success " : False , " message " : " Unable to create preview gif " } ,
status_code = 500 ,
2024-04-19 06:34:57 +03:00
)
2024-09-24 16:05:30 +03:00
headers = {
" Content-Description " : " File Transfer " ,
" Cache-Control " : f " private, max-age= { max_cache_age } " ,
" 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 } " ,
}
2024-04-19 06:34:57 +03:00
2024-09-24 16:05:30 +03:00
return FileResponse (
path ,
media_type = " video/mp4 " ,
filename = file_name ,
headers = headers ,
)
2024-04-19 06:34:57 +03:00
2025-11-27 00:07:28 +03:00
@router.get ( " /review/ {event_id} /preview " , dependencies = [ Depends ( require_camera_access ) ] )
2024-09-24 16:05:30 +03:00
def review_preview (
request : Request ,
event_id : str ,
format : str = Query ( default = " gif " , enum = [ " gif " , " mp4 " ] ) ,
) :
2024-03-03 01:10:37 +03:00
try :
2024-09-24 16:05:30 +03:00
review : ReviewSegment = ReviewSegment . get ( ReviewSegment . id == event_id )
2024-03-03 01:10:37 +03:00
except DoesNotExist :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = ( { " success " : False , " message " : " Review segment not found " } ) ,
status_code = 404 ,
2024-03-03 01:10:37 +03:00
)
padding = 8
start_ts = review . start_time - padding
2024-04-11 15:42:16 +03:00
end_ts = (
review . end_time + padding if review . end_time else datetime . now ( ) . timestamp ( )
)
2024-04-19 06:34:57 +03:00
if format == " gif " :
2024-09-24 16:05:30 +03:00
return preview_gif ( request , review . camera , start_ts , end_ts )
2024-04-19 06:34:57 +03:00
else :
2024-09-24 16:05:30 +03:00
return preview_mp4 ( request , review . camera , start_ts , end_ts )
2024-03-03 01:10:37 +03:00
2025-11-27 00:07:28 +03:00
@router.get (
" /preview/ {file_name} /thumbnail.jpg " , dependencies = [ Depends ( require_camera_access ) ]
)
@router.get (
" /preview/ {file_name} /thumbnail.webp " , dependencies = [ Depends ( require_camera_access ) ]
)
2024-03-03 01:10:37 +03:00
def preview_thumbnail ( file_name : str ) :
2024-03-12 02:31:05 +03:00
""" Get a thumbnail from the cached preview frames. """
2024-04-27 19:27:23 +03:00
if len ( file_name ) > 1000 :
2024-09-24 16:05:30 +03:00
return JSONResponse (
content = (
2024-04-27 19:27:23 +03:00
{ " success " : False , " message " : " Filename exceeded max length of 1000 " }
) ,
2024-09-24 16:05:30 +03:00
status_code = 403 ,
2024-04-27 19:27:23 +03:00
)
2024-09-24 16:05:30 +03:00
safe_file_name_current = sanitize_filename ( file_name )
2024-03-03 01:10:37 +03:00
preview_dir = os . path . join ( CACHE_DIR , " preview_frames " )
2024-04-11 15:42:16 +03: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 16:05:30 +03:00
return JSONResponse (
content = ( { " success " : False , " message " : " Image file not found " } ) ,
status_code = 404 ,
2024-04-11 15:42:16 +03:00
)
2024-03-03 01:10:37 +03:00
2024-09-24 17:50:20 +03:00
return Response (
jpg_bytes ,
2024-09-24 16:05:30 +03:00
media_type = " image/webp " ,
headers = {
" Content-Type " : " image/webp " ,
" Cache-Control " : " private, max-age=31536000 " ,
} ,
)
2024-09-27 21:09:53 +03:00
####################### dynamic routes ###########################
2025-09-12 14:19:29 +03: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 16:08:52 +03:00
async def label_thumbnail ( request : Request , camera_name : str , label : str ) :
2024-09-27 21:09:53 +03: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 16:08:52 +03:00
return await event_thumbnail ( request , event_id , Extension . jpg , 60 )
2024-09-27 21:09:53 +03: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 15:31:01 +03:00
jpg . tobytes ( ) ,
2024-09-27 21:09:53 +03:00
media_type = " image/jpeg " ,
headers = { " Cache-Control " : " no-store " } ,
)
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} / {label} /clip.mp4 " , dependencies = [ Depends ( require_camera_access ) ]
)
2025-09-28 16:08:52 +03:00
async def label_clip ( request : Request , camera_name : str , label : str ) :
2024-09-27 21:09:53 +03: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 ( )
2025-09-28 16:08:52 +03:00
return await event_clip ( request , event . id )
2024-09-27 21:09:53 +03:00
except DoesNotExist :
return JSONResponse (
content = { " success " : False , " message " : " Event not found " } , status_code = 404
)
2025-09-12 14:19:29 +03:00
@router.get (
" / {camera_name} / {label} /snapshot.jpg " , dependencies = [ Depends ( require_camera_access ) ]
)
2025-09-28 16:08:52 +03:00
async def label_snapshot ( request : Request , camera_name : str , label : str ) :
2024-09-27 21:09:53 +03: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 22:11:49 +03:00
event : Event = event_query . get ( )
2025-09-28 16:08:52 +03:00
return await event_snapshot ( request , event . id , MediaEventsSnapshotQueryParams ( ) )
2024-09-27 21:09:53 +03:00
except DoesNotExist :
frame = np . zeros ( ( 720 , 1280 , 3 ) , np . uint8 )
2024-10-19 22:11:49 +03:00
_ , jpg = cv2 . imencode ( " .jpg " , frame , [ int ( cv2 . IMWRITE_JPEG_QUALITY ) , 70 ] )
2024-09-27 21:09:53 +03:00
return Response (
2024-10-31 15:31:01 +03:00
jpg . tobytes ( ) ,
2024-09-27 21:09:53 +03:00
media_type = " image/jpeg " ,
)