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/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()