From 4368785b92a94e3740b27cb25582f7467bab223e Mon Sep 17 00:00:00 2001 From: Rui Alves Personal Date: Sat, 24 Aug 2024 17:49:02 +0100 Subject: [PATCH] FastAPI example POC --- .../rootfs/usr/local/nginx/conf/nginx.conf | 16 ++- frigate/api/app.py | 49 +++++---- frigate/api/media.py | 104 ++++++++++-------- frigate/api/new_app.py | 37 +++++++ frigate/api/preview.py | 48 ++++---- frigate/app.py | 15 ++- 6 files changed, 171 insertions(+), 98 deletions(-) create mode 100644 frigate/api/new_app.py diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 186b7037c..609b3d6ad 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -222,12 +222,16 @@ http { include proxy.conf; } - location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { - include auth_request.conf; - rewrite ^/api/(.*)$ $1 break; - proxy_pass http://frigate_api; - include proxy.conf; - } +# FIXME: Needed to disabled this rule, otherwise it fails for endpoints that end with one of those file extensions +# 1. with httptools it passes the auth.conf but then throws a 400 error "WARN "Invalid HTTP request received." -> https://github.com/encode/uvicorn/blob/47304d9ae76321f0f5f649ff4f73e09b17085933/uvicorn/protocols/http/httptools_impl.py#L165 +# 2. With h11 it goes through the auth.conf but returns a 404 error +# We might need to add extra rules that will allow endpoint that end with an extension OR find a fix without creating other rules +# location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { +# include auth_request.conf; +# rewrite ^/api/(.*)$ $1 break; +# proxy_pass http://frigate_api; +# include proxy.conf; +# } location /api/ { include auth_request.conf; diff --git a/frigate/api/app.py b/frigate/api/app.py index 46a2b9c13..7f18a6c97 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -10,6 +10,9 @@ from functools import reduce from typing import Optional import requests +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse from flask import Blueprint, Flask, current_app, jsonify, make_response, request from markupsafe import escape from peewee import operator @@ -21,7 +24,6 @@ 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.preview import PreviewBp from frigate.api.review import ReviewBp from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR @@ -47,11 +49,12 @@ bp = Blueprint("frigate", __name__) bp.register_blueprint(EventBp) bp.register_blueprint(ExportBp) bp.register_blueprint(MediaBp) -bp.register_blueprint(PreviewBp) bp.register_blueprint(ReviewBp) bp.register_blueprint(AuthBp) bp.register_blueprint(NotificationBp) +router = APIRouter() + def create_app( frigate_config, @@ -454,19 +457,26 @@ def vainfo(): ) -@bp.route("/logs/", methods=["GET"]) -def logs(service: str): +@router.get("/logs/{service}", tags=["Logs"]) +def logs( + service: str, + download: Optional[str] = None, + start: Optional[int] = 0, + end: Optional[int] = None, +): + """Get logs for the requested service (frigate/nginx/go2rtc/chroma)""" + def download_logs(service_location: str): try: file = open(service_location, "r") contents = file.read() file.close() - return jsonify(contents) + return JSONResponse(jsonable_encoder(contents)) except FileNotFoundError as e: logger.error(e) - return make_response( - jsonify({"success": False, "message": "Could not find log file"}), - 500, + return JSONResponse( + content={"success": False, "message": "Could not find log file"}, + status_code=500, ) log_locations = { @@ -478,17 +488,14 @@ def logs(service: str): service_location = log_locations.get(service) if not service_location: - return make_response( - jsonify({"success": False, "message": "Not a valid service"}), - 404, + return JSONResponse( + content={"success": False, "message": "Not a valid service"}, + status_code=404, ) - if request.args.get("download", type=bool, default=False): + if download: return download_logs(service_location) - start = request.args.get("start", type=int, default=0) - end = request.args.get("end", type=int) - try: file = open(service_location, "r") contents = file.read() @@ -529,15 +536,15 @@ def logs(service: str): logLines.append(currentLine) - return make_response( - jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}), - 200, + return JSONResponse( + content={"totalLines": len(logLines), "lines": logLines[start:end]}, + status_code=200, ) except FileNotFoundError as e: logger.error(e) - return make_response( - jsonify({"success": False, "message": "Could not find log file"}), - 500, + return JSONResponse( + content={"success": False, "message": "Could not find log file"}, + status_code=500, ) diff --git a/frigate/api/media.py b/frigate/api/media.py index 911e13f7e..69e759395 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -2,16 +2,20 @@ 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 fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse from flask import Blueprint, Response, current_app, jsonify, make_response, request from peewee import DoesNotExist, fn from tzlocal import get_localzone_name @@ -32,6 +36,8 @@ logger = logging.getLogger(__name__) MediaBp = Blueprint("media", __name__) +router = APIRouter(tags=["Media"]) + @MediaBp.route("/") def mjpeg_feed(camera_name): @@ -92,90 +98,102 @@ def camera_ptz_info(camera_name): 404, ) - -@MediaBp.route("//latest.jpg") -@MediaBp.route("//latest.webp") -def latest_frame(camera_name): +@router.get("/{camera_name}/latest.{extension}") +def latest_frame( + request: Request, + camera_name: str, + extension: str, # jpg/jpeg/png/webp + 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, + h: 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) - + retry_interval + 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(h 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(h 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, ) diff --git a/frigate/api/new_app.py b/frigate/api/new_app.py new file mode 100644 index 000000000..985fb9de7 --- /dev/null +++ b/frigate/api/new_app.py @@ -0,0 +1,37 @@ +import logging + +from fastapi import FastAPI + +from frigate.api import app as main_app +from frigate.api import media, preview + +logger = logging.getLogger(__name__) + +# https://fastapi.tiangolo.com/tutorial/metadata/#use-your-tags +tags_metadata = [ + { + "name": "Preview", + "description": "Preview routes", + }, + { + "name": "Logs", + "description": "Logs routes", + }, + { + "name": "Media", + "description": "Media routes", + }, +] + + +def create_fastapi_app(frigate_config, detected_frames_processor): + logger.info("Starting FastAPI app") + app = FastAPI(debug=False, tags_metadata=tags_metadata) + app.include_router(preview.router) + app.include_router(media.router) + app.include_router(main_app.router) + app.frigate_config = frigate_config + app.detected_frames_processor = detected_frames_processor + app.camera_error_image = None + + return app diff --git a/frigate/api/preview.py b/frigate/api/preview.py index 46d3d0e82..3f3611a97 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -5,23 +5,20 @@ import os from datetime import datetime, timedelta, timezone import pytz -from flask import ( - Blueprint, - jsonify, - make_response, -) +from fastapi import APIRouter +from fastapi.responses import JSONResponse from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews logger = logging.getLogger(__name__) -PreviewBp = Blueprint("previews", __name__) + +router = APIRouter(tags=["Preview"]) -@PreviewBp.route("/preview//start//end/") -@PreviewBp.route("/preview//start//end/") -def preview_ts(camera_name, start_ts, end_ts): +@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}") +def preview_ts(camera_name: str, start_ts: float, end_ts: float): """Get all mp4 previews relevant for time period.""" if camera_name != "all": camera_clause = Previews.camera == camera_name @@ -62,21 +59,20 @@ def preview_ts(camera_name, start_ts, end_ts): ) if not clips: - return make_response( - jsonify( - { - "success": False, - "message": "No previews found.", - } - ), - 404, + return JSONResponse( + content={ + "success": False, + "message": "No previews found.", + }, + status_code=404, ) - return make_response(jsonify(clips), 200) + return JSONResponse(content=clips, status_code=200) -@PreviewBp.route("/preview/////") -def preview_hour(year_month, day, hour, camera_name, tz_name): +@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}") +def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): + """Get all mp4 previews relevant for time period given the timezone""" parts = year_month.split("-") start_date = ( datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc) @@ -89,11 +85,8 @@ def preview_hour(year_month, day, hour, camera_name, tz_name): return preview_ts(camera_name, start_ts, end_ts) -@PreviewBp.route("/preview//start//end//frames") -@PreviewBp.route( - "/preview//start//end//frames" -) -def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts): +@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames") +def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float): """Get list of cached preview frames""" preview_dir = os.path.join(CACHE_DIR, "preview_frames") file_start = f"preview_{camera_name}" @@ -113,4 +106,7 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts): selected_previews.append(file) - return jsonify(selected_previews) + return JSONResponse( + content=selected_previews, + status_code=200, + ) diff --git a/frigate/app.py b/frigate/app.py index 9149f9854..603b166d1 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -14,6 +14,8 @@ from types import FrameType from typing import Optional import psutil +import uvicorn +from fastapi.middleware.wsgi import WSGIMiddleware from peewee_migrate import Router from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase @@ -21,6 +23,7 @@ from pydantic import ValidationError from frigate.api.app import create_app from frigate.api.auth import hash_password +from frigate.api.new_app import create_fastapi_app from frigate.comms.config_updater import ConfigPublisher from frigate.comms.dispatcher import Communicator, Dispatcher from frigate.comms.inter_process import InterProcessCommunicator @@ -397,6 +400,8 @@ class FrigateApp: self.stats_emitter, ) + self.fastapi_app = create_fastapi_app(self.config, self.detected_frames_processor) + def init_onvif(self) -> None: self.onvif_controller = OnvifController(self.config, self.ptz_metrics) @@ -739,11 +744,17 @@ class FrigateApp: signal.signal(signal.SIGTERM, receiveSignal) try: - self.flask_app.run(host="127.0.0.1", port=5001, debug=False, threaded=True) + # Run the flask app inside fastapi: https://fastapi.tiangolo.com/advanced/sub-applications/ + self.fastapi_app.mount("", WSGIMiddleware(self.flask_app)) + uvicorn.run( + self.fastapi_app, + host="127.0.0.1", + port=5001, + ) except KeyboardInterrupt: pass - logger.info("Flask has exited...") + logger.info("FastAPI/Flask has exited...") self.stop()