mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-22 15:07:41 +08:00
Export improvements (#22867)
* backend * frontend + i18n * tests + api spec * tweak backend to use Job infrastructure for exports * frontend tweaks and Job infrastructure * tests * tweaks - add ability to remove from case - change location of counts in case card * add stale export reaper on startup * fix toaster close button color * improve add dialog * formatting * hide max_concurrent from camera config export settings * remove border * refactor batch endpoint for multiple review items * frontend * tests and fastapi spec * fix deletion of in-progress exports in a case * tweaks - hide cases when filtering cameras that have no exports from those cameras - remove description from case card - use textarea instead of input for case description in add new case dialog * add auth exceptions for exports * add e2e test for deleting cases with exports * refactor delete and case endpoints allow bulk deleting and reassigning * frontend - bulk selection like Review - gate admin-only actions - consolidate dialogs - spacing/padding tweaks * i18n and tests * update openapi spec * tweaks - add None to case selection list - allow new case creation from single cam export dialog * fix codeql * fix i18n * remove unused * fix frontend tests
This commit is contained in:
Vendored
+299
-63
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
+634
-259
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) =>
|
||||
|
||||
+710
-50
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -61,10 +69,30 @@ export function CaseCard({
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{!firstExport && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
|
||||
<FaFolder />
|
||||
<div className="capitalize">{exportCase.name}</div>
|
||||
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
|
||||
<div className="flex items-center gap-1">
|
||||
<HiSquare2Stack className="size-3" />
|
||||
<div>{exports.length}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FaVideo className="size-3" />
|
||||
<div>{cameraCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<FaFolder />
|
||||
<div className="truncate smart-capitalize">{exportCase.name}</div>
|
||||
</div>
|
||||
{exports.length === 0 && (
|
||||
<div className="mt-1 text-xs text-white/80">
|
||||
{t("caseCard.emptyCase")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
useContextMenu(cardRef, () => {
|
||||
if (!exportedRecording.in_progress && onContextSelect) {
|
||||
onContextSelect(exportedRecording);
|
||||
}
|
||||
});
|
||||
|
||||
// editing name
|
||||
|
||||
const [editName, setEditName] = useState<{
|
||||
@@ -180,13 +225,18 @@ export function ExportCard({
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
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 && (
|
||||
<div className="absolute bottom-2 right-3 z-40">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -254,6 +304,18 @@ export function ExportCard({
|
||||
{t("tooltip.assignToCase")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && onRemoveFromCase && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.removeFromCase")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveFromCase(exportedRecording);
|
||||
}}
|
||||
>
|
||||
{t("tooltip.removeFromCase")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
@@ -292,10 +354,61 @@ export function ExportCard({
|
||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
<ImageShadowOverlay />
|
||||
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
|
||||
isSelected
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
)}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
|
||||
<div className="truncate smart-capitalize">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
|
||||
{statusLabel}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 px-6 text-center">
|
||||
<ActivityIndicator />
|
||||
<div className="text-sm font-medium text-primary">{displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -56,6 +56,11 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"sync_recordings",
|
||||
"export.max_concurrent",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("bulkDelete.title")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("bulkDelete.desc", { count: selectedExports.length })}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Remove from case dialog */}
|
||||
{context === "case" && (
|
||||
<AlertDialog
|
||||
open={removeDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRemoveDialogOpen(false);
|
||||
setDeleteExportsOnRemove(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("bulkRemoveFromCase.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("bulkRemoveFromCase.desc", {
|
||||
count: selectedExports.length,
|
||||
})}{" "}
|
||||
{deleteExportsOnRemove
|
||||
? t("bulkRemoveFromCase.descDeleteExports")
|
||||
: t("bulkRemoveFromCase.descKeepExports")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center justify-start gap-6">
|
||||
<Label
|
||||
htmlFor="bulk-delete-exports-switch"
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t("bulkRemoveFromCase.deleteExports")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="bulk-delete-exports-switch"
|
||||
checked={deleteExportsOnRemove}
|
||||
onCheckedChange={setDeleteExportsOnRemove}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={handleRemoveFromCase}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* Case picker dialog */}
|
||||
<OptionAndInputDialog
|
||||
open={casePickerOpen}
|
||||
title={t("caseDialog.title")}
|
||||
description={t("caseDialog.description")}
|
||||
setOpen={setCasePickerOpen}
|
||||
options={caseOptions}
|
||||
nameLabel={t("caseDialog.nameLabel")}
|
||||
descriptionLabel={t("caseDialog.descriptionLabel")}
|
||||
initialValue={caseOptions[0]?.value}
|
||||
newValueKey="new"
|
||||
onSave={handleAssignToCase}
|
||||
onCreateNew={handleCreateNewCase}
|
||||
/>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="p-1">
|
||||
{t("selected", { count: selectedExports.length })}
|
||||
</div>
|
||||
<div className="p-1">{"|"}</div>
|
||||
<div
|
||||
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
||||
onClick={onClearSelected}
|
||||
>
|
||||
{t("button.unselect", { ns: "common" })}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{/* Add to Case / Move to Case */}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={
|
||||
context === "case"
|
||||
? t("bulkActions.moveToCase")
|
||||
: t("bulkActions.addToCase")
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setCasePickerOpen(true)}
|
||||
>
|
||||
<LuFolderPlus className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{context === "case"
|
||||
? t("bulkActions.moveToCase")
|
||||
: t("bulkActions.addToCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Remove from Case (case context only) */}
|
||||
{context === "case" && (
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("bulkActions.removeFromCase")}
|
||||
size="sm"
|
||||
onClick={() => setRemoveDialogOpen(true)}
|
||||
>
|
||||
<LuFolderX className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("bulkActions.removeFromCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{bypassDialog
|
||||
? t("bulkActions.deleteNow")
|
||||
: t("bulkActions.delete")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedReviews.length >= 2 &&
|
||||
selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && (
|
||||
<MultiExportDialog
|
||||
selectedReviews={selectedReviews}
|
||||
onStarted={() => {
|
||||
onClearSelected();
|
||||
pullLatestData();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("recording.button.export")}
|
||||
size="sm"
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("recording.button.export")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</MultiExportDialog>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { Button } from "../ui/button";
|
||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||
import { LuBug } from "react-icons/lu";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
||||
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
|
||||
import {
|
||||
DebugReplayContent,
|
||||
SaveDebugReplayOverlay,
|
||||
@@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { StartExportResponse } from "@/types/export";
|
||||
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
@@ -102,6 +103,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||
"1" | "5" | "custom" | "timeline"
|
||||
>("1");
|
||||
@@ -113,67 +115,112 @@ export default function MobileReviewSettingsDrawer({
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const onStartExport = useCallback(() => {
|
||||
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
||||
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
||||
const [isStartingExport, setIsStartingExport] = useState(false);
|
||||
const onStartExport = useCallback(async () => {
|
||||
if (isStartingExport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
toast.error(t("toast.error.noValidTimeSelected"), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
toast.error(
|
||||
t("export.toast.error.noVaildTimeSelected", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (range.before < range.after) {
|
||||
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
toast.error(
|
||||
t("export.toast.error.endTimeMustAfterStartTime", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
axios
|
||||
.post(
|
||||
setIsStartingExport(true);
|
||||
|
||||
try {
|
||||
let exportCaseId: string | undefined = selectedCaseId;
|
||||
|
||||
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
|
||||
const caseResp = await axios.post("cases", {
|
||||
name: singleNewCaseName.trim(),
|
||||
description: singleNewCaseDescription.trim() || undefined,
|
||||
});
|
||||
exportCaseId = caseResp.data?.id;
|
||||
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
|
||||
exportCaseId = undefined;
|
||||
}
|
||||
|
||||
await axios.post<StartExportResponse>(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
{
|
||||
playback: "realtime",
|
||||
source: "recordings",
|
||||
name,
|
||||
export_case_id: selectedCaseId || undefined,
|
||||
export_case_id: exportCaseId,
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>
|
||||
{t("export.toast.view", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
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", {
|
||||
ns: "components/dialog",
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>
|
||||
{t("export.toast.view", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}, [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", {
|
||||
ns: "components/dialog",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsStartingExport(false);
|
||||
}
|
||||
}, [
|
||||
camera,
|
||||
isStartingExport,
|
||||
name,
|
||||
range,
|
||||
selectedCaseId,
|
||||
singleNewCaseDescription,
|
||||
singleNewCaseName,
|
||||
setRange,
|
||||
setMode,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onStartDebugReplay = useCallback(async () => {
|
||||
if (
|
||||
@@ -267,6 +314,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("export")}
|
||||
onClick={() => {
|
||||
setExportTab("export");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
}}
|
||||
@@ -331,14 +379,21 @@ export default function MobileReviewSettingsDrawer({
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
singleNewCaseName={singleNewCaseName}
|
||||
singleNewCaseDescription={singleNewCaseDescription}
|
||||
activeTab={exportTab}
|
||||
isStartingExport={isStartingExport}
|
||||
onStartExport={onStartExport}
|
||||
setActiveTab={setExportTab}
|
||||
setName={setName}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSingleNewCaseName={setSingleNewCaseName}
|
||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||
setRange={setRange}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
|
||||
if (mode == "timeline") {
|
||||
if (mode == "timeline" || mode == "timeline_multi") {
|
||||
setDrawerMode("none");
|
||||
}
|
||||
}}
|
||||
@@ -346,6 +401,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setExportTab("export");
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
/>
|
||||
@@ -483,9 +541,29 @@ export default function MobileReviewSettingsDrawer({
|
||||
<>
|
||||
<SaveExportOverlay
|
||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||
show={mode == "timeline"}
|
||||
onSave={() => onStartExport()}
|
||||
onCancel={() => setMode("none")}
|
||||
show={mode == "timeline" || mode == "timeline_multi"}
|
||||
hidePreview={mode == "timeline_multi"}
|
||||
isSaving={isStartingExport}
|
||||
saveLabel={
|
||||
mode == "timeline_multi"
|
||||
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||
: undefined
|
||||
}
|
||||
onSave={() => {
|
||||
if (mode == "timeline_multi") {
|
||||
setExportTab("multi");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
|
||||
void onStartExport();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setExportTab("export");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
}}
|
||||
onPreview={() => setShowExportPreview(true)}
|
||||
/>
|
||||
<SaveDebugReplayOverlay
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "../ui/drawer";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
import {
|
||||
BatchExportBody,
|
||||
BatchExportResponse,
|
||||
BatchExportResult,
|
||||
ExportCase,
|
||||
} from "@/types/export";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
|
||||
type MultiExportDialogProps = {
|
||||
selectedReviews: ReviewSegment[];
|
||||
onStarted: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const NONE_CASE_OPTION = "none";
|
||||
const NEW_CASE_OPTION = "new";
|
||||
|
||||
export default function MultiExportDialog({
|
||||
selectedReviews,
|
||||
onStarted,
|
||||
children,
|
||||
}: MultiExportDialogProps) {
|
||||
const { t } = useTranslation(["components/dialog", "common"]);
|
||||
const locale = useDateLocale();
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
// Only admins can attach exports to an existing case (enforced server-side
|
||||
// by POST /exports/batch). Skip fetching the case list entirely for
|
||||
// non-admins — they can only ever use the "Create new case" branch.
|
||||
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [caseSelection, setCaseSelection] = useState<string>(NONE_CASE_OPTION);
|
||||
const [newCaseName, setNewCaseName] = useState("");
|
||||
const [newCaseDescription, setNewCaseDescription] = useState("");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const count = selectedReviews.length;
|
||||
|
||||
// Resolve a failed batch result back to a human-readable label via the
|
||||
// client-provided review id when available. Falls back to item_index and
|
||||
// finally camera name for defensive compatibility.
|
||||
const formatFailureLabel = useCallback(
|
||||
(result: BatchExportResult): string => {
|
||||
const cameraName = resolveCameraName(config, result.camera);
|
||||
if (result.client_item_id) {
|
||||
const review = selectedReviews.find(
|
||||
(item) => item.id === result.client_item_id,
|
||||
);
|
||||
if (review) {
|
||||
const time = formatUnixTimestampToDateTime(review.start_time, {
|
||||
date_style: "short",
|
||||
time_style: "short",
|
||||
locale,
|
||||
});
|
||||
return `${cameraName} • ${time}`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof result.item_index === "number" &&
|
||||
result.item_index >= 0 &&
|
||||
result.item_index < selectedReviews.length
|
||||
) {
|
||||
const review = selectedReviews[result.item_index];
|
||||
const time = formatUnixTimestampToDateTime(review.start_time, {
|
||||
date_style: "short",
|
||||
time_style: "short",
|
||||
locale,
|
||||
});
|
||||
return `${cameraName} • ${time}`;
|
||||
}
|
||||
return cameraName;
|
||||
},
|
||||
[config, locale, selectedReviews],
|
||||
);
|
||||
|
||||
const defaultCaseName = useMemo(() => {
|
||||
const formattedDate = formatUnixTimestampToDateTime(Date.now() / 1000, {
|
||||
date_style: "medium",
|
||||
time_style: "short",
|
||||
locale,
|
||||
});
|
||||
return t("export.multi.caseNamePlaceholder", {
|
||||
ns: "components/dialog",
|
||||
date: formattedDate,
|
||||
});
|
||||
}, [t, locale]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setCaseSelection(NONE_CASE_OPTION);
|
||||
setNewCaseName("");
|
||||
setNewCaseDescription("");
|
||||
setIsExporting(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
resetState();
|
||||
} else {
|
||||
// Freshly reset each time so the default name reflects "now"
|
||||
setCaseSelection(NONE_CASE_OPTION);
|
||||
setNewCaseName(defaultCaseName);
|
||||
setNewCaseDescription("");
|
||||
setIsExporting(false);
|
||||
}
|
||||
setOpen(next);
|
||||
},
|
||||
[defaultCaseName, resetState],
|
||||
);
|
||||
|
||||
const existingCases = useMemo(() => {
|
||||
return (cases ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [cases]);
|
||||
|
||||
const isNewCase = caseSelection === NEW_CASE_OPTION;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isExporting) return false;
|
||||
if (count === 0) return false;
|
||||
if (!isAdmin) return true;
|
||||
if (isNewCase) {
|
||||
return newCaseName.trim().length > 0;
|
||||
}
|
||||
return caseSelection.length > 0;
|
||||
}, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
|
||||
const items = selectedReviews.map((review) => ({
|
||||
camera: review.camera,
|
||||
start_time: review.start_time - REVIEW_PADDING,
|
||||
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
image_path: review.thumb_path || undefined,
|
||||
client_item_id: review.id,
|
||||
}));
|
||||
|
||||
const payload: BatchExportBody = { items };
|
||||
|
||||
if (isAdmin && caseSelection !== NONE_CASE_OPTION) {
|
||||
if (isNewCase) {
|
||||
payload.new_case_name = newCaseName.trim();
|
||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||
} else {
|
||||
payload.export_case_id = caseSelection;
|
||||
}
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await axios.post<BatchExportResponse>(
|
||||
"exports/batch",
|
||||
payload,
|
||||
);
|
||||
|
||||
const results = response.data.results ?? [];
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
|
||||
if (successful.length > 0 && failed.length === 0) {
|
||||
toast.success(
|
||||
t(
|
||||
isAdmin
|
||||
? "export.multi.toast.started"
|
||||
: "export.multi.toast.startedNoCase",
|
||||
{
|
||||
ns: "components/dialog",
|
||||
count: successful.length,
|
||||
},
|
||||
),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else if (successful.length > 0 && failed.length > 0) {
|
||||
// Resolve each failure to its review via item_index so same-camera
|
||||
// items are disambiguated by time. Falls back to camera-only if the
|
||||
// server didn't populate item_index.
|
||||
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
||||
toast.success(
|
||||
t("export.multi.toast.partial", {
|
||||
ns: "components/dialog",
|
||||
successful: successful.length,
|
||||
total: results.length,
|
||||
failedItems: failedLabels,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
||||
toast.error(
|
||||
t("export.multi.toast.failed", {
|
||||
ns: "components/dialog",
|
||||
total: results.length,
|
||||
failedItems: failedLabels,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
|
||||
if (successful.length > 0) {
|
||||
onStarted();
|
||||
setOpen(false);
|
||||
resetState();
|
||||
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", {
|
||||
ns: "components/dialog",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [
|
||||
canSubmit,
|
||||
caseSelection,
|
||||
formatFailureLabel,
|
||||
isAdmin,
|
||||
isNewCase,
|
||||
navigate,
|
||||
newCaseDescription,
|
||||
newCaseName,
|
||||
onStarted,
|
||||
resetState,
|
||||
selectedReviews,
|
||||
t,
|
||||
]);
|
||||
|
||||
// New-case inputs: rendered below the Select when caseSelection === "new",
|
||||
// or rendered standalone for non-admins (who never see the Select since
|
||||
// they cannot attach to an existing case).
|
||||
const newCaseInputs = (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={newCaseName}
|
||||
onChange={(event) => setNewCaseName(event.target.value)}
|
||||
maxLength={100}
|
||||
autoFocus={isDesktop}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={newCaseDescription}
|
||||
onChange={(event) => setNewCaseDescription(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-secondary-foreground">
|
||||
{t("export.case.label")}
|
||||
</Label>
|
||||
<Select
|
||||
value={caseSelection}
|
||||
onValueChange={(value) => setCaseSelection(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("export.case.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_CASE_OPTION}>
|
||||
{t("label.none", { ns: "common" })}
|
||||
</SelectItem>
|
||||
{existingCases.map((caseItem) => (
|
||||
<SelectItem key={caseItem.id} value={caseItem.id}>
|
||||
{caseItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value={NEW_CASE_OPTION}>
|
||||
{t("export.case.newCaseOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isNewCase && newCaseInputs}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
aria-label={t("export.multi.exportButton", { count })}
|
||||
>
|
||||
{isExporting
|
||||
? t("export.multi.exportingButton")
|
||||
: t("export.multi.exportButton", { count })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<DialogFooter className="gap-2">{footer}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent className="px-4 pb-6">
|
||||
<DrawerHeader className="px-0">
|
||||
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
|
||||
type SaveExportOverlayProps = {
|
||||
className: string;
|
||||
show: boolean;
|
||||
hidePreview?: boolean;
|
||||
saveLabel?: string;
|
||||
isSaving?: boolean;
|
||||
onPreview: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -14,6 +17,9 @@ type SaveExportOverlayProps = {
|
||||
export default function SaveExportOverlay({
|
||||
className,
|
||||
show,
|
||||
hidePreview = false,
|
||||
saveLabel,
|
||||
isSaving = false,
|
||||
onPreview,
|
||||
onSave,
|
||||
onCancel,
|
||||
@@ -32,29 +38,36 @@ export default function SaveExportOverlay({
|
||||
className="flex items-center gap-1 text-primary"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<LuX />
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{!hidePreview && (
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.previewExport")}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onPreview}
|
||||
>
|
||||
<LuVideo />
|
||||
{t("export.fromTimeline.previewExport")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.previewExport")}
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<LuVideo />
|
||||
{t("export.fromTimeline.previewExport")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.saveExport")}
|
||||
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
<FaCompactDisc />
|
||||
{t("export.fromTimeline.saveExport")}
|
||||
{isSaving
|
||||
? t("export.fromTimeline.queueingExport")
|
||||
: saveLabel || t("export.fromTimeline.saveExport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,9 +16,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Option = {
|
||||
@@ -35,8 +37,8 @@ type OptionAndInputDialogProps = {
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSave: (value: string) => void;
|
||||
onCreateNew: (name: string, description: string) => void;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
onCreateNew: (name: string, description: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function OptionAndInputDialog({
|
||||
@@ -69,10 +71,12 @@ export default function OptionAndInputDialog({
|
||||
}
|
||||
}, [open, initialValue, firstOption]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isNew = selectedValue === newValueKey;
|
||||
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
|
||||
const disableSave =
|
||||
!selectedValue || (isNew && name.trim().length === 0) || isLoading;
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
@@ -80,13 +84,26 @@ export default function OptionAndInputDialog({
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDescription = descriptionValue.trim();
|
||||
|
||||
if (isNew) {
|
||||
onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
onSave(selectedValue);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
await onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
await onSave(selectedValue);
|
||||
}
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
}, [
|
||||
selectedValue,
|
||||
name,
|
||||
descriptionValue,
|
||||
isNew,
|
||||
onCreateNew,
|
||||
onSave,
|
||||
setOpen,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||
@@ -127,15 +144,21 @@ export default function OptionAndInputDialog({
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
className="text-md"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Input
|
||||
<Textarea
|
||||
className="text-md"
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,6 +168,7 @@ export default function OptionAndInputDialog({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -155,9 +179,13 @@ export default function OptionAndInputDialog({
|
||||
type="button"
|
||||
variant="select"
|
||||
disabled={disableSave}
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t("button.save")}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
t("button.save")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
closeButton:
|
||||
"group-[.toast]:bg-secondary border-primary border-[1px]",
|
||||
"group-[.toast]:bg-secondary group-[.toast]:text-primary group-[.toast]:border-primary group-[.toast]:border-[1px]",
|
||||
success:
|
||||
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
error:
|
||||
|
||||
+875
-75
File diff suppressed because it is too large
Load Diff
+77
-1
@@ -6,7 +6,8 @@ export type Export = {
|
||||
video_path: string;
|
||||
thumb_path: string;
|
||||
in_progress: boolean;
|
||||
export_case?: string;
|
||||
export_case?: string | null;
|
||||
export_case_id?: string | null;
|
||||
};
|
||||
|
||||
export type ExportCase = {
|
||||
@@ -17,6 +18,81 @@ export type ExportCase = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type BatchExportBody = {
|
||||
items: BatchExportItem[];
|
||||
export_case_id?: string;
|
||||
new_case_name?: string;
|
||||
new_case_description?: string;
|
||||
};
|
||||
|
||||
export const MAX_BATCH_EXPORT_ITEMS = 50;
|
||||
|
||||
export type BatchExportItem = {
|
||||
camera: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
image_path?: string;
|
||||
friendly_name?: string;
|
||||
client_item_id?: string;
|
||||
};
|
||||
|
||||
export type BatchExportResult = {
|
||||
camera: string;
|
||||
export_id?: string | null;
|
||||
success: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
item_index?: number | null;
|
||||
client_item_id?: string | null;
|
||||
};
|
||||
|
||||
export type BatchExportResponse = {
|
||||
export_case_id?: string | null;
|
||||
export_ids: string[];
|
||||
results: BatchExportResult[];
|
||||
};
|
||||
|
||||
export type StartExportResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
export_id?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type ExportJob = {
|
||||
id: string;
|
||||
job_type: string;
|
||||
status: string;
|
||||
camera: string;
|
||||
name?: string | null;
|
||||
export_case_id?: string | null;
|
||||
request_start_time: number;
|
||||
request_end_time: number;
|
||||
start_time?: number | null;
|
||||
end_time?: number | null;
|
||||
error_message?: string | null;
|
||||
results?: {
|
||||
export_id?: string;
|
||||
export_case_id?: string | null;
|
||||
video_path?: string;
|
||||
thumb_path?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CameraActivitySegment = {
|
||||
/** Fractional start position within the time range, 0-1 inclusive. */
|
||||
start: number;
|
||||
/** Fractional end position within the time range, 0-1 inclusive. */
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type CameraActivity = {
|
||||
camera: string;
|
||||
count: number;
|
||||
hasDetections: boolean;
|
||||
segments: CameraActivitySegment[];
|
||||
};
|
||||
|
||||
export type DeleteClipType = {
|
||||
file: string;
|
||||
exportName: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FilterType = { [searchKey: string]: any };
|
||||
|
||||
export type ExportMode = "select" | "timeline" | "none";
|
||||
export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
|
||||
|
||||
export type FilterList = {
|
||||
labels?: string[];
|
||||
|
||||
@@ -270,7 +270,10 @@ export default function MotionSearchView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (exportMode !== "timeline" || exportRange) {
|
||||
if (
|
||||
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
|
||||
exportRange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -955,9 +958,25 @@ export default function MotionSearchView({
|
||||
|
||||
<SaveExportOverlay
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
show={exportMode === "timeline" && Boolean(exportRange)}
|
||||
show={
|
||||
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||
Boolean(exportRange)
|
||||
}
|
||||
hidePreview={exportMode === "timeline_multi"}
|
||||
saveLabel={
|
||||
exportMode === "timeline_multi"
|
||||
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||
: undefined
|
||||
}
|
||||
onPreview={handleExportPreview}
|
||||
onSave={handleExportSave}
|
||||
onSave={() => {
|
||||
if (exportMode === "timeline_multi") {
|
||||
setExportMode("select");
|
||||
return;
|
||||
}
|
||||
|
||||
handleExportSave();
|
||||
}}
|
||||
onCancel={handleExportCancel}
|
||||
/>
|
||||
|
||||
@@ -976,7 +995,10 @@ export default function MotionSearchView({
|
||||
noRecordingRanges={noRecordings ?? []}
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
||||
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
|
||||
showExportHandles={
|
||||
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||
Boolean(exportRange)
|
||||
}
|
||||
exportStartTime={exportRange?.after}
|
||||
exportEndTime={exportRange?.before}
|
||||
setExportStartTime={setExportStartTime}
|
||||
@@ -1408,7 +1430,11 @@ export default function MotionSearchView({
|
||||
onControllerReady={(controller) => {
|
||||
mainControllerRef.current = controller;
|
||||
}}
|
||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
||||
isScrubbing={
|
||||
scrubbing ||
|
||||
exportMode == "timeline" ||
|
||||
exportMode == "timeline_multi"
|
||||
}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
setFullResolution={setFullResolution}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
|
||||
@@ -833,6 +833,7 @@ export function RecordingView({
|
||||
isScrubbing={
|
||||
scrubbing ||
|
||||
exportMode == "timeline" ||
|
||||
exportMode == "timeline_multi" ||
|
||||
debugReplayMode == "timeline"
|
||||
}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
@@ -911,7 +912,7 @@ export function RecordingView({
|
||||
activeReviewItem={activeReviewItem}
|
||||
currentTime={currentTime}
|
||||
exportRange={
|
||||
exportMode == "timeline"
|
||||
exportMode == "timeline" || exportMode == "timeline_multi"
|
||||
? exportRange
|
||||
: debugReplayMode == "timeline"
|
||||
? debugReplayRange
|
||||
|
||||
Reference in New Issue
Block a user