Frigate HTTP API using FastAPI (#13871)

* POC: Added FastAPI with one endpoint (get /logs/service)

* POC: Revert error_log

* POC: Converted preview related endpoints to FastAPI

* POC: Converted two more endpoints to FastAPI

* POC: lint

* Convert all media endpoints to FastAPI. Added /media prefix (/media/camera && media/events && /media/preview)

* Convert all notifications API endpoints to FastAPI

* Convert first review API endpoints to FastAPI

* Convert remaining review API endpoints to FastAPI

* Convert export endpoints to FastAPI

* Fix path parameters

* Convert events endpoints to FastAPI

* Use body for multiple events endpoints

* Use body for multiple events endpoints (create and end event)

* Convert app endpoints to FastAPI

* Convert app endpoints to FastAPI

* Convert auth endpoints to FastAPI

* Removed flask app in favour of FastAPI app. Implemented FastAPI middleware to check CSRF, connect and disconnect from DB. Added middleware x-forwared-for headers

* Added starlette plugin to expose custom headers

* Use slowapi as the limiter

* Use query parameters for the frame latest endpoint

* Use query parameters for the media snapshot.jpg endpoint

* Use query parameters for the media MJPEG feed endpoint

* Revert initial nginx.conf change

* Added missing even_id for /events/search endpoint

* Removed left over comment

* Use FastAPI TestClient

* severity query parameter should be a string

* Use the same pattern for all tests

* Fix endpoint

* Revert media routers to old names. Order routes to make sure the dynamic ones from media.py are only used whenever there's no match on auth/etc

* Reverted paths for media on tsx files

* Deleted file

* Fix test_http to use TestClient

* Formatting

* Bind timeline to DB

* Fix http tests

* Replace filename with pathvalidate

* Fix latest.ext handling and disable uvicorn access logs

* Add cosntraints to api provided values

* Formatting

* Remove unused

* Remove unused

* Get rate limiter working

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Rui Alves 2024-09-24 14:05:30 +01:00 committed by GitHub
parent dc54981784
commit cffc431bf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1654 additions and 1321 deletions

View File

@ -1,8 +1,11 @@
click == 8.1.* click == 8.1.*
Flask == 3.0.* # FastAPI
Flask_Limiter == 3.8.* starlette-context == 0.3.6
fastapi == 0.115.0
slowapi == 0.1.9
imutils == 0.5.* imutils == 0.5.*
joserfc == 1.0.* joserfc == 1.0.*
pathvalidate == 3.2.*
markupsafe == 2.1.* markupsafe == 2.1.*
mypy == 1.6.1 mypy == 1.6.1
numpy == 1.26.* numpy == 1.26.*

View File

@ -226,7 +226,7 @@ http {
location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ {
include auth_request.conf; include auth_request.conf;
rewrite ^/api/(.*)$ $1 break; rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://frigate_api; proxy_pass http://frigate_api;
include proxy.conf; include proxy.conf;
} }

View File

@ -26,7 +26,7 @@ In the event that you are locked out of your instance, you can tell Frigate to r
## Login failure rate limiting ## Login failure rate limiting
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation). In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples).
For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than: For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than:

View File

@ -5,7 +5,6 @@ import signal
import sys import sys
import threading import threading
from flask import cli
from pydantic import ValidationError from pydantic import ValidationError
from frigate.app import FrigateApp from frigate.app import FrigateApp
@ -24,7 +23,6 @@ def main() -> None:
) )
threading.current_thread().name = "frigate" threading.current_thread().name = "frigate"
cli.show_server_banner = lambda *x: None
# Make sure we exit cleanly on SIGTERM. # Make sure we exit cleanly on SIGTERM.
signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit()) signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit())

View File

@ -10,27 +10,19 @@ from functools import reduce
from typing import Optional from typing import Optional
import requests import requests
from flask import Blueprint, Flask, current_app, jsonify, make_response, request from fastapi import APIRouter, Path, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from markupsafe import escape from markupsafe import escape
from peewee import operator from peewee import operator
from playhouse.sqliteq import SqliteQueueDatabase
from werkzeug.middleware.proxy_fix import ProxyFix
from frigate.api.auth import AuthBp, get_jwt_secret, limiter from frigate.api.defs.app_body import AppConfigSetBody
from frigate.api.event import EventBp from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.export import ExportBp from frigate.api.defs.tags import Tags
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.config import FrigateConfig
from frigate.const import CONFIG_DIR from frigate.const import CONFIG_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
get_tz_modifiers, get_tz_modifiers,
@ -42,134 +34,75 @@ from frigate.version import VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint("frigate", __name__) router = APIRouter(tags=[Tags.app])
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)
def create_app( @router.get("/")
frigate_config,
database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext],
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
external_processor: ExternalEventProcessor,
stats_emitter: StatsEmitter,
):
app = Flask(__name__)
@app.before_request
def check_csrf():
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
pass
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return jsonify({"success": False, "message": "Missing CSRF header"}), 401
@app.before_request
def _db_connect():
if database.is_closed():
database.connect()
@app.teardown_request
def _db_close(exc):
if not database.is_closed():
database.close()
app.frigate_config = frigate_config
app.embeddings = embeddings
app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer
app.onvif = onvif
app.external_processor = external_processor
app.camera_error_image = None
app.stats_emitter = stats_emitter
app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None
# update the request_address with the x-forwarded-for header from nginx
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)
# initialize the rate limiter for the login endpoint
limiter.init_app(app)
if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False
app.register_blueprint(bp)
return app
@bp.route("/")
def is_healthy(): def is_healthy():
return "Frigate is running. Alive and healthy!" return "Frigate is running. Alive and healthy!"
@bp.route("/config/schema.json") @router.get("/config/schema.json")
def config_schema(): def config_schema(request: Request):
return current_app.response_class( return Response(
current_app.frigate_config.schema_json(), mimetype="application/json" content=request.app.frigate_config.schema_json(), media_type="application/json"
) )
@bp.route("/go2rtc/streams") @router.get("/go2rtc/streams")
def go2rtc_streams(): def go2rtc_streams():
r = requests.get("http://127.0.0.1:1984/api/streams") r = requests.get("http://127.0.0.1:1984/api/streams")
if not r.ok: if not r.ok:
logger.error("Failed to fetch streams from go2rtc") logger.error("Failed to fetch streams from go2rtc")
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Error fetching stream data"}), content=({"success": False, "message": "Error fetching stream data"}),
500, status_code=500,
) )
stream_data = r.json() stream_data = r.json()
for data in stream_data.values(): for data in stream_data.values():
for producer in data.get("producers", []): for producer in data.get("producers", []):
producer["url"] = clean_camera_user_pass(producer.get("url", "")) producer["url"] = clean_camera_user_pass(producer.get("url", ""))
return jsonify(stream_data) return JSONResponse(content=stream_data)
@bp.route("/go2rtc/streams/<camera_name>") @router.get("/go2rtc/streams/{camera_name}")
def go2rtc_camera_stream(camera_name: str): def go2rtc_camera_stream(camera_name: str):
r = requests.get( r = requests.get(
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone" f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone"
) )
if not r.ok: if not r.ok:
logger.error("Failed to fetch streams from go2rtc") logger.error("Failed to fetch streams from go2rtc")
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Error fetching stream data"}), content=({"success": False, "message": "Error fetching stream data"}),
500, status_code=500,
) )
stream_data = r.json() stream_data = r.json()
for producer in stream_data.get("producers", []): for producer in stream_data.get("producers", []):
producer["url"] = clean_camera_user_pass(producer.get("url", "")) producer["url"] = clean_camera_user_pass(producer.get("url", ""))
return jsonify(stream_data) return JSONResponse(content=stream_data)
@bp.route("/version") @router.get("/version")
def version(): def version():
return VERSION return VERSION
@bp.route("/stats") @router.get("/stats")
def stats(): def stats(request: Request):
return jsonify(current_app.stats_emitter.get_latest_stats()) return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
@bp.route("/stats/history") @router.get("/stats/history")
def stats_history(): def stats_history(request: Request, keys: str = None):
keys = request.args.get("keys", default=None)
if keys: if keys:
keys = keys.split(",") keys = keys.split(",")
return jsonify(current_app.stats_emitter.get_stats_history(keys)) return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys))
@bp.route("/config") @router.get("/config")
def config(): def config(request: Request):
config_obj: FrigateConfig = current_app.frigate_config config_obj: FrigateConfig = request.app.frigate_config
config: dict[str, dict[str, any]] = config_obj.model_dump( config: dict[str, dict[str, any]] = config_obj.model_dump(
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True
) )
@ -180,7 +113,7 @@ def config():
# remove the proxy secret # remove the proxy secret
config["proxy"].pop("auth_secret", None) config["proxy"].pop("auth_secret", None)
for camera_name, camera in current_app.frigate_config.cameras.items(): for camera_name, camera in request.app.frigate_config.cameras.items():
camera_dict = config["cameras"][camera_name] camera_dict = config["cameras"][camera_name]
# clean paths # clean paths
@ -196,18 +129,18 @@ def config():
for zone_name, zone in config_obj.cameras[camera_name].zones.items(): for zone_name, zone in config_obj.cameras[camera_name].zones.items():
camera_dict["zones"][zone_name]["color"] = zone.color camera_dict["zones"][zone_name]["color"] = zone.color
config["plus"] = {"enabled": current_app.frigate_config.plus_api.is_active()} config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
config["model"]["colormap"] = config_obj.model.colormap config["model"]["colormap"] = config_obj.model.colormap
for detector_config in config["detectors"].values(): for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = ( detector_config["model"]["labelmap"] = (
current_app.frigate_config.model.merged_labelmap request.app.frigate_config.model.merged_labelmap
) )
return jsonify(config) return JSONResponse(content=config)
@bp.route("/config/raw") @router.get("/config/raw")
def config_raw(): def config_raw():
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml") config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
@ -218,8 +151,9 @@ def config_raw():
config_file = config_file_yaml config_file = config_file_yaml
if not os.path.isfile(config_file): if not os.path.isfile(config_file):
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Could not find file"}), 404 content=({"success": False, "message": "Could not find file"}),
status_code=404,
) )
with open(config_file, "r") as f: with open(config_file, "r") as f:
@ -229,32 +163,30 @@ def config_raw():
return raw_config, 200 return raw_config, 200
@bp.route("/config/save", methods=["POST"]) @router.post("/config/save")
def config_save(): def config_save(save_option: str, body: dict):
save_option = request.args.get("save_option") new_config = body
new_config = request.get_data().decode()
if not new_config: if not new_config:
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": "Config with body param is required"} {"success": False, "message": "Config with body param is required"}
), ),
400, status_code=400,
) )
# Validate the config schema # Validate the config schema
try: try:
FrigateConfig.parse_yaml(new_config) FrigateConfig.parse_yaml(new_config)
except Exception: except Exception:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}", "message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
} }
), ),
400, status_code=400,
) )
# Save the config to file # Save the config to file
@ -271,14 +203,14 @@ def config_save():
f.write(new_config) f.write(new_config)
f.close() f.close()
except Exception: except Exception:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Could not write config file, be sure that Frigate has write permission on the config file.", "message": "Could not write config file, be sure that Frigate has write permission on the config file.",
} }
), ),
400, status_code=400,
) )
if save_option == "restart": if save_option == "restart":
@ -286,34 +218,34 @@ def config_save():
restart_frigate() restart_frigate()
except Exception as e: except Exception as e:
logging.error(f"Error restarting Frigate: {e}") logging.error(f"Error restarting Frigate: {e}")
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Config successfully saved, unable to restart Frigate", "message": "Config successfully saved, unable to restart Frigate",
} }
), ),
200, status_code=200,
) )
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Config successfully saved, restarting (this can take up to one minute)...", "message": "Config successfully saved, restarting (this can take up to one minute)...",
} }
), ),
200, status_code=200,
) )
else: else:
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Config successfully saved."}), content=({"success": True, "message": "Config successfully saved."}),
200, status_code=200,
) )
@bp.route("/config/set", methods=["PUT"]) @router.put("/config/set")
def config_set(): def config_set(request: Request, body: AppConfigSetBody):
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml") config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
# Check if we can use .yaml instead of .yml # Check if we can use .yaml instead of .yml
@ -339,68 +271,68 @@ def config_set():
f.write(old_raw_config) f.write(old_raw_config)
f.close() f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}") logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Error parsing config. Check logs for error message.", "message": "Error parsing config. Check logs for error message.",
} }
), ),
400, status_code=400,
) )
except Exception as e: except Exception as e:
logging.error(f"Error updating config: {e}") logging.error(f"Error updating config: {e}")
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Error updating config"}), content=({"success": False, "message": "Error updating config"}),
500, status_code=500,
) )
json = request.get_json(silent=True) or {} if body.requires_restart == 0:
request.app.frigate_config = FrigateConfig.parse_object(
if json.get("requires_restart", 1) == 0: config_obj, request.app.frigate_config.plus_api
current_app.frigate_config = FrigateConfig.parse_object(
config_obj, plus_api=current_app.frigate_config.plus_api
) )
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Config successfully updated, restart to apply", "message": "Config successfully updated, restart to apply",
} }
), ),
200, status_code=200,
) )
@bp.route("/ffprobe", methods=["GET"]) @router.get("/ffprobe")
def ffprobe(): def ffprobe(request: Request, paths: str = ""):
path_param = request.args.get("paths", "") path_param = paths
if not path_param: if not path_param:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Path needs to be provided."}), 404 content=({"success": False, "message": "Path needs to be provided."}),
status_code=404,
) )
if path_param.startswith("camera"): if path_param.startswith("camera"):
camera = path_param[7:] camera = path_param[7:]
if camera not in current_app.frigate_config.cameras.keys(): if camera not in request.app.frigate_config.cameras.keys():
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": f"{camera} is not a valid camera."} {"success": False, "message": f"{camera} is not a valid camera."}
), ),
404, status_code=404,
) )
if not current_app.frigate_config.cameras[camera].enabled: if not request.app.frigate_config.cameras[camera].enabled:
return make_response( return JSONResponse(
jsonify({"success": False, "message": f"{camera} is not enabled."}), 404 content=({"success": False, "message": f"{camera} is not enabled."}),
status_code=404,
) )
paths = map( paths = map(
lambda input: input.path, lambda input: input.path,
current_app.frigate_config.cameras[camera].ffmpeg.inputs, request.app.frigate_config.cameras[camera].ffmpeg.inputs,
) )
elif "," in clean_camera_user_pass(path_param): elif "," in clean_camera_user_pass(path_param):
paths = path_param.split(",") paths = path_param.split(",")
@ -411,7 +343,7 @@ def ffprobe():
output = [] output = []
for path in paths: for path in paths:
ffprobe = ffprobe_stream(current_app.frigate_config.ffmpeg, path.strip()) ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip())
output.append( output.append(
{ {
"return_code": ffprobe.returncode, "return_code": ffprobe.returncode,
@ -428,14 +360,14 @@ def ffprobe():
} }
) )
return jsonify(output) return JSONResponse(content=output)
@bp.route("/vainfo", methods=["GET"]) @router.get("/vainfo")
def vainfo(): def vainfo():
vainfo = vainfo_hwaccel() vainfo = vainfo_hwaccel()
return jsonify( return JSONResponse(
{ content={
"return_code": vainfo.returncode, "return_code": vainfo.returncode,
"stderr": ( "stderr": (
vainfo.stderr.decode("unicode_escape").strip() vainfo.stderr.decode("unicode_escape").strip()
@ -451,19 +383,26 @@ def vainfo():
) )
@bp.route("/logs/<service>", methods=["GET"]) @router.get("/logs/{service}", tags=[Tags.logs])
def logs(service: str): def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc", "chroma"]),
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 = {
@ -475,17 +414,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()
@ -526,49 +462,47 @@ 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,
) )
@bp.route("/restart", methods=["POST"]) @router.post("/restart")
def restart(): def restart():
try: try:
restart_frigate() restart_frigate()
except Exception as e: except Exception as e:
logging.error(f"Error restarting Frigate: {e}") logging.error(f"Error restarting Frigate: {e}")
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Unable to restart Frigate.", "message": "Unable to restart Frigate.",
} }
), ),
500, status_code=500,
) )
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Restarting (this can take up to one minute)...", "message": "Restarting (this can take up to one minute)...",
} }
), ),
200, status_code=200,
) )
@bp.route("/labels") @router.get("/labels")
def get_labels(): def get_labels(camera: str = ""):
camera = request.args.get("camera", type=str, default="")
try: try:
if camera: if camera:
events = Event.select(Event.label).where(Event.camera == camera).distinct() events = Event.select(Event.label).where(Event.camera == camera).distinct()
@ -576,24 +510,23 @@ def get_labels():
events = Event.select(Event.label).distinct() events = Event.select(Event.label).distinct()
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Failed to get labels"}), 404 content=({"success": False, "message": "Failed to get labels"}),
status_code=404,
) )
labels = sorted([e.label for e in events]) labels = sorted([e.label for e in events])
return jsonify(labels) return JSONResponse(content=labels)
@bp.route("/sub_labels") @router.get("/sub_labels")
def get_sub_labels(): def get_sub_labels(split_joined: Optional[int] = None):
split_joined = request.args.get("split_joined", type=int)
try: try:
events = Event.select(Event.sub_label).distinct() events = Event.select(Event.sub_label).distinct()
except Exception: except Exception:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Failed to get sub_labels"}), content=({"success": False, "message": "Failed to get sub_labels"}),
404, status_code=404,
) )
sub_labels = [e.sub_label for e in events] sub_labels = [e.sub_label for e in events]
@ -614,15 +547,11 @@ def get_sub_labels():
sub_labels.append(part.strip()) sub_labels.append(part.strip())
sub_labels.sort() sub_labels.sort()
return jsonify(sub_labels) return JSONResponse(content=sub_labels)
@bp.route("/timeline") @router.get("/timeline")
def timeline(): def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
camera = request.args.get("camera", "all")
source_id = request.args.get("source_id", type=str)
limit = request.args.get("limit", 100)
clauses = [] clauses = []
selected_columns = [ selected_columns = [
@ -651,18 +580,18 @@ def timeline():
.dicts() .dicts()
) )
return jsonify([t for t in timeline]) return JSONResponse(content=[t for t in timeline])
@bp.route("/timeline/hourly") @router.get("/timeline/hourly")
def hourly_timeline(): def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
"""Get hourly summary for timeline.""" """Get hourly summary for timeline."""
cameras = request.args.get("cameras", "all") cameras = params.cameras
labels = request.args.get("labels", "all") labels = params.labels
before = request.args.get("before", type=float) before = params.before
after = request.args.get("after", type=float) after = params.after
limit = request.args.get("limit", 200) limit = params.limit
tz_name = request.args.get("timezone", default="utc", type=str) tz_name = params.timezone
_, minute_modifier, _ = get_tz_modifiers(tz_name) _, minute_modifier, _ = get_tz_modifiers(tz_name)
minute_offset = int(minute_modifier.split(" ")[0]) minute_offset = int(minute_modifier.split(" ")[0])
@ -728,8 +657,8 @@ def hourly_timeline():
else: else:
hours[hour].insert(0, t) hours[hour].insert(0, t)
return jsonify( return JSONResponse(
{ content={
"start": start, "start": start,
"end": end, "end": end,
"count": count, "count": count,

View File

@ -12,25 +12,45 @@ import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from flask import Blueprint, current_app, jsonify, make_response, redirect, request from fastapi import APIRouter, Request, Response
from flask_limiter import Limiter from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt from joserfc import jwt
from peewee import DoesNotExist from peewee import DoesNotExist
from slowapi import Limiter
from frigate.api.defs.app_body import (
AppPostLoginBody,
AppPostUsersBody,
AppPutPasswordBody,
)
from frigate.api.defs.tags import Tags
from frigate.config import AuthConfig, ProxyConfig from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User from frigate.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AuthBp = Blueprint("auth", __name__) router = APIRouter(tags=[Tags.auth])
def get_remote_addr(): class RateLimiter:
_limit = ""
def set_limit(self, limit: str):
self._limit = limit
def get_limit(self) -> str:
return self._limit
rateLimiter = RateLimiter()
def get_remote_addr(request: Request):
route = list(reversed(request.headers.get("x-forwarded-for").split(","))) route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
logger.debug(f"IP Route: {[r for r in route]}") logger.debug(f"IP Route: {[r for r in route]}")
trusted_proxies = [] trusted_proxies = []
for proxy in current_app.frigate_config.auth.trusted_proxies: for proxy in request.app.frigate_config.auth.trusted_proxies:
try: try:
network = ipaddress.ip_network(proxy) network = ipaddress.ip_network(proxy)
except ValueError: except ValueError:
@ -68,16 +88,6 @@ def get_remote_addr():
return request.remote_addr or "127.0.0.1" return request.remote_addr or "127.0.0.1"
limiter = Limiter(
get_remote_addr,
storage_uri="memory://",
)
def get_rate_limit():
return current_app.frigate_config.auth.failed_login_rate_limit
def get_jwt_secret() -> str: def get_jwt_secret() -> str:
jwt_secret = None jwt_secret = None
# check env var # check env var
@ -132,7 +142,7 @@ def get_jwt_secret() -> str:
return jwt_secret return jwt_secret
def hash_password(password, salt=None, iterations=600000): def hash_password(password: str, salt=None, iterations=600000):
if salt is None: if salt is None:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
assert salt and isinstance(salt, str) and "$" not in salt assert salt and isinstance(salt, str) and "$" not in salt
@ -158,33 +168,36 @@ def create_encoded_jwt(user, expiration, secret):
return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret)
def set_jwt_cookie(response, cookie_name, encoded_jwt, expiration, secure): def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
# TODO: ideally this would set secure as well, but that requires TLS # TODO: ideally this would set secure as well, but that requires TLS
response.set_cookie( response.set_cookie(
cookie_name, encoded_jwt, httponly=True, expires=expiration, secure=secure key=cookie_name,
value=encoded_jwt,
httponly=True,
expires=expiration,
secure=secure,
) )
# Endpoint for use with nginx auth_request # Endpoint for use with nginx auth_request
@AuthBp.route("/auth") @router.get("/auth")
def auth(): def auth(request: Request):
auth_config: AuthConfig = current_app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = current_app.frigate_config.proxy proxy_config: ProxyConfig = request.app.frigate_config.proxy
success_response = make_response({}, 202) success_response = Response("", status_code=202)
# dont require auth if the request is on the internal port # dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed # this header is set by Frigate's nginx proxy, so it cant be spoofed
if request.headers.get("x-server-port", 0, type=int) == 5000: if int(request.headers.get("x-server-port", default=0)) == 5000:
return success_response return success_response
fail_response = make_response({}, 401) fail_response = Response("", status_code=401)
# ensure the proxy secret matches if configured # ensure the proxy secret matches if configured
if ( if (
proxy_config.auth_secret is not None proxy_config.auth_secret is not None
and request.headers.get("x-proxy-secret", "", type=str) and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret
!= proxy_config.auth_secret
): ):
logger.debug("X-Proxy-Secret header does not match configured secret value") logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response return fail_response
@ -196,7 +209,6 @@ def auth():
if proxy_config.header_map.user is not None: if proxy_config.header_map.user is not None:
upstream_user_header_value = request.headers.get( upstream_user_header_value = request.headers.get(
proxy_config.header_map.user, proxy_config.header_map.user,
type=str,
default="anonymous", default="anonymous",
) )
success_response.headers["remote-user"] = upstream_user_header_value success_response.headers["remote-user"] = upstream_user_header_value
@ -207,10 +219,10 @@ def auth():
# now apply authentication # now apply authentication
fail_response.headers["location"] = "/login" fail_response.headers["location"] = "/login"
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_REFRESH = current_app.frigate_config.auth.refresh_time JWT_REFRESH = request.app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
jwt_source = None jwt_source = None
encoded_token = None encoded_token = None
@ -230,7 +242,7 @@ def auth():
return fail_response return fail_response
try: try:
token = jwt.decode(encoded_token, current_app.jwt_token) token = jwt.decode(encoded_token, request.app.jwt_token)
if "sub" not in token.claims: if "sub" not in token.claims:
logger.debug("user not set in jwt token") logger.debug("user not set in jwt token")
return fail_response return fail_response
@ -266,7 +278,7 @@ def auth():
return fail_response return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt( new_encoded_jwt = create_encoded_jwt(
user, new_expiration, current_app.jwt_token user, new_expiration, request.app.jwt_token
) )
set_jwt_cookie( set_jwt_cookie(
success_response, success_response,
@ -283,86 +295,84 @@ def auth():
return fail_response return fail_response
@AuthBp.route("/profile") @router.get("/profile")
def profile(): def profile(request: Request):
username = request.headers.get("remote-user", type=str) username = request.headers.get("remote-user")
return jsonify({"username": username}) return JSONResponse(content={"username": username})
@AuthBp.route("/logout") @router.get("/logout")
def logout(): def logout(request: Request):
auth_config: AuthConfig = current_app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
response = make_response(redirect("/login", code=303)) response = RedirectResponse("/login", status_code=303)
response.delete_cookie(auth_config.cookie_name) response.delete_cookie(auth_config.cookie_name)
return response return response
@AuthBp.route("/login", methods=["POST"]) limiter = Limiter(key_func=get_remote_addr)
@limiter.limit(get_rate_limit, deduct_when=lambda response: response.status_code == 400)
def login():
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name @router.post("/login")
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure @limiter.limit(limit_value=rateLimiter.get_limit)
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length def login(request: Request, body: AppPostLoginBody):
content = request.get_json() JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
user = content["user"] JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
password = content["password"] JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
user = body.user
password = body.password
try: try:
db_user: User = User.get_by_id(user) db_user: User = User.get_by_id(user)
except DoesNotExist: except DoesNotExist:
return make_response({"message": "Login failed"}, 400) return JSONResponse(content={"message": "Login failed"}, status_code=400)
password_hash = db_user.password_hash password_hash = db_user.password_hash
if verify_password(password, password_hash): if verify_password(password, password_hash):
expiration = int(time.time()) + JWT_SESSION_LENGTH expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, expiration, current_app.jwt_token) encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token)
response = make_response({}, 200) response = Response("", 200)
set_jwt_cookie( set_jwt_cookie(
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
) )
return response return response
return make_response({"message": "Login failed"}, 400) return JSONResponse(content={"message": "Login failed"}, status_code=400)
@AuthBp.route("/users") @router.get("/users")
def get_users(): def get_users():
exports = User.select(User.username).order_by(User.username).dicts().iterator() exports = User.select(User.username).order_by(User.username).dicts().iterator()
return jsonify([e for e in exports]) return JSONResponse([e for e in exports])
@AuthBp.route("/users", methods=["POST"]) @router.post("/users")
def create_user(): def create_user(request: Request, body: AppPostUsersBody):
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
request_data = request.get_json() if not re.match("^[A-Za-z0-9._]+$", body.username):
JSONResponse(content={"message": "Invalid username"}, status_code=400)
if not re.match("^[A-Za-z0-9._]+$", request_data.get("username", "")): password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
make_response({"message": "Invalid username"}, 400)
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
User.insert( User.insert(
{ {
User.username: request_data["username"], User.username: body.username,
User.password_hash: password_hash, User.password_hash: password_hash,
} }
).execute() ).execute()
return jsonify({"username": request_data["username"]}) return JSONResponse(content={"username": body.username})
@AuthBp.route("/users/<username>", methods=["DELETE"]) @router.delete("/users/{username}")
def delete_user(username: str): def delete_user(username: str):
User.delete_by_id(username) User.delete_by_id(username)
return jsonify({"success": True}) return JSONResponse(content={"success": True})
@AuthBp.route("/users/<username>/password", methods=["PUT"]) @router.put("/users/{username}/password")
def update_password(username: str): def update_password(request: Request, username: str, body: AppPutPasswordBody):
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
request_data = request.get_json() password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
User.set_by_id( User.set_by_id(
username, username,
@ -370,4 +380,4 @@ def update_password(username: str):
User.password_hash: password_hash, User.password_hash: password_hash,
}, },
) )
return jsonify({"success": True}) return JSONResponse(content={"success": True})

View File

@ -0,0 +1,19 @@
from pydantic import BaseModel
class AppConfigSetBody(BaseModel):
requires_restart: int = 1
class AppPutPasswordBody(BaseModel):
password: str
class AppPostUsersBody(BaseModel):
username: str
password: str
class AppPostLoginBody(BaseModel):
user: str
password: str

View File

@ -0,0 +1,12 @@
from typing import Optional
from pydantic import BaseModel
class AppTimelineHourlyQueryParameters(BaseModel):
cameras: Optional[str] = "all"
labels: Optional[str] = "all"
after: Optional[float] = None
before: Optional[float] = None
limit: Optional[int] = 200
timezone: Optional[str] = "utc"

View File

@ -0,0 +1,30 @@
from datetime import datetime
from typing import Optional, Union
from pydantic import BaseModel, Field
class EventsSubLabelBody(BaseModel):
subLabel: str = Field(title="Sub label", max_length=100)
subLabelScore: Optional[float] = Field(
title="Score for sub label", default=None, gt=0.0, le=1.0
)
class EventsDescriptionBody(BaseModel):
description: Union[str, None] = Field(
title="The description of the event", min_length=1
)
class EventsCreateBody(BaseModel):
source_type: Optional[str] = "api"
sub_label: Optional[str] = None
score: Optional[int] = 0
duration: Optional[int] = 30
include_recording: Optional[bool] = True
draw: Optional[dict] = {}
class EventsEndBody(BaseModel):
end_time: Optional[int] = datetime.now().timestamp()

View File

@ -0,0 +1,52 @@
from typing import Optional
from pydantic import BaseModel
DEFAULT_TIME_RANGE = "00:00,24:00"
class EventsQueryParams(BaseModel):
camera: Optional[str] = "all"
cameras: Optional[str] = "all"
label: Optional[str] = "all"
labels: Optional[str] = "all"
sub_label: Optional[str] = "all"
sub_labels: Optional[str] = "all"
zone: Optional[str] = "all"
zones: Optional[str] = "all"
limit: Optional[int] = 100
after: Optional[float] = None
before: Optional[float] = None
time_range: Optional[str] = DEFAULT_TIME_RANGE
has_clip: Optional[int] = None
has_snapshot: Optional[int] = None
in_progress: Optional[int] = None
include_thumbnails: Optional[int] = 1
favorites: Optional[int] = None
min_score: Optional[float] = None
max_score: Optional[float] = None
is_submitted: Optional[int] = None
min_length: Optional[float] = None
max_length: Optional[float] = None
sort: Optional[str] = None
timezone: Optional[str] = "utc"
class EventsSearchQueryParams(BaseModel):
query: Optional[str] = None
event_id: Optional[str] = None
search_type: Optional[str] = "thumbnail,description"
include_thumbnails: Optional[int] = 1
limit: Optional[int] = 50
cameras: Optional[str] = "all"
labels: Optional[str] = "all"
zones: Optional[str] = "all"
after: Optional[float] = None
before: Optional[float] = None
timezone: Optional[str] = "utc"
class EventsSummaryQueryParams(BaseModel):
timezone: Optional[str] = "utc"
has_clip: Optional[int] = None
has_snapshot: Optional[int] = None

View File

@ -0,0 +1,42 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel
class Extension(str, Enum):
webp = "webp"
png = "png"
jpg = "jpg"
jpeg = "jpeg"
class MediaLatestFrameQueryParams(BaseModel):
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
class MediaEventsSnapshotQueryParams(BaseModel):
download: Optional[bool] = False
timestamp: Optional[int] = None
bbox: Optional[int] = None
crop: Optional[int] = None
height: Optional[int] = None
quality: Optional[int] = 70
class MediaMjpegFeedQueryParams(BaseModel):
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

View File

@ -0,0 +1,31 @@
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel
class ReviewQueryParams(BaseModel):
cameras: Optional[str] = "all"
labels: Optional[str] = "all"
zones: Optional[str] = "all"
reviewed: Optional[int] = 0
limit: Optional[int] = None
severity: Optional[str] = None
before: Optional[float] = datetime.now().timestamp()
after: Optional[float] = (datetime.now() - timedelta(hours=24)).timestamp()
class ReviewSummaryQueryParams(BaseModel):
cameras: Optional[str] = "all"
labels: Optional[str] = "all"
zones: Optional[str] = "all"
timezone: Optional[str] = "utc"
day_ago: Optional[int] = (datetime.now() - timedelta(hours=24)).timestamp()
month_ago: Optional[int] = (datetime.now() - timedelta(days=30)).timestamp()
class ReviewActivityMotionQueryParams(BaseModel):
cameras: Optional[str] = "all"
before: Optional[float] = datetime.now().timestamp()
after: Optional[float] = (datetime.now() - timedelta(hours=1)).timestamp()
scale: Optional[int] = 30

13
frigate/api/defs/tags.py Normal file
View File

@ -0,0 +1,13 @@
from enum import Enum
class Tags(Enum):
app = "App"
preview = "Preview"
logs = "Logs"
media = "Media"
notifications = "Notifications"
review = "Review"
export = "Export"
events = "Events"
auth = "Auth"

View File

@ -4,24 +4,32 @@ import base64
import io import io
import logging import logging
import os import os
from datetime import datetime
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
import numpy as np import numpy as np
from flask import ( from fastapi import APIRouter, Request
Blueprint, from fastapi.params import Depends
current_app, from fastapi.responses import JSONResponse
jsonify,
make_response,
request,
)
from peewee import JOIN, DoesNotExist, fn, operator from peewee import JOIN, DoesNotExist, fn, operator
from PIL import Image from PIL import Image
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.defs.events_body import (
EventsCreateBody,
EventsDescriptionBody,
EventsEndBody,
EventsSubLabelBody,
)
from frigate.api.defs.events_query_parameters import (
DEFAULT_TIME_RANGE,
EventsQueryParams,
EventsSearchQueryParams,
EventsSummaryQueryParams,
)
from frigate.api.defs.tags import Tags
from frigate.const import ( from frigate.const import (
CLIPS_DIR, CLIPS_DIR,
) )
@ -33,57 +41,55 @@ from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EventBp = Blueprint("events", __name__) router = APIRouter(tags=[Tags.events])
DEFAULT_TIME_RANGE = "00:00,24:00"
@EventBp.route("/events") @router.get("/events")
def events(): def events(params: EventsQueryParams = Depends()):
camera = request.args.get("camera", "all") camera = params.camera
cameras = request.args.get("cameras", "all") cameras = params.cameras
# handle old camera arg # handle old camera arg
if cameras == "all" and camera != "all": if cameras == "all" and camera != "all":
cameras = camera cameras = camera
label = unquote(request.args.get("label", "all")) label = unquote(params.label)
labels = request.args.get("labels", "all") labels = params.labels
# handle old label arg # handle old label arg
if labels == "all" and label != "all": if labels == "all" and label != "all":
labels = label labels = label
sub_label = request.args.get("sub_label", "all") sub_label = params.sub_label
sub_labels = request.args.get("sub_labels", "all") sub_labels = params.sub_labels
# handle old sub_label arg # handle old sub_label arg
if sub_labels == "all" and sub_label != "all": if sub_labels == "all" and sub_label != "all":
sub_labels = sub_label sub_labels = sub_label
zone = request.args.get("zone", "all") zone = params.zone
zones = request.args.get("zones", "all") zones = params.zones
# handle old label arg # handle old label arg
if zones == "all" and zone != "all": if zones == "all" and zone != "all":
zones = zone zones = zone
limit = request.args.get("limit", 100) limit = params.limit
after = request.args.get("after", type=float) after = params.after
before = request.args.get("before", type=float) before = params.before
time_range = request.args.get("time_range", DEFAULT_TIME_RANGE) time_range = params.time_range
has_clip = request.args.get("has_clip", type=int) has_clip = params.has_clip
has_snapshot = request.args.get("has_snapshot", type=int) has_snapshot = params.has_snapshot
in_progress = request.args.get("in_progress", type=int) in_progress = params.in_progress
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) include_thumbnails = params.include_thumbnails
favorites = request.args.get("favorites", type=int) favorites = params.favorites
min_score = request.args.get("min_score", type=float) min_score = params.min_score
max_score = request.args.get("max_score", type=float) max_score = params.max_score
is_submitted = request.args.get("is_submitted", type=int) is_submitted = params.is_submitted
min_length = request.args.get("min_length", type=float) min_length = params.min_length
max_length = request.args.get("max_length", type=float) max_length = params.max_length
sort = request.args.get("sort", type=str) sort = params.sort
clauses = [] clauses = []
@ -163,7 +169,7 @@ def events():
if time_range != DEFAULT_TIME_RANGE: if time_range != DEFAULT_TIME_RANGE:
# get timezone arg to ensure browser times are used # get timezone arg to ensure browser times are used
tz_name = request.args.get("timezone", default="utc", type=str) tz_name = params.timezone
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name) hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
times = time_range.split(",") times = time_range.split(",")
@ -248,13 +254,11 @@ def events():
.iterator() .iterator()
) )
return jsonify(list(events)) return JSONResponse(content=list(events))
@EventBp.route("/events/explore") @router.get("/events/explore")
def events_explore(): def events_explore(limit: int = 10):
limit = request.args.get("limit", 10, type=int)
subquery = Event.select( subquery = Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
@ -316,69 +320,68 @@ def events_explore():
for event in events for event in events
] ]
return jsonify(processed_events) return JSONResponse(content=processed_events)
@EventBp.route("/event_ids") @router.get("/event_ids")
def event_ids(): def event_ids(ids: str):
idString = request.args.get("ids") ids = ids.split(",")
ids = idString.split(",")
if not ids: if not ids:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Valid list of ids must be sent"}), content=({"success": False, "message": "Valid list of ids must be sent"}),
400, status_code=400,
) )
try: try:
events = Event.select().where(Event.id << ids).dicts().iterator() events = Event.select().where(Event.id << ids).dicts().iterator()
return jsonify(list(events)) return JSONResponse(list(events))
except Exception: except Exception:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Events not found"}), 400 content=({"success": False, "message": "Events not found"}), status_code=400
) )
@EventBp.route("/events/search") @router.get("/events/search")
def events_search(): def events_search(request: Request, params: EventsSearchQueryParams = Depends()):
query = request.args.get("query", type=str) query = params.query
search_type = request.args.get("search_type", "thumbnail,description", type=str) search_type = params.search_type
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) include_thumbnails = params.include_thumbnails
limit = request.args.get("limit", 50, type=int) limit = params.limit
# Filters # Filters
cameras = request.args.get("cameras", "all", type=str) cameras = params.cameras
labels = request.args.get("labels", "all", type=str) labels = params.labels
zones = request.args.get("zones", "all", type=str) zones = params.zones
after = request.args.get("after", type=float) after = params.after
before = request.args.get("before", type=float) before = params.before
# for similarity search # for similarity search
event_id = request.args.get("event_id", type=str) event_id = params.event_id
if not query and not event_id: if not query and not event_id:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "A search query must be supplied", "message": "A search query must be supplied",
} }
), ),
400, status_code=400,
) )
if not current_app.frigate_config.semantic_search.enabled: if not request.app.frigate_config.semantic_search.enabled:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Semantic search is not enabled", "message": "Semantic search is not enabled",
} }
), ),
400, status_code=400,
) )
context: EmbeddingsContext = current_app.embeddings context: EmbeddingsContext = request.app.embeddings
selected_columns = [ selected_columns = [
Event.id, Event.id,
@ -437,14 +440,14 @@ def events_search():
try: try:
search_event: Event = Event.get(Event.id == event_id) search_event: Event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Event not found", "message": "Event not found",
} }
), ),
404, status_code=404,
) )
thumbnail = base64.b64decode(search_event.thumbnail) thumbnail = base64.b64decode(search_event.thumbnail)
img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB")) img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB"))
@ -504,7 +507,7 @@ def events_search():
} }
if not results: if not results:
return jsonify([]) return JSONResponse(content=[])
# Get the event data # Get the event data
events = ( events = (
@ -537,15 +540,15 @@ def events_search():
] ]
events = sorted(events, key=lambda x: x["search_distance"])[:limit] events = sorted(events, key=lambda x: x["search_distance"])[:limit]
return jsonify(events) return JSONResponse(content=events)
@EventBp.route("/events/summary") @router.get("/events/summary")
def events_summary(): def events_summary(params: EventsSummaryQueryParams = Depends()):
tz_name = request.args.get("timezone", default="utc", type=str) tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = request.args.get("has_clip", type=int) has_clip = params.has_clip
has_snapshot = request.args.get("has_snapshot", type=int) has_snapshot = params.has_snapshot
clauses = [] clauses = []
@ -582,47 +585,49 @@ def events_summary():
) )
) )
return jsonify([e for e in groups.dicts()]) return JSONResponse(content=[e for e in groups.dicts()])
@EventBp.route("/events/<id>", methods=("GET",)) @router.get("/events/{event_id}")
def event(id): def event(event_id: str):
try: try:
return model_to_dict(Event.get(Event.id == id)) return model_to_dict(Event.get(Event.id == event_id))
except DoesNotExist: except DoesNotExist:
return "Event not found", 404 return JSONResponse(content="Event not found", status_code=404)
@EventBp.route("/events/<id>/retain", methods=("POST",)) @router.post("/events/{event_id}/retain")
def set_retain(id): def set_retain(event_id: str):
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404 content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
) )
event.retain_indefinitely = True event.retain_indefinitely = True
event.save() event.save()
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Event " + id + " retained"}), 200 content=({"success": True, "message": "Event " + event_id + " retained"}),
status_code=200,
) )
@EventBp.route("/events/<id>/plus", methods=("POST",)) @router.post("/events/{event_id}/plus")
def send_to_plus(id): def send_to_plus(request: Request, event_id: str):
if not current_app.frigate_config.plus_api.is_active(): if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set" message = "PLUS_API_KEY environment variable is not set"
logger.error(message) logger.error(message)
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": message, "message": message,
} }
), ),
400, status_code=400,
) )
include_annotation = ( include_annotation = (
@ -630,11 +635,13 @@ def send_to_plus(id):
) )
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
message = f"Event {id} not found" message = f"Event {event_id} not found"
logger.error(message) logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 404) return JSONResponse(
content=({"success": False, "message": message}), status_code=404
)
# events from before the conversion to relative dimensions cant include annotations # events from before the conversion to relative dimensions cant include annotations
if event.data.get("box") is None: if event.data.get("box") is None:
@ -642,20 +649,22 @@ def send_to_plus(id):
if event.end_time is None: if event.end_time is None:
logger.error(f"Unable to load clean png for in-progress event: {event.id}") logger.error(f"Unable to load clean png for in-progress event: {event.id}")
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Unable to load clean png for in-progress event", "message": "Unable to load clean png for in-progress event",
} }
), ),
400, status_code=400,
) )
if event.plus_id: if event.plus_id:
message = "Already submitted to plus" message = "Already submitted to plus"
logger.error(message) logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400) return JSONResponse(
content=({"success": False, "message": message}), status_code=400
)
# load clean.png # load clean.png
try: try:
@ -663,29 +672,29 @@ def send_to_plus(id):
image = cv2.imread(os.path.join(CLIPS_DIR, filename)) image = cv2.imread(os.path.join(CLIPS_DIR, filename))
except Exception: except Exception:
logger.error(f"Unable to load clean png for event: {event.id}") logger.error(f"Unable to load clean png for event: {event.id}")
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": "Unable to load clean png for event"} {"success": False, "message": "Unable to load clean png for event"}
), ),
400, status_code=400,
) )
if image is None or image.size == 0: if image is None or image.size == 0:
logger.error(f"Unable to load clean png for event: {event.id}") logger.error(f"Unable to load clean png for event: {event.id}")
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": "Unable to load clean png for event"} {"success": False, "message": "Unable to load clean png for event"}
), ),
400, status_code=400,
) )
try: try:
plus_id = current_app.frigate_config.plus_api.upload_image(image, event.camera) plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera)
except Exception as ex: except Exception as ex:
logger.exception(ex) logger.exception(ex)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Error uploading image"}), content=({"success": False, "message": "Error uploading image"}),
400, status_code=400,
) )
# store image id in the database # store image id in the database
@ -696,7 +705,7 @@ def send_to_plus(id):
box = event.data["box"] box = event.data["box"]
try: try:
current_app.frigate_config.plus_api.add_annotation( request.app.frigate_config.plus_api.add_annotation(
event.plus_id, event.plus_id,
box, box,
event.label, event.label,
@ -704,59 +713,67 @@ def send_to_plus(id):
except ValueError: except ValueError:
message = "Error uploading annotation, unsupported label provided." message = "Error uploading annotation, unsupported label provided."
logger.error(message) logger.error(message)
return make_response( return JSONResponse(
jsonify({"success": False, "message": message}), content=({"success": False, "message": message}),
400, status_code=400,
) )
except Exception as ex: except Exception as ex:
logger.exception(ex) logger.exception(ex)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Error uploading annotation"}), content=({"success": False, "message": "Error uploading annotation"}),
400, status_code=400,
) )
return make_response(jsonify({"success": True, "plus_id": plus_id}), 200) return JSONResponse(
content=({"success": True, "plus_id": plus_id}), status_code=200
)
@EventBp.route("/events/<id>/false_positive", methods=("PUT",)) @router.put("/events/{event_id}/false_positive")
def false_positive(id): def false_positive(request: Request, event_id: str):
if not current_app.frigate_config.plus_api.is_active(): if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set" message = "PLUS_API_KEY environment variable is not set"
logger.error(message) logger.error(message)
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": message, "message": message,
} }
), ),
400, status_code=400,
) )
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
message = f"Event {id} not found" message = f"Event {event_id} not found"
logger.error(message) logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 404) return JSONResponse(
content=({"success": False, "message": message}), status_code=404
)
# events from before the conversion to relative dimensions cant include annotations # events from before the conversion to relative dimensions cant include annotations
if event.data.get("box") is None: if event.data.get("box") is None:
message = "Events prior to 0.13 cannot be submitted as false positives" message = "Events prior to 0.13 cannot be submitted as false positives"
logger.error(message) logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400) return JSONResponse(
content=({"success": False, "message": message}), status_code=400
)
if event.false_positive: if event.false_positive:
message = "False positive already submitted to Frigate+" message = "False positive already submitted to Frigate+"
logger.error(message) logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400) return JSONResponse(
content=({"success": False, "message": message}), status_code=400
)
if not event.plus_id: if not event.plus_id:
plus_response = send_to_plus(id) plus_response = send_to_plus(event_id)
if plus_response.status_code != 200: if plus_response.status_code != 200:
return plus_response return plus_response
# need to refetch the event now that it has a plus_id # need to refetch the event now that it has a plus_id
event = Event.get(Event.id == id) event = Event.get(Event.id == event_id)
region = event.data["region"] region = event.data["region"]
box = event.data["box"] box = event.data["box"]
@ -769,7 +786,7 @@ def false_positive(id):
) )
try: try:
current_app.frigate_config.plus_api.add_false_positive( request.app.frigate_config.plus_api.add_false_positive(
event.plus_id, event.plus_id,
region, region,
box, box,
@ -782,92 +799,65 @@ def false_positive(id):
except ValueError: except ValueError:
message = "Error uploading false positive, unsupported label provided." message = "Error uploading false positive, unsupported label provided."
logger.error(message) logger.error(message)
return make_response( return JSONResponse(
jsonify({"success": False, "message": message}), content=({"success": False, "message": message}),
400, status_code=400,
) )
except Exception as ex: except Exception as ex:
logger.exception(ex) logger.exception(ex)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Error uploading false positive"}), content=({"success": False, "message": "Error uploading false positive"}),
400, status_code=400,
) )
event.false_positive = True event.false_positive = True
event.save() event.save()
return make_response(jsonify({"success": True, "plus_id": event.plus_id}), 200) return JSONResponse(
content=({"success": True, "plus_id": event.plus_id}), status_code=200
)
@EventBp.route("/events/<id>/retain", methods=("DELETE",)) @router.delete("/events/{event_id}/retain")
def delete_retain(id): def delete_retain(event_id: str):
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404 content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
) )
event.retain_indefinitely = False event.retain_indefinitely = False
event.save() event.save()
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200 content=({"success": True, "message": "Event " + event_id + " un-retained"}),
status_code=200,
) )
@EventBp.route("/events/<id>/sub_label", methods=("POST",)) @router.post("/events/{event_id}/sub_label")
def set_sub_label(id): def set_sub_label(
request: Request,
event_id: str,
body: EventsSubLabelBody,
):
try: try:
event: Event = Event.get(Event.id == id) event: Event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404 content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
) )
json: dict[str, any] = request.get_json(silent=True) or {} new_sub_label = body.subLabel
new_sub_label = json.get("subLabel") new_score = body.subLabelScore
new_score = json.get("subLabelScore")
if new_sub_label is None:
return make_response(
jsonify(
{
"success": False,
"message": "A sub label must be supplied",
}
),
400,
)
if new_sub_label and len(new_sub_label) > 100:
return make_response(
jsonify(
{
"success": False,
"message": new_sub_label
+ " exceeds the 100 character limit for sub_label",
}
),
400,
)
if new_score is not None and (new_score > 1.0 or new_score < 0):
return make_response(
jsonify(
{
"success": False,
"message": new_score
+ " does not fit within the expected bounds 0 <= score <= 1.0",
}
),
400,
)
if not event.end_time: if not event.end_time:
# update tracked object # update tracked object
tracked_obj: TrackedObject = ( tracked_obj: TrackedObject = (
current_app.detected_frames_processor.camera_states[ request.app.detected_frames_processor.camera_states[
event.camera event.camera
].tracked_objects.get(event.id) ].tracked_objects.get(event.id)
) )
@ -878,7 +868,7 @@ def set_sub_label(id):
# update timeline items # update timeline items
Timeline.update( Timeline.update(
data=Timeline.data.update({"sub_label": (new_sub_label, new_score)}) data=Timeline.data.update({"sub_label": (new_sub_label, new_score)})
).where(Timeline.source_id == id).execute() ).where(Timeline.source_id == event_id).execute()
event.sub_label = new_sub_label event.sub_label = new_sub_label
@ -888,70 +878,78 @@ def set_sub_label(id):
event.data = data event.data = data
event.save() event.save()
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Event " + id + " sub label set to " + new_sub_label, "message": "Event " + event_id + " sub label set to " + new_sub_label,
} }
), ),
200, status_code=200,
) )
@EventBp.route("/events/<id>/description", methods=("POST",)) @router.post("/events/{event_id}/description")
def set_description(id): def set_description(
request: Request,
event_id: str,
body: EventsDescriptionBody,
):
try: try:
event: Event = Event.get(Event.id == id) event: Event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404 content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
) )
json: dict[str, any] = request.get_json(silent=True) or {} new_description = body.description
new_description = json.get("description")
if new_description is None or len(new_description) == 0: if new_description is None or len(new_description) == 0:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "description cannot be empty", "message": "description cannot be empty",
} }
), ),
400, status_code=400,
) )
event.data["description"] = new_description event.data["description"] = new_description
event.save() event.save()
# If semantic search is enabled, update the index # If semantic search is enabled, update the index
if current_app.frigate_config.semantic_search.enabled: if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = current_app.embeddings context: EmbeddingsContext = request.app.embeddings
context.embeddings.description.upsert( context.embeddings.description.upsert(
documents=[new_description], documents=[new_description],
metadatas=[get_metadata(event)], metadatas=[get_metadata(event)],
ids=[id], ids=[event_id],
) )
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Event " + id + " description set to " + new_description, "message": "Event "
+ event_id
+ " description set to "
+ new_description,
} }
), ),
200, status_code=200,
) )
@EventBp.route("/events/<id>", methods=("DELETE",)) @router.delete("/events/{event_id}")
def delete_event(id): def delete_event(request: Request, event_id: str):
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404 content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
) )
media_name = f"{event.camera}-{event.id}" media_name = f"{event.camera}-{event.id}"
@ -965,82 +963,86 @@ def delete_event(id):
media.unlink(missing_ok=True) media.unlink(missing_ok=True)
event.delete_instance() event.delete_instance()
Timeline.delete().where(Timeline.source_id == id).execute() Timeline.delete().where(Timeline.source_id == event_id).execute()
# If semantic search is enabled, update the index # If semantic search is enabled, update the index
if current_app.frigate_config.semantic_search.enabled: if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = current_app.embeddings context: EmbeddingsContext = request.app.embeddings
context.embeddings.thumbnail.delete(ids=[id]) context.embeddings.thumbnail.delete(ids=[event_id])
context.embeddings.description.delete(ids=[id]) context.embeddings.description.delete(ids=[event_id])
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Event " + id + " deleted"}), 200 content=({"success": True, "message": "Event " + event_id + " deleted"}),
status_code=200,
) )
@EventBp.route("/events/<camera_name>/<label>/create", methods=["POST"]) @router.post("/events/{camera_name}/{label}/create")
def create_event(camera_name, label): def create_event(
if not camera_name or not current_app.frigate_config.cameras.get(camera_name): request: Request,
return make_response( camera_name: str,
jsonify( label: str,
body: EventsCreateBody = None,
):
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
return JSONResponse(
content=(
{"success": False, "message": f"{camera_name} is not a valid camera."} {"success": False, "message": f"{camera_name} is not a valid camera."}
), ),
404, status_code=404,
) )
if not label: if not label:
return make_response( return JSONResponse(
jsonify({"success": False, "message": f"{label} must be set."}), 404 content=({"success": False, "message": f"{label} must be set."}),
status_code=404,
) )
json: dict[str, any] = request.get_json(silent=True) or {}
try: try:
frame = current_app.detected_frames_processor.get_current_frame(camera_name) frame = request.app.detected_frames_processor.get_current_frame(camera_name)
event_id = current_app.external_processor.create_manual_event( event_id = request.app.external_processor.create_manual_event(
camera_name, camera_name,
label, label,
json.get("source_type", "api"), body.source_type,
json.get("sub_label", None), body.sub_label,
json.get("score", 0), body.score,
json.get("duration", 30), body.duration,
json.get("include_recording", True), body.include_recording,
json.get("draw", {}), body.draw,
frame, frame,
) )
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return make_response( return JSONResponse(
jsonify({"success": False, "message": "An unknown error occurred"}), content=({"success": False, "message": "An unknown error occurred"}),
500, status_code=500,
) )
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Successfully created event.", "message": "Successfully created event.",
"event_id": event_id, "event_id": event_id,
} }
), ),
200, status_code=200,
) )
@EventBp.route("/events/<event_id>/end", methods=["PUT"]) @router.put("/events/{event_id}/end")
def end_event(event_id): def end_event(request: Request, event_id: str, body: EventsEndBody):
json: dict[str, any] = request.get_json(silent=True) or {}
try: try:
end_time = json.get("end_time", datetime.now().timestamp()) end_time = body.end_time
current_app.external_processor.finish_manual_event(event_id, end_time) request.app.external_processor.finish_manual_event(event_id, end_time)
except Exception: except Exception:
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": f"{event_id} must be set and valid."} {"success": False, "message": f"{event_id} must be set and valid."}
), ),
404, status_code=404,
) )
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Event successfully ended."}), 200 content=({"success": True, "message": "Event successfully ended."}),
status_code=200,
) )

View File

@ -5,54 +5,50 @@ from pathlib import Path
from typing import Optional from typing import Optional
import psutil import psutil
from flask import ( from fastapi import APIRouter, Request
Blueprint, from fastapi.responses import JSONResponse
current_app,
jsonify,
make_response,
request,
)
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.api.defs.tags import Tags
from frigate.const import EXPORT_DIR from frigate.const import EXPORT_DIR
from frigate.models import Export, Recordings from frigate.models import Export, Recordings
from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.record.export import PlaybackFactorEnum, RecordingExporter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ExportBp = Blueprint("exports", __name__) router = APIRouter(tags=[Tags.export])
@ExportBp.route("/exports") @router.get("/exports")
def get_exports(): def get_exports():
exports = Export.select().order_by(Export.date.desc()).dicts().iterator() exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
return jsonify([e for e in exports]) return JSONResponse(content=[e for e in exports])
@ExportBp.route( @router.post("/export/{camera_name}/start/{start_time}/end/{end_time}")
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"] def export_recording(
) request: Request,
@ExportBp.route( camera_name: str,
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>", start_time: float,
methods=["POST"], end_time: float,
) body: dict = None,
def export_recording(camera_name: str, start_time, end_time): ):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name): if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": f"{camera_name} is not a valid camera."} {"success": False, "message": f"{camera_name} is not a valid camera."}
), ),
404, status_code=404,
) )
json: dict[str, any] = request.get_json(silent=True) or {} json: dict[str, any] = body or {}
playback_factor = json.get("playback", "realtime") playback_factor = json.get("playback", "realtime")
friendly_name: Optional[str] = json.get("name") friendly_name: Optional[str] = json.get("name")
if len(friendly_name or "") > 256: if len(friendly_name or "") > 256:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "File name is too long."}), content=({"success": False, "message": "File name is too long."}),
401, status_code=401,
) )
existing_image = json.get("image_path") existing_image = json.get("image_path")
@ -69,15 +65,15 @@ def export_recording(camera_name: str, start_time, end_time):
) )
if recordings_count <= 0: if recordings_count <= 0:
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": "No recordings found for time range"} {"success": False, "message": "No recordings found for time range"}
), ),
400, status_code=400,
) )
exporter = RecordingExporter( exporter = RecordingExporter(
current_app.frigate_config, request.app.frigate_config,
camera_name, camera_name,
friendly_name, friendly_name,
existing_image, existing_image,
@ -90,58 +86,58 @@ def export_recording(camera_name: str, start_time, end_time):
), ),
) )
exporter.start() exporter.start()
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Starting export of recording.", "message": "Starting export of recording.",
} }
), ),
200, status_code=200,
) )
@ExportBp.route("/export/<id>/<new_name>", methods=["PATCH"]) @router.patch("/export/{event_id}/{new_name}")
def export_rename(id, new_name: str): def export_rename(event_id: str, new_name: str):
try: try:
export: Export = Export.get(Export.id == id) export: Export = Export.get(Export.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Export not found.", "message": "Export not found.",
} }
), ),
404, status_code=404,
) )
export.name = new_name export.name = new_name
export.save() export.save()
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Successfully renamed export.", "message": "Successfully renamed export.",
} }
), ),
200, status_code=200,
) )
@ExportBp.route("/export/<id>", methods=["DELETE"]) @router.delete("/export/{event_id}")
def export_delete(id: str): def export_delete(event_id: str):
try: try:
export: Export = Export.get(Export.id == id) export: Export = Export.get(Export.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": False, "success": False,
"message": "Export not found.", "message": "Export not found.",
} }
), ),
404, status_code=404,
) )
files_in_use = [] files_in_use = []
@ -158,11 +154,11 @@ def export_delete(id: str):
continue continue
if export.video_path.split("/")[-1] in files_in_use: if export.video_path.split("/")[-1] in files_in_use:
return make_response( return JSONResponse(
jsonify( content=(
{"success": False, "message": "Can not delete in progress export."} {"success": False, "message": "Can not delete in progress export."}
), ),
400, status_code=400,
) )
Path(export.video_path).unlink(missing_ok=True) Path(export.video_path).unlink(missing_ok=True)
@ -171,12 +167,12 @@ def export_delete(id: str):
Path(export.thumb_path).unlink(missing_ok=True) Path(export.thumb_path).unlink(missing_ok=True)
export.delete_instance() export.delete_instance()
return make_response( return JSONResponse(
jsonify( content=(
{ {
"success": True, "success": True,
"message": "Successfully deleted export.", "message": "Successfully deleted export.",
} }
), ),
200, status_code=200,
) )

108
frigate/api/fastapi_app.py Normal file
View File

@ -0,0 +1,108 @@
import logging
from typing import Optional
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from playhouse.sqliteq import SqliteQueueDatabase
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette_context import middleware, plugins
from starlette_context.plugins import Plugin
from frigate.api import app as main_app
from frigate.api import auth, event, export, media, notification, preview, review
from frigate.api.auth import get_jwt_secret, limiter
from frigate.config import FrigateConfig
from frigate.embeddings import EmbeddingsContext
from frigate.events.external import ExternalEventProcessor
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer
logger = logging.getLogger(__name__)
def check_csrf(request: Request):
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
pass
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return JSONResponse(
content={"success": False, "message": "Missing CSRF header"},
status_code=401,
)
# Used to retrieve the remote-user header: https://starlette-context.readthedocs.io/en/latest/plugins.html#easy-mode
class RemoteUserPlugin(Plugin):
key = "Remote-User"
def create_fastapi_app(
frigate_config: FrigateConfig,
database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext],
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
external_processor: ExternalEventProcessor,
stats_emitter: StatsEmitter,
):
logger.info("Starting FastAPI app")
app = FastAPI(
debug=False,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
)
# update the request_address with the x-forwarded-for header from nginx
# https://starlette-context.readthedocs.io/en/latest/plugins.html#forwarded-for
app.add_middleware(
middleware.ContextMiddleware,
plugins=(plugins.ForwardedForPlugin(),),
)
# Middleware to connect to DB before and close connection after request
# https://github.com/fastapi/full-stack-fastapi-template/issues/224#issuecomment-737423886
# https://fastapi.tiangolo.com/tutorial/middleware/#before-and-after-the-response
@app.middleware("http")
async def frigate_middleware(request: Request, call_next):
# Before request
check_csrf(request)
if database.is_closed():
database.connect()
response = await call_next(request)
# After request https://stackoverflow.com/a/75487519
if not database.is_closed():
database.close()
return response
# Rate limiter (used for login endpoint)
auth.rateLimiter.set_limit(frigate_config.auth.failed_login_rate_limit)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# Routes
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
app.include_router(auth.router)
app.include_router(review.router)
app.include_router(main_app.router)
app.include_router(preview.router)
app.include_router(notification.router)
app.include_router(export.router)
app.include_router(event.router)
app.include_router(media.router)
# App Properties
app.frigate_config = frigate_config
app.embeddings = embeddings
app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer
app.camera_error_image = None
app.onvif = onvif
app.stats_emitter = stats_emitter
app.external_processor = external_processor
app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None
return app

File diff suppressed because it is too large Load Diff

View File

@ -4,62 +4,62 @@ import logging
import os import os
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from flask import ( from fastapi import APIRouter, Request
Blueprint, from fastapi.responses import JSONResponse
current_app,
jsonify,
make_response,
request,
)
from peewee import DoesNotExist from peewee import DoesNotExist
from py_vapid import Vapid01, utils from py_vapid import Vapid01, utils
from frigate.api.defs.tags import Tags
from frigate.const import CONFIG_DIR from frigate.const import CONFIG_DIR
from frigate.models import User from frigate.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
NotificationBp = Blueprint("notifications", __name__) router = APIRouter(tags=[Tags.notifications])
@NotificationBp.route("/notifications/pubkey", methods=["GET"]) @router.get("/notifications/pubkey")
def get_vapid_pub_key(): def get_vapid_pub_key(request: Request):
if not current_app.frigate_config.notifications.enabled: if not request.app.frigate_config.notifications.enabled:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Notifications are not enabled."}), content=({"success": False, "message": "Notifications are not enabled."}),
400, status_code=400,
) )
key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
raw_pub = key.public_key.public_bytes( raw_pub = key.public_key.public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
) )
return jsonify(utils.b64urlencode(raw_pub)), 200 return JSONResponse(content=utils.b64urlencode(raw_pub), status_code=200)
@NotificationBp.route("/notifications/register", methods=["POST"]) @router.post("/notifications/register")
def register_notifications(): def register_notifications(request: Request, body: dict = None):
if current_app.frigate_config.auth.enabled: if request.app.frigate_config.auth.enabled:
username = request.headers.get("remote-user", type=str) or "admin" # FIXME: For FastAPI the remote-user is not being populated
username = request.headers.get("remote-user") or "admin"
else: else:
username = "admin" username = "admin"
json: dict[str, any] = request.get_json(silent=True) or {} json: dict[str, any] = body or {}
sub = json.get("sub") sub = json.get("sub")
if not sub: if not sub:
return jsonify( return JSONResponse(
{"success": False, "message": "Subscription must be provided."} content={"success": False, "message": "Subscription must be provided."},
), 400 status_code=400,
)
try: try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where( User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username User.username == username
).execute() ).execute()
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Successfully saved token."}), 200 content=({"success": True, "message": "Successfully saved token."}),
status_code=200,
) )
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Could not find user."}), 404 content=({"success": False, "message": "Could not find user."}),
status_code=404,
) )

View File

@ -5,23 +5,21 @@ 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.api.defs.tags import Tags
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=[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,24 +60,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>/<int:day>/<int:hour>/<camera_name>/<tz_name>") @router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
@PreviewBp.route( def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
"/preview/<year_month>/<float:day>/<float:hour>/<camera_name>/<tz_name>" """Get all mp4 previews relevant for time period given the timezone"""
)
def preview_hour(year_month, day, hour, camera_name, tz_name):
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)
@ -92,11 +86,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}"
@ -116,4 +107,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

@ -1,36 +1,40 @@
"""Review apis.""" """Review apis."""
import logging import logging
from datetime import datetime, timedelta
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
from flask import Blueprint, jsonify, make_response, request from fastapi import APIRouter
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, fn, operator from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.defs.review_query_parameters import (
ReviewActivityMotionQueryParams,
ReviewQueryParams,
ReviewSummaryQueryParams,
)
from frigate.api.defs.tags import Tags
from frigate.models import Recordings, ReviewSegment from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ReviewBp = Blueprint("reviews", __name__) router = APIRouter(tags=[Tags.review])
@ReviewBp.route("/review") @router.get("/review")
def review(): def review(params: ReviewQueryParams = Depends()):
cameras = request.args.get("cameras", "all") cameras = params.cameras
labels = request.args.get("labels", "all") labels = params.labels
zones = request.args.get("zones", "all") zones = params.zones
reviewed = request.args.get("reviewed", type=int, default=0) reviewed = params.reviewed
limit = request.args.get("limit", type=int, default=None) limit = params.limit
severity = request.args.get("severity", None) severity = params.severity
before = params.before
before = request.args.get("before", type=float, default=datetime.now().timestamp()) after = params.after
after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=24)).timestamp()
)
clauses = [ clauses = [
( (
@ -91,39 +95,38 @@ def review():
.iterator() .iterator()
) )
return jsonify([r for r in review]) return JSONResponse(content=[r for r in review])
@ReviewBp.route("/review/event/<id>") @router.get("/review/event/{event_id}")
def get_review_from_event(id: str): def get_review_from_event(event_id: str):
try: try:
return model_to_dict( return model_to_dict(
ReviewSegment.get( ReviewSegment.get(
ReviewSegment.data["detections"].cast("text") % f'*"{id}"*' ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
) )
) )
except DoesNotExist: except DoesNotExist:
return "Review item not found", 404 return "Review item not found", 404
@ReviewBp.route("/review/<id>") @router.get("/review/{event_id}")
def get_review(id: str): def get_review(event_id: str):
try: try:
return model_to_dict(ReviewSegment.get(ReviewSegment.id == id)) return model_to_dict(ReviewSegment.get(ReviewSegment.id == event_id))
except DoesNotExist: except DoesNotExist:
return "Review item not found", 404 return "Review item not found", 404
@ReviewBp.route("/review/summary") @router.get("/review/summary")
def review_summary(): def review_summary(params: ReviewSummaryQueryParams = Depends()):
tz_name = request.args.get("timezone", default="utc", type=str) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) day_ago = params.day_ago
day_ago = (datetime.now() - timedelta(hours=24)).timestamp() month_ago = params.month_ago
month_ago = (datetime.now() - timedelta(days=30)).timestamp()
cameras = request.args.get("cameras", "all") cameras = params.cameras
labels = request.args.get("labels", "all") labels = params.labels
zones = request.args.get("zones", "all") zones = params.zones
clauses = [(ReviewSegment.start_time > day_ago)] clauses = [(ReviewSegment.start_time > day_ago)]
@ -358,53 +361,60 @@ def review_summary():
for e in last_month.dicts().iterator(): for e in last_month.dicts().iterator():
data[e["day"]] = e data[e["day"]] = e
return jsonify(data) return JSONResponse(content=data)
@ReviewBp.route("/reviews/viewed", methods=("POST",)) @router.post("/reviews/viewed")
def set_multiple_reviewed(): def set_multiple_reviewed(body: dict = None):
json: dict[str, any] = request.get_json(silent=True) or {} json: dict[str, any] = body or {}
list_of_ids = json.get("ids", "") list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0: if not list_of_ids or len(list_of_ids) == 0:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Not a valid list of ids"}), 404 context=({"success": False, "message": "Not a valid list of ids"}),
status_code=404,
) )
ReviewSegment.update(has_been_reviewed=True).where( ReviewSegment.update(has_been_reviewed=True).where(
ReviewSegment.id << list_of_ids ReviewSegment.id << list_of_ids
).execute() ).execute()
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Reviewed multiple items"}), 200 content=({"success": True, "message": "Reviewed multiple items"}),
status_code=200,
) )
@ReviewBp.route("/review/<id>/viewed", methods=("DELETE",)) @router.delete("/review/{event_id}/viewed")
def set_not_reviewed(id): def set_not_reviewed(event_id: str):
try: try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id) review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id)
except DoesNotExist: except DoesNotExist:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Review " + id + " not found"}), 404 content=(
{"success": False, "message": "Review " + event_id + " not found"}
),
status_code=404,
) )
review.has_been_reviewed = False review.has_been_reviewed = False
review.save() review.save()
return make_response( return JSONResponse(
jsonify({"success": True, "message": "Reviewed " + id + " not viewed"}), 200 content=({"success": True, "message": "Reviewed " + event_id + " not viewed"}),
status_code=200,
) )
@ReviewBp.route("/reviews/delete", methods=("POST",)) @router.post("/reviews/delete")
def delete_reviews(): def delete_reviews(body: dict = None):
json: dict[str, any] = request.get_json(silent=True) or {} json: dict[str, any] = body or {}
list_of_ids = json.get("ids", "") list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0: if not list_of_ids or len(list_of_ids) == 0:
return make_response( return JSONResponse(
jsonify({"success": False, "message": "Not a valid list of ids"}), 404 content=({"success": False, "message": "Not a valid list of ids"}),
status_code=404,
) )
reviews = ( reviews = (
@ -446,18 +456,20 @@ def delete_reviews():
Recordings.delete().where(Recordings.id << recording_ids).execute() Recordings.delete().where(Recordings.id << recording_ids).execute()
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200) return JSONResponse(
content=({"success": True, "message": "Delete reviews"}), status_code=200
@ReviewBp.route("/review/activity/motion")
def motion_activity():
"""Get motion and audio activity."""
cameras = request.args.get("cameras", "all")
before = request.args.get("before", type=float, default=datetime.now().timestamp())
after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
) )
@router.get("/review/activity/motion")
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
"""Get motion and audio activity."""
cameras = params.cameras
before = params.before
after = params.after
# get scale in seconds
scale = params.scale
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
clauses.append((Recordings.motion > 0)) clauses.append((Recordings.motion > 0))
@ -477,15 +489,12 @@ def motion_activity():
.iterator() .iterator()
) )
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
# resample data using pandas to get activity on scaled basis # resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"]) df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
if df.empty: if df.empty:
logger.warning("No motion data found for the requested time range") logger.warning("No motion data found for the requested time range")
return jsonify([]) return JSONResponse(content=[])
df = df.astype(dtype={"motion": "float32"}) df = df.astype(dtype={"motion": "float32"})
@ -520,17 +529,17 @@ def motion_activity():
# change types for output # change types for output
df.index = df.index.astype(int) // (10**9) df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records") normalized = df.reset_index().to_dict("records")
return jsonify(normalized) return JSONResponse(content=normalized)
@ReviewBp.route("/review/activity/audio") @router.get("/review/activity/audio")
def audio_activity(): def audio_activity(params: ReviewActivityMotionQueryParams = Depends()):
"""Get motion and audio activity.""" """Get motion and audio activity."""
cameras = request.args.get("cameras", "all") cameras = params.cameras
before = request.args.get("before", type=float, default=datetime.now().timestamp()) before = params.before
after = request.args.get( after = params.after
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp() # get scale in seconds
) scale = params.scale
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
@ -562,9 +571,6 @@ def audio_activity():
} }
) )
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
# resample data using pandas to get activity on scaled basis # resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "audio"]) df = pd.DataFrame(data, columns=["start_time", "audio"])
df = df.astype(dtype={"audio": "float16"}) df = df.astype(dtype={"audio": "float16"})
@ -584,4 +590,4 @@ def audio_activity():
# change types for output # change types for output
df.index = df.index.astype(int) // (10**9) df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records") normalized = df.reset_index().to_dict("records")
return jsonify(normalized) return JSONResponse(content=normalized)

View File

@ -9,12 +9,13 @@ from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
import psutil import psutil
import uvicorn
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
from frigate.api.app import create_app
from frigate.api.auth import hash_password from frigate.api.auth import hash_password
from frigate.api.fastapi_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
@ -645,16 +646,21 @@ class FrigateApp:
self.init_auth() self.init_auth()
try: try:
create_app( uvicorn.run(
self.config, create_fastapi_app(
self.db, self.config,
self.embeddings, self.db,
self.detected_frames_processor, self.embeddings,
self.storage_maintainer, self.detected_frames_processor,
self.onvif_controller, self.storage_maintainer,
self.external_event_processor, self.onvif_controller,
self.stats_emitter, self.external_event_processor,
).run(host="127.0.0.1", port=5001, debug=False, threaded=True) self.stats_emitter,
),
host="127.0.0.1",
port=5001,
log_level="error",
)
finally: finally:
self.stop() self.stop()

View File

@ -1,18 +1,18 @@
import datetime import datetime
import json
import logging import logging
import os import os
import unittest import unittest
from unittest.mock import Mock from unittest.mock import Mock
from fastapi.testclient import TestClient
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.app import create_app from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.models import Event, Recordings from frigate.models import Event, Recordings, Timeline
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
@ -26,7 +26,7 @@ class TestHttp(unittest.TestCase):
router.run() router.run()
migrate_db.close() migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB) self.db = SqliteQueueDatabase(TEST_DB)
models = [Event, Recordings] models = [Event, Recordings, Timeline]
self.db.bind(models) self.db.bind(models)
self.minimal_config = { self.minimal_config = {
@ -112,7 +112,7 @@ class TestHttp(unittest.TestCase):
pass pass
def test_get_event_list(self): def test_get_event_list(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -125,30 +125,30 @@ class TestHttp(unittest.TestCase):
id = "123456.random" id = "123456.random"
id2 = "7890.random" id2 = "7890.random"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
events = client.get("/events").json events = client.get("/events").json()
assert events assert events
assert len(events) == 1 assert len(events) == 1
assert events[0]["id"] == id assert events[0]["id"] == id
_insert_mock_event(id2) _insert_mock_event(id2)
events = client.get("/events").json events = client.get("/events").json()
assert events assert events
assert len(events) == 2 assert len(events) == 2
events = client.get( events = client.get(
"/events", "/events",
query_string={"limit": 1}, params={"limit": 1},
).json ).json()
assert events assert events
assert len(events) == 1 assert len(events) == 1
events = client.get( events = client.get(
"/events", "/events",
query_string={"has_clip": 0}, params={"has_clip": 0},
).json ).json()
assert not events assert not events
def test_get_good_event(self): def test_get_good_event(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -160,16 +160,16 @@ class TestHttp(unittest.TestCase):
) )
id = "123456.random" id = "123456.random"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
event = client.get(f"/events/{id}").json event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
assert event == model_to_dict(Event.get(Event.id == id)) assert event == model_to_dict(Event.get(Event.id == id))
def test_get_bad_event(self): def test_get_bad_event(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -182,14 +182,14 @@ class TestHttp(unittest.TestCase):
id = "123456.random" id = "123456.random"
bad_id = "654321.other" bad_id = "654321.other"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
event = client.get(f"/events/{bad_id}").json event_response = client.get(f"/events/{bad_id}")
assert event_response.status_code == 404
assert not event assert event_response.json() == "Event not found"
def test_delete_event(self): def test_delete_event(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -201,17 +201,17 @@ class TestHttp(unittest.TestCase):
) )
id = "123456.random" id = "123456.random"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
event = client.get(f"/events/{id}").json event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
client.delete(f"/events/{id}") client.delete(f"/events/{id}")
event = client.get(f"/events/{id}").json event = client.get(f"/events/{id}").json()
assert not event assert event == "Event not found"
def test_event_retention(self): def test_event_retention(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -223,21 +223,21 @@ class TestHttp(unittest.TestCase):
) )
id = "123456.random" id = "123456.random"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
client.post(f"/events/{id}/retain") client.post(f"/events/{id}/retain")
event = client.get(f"/events/{id}").json event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
assert event["retain_indefinitely"] is True assert event["retain_indefinitely"] is True
client.delete(f"/events/{id}/retain") client.delete(f"/events/{id}/retain")
event = client.get(f"/events/{id}").json event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
assert event["retain_indefinitely"] is False assert event["retain_indefinitely"] is False
def test_event_time_filtering(self): def test_event_time_filtering(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -252,30 +252,30 @@ class TestHttp(unittest.TestCase):
morning = 1656590400 # 06/30/2022 6 am (GMT) morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT) evening = 1656633600 # 06/30/2022 6 pm (GMT)
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(morning_id, morning) _insert_mock_event(morning_id, morning)
_insert_mock_event(evening_id, evening) _insert_mock_event(evening_id, evening)
# both events come back # both events come back
events = client.get("/events").json events = client.get("/events").json()
assert events assert events
assert len(events) == 2 assert len(events) == 2
# morning event is excluded # morning event is excluded
events = client.get( events = client.get(
"/events", "/events",
query_string={"time_range": "07:00,24:00"}, params={"time_range": "07:00,24:00"},
).json ).json()
assert events assert events
# assert len(events) == 1 # assert len(events) == 1
# evening event is excluded # evening event is excluded
events = client.get( events = client.get(
"/events", "/events",
query_string={"time_range": "00:00,18:00"}, params={"time_range": "00:00,18:00"},
).json ).json()
assert events assert events
assert len(events) == 1 assert len(events) == 1
def test_set_delete_sub_label(self): def test_set_delete_sub_label(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -288,29 +288,29 @@ class TestHttp(unittest.TestCase):
id = "123456.random" id = "123456.random"
sub_label = "sub" sub_label = "sub"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
client.post( new_sub_label_response = client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
data=json.dumps({"subLabel": sub_label}), json={"subLabel": sub_label},
content_type="application/json",
) )
event = client.get(f"/events/{id}").json assert new_sub_label_response.status_code == 200
event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
assert event["sub_label"] == sub_label assert event["sub_label"] == sub_label
client.post( empty_sub_label_response = client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
data=json.dumps({"subLabel": ""}), json={"subLabel": ""},
content_type="application/json",
) )
event = client.get(f"/events/{id}").json assert empty_sub_label_response.status_code == 200
event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
assert event["sub_label"] == "" assert event["sub_label"] == ""
def test_sub_label_list(self): def test_sub_label_list(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -323,19 +323,18 @@ class TestHttp(unittest.TestCase):
id = "123456.random" id = "123456.random"
sub_label = "sub" sub_label = "sub"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
client.post( client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
data=json.dumps({"subLabel": sub_label}), json={"subLabel": sub_label},
content_type="application/json",
) )
sub_labels = client.get("/sub_labels").json sub_labels = client.get("/sub_labels").json()
assert sub_labels assert sub_labels
assert sub_labels == [sub_label] assert sub_labels == [sub_label]
def test_config(self): def test_config(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -346,13 +345,13 @@ class TestHttp(unittest.TestCase):
None, None,
) )
with app.test_client() as client: with TestClient(app) as client:
config = client.get("/config").json config = client.get("/config").json()
assert config assert config
assert config["cameras"]["front_door"] assert config["cameras"]["front_door"]
def test_recordings(self): def test_recordings(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -364,16 +363,18 @@ class TestHttp(unittest.TestCase):
) )
id = "123456.random" id = "123456.random"
with app.test_client() as client: with TestClient(app) as client:
_insert_mock_recording(id) _insert_mock_recording(id)
recording = client.get("/front_door/recordings").json response = client.get("/front_door/recordings")
assert response.status_code == 200
recording = response.json()
assert recording assert recording
assert recording[0]["id"] == id assert recording[0]["id"] == id
def test_stats(self): def test_stats(self):
stats = Mock(spec=StatsEmitter) stats = Mock(spec=StatsEmitter)
stats.get_latest_stats.return_value = self.test_stats stats.get_latest_stats.return_value = self.test_stats
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -384,8 +385,8 @@ class TestHttp(unittest.TestCase):
stats, stats,
) )
with app.test_client() as client: with TestClient(app) as client:
full_stats = client.get("/stats").json full_stats = client.get("/stats").json()
assert full_stats == self.test_stats assert full_stats == self.test_stats
@ -418,8 +419,8 @@ def _insert_mock_recording(id: str) -> Event:
id=id, id=id,
camera="front_door", camera="front_door",
path=f"/recordings/{id}", path=f"/recordings/{id}",
start_time=datetime.datetime.now().timestamp() - 50, start_time=datetime.datetime.now().timestamp() - 60,
end_time=datetime.datetime.now().timestamp() - 60, end_time=datetime.datetime.now().timestamp() - 50,
duration=10, duration=10,
motion=True, motion=True,
objects=True, objects=True,

View File

@ -54,7 +54,7 @@ export default function CameraImage({
return; return;
} }
const newSrc = `${apiHost}api/${name}/latest.webp?h=${requestHeight}${ const newSrc = `${apiHost}api/${name}/latest.webp?height=${requestHeight}${
searchParams ? `&${searchParams}` : "" searchParams ? `&${searchParams}` : ""
}`; }`;

View File

@ -89,7 +89,7 @@ export default function CameraImage({
if (!config || scaledHeight === 0 || !canvasRef.current) { if (!config || scaledHeight === 0 || !canvasRef.current) {
return; return;
} }
img.src = `${apiHost}api/${name}/latest.webp?h=${scaledHeight}${ img.src = `${apiHost}api/${name}/latest.webp?height=${scaledHeight}${
searchParams ? `&${searchParams}` : "" searchParams ? `&${searchParams}` : ""
}`; }`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); }, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);