cctv-stack 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/reviews.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Turning a review item into a candidate clip, and deciding whether to make one.
|
|
2
|
+
|
|
3
|
+
This is the trigger. It exists as pure functions because the restart experiment made its
|
|
4
|
+
shape non-negotiable: **a Frigate restart closes a review without publishing `end` at
|
|
5
|
+
all** — measured, with the subscriber connected to a broker that never restarted. A
|
|
6
|
+
trigger that waits for `type: end` loses that clip silently. So the trigger evaluates on
|
|
7
|
+
*every* message, and the only condition that matters is whether `end_time` is set.
|
|
8
|
+
|
|
9
|
+
## Two shapes, one parser
|
|
10
|
+
|
|
11
|
+
The same review arrives in two forms and both must parse:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// frigate/reviews on MQTT
|
|
15
|
+
{"type": "end", "before": {…}, "after": {"id": …, "end_time": …}}
|
|
16
|
+
|
|
17
|
+
// GET /api/review
|
|
18
|
+
[{"id": …, "end_time": …}]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Writing two parsers would mean two places to keep in step, and the sweep — which reads the
|
|
22
|
+
API — would drift from the trigger, which reads MQTT. That drift would show up as clipped
|
|
23
|
+
reviews the sweep keeps re-offering forever.
|
|
24
|
+
|
|
25
|
+
## Tolerance is a requirement, not politeness
|
|
26
|
+
|
|
27
|
+
Contract §3.4: "Unknown fields and unknown cameras must not break parsing." Frigate adds
|
|
28
|
+
fields between releases, and a stricter parser would start rejecting every message after an
|
|
29
|
+
upgrade — turning a routine update into an outage, remotely, with no way to patch it from
|
|
30
|
+
here. Unknown keys are therefore ignored, and only the fields we actually need are read.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from .timeutil import Instant
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ReviewCandidate",
|
|
44
|
+
"Verdict",
|
|
45
|
+
"candidate_from_message",
|
|
46
|
+
"candidates_from_reviews",
|
|
47
|
+
"decide",
|
|
48
|
+
"parse_review",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
SEVERITIES = frozenset({"alert", "detection"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ReviewParseError(ValueError):
|
|
55
|
+
"""A review payload missing something we cannot proceed without."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ReviewCandidate:
|
|
60
|
+
"""A review item, reduced to the fields we act on.
|
|
61
|
+
|
|
62
|
+
`event_ids` come from `data.detections` and are preserved rather than discarded, so a
|
|
63
|
+
per-visit clip stays searchable by event — the sidecar and the `clips.event_ids` column
|
|
64
|
+
both carry them.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
review_id: str
|
|
68
|
+
camera: str
|
|
69
|
+
severity: str
|
|
70
|
+
start_time: Instant
|
|
71
|
+
#: `None` while the review is still open. Not a missing value — an open visit, which
|
|
72
|
+
#: must not be clipped, because clipping it would capture half a visit with no way to
|
|
73
|
+
#: extend it afterwards.
|
|
74
|
+
end_time: Instant | None
|
|
75
|
+
event_ids: tuple[str, ...] = ()
|
|
76
|
+
objects: tuple[str, ...] = ()
|
|
77
|
+
zones: tuple[str, ...] = ()
|
|
78
|
+
sub_labels: tuple[str, ...] = ()
|
|
79
|
+
audio: tuple[str, ...] = ()
|
|
80
|
+
thumb_path: str | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_open(self) -> bool:
|
|
84
|
+
return self.end_time is None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def duration_s(self) -> float | None:
|
|
88
|
+
return None if self.end_time is None else self.end_time - self.start_time
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class Verdict:
|
|
93
|
+
"""Whether to materialise, and why not.
|
|
94
|
+
|
|
95
|
+
A reason rather than a bare boolean, because "why did this clip not appear" is the
|
|
96
|
+
question an operator actually asks, and answering it from a log line beats
|
|
97
|
+
reconstructing it from config.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
materialise: bool
|
|
101
|
+
reason: str
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def yes(cls) -> Verdict:
|
|
105
|
+
return cls(True, "ok")
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def no(cls, reason: str) -> Verdict:
|
|
109
|
+
return cls(False, reason)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _as_tuple(value: Any, *, where: str) -> tuple[str, ...]:
|
|
113
|
+
if value is None:
|
|
114
|
+
return ()
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
# A single label serialised as a bare string rather than a one-element list.
|
|
117
|
+
# Frigate does this in places, and refusing it would drop a real review.
|
|
118
|
+
return (value,)
|
|
119
|
+
if isinstance(value, Sequence):
|
|
120
|
+
return tuple(str(item) for item in value)
|
|
121
|
+
raise ReviewParseError(f"{where} must be a list or a string, got {type(value).__name__}.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _data_of(payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
125
|
+
"""Extract the `data` block, which arrives as a dict or as a JSON string.
|
|
126
|
+
|
|
127
|
+
Both are real: MQTT and the HTTP API deliver a dict, while the same field read back
|
|
128
|
+
out of Frigate's own database is a JSON string. Handling one and not the other is the
|
|
129
|
+
kind of bug that only appears in the sweep, hours later.
|
|
130
|
+
"""
|
|
131
|
+
raw = payload.get("data")
|
|
132
|
+
if raw is None:
|
|
133
|
+
return {}
|
|
134
|
+
if isinstance(raw, Mapping):
|
|
135
|
+
return raw
|
|
136
|
+
if isinstance(raw, str):
|
|
137
|
+
try:
|
|
138
|
+
decoded = json.loads(raw)
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
raise ReviewParseError(f"`data` is a string but not JSON: {exc}") from exc
|
|
141
|
+
if not isinstance(decoded, Mapping):
|
|
142
|
+
raise ReviewParseError("`data` decoded to something other than an object.")
|
|
143
|
+
return decoded
|
|
144
|
+
raise ReviewParseError(f"`data` must be an object or a JSON string, got {type(raw).__name__}.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_review(payload: Mapping[str, Any]) -> ReviewCandidate:
|
|
148
|
+
"""Parse one review object, in either shape, tolerantly.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ReviewParseError: an id, camera, severity or start time is missing, or a field we
|
|
152
|
+
read has the wrong type. Unknown *keys* are ignored by design.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(payload, Mapping):
|
|
155
|
+
raise ReviewParseError(f"Expected a review object, got {type(payload).__name__}.")
|
|
156
|
+
|
|
157
|
+
review_id = payload.get("id")
|
|
158
|
+
if not isinstance(review_id, str) or not review_id:
|
|
159
|
+
raise ReviewParseError("A review must have a non-empty string `id`.")
|
|
160
|
+
|
|
161
|
+
camera = payload.get("camera")
|
|
162
|
+
if not isinstance(camera, str) or not camera:
|
|
163
|
+
raise ReviewParseError(f"Review {review_id}: missing `camera`.")
|
|
164
|
+
|
|
165
|
+
severity = payload.get("severity")
|
|
166
|
+
if severity not in SEVERITIES:
|
|
167
|
+
raise ReviewParseError(
|
|
168
|
+
f"Review {review_id}: severity {severity!r} is not one of {sorted(SEVERITIES)}."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
start_time = payload.get("start_time")
|
|
172
|
+
if not isinstance(start_time, (int, float)) or isinstance(start_time, bool):
|
|
173
|
+
raise ReviewParseError(f"Review {review_id}: `start_time` must be a number.")
|
|
174
|
+
|
|
175
|
+
end_time = payload.get("end_time")
|
|
176
|
+
if end_time is not None and (
|
|
177
|
+
not isinstance(end_time, (int, float)) or isinstance(end_time, bool)
|
|
178
|
+
):
|
|
179
|
+
raise ReviewParseError(f"Review {review_id}: `end_time` must be a number or null.")
|
|
180
|
+
if end_time is not None and end_time < start_time:
|
|
181
|
+
raise ReviewParseError(
|
|
182
|
+
f"Review {review_id}: ends ({end_time}) before it starts ({start_time})."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
data = _data_of(payload)
|
|
186
|
+
return ReviewCandidate(
|
|
187
|
+
review_id=review_id,
|
|
188
|
+
camera=camera,
|
|
189
|
+
severity=str(severity),
|
|
190
|
+
start_time=float(start_time),
|
|
191
|
+
end_time=None if end_time is None else float(end_time),
|
|
192
|
+
event_ids=_as_tuple(data.get("detections"), where=f"Review {review_id}: data.detections"),
|
|
193
|
+
objects=_as_tuple(data.get("objects"), where=f"Review {review_id}: data.objects"),
|
|
194
|
+
zones=_as_tuple(data.get("zones"), where=f"Review {review_id}: data.zones"),
|
|
195
|
+
sub_labels=_as_tuple(data.get("sub_labels"), where=f"Review {review_id}: data.sub_labels"),
|
|
196
|
+
audio=_as_tuple(data.get("audio"), where=f"Review {review_id}: data.audio"),
|
|
197
|
+
thumb_path=(
|
|
198
|
+
payload.get("thumb_path") if isinstance(payload.get("thumb_path"), str) else None
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def candidate_from_message(message: Mapping[str, Any]) -> ReviewCandidate | None:
|
|
204
|
+
"""Parse an MQTT `frigate/reviews` message.
|
|
205
|
+
|
|
206
|
+
**The message `type` is deliberately not consulted.** `new`, `update` and `end` all
|
|
207
|
+
carry the current state of the review in `after`, and the restart experiment showed
|
|
208
|
+
that `end` cannot be trusted to arrive. Reading `after` on every message is what makes
|
|
209
|
+
the trigger work regardless.
|
|
210
|
+
|
|
211
|
+
Returns `None` when a message is structurally unusable — a payload with no `after`, or
|
|
212
|
+
one that fails to parse. A malformed message must never stop the worker: it is one
|
|
213
|
+
message, and the sweep will find the review anyway.
|
|
214
|
+
"""
|
|
215
|
+
after = message.get("after")
|
|
216
|
+
if not isinstance(after, Mapping):
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
return parse_review(after)
|
|
220
|
+
except ReviewParseError:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def candidates_from_reviews(reviews: Sequence[Any]) -> list[ReviewCandidate]:
|
|
225
|
+
"""Parse `GET /api/review`, skipping anything unusable.
|
|
226
|
+
|
|
227
|
+
Skips rather than raises, for the same reason as above: one bad row must not stop a
|
|
228
|
+
sweep that is the only thing standing between a restart and a lost clip.
|
|
229
|
+
"""
|
|
230
|
+
candidates: list[ReviewCandidate] = []
|
|
231
|
+
for entry in reviews:
|
|
232
|
+
if not isinstance(entry, Mapping):
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
candidates.append(parse_review(entry))
|
|
236
|
+
except ReviewParseError:
|
|
237
|
+
continue
|
|
238
|
+
return sorted(candidates, key=lambda c: c.start_time)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def decide(
|
|
242
|
+
candidate: ReviewCandidate,
|
|
243
|
+
*,
|
|
244
|
+
severities: Collection[str],
|
|
245
|
+
objects: Collection[str],
|
|
246
|
+
known_review_ids: Collection[str] = (),
|
|
247
|
+
) -> Verdict:
|
|
248
|
+
"""Whether this review should become a clip, and if not, why.
|
|
249
|
+
|
|
250
|
+
The order of the checks is the order of the log lines an operator will read: an open
|
|
251
|
+
review is the most common reason by a wide margin and the cheapest to rule out, and
|
|
252
|
+
"already materialised" comes next because it is what a sweep sees for every review it
|
|
253
|
+
has already handled.
|
|
254
|
+
|
|
255
|
+
`known_review_ids` arrives **already resolved** rather than being queried here, so this
|
|
256
|
+
function stays pure and a caller can batch the lookup for a whole page of reviews. That
|
|
257
|
+
also makes the membership test a set lookup rather than a database round trip.
|
|
258
|
+
"""
|
|
259
|
+
if candidate.is_open:
|
|
260
|
+
return Verdict.no("review is still open")
|
|
261
|
+
|
|
262
|
+
if candidate.review_id in known_review_ids:
|
|
263
|
+
return Verdict.no("already materialised")
|
|
264
|
+
|
|
265
|
+
if candidate.severity not in severities:
|
|
266
|
+
return Verdict.no(f"severity {candidate.severity} is not in clip.severities")
|
|
267
|
+
|
|
268
|
+
if not objects:
|
|
269
|
+
# An empty filter is a configuration error caught earlier, but treating it as
|
|
270
|
+
# "match nothing" here would silently produce no clips at all.
|
|
271
|
+
return Verdict.no("clip.objects is empty")
|
|
272
|
+
|
|
273
|
+
if not any(label in objects for label in candidate.objects):
|
|
274
|
+
# This is also where an audio-only review lands: it populates `data.audio` and
|
|
275
|
+
# leaves `data.objects` empty, so with the default `[person]` filter it is dropped.
|
|
276
|
+
# Nothing can be pinned for it either, since there is no event to retain.
|
|
277
|
+
return Verdict.no(f"no label in clip.objects among {list(candidate.objects) or ['(none)']}")
|
|
278
|
+
|
|
279
|
+
return Verdict.yes()
|