mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
5 Commits
11bb9fed4c
...
b47a47c44a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47a47c44a | ||
|
|
33abaaa9f8 | ||
|
|
95b5b89ed9 | ||
|
|
a182385618 | ||
|
|
088e1ad7ef |
@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
|
|||||||
FROM deps AS deps-prelim
|
FROM deps AS deps-prelim
|
||||||
|
|
||||||
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
|
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
|
||||||
RUN apt-get update && \
|
# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching
|
||||||
|
# -dev package must also come from trixie or apt refuses to satisfy it.
|
||||||
|
RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y libnuma1 && \
|
apt-get install -y libnuma1 && \
|
||||||
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
|
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
|
||||||
# Install C++ standard library headers for HIPRTC kernel compilation fallback
|
apt-get install -qq -y -t trixie libstdc++-14-dev && \
|
||||||
apt-get install -qq -y libstdc++-12-dev && \
|
rm -f /etc/apt/sources.list.d/trixie.list && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/frigate
|
WORKDIR /opt/frigate
|
||||||
|
|||||||
@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al
|
|||||||
- If it is difficult to make out details in a persons face it will not be helpful in training.
|
- If it is difficult to make out details in a persons face it will not be helpful in training.
|
||||||
- Avoid images with extreme under/over-exposure.
|
- Avoid images with extreme under/over-exposure.
|
||||||
- Avoid blurry / pixelated images.
|
- Avoid blurry / pixelated images.
|
||||||
- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images.
|
- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images.
|
||||||
- Using images of people wearing hats / sunglasses may confuse the model.
|
- Using images of people wearing hats / sunglasses may confuse the model.
|
||||||
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.
|
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.
|
||||||
|
|
||||||
|
|||||||
@ -5,13 +5,15 @@ 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 List, Optional
|
from typing import Iterator, 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
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pathvalidate import sanitize_filepath
|
from pathvalidate import sanitize_filename, sanitize_filepath
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
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(
|
@router.patch(
|
||||||
"/cases/{case_id}",
|
"/cases/{case_id}",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess):
|
|||||||
self.stop_event,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
maintainer.start()
|
maintainer.start()
|
||||||
|
maintainer.join()
|
||||||
|
|
||||||
|
# If the maintainer thread exited but no shutdown was requested, it
|
||||||
|
# crashed. Surface as a non-zero exit so the watchdog restarts us
|
||||||
|
# instead of treating the silent thread death as a clean shutdown.
|
||||||
|
if not self.stop_event.is_set():
|
||||||
|
logger.error("Embeddings maintainer thread exited unexpectedly")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingsContext:
|
class EmbeddingsContext:
|
||||||
|
|||||||
@ -153,9 +153,6 @@ Each line represents a detection state, not necessarily unique individuals. The
|
|||||||
if "other_concerns" in schema.get("required", []):
|
if "other_concerns" in schema.get("required", []):
|
||||||
schema["required"].remove("other_concerns")
|
schema["required"].remove("other_concerns")
|
||||||
|
|
||||||
# OpenAI strict mode requires additionalProperties: false on all objects
|
|
||||||
schema["additionalProperties"] = False
|
|
||||||
|
|
||||||
response_format = {
|
response_format = {
|
||||||
"type": "json_schema",
|
"type": "json_schema",
|
||||||
"json_schema": {
|
"json_schema": {
|
||||||
|
|||||||
@ -136,22 +136,44 @@ class GeminiClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
gemini_messages.append(
|
parts: list[types.Part] = []
|
||||||
types.Content(
|
if content:
|
||||||
role="model", parts=[types.Part.from_text(text=content)]
|
parts.append(types.Part.from_text(text=content))
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
func = tc.get("function") or {}
|
||||||
|
tc_name = func.get("name") or ""
|
||||||
|
tc_args: Any = func.get("arguments")
|
||||||
|
if isinstance(tc_args, str):
|
||||||
|
try:
|
||||||
|
tc_args = json.loads(tc_args)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
tc_args = {}
|
||||||
|
if not isinstance(tc_args, dict):
|
||||||
|
tc_args = {}
|
||||||
|
if tc_name:
|
||||||
|
parts.append(
|
||||||
|
types.Part.from_function_call(
|
||||||
|
name=tc_name, args=tc_args
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not parts:
|
||||||
|
parts.append(types.Part.from_text(text=" "))
|
||||||
|
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||||
elif role == "tool":
|
elif role == "tool":
|
||||||
# Handle tool response
|
# Handle tool response
|
||||||
function_response = {
|
response_payload = (
|
||||||
"name": msg.get("name", ""),
|
content if isinstance(content, dict) else {"result": content}
|
||||||
"response": content,
|
)
|
||||||
}
|
|
||||||
gemini_messages.append(
|
gemini_messages.append(
|
||||||
types.Content(
|
types.Content(
|
||||||
role="function",
|
role="function",
|
||||||
parts=[
|
parts=[
|
||||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
types.Part.from_function_response(
|
||||||
|
name=msg.get("name")
|
||||||
|
or msg.get("tool_call_id")
|
||||||
|
or "",
|
||||||
|
response=response_payload,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -343,22 +365,44 @@ class GeminiClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
gemini_messages.append(
|
parts: list[types.Part] = []
|
||||||
types.Content(
|
if content:
|
||||||
role="model", parts=[types.Part.from_text(text=content)]
|
parts.append(types.Part.from_text(text=content))
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
func = tc.get("function") or {}
|
||||||
|
tc_name = func.get("name") or ""
|
||||||
|
tc_args: Any = func.get("arguments")
|
||||||
|
if isinstance(tc_args, str):
|
||||||
|
try:
|
||||||
|
tc_args = json.loads(tc_args)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
tc_args = {}
|
||||||
|
if not isinstance(tc_args, dict):
|
||||||
|
tc_args = {}
|
||||||
|
if tc_name:
|
||||||
|
parts.append(
|
||||||
|
types.Part.from_function_call(
|
||||||
|
name=tc_name, args=tc_args
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not parts:
|
||||||
|
parts.append(types.Part.from_text(text=" "))
|
||||||
|
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||||
elif role == "tool":
|
elif role == "tool":
|
||||||
# Handle tool response
|
# Handle tool response
|
||||||
function_response = {
|
response_payload = (
|
||||||
"name": msg.get("name", ""),
|
content if isinstance(content, dict) else {"result": content}
|
||||||
"response": content,
|
)
|
||||||
}
|
|
||||||
gemini_messages.append(
|
gemini_messages.append(
|
||||||
types.Content(
|
types.Content(
|
||||||
role="function",
|
role="function",
|
||||||
parts=[
|
parts=[
|
||||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
types.Part.from_function_response(
|
||||||
|
name=msg.get("name")
|
||||||
|
or msg.get("tool_call_id")
|
||||||
|
or "",
|
||||||
|
response=response_payload,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -44,6 +44,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
_supports_tools: bool
|
_supports_tools: bool
|
||||||
_image_token_cache: dict[tuple[int, int], int]
|
_image_token_cache: dict[tuple[int, int], int]
|
||||||
_text_baseline_tokens: int | None
|
_text_baseline_tokens: int | None
|
||||||
|
_media_marker: str
|
||||||
|
|
||||||
def _init_provider(self) -> str | None:
|
def _init_provider(self) -> str | None:
|
||||||
"""Initialize the client and query model metadata from the server."""
|
"""Initialize the client and query model metadata from the server."""
|
||||||
@ -56,6 +57,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
self._supports_tools = False
|
self._supports_tools = False
|
||||||
self._image_token_cache = {}
|
self._image_token_cache = {}
|
||||||
self._text_baseline_tokens = None
|
self._text_baseline_tokens = None
|
||||||
|
self._media_marker = "<__media__>"
|
||||||
|
|
||||||
base_url = (
|
base_url = (
|
||||||
self.genai_config.base_url.rstrip("/")
|
self.genai_config.base_url.rstrip("/")
|
||||||
@ -141,6 +143,13 @@ class LlamaCppClient(GenAIClient):
|
|||||||
chat_caps = props.get("chat_template_caps", {})
|
chat_caps = props.get("chat_template_caps", {})
|
||||||
self._supports_tools = chat_caps.get("supports_tools", False)
|
self._supports_tools = chat_caps.get("supports_tools", False)
|
||||||
|
|
||||||
|
# Media marker for multimodal embeddings; the server randomizes this
|
||||||
|
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
|
||||||
|
# from /props rather than hardcoding "<__media__>".
|
||||||
|
media_marker = props.get("media_marker")
|
||||||
|
if isinstance(media_marker, str) and media_marker:
|
||||||
|
self._media_marker = media_marker
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||||
configured_model,
|
configured_model,
|
||||||
@ -465,10 +474,11 @@ class LlamaCppClient(GenAIClient):
|
|||||||
jpeg_bytes = _to_jpeg(img)
|
jpeg_bytes = _to_jpeg(img)
|
||||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||||
# prompt_string must contain <__media__> placeholder for image tokenization
|
# prompt_string must contain the server's media marker placeholder.
|
||||||
|
# The marker is randomized per server startup (read from /props).
|
||||||
content.append(
|
content.append(
|
||||||
{
|
{
|
||||||
"prompt_string": "<__media__>\n",
|
"prompt_string": f"{self._media_marker}\n",
|
||||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
"multimodal_data": [encoded], # type: ignore[dict-item]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -73,8 +73,17 @@ class OpenAIClient(GenAIClient):
|
|||||||
**self.genai_config.runtime_options,
|
**self.genai_config.runtime_options,
|
||||||
}
|
}
|
||||||
if response_format:
|
if response_format:
|
||||||
|
# OpenAI strict mode requires additionalProperties: false on the schema
|
||||||
|
if response_format.get("type") == "json_schema" and response_format.get(
|
||||||
|
"json_schema", {}
|
||||||
|
).get("strict"):
|
||||||
|
schema = response_format.get("json_schema", {}).get("schema")
|
||||||
|
if isinstance(schema, dict):
|
||||||
|
schema["additionalProperties"] = False
|
||||||
request_params["response_format"] = response_format
|
request_params["response_format"] = response_format
|
||||||
|
|
||||||
result = self.provider.chat.completions.create(**request_params)
|
result = self.provider.chat.completions.create(**request_params)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
result is not None
|
result is not None
|
||||||
and hasattr(result, "choices")
|
and hasattr(result, "choices")
|
||||||
|
|||||||
@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
|
|||||||
)
|
)
|
||||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||||
from frigate.log import LogPipe
|
from frigate.log import LogPipe
|
||||||
from frigate.util.builtin import EventsPerSecond
|
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
|
||||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
FrameManager,
|
FrameManager,
|
||||||
@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# all built-in record presets use this segment_time
|
||||||
|
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _get_record_segment_time(config: CameraConfig) -> int:
|
||||||
|
"""Extract -segment_time from the camera's record output args."""
|
||||||
|
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||||
|
|
||||||
|
if record_args and record_args[0].startswith("preset"):
|
||||||
|
return DEFAULT_RECORD_SEGMENT_TIME
|
||||||
|
|
||||||
|
try:
|
||||||
|
idx = record_args.index("-segment_time")
|
||||||
|
return int(record_args[idx + 1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return DEFAULT_RECORD_SEGMENT_TIME
|
||||||
|
|
||||||
|
|
||||||
def capture_frames(
|
def capture_frames(
|
||||||
ffmpeg_process: sp.Popen[Any],
|
ffmpeg_process: sp.Popen[Any],
|
||||||
@ -164,6 +181,12 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.latest_cache_segment_time: float = 0
|
self.latest_cache_segment_time: float = 0
|
||||||
self.record_enable_time: datetime | None = None
|
self.record_enable_time: datetime | None = None
|
||||||
|
|
||||||
|
# `valid` segments are published with the segment's start time, so the
|
||||||
|
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||||
|
# staleness threshold so it's never tighter than that worst case.
|
||||||
|
segment_time = _get_record_segment_time(self.config)
|
||||||
|
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
||||||
|
|
||||||
# Stall tracking (based on last processed frame)
|
# Stall tracking (based on last processed frame)
|
||||||
self._stall_timestamps: deque[float] = deque()
|
self._stall_timestamps: deque[float] = deque()
|
||||||
self._stall_active: bool = False
|
self._stall_active: bool = False
|
||||||
@ -413,16 +436,17 @@ class CameraWatchdog(threading.Thread):
|
|||||||
|
|
||||||
# ensure segments are still being created and that they have valid video data
|
# ensure segments are still being created and that they have valid video data
|
||||||
# Skip checks during grace period to allow segments to start being created
|
# Skip checks during grace period to allow segments to start being created
|
||||||
|
stale_window = timedelta(seconds=self.record_stale_threshold)
|
||||||
cache_stale = not in_grace_period and now_utc > (
|
cache_stale = not in_grace_period and now_utc > (
|
||||||
latest_cache_dt + timedelta(seconds=120)
|
latest_cache_dt + stale_window
|
||||||
)
|
)
|
||||||
valid_stale = not in_grace_period and now_utc > (
|
valid_stale = not in_grace_period and now_utc > (
|
||||||
latest_valid_dt + timedelta(seconds=120)
|
latest_valid_dt + stale_window
|
||||||
)
|
)
|
||||||
invalid_stale_condition = (
|
invalid_stale_condition = (
|
||||||
self.latest_invalid_segment_time > 0
|
self.latest_invalid_segment_time > 0
|
||||||
and not in_grace_period
|
and not in_grace_period
|
||||||
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
|
and now_utc > (latest_invalid_dt + stale_window)
|
||||||
and self.latest_valid_segment_time
|
and self.latest_valid_segment_time
|
||||||
<= self.latest_invalid_segment_time
|
<= self.latest_invalid_segment_time
|
||||||
)
|
)
|
||||||
@ -439,7 +463,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..."
|
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
|
||||||
)
|
)
|
||||||
p["process"] = start_or_restart_ffmpeg(
|
p["process"] = start_or_restart_ffmpeg(
|
||||||
p["cmd"],
|
p["cmd"],
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class MonitoredProcess:
|
|||||||
restart_timestamps: deque[float] = field(
|
restart_timestamps: deque[float] = field(
|
||||||
default_factory=lambda: deque(maxlen=MAX_RESTARTS)
|
default_factory=lambda: deque(maxlen=MAX_RESTARTS)
|
||||||
)
|
)
|
||||||
|
clean_exit_logged: bool = False
|
||||||
|
|
||||||
def is_restarting_too_fast(self, now: float) -> bool:
|
def is_restarting_too_fast(self, now: float) -> bool:
|
||||||
while (
|
while (
|
||||||
@ -72,7 +73,9 @@ class FrigateWatchdog(threading.Thread):
|
|||||||
|
|
||||||
exitcode = entry.process.exitcode
|
exitcode = entry.process.exitcode
|
||||||
if exitcode == 0:
|
if exitcode == 0:
|
||||||
|
if not entry.clean_exit_logged:
|
||||||
logger.info("Process %s exited cleanly, not restarting", entry.name)
|
logger.info("Process %s exited cleanly, not restarting", entry.name)
|
||||||
|
entry.clean_exit_logged = True
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@ -57,6 +57,7 @@ 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,
|
||||||
@ -777,8 +778,29 @@ function Exports() {
|
|||||||
filters={["cameras"]}
|
filters={["cameras"]}
|
||||||
onUpdateFilter={setExportFilter}
|
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("button.download", { ns: "common" })}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2 p-2"
|
className="flex items-center gap-2 p-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -823,9 +845,10 @@ function Exports() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,13 +10,16 @@ import axios from "axios";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useJobStatus } from "@/api/ws";
|
import { useJobStatus } from "@/api/ws";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { LuCheck, LuX } from "react-icons/lu";
|
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { MediaSyncResults, MediaSyncStats } from "@/types/ws";
|
import { MediaSyncResults, MediaSyncStats } from "@/types/ws";
|
||||||
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export default function MediaSyncSettingsView() {
|
export default function MediaSyncSettingsView() {
|
||||||
const { t } = useTranslation("views/settings");
|
const { t } = useTranslation("views/settings");
|
||||||
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const [selectedMediaTypes, setSelectedMediaTypes] = useState<string[]>([
|
const [selectedMediaTypes, setSelectedMediaTypes] = useState<string[]>([
|
||||||
"all",
|
"all",
|
||||||
]);
|
]);
|
||||||
@ -109,13 +112,25 @@ export default function MediaSyncSettingsView() {
|
|||||||
<Heading as="h4" className="mb-2 hidden md:block">
|
<Heading as="h4" className="mb-2 hidden md:block">
|
||||||
{t("maintenance.sync.title")}
|
{t("maintenance.sync.title")}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-muted-foreground">
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-muted-foreground">
|
||||||
<p>{t("maintenance.sync.desc")}</p>
|
<p>{t("maintenance.sync.desc")}</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex items-center text-primary-variant">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl(
|
||||||
|
"configuration/record#syncing-media-files-with-disk",
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Media Types Selection */}
|
{/* Media Types Selection */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user