mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Frigate HTTP API using FastAPI (#13871)
* POC: Added FastAPI with one endpoint (get /logs/service) * POC: Revert error_log * POC: Converted preview related endpoints to FastAPI * POC: Converted two more endpoints to FastAPI * POC: lint * Convert all media endpoints to FastAPI. Added /media prefix (/media/camera && media/events && /media/preview) * Convert all notifications API endpoints to FastAPI * Convert first review API endpoints to FastAPI * Convert remaining review API endpoints to FastAPI * Convert export endpoints to FastAPI * Fix path parameters * Convert events endpoints to FastAPI * Use body for multiple events endpoints * Use body for multiple events endpoints (create and end event) * Convert app endpoints to FastAPI * Convert app endpoints to FastAPI * Convert auth endpoints to FastAPI * Removed flask app in favour of FastAPI app. Implemented FastAPI middleware to check CSRF, connect and disconnect from DB. Added middleware x-forwared-for headers * Added starlette plugin to expose custom headers * Use slowapi as the limiter * Use query parameters for the frame latest endpoint * Use query parameters for the media snapshot.jpg endpoint * Use query parameters for the media MJPEG feed endpoint * Revert initial nginx.conf change * Added missing even_id for /events/search endpoint * Removed left over comment * Use FastAPI TestClient * severity query parameter should be a string * Use the same pattern for all tests * Fix endpoint * Revert media routers to old names. Order routes to make sure the dynamic ones from media.py are only used whenever there's no match on auth/etc * Reverted paths for media on tsx files * Deleted file * Fix test_http to use TestClient * Formatting * Bind timeline to DB * Fix http tests * Replace filename with pathvalidate * Fix latest.ext handling and disable uvicorn access logs * Add cosntraints to api provided values * Formatting * Remove unused * Remove unused * Get rate limiter working --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
dc54981784
commit
cffc431bf0
@ -1,8 +1,11 @@
|
|||||||
click == 8.1.*
|
click == 8.1.*
|
||||||
Flask == 3.0.*
|
# FastAPI
|
||||||
Flask_Limiter == 3.8.*
|
starlette-context == 0.3.6
|
||||||
|
fastapi == 0.115.0
|
||||||
|
slowapi == 0.1.9
|
||||||
imutils == 0.5.*
|
imutils == 0.5.*
|
||||||
joserfc == 1.0.*
|
joserfc == 1.0.*
|
||||||
|
pathvalidate == 3.2.*
|
||||||
markupsafe == 2.1.*
|
markupsafe == 2.1.*
|
||||||
mypy == 1.6.1
|
mypy == 1.6.1
|
||||||
numpy == 1.26.*
|
numpy == 1.26.*
|
||||||
|
@ -226,7 +226,7 @@ http {
|
|||||||
|
|
||||||
location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ {
|
location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ {
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
rewrite ^/api/(.*)$ $1 break;
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
proxy_pass http://frigate_api;
|
proxy_pass http://frigate_api;
|
||||||
include proxy.conf;
|
include proxy.conf;
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ In the event that you are locked out of your instance, you can tell Frigate to r
|
|||||||
|
|
||||||
## Login failure rate limiting
|
## Login failure rate limiting
|
||||||
|
|
||||||
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation).
|
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples).
|
||||||
|
|
||||||
For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than:
|
For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than:
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from flask import cli
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.app import FrigateApp
|
from frigate.app import FrigateApp
|
||||||
@ -24,7 +23,6 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
threading.current_thread().name = "frigate"
|
threading.current_thread().name = "frigate"
|
||||||
cli.show_server_banner = lambda *x: None
|
|
||||||
|
|
||||||
# Make sure we exit cleanly on SIGTERM.
|
# Make sure we exit cleanly on SIGTERM.
|
||||||
signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit())
|
signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit())
|
||||||
|
@ -10,27 +10,19 @@ from functools import reduce
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Blueprint, Flask, current_app, jsonify, make_response, request
|
from fastapi import APIRouter, Path, Request, Response
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
from peewee import operator
|
from peewee import operator
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
||||||
|
|
||||||
from frigate.api.auth import AuthBp, get_jwt_secret, limiter
|
from frigate.api.defs.app_body import AppConfigSetBody
|
||||||
from frigate.api.event import EventBp
|
from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||||
from frigate.api.export import ExportBp
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.api.media import MediaBp
|
|
||||||
from frigate.api.notification import NotificationBp
|
|
||||||
from frigate.api.preview import PreviewBp
|
|
||||||
from frigate.api.review import ReviewBp
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR
|
||||||
from frigate.embeddings import EmbeddingsContext
|
|
||||||
from frigate.events.external import ExternalEventProcessor
|
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
from frigate.ptz.onvif import OnvifController
|
|
||||||
from frigate.stats.emitter import StatsEmitter
|
|
||||||
from frigate.storage import StorageMaintainer
|
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import (
|
||||||
clean_camera_user_pass,
|
clean_camera_user_pass,
|
||||||
get_tz_modifiers,
|
get_tz_modifiers,
|
||||||
@ -42,134 +34,75 @@ from frigate.version import VERSION
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("frigate", __name__)
|
router = APIRouter(tags=[Tags.app])
|
||||||
bp.register_blueprint(EventBp)
|
|
||||||
bp.register_blueprint(ExportBp)
|
|
||||||
bp.register_blueprint(MediaBp)
|
|
||||||
bp.register_blueprint(PreviewBp)
|
|
||||||
bp.register_blueprint(ReviewBp)
|
|
||||||
bp.register_blueprint(AuthBp)
|
|
||||||
bp.register_blueprint(NotificationBp)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
@router.get("/")
|
||||||
frigate_config,
|
|
||||||
database: SqliteQueueDatabase,
|
|
||||||
embeddings: Optional[EmbeddingsContext],
|
|
||||||
detected_frames_processor,
|
|
||||||
storage_maintainer: StorageMaintainer,
|
|
||||||
onvif: OnvifController,
|
|
||||||
external_processor: ExternalEventProcessor,
|
|
||||||
stats_emitter: StatsEmitter,
|
|
||||||
):
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def check_csrf():
|
|
||||||
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
|
|
||||||
pass
|
|
||||||
if "origin" in request.headers and "x-csrf-token" not in request.headers:
|
|
||||||
return jsonify({"success": False, "message": "Missing CSRF header"}), 401
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def _db_connect():
|
|
||||||
if database.is_closed():
|
|
||||||
database.connect()
|
|
||||||
|
|
||||||
@app.teardown_request
|
|
||||||
def _db_close(exc):
|
|
||||||
if not database.is_closed():
|
|
||||||
database.close()
|
|
||||||
|
|
||||||
app.frigate_config = frigate_config
|
|
||||||
app.embeddings = embeddings
|
|
||||||
app.detected_frames_processor = detected_frames_processor
|
|
||||||
app.storage_maintainer = storage_maintainer
|
|
||||||
app.onvif = onvif
|
|
||||||
app.external_processor = external_processor
|
|
||||||
app.camera_error_image = None
|
|
||||||
app.stats_emitter = stats_emitter
|
|
||||||
app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None
|
|
||||||
# update the request_address with the x-forwarded-for header from nginx
|
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)
|
|
||||||
# initialize the rate limiter for the login endpoint
|
|
||||||
limiter.init_app(app)
|
|
||||||
if frigate_config.auth.failed_login_rate_limit is None:
|
|
||||||
limiter.enabled = False
|
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
|
||||||
def is_healthy():
|
def is_healthy():
|
||||||
return "Frigate is running. Alive and healthy!"
|
return "Frigate is running. Alive and healthy!"
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/schema.json")
|
@router.get("/config/schema.json")
|
||||||
def config_schema():
|
def config_schema(request: Request):
|
||||||
return current_app.response_class(
|
return Response(
|
||||||
current_app.frigate_config.schema_json(), mimetype="application/json"
|
content=request.app.frigate_config.schema_json(), media_type="application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/go2rtc/streams")
|
@router.get("/go2rtc/streams")
|
||||||
def go2rtc_streams():
|
def go2rtc_streams():
|
||||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
logger.error("Failed to fetch streams from go2rtc")
|
logger.error("Failed to fetch streams from go2rtc")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Error fetching stream data"}),
|
content=({"success": False, "message": "Error fetching stream data"}),
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
stream_data = r.json()
|
stream_data = r.json()
|
||||||
for data in stream_data.values():
|
for data in stream_data.values():
|
||||||
for producer in data.get("producers", []):
|
for producer in data.get("producers", []):
|
||||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||||
return jsonify(stream_data)
|
return JSONResponse(content=stream_data)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/go2rtc/streams/<camera_name>")
|
@router.get("/go2rtc/streams/{camera_name}")
|
||||||
def go2rtc_camera_stream(camera_name: str):
|
def go2rtc_camera_stream(camera_name: str):
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||||
)
|
)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
logger.error("Failed to fetch streams from go2rtc")
|
logger.error("Failed to fetch streams from go2rtc")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Error fetching stream data"}),
|
content=({"success": False, "message": "Error fetching stream data"}),
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
stream_data = r.json()
|
stream_data = r.json()
|
||||||
for producer in stream_data.get("producers", []):
|
for producer in stream_data.get("producers", []):
|
||||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||||
return jsonify(stream_data)
|
return JSONResponse(content=stream_data)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/version")
|
@router.get("/version")
|
||||||
def version():
|
def version():
|
||||||
return VERSION
|
return VERSION
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/stats")
|
@router.get("/stats")
|
||||||
def stats():
|
def stats(request: Request):
|
||||||
return jsonify(current_app.stats_emitter.get_latest_stats())
|
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/stats/history")
|
@router.get("/stats/history")
|
||||||
def stats_history():
|
def stats_history(request: Request, keys: str = None):
|
||||||
keys = request.args.get("keys", default=None)
|
|
||||||
|
|
||||||
if keys:
|
if keys:
|
||||||
keys = keys.split(",")
|
keys = keys.split(",")
|
||||||
|
|
||||||
return jsonify(current_app.stats_emitter.get_stats_history(keys))
|
return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config")
|
@router.get("/config")
|
||||||
def config():
|
def config(request: Request):
|
||||||
config_obj: FrigateConfig = current_app.frigate_config
|
config_obj: FrigateConfig = request.app.frigate_config
|
||||||
config: dict[str, dict[str, any]] = config_obj.model_dump(
|
config: dict[str, dict[str, any]] = config_obj.model_dump(
|
||||||
mode="json", warnings="none", exclude_none=True
|
mode="json", warnings="none", exclude_none=True
|
||||||
)
|
)
|
||||||
@ -180,7 +113,7 @@ def config():
|
|||||||
# remove the proxy secret
|
# remove the proxy secret
|
||||||
config["proxy"].pop("auth_secret", None)
|
config["proxy"].pop("auth_secret", None)
|
||||||
|
|
||||||
for camera_name, camera in current_app.frigate_config.cameras.items():
|
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||||
camera_dict = config["cameras"][camera_name]
|
camera_dict = config["cameras"][camera_name]
|
||||||
|
|
||||||
# clean paths
|
# clean paths
|
||||||
@ -196,18 +129,18 @@ def config():
|
|||||||
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
|
for zone_name, zone in config_obj.cameras[camera_name].zones.items():
|
||||||
camera_dict["zones"][zone_name]["color"] = zone.color
|
camera_dict["zones"][zone_name]["color"] = zone.color
|
||||||
|
|
||||||
config["plus"] = {"enabled": current_app.frigate_config.plus_api.is_active()}
|
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||||
config["model"]["colormap"] = config_obj.model.colormap
|
config["model"]["colormap"] = config_obj.model.colormap
|
||||||
|
|
||||||
for detector_config in config["detectors"].values():
|
for detector_config in config["detectors"].values():
|
||||||
detector_config["model"]["labelmap"] = (
|
detector_config["model"]["labelmap"] = (
|
||||||
current_app.frigate_config.model.merged_labelmap
|
request.app.frigate_config.model.merged_labelmap
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify(config)
|
return JSONResponse(content=config)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/raw")
|
@router.get("/config/raw")
|
||||||
def config_raw():
|
def config_raw():
|
||||||
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
||||||
|
|
||||||
@ -218,8 +151,9 @@ def config_raw():
|
|||||||
config_file = config_file_yaml
|
config_file = config_file_yaml
|
||||||
|
|
||||||
if not os.path.isfile(config_file):
|
if not os.path.isfile(config_file):
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Could not find file"}), 404
|
content=({"success": False, "message": "Could not find file"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(config_file, "r") as f:
|
with open(config_file, "r") as f:
|
||||||
@ -229,32 +163,30 @@ def config_raw():
|
|||||||
return raw_config, 200
|
return raw_config, 200
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/save", methods=["POST"])
|
@router.post("/config/save")
|
||||||
def config_save():
|
def config_save(save_option: str, body: dict):
|
||||||
save_option = request.args.get("save_option")
|
new_config = body
|
||||||
|
|
||||||
new_config = request.get_data().decode()
|
|
||||||
|
|
||||||
if not new_config:
|
if not new_config:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": "Config with body param is required"}
|
{"success": False, "message": "Config with body param is required"}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate the config schema
|
# Validate the config schema
|
||||||
try:
|
try:
|
||||||
FrigateConfig.parse_yaml(new_config)
|
FrigateConfig.parse_yaml(new_config)
|
||||||
except Exception:
|
except Exception:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
|
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the config to file
|
# Save the config to file
|
||||||
@ -271,14 +203,14 @@ def config_save():
|
|||||||
f.write(new_config)
|
f.write(new_config)
|
||||||
f.close()
|
f.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Could not write config file, be sure that Frigate has write permission on the config file.",
|
"message": "Could not write config file, be sure that Frigate has write permission on the config file.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if save_option == "restart":
|
if save_option == "restart":
|
||||||
@ -286,34 +218,34 @@ def config_save():
|
|||||||
restart_frigate()
|
restart_frigate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error restarting Frigate: {e}")
|
logging.error(f"Error restarting Frigate: {e}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Config successfully saved, unable to restart Frigate",
|
"message": "Config successfully saved, unable to restart Frigate",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Config successfully saved, restarting (this can take up to one minute)...",
|
"message": "Config successfully saved, restarting (this can take up to one minute)...",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Config successfully saved."}),
|
content=({"success": True, "message": "Config successfully saved."}),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/set", methods=["PUT"])
|
@router.put("/config/set")
|
||||||
def config_set():
|
def config_set(request: Request, body: AppConfigSetBody):
|
||||||
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
|
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
|
||||||
|
|
||||||
# Check if we can use .yaml instead of .yml
|
# Check if we can use .yaml instead of .yml
|
||||||
@ -339,68 +271,68 @@ def config_set():
|
|||||||
f.write(old_raw_config)
|
f.write(old_raw_config)
|
||||||
f.close()
|
f.close()
|
||||||
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Error parsing config. Check logs for error message.",
|
"message": "Error parsing config. Check logs for error message.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error updating config: {e}")
|
logging.error(f"Error updating config: {e}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Error updating config"}),
|
content=({"success": False, "message": "Error updating config"}),
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
json = request.get_json(silent=True) or {}
|
if body.requires_restart == 0:
|
||||||
|
request.app.frigate_config = FrigateConfig.parse_object(
|
||||||
if json.get("requires_restart", 1) == 0:
|
config_obj, request.app.frigate_config.plus_api
|
||||||
current_app.frigate_config = FrigateConfig.parse_object(
|
|
||||||
config_obj, plus_api=current_app.frigate_config.plus_api
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Config successfully updated, restart to apply",
|
"message": "Config successfully updated, restart to apply",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/ffprobe", methods=["GET"])
|
@router.get("/ffprobe")
|
||||||
def ffprobe():
|
def ffprobe(request: Request, paths: str = ""):
|
||||||
path_param = request.args.get("paths", "")
|
path_param = paths
|
||||||
|
|
||||||
if not path_param:
|
if not path_param:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Path needs to be provided."}), 404
|
content=({"success": False, "message": "Path needs to be provided."}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
if path_param.startswith("camera"):
|
if path_param.startswith("camera"):
|
||||||
camera = path_param[7:]
|
camera = path_param[7:]
|
||||||
|
|
||||||
if camera not in current_app.frigate_config.cameras.keys():
|
if camera not in request.app.frigate_config.cameras.keys():
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": f"{camera} is not a valid camera."}
|
{"success": False, "message": f"{camera} is not a valid camera."}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not current_app.frigate_config.cameras[camera].enabled:
|
if not request.app.frigate_config.cameras[camera].enabled:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": f"{camera} is not enabled."}), 404
|
content=({"success": False, "message": f"{camera} is not enabled."}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
paths = map(
|
paths = map(
|
||||||
lambda input: input.path,
|
lambda input: input.path,
|
||||||
current_app.frigate_config.cameras[camera].ffmpeg.inputs,
|
request.app.frigate_config.cameras[camera].ffmpeg.inputs,
|
||||||
)
|
)
|
||||||
elif "," in clean_camera_user_pass(path_param):
|
elif "," in clean_camera_user_pass(path_param):
|
||||||
paths = path_param.split(",")
|
paths = path_param.split(",")
|
||||||
@ -411,7 +343,7 @@ def ffprobe():
|
|||||||
output = []
|
output = []
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
ffprobe = ffprobe_stream(current_app.frigate_config.ffmpeg, path.strip())
|
ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip())
|
||||||
output.append(
|
output.append(
|
||||||
{
|
{
|
||||||
"return_code": ffprobe.returncode,
|
"return_code": ffprobe.returncode,
|
||||||
@ -428,14 +360,14 @@ def ffprobe():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify(output)
|
return JSONResponse(content=output)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/vainfo", methods=["GET"])
|
@router.get("/vainfo")
|
||||||
def vainfo():
|
def vainfo():
|
||||||
vainfo = vainfo_hwaccel()
|
vainfo = vainfo_hwaccel()
|
||||||
return jsonify(
|
return JSONResponse(
|
||||||
{
|
content={
|
||||||
"return_code": vainfo.returncode,
|
"return_code": vainfo.returncode,
|
||||||
"stderr": (
|
"stderr": (
|
||||||
vainfo.stderr.decode("unicode_escape").strip()
|
vainfo.stderr.decode("unicode_escape").strip()
|
||||||
@ -451,19 +383,26 @@ def vainfo():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/logs/<service>", methods=["GET"])
|
@router.get("/logs/{service}", tags=[Tags.logs])
|
||||||
def logs(service: str):
|
def logs(
|
||||||
|
service: str = Path(enum=["frigate", "nginx", "go2rtc", "chroma"]),
|
||||||
|
download: Optional[str] = None,
|
||||||
|
start: Optional[int] = 0,
|
||||||
|
end: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""Get logs for the requested service (frigate/nginx/go2rtc/chroma)"""
|
||||||
|
|
||||||
def download_logs(service_location: str):
|
def download_logs(service_location: str):
|
||||||
try:
|
try:
|
||||||
file = open(service_location, "r")
|
file = open(service_location, "r")
|
||||||
contents = file.read()
|
contents = file.read()
|
||||||
file.close()
|
file.close()
|
||||||
return jsonify(contents)
|
return JSONResponse(jsonable_encoder(contents))
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Could not find log file"}),
|
content={"success": False, "message": "Could not find log file"},
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
log_locations = {
|
log_locations = {
|
||||||
@ -475,17 +414,14 @@ def logs(service: str):
|
|||||||
service_location = log_locations.get(service)
|
service_location = log_locations.get(service)
|
||||||
|
|
||||||
if not service_location:
|
if not service_location:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Not a valid service"}),
|
content={"success": False, "message": "Not a valid service"},
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.args.get("download", type=bool, default=False):
|
if download:
|
||||||
return download_logs(service_location)
|
return download_logs(service_location)
|
||||||
|
|
||||||
start = request.args.get("start", type=int, default=0)
|
|
||||||
end = request.args.get("end", type=int)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file = open(service_location, "r")
|
file = open(service_location, "r")
|
||||||
contents = file.read()
|
contents = file.read()
|
||||||
@ -526,49 +462,47 @@ def logs(service: str):
|
|||||||
|
|
||||||
logLines.append(currentLine)
|
logLines.append(currentLine)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}),
|
content={"totalLines": len(logLines), "lines": logLines[start:end]},
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Could not find log file"}),
|
content={"success": False, "message": "Could not find log file"},
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/restart", methods=["POST"])
|
@router.post("/restart")
|
||||||
def restart():
|
def restart():
|
||||||
try:
|
try:
|
||||||
restart_frigate()
|
restart_frigate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error restarting Frigate: {e}")
|
logging.error(f"Error restarting Frigate: {e}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Unable to restart Frigate.",
|
"message": "Unable to restart Frigate.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Restarting (this can take up to one minute)...",
|
"message": "Restarting (this can take up to one minute)...",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/labels")
|
@router.get("/labels")
|
||||||
def get_labels():
|
def get_labels(camera: str = ""):
|
||||||
camera = request.args.get("camera", type=str, default="")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if camera:
|
if camera:
|
||||||
events = Event.select(Event.label).where(Event.camera == camera).distinct()
|
events = Event.select(Event.label).where(Event.camera == camera).distinct()
|
||||||
@ -576,24 +510,23 @@ def get_labels():
|
|||||||
events = Event.select(Event.label).distinct()
|
events = Event.select(Event.label).distinct()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Failed to get labels"}), 404
|
content=({"success": False, "message": "Failed to get labels"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
labels = sorted([e.label for e in events])
|
labels = sorted([e.label for e in events])
|
||||||
return jsonify(labels)
|
return JSONResponse(content=labels)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/sub_labels")
|
@router.get("/sub_labels")
|
||||||
def get_sub_labels():
|
def get_sub_labels(split_joined: Optional[int] = None):
|
||||||
split_joined = request.args.get("split_joined", type=int)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = Event.select(Event.sub_label).distinct()
|
events = Event.select(Event.sub_label).distinct()
|
||||||
except Exception:
|
except Exception:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Failed to get sub_labels"}),
|
content=({"success": False, "message": "Failed to get sub_labels"}),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
sub_labels = [e.sub_label for e in events]
|
sub_labels = [e.sub_label for e in events]
|
||||||
@ -614,15 +547,11 @@ def get_sub_labels():
|
|||||||
sub_labels.append(part.strip())
|
sub_labels.append(part.strip())
|
||||||
|
|
||||||
sub_labels.sort()
|
sub_labels.sort()
|
||||||
return jsonify(sub_labels)
|
return JSONResponse(content=sub_labels)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/timeline")
|
@router.get("/timeline")
|
||||||
def timeline():
|
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
|
||||||
camera = request.args.get("camera", "all")
|
|
||||||
source_id = request.args.get("source_id", type=str)
|
|
||||||
limit = request.args.get("limit", 100)
|
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
selected_columns = [
|
selected_columns = [
|
||||||
@ -651,18 +580,18 @@ def timeline():
|
|||||||
.dicts()
|
.dicts()
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify([t for t in timeline])
|
return JSONResponse(content=[t for t in timeline])
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/timeline/hourly")
|
@router.get("/timeline/hourly")
|
||||||
def hourly_timeline():
|
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||||
"""Get hourly summary for timeline."""
|
"""Get hourly summary for timeline."""
|
||||||
cameras = request.args.get("cameras", "all")
|
cameras = params.cameras
|
||||||
labels = request.args.get("labels", "all")
|
labels = params.labels
|
||||||
before = request.args.get("before", type=float)
|
before = params.before
|
||||||
after = request.args.get("after", type=float)
|
after = params.after
|
||||||
limit = request.args.get("limit", 200)
|
limit = params.limit
|
||||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
tz_name = params.timezone
|
||||||
|
|
||||||
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||||
minute_offset = int(minute_modifier.split(" ")[0])
|
minute_offset = int(minute_modifier.split(" ")[0])
|
||||||
@ -728,8 +657,8 @@ def hourly_timeline():
|
|||||||
else:
|
else:
|
||||||
hours[hour].insert(0, t)
|
hours[hour].insert(0, t)
|
||||||
|
|
||||||
return jsonify(
|
return JSONResponse(
|
||||||
{
|
content={
|
||||||
"start": start,
|
"start": start,
|
||||||
"end": end,
|
"end": end,
|
||||||
"count": count,
|
"count": count,
|
||||||
|
@ -12,25 +12,45 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify, make_response, redirect, request
|
from fastapi import APIRouter, Request, Response
|
||||||
from flask_limiter import Limiter
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from joserfc import jwt
|
from joserfc import jwt
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
from slowapi import Limiter
|
||||||
|
|
||||||
|
from frigate.api.defs.app_body import (
|
||||||
|
AppPostLoginBody,
|
||||||
|
AppPostUsersBody,
|
||||||
|
AppPutPasswordBody,
|
||||||
|
)
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config import AuthConfig, ProxyConfig
|
from frigate.config import AuthConfig, ProxyConfig
|
||||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
AuthBp = Blueprint("auth", __name__)
|
router = APIRouter(tags=[Tags.auth])
|
||||||
|
|
||||||
|
|
||||||
def get_remote_addr():
|
class RateLimiter:
|
||||||
|
_limit = ""
|
||||||
|
|
||||||
|
def set_limit(self, limit: str):
|
||||||
|
self._limit = limit
|
||||||
|
|
||||||
|
def get_limit(self) -> str:
|
||||||
|
return self._limit
|
||||||
|
|
||||||
|
|
||||||
|
rateLimiter = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_remote_addr(request: Request):
|
||||||
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
|
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
|
||||||
logger.debug(f"IP Route: {[r for r in route]}")
|
logger.debug(f"IP Route: {[r for r in route]}")
|
||||||
trusted_proxies = []
|
trusted_proxies = []
|
||||||
for proxy in current_app.frigate_config.auth.trusted_proxies:
|
for proxy in request.app.frigate_config.auth.trusted_proxies:
|
||||||
try:
|
try:
|
||||||
network = ipaddress.ip_network(proxy)
|
network = ipaddress.ip_network(proxy)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -68,16 +88,6 @@ def get_remote_addr():
|
|||||||
return request.remote_addr or "127.0.0.1"
|
return request.remote_addr or "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
limiter = Limiter(
|
|
||||||
get_remote_addr,
|
|
||||||
storage_uri="memory://",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_rate_limit():
|
|
||||||
return current_app.frigate_config.auth.failed_login_rate_limit
|
|
||||||
|
|
||||||
|
|
||||||
def get_jwt_secret() -> str:
|
def get_jwt_secret() -> str:
|
||||||
jwt_secret = None
|
jwt_secret = None
|
||||||
# check env var
|
# check env var
|
||||||
@ -132,7 +142,7 @@ def get_jwt_secret() -> str:
|
|||||||
return jwt_secret
|
return jwt_secret
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password, salt=None, iterations=600000):
|
def hash_password(password: str, salt=None, iterations=600000):
|
||||||
if salt is None:
|
if salt is None:
|
||||||
salt = secrets.token_hex(16)
|
salt = secrets.token_hex(16)
|
||||||
assert salt and isinstance(salt, str) and "$" not in salt
|
assert salt and isinstance(salt, str) and "$" not in salt
|
||||||
@ -158,33 +168,36 @@ def create_encoded_jwt(user, expiration, secret):
|
|||||||
return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret)
|
return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret)
|
||||||
|
|
||||||
|
|
||||||
def set_jwt_cookie(response, cookie_name, encoded_jwt, expiration, secure):
|
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
||||||
# TODO: ideally this would set secure as well, but that requires TLS
|
# TODO: ideally this would set secure as well, but that requires TLS
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
cookie_name, encoded_jwt, httponly=True, expires=expiration, secure=secure
|
key=cookie_name,
|
||||||
|
value=encoded_jwt,
|
||||||
|
httponly=True,
|
||||||
|
expires=expiration,
|
||||||
|
secure=secure,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Endpoint for use with nginx auth_request
|
# Endpoint for use with nginx auth_request
|
||||||
@AuthBp.route("/auth")
|
@router.get("/auth")
|
||||||
def auth():
|
def auth(request: Request):
|
||||||
auth_config: AuthConfig = current_app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
proxy_config: ProxyConfig = current_app.frigate_config.proxy
|
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||||
|
|
||||||
success_response = make_response({}, 202)
|
success_response = Response("", status_code=202)
|
||||||
|
|
||||||
# dont require auth if the request is on the internal port
|
# dont require auth if the request is on the internal port
|
||||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
||||||
if request.headers.get("x-server-port", 0, type=int) == 5000:
|
if int(request.headers.get("x-server-port", default=0)) == 5000:
|
||||||
return success_response
|
return success_response
|
||||||
|
|
||||||
fail_response = make_response({}, 401)
|
fail_response = Response("", status_code=401)
|
||||||
|
|
||||||
# ensure the proxy secret matches if configured
|
# ensure the proxy secret matches if configured
|
||||||
if (
|
if (
|
||||||
proxy_config.auth_secret is not None
|
proxy_config.auth_secret is not None
|
||||||
and request.headers.get("x-proxy-secret", "", type=str)
|
and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret
|
||||||
!= proxy_config.auth_secret
|
|
||||||
):
|
):
|
||||||
logger.debug("X-Proxy-Secret header does not match configured secret value")
|
logger.debug("X-Proxy-Secret header does not match configured secret value")
|
||||||
return fail_response
|
return fail_response
|
||||||
@ -196,7 +209,6 @@ def auth():
|
|||||||
if proxy_config.header_map.user is not None:
|
if proxy_config.header_map.user is not None:
|
||||||
upstream_user_header_value = request.headers.get(
|
upstream_user_header_value = request.headers.get(
|
||||||
proxy_config.header_map.user,
|
proxy_config.header_map.user,
|
||||||
type=str,
|
|
||||||
default="anonymous",
|
default="anonymous",
|
||||||
)
|
)
|
||||||
success_response.headers["remote-user"] = upstream_user_header_value
|
success_response.headers["remote-user"] = upstream_user_header_value
|
||||||
@ -207,10 +219,10 @@ def auth():
|
|||||||
# now apply authentication
|
# now apply authentication
|
||||||
fail_response.headers["location"] = "/login"
|
fail_response.headers["location"] = "/login"
|
||||||
|
|
||||||
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure
|
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||||
JWT_REFRESH = current_app.frigate_config.auth.refresh_time
|
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
||||||
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
|
||||||
jwt_source = None
|
jwt_source = None
|
||||||
encoded_token = None
|
encoded_token = None
|
||||||
@ -230,7 +242,7 @@ def auth():
|
|||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = jwt.decode(encoded_token, current_app.jwt_token)
|
token = jwt.decode(encoded_token, request.app.jwt_token)
|
||||||
if "sub" not in token.claims:
|
if "sub" not in token.claims:
|
||||||
logger.debug("user not set in jwt token")
|
logger.debug("user not set in jwt token")
|
||||||
return fail_response
|
return fail_response
|
||||||
@ -266,7 +278,7 @@ def auth():
|
|||||||
return fail_response
|
return fail_response
|
||||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||||
new_encoded_jwt = create_encoded_jwt(
|
new_encoded_jwt = create_encoded_jwt(
|
||||||
user, new_expiration, current_app.jwt_token
|
user, new_expiration, request.app.jwt_token
|
||||||
)
|
)
|
||||||
set_jwt_cookie(
|
set_jwt_cookie(
|
||||||
success_response,
|
success_response,
|
||||||
@ -283,86 +295,84 @@ def auth():
|
|||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/profile")
|
@router.get("/profile")
|
||||||
def profile():
|
def profile(request: Request):
|
||||||
username = request.headers.get("remote-user", type=str)
|
username = request.headers.get("remote-user")
|
||||||
return jsonify({"username": username})
|
return JSONResponse(content={"username": username})
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/logout")
|
@router.get("/logout")
|
||||||
def logout():
|
def logout(request: Request):
|
||||||
auth_config: AuthConfig = current_app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
response = make_response(redirect("/login", code=303))
|
response = RedirectResponse("/login", status_code=303)
|
||||||
response.delete_cookie(auth_config.cookie_name)
|
response.delete_cookie(auth_config.cookie_name)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/login", methods=["POST"])
|
limiter = Limiter(key_func=get_remote_addr)
|
||||||
@limiter.limit(get_rate_limit, deduct_when=lambda response: response.status_code == 400)
|
|
||||||
def login():
|
|
||||||
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
|
@router.post("/login")
|
||||||
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure
|
@limiter.limit(limit_value=rateLimiter.get_limit)
|
||||||
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
|
def login(request: Request, body: AppPostLoginBody):
|
||||||
content = request.get_json()
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
user = content["user"]
|
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||||
password = content["password"]
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
user = body.user
|
||||||
|
password = body.password
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db_user: User = User.get_by_id(user)
|
db_user: User = User.get_by_id(user)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response({"message": "Login failed"}, 400)
|
return JSONResponse(content={"message": "Login failed"}, status_code=400)
|
||||||
|
|
||||||
password_hash = db_user.password_hash
|
password_hash = db_user.password_hash
|
||||||
if verify_password(password, password_hash):
|
if verify_password(password, password_hash):
|
||||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
encoded_jwt = create_encoded_jwt(user, expiration, current_app.jwt_token)
|
encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token)
|
||||||
response = make_response({}, 200)
|
response = Response("", 200)
|
||||||
set_jwt_cookie(
|
set_jwt_cookie(
|
||||||
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
return make_response({"message": "Login failed"}, 400)
|
return JSONResponse(content={"message": "Login failed"}, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/users")
|
@router.get("/users")
|
||||||
def get_users():
|
def get_users():
|
||||||
exports = User.select(User.username).order_by(User.username).dicts().iterator()
|
exports = User.select(User.username).order_by(User.username).dicts().iterator()
|
||||||
return jsonify([e for e in exports])
|
return JSONResponse([e for e in exports])
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/users", methods=["POST"])
|
@router.post("/users")
|
||||||
def create_user():
|
def create_user(request: Request, body: AppPostUsersBody):
|
||||||
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
|
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
request_data = request.get_json()
|
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
||||||
|
JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
||||||
|
|
||||||
if not re.match("^[A-Za-z0-9._]+$", request_data.get("username", "")):
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
make_response({"message": "Invalid username"}, 400)
|
|
||||||
|
|
||||||
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
|
|
||||||
|
|
||||||
User.insert(
|
User.insert(
|
||||||
{
|
{
|
||||||
User.username: request_data["username"],
|
User.username: body.username,
|
||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
return jsonify({"username": request_data["username"]})
|
return JSONResponse(content={"username": body.username})
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/users/<username>", methods=["DELETE"])
|
@router.delete("/users/{username}")
|
||||||
def delete_user(username: str):
|
def delete_user(username: str):
|
||||||
User.delete_by_id(username)
|
User.delete_by_id(username)
|
||||||
return jsonify({"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
|
||||||
@AuthBp.route("/users/<username>/password", methods=["PUT"])
|
@router.put("/users/{username}/password")
|
||||||
def update_password(username: str):
|
def update_password(request: Request, username: str, body: AppPutPasswordBody):
|
||||||
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
|
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
request_data = request.get_json()
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
|
|
||||||
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
|
|
||||||
|
|
||||||
User.set_by_id(
|
User.set_by_id(
|
||||||
username,
|
username,
|
||||||
@ -370,4 +380,4 @@ def update_password(username: str):
|
|||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return jsonify({"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
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 io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from flask import (
|
from fastapi import APIRouter, Request
|
||||||
Blueprint,
|
from fastapi.params import Depends
|
||||||
current_app,
|
from fastapi.responses import JSONResponse
|
||||||
jsonify,
|
|
||||||
make_response,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
from peewee import JOIN, DoesNotExist, fn, operator
|
from peewee import JOIN, DoesNotExist, fn, operator
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.api.defs.events_body import (
|
||||||
|
EventsCreateBody,
|
||||||
|
EventsDescriptionBody,
|
||||||
|
EventsEndBody,
|
||||||
|
EventsSubLabelBody,
|
||||||
|
)
|
||||||
|
from frigate.api.defs.events_query_parameters import (
|
||||||
|
DEFAULT_TIME_RANGE,
|
||||||
|
EventsQueryParams,
|
||||||
|
EventsSearchQueryParams,
|
||||||
|
EventsSummaryQueryParams,
|
||||||
|
)
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CLIPS_DIR,
|
CLIPS_DIR,
|
||||||
)
|
)
|
||||||
@ -33,57 +41,55 @@ from frigate.util.builtin import get_tz_modifiers
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
EventBp = Blueprint("events", __name__)
|
router = APIRouter(tags=[Tags.events])
|
||||||
|
|
||||||
DEFAULT_TIME_RANGE = "00:00,24:00"
|
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events")
|
@router.get("/events")
|
||||||
def events():
|
def events(params: EventsQueryParams = Depends()):
|
||||||
camera = request.args.get("camera", "all")
|
camera = params.camera
|
||||||
cameras = request.args.get("cameras", "all")
|
cameras = params.cameras
|
||||||
|
|
||||||
# handle old camera arg
|
# handle old camera arg
|
||||||
if cameras == "all" and camera != "all":
|
if cameras == "all" and camera != "all":
|
||||||
cameras = camera
|
cameras = camera
|
||||||
|
|
||||||
label = unquote(request.args.get("label", "all"))
|
label = unquote(params.label)
|
||||||
labels = request.args.get("labels", "all")
|
labels = params.labels
|
||||||
|
|
||||||
# handle old label arg
|
# handle old label arg
|
||||||
if labels == "all" and label != "all":
|
if labels == "all" and label != "all":
|
||||||
labels = label
|
labels = label
|
||||||
|
|
||||||
sub_label = request.args.get("sub_label", "all")
|
sub_label = params.sub_label
|
||||||
sub_labels = request.args.get("sub_labels", "all")
|
sub_labels = params.sub_labels
|
||||||
|
|
||||||
# handle old sub_label arg
|
# handle old sub_label arg
|
||||||
if sub_labels == "all" and sub_label != "all":
|
if sub_labels == "all" and sub_label != "all":
|
||||||
sub_labels = sub_label
|
sub_labels = sub_label
|
||||||
|
|
||||||
zone = request.args.get("zone", "all")
|
zone = params.zone
|
||||||
zones = request.args.get("zones", "all")
|
zones = params.zones
|
||||||
|
|
||||||
# handle old label arg
|
# handle old label arg
|
||||||
if zones == "all" and zone != "all":
|
if zones == "all" and zone != "all":
|
||||||
zones = zone
|
zones = zone
|
||||||
|
|
||||||
limit = request.args.get("limit", 100)
|
limit = params.limit
|
||||||
after = request.args.get("after", type=float)
|
after = params.after
|
||||||
before = request.args.get("before", type=float)
|
before = params.before
|
||||||
time_range = request.args.get("time_range", DEFAULT_TIME_RANGE)
|
time_range = params.time_range
|
||||||
has_clip = request.args.get("has_clip", type=int)
|
has_clip = params.has_clip
|
||||||
has_snapshot = request.args.get("has_snapshot", type=int)
|
has_snapshot = params.has_snapshot
|
||||||
in_progress = request.args.get("in_progress", type=int)
|
in_progress = params.in_progress
|
||||||
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
|
include_thumbnails = params.include_thumbnails
|
||||||
favorites = request.args.get("favorites", type=int)
|
favorites = params.favorites
|
||||||
min_score = request.args.get("min_score", type=float)
|
min_score = params.min_score
|
||||||
max_score = request.args.get("max_score", type=float)
|
max_score = params.max_score
|
||||||
is_submitted = request.args.get("is_submitted", type=int)
|
is_submitted = params.is_submitted
|
||||||
min_length = request.args.get("min_length", type=float)
|
min_length = params.min_length
|
||||||
max_length = request.args.get("max_length", type=float)
|
max_length = params.max_length
|
||||||
|
|
||||||
sort = request.args.get("sort", type=str)
|
sort = params.sort
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
@ -163,7 +169,7 @@ def events():
|
|||||||
|
|
||||||
if time_range != DEFAULT_TIME_RANGE:
|
if time_range != DEFAULT_TIME_RANGE:
|
||||||
# get timezone arg to ensure browser times are used
|
# get timezone arg to ensure browser times are used
|
||||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
tz_name = params.timezone
|
||||||
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
|
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||||
|
|
||||||
times = time_range.split(",")
|
times = time_range.split(",")
|
||||||
@ -248,13 +254,11 @@ def events():
|
|||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify(list(events))
|
return JSONResponse(content=list(events))
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/explore")
|
@router.get("/events/explore")
|
||||||
def events_explore():
|
def events_explore(limit: int = 10):
|
||||||
limit = request.args.get("limit", 10, type=int)
|
|
||||||
|
|
||||||
subquery = Event.select(
|
subquery = Event.select(
|
||||||
Event.id,
|
Event.id,
|
||||||
Event.camera,
|
Event.camera,
|
||||||
@ -316,69 +320,68 @@ def events_explore():
|
|||||||
for event in events
|
for event in events
|
||||||
]
|
]
|
||||||
|
|
||||||
return jsonify(processed_events)
|
return JSONResponse(content=processed_events)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/event_ids")
|
@router.get("/event_ids")
|
||||||
def event_ids():
|
def event_ids(ids: str):
|
||||||
idString = request.args.get("ids")
|
ids = ids.split(",")
|
||||||
ids = idString.split(",")
|
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Valid list of ids must be sent"}),
|
content=({"success": False, "message": "Valid list of ids must be sent"}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = Event.select().where(Event.id << ids).dicts().iterator()
|
events = Event.select().where(Event.id << ids).dicts().iterator()
|
||||||
return jsonify(list(events))
|
return JSONResponse(list(events))
|
||||||
except Exception:
|
except Exception:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Events not found"}), 400
|
content=({"success": False, "message": "Events not found"}), status_code=400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/search")
|
@router.get("/events/search")
|
||||||
def events_search():
|
def events_search(request: Request, params: EventsSearchQueryParams = Depends()):
|
||||||
query = request.args.get("query", type=str)
|
query = params.query
|
||||||
search_type = request.args.get("search_type", "thumbnail,description", type=str)
|
search_type = params.search_type
|
||||||
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
|
include_thumbnails = params.include_thumbnails
|
||||||
limit = request.args.get("limit", 50, type=int)
|
limit = params.limit
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
cameras = request.args.get("cameras", "all", type=str)
|
cameras = params.cameras
|
||||||
labels = request.args.get("labels", "all", type=str)
|
labels = params.labels
|
||||||
zones = request.args.get("zones", "all", type=str)
|
zones = params.zones
|
||||||
after = request.args.get("after", type=float)
|
after = params.after
|
||||||
before = request.args.get("before", type=float)
|
before = params.before
|
||||||
|
|
||||||
# for similarity search
|
# for similarity search
|
||||||
event_id = request.args.get("event_id", type=str)
|
event_id = params.event_id
|
||||||
|
|
||||||
if not query and not event_id:
|
if not query and not event_id:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "A search query must be supplied",
|
"message": "A search query must be supplied",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not current_app.frigate_config.semantic_search.enabled:
|
if not request.app.frigate_config.semantic_search.enabled:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Semantic search is not enabled",
|
"message": "Semantic search is not enabled",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
context: EmbeddingsContext = current_app.embeddings
|
context: EmbeddingsContext = request.app.embeddings
|
||||||
|
|
||||||
selected_columns = [
|
selected_columns = [
|
||||||
Event.id,
|
Event.id,
|
||||||
@ -437,14 +440,14 @@ def events_search():
|
|||||||
try:
|
try:
|
||||||
search_event: Event = Event.get(Event.id == event_id)
|
search_event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Event not found",
|
"message": "Event not found",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
thumbnail = base64.b64decode(search_event.thumbnail)
|
thumbnail = base64.b64decode(search_event.thumbnail)
|
||||||
img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB"))
|
img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB"))
|
||||||
@ -504,7 +507,7 @@ def events_search():
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return jsonify([])
|
return JSONResponse(content=[])
|
||||||
|
|
||||||
# Get the event data
|
# Get the event data
|
||||||
events = (
|
events = (
|
||||||
@ -537,15 +540,15 @@ def events_search():
|
|||||||
]
|
]
|
||||||
events = sorted(events, key=lambda x: x["search_distance"])[:limit]
|
events = sorted(events, key=lambda x: x["search_distance"])[:limit]
|
||||||
|
|
||||||
return jsonify(events)
|
return JSONResponse(content=events)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/summary")
|
@router.get("/events/summary")
|
||||||
def events_summary():
|
def events_summary(params: EventsSummaryQueryParams = Depends()):
|
||||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
tz_name = params.timezone
|
||||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
||||||
has_clip = request.args.get("has_clip", type=int)
|
has_clip = params.has_clip
|
||||||
has_snapshot = request.args.get("has_snapshot", type=int)
|
has_snapshot = params.has_snapshot
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
@ -582,47 +585,49 @@ def events_summary():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify([e for e in groups.dicts()])
|
return JSONResponse(content=[e for e in groups.dicts()])
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>", methods=("GET",))
|
@router.get("/events/{event_id}")
|
||||||
def event(id):
|
def event(event_id: str):
|
||||||
try:
|
try:
|
||||||
return model_to_dict(Event.get(Event.id == id))
|
return model_to_dict(Event.get(Event.id == event_id))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return "Event not found", 404
|
return JSONResponse(content="Event not found", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>/retain", methods=("POST",))
|
@router.post("/events/{event_id}/retain")
|
||||||
def set_retain(id):
|
def set_retain(event_id: str):
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
event.retain_indefinitely = True
|
event.retain_indefinitely = True
|
||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Event " + id + " retained"}), 200
|
content=({"success": True, "message": "Event " + event_id + " retained"}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>/plus", methods=("POST",))
|
@router.post("/events/{event_id}/plus")
|
||||||
def send_to_plus(id):
|
def send_to_plus(request: Request, event_id: str):
|
||||||
if not current_app.frigate_config.plus_api.is_active():
|
if not request.app.frigate_config.plus_api.is_active():
|
||||||
message = "PLUS_API_KEY environment variable is not set"
|
message = "PLUS_API_KEY environment variable is not set"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
include_annotation = (
|
include_annotation = (
|
||||||
@ -630,11 +635,13 @@ def send_to_plus(id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
message = f"Event {id} not found"
|
message = f"Event {event_id} not found"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(jsonify({"success": False, "message": message}), 404)
|
return JSONResponse(
|
||||||
|
content=({"success": False, "message": message}), status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
# events from before the conversion to relative dimensions cant include annotations
|
# events from before the conversion to relative dimensions cant include annotations
|
||||||
if event.data.get("box") is None:
|
if event.data.get("box") is None:
|
||||||
@ -642,20 +649,22 @@ def send_to_plus(id):
|
|||||||
|
|
||||||
if event.end_time is None:
|
if event.end_time is None:
|
||||||
logger.error(f"Unable to load clean png for in-progress event: {event.id}")
|
logger.error(f"Unable to load clean png for in-progress event: {event.id}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Unable to load clean png for in-progress event",
|
"message": "Unable to load clean png for in-progress event",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if event.plus_id:
|
if event.plus_id:
|
||||||
message = "Already submitted to plus"
|
message = "Already submitted to plus"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
return JSONResponse(
|
||||||
|
content=({"success": False, "message": message}), status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
# load clean.png
|
# load clean.png
|
||||||
try:
|
try:
|
||||||
@ -663,29 +672,29 @@ def send_to_plus(id):
|
|||||||
image = cv2.imread(os.path.join(CLIPS_DIR, filename))
|
image = cv2.imread(os.path.join(CLIPS_DIR, filename))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error(f"Unable to load clean png for event: {event.id}")
|
logger.error(f"Unable to load clean png for event: {event.id}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": "Unable to load clean png for event"}
|
{"success": False, "message": "Unable to load clean png for event"}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if image is None or image.size == 0:
|
if image is None or image.size == 0:
|
||||||
logger.error(f"Unable to load clean png for event: {event.id}")
|
logger.error(f"Unable to load clean png for event: {event.id}")
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": "Unable to load clean png for event"}
|
{"success": False, "message": "Unable to load clean png for event"}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plus_id = current_app.frigate_config.plus_api.upload_image(image, event.camera)
|
plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(ex)
|
logger.exception(ex)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Error uploading image"}),
|
content=({"success": False, "message": "Error uploading image"}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# store image id in the database
|
# store image id in the database
|
||||||
@ -696,7 +705,7 @@ def send_to_plus(id):
|
|||||||
box = event.data["box"]
|
box = event.data["box"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.frigate_config.plus_api.add_annotation(
|
request.app.frigate_config.plus_api.add_annotation(
|
||||||
event.plus_id,
|
event.plus_id,
|
||||||
box,
|
box,
|
||||||
event.label,
|
event.label,
|
||||||
@ -704,59 +713,67 @@ def send_to_plus(id):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
message = "Error uploading annotation, unsupported label provided."
|
message = "Error uploading annotation, unsupported label provided."
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": message}),
|
content=({"success": False, "message": message}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(ex)
|
logger.exception(ex)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Error uploading annotation"}),
|
content=({"success": False, "message": "Error uploading annotation"}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(jsonify({"success": True, "plus_id": plus_id}), 200)
|
return JSONResponse(
|
||||||
|
content=({"success": True, "plus_id": plus_id}), status_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>/false_positive", methods=("PUT",))
|
@router.put("/events/{event_id}/false_positive")
|
||||||
def false_positive(id):
|
def false_positive(request: Request, event_id: str):
|
||||||
if not current_app.frigate_config.plus_api.is_active():
|
if not request.app.frigate_config.plus_api.is_active():
|
||||||
message = "PLUS_API_KEY environment variable is not set"
|
message = "PLUS_API_KEY environment variable is not set"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
message = f"Event {id} not found"
|
message = f"Event {event_id} not found"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(jsonify({"success": False, "message": message}), 404)
|
return JSONResponse(
|
||||||
|
content=({"success": False, "message": message}), status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
# events from before the conversion to relative dimensions cant include annotations
|
# events from before the conversion to relative dimensions cant include annotations
|
||||||
if event.data.get("box") is None:
|
if event.data.get("box") is None:
|
||||||
message = "Events prior to 0.13 cannot be submitted as false positives"
|
message = "Events prior to 0.13 cannot be submitted as false positives"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
return JSONResponse(
|
||||||
|
content=({"success": False, "message": message}), status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
if event.false_positive:
|
if event.false_positive:
|
||||||
message = "False positive already submitted to Frigate+"
|
message = "False positive already submitted to Frigate+"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
return JSONResponse(
|
||||||
|
content=({"success": False, "message": message}), status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
if not event.plus_id:
|
if not event.plus_id:
|
||||||
plus_response = send_to_plus(id)
|
plus_response = send_to_plus(event_id)
|
||||||
if plus_response.status_code != 200:
|
if plus_response.status_code != 200:
|
||||||
return plus_response
|
return plus_response
|
||||||
# need to refetch the event now that it has a plus_id
|
# need to refetch the event now that it has a plus_id
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == event_id)
|
||||||
|
|
||||||
region = event.data["region"]
|
region = event.data["region"]
|
||||||
box = event.data["box"]
|
box = event.data["box"]
|
||||||
@ -769,7 +786,7 @@ def false_positive(id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.frigate_config.plus_api.add_false_positive(
|
request.app.frigate_config.plus_api.add_false_positive(
|
||||||
event.plus_id,
|
event.plus_id,
|
||||||
region,
|
region,
|
||||||
box,
|
box,
|
||||||
@ -782,92 +799,65 @@ def false_positive(id):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
message = "Error uploading false positive, unsupported label provided."
|
message = "Error uploading false positive, unsupported label provided."
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": message}),
|
content=({"success": False, "message": message}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(ex)
|
logger.exception(ex)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Error uploading false positive"}),
|
content=({"success": False, "message": "Error uploading false positive"}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
event.false_positive = True
|
event.false_positive = True
|
||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
return make_response(jsonify({"success": True, "plus_id": event.plus_id}), 200)
|
return JSONResponse(
|
||||||
|
content=({"success": True, "plus_id": event.plus_id}), status_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>/retain", methods=("DELETE",))
|
@router.delete("/events/{event_id}/retain")
|
||||||
def delete_retain(id):
|
def delete_retain(event_id: str):
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
event.retain_indefinitely = False
|
event.retain_indefinitely = False
|
||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200
|
content=({"success": True, "message": "Event " + event_id + " un-retained"}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>/sub_label", methods=("POST",))
|
@router.post("/events/{event_id}/sub_label")
|
||||||
def set_sub_label(id):
|
def set_sub_label(
|
||||||
|
request: Request,
|
||||||
|
event_id: str,
|
||||||
|
body: EventsSubLabelBody,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
new_sub_label = body.subLabel
|
||||||
new_sub_label = json.get("subLabel")
|
new_score = body.subLabelScore
|
||||||
new_score = json.get("subLabelScore")
|
|
||||||
|
|
||||||
if new_sub_label is None:
|
|
||||||
return make_response(
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"message": "A sub label must be supplied",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if new_sub_label and len(new_sub_label) > 100:
|
|
||||||
return make_response(
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"message": new_sub_label
|
|
||||||
+ " exceeds the 100 character limit for sub_label",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if new_score is not None and (new_score > 1.0 or new_score < 0):
|
|
||||||
return make_response(
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"message": new_score
|
|
||||||
+ " does not fit within the expected bounds 0 <= score <= 1.0",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not event.end_time:
|
if not event.end_time:
|
||||||
# update tracked object
|
# update tracked object
|
||||||
tracked_obj: TrackedObject = (
|
tracked_obj: TrackedObject = (
|
||||||
current_app.detected_frames_processor.camera_states[
|
request.app.detected_frames_processor.camera_states[
|
||||||
event.camera
|
event.camera
|
||||||
].tracked_objects.get(event.id)
|
].tracked_objects.get(event.id)
|
||||||
)
|
)
|
||||||
@ -878,7 +868,7 @@ def set_sub_label(id):
|
|||||||
# update timeline items
|
# update timeline items
|
||||||
Timeline.update(
|
Timeline.update(
|
||||||
data=Timeline.data.update({"sub_label": (new_sub_label, new_score)})
|
data=Timeline.data.update({"sub_label": (new_sub_label, new_score)})
|
||||||
).where(Timeline.source_id == id).execute()
|
).where(Timeline.source_id == event_id).execute()
|
||||||
|
|
||||||
event.sub_label = new_sub_label
|
event.sub_label = new_sub_label
|
||||||
|
|
||||||
@ -888,70 +878,78 @@ def set_sub_label(id):
|
|||||||
event.data = data
|
event.data = data
|
||||||
|
|
||||||
event.save()
|
event.save()
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Event " + id + " sub label set to " + new_sub_label,
|
"message": "Event " + event_id + " sub label set to " + new_sub_label,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>/description", methods=("POST",))
|
@router.post("/events/{event_id}/description")
|
||||||
def set_description(id):
|
def set_description(
|
||||||
|
request: Request,
|
||||||
|
event_id: str,
|
||||||
|
body: EventsDescriptionBody,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
new_description = body.description
|
||||||
new_description = json.get("description")
|
|
||||||
|
|
||||||
if new_description is None or len(new_description) == 0:
|
if new_description is None or len(new_description) == 0:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "description cannot be empty",
|
"message": "description cannot be empty",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
event.data["description"] = new_description
|
event.data["description"] = new_description
|
||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
# If semantic search is enabled, update the index
|
# If semantic search is enabled, update the index
|
||||||
if current_app.frigate_config.semantic_search.enabled:
|
if request.app.frigate_config.semantic_search.enabled:
|
||||||
context: EmbeddingsContext = current_app.embeddings
|
context: EmbeddingsContext = request.app.embeddings
|
||||||
context.embeddings.description.upsert(
|
context.embeddings.description.upsert(
|
||||||
documents=[new_description],
|
documents=[new_description],
|
||||||
metadatas=[get_metadata(event)],
|
metadatas=[get_metadata(event)],
|
||||||
ids=[id],
|
ids=[event_id],
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Event " + id + " description set to " + new_description,
|
"message": "Event "
|
||||||
|
+ event_id
|
||||||
|
+ " description set to "
|
||||||
|
+ new_description,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<id>", methods=("DELETE",))
|
@router.delete("/events/{event_id}")
|
||||||
def delete_event(id):
|
def delete_event(request: Request, event_id: str):
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
media_name = f"{event.camera}-{event.id}"
|
media_name = f"{event.camera}-{event.id}"
|
||||||
@ -965,82 +963,86 @@ def delete_event(id):
|
|||||||
media.unlink(missing_ok=True)
|
media.unlink(missing_ok=True)
|
||||||
|
|
||||||
event.delete_instance()
|
event.delete_instance()
|
||||||
Timeline.delete().where(Timeline.source_id == id).execute()
|
Timeline.delete().where(Timeline.source_id == event_id).execute()
|
||||||
# If semantic search is enabled, update the index
|
# If semantic search is enabled, update the index
|
||||||
if current_app.frigate_config.semantic_search.enabled:
|
if request.app.frigate_config.semantic_search.enabled:
|
||||||
context: EmbeddingsContext = current_app.embeddings
|
context: EmbeddingsContext = request.app.embeddings
|
||||||
context.embeddings.thumbnail.delete(ids=[id])
|
context.embeddings.thumbnail.delete(ids=[event_id])
|
||||||
context.embeddings.description.delete(ids=[id])
|
context.embeddings.description.delete(ids=[event_id])
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Event " + id + " deleted"}), 200
|
content=({"success": True, "message": "Event " + event_id + " deleted"}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<camera_name>/<label>/create", methods=["POST"])
|
@router.post("/events/{camera_name}/{label}/create")
|
||||||
def create_event(camera_name, label):
|
def create_event(
|
||||||
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
|
request: Request,
|
||||||
return make_response(
|
camera_name: str,
|
||||||
jsonify(
|
label: str,
|
||||||
|
body: EventsCreateBody = None,
|
||||||
|
):
|
||||||
|
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not label:
|
if not label:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": f"{label} must be set."}), 404
|
content=({"success": False, "message": f"{label} must be set."}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frame = current_app.detected_frames_processor.get_current_frame(camera_name)
|
frame = request.app.detected_frames_processor.get_current_frame(camera_name)
|
||||||
|
|
||||||
event_id = current_app.external_processor.create_manual_event(
|
event_id = request.app.external_processor.create_manual_event(
|
||||||
camera_name,
|
camera_name,
|
||||||
label,
|
label,
|
||||||
json.get("source_type", "api"),
|
body.source_type,
|
||||||
json.get("sub_label", None),
|
body.sub_label,
|
||||||
json.get("score", 0),
|
body.score,
|
||||||
json.get("duration", 30),
|
body.duration,
|
||||||
json.get("include_recording", True),
|
body.include_recording,
|
||||||
json.get("draw", {}),
|
body.draw,
|
||||||
frame,
|
frame,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "An unknown error occurred"}),
|
content=({"success": False, "message": "An unknown error occurred"}),
|
||||||
500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Successfully created event.",
|
"message": "Successfully created event.",
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/<event_id>/end", methods=["PUT"])
|
@router.put("/events/{event_id}/end")
|
||||||
def end_event(event_id):
|
def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
end_time = json.get("end_time", datetime.now().timestamp())
|
end_time = body.end_time
|
||||||
current_app.external_processor.finish_manual_event(event_id, end_time)
|
request.app.external_processor.finish_manual_event(event_id, end_time)
|
||||||
except Exception:
|
except Exception:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": f"{event_id} must be set and valid."}
|
{"success": False, "message": f"{event_id} must be set and valid."}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Event successfully ended."}), 200
|
content=({"success": True, "message": "Event successfully ended."}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
@ -5,54 +5,50 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from flask import (
|
from fastapi import APIRouter, Request
|
||||||
Blueprint,
|
from fastapi.responses import JSONResponse
|
||||||
current_app,
|
|
||||||
jsonify,
|
|
||||||
make_response,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import EXPORT_DIR
|
from frigate.const import EXPORT_DIR
|
||||||
from frigate.models import Export, Recordings
|
from frigate.models import Export, Recordings
|
||||||
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
|
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ExportBp = Blueprint("exports", __name__)
|
router = APIRouter(tags=[Tags.export])
|
||||||
|
|
||||||
|
|
||||||
@ExportBp.route("/exports")
|
@router.get("/exports")
|
||||||
def get_exports():
|
def get_exports():
|
||||||
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
|
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
|
||||||
return jsonify([e for e in exports])
|
return JSONResponse(content=[e for e in exports])
|
||||||
|
|
||||||
|
|
||||||
@ExportBp.route(
|
@router.post("/export/{camera_name}/start/{start_time}/end/{end_time}")
|
||||||
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
|
def export_recording(
|
||||||
)
|
request: Request,
|
||||||
@ExportBp.route(
|
camera_name: str,
|
||||||
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>",
|
start_time: float,
|
||||||
methods=["POST"],
|
end_time: float,
|
||||||
)
|
body: dict = None,
|
||||||
def export_recording(camera_name: str, start_time, end_time):
|
):
|
||||||
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
|
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
json: dict[str, any] = body or {}
|
||||||
playback_factor = json.get("playback", "realtime")
|
playback_factor = json.get("playback", "realtime")
|
||||||
friendly_name: Optional[str] = json.get("name")
|
friendly_name: Optional[str] = json.get("name")
|
||||||
|
|
||||||
if len(friendly_name or "") > 256:
|
if len(friendly_name or "") > 256:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "File name is too long."}),
|
content=({"success": False, "message": "File name is too long."}),
|
||||||
401,
|
status_code=401,
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_image = json.get("image_path")
|
existing_image = json.get("image_path")
|
||||||
@ -69,15 +65,15 @@ def export_recording(camera_name: str, start_time, end_time):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if recordings_count <= 0:
|
if recordings_count <= 0:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": "No recordings found for time range"}
|
{"success": False, "message": "No recordings found for time range"}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
exporter = RecordingExporter(
|
exporter = RecordingExporter(
|
||||||
current_app.frigate_config,
|
request.app.frigate_config,
|
||||||
camera_name,
|
camera_name,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
existing_image,
|
existing_image,
|
||||||
@ -90,58 +86,58 @@ def export_recording(camera_name: str, start_time, end_time):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
exporter.start()
|
exporter.start()
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Starting export of recording.",
|
"message": "Starting export of recording.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ExportBp.route("/export/<id>/<new_name>", methods=["PATCH"])
|
@router.patch("/export/{event_id}/{new_name}")
|
||||||
def export_rename(id, new_name: str):
|
def export_rename(event_id: str, new_name: str):
|
||||||
try:
|
try:
|
||||||
export: Export = Export.get(Export.id == id)
|
export: Export = Export.get(Export.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Export not found.",
|
"message": "Export not found.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
export.name = new_name
|
export.name = new_name
|
||||||
export.save()
|
export.save()
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Successfully renamed export.",
|
"message": "Successfully renamed export.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ExportBp.route("/export/<id>", methods=["DELETE"])
|
@router.delete("/export/{event_id}")
|
||||||
def export_delete(id: str):
|
def export_delete(event_id: str):
|
||||||
try:
|
try:
|
||||||
export: Export = Export.get(Export.id == id)
|
export: Export = Export.get(Export.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Export not found.",
|
"message": "Export not found.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
files_in_use = []
|
files_in_use = []
|
||||||
@ -158,11 +154,11 @@ def export_delete(id: str):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if export.video_path.split("/")[-1] in files_in_use:
|
if export.video_path.split("/")[-1] in files_in_use:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{"success": False, "message": "Can not delete in progress export."}
|
{"success": False, "message": "Can not delete in progress export."}
|
||||||
),
|
),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
Path(export.video_path).unlink(missing_ok=True)
|
Path(export.video_path).unlink(missing_ok=True)
|
||||||
@ -171,12 +167,12 @@ def export_delete(id: str):
|
|||||||
Path(export.thumb_path).unlink(missing_ok=True)
|
Path(export.thumb_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
export.delete_instance()
|
export.delete_instance()
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Successfully deleted export.",
|
"message": "Successfully deleted export.",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
108
frigate/api/fastapi_app.py
Normal file
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
|
import os
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from flask import (
|
from fastapi import APIRouter, Request
|
||||||
Blueprint,
|
from fastapi.responses import JSONResponse
|
||||||
current_app,
|
|
||||||
jsonify,
|
|
||||||
make_response,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from py_vapid import Vapid01, utils
|
from py_vapid import Vapid01, utils
|
||||||
|
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
NotificationBp = Blueprint("notifications", __name__)
|
router = APIRouter(tags=[Tags.notifications])
|
||||||
|
|
||||||
|
|
||||||
@NotificationBp.route("/notifications/pubkey", methods=["GET"])
|
@router.get("/notifications/pubkey")
|
||||||
def get_vapid_pub_key():
|
def get_vapid_pub_key(request: Request):
|
||||||
if not current_app.frigate_config.notifications.enabled:
|
if not request.app.frigate_config.notifications.enabled:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Notifications are not enabled."}),
|
content=({"success": False, "message": "Notifications are not enabled."}),
|
||||||
400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
|
key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
|
||||||
raw_pub = key.public_key.public_bytes(
|
raw_pub = key.public_key.public_bytes(
|
||||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||||
)
|
)
|
||||||
return jsonify(utils.b64urlencode(raw_pub)), 200
|
return JSONResponse(content=utils.b64urlencode(raw_pub), status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@NotificationBp.route("/notifications/register", methods=["POST"])
|
@router.post("/notifications/register")
|
||||||
def register_notifications():
|
def register_notifications(request: Request, body: dict = None):
|
||||||
if current_app.frigate_config.auth.enabled:
|
if request.app.frigate_config.auth.enabled:
|
||||||
username = request.headers.get("remote-user", type=str) or "admin"
|
# FIXME: For FastAPI the remote-user is not being populated
|
||||||
|
username = request.headers.get("remote-user") or "admin"
|
||||||
else:
|
else:
|
||||||
username = "admin"
|
username = "admin"
|
||||||
|
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
json: dict[str, any] = body or {}
|
||||||
sub = json.get("sub")
|
sub = json.get("sub")
|
||||||
|
|
||||||
if not sub:
|
if not sub:
|
||||||
return jsonify(
|
return JSONResponse(
|
||||||
{"success": False, "message": "Subscription must be provided."}
|
content={"success": False, "message": "Subscription must be provided."},
|
||||||
), 400
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
|
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
|
||||||
User.username == username
|
User.username == username
|
||||||
).execute()
|
).execute()
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Successfully saved token."}), 200
|
content=({"success": True, "message": "Successfully saved token."}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Could not find user."}), 404
|
content=({"success": False, "message": "Could not find user."}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
@ -5,23 +5,21 @@ import os
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from flask import (
|
from fastapi import APIRouter
|
||||||
Blueprint,
|
from fastapi.responses import JSONResponse
|
||||||
jsonify,
|
|
||||||
make_response,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE
|
from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||||
from frigate.models import Previews
|
from frigate.models import Previews
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PreviewBp = Blueprint("previews", __name__)
|
|
||||||
|
router = APIRouter(tags=[Tags.preview])
|
||||||
|
|
||||||
|
|
||||||
@PreviewBp.route("/preview/<camera_name>/start/<int:start_ts>/end/<int:end_ts>")
|
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}")
|
||||||
@PreviewBp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>")
|
def preview_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||||
def preview_ts(camera_name, start_ts, end_ts):
|
|
||||||
"""Get all mp4 previews relevant for time period."""
|
"""Get all mp4 previews relevant for time period."""
|
||||||
if camera_name != "all":
|
if camera_name != "all":
|
||||||
camera_clause = Previews.camera == camera_name
|
camera_clause = Previews.camera == camera_name
|
||||||
@ -62,24 +60,20 @@ def preview_ts(camera_name, start_ts, end_ts):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not clips:
|
if not clips:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify(
|
content={
|
||||||
{
|
"success": False,
|
||||||
"success": False,
|
"message": "No previews found.",
|
||||||
"message": "No previews found.",
|
},
|
||||||
}
|
status_code=404,
|
||||||
),
|
|
||||||
404,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return make_response(jsonify(clips), 200)
|
return JSONResponse(content=clips, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@PreviewBp.route("/preview/<year_month>/<int:day>/<int:hour>/<camera_name>/<tz_name>")
|
@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
|
||||||
@PreviewBp.route(
|
def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
|
||||||
"/preview/<year_month>/<float:day>/<float:hour>/<camera_name>/<tz_name>"
|
"""Get all mp4 previews relevant for time period given the timezone"""
|
||||||
)
|
|
||||||
def preview_hour(year_month, day, hour, camera_name, tz_name):
|
|
||||||
parts = year_month.split("-")
|
parts = year_month.split("-")
|
||||||
start_date = (
|
start_date = (
|
||||||
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc)
|
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc)
|
||||||
@ -92,11 +86,8 @@ def preview_hour(year_month, day, hour, camera_name, tz_name):
|
|||||||
return preview_ts(camera_name, start_ts, end_ts)
|
return preview_ts(camera_name, start_ts, end_ts)
|
||||||
|
|
||||||
|
|
||||||
@PreviewBp.route("/preview/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/frames")
|
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames")
|
||||||
@PreviewBp.route(
|
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
|
||||||
"/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/frames"
|
|
||||||
)
|
|
||||||
def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
|
|
||||||
"""Get list of cached preview frames"""
|
"""Get list of cached preview frames"""
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
file_start = f"preview_{camera_name}"
|
file_start = f"preview_{camera_name}"
|
||||||
@ -116,4 +107,7 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
|
|||||||
|
|
||||||
selected_previews.append(file)
|
selected_previews.append(file)
|
||||||
|
|
||||||
return jsonify(selected_previews)
|
return JSONResponse(
|
||||||
|
content=selected_previews,
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
@ -1,36 +1,40 @@
|
|||||||
"""Review apis."""
|
"""Review apis."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from flask import Blueprint, jsonify, make_response, request
|
from fastapi import APIRouter
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from peewee import Case, DoesNotExist, fn, operator
|
from peewee import Case, DoesNotExist, fn, operator
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.api.defs.review_query_parameters import (
|
||||||
|
ReviewActivityMotionQueryParams,
|
||||||
|
ReviewQueryParams,
|
||||||
|
ReviewSummaryQueryParams,
|
||||||
|
)
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.models import Recordings, ReviewSegment
|
from frigate.models import Recordings, ReviewSegment
|
||||||
from frigate.util.builtin import get_tz_modifiers
|
from frigate.util.builtin import get_tz_modifiers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ReviewBp = Blueprint("reviews", __name__)
|
router = APIRouter(tags=[Tags.review])
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/review")
|
@router.get("/review")
|
||||||
def review():
|
def review(params: ReviewQueryParams = Depends()):
|
||||||
cameras = request.args.get("cameras", "all")
|
cameras = params.cameras
|
||||||
labels = request.args.get("labels", "all")
|
labels = params.labels
|
||||||
zones = request.args.get("zones", "all")
|
zones = params.zones
|
||||||
reviewed = request.args.get("reviewed", type=int, default=0)
|
reviewed = params.reviewed
|
||||||
limit = request.args.get("limit", type=int, default=None)
|
limit = params.limit
|
||||||
severity = request.args.get("severity", None)
|
severity = params.severity
|
||||||
|
before = params.before
|
||||||
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
after = params.after
|
||||||
after = request.args.get(
|
|
||||||
"after", type=float, default=(datetime.now() - timedelta(hours=24)).timestamp()
|
|
||||||
)
|
|
||||||
|
|
||||||
clauses = [
|
clauses = [
|
||||||
(
|
(
|
||||||
@ -91,39 +95,38 @@ def review():
|
|||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify([r for r in review])
|
return JSONResponse(content=[r for r in review])
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/review/event/<id>")
|
@router.get("/review/event/{event_id}")
|
||||||
def get_review_from_event(id: str):
|
def get_review_from_event(event_id: str):
|
||||||
try:
|
try:
|
||||||
return model_to_dict(
|
return model_to_dict(
|
||||||
ReviewSegment.get(
|
ReviewSegment.get(
|
||||||
ReviewSegment.data["detections"].cast("text") % f'*"{id}"*'
|
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return "Review item not found", 404
|
return "Review item not found", 404
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/review/<id>")
|
@router.get("/review/{event_id}")
|
||||||
def get_review(id: str):
|
def get_review(event_id: str):
|
||||||
try:
|
try:
|
||||||
return model_to_dict(ReviewSegment.get(ReviewSegment.id == id))
|
return model_to_dict(ReviewSegment.get(ReviewSegment.id == event_id))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return "Review item not found", 404
|
return "Review item not found", 404
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/review/summary")
|
@router.get("/review/summary")
|
||||||
def review_summary():
|
def review_summary(params: ReviewSummaryQueryParams = Depends()):
|
||||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
|
||||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
day_ago = params.day_ago
|
||||||
day_ago = (datetime.now() - timedelta(hours=24)).timestamp()
|
month_ago = params.month_ago
|
||||||
month_ago = (datetime.now() - timedelta(days=30)).timestamp()
|
|
||||||
|
|
||||||
cameras = request.args.get("cameras", "all")
|
cameras = params.cameras
|
||||||
labels = request.args.get("labels", "all")
|
labels = params.labels
|
||||||
zones = request.args.get("zones", "all")
|
zones = params.zones
|
||||||
|
|
||||||
clauses = [(ReviewSegment.start_time > day_ago)]
|
clauses = [(ReviewSegment.start_time > day_ago)]
|
||||||
|
|
||||||
@ -358,53 +361,60 @@ def review_summary():
|
|||||||
for e in last_month.dicts().iterator():
|
for e in last_month.dicts().iterator():
|
||||||
data[e["day"]] = e
|
data[e["day"]] = e
|
||||||
|
|
||||||
return jsonify(data)
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/reviews/viewed", methods=("POST",))
|
@router.post("/reviews/viewed")
|
||||||
def set_multiple_reviewed():
|
def set_multiple_reviewed(body: dict = None):
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
json: dict[str, any] = body or {}
|
||||||
list_of_ids = json.get("ids", "")
|
list_of_ids = json.get("ids", "")
|
||||||
|
|
||||||
if not list_of_ids or len(list_of_ids) == 0:
|
if not list_of_ids or len(list_of_ids) == 0:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Not a valid list of ids"}), 404
|
context=({"success": False, "message": "Not a valid list of ids"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
ReviewSegment.update(has_been_reviewed=True).where(
|
ReviewSegment.update(has_been_reviewed=True).where(
|
||||||
ReviewSegment.id << list_of_ids
|
ReviewSegment.id << list_of_ids
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Reviewed multiple items"}), 200
|
content=({"success": True, "message": "Reviewed multiple items"}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/review/<id>/viewed", methods=("DELETE",))
|
@router.delete("/review/{event_id}/viewed")
|
||||||
def set_not_reviewed(id):
|
def set_not_reviewed(event_id: str):
|
||||||
try:
|
try:
|
||||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
|
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
|
content=(
|
||||||
|
{"success": False, "message": "Review " + event_id + " not found"}
|
||||||
|
),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
review.has_been_reviewed = False
|
review.has_been_reviewed = False
|
||||||
review.save()
|
review.save()
|
||||||
|
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": True, "message": "Reviewed " + id + " not viewed"}), 200
|
content=({"success": True, "message": "Reviewed " + event_id + " not viewed"}),
|
||||||
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/reviews/delete", methods=("POST",))
|
@router.post("/reviews/delete")
|
||||||
def delete_reviews():
|
def delete_reviews(body: dict = None):
|
||||||
json: dict[str, any] = request.get_json(silent=True) or {}
|
json: dict[str, any] = body or {}
|
||||||
list_of_ids = json.get("ids", "")
|
list_of_ids = json.get("ids", "")
|
||||||
|
|
||||||
if not list_of_ids or len(list_of_ids) == 0:
|
if not list_of_ids or len(list_of_ids) == 0:
|
||||||
return make_response(
|
return JSONResponse(
|
||||||
jsonify({"success": False, "message": "Not a valid list of ids"}), 404
|
content=({"success": False, "message": "Not a valid list of ids"}),
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
reviews = (
|
reviews = (
|
||||||
@ -446,18 +456,20 @@ def delete_reviews():
|
|||||||
Recordings.delete().where(Recordings.id << recording_ids).execute()
|
Recordings.delete().where(Recordings.id << recording_ids).execute()
|
||||||
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
|
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
|
||||||
|
|
||||||
return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200)
|
return JSONResponse(
|
||||||
|
content=({"success": True, "message": "Delete reviews"}), status_code=200
|
||||||
|
|
||||||
@ReviewBp.route("/review/activity/motion")
|
|
||||||
def motion_activity():
|
|
||||||
"""Get motion and audio activity."""
|
|
||||||
cameras = request.args.get("cameras", "all")
|
|
||||||
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
|
||||||
after = request.args.get(
|
|
||||||
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/review/activity/motion")
|
||||||
|
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||||
|
"""Get motion and audio activity."""
|
||||||
|
cameras = params.cameras
|
||||||
|
before = params.before
|
||||||
|
after = params.after
|
||||||
|
# get scale in seconds
|
||||||
|
scale = params.scale
|
||||||
|
|
||||||
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
||||||
clauses.append((Recordings.motion > 0))
|
clauses.append((Recordings.motion > 0))
|
||||||
|
|
||||||
@ -477,15 +489,12 @@ def motion_activity():
|
|||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|
||||||
# get scale in seconds
|
|
||||||
scale = request.args.get("scale", type=int, default=30)
|
|
||||||
|
|
||||||
# resample data using pandas to get activity on scaled basis
|
# resample data using pandas to get activity on scaled basis
|
||||||
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
|
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
logger.warning("No motion data found for the requested time range")
|
logger.warning("No motion data found for the requested time range")
|
||||||
return jsonify([])
|
return JSONResponse(content=[])
|
||||||
|
|
||||||
df = df.astype(dtype={"motion": "float32"})
|
df = df.astype(dtype={"motion": "float32"})
|
||||||
|
|
||||||
@ -520,17 +529,17 @@ def motion_activity():
|
|||||||
# change types for output
|
# change types for output
|
||||||
df.index = df.index.astype(int) // (10**9)
|
df.index = df.index.astype(int) // (10**9)
|
||||||
normalized = df.reset_index().to_dict("records")
|
normalized = df.reset_index().to_dict("records")
|
||||||
return jsonify(normalized)
|
return JSONResponse(content=normalized)
|
||||||
|
|
||||||
|
|
||||||
@ReviewBp.route("/review/activity/audio")
|
@router.get("/review/activity/audio")
|
||||||
def audio_activity():
|
def audio_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||||
"""Get motion and audio activity."""
|
"""Get motion and audio activity."""
|
||||||
cameras = request.args.get("cameras", "all")
|
cameras = params.cameras
|
||||||
before = request.args.get("before", type=float, default=datetime.now().timestamp())
|
before = params.before
|
||||||
after = request.args.get(
|
after = params.after
|
||||||
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
|
# get scale in seconds
|
||||||
)
|
scale = params.scale
|
||||||
|
|
||||||
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
||||||
|
|
||||||
@ -562,9 +571,6 @@ def audio_activity():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# get scale in seconds
|
|
||||||
scale = request.args.get("scale", type=int, default=30)
|
|
||||||
|
|
||||||
# resample data using pandas to get activity on scaled basis
|
# resample data using pandas to get activity on scaled basis
|
||||||
df = pd.DataFrame(data, columns=["start_time", "audio"])
|
df = pd.DataFrame(data, columns=["start_time", "audio"])
|
||||||
df = df.astype(dtype={"audio": "float16"})
|
df = df.astype(dtype={"audio": "float16"})
|
||||||
@ -584,4 +590,4 @@ def audio_activity():
|
|||||||
# change types for output
|
# change types for output
|
||||||
df.index = df.index.astype(int) // (10**9)
|
df.index = df.index.astype(int) // (10**9)
|
||||||
normalized = df.reset_index().to_dict("records")
|
normalized = df.reset_index().to_dict("records")
|
||||||
return jsonify(normalized)
|
return JSONResponse(content=normalized)
|
||||||
|
@ -9,12 +9,13 @@ from multiprocessing.synchronize import Event as MpEvent
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
import uvicorn
|
||||||
from peewee_migrate import Router
|
from peewee_migrate import Router
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
from frigate.api.app import create_app
|
|
||||||
from frigate.api.auth import hash_password
|
from frigate.api.auth import hash_password
|
||||||
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
from frigate.comms.config_updater import ConfigPublisher
|
from frigate.comms.config_updater import ConfigPublisher
|
||||||
from frigate.comms.dispatcher import Communicator, Dispatcher
|
from frigate.comms.dispatcher import Communicator, Dispatcher
|
||||||
from frigate.comms.inter_process import InterProcessCommunicator
|
from frigate.comms.inter_process import InterProcessCommunicator
|
||||||
@ -645,16 +646,21 @@ class FrigateApp:
|
|||||||
self.init_auth()
|
self.init_auth()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
create_app(
|
uvicorn.run(
|
||||||
self.config,
|
create_fastapi_app(
|
||||||
self.db,
|
self.config,
|
||||||
self.embeddings,
|
self.db,
|
||||||
self.detected_frames_processor,
|
self.embeddings,
|
||||||
self.storage_maintainer,
|
self.detected_frames_processor,
|
||||||
self.onvif_controller,
|
self.storage_maintainer,
|
||||||
self.external_event_processor,
|
self.onvif_controller,
|
||||||
self.stats_emitter,
|
self.external_event_processor,
|
||||||
).run(host="127.0.0.1", port=5001, debug=False, threaded=True)
|
self.stats_emitter,
|
||||||
|
),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=5001,
|
||||||
|
log_level="error",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from peewee_migrate import Router
|
from peewee_migrate import Router
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
from frigate.api.app import create_app
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings, Timeline
|
||||||
from frigate.stats.emitter import StatsEmitter
|
from frigate.stats.emitter import StatsEmitter
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
router.run()
|
router.run()
|
||||||
migrate_db.close()
|
migrate_db.close()
|
||||||
self.db = SqliteQueueDatabase(TEST_DB)
|
self.db = SqliteQueueDatabase(TEST_DB)
|
||||||
models = [Event, Recordings]
|
models = [Event, Recordings, Timeline]
|
||||||
self.db.bind(models)
|
self.db.bind(models)
|
||||||
|
|
||||||
self.minimal_config = {
|
self.minimal_config = {
|
||||||
@ -112,7 +112,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_get_event_list(self):
|
def test_get_event_list(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -125,30 +125,30 @@ class TestHttp(unittest.TestCase):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "7890.random"
|
id2 = "7890.random"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
events = client.get("/events").json
|
events = client.get("/events").json()
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
_insert_mock_event(id2)
|
_insert_mock_event(id2)
|
||||||
events = client.get("/events").json
|
events = client.get("/events").json()
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
events = client.get(
|
events = client.get(
|
||||||
"/events",
|
"/events",
|
||||||
query_string={"limit": 1},
|
params={"limit": 1},
|
||||||
).json
|
).json()
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
events = client.get(
|
events = client.get(
|
||||||
"/events",
|
"/events",
|
||||||
query_string={"has_clip": 0},
|
params={"has_clip": 0},
|
||||||
).json
|
).json()
|
||||||
assert not events
|
assert not events
|
||||||
|
|
||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -160,16 +160,16 @@ class TestHttp(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
event = client.get(f"/events/{id}").json
|
event = client.get(f"/events/{id}").json()
|
||||||
|
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event == model_to_dict(Event.get(Event.id == id))
|
assert event == model_to_dict(Event.get(Event.id == id))
|
||||||
|
|
||||||
def test_get_bad_event(self):
|
def test_get_bad_event(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -182,14 +182,14 @@ class TestHttp(unittest.TestCase):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
bad_id = "654321.other"
|
bad_id = "654321.other"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
event = client.get(f"/events/{bad_id}").json
|
event_response = client.get(f"/events/{bad_id}")
|
||||||
|
assert event_response.status_code == 404
|
||||||
assert not event
|
assert event_response.json() == "Event not found"
|
||||||
|
|
||||||
def test_delete_event(self):
|
def test_delete_event(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -201,17 +201,17 @@ class TestHttp(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
event = client.get(f"/events/{id}").json
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
client.delete(f"/events/{id}")
|
client.delete(f"/events/{id}")
|
||||||
event = client.get(f"/events/{id}").json
|
event = client.get(f"/events/{id}").json()
|
||||||
assert not event
|
assert event == "Event not found"
|
||||||
|
|
||||||
def test_event_retention(self):
|
def test_event_retention(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -223,21 +223,21 @@ class TestHttp(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
client.post(f"/events/{id}/retain")
|
client.post(f"/events/{id}/retain")
|
||||||
event = client.get(f"/events/{id}").json
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["retain_indefinitely"] is True
|
assert event["retain_indefinitely"] is True
|
||||||
client.delete(f"/events/{id}/retain")
|
client.delete(f"/events/{id}/retain")
|
||||||
event = client.get(f"/events/{id}").json
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["retain_indefinitely"] is False
|
assert event["retain_indefinitely"] is False
|
||||||
|
|
||||||
def test_event_time_filtering(self):
|
def test_event_time_filtering(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -252,30 +252,30 @@ class TestHttp(unittest.TestCase):
|
|||||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
||||||
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(morning_id, morning)
|
_insert_mock_event(morning_id, morning)
|
||||||
_insert_mock_event(evening_id, evening)
|
_insert_mock_event(evening_id, evening)
|
||||||
# both events come back
|
# both events come back
|
||||||
events = client.get("/events").json
|
events = client.get("/events").json()
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
# morning event is excluded
|
# morning event is excluded
|
||||||
events = client.get(
|
events = client.get(
|
||||||
"/events",
|
"/events",
|
||||||
query_string={"time_range": "07:00,24:00"},
|
params={"time_range": "07:00,24:00"},
|
||||||
).json
|
).json()
|
||||||
assert events
|
assert events
|
||||||
# assert len(events) == 1
|
# assert len(events) == 1
|
||||||
# evening event is excluded
|
# evening event is excluded
|
||||||
events = client.get(
|
events = client.get(
|
||||||
"/events",
|
"/events",
|
||||||
query_string={"time_range": "00:00,18:00"},
|
params={"time_range": "00:00,18:00"},
|
||||||
).json
|
).json()
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
|
|
||||||
def test_set_delete_sub_label(self):
|
def test_set_delete_sub_label(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -288,29 +288,29 @@ class TestHttp(unittest.TestCase):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
client.post(
|
new_sub_label_response = client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
data=json.dumps({"subLabel": sub_label}),
|
json={"subLabel": sub_label},
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
event = client.get(f"/events/{id}").json
|
assert new_sub_label_response.status_code == 200
|
||||||
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["sub_label"] == sub_label
|
assert event["sub_label"] == sub_label
|
||||||
client.post(
|
empty_sub_label_response = client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
data=json.dumps({"subLabel": ""}),
|
json={"subLabel": ""},
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
event = client.get(f"/events/{id}").json
|
assert empty_sub_label_response.status_code == 200
|
||||||
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["sub_label"] == ""
|
assert event["sub_label"] == ""
|
||||||
|
|
||||||
def test_sub_label_list(self):
|
def test_sub_label_list(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -323,19 +323,18 @@ class TestHttp(unittest.TestCase):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
client.post(
|
client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
data=json.dumps({"subLabel": sub_label}),
|
json={"subLabel": sub_label},
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
sub_labels = client.get("/sub_labels").json
|
sub_labels = client.get("/sub_labels").json()
|
||||||
assert sub_labels
|
assert sub_labels
|
||||||
assert sub_labels == [sub_label]
|
assert sub_labels == [sub_label]
|
||||||
|
|
||||||
def test_config(self):
|
def test_config(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -346,13 +345,13 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
config = client.get("/config").json
|
config = client.get("/config").json()
|
||||||
assert config
|
assert config
|
||||||
assert config["cameras"]["front_door"]
|
assert config["cameras"]["front_door"]
|
||||||
|
|
||||||
def test_recordings(self):
|
def test_recordings(self):
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -364,16 +363,18 @@ class TestHttp(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_recording(id)
|
_insert_mock_recording(id)
|
||||||
recording = client.get("/front_door/recordings").json
|
response = client.get("/front_door/recordings")
|
||||||
|
assert response.status_code == 200
|
||||||
|
recording = response.json()
|
||||||
assert recording
|
assert recording
|
||||||
assert recording[0]["id"] == id
|
assert recording[0]["id"] == id
|
||||||
|
|
||||||
def test_stats(self):
|
def test_stats(self):
|
||||||
stats = Mock(spec=StatsEmitter)
|
stats = Mock(spec=StatsEmitter)
|
||||||
stats.get_latest_stats.return_value = self.test_stats
|
stats.get_latest_stats.return_value = self.test_stats
|
||||||
app = create_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -384,8 +385,8 @@ class TestHttp(unittest.TestCase):
|
|||||||
stats,
|
stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
with app.test_client() as client:
|
with TestClient(app) as client:
|
||||||
full_stats = client.get("/stats").json
|
full_stats = client.get("/stats").json()
|
||||||
assert full_stats == self.test_stats
|
assert full_stats == self.test_stats
|
||||||
|
|
||||||
|
|
||||||
@ -418,8 +419,8 @@ def _insert_mock_recording(id: str) -> Event:
|
|||||||
id=id,
|
id=id,
|
||||||
camera="front_door",
|
camera="front_door",
|
||||||
path=f"/recordings/{id}",
|
path=f"/recordings/{id}",
|
||||||
start_time=datetime.datetime.now().timestamp() - 50,
|
start_time=datetime.datetime.now().timestamp() - 60,
|
||||||
end_time=datetime.datetime.now().timestamp() - 60,
|
end_time=datetime.datetime.now().timestamp() - 50,
|
||||||
duration=10,
|
duration=10,
|
||||||
motion=True,
|
motion=True,
|
||||||
objects=True,
|
objects=True,
|
||||||
|
@ -54,7 +54,7 @@ export default function CameraImage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSrc = `${apiHost}api/${name}/latest.webp?h=${requestHeight}${
|
const newSrc = `${apiHost}api/${name}/latest.webp?height=${requestHeight}${
|
||||||
searchParams ? `&${searchParams}` : ""
|
searchParams ? `&${searchParams}` : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ export default function CameraImage({
|
|||||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
img.src = `${apiHost}api/${name}/latest.webp?h=${scaledHeight}${
|
img.src = `${apiHost}api/${name}/latest.webp?height=${scaledHeight}${
|
||||||
searchParams ? `&${searchParams}` : ""
|
searchParams ? `&${searchParams}` : ""
|
||||||
}`;
|
}`;
|
||||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||||
|
Loading…
Reference in New Issue
Block a user