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.*
Flask == 3.0.*
Flask_Limiter == 3.8.*
# FastAPI
starlette-context == 0.3.6
fastapi == 0.115.0
slowapi == 0.1.9
imutils == 0.5.*
joserfc == 1.0.*
pathvalidate == 3.2.*
markupsafe == 2.1.*
mypy == 1.6.1
numpy == 1.26.*

View File

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

View File

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

View File

@ -10,27 +10,19 @@ from functools import reduce
from typing import Optional
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 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.event import EventBp
from frigate.api.export import ExportBp
from frigate.api.media import MediaBp
from frigate.api.notification import NotificationBp
from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp
from frigate.api.defs.app_body import AppConfigSetBody
from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
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.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer
from frigate.util.builtin import (
clean_camera_user_pass,
get_tz_modifiers,
@ -42,134 +34,75 @@ from frigate.version import VERSION
logger = logging.getLogger(__name__)
bp = Blueprint("frigate", __name__)
bp.register_blueprint(EventBp)
bp.register_blueprint(ExportBp)
bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp)
bp.register_blueprint(NotificationBp)
router = APIRouter(tags=[Tags.app])
def create_app(
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("/")
@router.get("/")
def is_healthy():
return "Frigate is running. Alive and healthy!"
@bp.route("/config/schema.json")
def config_schema():
return current_app.response_class(
current_app.frigate_config.schema_json(), mimetype="application/json"
@router.get("/config/schema.json")
def config_schema(request: Request):
return Response(
content=request.app.frigate_config.schema_json(), media_type="application/json"
)
@bp.route("/go2rtc/streams")
@router.get("/go2rtc/streams")
def go2rtc_streams():
r = requests.get("http://127.0.0.1:1984/api/streams")
if not r.ok:
logger.error("Failed to fetch streams from go2rtc")
return make_response(
jsonify({"success": False, "message": "Error fetching stream data"}),
500,
return JSONResponse(
content=({"success": False, "message": "Error fetching stream data"}),
status_code=500,
)
stream_data = r.json()
for data in stream_data.values():
for producer in data.get("producers", []):
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):
r = requests.get(
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone"
)
if not r.ok:
logger.error("Failed to fetch streams from go2rtc")
return make_response(
jsonify({"success": False, "message": "Error fetching stream data"}),
500,
return JSONResponse(
content=({"success": False, "message": "Error fetching stream data"}),
status_code=500,
)
stream_data = r.json()
for producer in stream_data.get("producers", []):
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():
return VERSION
@bp.route("/stats")
def stats():
return jsonify(current_app.stats_emitter.get_latest_stats())
@router.get("/stats")
def stats(request: Request):
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
@bp.route("/stats/history")
def stats_history():
keys = request.args.get("keys", default=None)
@router.get("/stats/history")
def stats_history(request: Request, keys: str = None):
if keys:
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")
def config():
config_obj: FrigateConfig = current_app.frigate_config
@router.get("/config")
def config(request: Request):
config_obj: FrigateConfig = request.app.frigate_config
config: dict[str, dict[str, any]] = config_obj.model_dump(
mode="json", warnings="none", exclude_none=True
)
@ -180,7 +113,7 @@ def config():
# remove the proxy secret
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]
# clean paths
@ -196,18 +129,18 @@ def config():
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
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
for detector_config in config["detectors"].values():
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():
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
@ -218,8 +151,9 @@ def config_raw():
config_file = config_file_yaml
if not os.path.isfile(config_file):
return make_response(
jsonify({"success": False, "message": "Could not find file"}), 404
return JSONResponse(
content=({"success": False, "message": "Could not find file"}),
status_code=404,
)
with open(config_file, "r") as f:
@ -229,32 +163,30 @@ def config_raw():
return raw_config, 200
@bp.route("/config/save", methods=["POST"])
def config_save():
save_option = request.args.get("save_option")
new_config = request.get_data().decode()
@router.post("/config/save")
def config_save(save_option: str, body: dict):
new_config = body
if not new_config:
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "Config with body param is required"}
),
400,
status_code=400,
)
# Validate the config schema
try:
FrigateConfig.parse_yaml(new_config)
except Exception:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
}
),
400,
status_code=400,
)
# Save the config to file
@ -271,14 +203,14 @@ def config_save():
f.write(new_config)
f.close()
except Exception:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"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":
@ -286,34 +218,34 @@ def config_save():
restart_frigate()
except Exception as e:
logging.error(f"Error restarting Frigate: {e}")
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Config successfully saved, unable to restart Frigate",
}
),
200,
status_code=200,
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Config successfully saved, restarting (this can take up to one minute)...",
}
),
200,
status_code=200,
)
else:
return make_response(
jsonify({"success": True, "message": "Config successfully saved."}),
200,
return JSONResponse(
content=({"success": True, "message": "Config successfully saved."}),
status_code=200,
)
@bp.route("/config/set", methods=["PUT"])
def config_set():
@router.put("/config/set")
def config_set(request: Request, body: AppConfigSetBody):
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
# Check if we can use .yaml instead of .yml
@ -339,68 +271,68 @@ def config_set():
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Error parsing config. Check logs for error message.",
}
),
400,
status_code=400,
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return make_response(
jsonify({"success": False, "message": "Error updating config"}),
500,
return JSONResponse(
content=({"success": False, "message": "Error updating config"}),
status_code=500,
)
json = request.get_json(silent=True) or {}
if json.get("requires_restart", 1) == 0:
current_app.frigate_config = FrigateConfig.parse_object(
config_obj, plus_api=current_app.frigate_config.plus_api
if body.requires_restart == 0:
request.app.frigate_config = FrigateConfig.parse_object(
config_obj, request.app.frigate_config.plus_api
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
200,
status_code=200,
)
@bp.route("/ffprobe", methods=["GET"])
def ffprobe():
path_param = request.args.get("paths", "")
@router.get("/ffprobe")
def ffprobe(request: Request, paths: str = ""):
path_param = paths
if not path_param:
return make_response(
jsonify({"success": False, "message": "Path needs to be provided."}), 404
return JSONResponse(
content=({"success": False, "message": "Path needs to be provided."}),
status_code=404,
)
if path_param.startswith("camera"):
camera = path_param[7:]
if camera not in current_app.frigate_config.cameras.keys():
return make_response(
jsonify(
if camera not in request.app.frigate_config.cameras.keys():
return JSONResponse(
content=(
{"success": False, "message": f"{camera} is not a valid camera."}
),
404,
status_code=404,
)
if not current_app.frigate_config.cameras[camera].enabled:
return make_response(
jsonify({"success": False, "message": f"{camera} is not enabled."}), 404
if not request.app.frigate_config.cameras[camera].enabled:
return JSONResponse(
content=({"success": False, "message": f"{camera} is not enabled."}),
status_code=404,
)
paths = map(
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):
paths = path_param.split(",")
@ -411,7 +343,7 @@ def ffprobe():
output = []
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(
{
"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():
vainfo = vainfo_hwaccel()
return jsonify(
{
return JSONResponse(
content={
"return_code": vainfo.returncode,
"stderr": (
vainfo.stderr.decode("unicode_escape").strip()
@ -451,19 +383,26 @@ def vainfo():
)
@bp.route("/logs/<service>", methods=["GET"])
def logs(service: str):
@router.get("/logs/{service}", tags=[Tags.logs])
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):
try:
file = open(service_location, "r")
contents = file.read()
file.close()
return jsonify(contents)
return JSONResponse(jsonable_encoder(contents))
except FileNotFoundError as e:
logger.error(e)
return make_response(
jsonify({"success": False, "message": "Could not find log file"}),
500,
return JSONResponse(
content={"success": False, "message": "Could not find log file"},
status_code=500,
)
log_locations = {
@ -475,17 +414,14 @@ def logs(service: str):
service_location = log_locations.get(service)
if not service_location:
return make_response(
jsonify({"success": False, "message": "Not a valid service"}),
404,
return JSONResponse(
content={"success": False, "message": "Not a valid service"},
status_code=404,
)
if request.args.get("download", type=bool, default=False):
if download:
return download_logs(service_location)
start = request.args.get("start", type=int, default=0)
end = request.args.get("end", type=int)
try:
file = open(service_location, "r")
contents = file.read()
@ -526,49 +462,47 @@ def logs(service: str):
logLines.append(currentLine)
return make_response(
jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}),
200,
return JSONResponse(
content={"totalLines": len(logLines), "lines": logLines[start:end]},
status_code=200,
)
except FileNotFoundError as e:
logger.error(e)
return make_response(
jsonify({"success": False, "message": "Could not find log file"}),
500,
return JSONResponse(
content={"success": False, "message": "Could not find log file"},
status_code=500,
)
@bp.route("/restart", methods=["POST"])
@router.post("/restart")
def restart():
try:
restart_frigate()
except Exception as e:
logging.error(f"Error restarting Frigate: {e}")
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Unable to restart Frigate.",
}
),
500,
status_code=500,
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Restarting (this can take up to one minute)...",
}
),
200,
status_code=200,
)
@bp.route("/labels")
def get_labels():
camera = request.args.get("camera", type=str, default="")
@router.get("/labels")
def get_labels(camera: str = ""):
try:
if camera:
events = Event.select(Event.label).where(Event.camera == camera).distinct()
@ -576,24 +510,23 @@ def get_labels():
events = Event.select(Event.label).distinct()
except Exception as e:
logger.error(e)
return make_response(
jsonify({"success": False, "message": "Failed to get labels"}), 404
return JSONResponse(
content=({"success": False, "message": "Failed to get labels"}),
status_code=404,
)
labels = sorted([e.label for e in events])
return jsonify(labels)
return JSONResponse(content=labels)
@bp.route("/sub_labels")
def get_sub_labels():
split_joined = request.args.get("split_joined", type=int)
@router.get("/sub_labels")
def get_sub_labels(split_joined: Optional[int] = None):
try:
events = Event.select(Event.sub_label).distinct()
except Exception:
return make_response(
jsonify({"success": False, "message": "Failed to get sub_labels"}),
404,
return JSONResponse(
content=({"success": False, "message": "Failed to get sub_labels"}),
status_code=404,
)
sub_labels = [e.sub_label for e in events]
@ -614,15 +547,11 @@ def get_sub_labels():
sub_labels.append(part.strip())
sub_labels.sort()
return jsonify(sub_labels)
return JSONResponse(content=sub_labels)
@bp.route("/timeline")
def timeline():
camera = request.args.get("camera", "all")
source_id = request.args.get("source_id", type=str)
limit = request.args.get("limit", 100)
@router.get("/timeline")
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
clauses = []
selected_columns = [
@ -651,18 +580,18 @@ def timeline():
.dicts()
)
return jsonify([t for t in timeline])
return JSONResponse(content=[t for t in timeline])
@bp.route("/timeline/hourly")
def hourly_timeline():
@router.get("/timeline/hourly")
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
"""Get hourly summary for timeline."""
cameras = request.args.get("cameras", "all")
labels = request.args.get("labels", "all")
before = request.args.get("before", type=float)
after = request.args.get("after", type=float)
limit = request.args.get("limit", 200)
tz_name = request.args.get("timezone", default="utc", type=str)
cameras = params.cameras
labels = params.labels
before = params.before
after = params.after
limit = params.limit
tz_name = params.timezone
_, minute_modifier, _ = get_tz_modifiers(tz_name)
minute_offset = int(minute_modifier.split(" ")[0])
@ -728,8 +657,8 @@ def hourly_timeline():
else:
hours[hour].insert(0, t)
return jsonify(
{
return JSONResponse(
content={
"start": start,
"end": end,
"count": count,

View File

@ -12,25 +12,45 @@ import time
from datetime import datetime
from pathlib import Path
from flask import Blueprint, current_app, jsonify, make_response, redirect, request
from flask_limiter import Limiter
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt
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.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
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(",")))
logger.debug(f"IP Route: {[r for r in route]}")
trusted_proxies = []
for proxy in current_app.frigate_config.auth.trusted_proxies:
for proxy in request.app.frigate_config.auth.trusted_proxies:
try:
network = ipaddress.ip_network(proxy)
except ValueError:
@ -68,16 +88,6 @@ def get_remote_addr():
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:
jwt_secret = None
# check env var
@ -132,7 +142,7 @@ def get_jwt_secret() -> str:
return jwt_secret
def hash_password(password, salt=None, iterations=600000):
def hash_password(password: str, salt=None, iterations=600000):
if salt is None:
salt = secrets.token_hex(16)
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)
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
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
@AuthBp.route("/auth")
def auth():
auth_config: AuthConfig = current_app.frigate_config.auth
proxy_config: ProxyConfig = current_app.frigate_config.proxy
@router.get("/auth")
def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
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
# 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
fail_response = make_response({}, 401)
fail_response = Response("", status_code=401)
# ensure the proxy secret matches if configured
if (
proxy_config.auth_secret is not None
and request.headers.get("x-proxy-secret", "", type=str)
!= proxy_config.auth_secret
and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret
):
logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response
@ -196,7 +209,6 @@ def auth():
if proxy_config.header_map.user is not None:
upstream_user_header_value = request.headers.get(
proxy_config.header_map.user,
type=str,
default="anonymous",
)
success_response.headers["remote-user"] = upstream_user_header_value
@ -207,10 +219,10 @@ def auth():
# now apply authentication
fail_response.headers["location"] = "/login"
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure
JWT_REFRESH = current_app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
jwt_source = None
encoded_token = None
@ -230,7 +242,7 @@ def auth():
return fail_response
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:
logger.debug("user not set in jwt token")
return fail_response
@ -266,7 +278,7 @@ def auth():
return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt(
user, new_expiration, current_app.jwt_token
user, new_expiration, request.app.jwt_token
)
set_jwt_cookie(
success_response,
@ -283,86 +295,84 @@ def auth():
return fail_response
@AuthBp.route("/profile")
def profile():
username = request.headers.get("remote-user", type=str)
return jsonify({"username": username})
@router.get("/profile")
def profile(request: Request):
username = request.headers.get("remote-user")
return JSONResponse(content={"username": username})
@AuthBp.route("/logout")
def logout():
auth_config: AuthConfig = current_app.frigate_config.auth
response = make_response(redirect("/login", code=303))
@router.get("/logout")
def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(auth_config.cookie_name)
return response
@AuthBp.route("/login", methods=["POST"])
@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
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
content = request.get_json()
user = content["user"]
password = content["password"]
limiter = Limiter(key_func=get_remote_addr)
@router.post("/login")
@limiter.limit(limit_value=rateLimiter.get_limit)
def login(request: Request, body: AppPostLoginBody):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
user = body.user
password = body.password
try:
db_user: User = User.get_by_id(user)
except DoesNotExist:
return make_response({"message": "Login failed"}, 400)
return JSONResponse(content={"message": "Login failed"}, status_code=400)
password_hash = db_user.password_hash
if verify_password(password, password_hash):
expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, expiration, current_app.jwt_token)
response = make_response({}, 200)
encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token)
response = Response("", 200)
set_jwt_cookie(
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
)
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():
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"])
def create_user():
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
@router.post("/users")
def create_user(request: Request, body: AppPostUsersBody):
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", "")):
make_response({"message": "Invalid username"}, 400)
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.insert(
{
User.username: request_data["username"],
User.username: body.username,
User.password_hash: password_hash,
}
).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):
User.delete_by_id(username)
return jsonify({"success": True})
return JSONResponse(content={"success": True})
@AuthBp.route("/users/<username>/password", methods=["PUT"])
def update_password(username: str):
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
@router.put("/users/{username}/password")
def update_password(request: Request, username: str, body: AppPutPasswordBody):
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
request_data = request.get_json()
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.set_by_id(
username,
@ -370,4 +380,4 @@ def update_password(username: str):
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 logging
import os
from datetime import datetime
from functools import reduce
from pathlib import Path
from urllib.parse import unquote
import cv2
import numpy as np
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import JOIN, DoesNotExist, fn, operator
from PIL import Image
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 (
CLIPS_DIR,
)
@ -33,57 +41,55 @@ from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__)
EventBp = Blueprint("events", __name__)
DEFAULT_TIME_RANGE = "00:00,24:00"
router = APIRouter(tags=[Tags.events])
@EventBp.route("/events")
def events():
camera = request.args.get("camera", "all")
cameras = request.args.get("cameras", "all")
@router.get("/events")
def events(params: EventsQueryParams = Depends()):
camera = params.camera
cameras = params.cameras
# handle old camera arg
if cameras == "all" and camera != "all":
cameras = camera
label = unquote(request.args.get("label", "all"))
labels = request.args.get("labels", "all")
label = unquote(params.label)
labels = params.labels
# handle old label arg
if labels == "all" and label != "all":
labels = label
sub_label = request.args.get("sub_label", "all")
sub_labels = request.args.get("sub_labels", "all")
sub_label = params.sub_label
sub_labels = params.sub_labels
# handle old sub_label arg
if sub_labels == "all" and sub_label != "all":
sub_labels = sub_label
zone = request.args.get("zone", "all")
zones = request.args.get("zones", "all")
zone = params.zone
zones = params.zones
# handle old label arg
if zones == "all" and zone != "all":
zones = zone
limit = request.args.get("limit", 100)
after = request.args.get("after", type=float)
before = request.args.get("before", type=float)
time_range = request.args.get("time_range", DEFAULT_TIME_RANGE)
has_clip = request.args.get("has_clip", type=int)
has_snapshot = request.args.get("has_snapshot", type=int)
in_progress = request.args.get("in_progress", type=int)
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
favorites = request.args.get("favorites", type=int)
min_score = request.args.get("min_score", type=float)
max_score = request.args.get("max_score", type=float)
is_submitted = request.args.get("is_submitted", type=int)
min_length = request.args.get("min_length", type=float)
max_length = request.args.get("max_length", type=float)
limit = params.limit
after = params.after
before = params.before
time_range = params.time_range
has_clip = params.has_clip
has_snapshot = params.has_snapshot
in_progress = params.in_progress
include_thumbnails = params.include_thumbnails
favorites = params.favorites
min_score = params.min_score
max_score = params.max_score
is_submitted = params.is_submitted
min_length = params.min_length
max_length = params.max_length
sort = request.args.get("sort", type=str)
sort = params.sort
clauses = []
@ -163,7 +169,7 @@ def events():
if time_range != DEFAULT_TIME_RANGE:
# 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)
times = time_range.split(",")
@ -248,13 +254,11 @@ def events():
.iterator()
)
return jsonify(list(events))
return JSONResponse(content=list(events))
@EventBp.route("/events/explore")
def events_explore():
limit = request.args.get("limit", 10, type=int)
@router.get("/events/explore")
def events_explore(limit: int = 10):
subquery = Event.select(
Event.id,
Event.camera,
@ -316,69 +320,68 @@ def events_explore():
for event in events
]
return jsonify(processed_events)
return JSONResponse(content=processed_events)
@EventBp.route("/event_ids")
def event_ids():
idString = request.args.get("ids")
ids = idString.split(",")
@router.get("/event_ids")
def event_ids(ids: str):
ids = ids.split(",")
if not ids:
return make_response(
jsonify({"success": False, "message": "Valid list of ids must be sent"}),
400,
return JSONResponse(
content=({"success": False, "message": "Valid list of ids must be sent"}),
status_code=400,
)
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return jsonify(list(events))
return JSONResponse(list(events))
except Exception:
return make_response(
jsonify({"success": False, "message": "Events not found"}), 400
return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400
)
@EventBp.route("/events/search")
def events_search():
query = request.args.get("query", type=str)
search_type = request.args.get("search_type", "thumbnail,description", type=str)
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
limit = request.args.get("limit", 50, type=int)
@router.get("/events/search")
def events_search(request: Request, params: EventsSearchQueryParams = Depends()):
query = params.query
search_type = params.search_type
include_thumbnails = params.include_thumbnails
limit = params.limit
# Filters
cameras = request.args.get("cameras", "all", type=str)
labels = request.args.get("labels", "all", type=str)
zones = request.args.get("zones", "all", type=str)
after = request.args.get("after", type=float)
before = request.args.get("before", type=float)
cameras = params.cameras
labels = params.labels
zones = params.zones
after = params.after
before = params.before
# for similarity search
event_id = request.args.get("event_id", type=str)
event_id = params.event_id
if not query and not event_id:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "A search query must be supplied",
}
),
400,
status_code=400,
)
if not current_app.frigate_config.semantic_search.enabled:
return make_response(
jsonify(
if not request.app.frigate_config.semantic_search.enabled:
return JSONResponse(
content=(
{
"success": False,
"message": "Semantic search is not enabled",
}
),
400,
status_code=400,
)
context: EmbeddingsContext = current_app.embeddings
context: EmbeddingsContext = request.app.embeddings
selected_columns = [
Event.id,
@ -437,14 +440,14 @@ def events_search():
try:
search_event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Event not found",
}
),
404,
status_code=404,
)
thumbnail = base64.b64decode(search_event.thumbnail)
img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB"))
@ -504,7 +507,7 @@ def events_search():
}
if not results:
return jsonify([])
return JSONResponse(content=[])
# Get the event data
events = (
@ -537,15 +540,15 @@ def events_search():
]
events = sorted(events, key=lambda x: x["search_distance"])[:limit]
return jsonify(events)
return JSONResponse(content=events)
@EventBp.route("/events/summary")
def events_summary():
tz_name = request.args.get("timezone", default="utc", type=str)
@router.get("/events/summary")
def events_summary(params: EventsSummaryQueryParams = Depends()):
tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = request.args.get("has_clip", type=int)
has_snapshot = request.args.get("has_snapshot", type=int)
has_clip = params.has_clip
has_snapshot = params.has_snapshot
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",))
def event(id):
@router.get("/events/{event_id}")
def event(event_id: str):
try:
return model_to_dict(Event.get(Event.id == id))
return model_to_dict(Event.get(Event.id == event_id))
except DoesNotExist:
return "Event not found", 404
return JSONResponse(content="Event not found", status_code=404)
@EventBp.route("/events/<id>/retain", methods=("POST",))
def set_retain(id):
@router.post("/events/{event_id}/retain")
def set_retain(event_id: str):
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
event.retain_indefinitely = True
event.save()
return make_response(
jsonify({"success": True, "message": "Event " + id + " retained"}), 200
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " retained"}),
status_code=200,
)
@EventBp.route("/events/<id>/plus", methods=("POST",))
def send_to_plus(id):
if not current_app.frigate_config.plus_api.is_active():
@router.post("/events/{event_id}/plus")
def send_to_plus(request: Request, event_id: str):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": message,
}
),
400,
status_code=400,
)
include_annotation = (
@ -630,11 +635,13 @@ def send_to_plus(id):
)
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
message = f"Event {id} not found"
message = f"Event {event_id} not found"
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
if event.data.get("box") is None:
@ -642,20 +649,22 @@ def send_to_plus(id):
if event.end_time is None:
logger.error(f"Unable to load clean png for in-progress event: {event.id}")
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Unable to load clean png for in-progress event",
}
),
400,
status_code=400,
)
if event.plus_id:
message = "Already submitted to plus"
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
try:
@ -663,29 +672,29 @@ def send_to_plus(id):
image = cv2.imread(os.path.join(CLIPS_DIR, filename))
except Exception:
logger.error(f"Unable to load clean png for event: {event.id}")
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "Unable to load clean png for event"}
),
400,
status_code=400,
)
if image is None or image.size == 0:
logger.error(f"Unable to load clean png for event: {event.id}")
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "Unable to load clean png for event"}
),
400,
status_code=400,
)
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:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": "Error uploading image"}),
400,
return JSONResponse(
content=({"success": False, "message": "Error uploading image"}),
status_code=400,
)
# store image id in the database
@ -696,7 +705,7 @@ def send_to_plus(id):
box = event.data["box"]
try:
current_app.frigate_config.plus_api.add_annotation(
request.app.frigate_config.plus_api.add_annotation(
event.plus_id,
box,
event.label,
@ -704,59 +713,67 @@ def send_to_plus(id):
except ValueError:
message = "Error uploading annotation, unsupported label provided."
logger.error(message)
return make_response(
jsonify({"success": False, "message": message}),
400,
return JSONResponse(
content=({"success": False, "message": message}),
status_code=400,
)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": "Error uploading annotation"}),
400,
return JSONResponse(
content=({"success": False, "message": "Error uploading annotation"}),
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",))
def false_positive(id):
if not current_app.frigate_config.plus_api.is_active():
@router.put("/events/{event_id}/false_positive")
def false_positive(request: Request, event_id: str):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": message,
}
),
400,
status_code=400,
)
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
message = f"Event {id} not found"
message = f"Event {event_id} not found"
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
if event.data.get("box") is None:
message = "Events prior to 0.13 cannot be submitted as false positives"
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:
message = "False positive already submitted to Frigate+"
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:
plus_response = send_to_plus(id)
plus_response = send_to_plus(event_id)
if plus_response.status_code != 200:
return plus_response
# 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"]
box = event.data["box"]
@ -769,7 +786,7 @@ def false_positive(id):
)
try:
current_app.frigate_config.plus_api.add_false_positive(
request.app.frigate_config.plus_api.add_false_positive(
event.plus_id,
region,
box,
@ -782,92 +799,65 @@ def false_positive(id):
except ValueError:
message = "Error uploading false positive, unsupported label provided."
logger.error(message)
return make_response(
jsonify({"success": False, "message": message}),
400,
return JSONResponse(
content=({"success": False, "message": message}),
status_code=400,
)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": "Error uploading false positive"}),
400,
return JSONResponse(
content=({"success": False, "message": "Error uploading false positive"}),
status_code=400,
)
event.false_positive = True
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",))
def delete_retain(id):
@router.delete("/events/{event_id}/retain")
def delete_retain(event_id: str):
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
event.retain_indefinitely = False
event.save()
return make_response(
jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " un-retained"}),
status_code=200,
)
@EventBp.route("/events/<id>/sub_label", methods=("POST",))
def set_sub_label(id):
@router.post("/events/{event_id}/sub_label")
def set_sub_label(
request: Request,
event_id: str,
body: EventsSubLabelBody,
):
try:
event: Event = Event.get(Event.id == id)
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
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 = json.get("subLabel")
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,
)
new_sub_label = body.subLabel
new_score = body.subLabelScore
if not event.end_time:
# update tracked object
tracked_obj: TrackedObject = (
current_app.detected_frames_processor.camera_states[
request.app.detected_frames_processor.camera_states[
event.camera
].tracked_objects.get(event.id)
)
@ -878,7 +868,7 @@ def set_sub_label(id):
# update timeline items
Timeline.update(
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
@ -888,70 +878,78 @@ def set_sub_label(id):
event.data = data
event.save()
return make_response(
jsonify(
return JSONResponse(
content=(
{
"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",))
def set_description(id):
@router.post("/events/{event_id}/description")
def set_description(
request: Request,
event_id: str,
body: EventsDescriptionBody,
):
try:
event: Event = Event.get(Event.id == id)
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
new_description = json.get("description")
new_description = body.description
if new_description is None or len(new_description) == 0:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "description cannot be empty",
}
),
400,
status_code=400,
)
event.data["description"] = new_description
event.save()
# If semantic search is enabled, update the index
if current_app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = current_app.embeddings
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context.embeddings.description.upsert(
documents=[new_description],
metadatas=[get_metadata(event)],
ids=[id],
ids=[event_id],
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"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",))
def delete_event(id):
@router.delete("/events/{event_id}")
def delete_event(request: Request, event_id: str):
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
media_name = f"{event.camera}-{event.id}"
@ -965,82 +963,86 @@ def delete_event(id):
media.unlink(missing_ok=True)
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 current_app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = current_app.embeddings
context.embeddings.thumbnail.delete(ids=[id])
context.embeddings.description.delete(ids=[id])
return make_response(
jsonify({"success": True, "message": "Event " + id + " deleted"}), 200
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context.embeddings.thumbnail.delete(ids=[event_id])
context.embeddings.description.delete(ids=[event_id])
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " deleted"}),
status_code=200,
)
@EventBp.route("/events/<camera_name>/<label>/create", methods=["POST"])
def create_event(camera_name, label):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
@router.post("/events/{camera_name}/{label}/create")
def create_event(
request: Request,
camera_name: str,
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."}
),
404,
status_code=404,
)
if not label:
return make_response(
jsonify({"success": False, "message": f"{label} must be set."}), 404
return JSONResponse(
content=({"success": False, "message": f"{label} must be set."}),
status_code=404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
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,
label,
json.get("source_type", "api"),
json.get("sub_label", None),
json.get("score", 0),
json.get("duration", 30),
json.get("include_recording", True),
json.get("draw", {}),
body.source_type,
body.sub_label,
body.score,
body.duration,
body.include_recording,
body.draw,
frame,
)
except Exception as e:
logger.error(e)
return make_response(
jsonify({"success": False, "message": "An unknown error occurred"}),
500,
return JSONResponse(
content=({"success": False, "message": "An unknown error occurred"}),
status_code=500,
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Successfully created event.",
"event_id": event_id,
}
),
200,
status_code=200,
)
@EventBp.route("/events/<event_id>/end", methods=["PUT"])
def end_event(event_id):
json: dict[str, any] = request.get_json(silent=True) or {}
@router.put("/events/{event_id}/end")
def end_event(request: Request, event_id: str, body: EventsEndBody):
try:
end_time = json.get("end_time", datetime.now().timestamp())
current_app.external_processor.finish_manual_event(event_id, end_time)
end_time = body.end_time
request.app.external_processor.finish_manual_event(event_id, end_time)
except Exception:
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": f"{event_id} must be set and valid."}
),
404,
status_code=404,
)
return make_response(
jsonify({"success": True, "message": "Event successfully ended."}), 200
return JSONResponse(
content=({"success": True, "message": "Event successfully ended."}),
status_code=200,
)

View File

@ -5,54 +5,50 @@ from pathlib import Path
from typing import Optional
import psutil
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from frigate.api.defs.tags import Tags
from frigate.const import EXPORT_DIR
from frigate.models import Export, Recordings
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
logger = logging.getLogger(__name__)
ExportBp = Blueprint("exports", __name__)
router = APIRouter(tags=[Tags.export])
@ExportBp.route("/exports")
@router.get("/exports")
def get_exports():
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(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)
@ExportBp.route(
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>",
methods=["POST"],
)
def export_recording(camera_name: str, start_time, end_time):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
@router.post("/export/{camera_name}/start/{start_time}/end/{end_time}")
def export_recording(
request: Request,
camera_name: str,
start_time: float,
end_time: float,
body: dict = 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."}
),
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")
friendly_name: Optional[str] = json.get("name")
if len(friendly_name or "") > 256:
return make_response(
jsonify({"success": False, "message": "File name is too long."}),
401,
return JSONResponse(
content=({"success": False, "message": "File name is too long."}),
status_code=401,
)
existing_image = json.get("image_path")
@ -69,15 +65,15 @@ def export_recording(camera_name: str, start_time, end_time):
)
if recordings_count <= 0:
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "No recordings found for time range"}
),
400,
status_code=400,
)
exporter = RecordingExporter(
current_app.frigate_config,
request.app.frigate_config,
camera_name,
friendly_name,
existing_image,
@ -90,58 +86,58 @@ def export_recording(camera_name: str, start_time, end_time):
),
)
exporter.start()
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
status_code=200,
)
@ExportBp.route("/export/<id>/<new_name>", methods=["PATCH"])
def export_rename(id, new_name: str):
@router.patch("/export/{event_id}/{new_name}")
def export_rename(event_id: str, new_name: str):
try:
export: Export = Export.get(Export.id == id)
export: Export = Export.get(Export.id == event_id)
except DoesNotExist:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Export not found.",
}
),
404,
status_code=404,
)
export.name = new_name
export.save()
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Successfully renamed export.",
}
),
200,
status_code=200,
)
@ExportBp.route("/export/<id>", methods=["DELETE"])
def export_delete(id: str):
@router.delete("/export/{event_id}")
def export_delete(event_id: str):
try:
export: Export = Export.get(Export.id == id)
export: Export = Export.get(Export.id == event_id)
except DoesNotExist:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Export not found.",
}
),
404,
status_code=404,
)
files_in_use = []
@ -158,11 +154,11 @@ def export_delete(id: str):
continue
if export.video_path.split("/")[-1] in files_in_use:
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "Can not delete in progress export."}
),
400,
status_code=400,
)
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)
export.delete_instance()
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"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
from cryptography.hazmat.primitives import serialization
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from py_vapid import Vapid01, utils
from frigate.api.defs.tags import Tags
from frigate.const import CONFIG_DIR
from frigate.models import User
logger = logging.getLogger(__name__)
NotificationBp = Blueprint("notifications", __name__)
router = APIRouter(tags=[Tags.notifications])
@NotificationBp.route("/notifications/pubkey", methods=["GET"])
def get_vapid_pub_key():
if not current_app.frigate_config.notifications.enabled:
return make_response(
jsonify({"success": False, "message": "Notifications are not enabled."}),
400,
@router.get("/notifications/pubkey")
def get_vapid_pub_key(request: Request):
if not request.app.frigate_config.notifications.enabled:
return JSONResponse(
content=({"success": False, "message": "Notifications are not enabled."}),
status_code=400,
)
key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
raw_pub = key.public_key.public_bytes(
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"])
def register_notifications():
if current_app.frigate_config.auth.enabled:
username = request.headers.get("remote-user", type=str) or "admin"
@router.post("/notifications/register")
def register_notifications(request: Request, body: dict = None):
if request.app.frigate_config.auth.enabled:
# FIXME: For FastAPI the remote-user is not being populated
username = request.headers.get("remote-user") or "admin"
else:
username = "admin"
json: dict[str, any] = request.get_json(silent=True) or {}
json: dict[str, any] = body or {}
sub = json.get("sub")
if not sub:
return jsonify(
{"success": False, "message": "Subscription must be provided."}
), 400
return JSONResponse(
content={"success": False, "message": "Subscription must be provided."},
status_code=400,
)
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
).execute()
return make_response(
jsonify({"success": True, "message": "Successfully saved token."}), 200
return JSONResponse(
content=({"success": True, "message": "Successfully saved token."}),
status_code=200,
)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Could not find user."}), 404
return JSONResponse(
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
import pytz
from flask import (
Blueprint,
jsonify,
make_response,
)
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from frigate.api.defs.tags import Tags
from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews
logger = logging.getLogger(__name__)
PreviewBp = Blueprint("previews", __name__)
router = APIRouter(tags=[Tags.preview])
@PreviewBp.route("/preview/<camera_name>/start/<int:start_ts>/end/<int:end_ts>")
@PreviewBp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>")
def preview_ts(camera_name, start_ts, end_ts):
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}")
def preview_ts(camera_name: str, start_ts: float, end_ts: float):
"""Get all mp4 previews relevant for time period."""
if camera_name != "all":
camera_clause = Previews.camera == camera_name
@ -62,24 +60,20 @@ def preview_ts(camera_name, start_ts, end_ts):
)
if not clips:
return make_response(
jsonify(
{
"success": False,
"message": "No previews found.",
}
),
404,
return JSONResponse(
content={
"success": False,
"message": "No previews found.",
},
status_code=404,
)
return make_response(jsonify(clips), 200)
return JSONResponse(content=clips, status_code=200)
@PreviewBp.route("/preview/<year_month>/<int:day>/<int:hour>/<camera_name>/<tz_name>")
@PreviewBp.route(
"/preview/<year_month>/<float:day>/<float:hour>/<camera_name>/<tz_name>"
)
def preview_hour(year_month, day, hour, camera_name, tz_name):
@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
"""Get all mp4 previews relevant for time period given the timezone"""
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc)
@ -92,11 +86,8 @@ def preview_hour(year_month, day, hour, camera_name, tz_name):
return preview_ts(camera_name, start_ts, end_ts)
@PreviewBp.route("/preview/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/frames")
@PreviewBp.route(
"/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):
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames")
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
"""Get list of cached preview frames"""
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}"
@ -116,4 +107,7 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
selected_previews.append(file)
return jsonify(selected_previews)
return JSONResponse(
content=selected_previews,
status_code=200,
)

View File

@ -1,36 +1,40 @@
"""Review apis."""
import logging
from datetime import datetime, timedelta
from functools import reduce
from pathlib import Path
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 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.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__)
ReviewBp = Blueprint("reviews", __name__)
router = APIRouter(tags=[Tags.review])
@ReviewBp.route("/review")
def review():
cameras = request.args.get("cameras", "all")
labels = request.args.get("labels", "all")
zones = request.args.get("zones", "all")
reviewed = request.args.get("reviewed", type=int, default=0)
limit = request.args.get("limit", type=int, default=None)
severity = request.args.get("severity", None)
before = request.args.get("before", type=float, default=datetime.now().timestamp())
after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=24)).timestamp()
)
@router.get("/review")
def review(params: ReviewQueryParams = Depends()):
cameras = params.cameras
labels = params.labels
zones = params.zones
reviewed = params.reviewed
limit = params.limit
severity = params.severity
before = params.before
after = params.after
clauses = [
(
@ -91,39 +95,38 @@ def review():
.iterator()
)
return jsonify([r for r in review])
return JSONResponse(content=[r for r in review])
@ReviewBp.route("/review/event/<id>")
def get_review_from_event(id: str):
@router.get("/review/event/{event_id}")
def get_review_from_event(event_id: str):
try:
return model_to_dict(
ReviewSegment.get(
ReviewSegment.data["detections"].cast("text") % f'*"{id}"*'
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
)
)
except DoesNotExist:
return "Review item not found", 404
@ReviewBp.route("/review/<id>")
def get_review(id: str):
@router.get("/review/{event_id}")
def get_review(event_id: str):
try:
return model_to_dict(ReviewSegment.get(ReviewSegment.id == id))
return model_to_dict(ReviewSegment.get(ReviewSegment.id == event_id))
except DoesNotExist:
return "Review item not found", 404
@ReviewBp.route("/review/summary")
def review_summary():
tz_name = request.args.get("timezone", default="utc", type=str)
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
day_ago = (datetime.now() - timedelta(hours=24)).timestamp()
month_ago = (datetime.now() - timedelta(days=30)).timestamp()
@router.get("/review/summary")
def review_summary(params: ReviewSummaryQueryParams = Depends()):
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = params.day_ago
month_ago = params.month_ago
cameras = request.args.get("cameras", "all")
labels = request.args.get("labels", "all")
zones = request.args.get("zones", "all")
cameras = params.cameras
labels = params.labels
zones = params.zones
clauses = [(ReviewSegment.start_time > day_ago)]
@ -358,53 +361,60 @@ def review_summary():
for e in last_month.dicts().iterator():
data[e["day"]] = e
return jsonify(data)
return JSONResponse(content=data)
@ReviewBp.route("/reviews/viewed", methods=("POST",))
def set_multiple_reviewed():
json: dict[str, any] = request.get_json(silent=True) or {}
@router.post("/reviews/viewed")
def set_multiple_reviewed(body: dict = None):
json: dict[str, any] = body or {}
list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0:
return make_response(
jsonify({"success": False, "message": "Not a valid list of ids"}), 404
return JSONResponse(
context=({"success": False, "message": "Not a valid list of ids"}),
status_code=404,
)
ReviewSegment.update(has_been_reviewed=True).where(
ReviewSegment.id << list_of_ids
).execute()
return make_response(
jsonify({"success": True, "message": "Reviewed multiple items"}), 200
return JSONResponse(
content=({"success": True, "message": "Reviewed multiple items"}),
status_code=200,
)
@ReviewBp.route("/review/<id>/viewed", methods=("DELETE",))
def set_not_reviewed(id):
@router.delete("/review/{event_id}/viewed")
def set_not_reviewed(event_id: str):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
return JSONResponse(
content=(
{"success": False, "message": "Review " + event_id + " not found"}
),
status_code=404,
)
review.has_been_reviewed = False
review.save()
return make_response(
jsonify({"success": True, "message": "Reviewed " + id + " not viewed"}), 200
return JSONResponse(
content=({"success": True, "message": "Reviewed " + event_id + " not viewed"}),
status_code=200,
)
@ReviewBp.route("/reviews/delete", methods=("POST",))
def delete_reviews():
json: dict[str, any] = request.get_json(silent=True) or {}
@router.post("/reviews/delete")
def delete_reviews(body: dict = None):
json: dict[str, any] = body or {}
list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0:
return make_response(
jsonify({"success": False, "message": "Not a valid list of ids"}), 404
return JSONResponse(
content=({"success": False, "message": "Not a valid list of ids"}),
status_code=404,
)
reviews = (
@ -446,18 +456,20 @@ def delete_reviews():
Recordings.delete().where(Recordings.id << recording_ids).execute()
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
return make_response(jsonify({"success": True, "message": "Delete reviews"}), 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()
return JSONResponse(
content=({"success": True, "message": "Delete reviews"}), status_code=200
)
@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.append((Recordings.motion > 0))
@ -477,15 +489,12 @@ def motion_activity():
.iterator()
)
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
if df.empty:
logger.warning("No motion data found for the requested time range")
return jsonify([])
return JSONResponse(content=[])
df = df.astype(dtype={"motion": "float32"})
@ -520,17 +529,17 @@ def motion_activity():
# change types for output
df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records")
return jsonify(normalized)
return JSONResponse(content=normalized)
@ReviewBp.route("/review/activity/audio")
def audio_activity():
@router.get("/review/activity/audio")
def audio_activity(params: ReviewActivityMotionQueryParams = Depends()):
"""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()
)
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)]
@ -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
df = pd.DataFrame(data, columns=["start_time", "audio"])
df = df.astype(dtype={"audio": "float16"})
@ -584,4 +590,4 @@ def audio_activity():
# change types for output
df.index = df.index.astype(int) // (10**9)
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
import psutil
import uvicorn
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.app import create_app
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.dispatcher import Communicator, Dispatcher
from frigate.comms.inter_process import InterProcessCommunicator
@ -645,16 +646,21 @@ class FrigateApp:
self.init_auth()
try:
create_app(
self.config,
self.db,
self.embeddings,
self.detected_frames_processor,
self.storage_maintainer,
self.onvif_controller,
self.external_event_processor,
self.stats_emitter,
).run(host="127.0.0.1", port=5001, debug=False, threaded=True)
uvicorn.run(
create_fastapi_app(
self.config,
self.db,
self.embeddings,
self.detected_frames_processor,
self.storage_maintainer,
self.onvif_controller,
self.external_event_processor,
self.stats_emitter,
),
host="127.0.0.1",
port=5001,
log_level="error",
)
finally:
self.stop()

View File

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

View File

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

View File

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