From 1b601b8c3517f710ab3b937fa113917cd1555804 Mon Sep 17 00:00:00 2001 From: Rui Alves Date: Mon, 16 Sep 2024 10:44:03 +0100 Subject: [PATCH] Convert events endpoints to FastAPI --- frigate/api/app.py | 2 - frigate/api/defs/events_query_parameters.py | 52 +++ frigate/api/defs/tags.py | 1 + frigate/api/event.py | 491 +++++++++++--------- frigate/api/fastapi_app.py | 7 + frigate/api/review.py | 4 +- frigate/app.py | 2 + frigate/test/test_http.py | 1 + 8 files changed, 330 insertions(+), 230 deletions(-) create mode 100644 frigate/api/defs/events_query_parameters.py diff --git a/frigate/api/app.py b/frigate/api/app.py index 1bde7abd8..c4421f743 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -21,7 +21,6 @@ from werkzeug.middleware.proxy_fix import ProxyFix from frigate.api.auth import AuthBp, get_jwt_secret, limiter from frigate.api.defs.tags import Tags -from frigate.api.event import EventBp from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR from frigate.embeddings import EmbeddingsContext @@ -43,7 +42,6 @@ logger = logging.getLogger(__name__) bp = Blueprint("frigate", __name__) -bp.register_blueprint(EventBp) bp.register_blueprint(AuthBp) router = APIRouter() diff --git a/frigate/api/defs/events_query_parameters.py b/frigate/api/defs/events_query_parameters.py new file mode 100644 index 000000000..4d70230f4 --- /dev/null +++ b/frigate/api/defs/events_query_parameters.py @@ -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 + 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 diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py index ee1ae9a64..037e3d44a 100644 --- a/frigate/api/defs/tags.py +++ b/frigate/api/defs/tags.py @@ -9,3 +9,4 @@ class Tags(Enum): notifications = "Notifications" review = "Review" export = "Export" + events = "Events" diff --git a/frigate/api/event.py b/frigate/api/event.py index fd3c4ad0b..a3b193575 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -11,17 +11,20 @@ from urllib.parse import unquote import cv2 import numpy as np -from flask import ( - Blueprint, - current_app, - jsonify, - make_response, - request, -) +from fastapi import APIRouter, Request +from fastapi.params import Depends +from fastapi.responses import JSONResponse from peewee import JOIN, DoesNotExist, fn, operator from PIL import Image from playhouse.shortcuts import model_to_dict +from frigate.api.defs.events_query_parameters import ( + DEFAULT_TIME_RANGE, + EventsQueryParams, + EventsSearchQueryParams, + EventsSummaryQueryParams, +) +from frigate.api.defs.tags import Tags from frigate.const import ( CLIPS_DIR, ) @@ -33,57 +36,55 @@ from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) -EventBp = Blueprint("events", __name__) - -DEFAULT_TIME_RANGE = "00:00,24:00" +router = APIRouter(tags=[Tags.events]) -@EventBp.route("/events") -def events(): - camera = request.args.get("camera", "all") - cameras = request.args.get("cameras", "all") +@router.get("/events") +def events(params: EventsQueryParams = Depends()): + camera = params.camera + cameras = params.cameras # handle old camera arg if cameras == "all" and camera != "all": cameras = camera - label = unquote(request.args.get("label", "all")) - labels = request.args.get("labels", "all") + label = unquote(params.label) + labels = params.labels # handle old label arg if labels == "all" and label != "all": labels = label - sub_label = request.args.get("sub_label", "all") - sub_labels = request.args.get("sub_labels", "all") + sub_label = params.sub_label + sub_labels = params.sub_labels # handle old sub_label arg if sub_labels == "all" and sub_label != "all": sub_labels = sub_label - zone = request.args.get("zone", "all") - zones = request.args.get("zones", "all") + zone = params.zone + zones = params.zones # handle old label arg if zones == "all" and zone != "all": zones = zone - limit = request.args.get("limit", 100) - after = request.args.get("after", type=float) - before = request.args.get("before", type=float) - time_range = request.args.get("time_range", DEFAULT_TIME_RANGE) - has_clip = request.args.get("has_clip", type=int) - has_snapshot = request.args.get("has_snapshot", type=int) - in_progress = request.args.get("in_progress", type=int) - include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) - favorites = request.args.get("favorites", type=int) - min_score = request.args.get("min_score", type=float) - max_score = request.args.get("max_score", type=float) - is_submitted = request.args.get("is_submitted", type=int) - min_length = request.args.get("min_length", type=float) - max_length = request.args.get("max_length", type=float) + limit = params.limit + after = params.after + before = params.before + time_range = params.time_range + has_clip = params.has_clip + has_snapshot = params.has_snapshot + in_progress = params.in_progress + include_thumbnails = params.include_thumbnails + favorites = params.favorites + min_score = params.min_score + max_score = params.max_score + is_submitted = params.is_submitted + min_length = params.min_length + max_length = params.max_length - sort = request.args.get("sort", type=str) + sort = params.sort clauses = [] @@ -163,7 +164,7 @@ def events(): if time_range != DEFAULT_TIME_RANGE: # get timezone arg to ensure browser times are used - tz_name = request.args.get("timezone", default="utc", type=str) + tz_name = params.timezone hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name) times = time_range.split(",") @@ -248,13 +249,11 @@ def events(): .iterator() ) - return jsonify(list(events)) + return JSONResponse(content=list(events)) -@EventBp.route("/events/explore") -def events_explore(): - limit = request.args.get("limit", 10, type=int) - +@router.get("/events/explore") +def events_explore(limit: int = 10): subquery = Event.select( Event.id, Event.camera, @@ -316,66 +315,65 @@ def events_explore(): for event in events ] - return jsonify(processed_events) + return JSONResponse(content=processed_events) -@EventBp.route("/event_ids") -def event_ids(): - idString = request.args.get("ids") - ids = idString.split(",") +@router.get("/event_ids") +def event_ids(ids: str): + ids = ids.split(",") if not ids: - return make_response( - jsonify({"success": False, "message": "Valid list of ids must be sent"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Valid list of ids must be sent"}), + status_code=400, ) try: events = Event.select().where(Event.id << ids).dicts().iterator() - return jsonify(list(events)) + return JSONResponse(list(events)) except Exception: - return make_response( - jsonify({"success": False, "message": "Events not found"}), 400 + return JSONResponse( + content=({"success": False, "message": "Events not found"}), status_code=400 ) -@EventBp.route("/events/search") -def events_search(): - query = request.args.get("query", type=str) - search_type = request.args.get("search_type", "thumbnail,description", type=str) - include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) - limit = request.args.get("limit", 50, type=int) +@router.get("/events/search") +def events_search(request: Request, params: EventsSearchQueryParams = Depends()): + query = params.query + search_type = params.search_type + include_thumbnails = params.include_thumbnails + limit = params.limit # Filters - cameras = request.args.get("cameras", "all", type=str) - labels = request.args.get("labels", "all", type=str) - zones = request.args.get("zones", "all", type=str) - after = request.args.get("after", type=float) - before = request.args.get("before", type=float) + cameras = params.cameras + labels = params.labels + zones = params.zones + after = params.after + before = params.before if not query: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "A search query must be supplied", } ), - 400, + status_code=400, ) - if not current_app.frigate_config.semantic_search.enabled: - return make_response( - jsonify( + if not request.app.frigate_config.semantic_search.enabled: + return JSONResponse( + content=( { "success": False, "message": "Semantic search is not enabled", } ), - 400, + status_code=400, ) - context: EmbeddingsContext = current_app.embeddings + context: EmbeddingsContext = request.app.embeddings selected_columns = [ Event.id, @@ -434,14 +432,14 @@ def events_search(): try: search_event: Event = Event.get(Event.id == query) except DoesNotExist: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Event not found", } ), - 404, + status_code=404, ) thumbnail = base64.b64decode(search_event.thumbnail) img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB")) @@ -501,7 +499,7 @@ def events_search(): } if not results: - return jsonify([]) + return JSONResponse(content=[]) # Get the event data events = ( @@ -534,15 +532,15 @@ def events_search(): ] events = sorted(events, key=lambda x: x["search_distance"])[:limit] - return jsonify(events) + return JSONResponse(content=events) -@EventBp.route("/events/summary") -def events_summary(): - tz_name = request.args.get("timezone", default="utc", type=str) +@router.get("/events/summary") +def events_summary(params: EventsSummaryQueryParams = Depends()): + tz_name = params.timezone hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) - has_clip = request.args.get("has_clip", type=int) - has_snapshot = request.args.get("has_snapshot", type=int) + has_clip = params.has_clip + has_snapshot = params.has_snapshot clauses = [] @@ -579,47 +577,49 @@ def events_summary(): ) ) - return jsonify([e for e in groups.dicts()]) + return JSONResponse(content=[e for e in groups.dicts()]) -@EventBp.route("/events/", methods=("GET",)) -def event(id): +@router.get("/events/{event_id}") +def event(event_id: str): try: - return model_to_dict(Event.get(Event.id == id)) + return model_to_dict(Event.get(Event.id == event_id)) except DoesNotExist: return "Event not found", 404 -@EventBp.route("/events//retain", methods=("POST",)) -def set_retain(id): +@router.post("/events/{event_id}/retain") +def set_retain(event_id: str): try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) event.retain_indefinitely = True event.save() - return make_response( - jsonify({"success": True, "message": "Event " + id + " retained"}), 200 + return JSONResponse( + content=({"success": True, "message": "Event " + event_id + " retained"}), + status_code=200, ) -@EventBp.route("/events//plus", methods=("POST",)) -def send_to_plus(id): - if not current_app.plus_api.is_active(): +@router.post("/events/{event_id}/plus") +def send_to_plus(request: Request, event_id: str): + if not request.app.plus_api.is_active(): message = "PLUS_API_KEY environment variable is not set" logger.error(message) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": message, } ), - 400, + status_code=400, ) include_annotation = ( @@ -627,11 +627,13 @@ def send_to_plus(id): ) try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - message = f"Event {id} not found" + message = f"Event {event_id} not found" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 404) + return JSONResponse( + content=({"success": False, "message": message}), status_code=404 + ) # events from before the conversion to relative dimensions cant include annotations if event.data.get("box") is None: @@ -639,20 +641,22 @@ def send_to_plus(id): if event.end_time is None: logger.error(f"Unable to load clean png for in-progress event: {event.id}") - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Unable to load clean png for in-progress event", } ), - 400, + status_code=400, ) if event.plus_id: message = "Already submitted to plus" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 400) + return JSONResponse( + content=({"success": False, "message": message}), status_code=400 + ) # load clean.png try: @@ -660,29 +664,29 @@ def send_to_plus(id): image = cv2.imread(os.path.join(CLIPS_DIR, filename)) except Exception: logger.error(f"Unable to load clean png for event: {event.id}") - return make_response( - jsonify( + return JSONResponse( + content=( {"success": False, "message": "Unable to load clean png for event"} ), - 400, + status_code=400, ) if image is None or image.size == 0: logger.error(f"Unable to load clean png for event: {event.id}") - return make_response( - jsonify( + return JSONResponse( + content=( {"success": False, "message": "Unable to load clean png for event"} ), - 400, + status_code=400, ) try: - plus_id = current_app.plus_api.upload_image(image, event.camera) + plus_id = request.app.plus_api.upload_image(image, event.camera) except Exception as ex: logger.exception(ex) - return make_response( - jsonify({"success": False, "message": "Error uploading image"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Error uploading image"}), + status_code=400, ) # store image id in the database @@ -693,7 +697,7 @@ def send_to_plus(id): box = event.data["box"] try: - current_app.plus_api.add_annotation( + request.app.plus_api.add_annotation( event.plus_id, box, event.label, @@ -701,59 +705,67 @@ def send_to_plus(id): except ValueError: message = "Error uploading annotation, unsupported label provided." logger.error(message) - return make_response( - jsonify({"success": False, "message": message}), - 400, + return JSONResponse( + content=({"success": False, "message": message}), + status_code=400, ) except Exception as ex: logger.exception(ex) - return make_response( - jsonify({"success": False, "message": "Error uploading annotation"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Error uploading annotation"}), + status_code=400, ) - return make_response(jsonify({"success": True, "plus_id": plus_id}), 200) + return JSONResponse( + content=({"success": True, "plus_id": plus_id}), status_code=200 + ) -@EventBp.route("/events//false_positive", methods=("PUT",)) -def false_positive(id): - if not current_app.plus_api.is_active(): +@router.put("/events/{event_id}/false_positive") +def false_positive(request: Request, event_id: str): + if not request.app.plus_api.is_active(): message = "PLUS_API_KEY environment variable is not set" logger.error(message) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": message, } ), - 400, + status_code=400, ) try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - message = f"Event {id} not found" + message = f"Event {event_id} not found" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 404) + return JSONResponse( + content=({"success": False, "message": message}), status_code=404 + ) # events from before the conversion to relative dimensions cant include annotations if event.data.get("box") is None: message = "Events prior to 0.13 cannot be submitted as false positives" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 400) + return JSONResponse( + content=({"success": False, "message": message}), status_code=400 + ) if event.false_positive: message = "False positive already submitted to Frigate+" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 400) + return JSONResponse( + content=({"success": False, "message": message}), status_code=400 + ) if not event.plus_id: - plus_response = send_to_plus(id) + plus_response = send_to_plus(event_id) if plus_response.status_code != 200: return plus_response # need to refetch the event now that it has a plus_id - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) region = event.data["region"] box = event.data["box"] @@ -766,7 +778,7 @@ def false_positive(id): ) try: - current_app.plus_api.add_false_positive( + request.app.plus_api.add_false_positive( event.plus_id, region, box, @@ -779,92 +791,101 @@ def false_positive(id): except ValueError: message = "Error uploading false positive, unsupported label provided." logger.error(message) - return make_response( - jsonify({"success": False, "message": message}), - 400, + return JSONResponse( + content=({"success": False, "message": message}), + status_code=400, ) except Exception as ex: logger.exception(ex) - return make_response( - jsonify({"success": False, "message": "Error uploading false positive"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Error uploading false positive"}), + status_code=400, ) event.false_positive = True event.save() - return make_response(jsonify({"success": True, "plus_id": event.plus_id}), 200) + return JSONResponse( + content=({"success": True, "plus_id": event.plus_id}), status_code=200 + ) -@EventBp.route("/events//retain", methods=("DELETE",)) -def delete_retain(id): +@router.delete("/events/{event_id}/retain") +def delete_retain(event_id: str): try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) event.retain_indefinitely = False event.save() - return make_response( - jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200 + return JSONResponse( + content=({"success": True, "message": "Event " + event_id + " un-retained"}), + status_code=200, ) -@EventBp.route("/events//sub_label", methods=("POST",)) -def set_sub_label(id): +@router.post("/events/{event_id}/sub_label") +def set_sub_label( + request: Request, + event_id: str, + body: dict = None, +): try: - event: Event = Event.get(Event.id == id) + event: Event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) - json: dict[str, any] = request.get_json(silent=True) or {} + json: dict[str, any] = body or {} new_sub_label = json.get("subLabel") new_score = json.get("subLabelScore") if new_sub_label is None: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "A sub label must be supplied", } ), - 400, + status_code=400, ) if new_sub_label and len(new_sub_label) > 100: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": new_sub_label + " exceeds the 100 character limit for sub_label", } ), - 400, + status_code=400, ) if new_score is not None and (new_score > 1.0 or new_score < 0): - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": new_score + " does not fit within the expected bounds 0 <= score <= 1.0", } ), - 400, + status_code=400, ) if not event.end_time: # update tracked object tracked_obj: TrackedObject = ( - current_app.detected_frames_processor.camera_states[ + request.app.detected_frames_processor.camera_states[ event.camera ].tracked_objects.get(event.id) ) @@ -875,7 +896,7 @@ def set_sub_label(id): # update timeline items Timeline.update( data=Timeline.data.update({"sub_label": (new_sub_label, new_score)}) - ).where(Timeline.source_id == id).execute() + ).where(Timeline.source_id == event_id).execute() event.sub_label = new_sub_label @@ -885,70 +906,79 @@ def set_sub_label(id): event.data = data event.save() - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, - "message": "Event " + id + " sub label set to " + new_sub_label, + "message": "Event " + event_id + " sub label set to " + new_sub_label, } ), - 200, + status_code=200, ) -@EventBp.route("/events//description", methods=("POST",)) -def set_description(id): +@router.post("/events/{event_id}/description") +def set_description( + request: Request, + event_id: str, + body: dict = None, +): try: - event: Event = Event.get(Event.id == id) + event: Event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) - json: dict[str, any] = request.get_json(silent=True) or {} + json: dict[str, any] = body or {} new_description = json.get("description") if new_description is None or len(new_description) == 0: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "description cannot be empty", } ), - 400, + status_code=400, ) event.data["description"] = new_description event.save() # If semantic search is enabled, update the index - if current_app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = current_app.embeddings + if request.app.frigate_config.semantic_search.enabled: + context: EmbeddingsContext = request.app.embeddings context.embeddings.description.upsert( documents=[new_description], metadatas=[get_metadata(event)], - ids=[id], + ids=[event_id], ) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, - "message": "Event " + id + " description set to " + new_description, + "message": "Event " + + event_id + + " description set to " + + new_description, } ), - 200, + status_code=200, ) -@EventBp.route("/events/", methods=("DELETE",)) -def delete_event(id): +@router.delete("/events/{event_id}") +def delete_event(request: Request, event_id: str): try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) media_name = f"{event.camera}-{event.id}" @@ -962,40 +992,48 @@ def delete_event(id): media.unlink(missing_ok=True) event.delete_instance() - Timeline.delete().where(Timeline.source_id == id).execute() + Timeline.delete().where(Timeline.source_id == event_id).execute() # If semantic search is enabled, update the index - if current_app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = current_app.embeddings - context.embeddings.thumbnail.delete(ids=[id]) - context.embeddings.description.delete(ids=[id]) - return make_response( - jsonify({"success": True, "message": "Event " + id + " deleted"}), 200 + if request.app.frigate_config.semantic_search.enabled: + context: EmbeddingsContext = request.app.embeddings + context.embeddings.thumbnail.delete(ids=[event_id]) + context.embeddings.description.delete(ids=[event_id]) + return JSONResponse( + content=({"success": True, "message": "Event " + event_id + " deleted"}), + status_code=200, ) -@EventBp.route("/events//