mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-01 11:07:41 +03:00
Add ability to download case as zip (#23034)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
This commit is contained in:
parent
011ad8eda7
commit
088e1ad7ef
@ -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,
|
||||
|
||||
@ -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 && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
asChild
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.addExport")}
|
||||
onClick={() => setCaseForAddExport(selectedCase)}
|
||||
aria-label={t("button.download", { ns: "common" })}
|
||||
>
|
||||
<LuPlus className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.addExport")}
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
download
|
||||
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
|
||||
>
|
||||
<LuDownload className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("button.download", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.editCase")}
|
||||
onClick={() =>
|
||||
setCaseDialog({
|
||||
mode: "edit",
|
||||
exportCase: selectedCase,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.editCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.deleteCase")}
|
||||
onClick={() => setCaseToDelete(selectedCase)}
|
||||
>
|
||||
<LuTrash2 className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.deleteCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.addExport")}
|
||||
onClick={() => setCaseForAddExport(selectedCase)}
|
||||
>
|
||||
<LuPlus className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.addExport")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.editCase")}
|
||||
onClick={() =>
|
||||
setCaseDialog({
|
||||
mode: "edit",
|
||||
exportCase: selectedCase,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.editCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.deleteCase")}
|
||||
onClick={() => setCaseToDelete(selectedCase)}
|
||||
>
|
||||
<LuTrash2 className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.deleteCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user