mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
backend
This commit is contained in:
parent
335229d0d4
commit
855e5cc0f9
42
frigate/api/defs/request/batch_export_body.py
Normal file
42
frigate/api/defs/request/batch_export_body.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExportBody(BaseModel):
|
||||||
|
start_time: float = Field(title="Start time")
|
||||||
|
end_time: float = Field(title="End time")
|
||||||
|
camera_ids: List[str] = Field(title="Camera IDs", min_length=1)
|
||||||
|
name: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
title="Friendly name template",
|
||||||
|
max_length=256,
|
||||||
|
description="Base export name. Each export is saved as '<name> - <camera>'",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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":
|
||||||
|
if self.end_time <= self.start_time:
|
||||||
|
raise ValueError("end_time must be after start_time")
|
||||||
|
|
||||||
|
if self.export_case_id is None and self.new_case_name is None:
|
||||||
|
raise ValueError("Either export_case_id or new_case_name must be provided")
|
||||||
|
|
||||||
|
return self
|
||||||
@ -30,4 +30,29 @@ class StartExportResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExportResultModel(BaseModel):
|
||||||
|
"""Per-camera 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")
|
||||||
|
error: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Validation or queueing error for this camera, if any",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExportResponse(BaseModel):
|
||||||
|
"""Response model for starting a multi-camera export batch."""
|
||||||
|
|
||||||
|
export_case_id: str = Field(description="Export case ID associated with the batch")
|
||||||
|
export_ids: List[str] = Field(description="Export IDs successfully queued")
|
||||||
|
results: List[BatchExportResultModel] = Field(
|
||||||
|
description="Per-camera batch export results"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ExportsResponse = List[ExportModel]
|
ExportsResponse = List[ExportModel]
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
"""Export apis."""
|
"""Export apis."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@ -19,6 +22,7 @@ from frigate.api.auth import (
|
|||||||
require_camera_access,
|
require_camera_access,
|
||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
|
from frigate.api.defs.request.batch_export_body import BatchExportBody
|
||||||
from frigate.api.defs.request.export_case_body import (
|
from frigate.api.defs.request.export_case_body import (
|
||||||
ExportCaseAssignBody,
|
ExportCaseAssignBody,
|
||||||
ExportCaseCreateBody,
|
ExportCaseCreateBody,
|
||||||
@ -34,6 +38,7 @@ from frigate.api.defs.response.export_case_response import (
|
|||||||
ExportCasesResponse,
|
ExportCasesResponse,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.response.export_response import (
|
from frigate.api.defs.response.export_response import (
|
||||||
|
BatchExportResponse,
|
||||||
ExportModel,
|
ExportModel,
|
||||||
ExportsResponse,
|
ExportsResponse,
|
||||||
StartExportResponse,
|
StartExportResponse,
|
||||||
@ -54,6 +59,214 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(tags=[Tags.export])
|
router = APIRouter(tags=[Tags.export])
|
||||||
|
|
||||||
|
EXPORT_START_SEMAPHORE = threading.Semaphore(3)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedRecordingExporter(RecordingExporter):
|
||||||
|
"""Recording exporter that releases the shared concurrency slot on exit."""
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
super().run()
|
||||||
|
finally:
|
||||||
|
EXPORT_START_SEMAPHORE.release()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_id(length: int = 12) -> str:
|
||||||
|
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_export_id(camera_name: str) -> str:
|
||||||
|
return f"{camera_name}_{_generate_id(6)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _create_export_case_record(
|
||||||
|
name: str,
|
||||||
|
description: Optional[str],
|
||||||
|
) -> ExportCase:
|
||||||
|
now = datetime.datetime.fromtimestamp(time.time())
|
||||||
|
return ExportCase.create(
|
||||||
|
id=_generate_id(),
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_camera_name(request: Request, camera_name: str) -> Optional[JSONResponse]:
|
||||||
|
if camera_name and request.app.frigate_config.cameras.get(camera_name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": f"{camera_name} is not a valid camera."},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_export_case(export_case_id: Optional[str]) -> Optional[JSONResponse]:
|
||||||
|
if export_case_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ExportCase.get(ExportCase.id == export_case_id)
|
||||||
|
except DoesNotExist:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Export case not found"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_existing_image(
|
||||||
|
image_path: Optional[str],
|
||||||
|
) -> tuple[Optional[str], Optional[JSONResponse]]:
|
||||||
|
existing_image = sanitize_filepath(image_path) if image_path else None
|
||||||
|
|
||||||
|
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||||
|
return None, JSONResponse(
|
||||||
|
content={"success": False, "message": "Invalid image path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
return existing_image, None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_export_source(
|
||||||
|
camera_name: str,
|
||||||
|
start_time: float,
|
||||||
|
end_time: float,
|
||||||
|
playback_source: PlaybackSourceEnum,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if playback_source == PlaybackSourceEnum.recordings:
|
||||||
|
recordings_count = (
|
||||||
|
Recordings.select()
|
||||||
|
.where(
|
||||||
|
Recordings.start_time.between(start_time, end_time)
|
||||||
|
| Recordings.end_time.between(start_time, end_time)
|
||||||
|
| (
|
||||||
|
(start_time > Recordings.start_time)
|
||||||
|
& (end_time < Recordings.end_time)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(Recordings.camera == camera_name)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
if recordings_count <= 0:
|
||||||
|
return "No recordings found for time range"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
previews_count = (
|
||||||
|
Previews.select()
|
||||||
|
.where(
|
||||||
|
Previews.start_time.between(start_time, end_time)
|
||||||
|
| Previews.end_time.between(start_time, end_time)
|
||||||
|
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
|
||||||
|
)
|
||||||
|
.where(Previews.camera == camera_name)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_current_hour(start_time) and previews_count <= 0:
|
||||||
|
return "No previews found for time range"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_batch_recording_export_errors(
|
||||||
|
request: Request,
|
||||||
|
camera_names: list[str],
|
||||||
|
start_time: float,
|
||||||
|
end_time: float,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
unique_camera_names = list(dict.fromkeys(camera_names))
|
||||||
|
configured_cameras = request.app.frigate_config.cameras
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
valid_camera_names = [
|
||||||
|
camera_name
|
||||||
|
for camera_name in unique_camera_names
|
||||||
|
if configured_cameras.get(camera_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
for camera_name in unique_camera_names:
|
||||||
|
if camera_name not in valid_camera_names:
|
||||||
|
errors[camera_name] = f"{camera_name} is not a valid camera."
|
||||||
|
|
||||||
|
if not valid_camera_names:
|
||||||
|
return errors
|
||||||
|
|
||||||
|
recordings = (
|
||||||
|
Recordings.select(Recordings.camera)
|
||||||
|
.distinct()
|
||||||
|
.where(
|
||||||
|
Recordings.camera << valid_camera_names,
|
||||||
|
Recordings.start_time.between(start_time, end_time)
|
||||||
|
| Recordings.end_time.between(start_time, end_time)
|
||||||
|
| ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)),
|
||||||
|
)
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
cameras_with_recordings = {recording.camera for recording in recordings}
|
||||||
|
|
||||||
|
for camera_name in valid_camera_names:
|
||||||
|
if camera_name not in cameras_with_recordings:
|
||||||
|
errors[camera_name] = "No recordings found for time range"
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _build_exporter(
|
||||||
|
request: Request,
|
||||||
|
camera_name: str,
|
||||||
|
start_time: float,
|
||||||
|
end_time: float,
|
||||||
|
friendly_name: Optional[str],
|
||||||
|
existing_image: Optional[str],
|
||||||
|
playback_source: PlaybackSourceEnum,
|
||||||
|
export_case_id: Optional[str],
|
||||||
|
ffmpeg_input_args: Optional[str] = None,
|
||||||
|
ffmpeg_output_args: Optional[str] = None,
|
||||||
|
cpu_fallback: bool = False,
|
||||||
|
) -> ManagedRecordingExporter:
|
||||||
|
return ManagedRecordingExporter(
|
||||||
|
request.app.frigate_config,
|
||||||
|
_generate_export_id(camera_name),
|
||||||
|
camera_name,
|
||||||
|
friendly_name,
|
||||||
|
existing_image,
|
||||||
|
int(start_time),
|
||||||
|
int(end_time),
|
||||||
|
playback_source,
|
||||||
|
export_case_id,
|
||||||
|
ffmpeg_input_args,
|
||||||
|
ffmpeg_output_args,
|
||||||
|
cpu_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_exporter(exporter: ManagedRecordingExporter) -> None:
|
||||||
|
EXPORT_START_SEMAPHORE.acquire()
|
||||||
|
try:
|
||||||
|
exporter.start()
|
||||||
|
except Exception:
|
||||||
|
EXPORT_START_SEMAPHORE.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _export_case_to_dict(case: ExportCase) -> dict[str, object]:
|
||||||
|
case_dict = model_to_dict(case)
|
||||||
|
|
||||||
|
for field in ("created_at", "updated_at"):
|
||||||
|
value = case_dict.get(field)
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
case_dict[field] = value.timestamp()
|
||||||
|
|
||||||
|
return case_dict
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/exports",
|
"/exports",
|
||||||
@ -103,10 +316,8 @@ def get_exports(
|
|||||||
description="Gets all export cases from the database.",
|
description="Gets all export cases from the database.",
|
||||||
)
|
)
|
||||||
def get_export_cases():
|
def get_export_cases():
|
||||||
cases = (
|
cases = ExportCase.select().order_by(ExportCase.created_at.desc()).iterator()
|
||||||
ExportCase.select().order_by(ExportCase.created_at.desc()).dicts().iterator()
|
return JSONResponse(content=[_export_case_to_dict(case) for case in cases])
|
||||||
)
|
|
||||||
return JSONResponse(content=[c for c in cases])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@ -117,14 +328,8 @@ def get_export_cases():
|
|||||||
description="Creates a new export case.",
|
description="Creates a new export case.",
|
||||||
)
|
)
|
||||||
def create_export_case(body: ExportCaseCreateBody):
|
def create_export_case(body: ExportCaseCreateBody):
|
||||||
case = ExportCase.create(
|
case = _create_export_case_record(body.name, body.description)
|
||||||
id="".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
|
return JSONResponse(content=_export_case_to_dict(case))
|
||||||
name=body.name,
|
|
||||||
description=body.description,
|
|
||||||
created_at=Path().stat().st_mtime,
|
|
||||||
updated_at=Path().stat().st_mtime,
|
|
||||||
)
|
|
||||||
return JSONResponse(content=model_to_dict(case))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@ -137,7 +342,7 @@ def create_export_case(body: ExportCaseCreateBody):
|
|||||||
def get_export_case(case_id: str):
|
def get_export_case(case_id: str):
|
||||||
try:
|
try:
|
||||||
case = ExportCase.get(ExportCase.id == case_id)
|
case = ExportCase.get(ExportCase.id == case_id)
|
||||||
return JSONResponse(content=model_to_dict(case))
|
return JSONResponse(content=_export_case_to_dict(case))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Export case not found"},
|
content={"success": False, "message": "Export case not found"},
|
||||||
@ -166,6 +371,8 @@ def update_export_case(case_id: str, body: ExportCaseUpdateBody):
|
|||||||
if body.description is not None:
|
if body.description is not None:
|
||||||
case.description = body.description
|
case.description = body.description
|
||||||
|
|
||||||
|
case.updated_at = datetime.datetime.fromtimestamp(time.time())
|
||||||
|
|
||||||
case.save()
|
case.save()
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -241,6 +448,82 @@ async def assign_export_case(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/exports/batch",
|
||||||
|
response_model=BatchExportResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
summary="Start multi-camera recording export",
|
||||||
|
description=(
|
||||||
|
"Starts recording exports for multiple cameras for the same time range and "
|
||||||
|
"assigns them to a single export case."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def export_recordings_batch(request: Request, body: BatchExportBody):
|
||||||
|
case_validation_error = _validate_export_case(body.export_case_id)
|
||||||
|
if case_validation_error is not None:
|
||||||
|
return case_validation_error
|
||||||
|
|
||||||
|
export_case_id = body.export_case_id
|
||||||
|
if export_case_id is None:
|
||||||
|
export_case = _create_export_case_record(
|
||||||
|
body.new_case_name or body.name or "New Case",
|
||||||
|
body.new_case_description,
|
||||||
|
)
|
||||||
|
export_case_id = export_case.id
|
||||||
|
|
||||||
|
export_ids: list[str] = []
|
||||||
|
results: list[dict[str, Optional[str] | bool]] = []
|
||||||
|
camera_errors = _get_batch_recording_export_errors(
|
||||||
|
request,
|
||||||
|
body.camera_ids,
|
||||||
|
body.start_time,
|
||||||
|
body.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
for camera_name in dict.fromkeys(body.camera_ids):
|
||||||
|
camera_error = camera_errors.get(camera_name)
|
||||||
|
if camera_error is not None:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"camera": camera_name,
|
||||||
|
"export_id": None,
|
||||||
|
"success": False,
|
||||||
|
"error": camera_error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
exporter = _build_exporter(
|
||||||
|
request,
|
||||||
|
camera_name,
|
||||||
|
body.start_time,
|
||||||
|
body.end_time,
|
||||||
|
f"{body.name} - {camera_name}" if body.name else None,
|
||||||
|
None,
|
||||||
|
PlaybackSourceEnum.recordings,
|
||||||
|
export_case_id,
|
||||||
|
)
|
||||||
|
_start_exporter(exporter)
|
||||||
|
|
||||||
|
export_ids.append(exporter.export_id)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"camera": camera_name,
|
||||||
|
"export_id": exporter.export_id,
|
||||||
|
"success": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"export_case_id": export_case_id,
|
||||||
|
"export_ids": export_ids,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
||||||
response_model=StartExportResponse,
|
response_model=StartExportResponse,
|
||||||
@ -258,100 +541,51 @@ def export_recording(
|
|||||||
end_time: float,
|
end_time: float,
|
||||||
body: ExportRecordingsBody,
|
body: ExportRecordingsBody,
|
||||||
):
|
):
|
||||||
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
|
camera_validation_error = _validate_camera_name(request, camera_name)
|
||||||
return JSONResponse(
|
if camera_validation_error is not None:
|
||||||
content=(
|
return camera_validation_error
|
||||||
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
playback_source = body.source
|
playback_source = body.source
|
||||||
friendly_name = body.name
|
friendly_name = body.name
|
||||||
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
|
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||||
|
if image_validation_error is not None:
|
||||||
|
return image_validation_error
|
||||||
|
|
||||||
export_case_id = body.export_case_id
|
export_case_id = body.export_case_id
|
||||||
if export_case_id is not None:
|
case_validation_error = _validate_export_case(export_case_id)
|
||||||
try:
|
if case_validation_error is not None:
|
||||||
ExportCase.get(ExportCase.id == export_case_id)
|
return case_validation_error
|
||||||
except DoesNotExist:
|
|
||||||
return JSONResponse(
|
|
||||||
content={"success": False, "message": "Export case not found"},
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure that existing_image is a valid path
|
source_error = _validate_export_source(
|
||||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
camera_name,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
playback_source,
|
||||||
|
)
|
||||||
|
if source_error is not None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Invalid image path"}),
|
content={"success": False, "message": source_error},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if playback_source == "recordings":
|
exporter = _build_exporter(
|
||||||
recordings_count = (
|
request,
|
||||||
Recordings.select()
|
|
||||||
.where(
|
|
||||||
Recordings.start_time.between(start_time, end_time)
|
|
||||||
| Recordings.end_time.between(start_time, end_time)
|
|
||||||
| (
|
|
||||||
(start_time > Recordings.start_time)
|
|
||||||
& (end_time < Recordings.end_time)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(Recordings.camera == camera_name)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
if recordings_count <= 0:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{"success": False, "message": "No recordings found for time range"}
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
previews_count = (
|
|
||||||
Previews.select()
|
|
||||||
.where(
|
|
||||||
Previews.start_time.between(start_time, end_time)
|
|
||||||
| Previews.end_time.between(start_time, end_time)
|
|
||||||
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
|
|
||||||
)
|
|
||||||
.where(Previews.camera == camera_name)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_current_hour(start_time) and previews_count <= 0:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{"success": False, "message": "No previews found for time range"}
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
|
|
||||||
exporter = RecordingExporter(
|
|
||||||
request.app.frigate_config,
|
|
||||||
export_id,
|
|
||||||
camera_name,
|
camera_name,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
existing_image,
|
existing_image,
|
||||||
int(start_time),
|
playback_source,
|
||||||
int(end_time),
|
|
||||||
(
|
|
||||||
PlaybackSourceEnum[playback_source]
|
|
||||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
|
||||||
else PlaybackSourceEnum.recordings
|
|
||||||
),
|
|
||||||
export_case_id,
|
export_case_id,
|
||||||
)
|
)
|
||||||
exporter.start()
|
_start_exporter(exporter)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Starting export of recording.",
|
"message": "Starting export of recording.",
|
||||||
"export_id": export_id,
|
"export_id": exporter.export_id,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
@ -472,82 +706,36 @@ def export_recording_custom(
|
|||||||
end_time: float,
|
end_time: float,
|
||||||
body: ExportRecordingsCustomBody,
|
body: ExportRecordingsCustomBody,
|
||||||
):
|
):
|
||||||
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
|
camera_validation_error = _validate_camera_name(request, camera_name)
|
||||||
return JSONResponse(
|
if camera_validation_error is not None:
|
||||||
content=(
|
return camera_validation_error
|
||||||
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
playback_source = body.source
|
playback_source = body.source
|
||||||
friendly_name = body.name
|
friendly_name = body.name
|
||||||
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
|
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||||
|
if image_validation_error is not None:
|
||||||
|
return image_validation_error
|
||||||
ffmpeg_input_args = body.ffmpeg_input_args
|
ffmpeg_input_args = body.ffmpeg_input_args
|
||||||
ffmpeg_output_args = body.ffmpeg_output_args
|
ffmpeg_output_args = body.ffmpeg_output_args
|
||||||
cpu_fallback = body.cpu_fallback
|
cpu_fallback = body.cpu_fallback
|
||||||
|
|
||||||
export_case_id = body.export_case_id
|
export_case_id = body.export_case_id
|
||||||
if export_case_id is not None:
|
case_validation_error = _validate_export_case(export_case_id)
|
||||||
try:
|
if case_validation_error is not None:
|
||||||
ExportCase.get(ExportCase.id == export_case_id)
|
return case_validation_error
|
||||||
except DoesNotExist:
|
|
||||||
return JSONResponse(
|
|
||||||
content={"success": False, "message": "Export case not found"},
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure that existing_image is a valid path
|
source_error = _validate_export_source(
|
||||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
camera_name,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
playback_source,
|
||||||
|
)
|
||||||
|
if source_error is not None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Invalid image path"}),
|
content={"success": False, "message": source_error},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if playback_source == "recordings":
|
|
||||||
recordings_count = (
|
|
||||||
Recordings.select()
|
|
||||||
.where(
|
|
||||||
Recordings.start_time.between(start_time, end_time)
|
|
||||||
| Recordings.end_time.between(start_time, end_time)
|
|
||||||
| (
|
|
||||||
(start_time > Recordings.start_time)
|
|
||||||
& (end_time < Recordings.end_time)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(Recordings.camera == camera_name)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
if recordings_count <= 0:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{"success": False, "message": "No recordings found for time range"}
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
previews_count = (
|
|
||||||
Previews.select()
|
|
||||||
.where(
|
|
||||||
Previews.start_time.between(start_time, end_time)
|
|
||||||
| Previews.end_time.between(start_time, end_time)
|
|
||||||
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
|
|
||||||
)
|
|
||||||
.where(Previews.camera == camera_name)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_current_hour(start_time) and previews_count <= 0:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{"success": False, "message": "No previews found for time range"}
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
|
|
||||||
|
|
||||||
# Validate user-provided ffmpeg args to prevent injection.
|
# Validate user-provided ffmpeg args to prevent injection.
|
||||||
# Admin users are trusted and skip validation.
|
# Admin users are trusted and skip validation.
|
||||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||||
@ -577,31 +765,27 @@ def export_recording_custom(
|
|||||||
if ffmpeg_output_args is None:
|
if ffmpeg_output_args is None:
|
||||||
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
||||||
|
|
||||||
exporter = RecordingExporter(
|
exporter = _build_exporter(
|
||||||
request.app.frigate_config,
|
request,
|
||||||
export_id,
|
|
||||||
camera_name,
|
camera_name,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
existing_image,
|
existing_image,
|
||||||
int(start_time),
|
playback_source,
|
||||||
int(end_time),
|
|
||||||
(
|
|
||||||
PlaybackSourceEnum[playback_source]
|
|
||||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
|
||||||
else PlaybackSourceEnum.recordings
|
|
||||||
),
|
|
||||||
export_case_id,
|
export_case_id,
|
||||||
ffmpeg_input_args,
|
ffmpeg_input_args,
|
||||||
ffmpeg_output_args,
|
ffmpeg_output_args,
|
||||||
cpu_fallback,
|
cpu_fallback,
|
||||||
)
|
)
|
||||||
exporter.start()
|
_start_exporter(exporter)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Starting export of recording.",
|
"message": "Starting export of recording.",
|
||||||
"export_id": export_id,
|
"export_id": exporter.export_id,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user