diff --git a/frigate/api/export.py b/frigate/api/export.py index 714420903..2f4ca78da 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -5,13 +5,15 @@ import logging import random import string import time +import zipfile +from collections import deque from pathlib import Path -from typing import List, Optional +from typing import Iterator, List, Optional import psutil from fastapi import APIRouter, Depends, Query, Request -from fastapi.responses import JSONResponse -from pathvalidate import sanitize_filepath +from fastapi.responses import JSONResponse, StreamingResponse +from pathvalidate import sanitize_filename, sanitize_filepath from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict @@ -361,6 +363,136 @@ def get_export_case(case_id: str): ) +_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB + + +class _StreamingZipBuffer: + """File-like sink for ZipFile that exposes written bytes via drain(). + + ZipFile writes synchronously into this buffer; the generator drains the + queue between writes so StreamingResponse can yield bytes without + materializing the whole archive in memory. + """ + + def __init__(self) -> None: + self._queue: deque[bytes] = deque() + self._offset = 0 + + def write(self, data: bytes) -> int: + if data: + self._queue.append(bytes(data)) + self._offset += len(data) + return len(data) + + def tell(self) -> int: + return self._offset + + def flush(self) -> None: + pass + + def drain(self) -> Iterator[bytes]: + while self._queue: + yield self._queue.popleft() + + +def _unique_archive_name(export: Export, used: set[str]) -> str: + base = sanitize_filename(export.name) if export.name else None + if not base: + base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}" + + candidate = f"{base}.mp4" + counter = 1 + while candidate in used: + candidate = f"{base}_{counter}.mp4" + counter += 1 + + used.add(candidate) + return candidate + + +def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]: + """Yield bytes of a zip archive built from the given exports' mp4 files.""" + buffer = _StreamingZipBuffer() + used_names: set[str] = set() + + # ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win. + with zipfile.ZipFile( + buffer, + mode="w", + compression=zipfile.ZIP_STORED, + allowZip64=True, + ) as archive: + for export in exports: + source = Path(export.video_path) + if not source.exists(): + continue + + arcname = _unique_archive_name(export, used_names) + + with ( + archive.open(arcname, mode="w", force_zip64=True) as entry, + source.open("rb") as src, + ): + while True: + chunk = src.read(_ZIP_STREAM_CHUNK_SIZE) + if not chunk: + break + + entry.write(chunk) + yield from buffer.drain() + + yield from buffer.drain() + + yield from buffer.drain() + + +@router.get( + "/cases/{case_id}/download", + dependencies=[Depends(allow_any_authenticated())], + summary="Download export case as zip", + description="Streams a zip archive containing every completed export's mp4 for the given case.", +) +def download_export_case( + case_id: str, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + try: + case = ExportCase.get(ExportCase.id == case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + exports = list( + Export.select() + .where( + Export.export_case == case_id, + ~Export.in_progress, + Export.camera << allowed_cameras, + ) + .order_by(Export.date.asc()) + ) + + if not exports: + return JSONResponse( + content={"success": False, "message": "No exports available to download."}, + status_code=404, + ) + + archive_base = sanitize_filename(case.name) if case.name else "" + if not archive_base: + archive_base = case_id + + return StreamingResponse( + _stream_case_archive(exports), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{archive_base}.zip"', + }, + ) + + @router.patch( "/cases/{case_id}", response_model=GenericResponse, diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 4f9b78c6d..7b336d7ad 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -57,6 +57,7 @@ import { useTranslation } from "react-i18next"; import { IoMdArrowRoundBack } from "react-icons/io"; import { + LuDownload, LuFolderPlus, LuFolderX, LuPencil, @@ -777,54 +778,76 @@ function Exports() { filters={["cameras"]} onUpdateFilter={setExportFilter} /> - {isAdmin && ( -