From 5cced22f65373505ab13a4939e171110c8b6cbac Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 15 Dec 2025 08:54:13 -0700 Subject: [PATCH] implement case management for export apis (#21295) --- frigate/api/defs/request/export_case_body.py | 10 +++ .../defs/request/export_recordings_body.py | 8 +- frigate/api/export.py | 90 +++++++++++++++++-- frigate/record/export.py | 27 +++--- 4 files changed, 114 insertions(+), 21 deletions(-) diff --git a/frigate/api/defs/request/export_case_body.py b/frigate/api/defs/request/export_case_body.py index 66cba58ea..35cd8ff7f 100644 --- a/frigate/api/defs/request/export_case_body.py +++ b/frigate/api/defs/request/export_case_body.py @@ -23,3 +23,13 @@ class ExportCaseUpdateBody(BaseModel): description: Optional[str] = Field( default=None, description="Updated description of the export case" ) + + +class ExportCaseAssignBody(BaseModel): + """Request body for assigning or unassigning an export to a case.""" + + export_case_id: Optional[str] = Field( + default=None, + max_length=30, + description="Case ID to assign to the export, or null to unassign", + ) diff --git a/frigate/api/defs/request/export_recordings_body.py b/frigate/api/defs/request/export_recordings_body.py index eb6c15155..841466982 100644 --- a/frigate/api/defs/request/export_recordings_body.py +++ b/frigate/api/defs/request/export_recordings_body.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema @@ -18,3 +18,9 @@ class ExportRecordingsBody(BaseModel): ) name: str = Field(title="Friendly name", default=None, max_length=256) image_path: Union[str, SkipJsonSchema[None]] = None + export_case_id: Optional[str] = Field( + default=None, + title="Export case ID", + max_length=30, + description="ID of the export case to assign this export to", + ) diff --git a/frigate/api/export.py b/frigate/api/export.py index a6051ecb9..812a1b4b2 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -4,10 +4,10 @@ import logging import random import string from pathlib import Path -from typing import List +from typing import List, Optional import psutil -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse from pathvalidate import sanitize_filepath from peewee import DoesNotExist @@ -20,6 +20,7 @@ from frigate.api.auth import ( require_role, ) from frigate.api.defs.request.export_case_body import ( + ExportCaseAssignBody, ExportCaseCreateBody, ExportCaseUpdateBody, ) @@ -60,14 +61,32 @@ router = APIRouter(tags=[Tags.export]) ) def get_exports( allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + export_case_id: Optional[str] = None, + camera: Optional[List[str]] = Query(default=None), + start_date: Optional[float] = None, + end_date: Optional[float] = None, ): - exports = ( - Export.select() - .where(Export.camera << allowed_cameras) - .order_by(Export.date.desc()) - .dicts() - .iterator() - ) + query = Export.select().where(Export.camera << allowed_cameras) + + if export_case_id is not None: + if export_case_id == "unassigned": + query = query.where(Export.export_case.is_null(True)) + else: + query = query.where(Export.export_case == export_case_id) + + if camera: + filtered_cameras = [c for c in camera if c in allowed_cameras] + if not filtered_cameras: + return JSONResponse(content=[]) + query = query.where(Export.camera << filtered_cameras) + + if start_date is not None: + query = query.where(Export.date >= start_date) + + if end_date is not None: + query = query.where(Export.date <= end_date) + + exports = query.order_by(Export.date.desc()).dicts().iterator() return JSONResponse(content=[e for e in exports]) @@ -175,6 +194,48 @@ def delete_export_case(case_id: str): ) +@router.patch( + "/export/{export_id}/case", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Assign export to case", + description=( + "Assigns an export to a case, or unassigns it if export_case_id is null." + ), +) +async def assign_export_case( + export_id: str, + body: ExportCaseAssignBody, + request: Request, +): + try: + export: Export = Export.get(Export.id == export_id) + await require_camera_access(export.camera, request=request) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export not found."}, + status_code=404, + ) + + if body.export_case_id is not None: + try: + ExportCase.get(ExportCase.id == body.export_case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found."}, + status_code=404, + ) + export.export_case = body.export_case_id + else: + export.export_case = None + + export.save() + + return JSONResponse( + content={"success": True, "message": "Successfully updated export case."} + ) + + @router.post( "/export/{camera_name}/start/{start_time}/end/{end_time}", response_model=StartExportResponse, @@ -205,6 +266,16 @@ def export_recording( friendly_name = body.name existing_image = sanitize_filepath(body.image_path) if body.image_path else None + export_case_id = body.export_case_id + if export_case_id is not None: + try: + ExportCase.get(ExportCase.id == export_case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + # Ensure that existing_image is a valid path if existing_image and not existing_image.startswith(CLIPS_DIR): return JSONResponse( @@ -273,6 +344,7 @@ def export_recording( if playback_source in PlaybackSourceEnum.__members__.values() else PlaybackSourceEnum.recordings ), + export_case_id, ) exporter.start() return JSONResponse( diff --git a/frigate/record/export.py b/frigate/record/export.py index d4b49bb4b..9a2a77ebf 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -64,6 +64,7 @@ class RecordingExporter(threading.Thread): end_time: int, playback_factor: PlaybackFactorEnum, playback_source: PlaybackSourceEnum, + export_case_id: Optional[str] = None, ) -> None: super().__init__() self.config = config @@ -75,6 +76,7 @@ class RecordingExporter(threading.Thread): self.end_time = end_time self.playback_factor = playback_factor self.playback_source = playback_source + self.export_case_id = export_case_id # ensure export thumb dir Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) @@ -348,17 +350,20 @@ class RecordingExporter(threading.Thread): video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4" thumb_path = self.save_thumbnail(self.export_id) - Export.insert( - { - Export.id: self.export_id, - Export.camera: self.camera, - Export.name: export_name, - Export.date: self.start_time, - Export.video_path: video_path, - Export.thumb_path: thumb_path, - Export.in_progress: True, - } - ).execute() + export_values = { + Export.id: self.export_id, + Export.camera: self.camera, + Export.name: export_name, + Export.date: self.start_time, + Export.video_path: video_path, + Export.thumb_path: thumb_path, + Export.in_progress: True, + } + + if self.export_case_id is not None: + export_values[Export.export_case] = self.export_case_id + + Export.insert(export_values).execute() try: if self.playback_source == PlaybackSourceEnum.recordings: