Compare commits

..

10 Commits

Author SHA1 Message Date
Josh Hawkins
a6f8e1b9a9 formatting 2026-01-13 14:56:56 -06:00
Josh Hawkins
66bbe62ffb fix double encoding of passwords in camera wizard 2026-01-13 13:01:01 -06:00
Nicolas Mowen
4b034644d2 Avoid unknown class for cover image 2026-01-13 10:31:25 -07:00
Nicolas Mowen
82410f8278 Return result 2026-01-13 10:09:23 -07:00
Nicolas Mowen
2b31c53614 Fix jetson stats reading 2026-01-13 10:08:43 -07:00
Nicolas Mowen
2c34e1ec10 Miscellaneous fixes (0.17 beta) (#21607)
* Strip model name before training

* Handle options file for go2rtc option

* Make reviewed optional and add null to API call

* Send reviewed for dashboard

* Allow setting context size for openai compatible endpoints

* push empty go2rtc config to avoid homekit error in log

* Add option to set runtime options for LLM providers

* Docs

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-01-12 20:36:38 -07:00
GuoQing Liu
91cc6747b6 i18n miscellaneous fixes (#21614)
* fix: fix face library unknown label i18n wrong

* fix: fix review genai threat level i18n

* fix: fix preview unknown label i18n

* fix: fix AM/PM i18n display issue
2026-01-12 09:15:27 -06:00
Blake Blackshear
7b5a1b7284 ensure cloudflare pages are indexed by google (#21606)
* ensure cloudflare pages are indexed by google

* avoid indexing dev-docs as well
2026-01-11 08:48:03 -07:00
Dermot Duffy
7e5d98dbab fix: Correctly apply API filter for "reviewed" (#21600) 2026-01-11 06:42:33 -07:00
Josh Hawkins
d952a97bda reduce gif size for docs assets changes (#21594) 2026-01-10 12:59:15 -07:00
28 changed files with 206 additions and 50 deletions

View File

@@ -54,8 +54,8 @@ function setup_homekit_config() {
local config_path="$1"
if [[ ! -f "${config_path}" ]]; then
echo "[INFO] Creating empty HomeKit config file..."
echo 'homekit: {}' > "${config_path}"
echo "[INFO] Creating empty config file for HomeKit..."
echo '{}' > "${config_path}"
fi
# Convert YAML to JSON for jq processing

View File

@@ -23,8 +23,28 @@ sys.path.remove("/opt/frigate")
yaml = YAML()
# Check if arbitrary exec sources are allowed (defaults to False for security)
ALLOW_ARBITRARY_EXEC = os.environ.get(
"GO2RTC_ALLOW_ARBITRARY_EXEC", "false"
allow_arbitrary_exec = None
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
elif (
os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK)
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
):
allow_arbitrary_exec = (
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
.read_text()
.strip()
)
# check for the add-on options file
elif os.path.isfile("/data/options.json"):
with open("/data/options.json") as f:
raw_options = f.read()
options = json.loads(raw_options)
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
allow_arbitrary_exec
).lower() in ("true", "1", "yes")
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}

View File

@@ -41,12 +41,12 @@ If you are trying to use a single model for Frigate and HomeAssistant, it will n
The following models are recommended:
| Model | Notes |
| ----------------- | -------------------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
| `Intern3.5VL` | Relatively fast with good vision comprehension |
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
| Model | Notes |
| ------------- | -------------------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
| `Intern3.5VL` | Relatively fast with good vision comprehension |
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
:::note
@@ -61,10 +61,10 @@ genai:
provider: ollama
base_url: http://localhost:11434
model: minicpm-v:8b
provider_options: # other Ollama client options can be defined
provider_options: # other Ollama client options can be defined
keep_alive: -1
options:
num_ctx: 8192 # make sure the context matches other services that are using ollama
num_ctx: 8192 # make sure the context matches other services that are using ollama
```
## Google Gemini
@@ -120,6 +120,23 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
:::
:::tip
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
```yaml
genai:
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
```
This ensures Frigate uses the correct context window size when generating prompts.
:::
## Azure OpenAI
Microsoft offers several vision models through Azure OpenAI. A subscription is required.

View File

@@ -696,6 +696,9 @@ genai:
# Optional additional args to pass to the GenAI Provider (default: None)
provider_options:
keep_alive: -1
# Optional: Options to pass during inference calls (default: {})
runtime_options:
temperature: 0.7
# Optional: Configuration for audio transcription
# NOTE: only the enabled option can be overridden at the camera level

8
docs/static/_headers vendored Normal file
View File

@@ -0,0 +1,8 @@
https://:project.pages.dev/*
X-Robots-Tag: noindex
https://:version.:project.pages.dev/*
X-Robots-Tag: noindex
https://docs-dev.frigate.video/*
X-Robots-Tag: noindex

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 MiB

After

Width:  |  Height:  |  Size: 12 MiB

View File

@@ -848,9 +848,10 @@ async def onvif_probe(
try:
if isinstance(uri, str) and uri.startswith("rtsp://"):
if username and password and "@" not in uri:
# Inject URL-encoded credentials and add only the
# authenticated version.
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
# Inject raw credentials and add only the
# authenticated version. The credentials will be encoded
# later by ffprobe_stream or the config system.
cred = f"{username}:{password}@"
injected = uri.replace(
"rtsp://", f"rtsp://{cred}", 1
)
@@ -903,12 +904,8 @@ async def onvif_probe(
"/cam/realmonitor?channel=1&subtype=0",
"/11",
]
# Use URL-encoded credentials for pattern fallback URIs when provided
auth_str = (
f"{quote_plus(username)}:{quote_plus(password)}@"
if username and password
else ""
)
# Use raw credentials for pattern fallback URIs when provided
auth_str = f"{username}:{password}@" if username and password else ""
rtsp_port = 554
for path in common_paths:
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
@@ -930,7 +927,7 @@ async def onvif_probe(
and uri.startswith("rtsp://")
and "@" not in uri
):
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
cred = f"{username}:{password}@"
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
if cred_uri not in to_test:
to_test.append(cred_uri)

View File

@@ -10,7 +10,7 @@ class ReviewQueryParams(BaseModel):
cameras: str = "all"
labels: str = "all"
zones: str = "all"
reviewed: int = 0
reviewed: Union[int, SkipJsonSchema[None]] = None
limit: Union[int, SkipJsonSchema[None]] = None
severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
before: Union[float, SkipJsonSchema[None]] = None

View File

@@ -144,6 +144,8 @@ async def review(
(UserReviewStatus.has_been_reviewed == False)
| (UserReviewStatus.has_been_reviewed.is_null())
)
elif reviewed == 1:
review_query = review_query.where(UserReviewStatus.has_been_reviewed == True)
# Apply ordering and limit
review_query = (

View File

@@ -26,3 +26,6 @@ class GenAIConfig(FrigateBaseModel):
provider_options: dict[str, Any] = Field(
default={}, title="GenAI Provider extra options."
)
runtime_options: dict[str, Any] = Field(
default={}, title="Options to pass during inference calls."
)

View File

@@ -64,6 +64,7 @@ class OpenAIClient(GenAIClient):
},
],
timeout=self.timeout,
**self.genai_config.runtime_options,
)
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))

View File

@@ -35,10 +35,14 @@ class GeminiClient(GenAIClient):
for img in images
] + [prompt]
try:
# Merge runtime_options into generation_config if provided
generation_config_dict = {"candidate_count": 1}
generation_config_dict.update(self.genai_config.runtime_options)
response = self.provider.generate_content(
data,
generation_config=genai.types.GenerationConfig(
candidate_count=1,
**generation_config_dict
),
request_options=genai.types.RequestOptions(
timeout=self.timeout,

View File

@@ -58,11 +58,15 @@ class OllamaClient(GenAIClient):
)
return None
try:
ollama_options = {
**self.provider_options,
**self.genai_config.runtime_options,
}
result = self.provider.generate(
self.genai_config.model,
prompt,
images=images if images else None,
**self.provider_options,
**ollama_options,
)
logger.debug(
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"

View File

@@ -22,9 +22,14 @@ class OpenAIClient(GenAIClient):
def _init_provider(self):
"""Initialize the client."""
return OpenAI(
api_key=self.genai_config.api_key, **self.genai_config.provider_options
)
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
# It will be used in get_context_size() instead
provider_opts = {
k: v
for k, v in self.genai_config.provider_options.items()
if k != "context_size"
}
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
"""Submit a request to OpenAI."""
@@ -56,6 +61,7 @@ class OpenAIClient(GenAIClient):
},
],
timeout=self.timeout,
**self.genai_config.runtime_options,
)
if (
result is not None
@@ -73,6 +79,16 @@ class OpenAIClient(GenAIClient):
if self.context_size is not None:
return self.context_size
# First check provider_options for manually specified context size
# This is necessary for llama.cpp and other OpenAI-compatible servers
# that don't expose the configured runtime context size in the API response
if "context_size" in self.genai_config.provider_options:
self.context_size = self.genai_config.provider_options["context_size"]
logger.debug(
f"Using context size {self.context_size} from provider_options for model {self.genai_config.model}"
)
return self.context_size
try:
models = self.provider.models.list()
for model in models.data:

View File

@@ -171,8 +171,8 @@ class BaseTestHttp(unittest.TestCase):
def insert_mock_event(
self,
id: str,
start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20,
start_time: float | None = None,
end_time: float | None = None,
has_clip: bool = True,
top_score: int = 100,
score: int = 0,
@@ -180,6 +180,11 @@ class BaseTestHttp(unittest.TestCase):
camera: str = "front_door",
) -> Event:
"""Inserts a basic event model with a given id."""
if start_time is None:
start_time = datetime.datetime.now().timestamp()
if end_time is None:
end_time = start_time + 20
return Event.insert(
id=id,
label="Mock",
@@ -229,11 +234,16 @@ class BaseTestHttp(unittest.TestCase):
def insert_mock_recording(
self,
id: str,
start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20,
start_time: float | None = None,
end_time: float | None = None,
motion: int = 0,
) -> Event:
"""Inserts a recording model with a given id."""
if start_time is None:
start_time = datetime.datetime.now().timestamp()
if end_time is None:
end_time = start_time + 20
return Recordings.insert(
id=id,
path=id,

View File

@@ -96,16 +96,17 @@ class TestHttpApp(BaseTestHttp):
assert len(events) == 0
def test_get_event_list_limit(self):
now = datetime.now().timestamp()
id = "123456.random"
id2 = "54321.random"
with AuthTestClient(self.app) as client:
super().insert_mock_event(id)
super().insert_mock_event(id, start_time=now + 1)
events = client.get("/events").json()
assert len(events) == 1
assert events[0]["id"] == id
super().insert_mock_event(id2)
super().insert_mock_event(id2, start_time=now)
events = client.get("/events").json()
assert len(events) == 2
@@ -144,7 +145,7 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id2
assert events[1]["id"] == id
events = client.get("/events", params={"sort": "score_des"}).json()
events = client.get("/events", params={"sort": "score_desc"}).json()
assert len(events) == 2
assert events[0]["id"] == id
assert events[1]["id"] == id2

View File

@@ -196,6 +196,50 @@ class TestHttpReview(BaseTestHttp):
assert len(response_json) == 1
assert response_json[0]["id"] == id
def test_get_review_with_reviewed_filter_unreviewed(self):
"""Test that reviewed=0 returns only unreviewed items."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
id_unreviewed = "123456.unreviewed"
id_reviewed = "123456.reviewed"
super().insert_mock_review_segment(id_unreviewed, now, now + 2)
super().insert_mock_review_segment(id_reviewed, now, now + 2)
self._insert_user_review_status(id_reviewed, reviewed=True)
params = {
"reviewed": 0,
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == id_unreviewed
def test_get_review_with_reviewed_filter_reviewed(self):
"""Test that reviewed=1 returns only reviewed items."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
id_unreviewed = "123456.unreviewed"
id_reviewed = "123456.reviewed"
super().insert_mock_review_segment(id_unreviewed, now, now + 2)
super().insert_mock_review_segment(id_reviewed, now, now + 2)
self._insert_user_review_status(id_reviewed, reviewed=True)
params = {
"reviewed": 1,
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == id_reviewed
####################################################################################################################
################################### GET /review/summary Endpoint #################################################
####################################################################################################################

View File

@@ -43,6 +43,7 @@ def write_training_metadata(model_name: str, image_count: int) -> None:
model_name: Name of the classification model
image_count: Number of images used in training
"""
model_name = model_name.strip()
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
os.makedirs(clips_model_dir, exist_ok=True)
@@ -70,6 +71,7 @@ def read_training_metadata(model_name: str) -> dict[str, any] | None:
Returns:
Dictionary with last_training_date and last_training_image_count, or None if not found
"""
model_name = model_name.strip()
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
@@ -95,6 +97,7 @@ def get_dataset_image_count(model_name: str) -> int:
Returns:
Total count of images across all categories
"""
model_name = model_name.strip()
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
if not os.path.exists(dataset_dir):
@@ -126,6 +129,7 @@ class ClassificationTrainingProcess(FrigateProcess):
"TF_KERAS_MOBILENET_V2_WEIGHTS_URL",
"",
)
model_name = model_name.strip()
super().__init__(
stop_event=None,
priority=PROCESS_PRIORITY_LOW,
@@ -292,6 +296,7 @@ class ClassificationTrainingProcess(FrigateProcess):
def kickoff_model_training(
embeddingRequestor: EmbeddingsRequestor, model_name: str
) -> None:
model_name = model_name.strip()
requestor = InterProcessRequestor()
requestor.send_data(
UPDATE_MODEL_STATE,
@@ -359,6 +364,7 @@ def collect_state_classification_examples(
model_name: Name of the classification model
cameras: Dict mapping camera names to normalized crop coordinates [x1, y1, x2, y2] (0-1)
"""
model_name = model_name.strip()
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
# Step 1: Get review items for the cameras
@@ -714,6 +720,7 @@ def collect_object_classification_examples(
model_name: Name of the classification model
label: Object label to collect (e.g., "person", "car")
"""
model_name = model_name.strip()
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
temp_dir = os.path.join(dataset_dir, "temp")
os.makedirs(temp_dir, exist_ok=True)

View File

@@ -540,9 +540,16 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
try:
results["mem"] = "-" # no discrete gpu memory
with open("/sys/devices/gpu.0/load", "r") as f:
gpuload = float(f.readline()) / 10
results["gpu"] = f"{gpuload}%"
if os.path.exists("/sys/devices/gpu.0/load"):
with open("/sys/devices/gpu.0/load", "r") as f:
gpuload = float(f.readline()) / 10
results["gpu"] = f"{gpuload}%"
elif os.path.exists("/sys/devices/platform/gpu.0/load"):
with open("/sys/devices/platform/gpu.0/load", "r") as f:
gpuload = float(f.readline()) / 10
results["gpu"] = f"{gpuload}%"
else:
results["gpu"] = "-"
except Exception:
return None

View File

@@ -15,6 +15,9 @@
},
"provider_options": {
"label": "GenAI Provider extra options."
},
"runtime_options": {
"label": "Options to pass during inference calls."
}
}
}
}

View File

@@ -164,7 +164,7 @@ export const ClassificationCard = forwardRef<
)}
>
<div className="break-all smart-capitalize">
{data.name == "unknown"
{data.name.toLowerCase() == "unknown"
? t("details.unknown")
: data.name.toLowerCase() == "none"
? t("details.none")
@@ -336,7 +336,7 @@ export function GroupedClassificationCard({
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
{classifiedEvent?.label && classifiedEvent.label !== "none"
? classifiedEvent.label
: t(noClassificationLabel)}
: t(noClassificationLabel, { ns: i18nLibrary })}
{classifiedEvent?.label &&
classifiedEvent.label !== "none" &&
classifiedEvent.score !== undefined && (

View File

@@ -57,7 +57,7 @@ export function GenAISummaryDialog({
!aiAnalysis ||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
) {
return "None";
return t("label.none", { ns: "common" });
}
let concerns = "";
@@ -74,7 +74,9 @@ export function GenAISummaryDialog({
label = t("securityConcern", { ns: "views/events" });
break;
default:
label = THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] || "Unknown";
label =
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
t("details.unknown", { ns: "views/classificationModel" });
}
concerns = `${label}\n`;
}
@@ -83,7 +85,7 @@ export function GenAISummaryDialog({
concerns += `${c}\n`;
});
return concerns || "None";
return concerns || t("label.none", { ns: "common" });
}, [aiAnalysis, t]);
// layout

View File

@@ -342,7 +342,9 @@ export default function PreviewThumbnailPlayer({
default:
return (
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
"Unknown"
t("details.unknown", {
ns: "views/classificationModel",
})
);
}
})()}

View File

@@ -205,7 +205,7 @@ export default function Events() {
cameras: reviewSearchParams["cameras"],
labels: reviewSearchParams["labels"],
zones: reviewSearchParams["zones"],
reviewed: 1,
reviewed: null, // We want both reviewed and unreviewed items as we filter in the UI
before: reviewSearchParams["before"] || last24Hours.before,
after: reviewSearchParams["after"] || last24Hours.after,
};

View File

@@ -81,7 +81,8 @@ export async function detectReolinkCamera(
export function maskUri(uri: string): string {
try {
// Handle RTSP URLs with user:pass@host format
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
// Use greedy match for password to handle passwords with @
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):(.+)@(.+)/);
if (rtspMatch) {
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
}

View File

@@ -165,7 +165,7 @@ export const formatUnixTimestampToDateTime = (
// Uppercase AM/PM for 12-hour formats
if (date_format.includes("a") || date_format.includes("aaa")) {
formatted = formatted.replace(/am|pm/gi, (match) =>
match.toUpperCase(),
i18n.t(`time.${match.toLowerCase()}`, { ns: "common" }).toUpperCase(),
);
}
return formatted;
@@ -217,7 +217,7 @@ export const formatUnixTimestampToDateTime = (
// Uppercase AM/PM in fallback
if (options.hour12) {
fallbackFormatted = fallbackFormatted.replace(/am|pm/gi, (match) =>
match.toUpperCase(),
i18n.t(`time.${match.toLowerCase()}`, { ns: "common" }).toUpperCase(),
);
}
return fallbackFormatted;

View File

@@ -266,7 +266,10 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
return undefined;
}
const keys = Object.keys(dataset.categories).filter((key) => key != "none");
const keys = Object.keys(dataset.categories).filter(
(key) => key != "none" && key.toLowerCase() != "unknown",
);
if (keys.length === 0) {
return undefined;
}

View File

@@ -114,6 +114,7 @@ export default function LiveDashboardView({
{
limit: 10,
severity: "alert",
reviewed: 0,
cameras: alertCameras,
},
]);