forked from Github/frigate
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user