diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 90fa505ec..60621ff4e 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2724,6 +2724,135 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" + /exports/batch: + post: + tags: + - Export + summary: Start recording export batch + description: >- + Starts recording exports for a batch of items, each with its own camera + and time range. Optionally assigns them to a new or existing export case. + When neither export_case_id nor new_case_name is provided, exports are + added as uncategorized. Attaching to an existing case is admin-only. + operationId: export_recordings_batch_exports_batch_post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BatchExportBody" + responses: + "202": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/BatchExportResponse" + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" + /exports/delete: + post: + tags: + - Export + summary: Bulk delete exports + description: >- + Deletes one or more exports by ID. All IDs must exist and none can be + in-progress. Admin-only. + operationId: bulk_delete_exports_exports_delete_post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExportBulkDeleteBody" + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "400": + description: Bad Request - one or more exports are in-progress + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "404": + description: Not Found - one or more export IDs do not exist + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" + /exports/reassign: + post: + tags: + - Export + summary: Bulk reassign exports to a case + description: >- + Assigns or unassigns one or more exports to/from a case. All IDs must + exist. Pass export_case_id as null to unassign (move to uncategorized). + Admin-only. + operationId: bulk_reassign_exports_exports_reassign_post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExportBulkReassignBody" + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "404": + description: Not Found - one or more export IDs or the target case do not exist + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" /cases: get: tags: @@ -2853,39 +2982,6 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" - "/export/{export_id}/case": - patch: - tags: - - Export - summary: Assign export to case - description: "Assigns an export to a case, or unassigns it if export_case_id is null." - operationId: assign_export_case_export__export_id__case_patch - parameters: - - name: export_id - in: path - required: true - schema: - type: string - title: Export Id - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExportCaseAssignBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" "/export/{camera_name}/start/{start_time}/end/{end_time}": post: tags: @@ -2973,32 +3069,6 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" - "/export/{event_id}": - delete: - tags: - - Export - summary: Delete export - operationId: export_delete_export__event_id__delete - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" "/export/custom/{camera_name}/start/{start_time}/end/{end_time}": post: tags: @@ -6501,6 +6571,149 @@ components: required: - recognizedLicensePlate title: EventsLPRBody + BatchExportBody: + properties: + items: + items: + $ref: "#/components/schemas/BatchExportItem" + type: array + minItems: 1 + maxItems: 50 + title: Items + description: List of export items. Each item has its own camera and time range. + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: "null" + title: Export case ID + description: Existing export case ID to assign all exports to. Attaching to an existing case is temporarily admin-only until case-level ACLs exist. + new_case_name: + anyOf: + - type: string + maxLength: 100 + - type: "null" + title: New case name + description: Name of a new export case to create when export_case_id is omitted + new_case_description: + anyOf: + - type: string + - type: "null" + title: New case description + description: Optional description for a newly created export case + type: object + required: + - items + title: BatchExportBody + BatchExportItem: + properties: + camera: + type: string + title: Camera name + start_time: + type: number + title: Start time + end_time: + type: number + title: End time + image_path: + anyOf: + - type: string + - type: "null" + title: Existing thumbnail path + description: Optional existing image to use as the export thumbnail + friendly_name: + anyOf: + - type: string + maxLength: 256 + - type: "null" + title: Friendly name + description: Optional friendly name for this specific export item + client_item_id: + anyOf: + - type: string + maxLength: 128 + - type: "null" + title: Client item ID + description: Optional opaque client identifier echoed back in results + type: object + required: + - camera + - start_time + - end_time + title: BatchExportItem + BatchExportResponse: + properties: + export_case_id: + anyOf: + - type: string + - type: "null" + title: Export Case Id + description: Export case ID associated with the batch + export_ids: + items: + type: string + type: array + title: Export Ids + description: Export IDs successfully queued + results: + items: + $ref: "#/components/schemas/BatchExportResultModel" + type: array + title: Results + description: Per-item batch export results + type: object + required: + - export_ids + - results + title: BatchExportResponse + description: Response model for starting an export batch. + BatchExportResultModel: + properties: + camera: + type: string + title: Camera + description: Camera name for this export attempt + export_id: + anyOf: + - type: string + - type: "null" + title: Export Id + description: The export ID when the export was successfully queued + success: + type: boolean + title: Success + description: Whether the export was successfully queued + status: + anyOf: + - type: string + - type: "null" + title: Status + description: Queue status for this camera export + error: + anyOf: + - type: string + - type: "null" + title: Error + description: Validation or queueing error for this item, if any + item_index: + anyOf: + - type: integer + - type: "null" + title: Item Index + description: Zero-based index of this result within the request items list + client_item_id: + anyOf: + - type: string + - type: "null" + title: Client Item Id + description: Opaque client-supplied item identifier echoed from the request + type: object + required: + - camera + - success + title: BatchExportResultModel + description: Per-item result for a batch export request. EventsSubLabelBody: properties: subLabel: @@ -6523,18 +6736,41 @@ components: required: - subLabel title: EventsSubLabelBody - ExportCaseAssignBody: + ExportBulkDeleteBody: properties: + ids: + items: + type: string + minLength: 1 + type: array + minItems: 1 + title: Ids + type: object + required: + - ids + title: ExportBulkDeleteBody + description: Request body for bulk deleting exports. + ExportBulkReassignBody: + properties: + ids: + items: + type: string + minLength: 1 + type: array + minItems: 1 + title: Ids export_case_id: anyOf: - type: string maxLength: 30 - type: "null" title: Export Case Id - description: "Case ID to assign to the export, or null to unassign" + description: "Case ID to assign to, or null to unassign from current case" type: object - title: ExportCaseAssignBody - description: Request body for assigning or unassigning an export to a case. + required: + - ids + title: ExportBulkReassignBody + description: Request body for bulk reassigning exports to a case. ExportCaseCreateBody: properties: name: diff --git a/frigate/api/auth.py b/frigate/api/auth.py index a7edb6ad4..d1c968818 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -88,7 +88,9 @@ def require_admin_by_default(): "/go2rtc/streams", "/event_ids", "/events", + "/cases", "/exports", + "/jobs/export", } # Path prefixes that should be exempt (for paths with parameters) @@ -101,7 +103,9 @@ def require_admin_by_default(): "/go2rtc/streams/", # /go2rtc/streams/{camera} "/users/", # /users/{username}/password (has own auth) "/preview/", # /preview/{file}/thumbnail.jpg + "/cases/", # /cases/{case_id} "/exports/", # /exports/{export_id} + "/jobs/export/", # /jobs/export/{export_id} "/vod/", # /vod/{camera_name}/... "/notifications/", # /notifications/pubkey, /notifications/register ) diff --git a/frigate/api/defs/request/batch_export_body.py b/frigate/api/defs/request/batch_export_body.py new file mode 100644 index 000000000..c0863c885 --- /dev/null +++ b/frigate/api/defs/request/batch_export_body.py @@ -0,0 +1,65 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field, model_validator + +MAX_BATCH_EXPORT_ITEMS = 50 + + +class BatchExportItem(BaseModel): + camera: str = Field(title="Camera name") + start_time: float = Field(title="Start time") + end_time: float = Field(title="End time") + image_path: Optional[str] = Field( + default=None, + title="Existing thumbnail path", + description="Optional existing image to use as the export thumbnail", + ) + friendly_name: Optional[str] = Field( + default=None, + title="Friendly name", + max_length=256, + description="Optional friendly name for this specific export item", + ) + client_item_id: Optional[str] = Field( + default=None, + title="Client item ID", + max_length=128, + description="Optional opaque client identifier echoed back in results", + ) + + +class BatchExportBody(BaseModel): + items: List[BatchExportItem] = Field( + title="Items", + min_length=1, + max_length=MAX_BATCH_EXPORT_ITEMS, + description="List of export items. Each item has its own camera and time range.", + ) + export_case_id: Optional[str] = Field( + default=None, + title="Export case ID", + max_length=30, + description=( + "Existing export case ID to assign all exports to. Attaching to an " + "existing case is temporarily admin-only until case-level ACLs exist." + ), + ) + new_case_name: Optional[str] = Field( + default=None, + title="New case name", + max_length=100, + description="Name of a new export case to create when export_case_id is omitted", + ) + new_case_description: Optional[str] = Field( + default=None, + title="New case description", + description="Optional description for a newly created export case", + ) + + @model_validator(mode="after") + def validate_case_target(self) -> "BatchExportBody": + for item in self.items: + if item.end_time <= item.start_time: + raise ValueError("end_time must be after start_time") + + return self diff --git a/frigate/api/defs/request/export_bulk_body.py b/frigate/api/defs/request/export_bulk_body.py new file mode 100644 index 000000000..004c67d90 --- /dev/null +++ b/frigate/api/defs/request/export_bulk_body.py @@ -0,0 +1,24 @@ +"""Request bodies for bulk export operations.""" + +from typing import Optional + +from pydantic import BaseModel, Field, conlist, constr + + +class ExportBulkDeleteBody(BaseModel): + """Request body for bulk deleting exports.""" + + # List of export IDs with at least one element and each element with at least one char + ids: conlist(constr(min_length=1), min_length=1) + + +class ExportBulkReassignBody(BaseModel): + """Request body for bulk reassigning exports to a case.""" + + # List of export IDs with at least one element and each element with at least one char + ids: conlist(constr(min_length=1), min_length=1) + export_case_id: Optional[str] = Field( + default=None, + max_length=30, + description="Case ID to assign to, or null to unassign from current case", + ) diff --git a/frigate/api/defs/request/export_case_body.py b/frigate/api/defs/request/export_case_body.py index 35cd8ff7f..66cba58ea 100644 --- a/frigate/api/defs/request/export_case_body.py +++ b/frigate/api/defs/request/export_case_body.py @@ -23,13 +23,3 @@ 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/response/export_response.py b/frigate/api/defs/response/export_response.py index 600794f97..b796ba9ac 100644 --- a/frigate/api/defs/response/export_response.py +++ b/frigate/api/defs/response/export_response.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, List, Optional from pydantic import BaseModel, Field @@ -28,6 +28,88 @@ class StartExportResponse(BaseModel): export_id: Optional[str] = Field( default=None, description="The export ID if successfully started" ) + status: Optional[str] = Field( + default=None, + description="Queue status for the export job", + ) + + +class BatchExportResultModel(BaseModel): + """Per-item result for a batch export request.""" + + camera: str = Field(description="Camera name for this export attempt") + export_id: Optional[str] = Field( + default=None, + description="The export ID when the export was successfully queued", + ) + success: bool = Field(description="Whether the export was successfully queued") + status: Optional[str] = Field( + default=None, + description="Queue status for this camera export", + ) + error: Optional[str] = Field( + default=None, + description="Validation or queueing error for this item, if any", + ) + item_index: Optional[int] = Field( + default=None, + description="Zero-based index of this result within the request items list", + ) + client_item_id: Optional[str] = Field( + default=None, + description="Opaque client-supplied item identifier echoed from the request", + ) + + +class BatchExportResponse(BaseModel): + """Response model for starting an export batch.""" + + export_case_id: Optional[str] = Field( + default=None, + description="Export case ID associated with the batch", + ) + export_ids: List[str] = Field(description="Export IDs successfully queued") + results: List[BatchExportResultModel] = Field( + description="Per-item batch export results" + ) + + +class ExportJobModel(BaseModel): + """Model representing a queued or running export job.""" + + id: str = Field(description="Unique identifier for the export job") + job_type: str = Field(description="Job type") + status: str = Field(description="Current job status") + camera: str = Field(description="Camera associated with this export job") + name: Optional[str] = Field( + default=None, + description="Friendly name for the export", + ) + export_case_id: Optional[str] = Field( + default=None, + description="ID of the export case this export belongs to", + ) + request_start_time: float = Field(description="Requested export start time") + request_end_time: float = Field(description="Requested export end time") + start_time: Optional[float] = Field( + default=None, + description="Unix timestamp when execution started", + ) + end_time: Optional[float] = Field( + default=None, + description="Unix timestamp when execution completed", + ) + error_message: Optional[str] = Field( + default=None, + description="Error message for failed jobs", + ) + results: Optional[dict[str, Any]] = Field( + default=None, + description="Result metadata for completed jobs", + ) + + +ExportJobsResponse = List[ExportJobModel] ExportsResponse = List[ExportModel] diff --git a/frigate/api/export.py b/frigate/api/export.py index 056a0613f..714420903 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -1,8 +1,10 @@ """Export apis.""" +import datetime import logging import random import string +import time from pathlib import Path from typing import List, Optional @@ -16,11 +18,19 @@ from playhouse.shortcuts import model_to_dict from frigate.api.auth import ( allow_any_authenticated, get_allowed_cameras_for_filter, + get_current_user, require_camera_access, require_role, ) +from frigate.api.defs.request.batch_export_body import ( + BatchExportBody, + BatchExportItem, +) +from frigate.api.defs.request.export_bulk_body import ( + ExportBulkDeleteBody, + ExportBulkReassignBody, +) from frigate.api.defs.request.export_case_body import ( - ExportCaseAssignBody, ExportCaseCreateBody, ExportCaseUpdateBody, ) @@ -34,6 +44,9 @@ from frigate.api.defs.response.export_case_response import ( ExportCasesResponse, ) from frigate.api.defs.response.export_response import ( + BatchExportResponse, + ExportJobModel, + ExportJobsResponse, ExportModel, ExportsResponse, StartExportResponse, @@ -41,11 +54,19 @@ from frigate.api.defs.response.export_response import ( from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR +from frigate.jobs.export import ( + ExportJob, + ExportQueueFullError, + available_export_queue_slots, + cancel_queued_export_jobs_for_case, + get_export_job, + list_active_export_jobs, + start_export_job, +) from frigate.models import Export, ExportCase, Previews, Recordings from frigate.record.export import ( DEFAULT_TIME_LAPSE_FFMPEG_ARGS, PlaybackSourceEnum, - RecordingExporter, validate_ffmpeg_args, ) from frigate.util.time import is_current_hour @@ -55,6 +76,209 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.export]) +def _generate_id(length: int = 12) -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def _generate_export_id(camera_name: str) -> str: + return f"{camera_name}_{_generate_id(6)}" + + +def _create_export_case_record( + name: str, + description: Optional[str], +) -> ExportCase: + now = datetime.datetime.fromtimestamp(time.time()) + return ExportCase.create( + id=_generate_id(), + name=name, + description=description, + created_at=now, + updated_at=now, + ) + + +def _validate_camera_name(request: Request, camera_name: str) -> Optional[JSONResponse]: + if camera_name and request.app.frigate_config.cameras.get(camera_name): + return None + + return JSONResponse( + content={"success": False, "message": f"{camera_name} is not a valid camera."}, + status_code=404, + ) + + +def _validate_export_case(export_case_id: Optional[str]) -> Optional[JSONResponse]: + if export_case_id is None: + return None + + try: + ExportCase.get(ExportCase.id == export_case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + return None + + +def _sanitize_existing_image( + image_path: Optional[str], +) -> tuple[Optional[str], Optional[JSONResponse]]: + existing_image = sanitize_filepath(image_path) if image_path else None + + if existing_image and not existing_image.startswith(CLIPS_DIR): + return None, JSONResponse( + content={"success": False, "message": "Invalid image path"}, + status_code=400, + ) + + return existing_image, None + + +def _validate_export_source( + camera_name: str, + start_time: float, + end_time: float, + playback_source: PlaybackSourceEnum, +) -> Optional[str]: + if playback_source == PlaybackSourceEnum.recordings: + recordings_count = ( + Recordings.select() + .where( + Recordings.start_time.between(start_time, end_time) + | Recordings.end_time.between(start_time, end_time) + | ( + (start_time > Recordings.start_time) + & (end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == camera_name) + .count() + ) + + if recordings_count <= 0: + return "No recordings found for time range" + + return None + + previews_count = ( + Previews.select() + .where( + Previews.start_time.between(start_time, end_time) + | Previews.end_time.between(start_time, end_time) + | ((start_time > Previews.start_time) & (end_time < Previews.end_time)) + ) + .where(Previews.camera == camera_name) + .count() + ) + + if not is_current_hour(start_time) and previews_count <= 0: + return "No previews found for time range" + + return None + + +def _get_item_recording_export_errors( + request: Request, + items: list[BatchExportItem], +) -> dict[int, str]: + """Return {item_index: error message} for items with invalid state. + + Checks camera configuration and recording presence per item. Groups by + camera and issues one query per unique camera covering that camera's + full requested range, then checks each item's range against the returned + rows in Python. This avoids O(N) DB round-trips on large batches. + """ + configured_cameras = request.app.frigate_config.cameras + errors: dict[int, str] = {} + + # Validate camera configuration first + item_ranges_by_camera: dict[str, list[tuple[int, float, float]]] = {} + for index, item in enumerate(items): + if not configured_cameras.get(item.camera): + errors[index] = f"{item.camera} is not a valid camera." + continue + item_ranges_by_camera.setdefault(item.camera, []).append( + (index, item.start_time, item.end_time) + ) + + if not item_ranges_by_camera: + return errors + + # For each camera, fetch recordings that cover the union of ranges + for camera_name, indexed_ranges in item_ranges_by_camera.items(): + min_start = min(r[1] for r in indexed_ranges) + max_end = max(r[2] for r in indexed_ranges) + + recording_ranges = list( + Recordings.select(Recordings.start_time, Recordings.end_time) + .where( + Recordings.camera == camera_name, + Recordings.start_time.between(min_start, max_end) + | Recordings.end_time.between(min_start, max_end) + | ( + (min_start > Recordings.start_time) + & (max_end < Recordings.end_time) + ), + ) + .iterator() + ) + + for index, start_time, end_time in indexed_ranges: + has_recording = any( + ( + start_time <= rec.start_time <= end_time + or start_time <= rec.end_time <= end_time + or (start_time > rec.start_time and end_time < rec.end_time) + ) + for rec in recording_ranges + ) + if not has_recording: + errors[index] = "No recordings found for time range" + + return errors + + +def _build_export_job( + camera_name: str, + start_time: float, + end_time: float, + friendly_name: Optional[str], + existing_image: Optional[str], + playback_source: PlaybackSourceEnum, + export_case_id: Optional[str], + ffmpeg_input_args: Optional[str] = None, + ffmpeg_output_args: Optional[str] = None, + cpu_fallback: bool = False, +) -> ExportJob: + return ExportJob( + id=_generate_export_id(camera_name), + camera=camera_name, + name=friendly_name, + image_path=existing_image, + export_case_id=export_case_id, + request_start_time=int(start_time), + request_end_time=int(end_time), + playback_source=playback_source.value, + ffmpeg_input_args=ffmpeg_input_args, + ffmpeg_output_args=ffmpeg_output_args, + cpu_fallback=cpu_fallback, + ) + + +def _export_case_to_dict(case: ExportCase) -> dict[str, object]: + case_dict = model_to_dict(case) + + for field in ("created_at", "updated_at"): + value = case_dict.get(field) + if isinstance(value, datetime.datetime): + case_dict[field] = value.timestamp() + + return case_dict + + @router.get( "/exports", response_model=ExportsResponse, @@ -103,10 +327,8 @@ def get_exports( description="Gets all export cases from the database.", ) def get_export_cases(): - cases = ( - ExportCase.select().order_by(ExportCase.created_at.desc()).dicts().iterator() - ) - return JSONResponse(content=[c for c in cases]) + cases = ExportCase.select().order_by(ExportCase.created_at.desc()).iterator() + return JSONResponse(content=[_export_case_to_dict(case) for case in cases]) @router.post( @@ -117,14 +339,8 @@ def get_export_cases(): description="Creates a new export case.", ) def create_export_case(body: ExportCaseCreateBody): - case = ExportCase.create( - id="".join(random.choices(string.ascii_lowercase + string.digits, k=12)), - name=body.name, - description=body.description, - created_at=Path().stat().st_mtime, - updated_at=Path().stat().st_mtime, - ) - return JSONResponse(content=model_to_dict(case)) + case = _create_export_case_record(body.name, body.description) + return JSONResponse(content=_export_case_to_dict(case)) @router.get( @@ -137,7 +353,7 @@ def create_export_case(body: ExportCaseCreateBody): def get_export_case(case_id: str): try: case = ExportCase.get(ExportCase.id == case_id) - return JSONResponse(content=model_to_dict(case)) + return JSONResponse(content=_export_case_to_dict(case)) except DoesNotExist: return JSONResponse( content={"success": False, "message": "Export case not found"}, @@ -166,6 +382,8 @@ def update_export_case(case_id: str, body: ExportCaseUpdateBody): if body.description is not None: case.description = body.description + case.updated_at = datetime.datetime.fromtimestamp(time.time()) + case.save() return JSONResponse( @@ -180,7 +398,7 @@ def update_export_case(case_id: str, body: ExportCaseUpdateBody): summary="Delete export case", description="""Deletes an export case.\n Exports that reference this case will have their export_case set to null.\n """, ) -def delete_export_case(case_id: str): +def delete_export_case(case_id: str, request: Request, delete_exports: bool = False): try: case = ExportCase.get(ExportCase.id == case_id) except DoesNotExist: @@ -189,8 +407,18 @@ def delete_export_case(case_id: str): status_code=404, ) - # Unassign exports from this case but keep the exports themselves - Export.update(export_case=None).where(Export.export_case == case).execute() + if delete_exports: + cancel_queued_export_jobs_for_case(request.app.frigate_config, case_id) + + exports = list(Export.select().where(Export.export_case == case_id)) + for export in exports: + Path(export.video_path).unlink(missing_ok=True) + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + export.delete_instance() + else: + # Unassign exports from this case but keep the exports themselves + Export.update(export_case=None).where(Export.export_case == case_id).execute() case.delete_instance() @@ -199,45 +427,214 @@ 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." - ), +@router.get( + "/jobs/export", + response_model=ExportJobsResponse, + dependencies=[Depends(allow_any_authenticated())], + summary="Get active export jobs", + description="Gets queued and running export jobs.", ) -async def assign_export_case( - export_id: str, - body: ExportCaseAssignBody, +def get_active_export_jobs( request: Request, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), ): - try: - export: Export = Export.get(Export.id == export_id) - await require_camera_access(export.camera, request=request) - except DoesNotExist: + jobs = list_active_export_jobs(request.app.frigate_config) + return JSONResponse( + content=[job.to_dict() for job in jobs if job.camera in allowed_cameras] + ) + + +@router.get( + "/jobs/export/{export_id}", + response_model=ExportJobModel, + dependencies=[Depends(allow_any_authenticated())], + summary="Get export job status", + description="Gets queued, running, or completed status for a specific export job.", +) +async def get_export_job_status(export_id: str, request: Request): + job = get_export_job(request.app.frigate_config, export_id) + if job is None: return JSONResponse( - content={"success": False, "message": "Export not found."}, + content={"success": False, "message": "Job 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 + await require_camera_access(job.camera, request=request) - export.save() + return JSONResponse(content=job.to_dict()) + + +@router.post( + "/exports/batch", + response_model=BatchExportResponse, + dependencies=[Depends(allow_any_authenticated())], + summary="Start recording export batch", + description=( + "Starts recording exports for a batch of items, each with its own camera " + "and time range, and assigns them to a single export case. Attaching to " + "an existing case is temporarily admin-only until case-level ACLs exist." + ), +) +def export_recordings_batch( + request: Request, + body: BatchExportBody, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + current_user: dict = Depends(get_current_user), +): + if isinstance(current_user, JSONResponse): + return current_user + + # Stopgap: attaching to an existing case remains admin-only until + # case-level ACLs exist. Non-admins can still create a fresh case + # as a side effect of queueing items they already have camera access to. + if body.export_case_id is not None and current_user["role"] != "admin": + return JSONResponse( + content={ + "success": False, + "message": "Only admins can attach exports to an existing case.", + }, + status_code=403, + ) + + case_validation_error = _validate_export_case(body.export_case_id) + if case_validation_error is not None: + return case_validation_error + + # Fail-closed camera access: any item referencing an inaccessible + # camera rejects the whole request. The UI's review list is already + # filtered by camera access, so reaching this branch implies a stale + # session or a crafted request — reject loudly rather than silently + # dropping items. + allowed_camera_set = set(allowed_cameras) + for item in body.items: + if item.camera not in allowed_camera_set: + return JSONResponse( + content={ + "success": False, + "message": f"Cannot export from {item.camera}: access denied", + }, + status_code=403, + ) + + # Sanitize each item's image_path up front. A bad path in any item + # kills the whole request, consistent with single-export behavior. + sanitized_images: list[Optional[str]] = [] + for item in body.items: + existing_image, image_validation_error = _sanitize_existing_image( + item.image_path + ) + if image_validation_error is not None: + return image_validation_error + sanitized_images.append(existing_image) + + item_errors = _get_item_recording_export_errors(request, body.items) + + queueable_indexes = [ + index for index in range(len(body.items)) if index not in item_errors + ] + + if not queueable_indexes: + return JSONResponse( + content={ + "success": False, + "message": ( + "No exports could be queued: no recordings found for the " + "requested ranges." + ), + }, + status_code=400, + ) + + # Preflight admission: reject the whole batch if we can't fit every + # queueable item. Prevents partial batches where the tail fails with + # "queue full" after we've already created a case. + if available_export_queue_slots(request.app.frigate_config) < len( + queueable_indexes + ): + return JSONResponse( + content={ + "success": False, + "message": "Export queue is full. Try again once current exports finish.", + }, + status_code=503, + ) + + export_case = None + export_case_id = body.export_case_id + if export_case_id is None and body.new_case_name: + export_case = _create_export_case_record( + body.new_case_name, + body.new_case_description, + ) + export_case_id = export_case.id + + export_ids: list[str] = [] + results: list[dict[str, Optional[str] | bool | int]] = [] + for index, item in enumerate(body.items): + if index in item_errors: + results.append( + { + "camera": item.camera, + "export_id": None, + "success": False, + "status": None, + "error": item_errors[index], + "item_index": index, + "client_item_id": item.client_item_id, + } + ) + continue + + export_job = _build_export_job( + item.camera, + item.start_time, + item.end_time, + item.friendly_name, + sanitized_images[index], + PlaybackSourceEnum.recordings, + export_case_id, + ) + try: + start_export_job(request.app.frigate_config, export_job) + except Exception: + logger.exception("Failed to queue export job %s", export_job.id) + results.append( + { + "camera": item.camera, + "export_id": None, + "success": False, + "status": None, + "error": "Failed to queue export job", + "item_index": index, + "client_item_id": item.client_item_id, + } + ) + continue + + export_ids.append(export_job.id) + results.append( + { + "camera": item.camera, + "export_id": export_job.id, + "success": True, + "status": "queued", + "error": None, + "item_index": index, + "client_item_id": item.client_item_id, + } + ) + + if export_case is not None and not export_ids: + export_case.delete_instance() + export_case_id = None return JSONResponse( - content={"success": True, "message": "Successfully updated export case."} + content={ + "export_case_id": export_case_id, + "export_ids": export_ids, + "results": results, + }, + status_code=202, ) @@ -257,104 +654,82 @@ def export_recording( start_time: float, end_time: float, body: ExportRecordingsBody, + current_user: dict = Depends(get_current_user), ): - 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."} - ), - status_code=404, - ) + if isinstance(current_user, JSONResponse): + return current_user + + camera_validation_error = _validate_camera_name(request, camera_name) + if camera_validation_error is not None: + return camera_validation_error playback_source = body.source friendly_name = body.name - existing_image = sanitize_filepath(body.image_path) if body.image_path else None + existing_image, image_validation_error = _sanitize_existing_image(body.image_path) + if image_validation_error is not None: + return image_validation_error 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): + # Attaching to an existing case requires admin. Single-export for + # cameras the user can access is otherwise non-admin; we only gate + # the case-attachment side effect. + if export_case_id is not None and current_user["role"] != "admin": return JSONResponse( - content=({"success": False, "message": "Invalid image path"}), + content={ + "success": False, + "message": "Only admins can attach exports to an existing case.", + }, + status_code=403, + ) + + case_validation_error = _validate_export_case(export_case_id) + if case_validation_error is not None: + return case_validation_error + + source_error = _validate_export_source( + camera_name, + start_time, + end_time, + playback_source, + ) + if source_error is not None: + return JSONResponse( + content={"success": False, "message": source_error}, status_code=400, ) - if playback_source == "recordings": - recordings_count = ( - Recordings.select() - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ( - (start_time > Recordings.start_time) - & (end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == camera_name) - .count() - ) - - if recordings_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No recordings found for time range"} - ), - status_code=400, - ) - else: - previews_count = ( - Previews.select() - .where( - Previews.start_time.between(start_time, end_time) - | Previews.end_time.between(start_time, end_time) - | ((start_time > Previews.start_time) & (end_time < Previews.end_time)) - ) - .where(Previews.camera == camera_name) - .count() - ) - - if not is_current_hour(start_time) and previews_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No previews found for time range"} - ), - status_code=400, - ) - - export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" - exporter = RecordingExporter( - request.app.frigate_config, - export_id, + export_job = _build_export_job( camera_name, + start_time, + end_time, friendly_name, existing_image, - int(start_time), - int(end_time), - ( - PlaybackSourceEnum[playback_source] - if playback_source in PlaybackSourceEnum.__members__.values() - else PlaybackSourceEnum.recordings - ), + playback_source, export_case_id, ) - exporter.start() + try: + start_export_job(request.app.frigate_config, export_job) + except ExportQueueFullError: + logger.warning("Export queue is full; rejecting %s", export_job.id) + return JSONResponse( + content={ + "success": False, + "message": "Export queue is full. Try again once current exports finish.", + }, + status_code=503, + ) + return JSONResponse( content=( { "success": True, - "message": "Starting export of recording.", - "export_id": export_id, + "message": "Export queued.", + "export_id": export_job.id, + "status": "queued", } ), - status_code=200, + status_code=202, ) @@ -395,65 +770,6 @@ async def export_rename(event_id: str, body: ExportRenameBody, request: Request) ) -@router.delete( - "/export/{event_id}", - response_model=GenericResponse, - dependencies=[Depends(require_role(["admin"]))], - summary="Delete export", -) -async def export_delete(event_id: str, request: Request): - try: - export: Export = Export.get(Export.id == event_id) - await require_camera_access(export.camera, request=request) - except DoesNotExist: - return JSONResponse( - content=( - { - "success": False, - "message": "Export not found.", - } - ), - status_code=404, - ) - - files_in_use = [] - for process in psutil.process_iter(): - try: - if process.name() != "ffmpeg": - continue - file_list = process.open_files() - if file_list: - for nt in file_list: - if nt.path.startswith(EXPORT_DIR): - files_in_use.append(nt.path.split("/")[-1]) - except psutil.Error: - continue - - if export.video_path.split("/")[-1] in files_in_use: - return JSONResponse( - content=( - {"success": False, "message": "Can not delete in progress export."} - ), - status_code=400, - ) - - Path(export.video_path).unlink(missing_ok=True) - - if export.thumb_path: - Path(export.thumb_path).unlink(missing_ok=True) - - export.delete_instance() - return JSONResponse( - content=( - { - "success": True, - "message": "Successfully deleted export.", - } - ), - status_code=200, - ) - - @router.post( "/export/custom/{camera_name}/start/{start_time}/end/{end_time}", response_model=StartExportResponse, @@ -472,82 +788,36 @@ def export_recording_custom( end_time: float, body: ExportRecordingsCustomBody, ): - 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."} - ), - status_code=404, - ) + camera_validation_error = _validate_camera_name(request, camera_name) + if camera_validation_error is not None: + return camera_validation_error playback_source = body.source friendly_name = body.name - existing_image = sanitize_filepath(body.image_path) if body.image_path else None + existing_image, image_validation_error = _sanitize_existing_image(body.image_path) + if image_validation_error is not None: + return image_validation_error ffmpeg_input_args = body.ffmpeg_input_args ffmpeg_output_args = body.ffmpeg_output_args cpu_fallback = body.cpu_fallback 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, - ) + case_validation_error = _validate_export_case(export_case_id) + if case_validation_error is not None: + return case_validation_error - # Ensure that existing_image is a valid path - if existing_image and not existing_image.startswith(CLIPS_DIR): + source_error = _validate_export_source( + camera_name, + start_time, + end_time, + playback_source, + ) + if source_error is not None: return JSONResponse( - content=({"success": False, "message": "Invalid image path"}), + content={"success": False, "message": source_error}, status_code=400, ) - if playback_source == "recordings": - recordings_count = ( - Recordings.select() - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ( - (start_time > Recordings.start_time) - & (end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == camera_name) - .count() - ) - - if recordings_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No recordings found for time range"} - ), - status_code=400, - ) - else: - previews_count = ( - Previews.select() - .where( - Previews.start_time.between(start_time, end_time) - | Previews.end_time.between(start_time, end_time) - | ((start_time > Previews.start_time) & (end_time < Previews.end_time)) - ) - .where(Previews.camera == camera_name) - .count() - ) - - if not is_current_hour(start_time) and previews_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No previews found for time range"} - ), - status_code=400, - ) - - export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" - # Validate user-provided ffmpeg args to prevent injection. # Admin users are trusted and skip validation. is_admin = request.headers.get("remote-role", "") == "admin" @@ -577,34 +847,40 @@ def export_recording_custom( if ffmpeg_output_args is None: ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS - exporter = RecordingExporter( - request.app.frigate_config, - export_id, + export_job = _build_export_job( camera_name, + start_time, + end_time, friendly_name, existing_image, - int(start_time), - int(end_time), - ( - PlaybackSourceEnum[playback_source] - if playback_source in PlaybackSourceEnum.__members__.values() - else PlaybackSourceEnum.recordings - ), + playback_source, export_case_id, ffmpeg_input_args, ffmpeg_output_args, cpu_fallback, ) - exporter.start() + try: + start_export_job(request.app.frigate_config, export_job) + except ExportQueueFullError: + logger.warning("Export queue is full; rejecting %s", export_job.id) + return JSONResponse( + content={ + "success": False, + "message": "Export queue is full. Try again once current exports finish.", + }, + status_code=503, + ) + return JSONResponse( content=( { "success": True, - "message": "Starting export of recording.", - "export_id": export_id, + "message": "Export queued.", + "export_id": export_job.id, + "status": "queued", } ), - status_code=200, + status_code=202, ) @@ -626,3 +902,102 @@ async def get_export(export_id: str, request: Request): content={"success": False, "message": "Export not found"}, status_code=404, ) + + +def _get_files_in_use() -> set[str]: + """Get set of export filenames currently in use by ffmpeg.""" + files_in_use: set[str] = set() + for process in psutil.process_iter(): + try: + if process.name() != "ffmpeg": + continue + file_list = process.open_files() + if file_list: + for nt in file_list: + if nt.path.startswith(EXPORT_DIR): + files_in_use.add(nt.path.split("/")[-1]) + except psutil.Error: + continue + return files_in_use + + +@router.post( + "/exports/delete", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Bulk delete exports", + description="Deletes one or more exports by ID. All IDs must exist and none can be in-progress.", +) +def bulk_delete_exports(body: ExportBulkDeleteBody): + exports = list(Export.select().where(Export.id << body.ids)) + + if len(exports) != len(body.ids): + return JSONResponse( + content={"success": False, "message": "One or more exports not found."}, + status_code=404, + ) + + files_in_use = _get_files_in_use() + + for export in exports: + if export.video_path.split("/")[-1] in files_in_use: + return JSONResponse( + content={ + "success": False, + "message": "Can not delete in-progress export.", + }, + status_code=400, + ) + + for export in exports: + Path(export.video_path).unlink(missing_ok=True) + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + + Export.delete().where(Export.id << body.ids).execute() + + return JSONResponse( + content={ + "success": True, + "message": f"Successfully deleted {len(exports)} export(s).", + }, + status_code=200, + ) + + +@router.post( + "/exports/reassign", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Bulk reassign exports to a case", + description="Assigns or unassigns one or more exports to/from a case. All IDs must exist.", +) +def bulk_reassign_exports(body: ExportBulkReassignBody): + exports = list(Export.select().where(Export.id << body.ids)) + + if len(exports) != len(body.ids): + return JSONResponse( + content={"success": False, "message": "One or more exports 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.update(export_case=body.export_case_id).where( + Export.id << body.ids + ).execute() + + return JSONResponse( + content={ + "success": True, + "message": f"Successfully updated {len(exports)} export(s).", + }, + status_code=200, + ) diff --git a/frigate/app.py b/frigate/app.py index 750f1ad23..0ead74268 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -52,6 +52,7 @@ from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.events.audio import AudioProcessor from frigate.events.cleanup import EventCleanup from frigate.events.maintainer import EventProcessor +from frigate.jobs.export import reap_stale_exports from frigate.jobs.motion_search import stop_all_motion_search_jobs from frigate.log import _stop_logging from frigate.models import ( @@ -611,6 +612,11 @@ class FrigateApp: # Clean up any stale replay camera artifacts (filesystem + DB) cleanup_replay_cameras() + # Reap any Export rows still marked in_progress from a previous + # session (crash, kill, broken migration). Runs synchronously before + # uvicorn binds so no API request can observe a stale row. + reap_stale_exports() + self.init_inter_process_communicator() self.start_detectors() self.init_dispatcher() diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 7eae7500d..1f7afc6ce 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -92,6 +92,12 @@ class RecordExportConfig(FrigateBaseModel): title="Export hwaccel args", description="Hardware acceleration args to use for export/transcode operations.", ) + max_concurrent: int = Field( + default=3, + ge=1, + title="Maximum concurrent exports", + description="Maximum number of export jobs to process at the same time.", + ) class RecordConfig(FrigateBaseModel): diff --git a/frigate/jobs/export.py b/frigate/jobs/export.py new file mode 100644 index 000000000..4540f7dd8 --- /dev/null +++ b/frigate/jobs/export.py @@ -0,0 +1,387 @@ +"""Export job management with queued background execution.""" + +import logging +import os +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from queue import Full, Queue +from typing import Any, Optional + +from peewee import DoesNotExist + +from frigate.config import FrigateConfig +from frigate.jobs.job import Job +from frigate.models import Export +from frigate.record.export import PlaybackSourceEnum, RecordingExporter +from frigate.types import JobStatusTypesEnum + +logger = logging.getLogger(__name__) + +# Maximum number of jobs that can sit in the queue waiting to run. +# Prevents a runaway client from unbounded memory growth. +MAX_QUEUED_EXPORT_JOBS = 100 + + +class ExportQueueFullError(RuntimeError): + """Raised when the export queue is at capacity.""" + + +@dataclass +class ExportJob(Job): + """Job state for export operations.""" + + job_type: str = "export" + camera: str = "" + name: Optional[str] = None + image_path: Optional[str] = None + export_case_id: Optional[str] = None + request_start_time: float = 0.0 + request_end_time: float = 0.0 + playback_source: str = PlaybackSourceEnum.recordings.value + ffmpeg_input_args: Optional[str] = None + ffmpeg_output_args: Optional[str] = None + cpu_fallback: bool = False + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for API responses. + + Only exposes fields that are part of the public ExportJobModel schema. + Internal execution details (image_path, ffmpeg args, cpu_fallback) are + intentionally omitted so they don't leak through the API. + """ + return { + "id": self.id, + "job_type": self.job_type, + "status": self.status, + "camera": self.camera, + "name": self.name, + "export_case_id": self.export_case_id, + "request_start_time": self.request_start_time, + "request_end_time": self.request_end_time, + "start_time": self.start_time, + "end_time": self.end_time, + "error_message": self.error_message, + "results": self.results, + } + + +class ExportQueueWorker(threading.Thread): + """Worker that executes queued exports.""" + + def __init__(self, manager: "ExportJobManager", worker_index: int) -> None: + super().__init__( + daemon=True, + name=f"export_queue_worker_{worker_index}", + ) + self.manager = manager + + def run(self) -> None: + while True: + job = self.manager.queue.get() + + try: + self.manager.run_job(job) + except Exception: + logger.exception( + "Export queue worker failed while processing %s", job.id + ) + finally: + self.manager.queue.task_done() + + +class ExportJobManager: + """Concurrency-limited manager for queued export jobs.""" + + def __init__( + self, + config: FrigateConfig, + max_concurrent: int, + max_queued: int = MAX_QUEUED_EXPORT_JOBS, + ) -> None: + self.config = config + self.max_concurrent = max(1, max_concurrent) + self.queue: Queue[ExportJob] = Queue(maxsize=max(1, max_queued)) + self.jobs: dict[str, ExportJob] = {} + self.lock = threading.Lock() + self.workers: list[ExportQueueWorker] = [] + self.started = False + + def ensure_started(self) -> None: + """Ensure worker threads are started exactly once.""" + with self.lock: + if self.started: + self._restart_dead_workers_locked() + return + + for index in range(self.max_concurrent): + worker = ExportQueueWorker(self, index) + worker.start() + self.workers.append(worker) + + self.started = True + + def _restart_dead_workers_locked(self) -> None: + for index, worker in enumerate(self.workers): + if worker.is_alive(): + continue + + logger.error( + "Export queue worker %s died unexpectedly, restarting", worker.name + ) + replacement = ExportQueueWorker(self, index) + replacement.start() + self.workers[index] = replacement + + def enqueue(self, job: ExportJob) -> str: + """Queue a job for background execution. + + Raises ExportQueueFullError if the queue is at capacity. + """ + self.ensure_started() + + try: + self.queue.put_nowait(job) + except Full as err: + raise ExportQueueFullError( + "Export queue is full; try again once current exports finish" + ) from err + + with self.lock: + self.jobs[job.id] = job + + return job.id + + def get_job(self, job_id: str) -> Optional[ExportJob]: + """Get a job by ID.""" + with self.lock: + return self.jobs.get(job_id) + + def list_active_jobs(self) -> list[ExportJob]: + """List queued and running jobs.""" + with self.lock: + return [ + job + for job in self.jobs.values() + if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running) + ] + + def cancel_queued_jobs_for_case(self, case_id: str) -> list[ExportJob]: + """Cancel queued export jobs assigned to a deleted case.""" + cancelled_jobs: list[ExportJob] = [] + + with self.lock: + with self.queue.mutex: + retained_jobs: list[ExportJob] = [] + + while self.queue.queue: + job = self.queue.queue.popleft() + + if ( + job.export_case_id == case_id + and job.status == JobStatusTypesEnum.queued + ): + job.status = JobStatusTypesEnum.cancelled + job.end_time = time.time() + cancelled_jobs.append(job) + continue + + retained_jobs.append(job) + + self.queue.queue.extend(retained_jobs) + + if cancelled_jobs: + self.queue.unfinished_tasks = max( + 0, + self.queue.unfinished_tasks - len(cancelled_jobs), + ) + if self.queue.unfinished_tasks == 0: + self.queue.all_tasks_done.notify_all() + self.queue.not_full.notify_all() + + return cancelled_jobs + + def available_slots(self) -> int: + """Approximate number of additional jobs that could be queued right now. + + Uses Queue.qsize() which is best-effort; callers should treat the + result as advisory since another thread could enqueue between + checking and enqueueing. + """ + return max(0, self.queue.maxsize - self.queue.qsize()) + + def run_job(self, job: ExportJob) -> None: + """Execute a queued export job.""" + job.status = JobStatusTypesEnum.running + job.start_time = time.time() + + exporter = RecordingExporter( + self.config, + job.id, + job.camera, + job.name, + job.image_path, + int(job.request_start_time), + int(job.request_end_time), + PlaybackSourceEnum(job.playback_source), + job.export_case_id, + job.ffmpeg_input_args, + job.ffmpeg_output_args, + job.cpu_fallback, + ) + + try: + exporter.run() + export = Export.get_or_none(Export.id == job.id) + if export is None: + job.status = JobStatusTypesEnum.failed + job.error_message = "Export failed" + elif export.in_progress: + job.status = JobStatusTypesEnum.failed + job.error_message = "Export did not complete" + else: + job.status = JobStatusTypesEnum.success + job.results = { + "export_id": export.id, + "export_case_id": export.export_case_id, + "video_path": export.video_path, + "thumb_path": export.thumb_path, + } + except DoesNotExist: + job.status = JobStatusTypesEnum.failed + job.error_message = "Export not found" + except Exception as err: + logger.exception("Export job %s failed: %s", job.id, err) + job.status = JobStatusTypesEnum.failed + job.error_message = str(err) + finally: + job.end_time = time.time() + + +_job_manager: Optional[ExportJobManager] = None +_job_manager_lock = threading.Lock() + + +def _get_max_concurrent(config: FrigateConfig) -> int: + return int(config.record.export.max_concurrent) + + +def reap_stale_exports() -> None: + """Sweep Export rows stuck with in_progress=True from previous sessions. + + On Frigate startup no export job is alive yet, so any in_progress=True + row must be a leftover from a previous session that crashed, was killed + mid-export, or returned early from RecordingExporter.run() without + flipping the flag. For each stale row we either: + + - delete the row (and any thumb) if the video file is missing or empty, + since there is nothing worth recovering + - flip in_progress to False if the video file exists on disk and is + non-empty, treating it as a completed export the user can manage + through the normal UI + + Must only be called when the export job manager is certain to have no + active jobs — i.e., at Frigate startup, before any worker runs. + + All exceptions are caught and logged; the caller does not need to wrap + this in a try/except. A failure on a single row will not stop the rest + of the sweep, and a failure in the top-level query will log and return. + """ + try: + stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 + except Exception: + logger.exception("Failed to query stale in-progress exports") + return + + if not stale_exports: + logger.debug("No stale in-progress exports found on startup") + return + + flipped = 0 + deleted = 0 + errored = 0 + + for export in stale_exports: + try: + video_path = export.video_path + has_usable_file = False + + if video_path: + try: + has_usable_file = os.path.getsize(video_path) > 0 + except OSError: + has_usable_file = False + + if has_usable_file: + # Unassign from any case on recovery: the user should + # re-triage a recovered export rather than have it silently + # reappear inside a case they curated. + Export.update( + {Export.in_progress: False, Export.export_case: None} + ).where(Export.id == export.id).execute() + flipped += 1 + logger.info( + "Recovered stale in-progress export %s (file intact on disk)", + export.id, + ) + continue + + if export.thumb_path: + Path(export.thumb_path).unlink(missing_ok=True) + if video_path: + Path(video_path).unlink(missing_ok=True) + Export.delete().where(Export.id == export.id).execute() + deleted += 1 + logger.info( + "Deleted stale in-progress export %s (no usable file on disk)", + export.id, + ) + except Exception: + errored += 1 + logger.exception("Failed to reap stale export %s", export.id) + + logger.info( + "Stale export cleanup complete: %d recovered, %d deleted, %d errored", + flipped, + deleted, + errored, + ) + + +def get_export_job_manager(config: FrigateConfig) -> ExportJobManager: + """Get or create the singleton export job manager.""" + global _job_manager + + with _job_manager_lock: + if _job_manager is None: + _job_manager = ExportJobManager(config, _get_max_concurrent(config)) + _job_manager.ensure_started() + return _job_manager + + +def start_export_job(config: FrigateConfig, job: ExportJob) -> str: + """Queue an export job and return its ID.""" + return get_export_job_manager(config).enqueue(job) + + +def get_export_job(config: FrigateConfig, job_id: str) -> Optional[ExportJob]: + """Get a queued or completed export job by ID.""" + return get_export_job_manager(config).get_job(job_id) + + +def list_active_export_jobs(config: FrigateConfig) -> list[ExportJob]: + """List queued and running export jobs.""" + return get_export_job_manager(config).list_active_jobs() + + +def cancel_queued_export_jobs_for_case( + config: FrigateConfig, case_id: str +) -> list[ExportJob]: + """Cancel queued export jobs that still point at a deleted case.""" + return get_export_job_manager(config).cancel_queued_jobs_for_case(case_id) + + +def available_export_queue_slots(config: FrigateConfig) -> int: + """Approximate number of additional export jobs that could be queued now.""" + return get_export_job_manager(config).available_slots() diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py new file mode 100644 index 000000000..e0ceec559 --- /dev/null +++ b/frigate/test/http_api/test_http_export.py @@ -0,0 +1,1433 @@ +import os +import tempfile +from unittest.mock import patch + +from frigate.jobs.export import ( + ExportJob, + get_export_job_manager, + reap_stale_exports, + start_export_job, +) +from frigate.models import Export, ExportCase, Previews, Recordings +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpExport(BaseTestHttp): + def setUp(self): + super().setUp([Export, ExportCase, Previews, Recordings]) + self.minimal_config["cameras"]["backyard"] = { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + self.app = super().create_app() + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def _insert_recording( + self, + recording_id: str, + camera: str, + start_time: float, + end_time: float, + ) -> None: + Recordings.create( + id=recording_id, + camera=camera, + path=f"/tmp/{recording_id}.mp4", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + motion=0, + objects=0, + dBFS=0, + segment_size=1, + regions=0, + motion_heatmap=[], + ) + + def test_create_export_case_uses_wall_clock_time(self): + with patch("frigate.api.export.time.time", return_value=1234.5): + with AuthTestClient(self.app) as client: + response = client.post( + "/cases", + json={ + "name": "Investigation", + "description": "A test case", + }, + ) + + assert response.status_code == 200 + response_json = response.json() + assert response_json["created_at"] == 1234.5 + assert response_json["updated_at"] == 1234.5 + + case = ExportCase.get(ExportCase.id == response_json["id"]) + assert case.created_at.timestamp() == 1234.5 + assert case.updated_at.timestamp() == 1234.5 + + def test_update_export_case_refreshes_updated_at(self): + case = ExportCase.create( + id="case123", + name="Old name", + description="Old description", + created_at=10, + updated_at=10, + ) + + with patch("frigate.api.export.time.time", return_value=2222.0): + with AuthTestClient(self.app) as client: + response = client.patch( + f"/cases/{case.id}", + json={"name": "New name", "description": "Updated"}, + ) + + assert response.status_code == 200 + + refreshed = ExportCase.get(ExportCase.id == case.id) + assert refreshed.name == "New name" + assert refreshed.description == "Updated" + assert refreshed.updated_at.timestamp() == 2222.0 + + def test_delete_export_case_delete_exports_cancels_queued_jobs(self): + case = ExportCase.create( + id="case_delete_me", + name="Delete me", + description="", + created_at=10, + updated_at=10, + ) + other_case = ExportCase.create( + id="case_keep_me", + name="Keep me", + description="", + created_at=20, + updated_at=20, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + video_path = os.path.join(tmpdir, "case_export.mp4") + thumb_path = os.path.join(tmpdir, "case_export.webp") + other_video_path = os.path.join(tmpdir, "other_export.mp4") + other_thumb_path = os.path.join(tmpdir, "other_export.webp") + + with open(video_path, "wb") as handle: + handle.write(b"case") + with open(thumb_path, "wb") as handle: + handle.write(b"thumb") + with open(other_video_path, "wb") as handle: + handle.write(b"other") + with open(other_thumb_path, "wb") as handle: + handle.write(b"thumb") + + Export.create( + id="export_in_case", + camera="front_door", + name="Case export", + date=100, + video_path=video_path, + thumb_path=thumb_path, + in_progress=False, + export_case=case, + ) + Export.create( + id="export_other_case", + camera="front_door", + name="Other export", + date=110, + video_path=other_video_path, + thumb_path=other_thumb_path, + in_progress=False, + export_case=other_case, + ) + + with ( + patch("frigate.jobs.export._job_manager", None), + patch( + "frigate.jobs.export.ExportJobManager.ensure_started", + autospec=True, + return_value=None, + ), + ): + start_export_job( + self.app.frigate_config, + ExportJob( + id="queued_case_job", + camera="front_door", + export_case_id=case.id, + request_start_time=100, + request_end_time=120, + ), + ) + start_export_job( + self.app.frigate_config, + ExportJob( + id="queued_other_job", + camera="front_door", + export_case_id=other_case.id, + request_start_time=130, + request_end_time=150, + ), + ) + + manager = get_export_job_manager(self.app.frigate_config) + assert {job.id for job in manager.list_active_jobs()} == { + "queued_case_job", + "queued_other_job", + } + + with AuthTestClient(self.app) as client: + response = client.delete(f"/cases/{case.id}?delete_exports=true") + + assert response.status_code == 200 + assert ExportCase.get_or_none(ExportCase.id == case.id) is None + assert ExportCase.get_or_none(ExportCase.id == other_case.id) is not None + assert Export.get_or_none(Export.id == "export_in_case") is None + assert Export.get_or_none(Export.id == "export_other_case") is not None + assert not os.path.exists(video_path) + assert not os.path.exists(thumb_path) + + cancelled_job = manager.get_job("queued_case_job") + assert cancelled_job is not None + assert cancelled_job.status == "cancelled" + + remaining_job = manager.get_job("queued_other_job") + assert remaining_job is not None + assert remaining_job.status == "queued" + assert [job.id for job in manager.list_active_jobs()] == [ + "queued_other_job" + ] + + def test_batch_export_creates_case_and_reports_partial_success(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "friendly_name": "Incident - Front Door", + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + "friendly_name": "Incident - Backyard", + }, + ], + "new_case_name": "Case Alpha", + "new_case_description": "Batch export", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + assert response_json["results"] == [ + { + "camera": "front_door", + "export_id": response_json["export_ids"][0], + "success": True, + "status": "queued", + "error": None, + "item_index": 0, + "client_item_id": None, + }, + { + "camera": "backyard", + "export_id": None, + "success": False, + "status": None, + "error": "No recordings found for time range", + "item_index": 1, + "client_item_id": None, + }, + ] + start_export_job.assert_called_once() + + case = ExportCase.get(ExportCase.id == response_json["export_case_id"]) + assert case.name == "Case Alpha" + assert case.description == "Batch export" + + def test_single_export_is_queued_immediately(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + json={ + "name": "Queued export", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["success"] is True + assert response_json["status"] == "queued" + assert response_json["export_id"].startswith("front_door_") + start_export_job.assert_called_once() + + def test_single_export_returns_503_when_queue_full(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + from frigate.jobs.export import ExportQueueFullError + + with patch( + "frigate.api.export.start_export_job", + side_effect=ExportQueueFullError("Export queue is full"), + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + json={ + "name": "Rejected export", + }, + ) + + assert response.status_code == 503 + response_json = response.json() + assert response_json["success"] is False + assert "queue is full" in response_json["message"].lower() + + def test_batch_export_returns_503_when_queue_cannot_fit_batch(self): + self._insert_recording("rec-front", "front_door", 100, 200) + self._insert_recording("rec-back", "backyard", 100, 200) + + with patch( + "frigate.api.export.available_export_queue_slots", + return_value=1, + ): + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Overflow Case", + }, + ) + + assert response.status_code == 503 + assert response.json()["success"] is False + start_export_job.assert_not_called() + + # Empty case should NOT have been created + assert ExportCase.select().count() == 0 + + def test_get_active_export_jobs_returns_queue_state(self): + queued_job = ExportJob( + id="front_door_queued", + camera="front_door", + status="queued", + request_start_time=100, + request_end_time=150, + ) + + with patch( + "frigate.api.export.list_active_export_jobs", + return_value=[queued_job], + ): + with AuthTestClient(self.app) as client: + response = client.get("/jobs/export") + + assert response.status_code == 200 + assert response.json() == [queued_job.to_dict()] + + def test_reap_stale_exports_deletes_rows_with_no_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + stale_video = os.path.join(tmpdir, "stale.mp4") + stale_thumb = os.path.join(tmpdir, "stale.webp") + # stale_video is intentionally NOT created + with open(stale_thumb, "w") as handle: + handle.write("thumb") + + Export.create( + id="stale_no_file", + camera="front_door", + name="Stuck export", + date=100, + video_path=stale_video, + thumb_path=stale_thumb, + in_progress=True, + ) + + reap_stale_exports() + + assert Export.get_or_none(Export.id == "stale_no_file") is None + assert not os.path.exists(stale_thumb) + + def test_reap_stale_exports_recovers_rows_with_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + intact_video = os.path.join(tmpdir, "intact.mp4") + intact_thumb = os.path.join(tmpdir, "intact.webp") + with open(intact_video, "wb") as handle: + handle.write(b"not actually an mp4 but non-empty") + with open(intact_thumb, "wb") as handle: + handle.write(b"thumb") + + case = ExportCase.create( + id="case_for_stale", + name="Curated case", + description="", + created_at=10, + updated_at=10, + ) + + Export.create( + id="stale_with_file", + camera="front_door", + name="Recoverable export", + date=200, + video_path=intact_video, + thumb_path=intact_thumb, + in_progress=True, + export_case=case, + ) + + reap_stale_exports() + + recovered = Export.get(Export.id == "stale_with_file") + assert recovered.in_progress is False + # Case link must be cleared so the user re-triages the recovered row + assert recovered.export_case is None + # The case itself is untouched + assert ExportCase.get_or_none(ExportCase.id == "case_for_stale") is not None + # Recovered files must NOT be unlinked + assert os.path.exists(intact_video) + assert os.path.exists(intact_thumb) + + def test_reap_stale_exports_delete_path_severs_case_link(self): + with tempfile.TemporaryDirectory() as tmpdir: + missing_video = os.path.join(tmpdir, "missing.mp4") + # file intentionally not created + + case = ExportCase.create( + id="case_losing_member", + name="Case losing a member", + description="", + created_at=20, + updated_at=20, + ) + + Export.create( + id="stale_in_case_no_file", + camera="front_door", + name="Stuck and in a case", + date=250, + video_path=missing_video, + thumb_path="", + in_progress=True, + export_case=case, + ) + + reap_stale_exports() + + # The export row is gone entirely + assert Export.get_or_none(Export.id == "stale_in_case_no_file") is None + # The case stays but has no exports pointing at it + remaining_case = ExportCase.get(ExportCase.id == "case_losing_member") + assert list(remaining_case.exports) == [] + + def test_reap_stale_exports_deletes_rows_with_empty_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + empty_video = os.path.join(tmpdir, "empty.mp4") + # Create a zero-byte file — partial ffmpeg output + open(empty_video, "w").close() + + Export.create( + id="stale_empty_file", + camera="front_door", + name="Zero byte export", + date=300, + video_path=empty_video, + thumb_path="", + in_progress=True, + ) + + reap_stale_exports() + + assert Export.get_or_none(Export.id == "stale_empty_file") is None + assert not os.path.exists(empty_video) + + def test_reap_stale_exports_skips_completed_rows(self): + with tempfile.TemporaryDirectory() as tmpdir: + done_video = os.path.join(tmpdir, "done.mp4") + with open(done_video, "wb") as handle: + handle.write(b"done") + + Export.create( + id="already_done", + camera="front_door", + name="Completed export", + date=400, + video_path=done_video, + thumb_path="", + in_progress=False, + ) + + reap_stale_exports() + + row = Export.get(Export.id == "already_done") + assert row.in_progress is False + assert os.path.exists(done_video) + + def test_batch_export_without_case_goes_to_uncategorized(self): + """Exports without a case target go to uncategorized.""" + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + # --- /exports/batch (item-shaped multi-export) --------------------------- + + def test_batch_export_happy_path_creates_case_and_queues_all(self): + self._insert_recording("rec-front", "front_door", 100, 400) + self._insert_recording("rec-back", "backyard", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "front_door", + "start_time": 200, + "end_time": 240, + }, + { + "camera": "backyard", + "start_time": 300, + "end_time": 340, + }, + ], + "new_case_name": "Incident Apr 11", + "new_case_description": "Review items", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 3 + assert all(r["success"] for r in response_json["results"]) + assert [r["item_index"] for r in response_json["results"]] == [0, 1, 2] + assert start_export_job.call_count == 3 + + case = ExportCase.get(ExportCase.id == response_json["export_case_id"]) + assert case.name == "Incident Apr 11" + assert case.description == "Review items" + + def test_batch_export_existing_case_does_not_create_new_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="existing_case", + name="Existing", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "existing_case", + }, + ) + + assert response.status_code == 202 + assert response.json()["export_case_id"] == "existing_case" + # No additional case was created + assert ExportCase.select().count() == 1 + + def test_batch_export_empty_items_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={"items": [], "new_case_name": "Empty"}, + ) + + assert response.status_code == 422 + + def test_batch_export_over_limit_rejected(self): + items = [ + {"camera": "front_door", "start_time": 100 + i, "end_time": 100 + i + 5} + for i in range(51) + ] + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={"items": items, "new_case_name": "Too many"}, + ) + + assert response.status_code == 422 + + def test_batch_export_end_before_start_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 200, + "end_time": 100, + } + ], + "new_case_name": "Bad range", + }, + ) + + assert response.status_code == 422 + assert ( + response.json()["detail"][0]["msg"] + == "Value error, end_time must be after start_time" + ) + + def test_batch_export_non_admin_without_case_goes_to_uncategorized(self): + """Non-admin batch exports go to uncategorized.""" + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 100, + "end_time": 150, + } + ], + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + def test_batch_export_camera_access_denied_fails_closed(self): + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter + + self._insert_recording("rec-front", "front_door", 100, 400) + + async def restricted(request: Request): + return ["front_door"] + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = restricted + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", # not in allowed list + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Nope", + }, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + # No case created + assert ExportCase.select().count() == 0 + + def test_batch_export_case_not_found(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "does_not_exist", + }, + ) + + assert response.status_code == 404 + + def test_batch_export_per_item_missing_recordings_partial_success(self): + self._insert_recording("rec-front", "front_door", 100, 200) + # backyard has no recordings at all + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Partial", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + results_by_camera = {r["camera"]: r for r in response_json["results"]} + assert results_by_camera["front_door"]["success"] is True + assert results_by_camera["backyard"]["success"] is False + assert ( + results_by_camera["backyard"]["error"] + == "No recordings found for time range" + ) + start_export_job.assert_called_once() + + # Case is still created because at least one item succeeded + assert ( + ExportCase.get(ExportCase.id == response_json["export_case_id"]) is not None + ) + + def test_batch_export_same_camera_different_ranges_one_missing(self): + # Recording covers 100-200 only. First item fits, second does not. + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "front_door", + "start_time": 500, + "end_time": 540, + }, + ], + "new_case_name": "Split recordings", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + results = response_json["results"] + assert results[0]["success"] is True + assert results[0]["item_index"] == 0 + assert results[1]["success"] is False + assert results[1]["item_index"] == 1 + assert results[1]["error"] == "No recordings found for time range" + # Both results carry the same camera — item_index is the only way + # the client can tell them apart. + assert results[0]["camera"] == results[1]["camera"] == "front_door" + start_export_job.assert_called_once() + + def test_batch_export_all_missing_recordings_rolls_back_case(self): + # No recordings inserted at all + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Should rollback", + }, + ) + + assert response.status_code == 400 + start_export_job.assert_not_called() + assert ExportCase.select().count() == 0 + + def test_batch_export_preflight_queue_full(self): + self._insert_recording("rec-front", "front_door", 100, 400) + self._insert_recording("rec-back", "backyard", 100, 400) + + with patch( + "frigate.api.export.available_export_queue_slots", + return_value=1, + ): + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Queue full", + }, + ) + + assert response.status_code == 503 + start_export_job.assert_not_called() + assert ExportCase.select().count() == 0 + + def test_batch_export_all_enqueue_calls_fail_rolls_back_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + def boom(_config, _job): + raise RuntimeError("simulated enqueue failure") + + with patch( + "frigate.api.export.start_export_job", + side_effect=boom, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Will fail", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_ids"] == [] + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + def test_batch_export_rejects_invalid_image_path(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "image_path": "/etc/passwd", + } + ], + "new_case_name": "Bad image", + }, + ) + + assert response.status_code == 400 + assert ExportCase.select().count() == 0 + + def test_batch_export_non_admin_can_queue(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Viewer export", + }, + ) + + assert response.status_code == 202 + assert len(response.json()["export_ids"]) == 1 + + def test_batch_export_non_admin_cannot_attach_to_existing_case(self): + """Non-admins can create cases via new_case_name but cannot attach + to existing cases they did not create. Closes a write-path hole that + would otherwise be reachable through the unfiltered GET /cases list. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="admins_only_case", + name="Admins only", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "admins_only_case", + }, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + # No exports should have been created in the target case + assert Export.select().count() == 0 + + def test_batch_export_admin_can_attach_to_existing_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="shared_case", + name="Shared", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "shared_case", + }, + ) + + assert response.status_code == 202 + assert response.json()["export_case_id"] == "shared_case" + # No additional case created + assert ExportCase.select().count() == 1 + + def test_batch_export_roundtrips_client_item_id(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "client_item_id": "review-123", + } + ], + "new_case_name": "Client id test", + }, + ) + + assert response.status_code == 202 + assert response.json()["results"][0]["client_item_id"] == "review-123" + + def test_single_export_non_admin_cannot_attach_to_existing_case(self): + """The single-export route has the same hole: non-admins should not + be able to smuggle exports into an existing case via export_case_id. + Admin-gating this matches /exports/batch. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="admins_only_case", + name="Admins only", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"export_case_id": "admins_only_case"}, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + assert Export.select().count() == 0 + + def test_single_export_non_admin_can_still_export_without_case(self): + """Regression guard: the admin gate only applies to export_case_id, + not to single exports in general. Non-admins should still be able + to start a single export for a camera they have access to. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={}, + ) + + assert response.status_code == 202 + assert response.json()["success"] is True + + # ── Bulk delete exports ──────────────────────────────────────── + + def test_bulk_delete_exports_success(self): + """All IDs exist, none in-progress → 200, all deleted.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + Export.create( + id="exp2", + camera="front_door", + name="export_2", + date=200, + video_path="/tmp/exp2.mp4", + thumb_path="/tmp/exp2.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1", "exp2"]}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + assert Export.select().count() == 0 + + def test_bulk_delete_exports_single_item(self): + """Regression: single-item delete via batch endpoint.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1"]}, + ) + + assert response.status_code == 200 + assert Export.select().count() == 0 + + def test_bulk_delete_exports_some_missing(self): + """Some IDs don't exist → 404, nothing deleted.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1", "nonexistent"]}, + ) + + assert response.status_code == 404 + # Nothing deleted + assert Export.select().count() == 1 + + def test_bulk_delete_exports_all_missing(self): + """All IDs don't exist → 404.""" + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["nope1", "nope2"]}, + ) + + assert response.status_code == 404 + + def test_bulk_delete_exports_in_progress(self): + """Some exports in-progress → 400, nothing deleted.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path=f"{os.environ.get('EXPORT_DIR', '/media/frigate/exports')}/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=True, + ) + + with patch( + "frigate.api.export._get_files_in_use", + return_value={"exp1.mp4"}, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + json={"ids": ["exp1"]}, + ) + + assert response.status_code == 400 + assert Export.select().count() == 1 + + def test_bulk_delete_exports_non_admin_rejected(self): + """Non-admin users cannot bulk delete.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"ids": ["exp1"]}, + ) + + assert response.status_code == 403 + assert Export.select().count() == 1 + + # ── Bulk reassign exports ────────────────────────────────────── + + def test_bulk_reassign_exports_to_case(self): + """All IDs exist, case exists → 200, all reassigned.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + Export.create( + id="exp2", + camera="front_door", + name="export_2", + date=200, + video_path="/tmp/exp2.mp4", + thumb_path="/tmp/exp2.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1", "exp2"], "export_case_id": "case1"}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + for exp_id in ["exp1", "exp2"]: + exp = Export.get(Export.id == exp_id) + assert exp.export_case_id == "case1" + + def test_bulk_reassign_exports_to_null(self): + """Reassign to null (uncategorize) → 200.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + export_case="case1", + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1"], "export_case_id": None}, + ) + + assert response.status_code == 200 + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id is None + + def test_bulk_reassign_exports_single_item(self): + """Regression: single-item reassign via batch endpoint.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1"], "export_case_id": "case1"}, + ) + + assert response.status_code == 200 + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id == "case1" + + def test_bulk_reassign_exports_some_missing(self): + """Some IDs don't exist → 404, nothing reassigned.""" + ExportCase.create( + id="case1", + name="Test Case", + description="", + created_at=10, + updated_at=10, + ) + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={ + "ids": ["exp1", "nonexistent"], + "export_case_id": "case1", + }, + ) + + assert response.status_code == 404 + # Nothing reassigned + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id is None + + def test_bulk_reassign_exports_case_not_found(self): + """Target case doesn't exist → 404.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + json={"ids": ["exp1"], "export_case_id": "nonexistent"}, + ) + + assert response.status_code == 404 + exp = Export.get(Export.id == "exp1") + assert exp.export_case_id is None + + def test_bulk_reassign_exports_non_admin_rejected(self): + """Non-admin users cannot bulk reassign.""" + Export.create( + id="exp1", + camera="front_door", + name="export_1", + date=100, + video_path="/tmp/exp1.mp4", + thumb_path="/tmp/exp1.jpg", + in_progress=False, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/reassign", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"ids": ["exp1"], "export_case_id": None}, + ) + + assert response.status_code == 403 diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 5de4ba86c..52f10d64b 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -82,14 +82,26 @@ export class ApiMocker { route.fulfill({ json: stats }), ); - // Reviews - await this.page.route("**/api/reviews**", (route) => { - const url = route.request().url(); - if (url.includes("summary")) { - return route.fulfill({ json: reviewSummary }); - } - return route.fulfill({ json: reviews }); - }); + // Reviews. The real backend exposes /review (singular) for the main + // list and /review/summary for the summary — the previous plural glob + // (**/api/reviews**) never matched either endpoint, so review-dependent + // tests silently ran without data. The POST mutations at /reviews/viewed + // and /reviews/delete (plural) still fall through to the generic + // mutation catch-all further down the file. + await this.page.route(/\/api\/review\/summary/, (route) => + route.fulfill({ json: reviewSummary }), + ); + await this.page.route(/\/api\/review(\?|$)/, (route) => + route.fulfill({ json: reviews }), + ); + + // Export jobs. The Exports page polls this every 2s while any export + // is in_progress; without a mock route it falls through to the preview + // server which returns 500 and makes the page flap between loading and + // rendered state, breaking tests that navigate to /export. + await this.page.route("**/api/jobs/export", (route) => + route.fulfill({ json: [] }), + ); // Recordings summary await this.page.route("**/api/recordings/summary**", (route) => diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 07454231a..605e2dca4 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -1,74 +1,734 @@ -/** - * Export page tests -- HIGH tier. - * - * Tests export card rendering with mock data, search filtering, - * and delete confirmation dialog. - */ - import { test, expect } from "../fixtures/frigate-test"; -test.describe("Export Page - Cards @high", () => { - test("export page renders export cards from mock data", async ({ +test.describe("Export Page - Overview @high", () => { + test("renders uncategorized exports and case cards from mock data", async ({ frigateApp, }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - // Should show export names from our mock data + await expect( frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible(); await expect( - frigateApp.page.getByText("Backyard - Car Detection"), + frigateApp.page.getByText("Garage - In Progress"), + ).toBeVisible(); + await expect( + frigateApp.page.getByText("Package Theft Investigation"), ).toBeVisible(); }); - test("export page shows in-progress indicator", async ({ frigateApp }) => { + test("search filters uncategorized exports", async ({ frigateApp }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - // "Garage - In Progress" export should be visible - await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible( - { timeout: 10_000 }, - ); + + const searchInput = frigateApp.page.getByPlaceholder(/search/i).first(); + await searchInput.fill("Front Door"); + + await expect( + frigateApp.page.getByText("Front Door - Person Alert"), + ).toBeVisible(); + await expect( + frigateApp.page.getByText("Backyard - Car Detection"), + ).toBeHidden(); + await expect( + frigateApp.page.getByText("Garage - In Progress"), + ).toBeHidden(); }); - test("export page shows case grouping", async ({ frigateApp }) => { + test("new case button opens the create case dialog", async ({ + frigateApp, + }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(3000); - // Cases may render differently depending on API response shape - const pageText = await frigateApp.page.textContent("#pageRoot"); - expect(pageText?.length).toBeGreaterThan(0); + + await frigateApp.page.getByRole("button", { name: "New Case" }).click(); + + await expect( + frigateApp.page.getByRole("dialog").filter({ hasText: "Create Case" }), + ).toBeVisible(); + await expect(frigateApp.page.getByPlaceholder("Case name")).toBeVisible(); }); }); -test.describe("Export Page - Search @high", () => { - test("search input filters export list", async ({ frigateApp }) => { +test.describe("Export Page - Case Detail @high", () => { + test("opening a case shows its detail view and associated export", async ({ + frigateApp, + }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - const searchInput = frigateApp.page.locator( - '#pageRoot input[type="text"], #pageRoot input', + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + + await expect( + frigateApp.page.getByRole("heading", { + name: "Package Theft Investigation", + }), + ).toBeVisible(); + await expect( + frigateApp.page.getByText("Backyard - Car Detection"), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: "Add Export" }), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: "Edit Case" }), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: "Delete Case" }), + ).toBeVisible(); + }); + + test("edit case opens a prefilled dialog", async ({ frigateApp }) => { + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Edit Case" }).click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: "Edit Case" }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("input")).toHaveValue( + "Package Theft Investigation", ); - if ( - (await searchInput.count()) > 0 && - (await searchInput.first().isVisible()) - ) { - // Type a search term that matches one export - await searchInput.first().fill("Front Door"); - await frigateApp.page.waitForTimeout(500); - // "Front Door - Person Alert" should still be visible - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); + await expect(dialog.locator("textarea")).toHaveValue( + "Review of suspicious activity near the front porch", + ); + }); + + test("add export shows completed uncategorized exports for assignment", async ({ + frigateApp, + }) => { + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Add Export" }).click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: "Add Export to Package Theft Investigation" }); + await expect(dialog).toBeVisible(); + // Completed, uncategorized exports are selectable + await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible(); + // In-progress exports are intentionally hidden by AssignExportDialog + // (see Exports.tsx filteredExports) — they can't be assigned until + // they finish, so they should not show in the picker. + await expect(dialog.getByText("Garage - In Progress")).toBeHidden(); + }); + + test("delete case opens a confirmation dialog", async ({ frigateApp }) => { + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Delete Case" }).click(); + + const dialog = frigateApp.page + .getByRole("alertdialog") + .filter({ hasText: "Delete Case" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/Package Theft Investigation/)).toBeVisible(); + }); + + test("delete case can also delete its exports", async ({ frigateApp }) => { + let deleteRequestUrl: string | null = null; + let deleteCaseCompleted = false; + + const initialCases = [ + { + id: "case-001", + name: "Package Theft Investigation", + description: "Review of suspicious activity near the front porch", + created_at: 1775407931.3863528, + updated_at: 1775483531.3863528, + }, + ]; + + const initialExports = [ + { + id: "export-001", + camera: "front_door", + name: "Front Door - Person Alert", + date: 1775490731.3863528, + video_path: "/exports/export-001.mp4", + thumb_path: "/exports/export-001-thumb.jpg", + in_progress: false, + export_case_id: null, + }, + { + id: "export-002", + camera: "backyard", + name: "Backyard - Car Detection", + date: 1775483531.3863528, + video_path: "/exports/export-002.mp4", + thumb_path: "/exports/export-002-thumb.jpg", + in_progress: false, + export_case_id: "case-001", + }, + { + id: "export-003", + camera: "garage", + name: "Garage - In Progress", + date: 1775492531.3863528, + video_path: "/exports/export-003.mp4", + thumb_path: "/exports/export-003-thumb.jpg", + in_progress: true, + export_case_id: null, + }, + ]; + + await frigateApp.page.route(/\/api\/cases(?:$|\?|\/)/, async (route) => { + const request = route.request(); + + if (request.method() === "DELETE") { + deleteRequestUrl = request.url(); + deleteCaseCompleted = true; + return route.fulfill({ json: { success: true } }); + } + + if (request.method() === "GET") { + return route.fulfill({ + json: deleteCaseCompleted ? [] : initialCases, + }); + } + + return route.fallback(); + }); + + await frigateApp.page.route("**/api/exports**", async (route) => { + if (route.request().method() !== "GET") { + return route.fallback(); + } + + return route.fulfill({ + json: deleteCaseCompleted + ? initialExports.filter((exp) => exp.export_case_id !== "case-001") + : initialExports, + }); + }); + + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Delete Case" }).click(); + + const dialog = frigateApp.page + .getByRole("alertdialog") + .filter({ hasText: "Delete Case" }); + await expect(dialog).toBeVisible(); + + const deleteExportsSwitch = dialog.getByRole("switch", { + name: "Also delete exports", + }); + await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "false"); + await expect( + dialog.getByText( + "Exports will remain available as uncategorized exports.", + ), + ).toBeVisible(); + + await deleteExportsSwitch.click(); + + await expect(deleteExportsSwitch).toHaveAttribute("aria-checked", "true"); + await expect( + dialog.getByText("All exports in this case will be permanently deleted."), + ).toBeVisible(); + + await dialog.getByRole("button", { name: /^delete$/i }).click(); + + await expect + .poll(() => deleteRequestUrl) + .toContain("/api/cases/case-001?delete_exports=true"); + + await expect(dialog).toBeHidden(); + await expect( + frigateApp.page.getByRole("heading", { + name: "Package Theft Investigation", + }), + ).toBeHidden(); + await expect( + frigateApp.page.getByText("Backyard - Car Detection"), + ).toBeHidden(); + await expect( + frigateApp.page.getByText("Front Door - Person Alert"), + ).toBeVisible(); + }); +}); + +test.describe("Export Page - Empty State @high", () => { + test("renders the empty state when there are no exports or cases", async ({ + frigateApp, + }) => { + await frigateApp.page.route("**/api/export**", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.page.route("**/api/exports**", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.page.route("**/api/cases", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.page.route("**/api/cases**", (route) => + route.fulfill({ json: [] }), + ); + + await frigateApp.goto("/export"); + + await expect(frigateApp.page.getByText("No exports found")).toBeVisible(); + }); +}); + +test.describe("Export Page - Mobile @high @mobile", () => { + test("mobile can open an export preview dialog", async ({ frigateApp }) => { + test.skip(!frigateApp.isMobile, "Mobile-only assertion"); + + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Front Door - Person Alert") + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: "Front Door - Person Alert" }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("video")).toBeVisible(); + }); +}); + +test.describe("Multi-Review Export @high", () => { + // Two alert reviews close enough to "now" to fall within the + // default last-24-hours review window. Using numeric timestamps + // because the TS ReviewSegment type expects numbers even though + // the backend pydantic model serializes datetime as ISO strings — + // the app reads these as numbers for display math. + const now = Date.now() / 1000; + const mockReviews = [ + { + id: "mex-review-001", + camera: "front_door", + start_time: now - 600, + end_time: now - 580, + has_been_reviewed: false, + severity: "alert", + thumb_path: "/clips/front_door/mex-review-001-thumb.jpg", + data: { + audio: [], + detections: ["person-001"], + objects: ["person"], + sub_labels: [], + significant_motion_areas: [], + zones: ["front_yard"], + }, + }, + { + id: "mex-review-002", + camera: "backyard", + start_time: now - 1200, + end_time: now - 1170, + has_been_reviewed: false, + severity: "alert", + thumb_path: "/clips/backyard/mex-review-002-thumb.jpg", + data: { + audio: [], + detections: ["car-002"], + objects: ["car"], + sub_labels: [], + significant_motion_areas: [], + zones: ["driveway"], + }, + }, + ]; + + // 51 alert reviews, all front_door, spaced 5 minutes apart. Used by the + // over-limit test to trigger Ctrl+A select-all and verify the Export + // button is hidden at 51 selected. + const oversizedReviews = Array.from({ length: 51 }, (_, i) => ({ + id: `mex-oversized-${i.toString().padStart(3, "0")}`, + camera: "front_door", + start_time: now - 60 * 60 - i * 300, + end_time: now - 60 * 60 - i * 300 + 20, + has_been_reviewed: false, + severity: "alert", + thumb_path: `/clips/front_door/mex-oversized-${i}-thumb.jpg`, + data: { + audio: [], + detections: [`person-${i}`], + objects: ["person"], + sub_labels: [], + significant_motion_areas: [], + zones: ["front_yard"], + }, + })); + + const mockSummary = { + last24Hours: { + reviewed_alert: 0, + reviewed_detection: 0, + total_alert: 2, + total_detection: 0, + }, + }; + + async function routeReviews( + page: import("@playwright/test").Page, + reviews: unknown[], + ) { + // Intercept the actual `/api/review` endpoint (singular — the + // default api-mocker only registers `/api/reviews**` (plural) + // which does not match the real request URL). + await page.route(/\/api\/review(\?|$)/, (route) => + route.fulfill({ json: reviews }), + ); + await page.route(/\/api\/review\/summary/, (route) => + route.fulfill({ json: mockSummary }), + ); + } + + test.beforeEach(async ({ frigateApp }) => { + await routeReviews(frigateApp.page, mockReviews); + // Empty cases list by default so the dialog defaults to "new case". + // Individual tests override this to populate existing cases. + await frigateApp.page.route("**/api/cases", (route) => + route.fulfill({ json: [] }), + ); + }); + + async function selectTwoReviews(frigateApp: { + page: import("@playwright/test").Page; + }) { + // Every review card has className `review-item` on its wrapper + // (see EventView.tsx). Cards also have data-start attributes that + // we can key off if needed. + const reviewItems = frigateApp.page.locator(".review-item"); + await reviewItems.first().waitFor({ state: "visible", timeout: 10_000 }); + + // Meta-click the first two items to enter multi-select mode. + // PreviewThumbnailPlayer reads e.metaKey to decide multi-select. + await reviewItems.nth(0).click({ modifiers: ["Meta"] }); + await reviewItems.nth(1).click(); + } + + test("selecting two reviews reveals the export button", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + await frigateApp.goto("/review"); + + await selectTwoReviews(frigateApp); + + // Action group replaces the filter bar once items are selected + await expect(frigateApp.page.getByText(/2.*selected/i)).toBeVisible({ + timeout: 5_000, + }); + + const exportButton = frigateApp.page.getByRole("button", { + name: /export/i, + }); + await expect(exportButton).toBeVisible(); + }); + + test("clicking export opens the multi-review dialog with correct title", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + await frigateApp.goto("/review"); + + await selectTwoReviews(frigateApp); + + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: /Export 2 reviews/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + // The dialog uses a Select trigger for case selection (admins). The + // default "None" value is shown on the trigger. + await expect(dialog.locator("button[role='combobox']")).toBeVisible(); + await expect(dialog.getByText(/None/)).toBeVisible(); + }); + + test("starting an export posts the expected payload and navigates to the case", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + let capturedPayload: unknown = null; + await frigateApp.page.route("**/api/exports/batch", async (route) => { + capturedPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + json: { + export_case_id: "new-case-xyz", + export_ids: ["front_door_a", "backyard_b"], + results: [ + { + camera: "front_door", + export_id: "front_door_a", + success: true, + status: "queued", + error: null, + item_index: 0, + }, + { + camera: "backyard", + export_id: "backyard_b", + success: true, + status: "queued", + error: null, + item_index: 1, + }, + ], + }, + }); + }); + + await frigateApp.goto("/review"); + await selectTwoReviews(frigateApp); + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: /Export 2 reviews/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Select "Create new case" from the case dropdown (default is "None") + await dialog.locator("button[role='combobox']").click(); + await frigateApp.page + .getByRole("option", { name: /Create new case/i }) + .click(); + + const nameInput = dialog.locator("input").first(); + await nameInput.fill("E2E Incident"); + + await dialog.getByRole("button", { name: /export 2 reviews/i }).click(); + + // Wait for the POST to fire + await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull(); + + const payload = capturedPayload as { + items: Array<{ + camera: string; + start_time: number; + end_time: number; + image_path?: string; + client_item_id?: string; + }>; + new_case_name?: string; + export_case_id?: string; + }; + expect(payload.items).toHaveLength(2); + expect(payload.new_case_name).toBe("E2E Incident"); + // When creating a new case, we must NOT also send export_case_id — + // the two fields are mutually exclusive on the backend. + expect(payload.export_case_id).toBeUndefined(); + expect(payload.items.map((i) => i.camera).sort()).toEqual([ + "backyard", + "front_door", + ]); + // Each item must preserve REVIEW_PADDING (4s) on the edges — + // i.e. the padded window is 8s longer than the original review. + // The mock reviews above have 20s and 30s raw durations, so the + // expected padded durations are 28s and 38s. + const paddedDurations = payload.items + .map((i) => i.end_time - i.start_time) + .sort((a, b) => a - b); + expect(paddedDurations).toEqual([28, 38]); + // Thumbnails should be passed through per item + for (const item of payload.items) { + expect(item.image_path).toMatch(/mex-review-\d+-thumb\.jpg$/); } - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - }); -}); + expect(payload.items.map((item) => item.client_item_id)).toEqual([ + "mex-review-001", + "mex-review-002", + ]); -test.describe("Export Page - Controls @high", () => { - test("export page filter controls are present", async ({ frigateApp }) => { - await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(1000); - const buttons = frigateApp.page.locator("#pageRoot button"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); + await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, { + timeout: 5_000, + }); + }); + + test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only Drawer assertion"); + + await frigateApp.goto("/review"); + await selectTwoReviews(frigateApp); + + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + // On mobile the component renders a shadcn Drawer, which uses + // role="dialog" but sets data-vaul-drawer. Desktop renders a + // shadcn Dialog with role="dialog" but no data-vaul-drawer. + // The title and submit button both contain "Export 2 reviews", so + // assert each element distinctly: the title is a heading and the + // submit button has role="button". + const drawer = frigateApp.page.locator("[data-vaul-drawer]"); + await expect(drawer).toBeVisible({ timeout: 5_000 }); + await expect( + drawer.getByRole("heading", { name: /Export 2 reviews/i }), + ).toBeVisible(); + await expect( + drawer.getByRole("button", { name: /export 2 reviews/i }), + ).toBeVisible(); + }); + + test("hides export button when more than 50 reviews are selected", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop select-all keyboard flow"); + + // Override the default 2-review mock with 51 reviews before + // navigation. Playwright matches routes last-registered-first so + // this takes precedence over the beforeEach. + await routeReviews(frigateApp.page, oversizedReviews); + + await frigateApp.goto("/review"); + + // Wait for any review item to render before firing the shortcut + await frigateApp.page + .locator(".review-item") + .first() + .waitFor({ state: "visible", timeout: 10_000 }); + + // Ctrl+A triggers onSelectAllReviews (see EventView.tsx useKeyboardListener) + await frigateApp.page.keyboard.press("Control+a"); + + // The action group should show "51 selected" but no Export button. + // Mark-as-reviewed is still there so the action bar is rendered. + // Scope the "Mark as reviewed" lookup to its exact aria-label because + // the page can render other "mark as reviewed" controls elsewhere + // (e.g. on individual cards) that would trip strict-mode matching. + await expect(frigateApp.page.getByText(/51.*selected/i)).toBeVisible({ + timeout: 5_000, + }); + await expect( + frigateApp.page.getByRole("button", { name: "Mark as reviewed" }), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: /^export$/i }), + ).toHaveCount(0); + }); + + test("attaching to an existing case sends export_case_id without new_case_name", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + // Seed one existing case so the dialog can offer the "existing" branch. + // The fixture mocks the user as admin (adminProfile()), so useIsAdmin() + // is true and the dialog renders the "Existing case" radio. + await frigateApp.page.route("**/api/cases", (route) => + route.fulfill({ + json: [ + { + id: "existing-case-abc", + name: "Incident #42", + description: "", + created_at: now - 3600, + updated_at: now - 3600, + }, + ], + }), + ); + + let capturedPayload: unknown = null; + await frigateApp.page.route("**/api/exports/batch", async (route) => { + capturedPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + json: { + export_case_id: "existing-case-abc", + export_ids: ["front_door_a", "backyard_b"], + results: [ + { + camera: "front_door", + export_id: "front_door_a", + success: true, + status: "queued", + error: null, + item_index: 0, + }, + { + camera: "backyard", + export_id: "backyard_b", + success: true, + status: "queued", + error: null, + item_index: 1, + }, + ], + }, + }); + }); + + await frigateApp.goto("/review"); + await selectTwoReviews(frigateApp); + + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: /Export 2 reviews/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Open the Case Select dropdown and pick the seeded case directly. + // The dialog now uses a single Select listing existing cases above + // the "Create new case" option — no radio toggle needed. + const selectTrigger = dialog.locator("button[role='combobox']").first(); + await selectTrigger.waitFor({ state: "visible", timeout: 5_000 }); + await selectTrigger.click(); + + // The dropdown portal renders outside the dialog + await frigateApp.page.getByRole("option", { name: /Incident #42/ }).click(); + + await dialog.getByRole("button", { name: /export 2 reviews/i }).click(); + + await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull(); + + const payload = capturedPayload as { + items: unknown[]; + new_case_name?: string; + new_case_description?: string; + export_case_id?: string; + }; + expect(payload.export_case_id).toBe("existing-case-abc"); + expect(payload.new_case_name).toBeUndefined(); + expect(payload.new_case_description).toBeUndefined(); + expect(payload.items).toHaveLength(2); + + // Navigate should hit /export. useSearchEffect consumes the caseId + // query param and strips it once the case is found in the cases list, + // so we assert on the path, not the query string. + await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, { + timeout: 5_000, + }); }); }); diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 9a6f68daf..6c2a2cac2 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -50,24 +50,79 @@ "placeholder": "Name the Export" }, "case": { + "newCaseOption": "Create new case", + "newCaseNamePlaceholder": "New case name", + "newCaseDescriptionPlaceholder": "Case description", "label": "Case", + "nonAdminHelp": "A new case will be created for these exports.", "placeholder": "Select a case" }, "select": "Select", "export": "Export", + "queueing": "Queueing Export...", "selectOrExport": "Select or Export", + "tabs": { + "export": "Single Camera", + "multiCamera": "Multi-Camera" + }, + "multiCamera": { + "timeRange": "Time range", + "selectFromTimeline": "Select from Timeline", + "cameraSelection": "Cameras", + "cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected", + "checkingActivity": "Checking camera activity...", + "noCameras": "No cameras available", + "detectionCount_one": "1 tracked object", + "detectionCount_other": "{{count}} tracked objects", + "nameLabel": "Export name", + "namePlaceholder": "Optional base name for these exports", + "queueingButton": "Queueing Exports...", + "exportButton_one": "Export 1 Camera", + "exportButton_other": "Export {{count}} Cameras" + }, + "multi": { + "title": "Export {{count}} reviews", + "title_one": "Export 1 review", + "title_other": "Export {{count}} reviews", + "description": "Export each selected review. All exports will be grouped under a single case.", + "descriptionNoCase": "Export each selected review.", + "caseNamePlaceholder": "Review export - {{date}}", + "exportButton": "Export {{count}} reviews", + "exportButton_one": "Export 1 review", + "exportButton_other": "Export {{count}} reviews", + "exportingButton": "Exporting...", + "toast": { + "started_one": "Started 1 export. Opening the case now.", + "started_other": "Started {{count}} exports. Opening the case now.", + "startedNoCase_one": "Started 1 export.", + "startedNoCase_other": "Started {{count}} exports.", + "partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}", + "failed": "Failed to start {{total}} exports. Failed: {{failedItems}}" + } + }, "toast": { "success": "Successfully started export. View the file in the exports page.", + "queued": "Export queued. View progress in the exports page.", "view": "View", + "batchSuccess_one": "Started 1 export. Opening the case now.", + "batchSuccess_other": "Started {{count}} exports. Opening the case now.", + "batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", + "batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}", + "batchQueuedSuccess_one": "Queued 1 export. Opening the case now.", + "batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.", + "batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", + "batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}", "error": { - "failed": "Failed to start export: {{error}}", + "failed": "Failed to queue export: {{error}}", "endTimeMustAfterStartTime": "End time must be after start time", "noVaildTimeSelected": "No valid time range selected" } }, "fromTimeline": { "saveExport": "Save Export", - "previewExport": "Preview Export" + "queueingExport": "Queueing Export...", + "previewExport": "Preview Export", + "useThisRange": "Use This Range" } }, "streaming": { diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json index 46cd06ead..5e64952d8 100644 --- a/web/public/locales/en/views/exports.json +++ b/web/public/locales/en/views/exports.json @@ -20,14 +20,30 @@ "downloadVideo": "Download video", "editName": "Edit name", "deleteExport": "Delete export", - "assignToCase": "Add to case" + "assignToCase": "Add to case", + "removeFromCase": "Remove from case" + }, + "toolbar": { + "newCase": "New Case", + "addExport": "Add Export", + "editCase": "Edit Case", + "deleteCase": "Delete Case" }, "toast": { "error": { "renameExportFailed": "Failed to rename export: {{errorMessage}}", - "assignCaseFailed": "Failed to update case assignment: {{errorMessage}}" + "assignCaseFailed": "Failed to update case assignment: {{errorMessage}}", + "caseSaveFailed": "Failed to save case: {{errorMessage}}", + "caseDeleteFailed": "Failed to delete case: {{errorMessage}}" } }, + "deleteCase": { + "label": "Delete Case", + "desc": "Are you sure you want to delete {{caseName}}?", + "descKeepExports": "Exports will remain available as uncategorized exports.", + "descDeleteExports": "All exports in this case will be permanently deleted.", + "deleteExports": "Also delete exports" + }, "caseDialog": { "title": "Add to case", "description": "Choose an existing case or create a new one.", @@ -35,5 +51,73 @@ "newCaseOption": "Create new case", "nameLabel": "Case name", "descriptionLabel": "Description" + }, + "caseCard": { + "emptyCase": "No exports yet" + }, + "jobCard": { + "defaultName": "{{camera}} export", + "queued": "Queued", + "running": "Running" + }, + "caseView": { + "noDescription": "No description", + "createdAt": "Created {{value}}", + "exportCount_one": "1 export", + "exportCount_other": "{{count}} exports", + "cameraCount_one": "1 camera", + "cameraCount_other": "{{count}} cameras", + "showMore": "Show more", + "showLess": "Show less", + "emptyTitle": "This case is empty", + "emptyDescription": "Add existing uncategorized exports to keep the case organized.", + "emptyDescriptionNoExports": "There are no uncategorized exports available to add yet." + }, + "caseEditor": { + "createTitle": "Create Case", + "editTitle": "Edit Case", + "namePlaceholder": "Case name", + "descriptionPlaceholder": "Add notes or context for this case" + }, + "addExportDialog": { + "title": "Add Export to {{caseName}}", + "searchPlaceholder": "Search uncategorized exports", + "empty": "No uncategorized exports match this search.", + "addButton_one": "Add 1 Export", + "addButton_other": "Add {{count}} Exports", + "adding": "Adding..." + }, + "selected_one": "{{count}} selected", + "selected_other": "{{count}} selected", + "bulkActions": { + "addToCase": "Add to Case", + "moveToCase": "Move to Case", + "removeFromCase": "Remove from Case", + "delete": "Delete", + "deleteNow": "Delete Now" + }, + "bulkDelete": { + "title": "Delete Exports", + "desc_one": "Are you sure you want to delete {{count}} export?", + "desc_other": "Are you sure you want to delete {{count}} exports?" + }, + "bulkRemoveFromCase": { + "title": "Remove from Case", + "desc_one": "Remove {{count}} export from this case?", + "desc_other": "Remove {{count}} exports from this case?", + "descKeepExports": "Exports will be moved to uncategorized.", + "descDeleteExports": "Exports will be permanently deleted.", + "deleteExports": "Delete exports instead" + }, + "bulkToast": { + "success": { + "delete": "Successfully deleted exports", + "reassign": "Successfully updated case assignment", + "remove": "Successfully removed exports from case" + }, + "error": { + "deleteFailed": "Failed to delete exports: {{errorMessage}}", + "reassignFailed": "Failed to update case assignment: {{errorMessage}}" + } } } diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index c8d9c4c65..724179128 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { Button } from "../ui/button"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { isMobile } from "react-device-detect"; import { FiMoreVertical } from "react-icons/fi"; import { Skeleton } from "../ui/skeleton"; @@ -13,7 +13,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import { DeleteClipType, Export, ExportCase } from "@/types/export"; +import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { shareOrCopy } from "@/utils/browserUtil"; @@ -27,7 +27,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { FaFolder } from "react-icons/fa"; +import { FaFolder, FaVideo } from "react-icons/fa"; +import { HiSquare2Stack } from "react-icons/hi2"; +import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import useContextMenu from "@/hooks/use-contextmenu"; type CaseCardProps = { className: string; @@ -41,10 +44,15 @@ export function CaseCard({ exports, onSelect, }: CaseCardProps) { + const { t } = useTranslation(["views/exports"]); const firstExport = useMemo( () => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0), [exports], ); + const cameraCount = useMemo( + () => new Set(exports.map((exp) => exp.camera)).size, + [exports], + ); return (
)} + {!firstExport && ( +
+ )}
-
- -
{exportCase.name}
+
+
+ +
{exports.length}
+
+
+ +
{cameraCount}
+
+
+
+
+ +
{exportCase.name}
+
+ {exports.length === 0 && ( +
+ {t("caseCard.emptyCase")} +
+ )}
); @@ -73,18 +101,26 @@ export function CaseCard({ type ExportCardProps = { className: string; exportedRecording: Export; + isSelected?: boolean; + selectionMode?: boolean; onSelect: (selected: Export) => void; + onContextSelect?: (selected: Export) => void; onRename: (original: string, update: string) => void; onDelete: ({ file, exportName }: DeleteClipType) => void; onAssignToCase?: (selected: Export) => void; + onRemoveFromCase?: (selected: Export) => void; }; export function ExportCard({ className, exportedRecording, + isSelected, + selectionMode, onSelect, + onContextSelect, onRename, onDelete, onAssignToCase, + onRemoveFromCase, }: ExportCardProps) { const { t } = useTranslation(["views/exports"]); const isAdmin = useIsAdmin(); @@ -92,6 +128,15 @@ export function ExportCard({ exportedRecording.thumb_path.length > 0, ); + // selection + + const cardRef = useRef(null); + useContextMenu(cardRef, () => { + if (!exportedRecording.in_progress && onContextSelect) { + onContextSelect(exportedRecording); + } + }); + // editing name const [editName, setEditName] = useState<{ @@ -180,13 +225,18 @@ export function ExportCard({
{ + onClick={(e) => { if (!exportedRecording.in_progress) { - onSelect(exportedRecording); + if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) { + onContextSelect(exportedRecording); + } else { + onSelect(exportedRecording); + } } }} > @@ -205,7 +255,7 @@ export function ExportCard({ )} )} - {!exportedRecording.in_progress && ( + {!exportedRecording.in_progress && !selectionMode && (
@@ -254,6 +304,18 @@ export function ExportCard({ {t("tooltip.assignToCase")} )} + {isAdmin && onRemoveFromCase && ( + { + e.stopPropagation(); + onRemoveFromCase(exportedRecording); + }} + > + {t("tooltip.removeFromCase")} + + )} {isAdmin && ( )} -
- {exportedRecording.name.replaceAll("_", " ")} +
+
+
+ {exportedRecording.name.replaceAll("_", " ")} +
); } + +type ActiveExportJobCardProps = { + className?: string; + job: ExportJob; +}; + +export function ActiveExportJobCard({ + className = "", + job, +}: ActiveExportJobCardProps) { + const { t } = useTranslation(["views/exports", "common"]); + const cameraName = useCameraFriendlyName(job.camera); + const displayName = useMemo(() => { + if (job.name && job.name.length > 0) { + return job.name.replaceAll("_", " "); + } + + return t("jobCard.defaultName", { + camera: cameraName, + }); + }, [cameraName, job.name, t]); + const statusLabel = + job.status === "queued" ? t("jobCard.queued") : t("jobCard.running"); + + return ( +
+
+ {statusLabel} +
+
+ +
{displayName}
+
+
+ ); +} diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 0ae8d376d..6fb72a6fa 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -81,7 +81,7 @@ export default function ReviewCard({ axios .post( - `export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`, + `export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`, { playback: "realtime" }, ) .then((response) => { diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 89b1232bf..1d47454d7 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -56,6 +56,11 @@ const record: SectionConfigOverrides = { }, camera: { restartRequired: [], + hiddenFields: [ + "enabled_in_config", + "sync_recordings", + "export.max_concurrent", + ], }, }; diff --git a/web/src/components/filter/ExportActionGroup.tsx b/web/src/components/filter/ExportActionGroup.tsx new file mode 100644 index 000000000..92e5f251b --- /dev/null +++ b/web/src/components/filter/ExportActionGroup.tsx @@ -0,0 +1,384 @@ +import { useCallback, useMemo, useState } from "react"; +import axios from "axios"; +import { Button, buttonVariants } from "../ui/button"; +import { isDesktop } from "react-device-detect"; +import { HiTrash } from "react-icons/hi"; +import { LuFolderPlus, LuFolderX } from "react-icons/lu"; +import { Export, ExportCase } from "@/types/export"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Label } from "../ui/label"; +import { Switch } from "../ui/switch"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { useIsAdmin } from "@/hooks/use-is-admin"; +import OptionAndInputDialog from "../overlay/dialog/OptionAndInputDialog"; + +type ExportActionGroupProps = { + selectedExports: Export[]; + setSelectedExports: (exports: Export[]) => void; + context: "uncategorized" | "case"; + cases?: ExportCase[]; + currentCaseId?: string; + mutate: () => void; +}; +export default function ExportActionGroup({ + selectedExports, + setSelectedExports, + context, + cases, + currentCaseId, + mutate, +}: ExportActionGroupProps) { + const { t } = useTranslation(["views/exports", "common"]); + const isAdmin = useIsAdmin(); + + const onClearSelected = useCallback(() => { + setSelectedExports([]); + }, [setSelectedExports]); + + // ── Delete ────────────────────────────────────────────────────── + + const onDelete = useCallback(() => { + const ids = selectedExports.map((e) => e.id); + axios + .post("exports/delete", { ids }) + .then((resp) => { + if (resp.status === 200) { + toast.success(t("bulkToast.success.delete"), { + position: "top-center", + }); + setSelectedExports([]); + mutate(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("bulkToast.error.deleteFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, [selectedExports, setSelectedExports, mutate, t]); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [bypassDialog, setBypassDialog] = useState(false); + + useKeyboardListener(["Shift"], (_, modifiers) => { + setBypassDialog(modifiers.shift); + return false; + }); + + const handleDelete = useCallback(() => { + if (bypassDialog) { + onDelete(); + } else { + setDeleteDialogOpen(true); + } + }, [bypassDialog, onDelete]); + + // ── Remove from case ──────────────────────────────────────────── + + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const [deleteExportsOnRemove, setDeleteExportsOnRemove] = useState(false); + + const handleRemoveFromCase = useCallback(() => { + const ids = selectedExports.map((e) => e.id); + + const request = deleteExportsOnRemove + ? axios.post("exports/delete", { ids }) + : axios.post("exports/reassign", { ids, export_case_id: null }); + + request + .then((resp) => { + if (resp.status === 200) { + toast.success(t("bulkToast.success.remove"), { + position: "top-center", + }); + setSelectedExports([]); + mutate(); + setRemoveDialogOpen(false); + setDeleteExportsOnRemove(false); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, [selectedExports, deleteExportsOnRemove, setSelectedExports, mutate, t]); + + // ── Case picker ───────────────────────────────────────────────── + + const [casePickerOpen, setCasePickerOpen] = useState(false); + + const caseOptions = useMemo( + () => [ + ...(cases ?? []) + .filter((c) => c.id !== currentCaseId) + .map((c) => ({ + value: c.id, + label: c.name, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + { + value: "new", + label: t("caseDialog.newCaseOption"), + }, + ], + [cases, currentCaseId, t], + ); + + const handleAssignToCase = useCallback( + async (caseId: string) => { + const ids = selectedExports.map((e) => e.id); + try { + await axios.post("exports/reassign", { + ids, + export_case_id: caseId, + }); + toast.success(t("bulkToast.success.reassign"), { + position: "top-center", + }); + setSelectedExports([]); + mutate(); + } catch (error) { + const apiError = error as { + response?: { data?: { message?: string; detail?: string } }; + }; + const errorMessage = + apiError.response?.data?.message || + apiError.response?.data?.detail || + "Unknown error"; + toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), { + position: "top-center", + }); + throw error; + } + }, + [selectedExports, setSelectedExports, mutate, t], + ); + + const handleCreateNewCase = useCallback( + async (name: string, description: string) => { + const ids = selectedExports.map((e) => e.id); + try { + const createResp = await axios.post("cases", { name, description }); + const newCaseId: string | undefined = createResp.data?.id; + + if (newCaseId) { + await axios.post("exports/reassign", { + ids, + export_case_id: newCaseId, + }); + } + + toast.success(t("bulkToast.success.reassign"), { + position: "top-center", + }); + setSelectedExports([]); + mutate(); + } catch (error) { + const apiError = error as { + response?: { data?: { message?: string; detail?: string } }; + }; + const errorMessage = + apiError.response?.data?.message || + apiError.response?.data?.detail || + "Unknown error"; + toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), { + position: "top-center", + }); + throw error; + } + }, + [selectedExports, setSelectedExports, mutate, t], + ); + + return ( + <> + {/* Delete confirmation dialog */} + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + {t("bulkDelete.title")} + + + {t("bulkDelete.desc", { count: selectedExports.length })} + + + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common" })} + + + + + + {/* Remove from case dialog */} + {context === "case" && ( + { + if (!open) { + setRemoveDialogOpen(false); + setDeleteExportsOnRemove(false); + } + }} + > + + + + {t("bulkRemoveFromCase.title")} + + + {t("bulkRemoveFromCase.desc", { + count: selectedExports.length, + })}{" "} + {deleteExportsOnRemove + ? t("bulkRemoveFromCase.descDeleteExports") + : t("bulkRemoveFromCase.descKeepExports")} + + +
+ + +
+ + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common" })} + + +
+
+ )} + + {/* Case picker dialog */} + + + {/* Action bar */} +
+
+
+ {t("selected", { count: selectedExports.length })} +
+
{"|"}
+
+ {t("button.unselect", { ns: "common" })} +
+
+ {isAdmin && ( +
+ {/* Add to Case / Move to Case */} + + + {/* Remove from Case (case context only) */} + {context === "case" && ( + + )} + + {/* Delete */} + +
+ )} +
+ + ); +} diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index 31c5a56f4..389d12104 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -6,6 +6,7 @@ import { isDesktop } from "react-device-detect"; import { FaCompactDisc } from "react-icons/fa"; import { HiTrash } from "react-icons/hi"; import { ReviewSegment } from "@/types/review"; +import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export"; import { AlertDialog, AlertDialogAction, @@ -20,6 +21,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import MultiExportDialog from "../overlay/MultiExportDialog"; type ReviewActionGroupProps = { selectedReviews: ReviewSegment[]; @@ -164,6 +166,29 @@ export default function ReviewActionGroup({ )} )} + {selectedReviews.length >= 2 && + selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && ( + { + onClearSelected(); + pullLatestData(); + }} + > + + + )} - - ), - }); - setName(""); - setSelectedCaseId(undefined); - setRange(undefined); - setMode("none"); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("export.toast.error.failed", { - error: errorMessage, - }), - { position: "top-center" }, - ); + ); + + toast.success(t("export.toast.queued"), { + position: "top-center", + action: ( + + + + ), }); - }, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]); + setName(""); + setSelectedCaseId(undefined); + setSingleNewCaseName(""); + setSingleNewCaseDescription(""); + setRange(undefined); + setMode("none"); + return true; + } catch (error) { + const apiError = error as { + response?: { data?: { message?: string; detail?: string } }; + }; + const errorMessage = + apiError.response?.data?.message || + apiError.response?.data?.detail || + "Unknown error"; + toast.error( + t("export.toast.error.failed", { + error: errorMessage, + }), + { position: "top-center" }, + ); + return false; + } finally { + setIsStartingExport(false); + } + }, [ + camera, + isStartingExport, + name, + range, + selectedCaseId, + singleNewCaseDescription, + singleNewCaseName, + setMode, + setRange, + t, + ]); const handleCancel = useCallback(() => { setName(""); setSelectedCaseId(undefined); + setSingleNewCaseName(""); + setSingleNewCaseDescription(""); setMode("none"); setRange(undefined); + setActiveTab("export"); }, [setMode, setRange]); const Overlay = isDesktop ? Dialog : Drawer; @@ -150,16 +230,31 @@ export default function ExportDialog({ /> setShowPreview(true)} - onSave={() => onStartExport()} + onSave={() => { + if (mode == "timeline_multi") { + setActiveTab("multi"); + setMode("select"); + return; + } + + void onStartExport(); + }} onCancel={handleCancel} /> { if (!open) { - setMode("none"); + handleCancel(); } }} > @@ -171,22 +266,16 @@ export default function ExportDialog({ size="sm" onClick={() => { const now = new Date(latestTime * 1000); - let start = 0; now.setHours(now.getHours() - 1); - start = now.getTime() / 1000; + setActiveTab("export"); setRange({ before: latestTime, - after: start, + after: now.getTime() / 1000, }); setMode("select"); }} > - {isDesktop && ( -
- {t("menu.export", { ns: "common" })} -
- )} )} @@ -203,9 +292,16 @@ export default function ExportDialog({ range={range} name={name} selectedCaseId={selectedCaseId} + singleNewCaseName={singleNewCaseName} + singleNewCaseDescription={singleNewCaseDescription} + activeTab={activeTab} + isStartingExport={isStartingExport} onStartExport={onStartExport} + setActiveTab={setActiveTab} setName={setName} setSelectedCaseId={setSelectedCaseId} + setSingleNewCaseName={setSingleNewCaseName} + setSingleNewCaseDescription={setSingleNewCaseDescription} setRange={setRange} setMode={setMode} onCancel={handleCancel} @@ -222,29 +318,205 @@ type ExportContentProps = { range?: TimeRange; name: string; selectedCaseId?: string; - onStartExport: () => void; + singleNewCaseName: string; + singleNewCaseDescription: string; + activeTab: ExportTab; + isStartingExport: boolean; + onStartExport: () => Promise; + setActiveTab: (tab: ExportTab) => void; setName: (name: string) => void; setSelectedCaseId: (caseId: string | undefined) => void; + setSingleNewCaseName: (name: string) => void; + setSingleNewCaseDescription: (description: string) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; onCancel: () => void; }; + export function ExportContent({ latestTime, currentTime, range, name, selectedCaseId, + singleNewCaseName, + singleNewCaseDescription, + activeTab, + isStartingExport, onStartExport, + setActiveTab, setName, setSelectedCaseId, + setSingleNewCaseName, + setSingleNewCaseDescription, setRange, setMode, onCancel, }: ExportContentProps) { const { t } = useTranslation(["components/dialog"]); + const navigate = useNavigate(); + const isAdmin = useIsAdmin(); const [selectedOption, setSelectedOption] = useState("1"); - const { data: cases } = useSWR("cases"); + const { data: cases } = useSWR(isAdmin ? "cases" : null); + const { data: config } = useSWR("config"); + const [debouncedRange, setDebouncedRange] = useState( + range, + ); + const [selectedCameraIds, setSelectedCameraIds] = useState([]); + const [batchCaseSelection, setBatchCaseSelection] = useState( + selectedCaseId || "none", + ); + const [hasManualCameraSelection, setHasManualCameraSelection] = + useState(false); + const [newCaseName, setNewCaseName] = useState(""); + const [newCaseDescription, setNewCaseDescription] = useState(""); + const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); + const multiRangeKey = useMemo(() => { + if (activeTab !== "multi" || !range) { + return undefined; + } + + return `${Math.round(range.after)}-${Math.round(range.before)}`; + }, [activeTab, range]); + + useEffect(() => { + if (activeTab !== "multi") { + setDebouncedRange(undefined); + return; + } + + if (!range) { + setDebouncedRange(undefined); + return; + } + + const timeoutId = window.setTimeout(() => { + setDebouncedRange(range); + }, 300); + + return () => window.clearTimeout(timeoutId); + }, [activeTab, range]); + + useEffect(() => { + if (activeTab !== "multi") { + return; + } + + if (selectedCaseId) { + setBatchCaseSelection(selectedCaseId); + return; + } + + if ((cases?.length ?? 0) === 0) { + setBatchCaseSelection("new"); + return; + } + + setBatchCaseSelection("new"); + }, [activeTab, cases?.length, selectedCaseId]); + + useEffect(() => { + setHasManualCameraSelection(false); + }, [multiRangeKey]); + + useEffect(() => { + if (activeTab !== "multi" || range) { + return; + } + + setRange({ + before: latestTime, + after: latestTime - 3600, + }); + }, [activeTab, latestTime, range, setRange]); + + const { data: events, isLoading: isEventsLoading } = useSWR( + activeTab === "multi" && debouncedRange + ? [ + "events", + { + after: Math.round(debouncedRange.after), + before: Math.round(debouncedRange.before), + limit: 500, + }, + ] + : null, + ); + + const cameraActivities = useMemo(() => { + const allCameraIds = Object.keys(config?.cameras ?? {}); + const byCamera = new Map(); + + events?.forEach((event) => { + const bucket = byCamera.get(event.camera); + if (bucket) { + bucket.push(event); + } else { + byCamera.set(event.camera, [event]); + } + }); + + const rangeStart = debouncedRange?.after ?? 0; + const rangeEnd = debouncedRange?.before ?? 0; + const rangeDuration = Math.max(1, rangeEnd - rangeStart); + + return allCameraIds.map((cameraId) => { + const cameraEvents = byCamera.get(cameraId) ?? []; + const segments = cameraEvents + .map((event) => { + // Event end_time is null for in-progress events; fall back to start. + const eventEnd = event.end_time ?? event.start_time; + const start = Math.max( + 0, + Math.min(1, (event.start_time - rangeStart) / rangeDuration), + ); + const end = Math.max( + 0, + Math.min(1, (eventEnd - rangeStart) / rangeDuration), + ); + return { start, end: Math.max(end, start) }; + }) + .sort((a, b) => a.start - b.start); + + return { + camera: cameraId, + count: cameraEvents.length, + hasDetections: cameraEvents.length > 0, + segments, + }; + }); + }, [config?.cameras, debouncedRange, events]); + + useEffect(() => { + if ( + activeTab !== "multi" || + !config || + isEventsLoading || + hasManualCameraSelection + ) { + return; + } + + setSelectedCameraIds( + cameraActivities + .filter((activity) => activity.hasDetections) + .map((activity) => activity.camera), + ); + }, [ + activeTab, + cameraActivities, + config, + hasManualCameraSelection, + isEventsLoading, + ]); + + const selectedCameraCount = selectedCameraIds.length; + const canStartBatchExport = + Boolean(range && range.before > range.after) && + selectedCameraCount > 0 && + !isStartingBatchExport && + (batchCaseSelection !== "new" || newCaseName.trim().length > 0) && + batchCaseSelection.length > 0; const onSelectTime = useCallback( (option: ExportOption) => { @@ -252,6 +524,7 @@ export function ExportContent({ const now = new Date(latestTime * 1000); let start = 0; + switch (option) { case "1": now.setHours(now.getHours() - 1); @@ -276,6 +549,8 @@ export function ExportContent({ case "custom": start = latestTime - 3600; break; + default: + start = latestTime - 3600; } setRange({ @@ -286,99 +561,486 @@ export function ExportContent({ [latestTime, setRange], ); + const toggleCameraSelection = useCallback((cameraId: string) => { + setHasManualCameraSelection(true); + setSelectedCameraIds((previous) => + previous.includes(cameraId) + ? previous.filter((selectedId) => selectedId !== cameraId) + : [...previous, cameraId], + ); + }, []); + + const startBatchExport = useCallback(async () => { + if (isStartingBatchExport) { + return; + } + + if (!range) { + toast.error(t("export.toast.error.noVaildTimeSelected"), { + position: "top-center", + }); + return; + } + + if (range.before <= range.after) { + toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { + position: "top-center", + }); + return; + } + + const payload: BatchExportBody = { + items: selectedCameraIds.map((cameraId) => ({ + camera: cameraId, + start_time: Math.round(range.after), + end_time: Math.round(range.before), + friendly_name: name + ? `${name} - ${resolveCameraName(config, cameraId)}` + : undefined, + })), + }; + + if (isAdmin && batchCaseSelection !== "none") { + if (batchCaseSelection === "new") { + payload.new_case_name = newCaseName.trim(); + payload.new_case_description = newCaseDescription.trim() || undefined; + } else { + payload.export_case_id = batchCaseSelection; + } + } + + setIsStartingBatchExport(true); + + try { + const response = await axios.post( + "exports/batch", + payload, + ); + const results = response.data.results; + const successfulResults = results.filter((result) => result.success); + const failedResults = results.filter((result) => !result.success); + const failedSummary = failedResults + .map((result) => { + const cameraName = resolveCameraName(config, result.camera); + return result.error ? `${cameraName}: ${result.error}` : cameraName; + }) + .join(", "); + + if (failedResults.length > 0 && successfulResults.length > 0) { + toast.success( + t("export.toast.batchQueuedPartial", { + successful: successfulResults.length, + total: results.length, + failedCameras: failedResults + .map((result) => resolveCameraName(config, result.camera)) + .join(", "), + }), + { + position: "top-center", + description: failedSummary, + }, + ); + } else if (failedResults.length > 0) { + toast.error( + t("export.toast.batchQueueFailed", { + total: results.length, + failedCameras: failedResults + .map((result) => resolveCameraName(config, result.camera)) + .join(", "), + }), + { + position: "top-center", + description: failedSummary, + }, + ); + } else { + toast.success( + t("export.toast.batchQueuedSuccess", { + count: successfulResults.length, + }), + { position: "top-center" }, + ); + } + + if (successfulResults.length > 0) { + setName(""); + setSelectedCaseId(undefined); + setBatchCaseSelection("new"); + setNewCaseName(""); + setNewCaseDescription(""); + setRange(undefined); + setMode("none"); + setActiveTab("export"); + if (response.data.export_case_id) { + navigate(`/export?caseId=${response.data.export_case_id}`); + } + } + } catch (error) { + const apiError = error as { + response?: { data?: { message?: string; detail?: string } }; + }; + const errorMessage = + apiError.response?.data?.message || + apiError.response?.data?.detail || + "Unknown error"; + + toast.error( + t("export.toast.error.failed", { + error: errorMessage, + }), + { position: "top-center" }, + ); + } finally { + setIsStartingBatchExport(false); + } + }, [ + batchCaseSelection, + config, + isAdmin, + isStartingBatchExport, + name, + newCaseDescription, + newCaseName, + range, + selectedCameraIds, + setActiveTab, + setMode, + setName, + setRange, + setSelectedCaseId, + t, + navigate, + ]); + return ( -
+
{isDesktop && ( - <> - - {t("menu.export", { ns: "common" })} - - - + + {t("menu.export", { ns: "common" })} + )} - onSelectTime(value as ExportOption)} + + setActiveTab(value as ExportTab)} + className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")} > - {EXPORT_OPTIONS.map((opt) => { - return ( -
- - -
- ); - })} -
- {selectedOption == "custom" && ( - - )} - setName(e.target.value)} - /> -
- - -
+ {isNaN(parseInt(opt)) + ? opt == "timeline" + ? t("export.time.fromTimeline") + : t(`export.time.${opt}`) + : t("export.time.lastHour", { + count: parseInt(opt), + })} + +
+ ))} + + + {selectedOption == "custom" && ( + + )} + + setName(e.target.value)} + /> + + {isAdmin && ( +
+ + + {selectedCaseId === "new" && ( +
+ setSingleNewCaseName(e.target.value)} + /> +