mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
21 Commits
c34e71784b
...
a5c699393a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5c699393a | ||
|
|
16b4eec91d | ||
|
|
37599e1641 | ||
|
|
98a9c50dd9 | ||
|
|
250a68947b | ||
|
|
e6d4f71f76 | ||
|
|
826456dd4f | ||
|
|
30840b5e0e | ||
|
|
ad8ce46dfb | ||
|
|
7cf336d373 | ||
|
|
22fa876340 | ||
|
|
7573f97174 | ||
|
|
eafc08e3de | ||
|
|
6d6fa303b8 | ||
|
|
725f211c63 | ||
|
|
8a17fad2fb | ||
|
|
d7b91ff320 | ||
|
|
3e526febe3 | ||
|
|
ababc2deba | ||
|
|
209e48e579 | ||
|
|
3aab79b435 |
@ -5,15 +5,13 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
import zipfile
|
|
||||||
from collections import deque
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pathvalidate import sanitize_filename, sanitize_filepath
|
from pathvalidate import sanitize_filepath
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
@ -363,136 +361,6 @@ 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(
|
@router.patch(
|
||||||
"/cases/{case_id}",
|
"/cases/{case_id}",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
|||||||
@ -20,7 +20,6 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
ffmpeg = "ffmpeg"
|
ffmpeg = "ffmpeg"
|
||||||
live = "live"
|
live = "live"
|
||||||
motion = "motion" # includes motion and motion masks
|
motion = "motion" # includes motion and motion masks
|
||||||
mqtt = "mqtt"
|
|
||||||
notifications = "notifications"
|
notifications = "notifications"
|
||||||
objects = "objects"
|
objects = "objects"
|
||||||
object_genai = "object_genai"
|
object_genai = "object_genai"
|
||||||
@ -34,7 +33,6 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
lpr = "lpr"
|
lpr = "lpr"
|
||||||
snapshots = "snapshots"
|
snapshots = "snapshots"
|
||||||
timestamp_style = "timestamp_style"
|
timestamp_style = "timestamp_style"
|
||||||
ui = "ui"
|
|
||||||
zones = "zones"
|
zones = "zones"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class ReviewMetadata(BaseModel):
|
|||||||
)
|
)
|
||||||
title: str = Field(
|
title: str = Field(
|
||||||
max_length=80,
|
max_length=80,
|
||||||
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
|
description="A short title characterizing what took place and where, under 10 words.",
|
||||||
)
|
)
|
||||||
scene: str = Field(
|
scene: str = Field(
|
||||||
min_length=150,
|
min_length=150,
|
||||||
@ -36,7 +36,7 @@ class ReviewMetadata(BaseModel):
|
|||||||
)
|
)
|
||||||
shortSummary: str = Field(
|
shortSummary: str = Field(
|
||||||
min_length=70,
|
min_length=70,
|
||||||
max_length=120,
|
max_length=100,
|
||||||
description="A brief 2-sentence summary of the scene, suitable for notifications.",
|
description="A brief 2-sentence summary of the scene, suitable for notifications.",
|
||||||
)
|
)
|
||||||
confidence: float = Field(
|
confidence: float = Field(
|
||||||
|
|||||||
@ -517,16 +517,10 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
for processor in self.post_processors:
|
|
||||||
if isinstance(processor, ObjectDescriptionProcessor):
|
|
||||||
processor.cleanup_event(event_id)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip the event if not an object
|
# Skip the event if not an object
|
||||||
if event.data.get("type") != "object":
|
if event.data.get("type") != "object":
|
||||||
for processor in self.post_processors:
|
|
||||||
if isinstance(processor, ObjectDescriptionProcessor):
|
|
||||||
processor.cleanup_event(event_id)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract valid thumbnail
|
# Extract valid thumbnail
|
||||||
|
|||||||
@ -205,7 +205,6 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.transcription_thread.start()
|
self.transcription_thread.start()
|
||||||
|
|
||||||
self.was_enabled = camera.enabled
|
self.was_enabled = camera.enabled
|
||||||
self.was_audio_enabled = camera.audio.enabled
|
|
||||||
|
|
||||||
def detect_audio(self, audio: np.ndarray) -> None:
|
def detect_audio(self, audio: np.ndarray) -> None:
|
||||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||||
@ -364,17 +363,6 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
audio_enabled = self.camera_config.audio.enabled
|
|
||||||
if audio_enabled != self.was_audio_enabled:
|
|
||||||
if not audio_enabled:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Disabling audio detections for {self.camera_config.name}, ending events"
|
|
||||||
)
|
|
||||||
self.requestor.send_data(
|
|
||||||
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
|
|
||||||
)
|
|
||||||
self.was_audio_enabled = audio_enabled
|
|
||||||
|
|
||||||
self.read_audio()
|
self.read_audio()
|
||||||
|
|
||||||
if self.audio_listener:
|
if self.audio_listener:
|
||||||
|
|||||||
@ -201,10 +201,9 @@ Each line represents a detection state, not necessarily unique individuals. The
|
|||||||
except json.JSONDecodeError as je:
|
except json.JSONDecodeError as je:
|
||||||
logger.error("Failed to parse review description JSON: %s", je)
|
logger.error("Failed to parse review description JSON: %s", je)
|
||||||
return None
|
return None
|
||||||
# observations and confidence are required on the model; fill an empty default
|
# observations is required on the model; fill an empty default
|
||||||
# if the response omitted it so attribute access stays safe.
|
# if the response omitted it so attribute access stays safe.
|
||||||
raw.setdefault("observations", [])
|
raw.setdefault("observations", [])
|
||||||
raw.setdefault("confidence", 0.0)
|
|
||||||
metadata = ReviewMetadata.model_construct(**raw)
|
metadata = ReviewMetadata.model_construct(**raw)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@ -317,16 +317,16 @@ class CameraWatchdog(threading.Thread):
|
|||||||
if camera != self.config.name:
|
if camera != self.config.name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
if topic.endswith(RecordingsDataTypeEnum.valid.value):
|
||||||
self.logger.warning(
|
|
||||||
f"Invalid recording segment detected for {camera} at {segment_time}"
|
|
||||||
)
|
|
||||||
self.latest_invalid_segment_time = segment_time
|
|
||||||
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"Latest valid recording segment time on {camera}: {segment_time}"
|
f"Latest valid recording segment time on {camera}: {segment_time}"
|
||||||
)
|
)
|
||||||
self.latest_valid_segment_time = segment_time
|
self.latest_valid_segment_time = segment_time
|
||||||
|
elif topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
||||||
|
self.logger.warning(
|
||||||
|
f"Invalid recording segment detected for {camera} at {segment_time}"
|
||||||
|
)
|
||||||
|
self.latest_invalid_segment_time = segment_time
|
||||||
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
||||||
if segment_time is not None:
|
if segment_time is not None:
|
||||||
self.latest_cache_segment_time = segment_time
|
self.latest_cache_segment_time = segment_time
|
||||||
|
|||||||
@ -1300,13 +1300,7 @@
|
|||||||
"enableDesc": "Dezactivează temporar o cameră până la repornirea Frigate. Dezactivarea oprește procesarea stream-urilor pentru această cameră. Detecția, înregistrarea și depanarea vor fi indisponibile.<br /> <em>Notă: Acest lucru nu dezactivează restream-urile go2rtc.</em>",
|
"enableDesc": "Dezactivează temporar o cameră până la repornirea Frigate. Dezactivarea oprește procesarea stream-urilor pentru această cameră. Detecția, înregistrarea și depanarea vor fi indisponibile.<br /> <em>Notă: Acest lucru nu dezactivează restream-urile go2rtc.</em>",
|
||||||
"disableLabel": "Camere dezactivate",
|
"disableLabel": "Camere dezactivate",
|
||||||
"disableDesc": "Activează o cameră care este ascunsă în interfață și dezactivată în configurație. Este necesară repornirea Frigate după activare.",
|
"disableDesc": "Activează o cameră care este ascunsă în interfață și dezactivată în configurație. Este necesară repornirea Frigate după activare.",
|
||||||
"enableSuccess": "Am activat {{cameraName}} în configurație. Repornește Frigate pentru a aplica modificările.",
|
"enableSuccess": "Am activat {{cameraName}} în configurație. Repornește Frigate pentru a aplica modificările."
|
||||||
"friendlyName": {
|
|
||||||
"edit": "Editează numele afișat al camerei",
|
|
||||||
"title": "Editează numele afișat",
|
|
||||||
"description": "Setează numele afișat pentru această cameră în întreaga interfață Frigate. Lasă necompletat pentru a folosi ID-ul camerei.",
|
|
||||||
"rename": "Redenumește"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"cameraConfig": {
|
"cameraConfig": {
|
||||||
"add": "Adaugă Cameră",
|
"add": "Adaugă Cameră",
|
||||||
|
|||||||
@ -57,7 +57,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import {
|
import {
|
||||||
LuDownload,
|
|
||||||
LuFolderPlus,
|
LuFolderPlus,
|
||||||
LuFolderX,
|
LuFolderX,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
@ -778,76 +777,54 @@ function Exports() {
|
|||||||
filters={["cameras"]}
|
filters={["cameras"]}
|
||||||
onUpdateFilter={setExportFilter}
|
onUpdateFilter={setExportFilter}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 md:gap-2">
|
{isAdmin && (
|
||||||
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
|
||||||
className="flex items-center gap-2 p-2"
|
className="flex items-center gap-2 p-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={t("button.download", { ns: "common" })}
|
aria-label={t("toolbar.addExport")}
|
||||||
|
onClick={() => setCaseForAddExport(selectedCase)}
|
||||||
>
|
>
|
||||||
<a
|
<LuPlus className="text-secondary-foreground" />
|
||||||
download
|
{!isMobile && (
|
||||||
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
|
<div className="text-primary">
|
||||||
>
|
{t("toolbar.addExport")}
|
||||||
<LuDownload className="text-secondary-foreground" />
|
</div>
|
||||||
{!isMobile && (
|
)}
|
||||||
<div className="text-primary">
|
|
||||||
{t("button.download", { ns: "common" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
{isAdmin && (
|
className="flex items-center gap-2 p-2"
|
||||||
<>
|
size="sm"
|
||||||
<Button
|
aria-label={t("toolbar.editCase")}
|
||||||
className="flex items-center gap-2 p-2"
|
onClick={() =>
|
||||||
size="sm"
|
setCaseDialog({
|
||||||
aria-label={t("toolbar.addExport")}
|
mode: "edit",
|
||||||
onClick={() => setCaseForAddExport(selectedCase)}
|
exportCase: selectedCase,
|
||||||
>
|
})
|
||||||
<LuPlus className="text-secondary-foreground" />
|
}
|
||||||
{!isMobile && (
|
>
|
||||||
<div className="text-primary">
|
<LuPencil className="text-secondary-foreground" />
|
||||||
{t("toolbar.addExport")}
|
{!isMobile && (
|
||||||
</div>
|
<div className="text-primary">
|
||||||
)}
|
{t("toolbar.editCase")}
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
)}
|
||||||
className="flex items-center gap-2 p-2"
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
aria-label={t("toolbar.editCase")}
|
className="flex items-center gap-2 p-2"
|
||||||
onClick={() =>
|
size="sm"
|
||||||
setCaseDialog({
|
aria-label={t("toolbar.deleteCase")}
|
||||||
mode: "edit",
|
onClick={() => setCaseToDelete(selectedCase)}
|
||||||
exportCase: selectedCase,
|
>
|
||||||
})
|
<LuTrash2 className="text-secondary-foreground" />
|
||||||
}
|
{!isMobile && (
|
||||||
>
|
<div className="text-primary">
|
||||||
<LuPencil className="text-secondary-foreground" />
|
{t("toolbar.deleteCase")}
|
||||||
{!isMobile && (
|
</div>
|
||||||
<div className="text-primary">
|
)}
|
||||||
{t("toolbar.editCase")}
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user