diff --git a/frigate/api/app.py b/frigate/api/app.py index 66e2522cd..62e37131f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -23,7 +23,6 @@ from frigate.api.auth import AuthBp, get_jwt_secret, limiter from frigate.api.defs.tags import Tags from frigate.api.event import EventBp from frigate.api.export import ExportBp -from frigate.api.media import MediaBp from frigate.api.notification import NotificationBp from frigate.api.review import ReviewBp from frigate.config import FrigateConfig @@ -49,7 +48,6 @@ logger = logging.getLogger(__name__) bp = Blueprint("frigate", __name__) bp.register_blueprint(EventBp) bp.register_blueprint(ExportBp) -bp.register_blueprint(MediaBp) bp.register_blueprint(ReviewBp) bp.register_blueprint(AuthBp) bp.register_blueprint(NotificationBp) diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index bda938f2b..9dd7b0610 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -3,7 +3,7 @@ import logging from fastapi import FastAPI from frigate.api import app as main_app -from frigate.api import preview +from frigate.api import media, preview from frigate.plus import PlusApi from frigate.ptz.onvif import OnvifController from frigate.stats.emitter import StatsEmitter @@ -21,9 +21,13 @@ def create_fastapi_app( stats_emitter: StatsEmitter, ): logger.info("Starting FastAPI app") - app = FastAPI(debug=False) + app = FastAPI( + debug=False, + swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, + ) # Routes app.include_router(main_app.router) + app.include_router(media.router) app.include_router(preview.router) # App Properties app.frigate_config = frigate_config diff --git a/frigate/api/media.py b/frigate/api/media.py index 092958581..edeece263 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -2,21 +2,25 @@ import base64 import glob +import io import logging import os import subprocess as sp import time from datetime import datetime, timedelta, timezone +from typing import Optional from urllib.parse import unquote import cv2 import numpy as np import pytz -from flask import Blueprint, Response, current_app, jsonify, make_response, request +from fastapi import APIRouter, Path, Query, Request, Response +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from peewee import DoesNotExist, fn from tzlocal import get_localzone_name from werkzeug.utils import secure_filename +from frigate.api.defs.tags import Tags from frigate.const import ( CACHE_DIR, CLIPS_DIR, @@ -30,41 +34,53 @@ from frigate.util.image import get_image_from_recording logger = logging.getLogger(__name__) -MediaBp = Blueprint("media", __name__) + +router = APIRouter(tags=[Tags.media]) -@MediaBp.route("/") -def mjpeg_feed(camera_name): - fps = int(request.args.get("fps", "3")) - height = int(request.args.get("h", "360")) +@router.get("/media/camera/{camera_name}") +def mjpeg_feed( + request: Request, + camera_name: str, + fps: int = 3, + height: int = 360, + bbox: Optional[int] = None, + timestamp: Optional[int] = None, + zones: Optional[int] = None, + mask: Optional[int] = None, + motion: Optional[int] = None, + regions: Optional[int] = None, +): draw_options = { - "bounding_boxes": request.args.get("bbox", type=int), - "timestamp": request.args.get("timestamp", type=int), - "zones": request.args.get("zones", type=int), - "mask": request.args.get("mask", type=int), - "motion_boxes": request.args.get("motion", type=int), - "regions": request.args.get("regions", type=int), + "bounding_boxes": bbox, + "timestamp": timestamp, + "zones": zones, + "mask": mask, + "motion_boxes": motion, + "regions": regions, } - if camera_name in current_app.frigate_config.cameras: + if camera_name in request.app.frigate_config.cameras: # return a multipart response - return Response( + return StreamingResponse( imagestream( - current_app.detected_frames_processor, + request.app.detected_frames_processor, camera_name, fps, height, draw_options, ), - mimetype="multipart/x-mixed-replace; boundary=frame", + media_type="multipart/x-mixed-replace;boundary=frame", ) else: - return make_response( - jsonify({"success": False, "message": "Camera not found"}), - 404, + return JSONResponse( + content={"success": False, "message": "Camera not found"}, + status_code=404, ) -def imagestream(detected_frames_processor, camera_name, fps, height, draw_options): +def imagestream( + detected_frames_processor, camera_name: str, fps: int, height: int, draw_options +): while True: # max out at specified FPS time.sleep(1 / fps) @@ -78,122 +94,136 @@ def imagestream(detected_frames_processor, camera_name, fps, height, draw_option ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) yield ( b"--frame\r\n" - b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n\r\n" + b"Content-Type: image/jpeg\r\n\r\n" + bytearray(jpg.tobytes()) + b"\r\n\r\n" ) -@MediaBp.route("//ptz/info") -def camera_ptz_info(camera_name): - if camera_name in current_app.frigate_config.cameras: - return jsonify(current_app.onvif.get_camera_info(camera_name)) +@router.get("/media/camera/{camera_name}/ptz/info") +def camera_ptz_info(request: Request, camera_name: str): + if camera_name in request.app.frigate_config.cameras: + return JSONResponse( + content=request.app.onvif.get_camera_info(camera_name), + ) else: - return make_response( - jsonify({"success": False, "message": "Camera not found"}), - 404, + return JSONResponse( + content={"success": False, "message": "Camera not found"}, + status_code=404, ) -@MediaBp.route("//latest.jpg") -@MediaBp.route("//latest.webp") -def latest_frame(camera_name): +@router.get("/media/camera/{camera_name}/frame/latest") +def latest_frame( + request: Request, + camera_name: str, + extension: Optional[str] = Query("webp", enum=["webp", "png", "jpg", "jpeg"]), + bbox: Optional[int] = None, + timestamp: Optional[int] = None, + zones: Optional[int] = None, + mask: Optional[int] = None, + motion: Optional[int] = None, + regions: Optional[int] = None, + quality: Optional[int] = 70, + height: Optional[int] = None, +): draw_options = { - "bounding_boxes": request.args.get("bbox", type=int), - "timestamp": request.args.get("timestamp", type=int), - "zones": request.args.get("zones", type=int), - "mask": request.args.get("mask", type=int), - "motion_boxes": request.args.get("motion", type=int), - "regions": request.args.get("regions", type=int), + "bounding_boxes": bbox, + "timestamp": timestamp, + "zones": zones, + "mask": mask, + "motion_boxes": motion, + "regions": regions, } - resize_quality = request.args.get("quality", default=70, type=int) - extension = os.path.splitext(request.path)[1][1:] - if camera_name in current_app.frigate_config.cameras: - frame = current_app.detected_frames_processor.get_current_frame( + if camera_name in request.app.frigate_config.cameras: + frame = request.app.detected_frames_processor.get_current_frame( camera_name, draw_options ) retry_interval = float( - current_app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval + request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval or 10 ) if frame is None or datetime.now().timestamp() > ( - current_app.detected_frames_processor.get_current_frame_time(camera_name) + request.app.detected_frames_processor.get_current_frame_time(camera_name) + retry_interval ): - if current_app.camera_error_image is None: + if request.app.camera_error_image is None: error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg") if len(error_image) > 0: - current_app.camera_error_image = cv2.imread( + request.app.camera_error_image = cv2.imread( error_image[0], cv2.IMREAD_UNCHANGED ) - frame = current_app.camera_error_image + frame = request.app.camera_error_image - height = int(request.args.get("h", str(frame.shape[0]))) + height = int(height or str(frame.shape[0])) width = int(height * frame.shape[1] / frame.shape[0]) if frame is None: - return make_response( - jsonify({"success": False, "message": "Unable to get valid frame"}), - 500, + return JSONResponse( + content={"success": False, "message": "Unable to get valid frame"}, + status_code=500, ) if height < 1 or width < 1: - return ( - "Invalid height / width requested :: {} / {}".format(height, width), - 400, + return JSONResponse( + content="Invalid height / width requested :: {} / {}".format( + height, width + ), + status_code=400, ) frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) ret, img = cv2.imencode( - f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality] + f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), quality] ) - response = make_response(img.tobytes()) - response.headers["Content-Type"] = f"image/{extension}" - response.headers["Cache-Control"] = "no-store" - return response - elif camera_name == "birdseye" and current_app.frigate_config.birdseye.restream: + return StreamingResponse( + io.BytesIO(img.tobytes()), + media_type=f"image/{extension}", + headers={"Content-Type": f"image/{extension}", "Cache-Control": "no-store"}, + ) + elif camera_name == "birdseye" and request.app.frigate_config.birdseye.restream: frame = cv2.cvtColor( - current_app.detected_frames_processor.get_current_frame(camera_name), + request.app.detected_frames_processor.get_current_frame(camera_name), cv2.COLOR_YUV2BGR_I420, ) - height = int(request.args.get("h", str(frame.shape[0]))) + height = int(height or str(frame.shape[0])) width = int(height * frame.shape[1] / frame.shape[0]) frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) ret, img = cv2.imencode( - f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality] + f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), quality] + ) + return StreamingResponse( + io.BytesIO(img.tobytes()), + media_type=f"image/{extension}", + headers={"Content-Type": f"image/{extension}", "Cache-Control": "no-store"}, ) - response = make_response(img.tobytes()) - response.headers["Content-Type"] = f"image/{extension}" - response.headers["Cache-Control"] = "no-store" - return response else: - return make_response( - jsonify({"success": False, "message": "Camera not found"}), - 404, + return JSONResponse( + content={"success": False, "message": "Camera not found"}, + status_code=404, ) -@MediaBp.route("//recordings//snapshot.") -def get_snapshot_from_recording(camera_name: str, frame_time: str, format: str): - if camera_name not in current_app.frigate_config.cameras: - return make_response( - jsonify({"success": False, "message": "Camera not found"}), - 404, +@router.get("/media/camera/{camera_name}/recordings/{frame_time}/snapshot.{format}") +def get_snapshot_from_recording( + 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, ) - if format not in ["png", "jpg"]: - return make_response( - jsonify({"success": False, "message": "Invalid format"}), - 400, - ) - - frame_time = float(frame_time) recording_query = ( Recordings.select( Recordings.path, @@ -213,46 +243,40 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str, format: str): try: recording: Recordings = recording_query.get() time_in_segment = frame_time - recording.start_time - - height = request.args.get("height", type=int) codec = "png" if format == "png" else "mjpeg" - image_data = get_image_from_recording( recording.path, time_in_segment, codec, height ) if not image_data: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": f"Unable to parse frame at time {frame_time}", } ), - 404, + status_code=404, ) - - response = make_response(image_data) - response.headers["Content-Type"] = f"image/{format}" - return response + return Response(image_data, headers={"Content-Type": f"image/{format}"}) except DoesNotExist: - return make_response( - jsonify( - { - "success": False, - "message": "Recording not found at {}".format(frame_time), - } - ), - 404, + return JSONResponse( + content={ + "success": False, + "message": "Recording not found at {}".format(frame_time), + }, + status_code=404, ) -@MediaBp.route("//plus/", methods=("POST",)) -def submit_recording_snapshot_to_plus(camera_name: str, frame_time: str): - if camera_name not in current_app.frigate_config.cameras: - return make_response( - jsonify({"success": False, "message": "Camera not found"}), - 404, +@router.post("/media/camera/{camera_name}/plus/{frame_time}") +def submit_recording_snapshot_to_plus( + 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, ) frame_time = float(frame_time) @@ -275,56 +299,50 @@ def submit_recording_snapshot_to_plus(camera_name: str, frame_time: str): try: recording: Recordings = recording_query.get() time_in_segment = frame_time - recording.start_time - image_data = get_image_from_recording(recording.path, time_in_segment, "png") + image_data = get_image_from_recording(recording.path, time_in_segment) if not image_data: - return make_response( - jsonify( - { - "success": False, - "message": f"Unable to parse frame at time {frame_time}", - } - ), - 404, + return JSONResponse( + content={ + "success": False, + "message": f"Unable to parse frame at time {frame_time}", + }, + status_code=404, ) nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR) - current_app.plus_api.upload_image(nd, camera_name) + request.app.plus_api.upload_image(nd, camera_name) - return make_response( - jsonify( - { - "success": True, - "message": "Successfully submitted image.", - } - ), - 200, + return JSONResponse( + content={ + "success": True, + "message": "Successfully submitted image.", + }, + status_code=200, ) except DoesNotExist: - return make_response( - jsonify( - { - "success": False, - "message": "Recording not found at {}".format(frame_time), - } - ), - 404, + return JSONResponse( + content={ + "success": False, + "message": "Recording not found at {}".format(frame_time), + }, + status_code=404, ) -@MediaBp.route("/recordings/storage", methods=["GET"]) -def get_recordings_storage_usage(): - recording_stats = current_app.stats_emitter.get_latest_stats()["service"][ +@router.get("/media/recordings/storage") +def get_recordings_storage_usage(request: Request): + recording_stats = request.app.stats_emitter.get_latest_stats()["service"][ "storage" ][RECORD_DIR] if not recording_stats: - return jsonify({}) + return JSONResponse({}) total_mb = recording_stats["total"] camera_usages: dict[str, dict] = ( - current_app.storage_maintainer.calculate_camera_usages() + request.app.storage_maintainer.calculate_camera_usages() ) for camera_name in camera_usages.keys(): @@ -333,14 +351,13 @@ def get_recordings_storage_usage(): camera_usages.get(camera_name, {}).get("usage", 0) / total_mb ) * 100 - return jsonify(camera_usages) + return JSONResponse(content=camera_usages) -# return hourly summary for recordings of camera -@MediaBp.route("//recordings/summary") -def recordings_summary(camera_name): - tz_name = request.args.get("timezone", default="utc", type=str) - hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) +@router.get("/media/camera/{camera_name}/recordings/summary") +def recordings_summary(camera_name: str, timezone: str = "utc"): + """Returns hourly summary for recordings of given camera""" + hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone) recording_groups = ( Recordings.select( fn.strftime( @@ -396,17 +413,16 @@ def recordings_summary(camera_name): days[day]["events"] += events_count days[day]["hours"].append(hour_data) - return jsonify(list(days.values())) + return JSONResponse(content=list(days.values())) -# return hour of recordings data for camera -@MediaBp.route("//recordings") -def recordings(camera_name): - after = request.args.get( - "after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp() - ) - before = request.args.get("before", type=float, default=datetime.now().timestamp()) - +@router.get("/media/camera/{camera_name}/recordings") +def recordings( + 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""" recordings = ( Recordings.select( Recordings.id, @@ -427,14 +443,13 @@ def recordings(camera_name): .iterator() ) - return jsonify(list(recordings)) + return JSONResponse(content=list(recordings)) -@MediaBp.route("//start//end//clip.mp4") -@MediaBp.route("//start//end//clip.mp4") -def recording_clip(camera_name, start_ts, end_ts): - download = request.args.get("download", type=bool) - +@router.get("/media/camera/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4") +def recording_clip( + camera_name: str, start_ts: float, end_ts: float, download: bool = False +): recordings = ( Recordings.select( Recordings.path, @@ -464,11 +479,12 @@ def recording_clip(camera_name, start_ts, end_ts): file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4" if len(file_name) > 1000: - return make_response( - jsonify( - {"success": False, "message": "Filename exceeded max length of 1000"} - ), - 403, + return JSONResponse( + content={ + "success": False, + "message": "Filename exceeded max length of 1000", + }, + status_code=403, ) file_name = secure_filename(file_name) @@ -502,37 +518,40 @@ def recording_clip(camera_name, start_ts, end_ts): if p.returncode != 0: logger.error(p.stderr) - return make_response( - jsonify( - { - "success": False, - "message": "Could not create clip from recordings", - } - ), - 500, + return JSONResponse( + content={ + "success": False, + "message": "Could not create clip from recordings", + }, + status_code=500, ) else: logger.debug( f"Ignoring subsequent request for {path} as it already exists in the cache." ) - response = make_response() - response.headers["Content-Description"] = "File Transfer" - response.headers["Cache-Control"] = "no-cache" - response.headers["Content-Type"] = "video/mp4" + headers = { + "Content-Description": "File Transfer", + "Cache-Control": "no-cache", + "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"/clips/cache/{file_name}", + } + if download: - response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name - response.headers["Content-Length"] = os.path.getsize(path) - response.headers["X-Accel-Redirect"] = ( - f"/clips/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers + headers["Content-Disposition"] = "attachment; filename=%s" % file_name + + return FileResponse( + path, + media_type="video/mp4", + filename=file_name, + headers=headers, ) - return response - -@MediaBp.route("/vod//start//end/") -@MediaBp.route("/vod//start//end/") -def vod_ts(camera_name, start_ts, end_ts): +@router.get("/vod/{camera_name}/start/{start_ts}/end/{end_ts}") +def vod_ts(camera_name: str, start_ts: float, end_ts: float): recordings = ( Recordings.select(Recordings.path, Recordings.duration, Recordings.end_time) .where( @@ -574,19 +593,17 @@ def vod_ts(camera_name, start_ts, end_ts): logger.error( f"No recordings found for {camera_name} during the requested time range" ) - return make_response( - jsonify( - { - "success": False, - "message": "No recordings found.", - } - ), - 404, + return JSONResponse( + content={ + "success": False, + "message": "No recordings found.", + }, + status_code=404, ) hour_ago = datetime.now() - timedelta(hours=1) - return jsonify( - { + return JSONResponse( + content={ "cache": hour_ago.timestamp() > start_ts, "discontinuity": False, "consistentSequenceMediaInfo": True, @@ -597,18 +614,19 @@ def vod_ts(camera_name, start_ts, end_ts): ) -@MediaBp.route("/vod////") -def vod_hour_no_timezone(year_month, day, hour, camera_name): +@router.get("/vod/{year_month}/{day}/{hour}/{camera_name}") +def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str): + """VOD for specific hour. Uses the default timezone (UTC).""" return vod_hour( year_month, day, hour, camera_name, get_localzone_name().replace("/", ",") ) -@MediaBp.route("/vod/////") -def vod_hour(year_month, day, hour, camera_name, tz_name): +@router.get("/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}") +def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): parts = year_month.split("-") start_date = ( - datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc) + datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=timezone.utc) - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) @@ -618,32 +636,28 @@ def vod_hour(year_month, day, hour, camera_name, tz_name): return vod_ts(camera_name, start_ts, end_ts) -@MediaBp.route("/vod/event/") -def vod_event(id): +@router.get("/vod/event/{event_id}") +def vod_event(event_id: str): try: - event: Event = Event.get(Event.id == id) + event: Event = Event.get(Event.id == event_id) except DoesNotExist: - logger.error(f"Event not found: {id}") - return make_response( - jsonify( - { - "success": False, - "message": "Event not found.", - } - ), - 404, + logger.error(f"Event not found: {event_id}") + return JSONResponse( + content={ + "success": False, + "message": "Event not found.", + }, + status_code=404, ) if not event.has_clip: - logger.error(f"Event does not have recordings: {id}") - return make_response( - jsonify( - { - "success": False, - "message": "Recordings not available.", - } - ), - 404, + logger.error(f"Event does not have recordings: {event_id}") + return JSONResponse( + content={ + "success": False, + "message": "Recordings not available.", + }, + status_code=404, ) clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.mp4") @@ -660,12 +674,12 @@ def vod_event(id): and len(vod_response) == 2 and vod_response[1] == 404 ): - Event.update(has_clip=False).where(Event.id == id).execute() + Event.update(has_clip=False).where(Event.id == event_id).execute() return vod_response duration = int((event.end_time - event.start_time) * 1000) - return jsonify( - { + return JSONResponse( + content={ "cache": True, "discontinuity": False, "durations": [duration], @@ -674,8 +688,9 @@ def vod_event(id): ) -@MediaBp.route("//