mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-25 18:55:25 -06:00
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:
parent
dc54981784
commit
cffc431bf0
@ -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.*
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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())
|
||||
|
@ -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µphone"
|
||||
)
|
||||
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,
|
||||
|
@ -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})
|
||||
|
19
frigate/api/defs/app_body.py
Normal file
19
frigate/api/defs/app_body.py
Normal 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
|
12
frigate/api/defs/app_query_parameters.py
Normal file
12
frigate/api/defs/app_query_parameters.py
Normal 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"
|
30
frigate/api/defs/events_body.py
Normal file
30
frigate/api/defs/events_body.py
Normal 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()
|
52
frigate/api/defs/events_query_parameters.py
Normal file
52
frigate/api/defs/events_query_parameters.py
Normal 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
|
42
frigate/api/defs/media_query_parameters.py
Normal file
42
frigate/api/defs/media_query_parameters.py
Normal 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
|
31
frigate/api/defs/review_query_parameters.py
Normal file
31
frigate/api/defs/review_query_parameters.py
Normal 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
13
frigate/api/defs/tags.py
Normal 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"
|
@ -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,
|
||||
)
|
||||
|
@ -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
108
frigate/api/fastapi_app.py
Normal 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
|
1055
frigate/api/media.py
1055
frigate/api/media.py
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}` : ""
|
||||
}`;
|
||||
|
||||
|
@ -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]);
|
||||
|
Loading…
Reference in New Issue
Block a user