FastAPI example POC

This commit is contained in:
Rui Alves Personal 2024-08-24 17:49:02 +01:00
parent bf11ae34eb
commit d99bfa1730
6 changed files with 180 additions and 107 deletions

View File

@ -222,12 +222,16 @@ http {
include proxy.conf; include proxy.conf;
} }
location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { # FIXME: Needed to disabled this rule, otherwise it fails for endpoints that end with one of those file extensions
include auth_request.conf; # 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
rewrite ^/api/(.*)$ $1 break; # 2. With h11 it goes through the auth.conf but returns a 404 error
proxy_pass http://frigate_api; # We might need to add extra rules that will allow endpoint that end with an extension OR find a fix without creating other rules
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;
# }
location /api/ { location /api/ {
include auth_request.conf; include auth_request.conf;

View File

@ -10,6 +10,9 @@ from functools import reduce
from typing import Optional from typing import Optional
import requests 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 flask import Blueprint, Flask, current_app, jsonify, make_response, request
from markupsafe import escape from markupsafe import escape
from peewee import operator from peewee import operator
@ -21,7 +24,6 @@ from frigate.api.event import EventBp
from frigate.api.export import ExportBp from frigate.api.export import ExportBp
from frigate.api.media import MediaBp from frigate.api.media import MediaBp
from frigate.api.notification import NotificationBp from frigate.api.notification import NotificationBp
from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp from frigate.api.review import ReviewBp
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR from frigate.const import CONFIG_DIR
@ -47,22 +49,23 @@ bp = Blueprint("frigate", __name__)
bp.register_blueprint(EventBp) bp.register_blueprint(EventBp)
bp.register_blueprint(ExportBp) bp.register_blueprint(ExportBp)
bp.register_blueprint(MediaBp) bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp) bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp) bp.register_blueprint(AuthBp)
bp.register_blueprint(NotificationBp) bp.register_blueprint(NotificationBp)
router = APIRouter()
def create_app( def create_app(
frigate_config, frigate_config,
database: SqliteQueueDatabase, database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext], embeddings: Optional[EmbeddingsContext],
detected_frames_processor, detected_frames_processor,
storage_maintainer: StorageMaintainer, storage_maintainer: StorageMaintainer,
onvif: OnvifController, onvif: OnvifController,
external_processor: ExternalEventProcessor, external_processor: ExternalEventProcessor,
plus_api: PlusApi, plus_api: PlusApi,
stats_emitter: StatsEmitter, stats_emitter: StatsEmitter,
): ):
app = Flask(__name__) app = Flask(__name__)
@ -454,19 +457,26 @@ def vainfo():
) )
@bp.route("/logs/<service>", methods=["GET"]) @router.get("/logs/{service}", tags=["Logs"])
def logs(service: str): 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): def download_logs(service_location: str):
try: try:
file = open(service_location, "r") file = open(service_location, "r")
contents = file.read() contents = file.read()
file.close() file.close()
return jsonify(contents) return JSONResponse(jsonable_encoder(contents))
except FileNotFoundError as e: except FileNotFoundError as e:
logger.error(e) logger.error(e)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Could not find log file"}), content={"success": False, "message": "Could not find log file"},
500, status_code=500,
) )
log_locations = { log_locations = {
@ -478,17 +488,14 @@ def logs(service: str):
service_location = log_locations.get(service) service_location = log_locations.get(service)
if not service_location: if not service_location:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Not a valid service"}), content={"success": False, "message": "Not a valid service"},
404, status_code=404,
) )
if request.args.get("download", type=bool, default=False): if download:
return download_logs(service_location) return download_logs(service_location)
start = request.args.get("start", type=int, default=0)
end = request.args.get("end", type=int)
try: try:
file = open(service_location, "r") file = open(service_location, "r")
contents = file.read() contents = file.read()
@ -529,15 +536,15 @@ def logs(service: str):
logLines.append(currentLine) logLines.append(currentLine)
return make_response( return JSONResponse(
jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}), content={"totalLines": len(logLines), "lines": logLines[start:end]},
200, status_code=200,
) )
except FileNotFoundError as e: except FileNotFoundError as e:
logger.error(e) logger.error(e)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Could not find log file"}), content={"success": False, "message": "Could not find log file"},
500, status_code=500,
) )

View File

@ -2,16 +2,20 @@
import base64 import base64
import glob import glob
import io
import logging import logging
import os import os
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
import numpy as np import numpy as np
import pytz 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 flask import Blueprint, Response, current_app, jsonify, make_response, request
from peewee import DoesNotExist, fn from peewee import DoesNotExist, fn
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
@ -32,6 +36,8 @@ logger = logging.getLogger(__name__)
MediaBp = Blueprint("media", __name__) MediaBp = Blueprint("media", __name__)
router = APIRouter(tags=["Media"])
@MediaBp.route("/<camera_name>") @MediaBp.route("/<camera_name>")
def mjpeg_feed(camera_name): def mjpeg_feed(camera_name):
@ -92,90 +98,102 @@ def camera_ptz_info(camera_name):
404, 404,
) )
@router.get("/{camera_name}/latest.{extension}")
@MediaBp.route("/<camera_name>/latest.jpg") def latest_frame(
@MediaBp.route("/<camera_name>/latest.webp") request: Request,
def latest_frame(camera_name): 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 = { draw_options = {
"bounding_boxes": request.args.get("bbox", type=int), "bounding_boxes": bbox,
"timestamp": request.args.get("timestamp", type=int), "timestamp": timestamp,
"zones": request.args.get("zones", type=int), "zones": zones,
"mask": request.args.get("mask", type=int), "mask": mask,
"motion_boxes": request.args.get("motion", type=int), "motion_boxes": motion,
"regions": request.args.get("regions", type=int), "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: if camera_name in request.app.frigate_config.cameras:
frame = current_app.detected_frames_processor.get_current_frame( frame = request.app.detected_frames_processor.get_current_frame(
camera_name, draw_options camera_name, draw_options
) )
retry_interval = float( 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 or 10
) )
if frame is None or datetime.now().timestamp() > ( 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 + 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") error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg")
if len(error_image) > 0: 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 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]) width = int(height * frame.shape[1] / frame.shape[0])
if frame is None: if frame is None:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Unable to get valid frame"}), content={"success": False, "message": "Unable to get valid frame"},
500, status_code=500,
) )
if height < 1 or width < 1: if height < 1 or width < 1:
return ( return JSONResponse(
"Invalid height / width requested :: {} / {}".format(height, width), content="Invalid height / width requested :: {} / {}".format(
400, height, width
),
status_code=400,
) )
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
ret, img = cv2.imencode( 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()) return StreamingResponse(
response.headers["Content-Type"] = f"image/{extension}" io.BytesIO(img.tobytes()),
response.headers["Cache-Control"] = "no-store" media_type=f"image/{extension}",
return response headers={"Content-Type": f"image/{extension}", "Cache-Control": "no-store"},
elif camera_name == "birdseye" and current_app.frigate_config.birdseye.restream: )
elif camera_name == "birdseye" and request.app.frigate_config.birdseye.restream:
frame = cv2.cvtColor( 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, 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]) width = int(height * frame.shape[1] / frame.shape[0])
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
ret, img = cv2.imencode( 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: else:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Camera not found"}), content={"success": False, "message": "Camera not found"},
404, status_code=404,
) )

37
frigate/api/new_app.py Normal file
View File

@ -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

View File

@ -5,23 +5,20 @@ import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytz import pytz
from flask import ( from fastapi import APIRouter
Blueprint, from fastapi.responses import JSONResponse
jsonify,
make_response,
)
from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews from frigate.models import Previews
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PreviewBp = Blueprint("previews", __name__)
router = APIRouter(tags=["Preview"])
@PreviewBp.route("/preview/<camera_name>/start/<int:start_ts>/end/<int:end_ts>") @router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}")
@PreviewBp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>") def preview_ts(camera_name: str, start_ts: float, end_ts: float):
def preview_ts(camera_name, start_ts, end_ts):
"""Get all mp4 previews relevant for time period.""" """Get all mp4 previews relevant for time period."""
if camera_name != "all": if camera_name != "all":
camera_clause = Previews.camera == camera_name camera_clause = Previews.camera == camera_name
@ -62,21 +59,20 @@ def preview_ts(camera_name, start_ts, end_ts):
) )
if not clips: if not clips:
return make_response( return JSONResponse(
jsonify( content={
{ "success": False,
"success": False, "message": "No previews found.",
"message": "No previews found.", },
} status_code=404,
),
404,
) )
return make_response(jsonify(clips), 200) return JSONResponse(content=clips, status_code=200)
@PreviewBp.route("/preview/<year_month>/<day>/<hour>/<camera_name>/<tz_name>") @router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
def preview_hour(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("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc) 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) return preview_ts(camera_name, start_ts, end_ts)
@PreviewBp.route("/preview/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/frames") @router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames")
@PreviewBp.route( def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
"/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/frames"
)
def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
"""Get list of cached preview frames""" """Get list of cached preview frames"""
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}" 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) selected_previews.append(file)
return jsonify(selected_previews) return JSONResponse(
content=selected_previews,
status_code=200,
)

View File

@ -14,6 +14,8 @@ from types import FrameType
from typing import Optional from typing import Optional
import psutil import psutil
import uvicorn
from fastapi.middleware.wsgi import WSGIMiddleware
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
@ -21,6 +23,7 @@ from pydantic import ValidationError
from frigate.api.app import create_app from frigate.api.app import create_app
from frigate.api.auth import hash_password 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.config_updater import ConfigPublisher
from frigate.comms.dispatcher import Communicator, Dispatcher from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.inter_process import InterProcessCommunicator from frigate.comms.inter_process import InterProcessCommunicator
@ -397,6 +400,8 @@ class FrigateApp:
self.stats_emitter, self.stats_emitter,
) )
self.fastapi_app = create_fastapi_app(self.config, self.detected_frames_processor)
def init_onvif(self) -> None: def init_onvif(self) -> None:
self.onvif_controller = OnvifController(self.config, self.ptz_metrics) self.onvif_controller = OnvifController(self.config, self.ptz_metrics)
@ -739,11 +744,17 @@ class FrigateApp:
signal.signal(signal.SIGTERM, receiveSignal) signal.signal(signal.SIGTERM, receiveSignal)
try: 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: except KeyboardInterrupt:
pass pass
logger.info("Flask has exited...") logger.info("FastAPI/Flask has exited...")
self.stop() self.stop()