implement case management for export apis (#21295)

This commit is contained in:
Nicolas Mowen 2025-12-15 08:54:13 -07:00 committed by GitHub
parent b962c95725
commit 5cced22f65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 114 additions and 21 deletions

View File

@ -23,3 +23,13 @@ class ExportCaseUpdateBody(BaseModel):
description: Optional[str] = Field( description: Optional[str] = Field(
default=None, description="Updated description of the export case" 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",
)

View File

@ -1,4 +1,4 @@
from typing import Union from typing import Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
@ -18,3 +18,9 @@ class ExportRecordingsBody(BaseModel):
) )
name: str = Field(title="Friendly name", default=None, max_length=256) name: str = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None 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",
)

View File

@ -4,10 +4,10 @@ import logging
import random import random
import string import string
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Optional
import psutil import psutil
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filepath from pathvalidate import sanitize_filepath
from peewee import DoesNotExist from peewee import DoesNotExist
@ -20,6 +20,7 @@ from frigate.api.auth import (
require_role, require_role,
) )
from frigate.api.defs.request.export_case_body import ( from frigate.api.defs.request.export_case_body import (
ExportCaseAssignBody,
ExportCaseCreateBody, ExportCaseCreateBody,
ExportCaseUpdateBody, ExportCaseUpdateBody,
) )
@ -60,14 +61,32 @@ router = APIRouter(tags=[Tags.export])
) )
def get_exports( def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), 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 = ( query = Export.select().where(Export.camera << allowed_cameras)
Export.select()
.where(Export.camera << allowed_cameras) if export_case_id is not None:
.order_by(Export.date.desc()) if export_case_id == "unassigned":
.dicts() query = query.where(Export.export_case.is_null(True))
.iterator() 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]) 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( @router.post(
"/export/{camera_name}/start/{start_time}/end/{end_time}", "/export/{camera_name}/start/{start_time}/end/{end_time}",
response_model=StartExportResponse, response_model=StartExportResponse,
@ -205,6 +266,16 @@ def export_recording(
friendly_name = body.name friendly_name = body.name
existing_image = sanitize_filepath(body.image_path) if body.image_path else None 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 # Ensure that existing_image is a valid path
if existing_image and not existing_image.startswith(CLIPS_DIR): if existing_image and not existing_image.startswith(CLIPS_DIR):
return JSONResponse( return JSONResponse(
@ -273,6 +344,7 @@ def export_recording(
if playback_source in PlaybackSourceEnum.__members__.values() if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings else PlaybackSourceEnum.recordings
), ),
export_case_id,
) )
exporter.start() exporter.start()
return JSONResponse( return JSONResponse(

View File

@ -64,6 +64,7 @@ class RecordingExporter(threading.Thread):
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum, playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum, playback_source: PlaybackSourceEnum,
export_case_id: Optional[str] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.config = config self.config = config
@ -75,6 +76,7 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time self.end_time = end_time
self.playback_factor = playback_factor self.playback_factor = playback_factor
self.playback_source = playback_source self.playback_source = playback_source
self.export_case_id = export_case_id
# ensure export thumb dir # ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
@ -348,8 +350,7 @@ class RecordingExporter(threading.Thread):
video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4" 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) thumb_path = self.save_thumbnail(self.export_id)
Export.insert( export_values = {
{
Export.id: self.export_id, Export.id: self.export_id,
Export.camera: self.camera, Export.camera: self.camera,
Export.name: export_name, Export.name: export_name,
@ -358,7 +359,11 @@ class RecordingExporter(threading.Thread):
Export.thumb_path: thumb_path, Export.thumb_path: thumb_path,
Export.in_progress: True, Export.in_progress: True,
} }
).execute()
if self.export_case_id is not None:
export_values[Export.export_case] = self.export_case_id
Export.insert(export_values).execute()
try: try:
if self.playback_source == PlaybackSourceEnum.recordings: if self.playback_source == PlaybackSourceEnum.recordings: