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/dispatch.py ADDED
@@ -0,0 +1,270 @@
1
+ """Getting review messages off the network thread and into a worker.
2
+
3
+ The MQTT callback must not do the work. Materialising a clip waits out the flush grace,
4
+ fetches, probes and writes — tens of seconds — and doing that on the network loop's thread
5
+ stops it servicing the socket, so messages are *missed*. That failure is invisible: the
6
+ broker does not know we were busy, the queue does not exist, and the only symptom is clips
7
+ that never appear.
8
+
9
+ So the callback parses, hands over, and returns. A bounded queue and a worker thread do the
10
+ rest.
11
+
12
+ ## Dropping under load is correct, and blocking is not
13
+
14
+ MQTT delivery here is at-most-once (see below), so **the sweep is the guarantee and this is
15
+ the latency optimisation**. Given that, a full queue must drop and count rather than block:
16
+ blocking the callback costs messages *and* stalls every other topic, whereas dropping costs
17
+ only latency — the sweep will find the review on its next pass.
18
+
19
+ ## The pre-filter is queue hygiene, not a second decision
20
+
21
+ Most traffic on `frigate/reviews` is `update` messages about reviews that are still **open**,
22
+ and an open review can never become a clip. Queueing them would let a chatty camera fill a
23
+ bounded queue and cause *closed* reviews to be dropped — losing exactly the messages that
24
+ mattered, to make room for ones that could not be acted on.
25
+
26
+ So open reviews are dropped at the door. This is not a duplicate of
27
+ [`reviews.decide`][cctv.reviews.decide] — it is the one condition that has to be applied
28
+ before the queue exists, and `decide` still applies it (and every other filter) in the
29
+ worker, because the sweep path has no door to filter at.
30
+
31
+ ## Why not QoS 1
32
+
33
+ Subscribing at QoS 1 does not upgrade a publisher sending at QoS 0, and a broker restart
34
+ still loses in-flight messages. The completeness guarantee has to come from somewhere that
35
+ does not depend on delivery at all, which is the sweep. Claiming reliability here would
36
+ only move the lie somewhere harder to find.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import contextlib
42
+ import queue
43
+ import threading
44
+ from collections.abc import Callable, Mapping
45
+ from dataclasses import dataclass, field
46
+ from typing import Any
47
+
48
+ from .reviews import (
49
+ ReviewCandidate,
50
+ ReviewParseError,
51
+ candidate_from_message,
52
+ parse_review,
53
+ )
54
+
55
+ __all__ = [
56
+ "DROPPED",
57
+ "OPEN",
58
+ "QUEUED",
59
+ "UNPARSABLE",
60
+ "DispatchStats",
61
+ "ReviewDispatcher",
62
+ ]
63
+
64
+ QUEUED = "queued"
65
+ DROPPED = "dropped"
66
+ UNPARSABLE = "unparsable"
67
+ OPEN = "open"
68
+
69
+ #: Deep enough to ride out a burst of closed reviews, shallow enough that a stall is
70
+ #: noticed as a count rather than as unbounded memory.
71
+ DEFAULT_MAX_PENDING = 1000
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class DispatchStats:
76
+ """Counters for the health check.
77
+
78
+ `dropped` and `unparsable` existing as *numbers* is the point: a system that silently
79
+ discards a tenth of its input and one that is simply idle look identical from the
80
+ outside.
81
+ """
82
+
83
+ received: int = 0
84
+ queued: int = 0
85
+ handle: int = 0
86
+ open_reviews: int = 0
87
+ unparsable: int = 0
88
+ dropped: int = 0
89
+ handler_errors: int = 0
90
+ last_error: str | None = None
91
+ _reasons: dict[str, int] = field(default_factory=dict, repr=False)
92
+
93
+ def note(self, reason: str) -> None:
94
+ self._reasons[reason] = self._reasons.get(reason, 0) + 1
95
+
96
+ @property
97
+ def reasons(self) -> Mapping[str, int]:
98
+ return dict(self._reasons)
99
+
100
+ @property
101
+ def lost(self) -> int:
102
+ """Messages that will only be recovered by the sweep."""
103
+ return self.dropped + self.unparsable
104
+
105
+
106
+ class ReviewDispatcher:
107
+ """Bounded hand-off between the MQTT callback and the materialiser.
108
+
109
+ `handler` receives a closed [`ReviewCandidate`][cctv.reviews.ReviewCandidate] and may
110
+ take as long as it likes: it runs on the worker, never on the callback.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ *,
116
+ handler: Callable[[ReviewCandidate], Any],
117
+ max_pending: int = DEFAULT_MAX_PENDING,
118
+ on_open: Callable[[ReviewCandidate], Any] | None = None,
119
+ clock: Callable[[], float] | None = None,
120
+ ) -> None:
121
+ if max_pending < 1:
122
+ raise ValueError("max_pending must be at least 1.")
123
+ self.handler = handler
124
+ self.max_pending = max_pending
125
+ self.on_open = on_open
126
+ self.stats = DispatchStats()
127
+
128
+ self._queue: queue.Queue[ReviewCandidate] = queue.Queue(maxsize=max_pending)
129
+ self._stop = threading.Event()
130
+ self._thread: threading.Thread | None = None
131
+
132
+ # -----------------------------------------------------------------------------------
133
+ # Called from the network thread. Must not block, and must not raise.
134
+ # -----------------------------------------------------------------------------------
135
+
136
+ def dispatch(self, message: Mapping[str, Any]) -> str:
137
+ """Parse and enqueue one `frigate/reviews` message.
138
+
139
+ Returns what happened, so a caller can count it. **Never raises**: an exception
140
+ escaping an MQTT callback is swallowed by the network loop, so a single bad message
141
+ would otherwise stop every future message from being seen.
142
+ """
143
+ self.stats.received += 1
144
+
145
+ candidate = self._parse(message)
146
+ if candidate is None:
147
+ # Counted, not just noted. `lost` is derived from this, and a discard that is
148
+ # only in the reason map would make the health check report nothing discarded.
149
+ self.stats.unparsable += 1
150
+ return UNPARSABLE
151
+
152
+ if candidate.is_open:
153
+ # The majority of traffic, and none of it can ever be a clip. See the module
154
+ # docstring: queueing it would evict the messages that matter.
155
+ self.stats.open_reviews += 1
156
+ self.stats.note("review still open")
157
+ if self.on_open is not None:
158
+ _swallow(self.on_open, candidate)
159
+ return OPEN
160
+
161
+ try:
162
+ self._queue.put_nowait(candidate)
163
+ except queue.Full:
164
+ self.stats.dropped += 1
165
+ self.stats.note("queue full")
166
+ return DROPPED
167
+
168
+ self.stats.queued += 1
169
+ return QUEUED
170
+
171
+ def _parse(self, message: Mapping[str, Any]) -> ReviewCandidate | None:
172
+ """Parse without trusting the shape.
173
+
174
+ `after` is read on **every** message type. The restart experiment showed Frigate
175
+ closes a review without publishing `end` at all, so a trigger keyed on the message
176
+ type would lose that clip silently.
177
+ """
178
+ if not isinstance(message, Mapping):
179
+ self.stats.note("message is not an object")
180
+ return None
181
+ try:
182
+ if "after" in message:
183
+ return candidate_from_message(message)
184
+ # Accept a bare review object too: the sweep reads that shape from the API, and
185
+ # a test or a replay tool will hand it over in the same form.
186
+ return parse_review(message)
187
+ except ReviewParseError as exc:
188
+ self.stats.note(str(exc)[:120])
189
+ return None
190
+
191
+ # -----------------------------------------------------------------------------------
192
+ # The worker
193
+ # -----------------------------------------------------------------------------------
194
+
195
+ def work_once(self, *, timeout: float | None = None) -> ReviewCandidate | None:
196
+ """Take one candidate and handle it. Returns what was handled, if anything.
197
+
198
+ Synchronous so tests need neither a broker nor a thread.
199
+ """
200
+ try:
201
+ candidate = self._queue.get(timeout=timeout)
202
+ except queue.Empty:
203
+ return None
204
+
205
+ try:
206
+ self.handler(candidate)
207
+ except Exception as exc:
208
+ # A handler failure is counted, not fatal. The review is still in Frigate, so
209
+ # the sweep will offer it again — which is the whole reason this can be
210
+ # forgiving.
211
+ self.stats.handler_errors += 1
212
+ self.stats.last_error = f"{type(exc).__name__}: {exc}"[:200]
213
+ self.stats.note("handler error")
214
+ else:
215
+ self.stats.handle += 1
216
+ finally:
217
+ self._queue.task_done()
218
+
219
+ return candidate
220
+
221
+ def drain(self, *, limit: int | None = None) -> int:
222
+ """Handle everything currently queued. Returns how many were handled."""
223
+ handled = 0
224
+ while limit is None or handled < limit:
225
+ if self.work_once(timeout=0) is None:
226
+ break
227
+ handled += 1
228
+ return handled
229
+
230
+ @property
231
+ def pending(self) -> int:
232
+ return self._queue.qsize()
233
+
234
+ # -----------------------------------------------------------------------------------
235
+ # Thread lifecycle
236
+ # -----------------------------------------------------------------------------------
237
+
238
+ def start(self) -> None:
239
+ if self._thread is not None:
240
+ raise RuntimeError("Dispatcher already started.")
241
+ self._stop.clear()
242
+ self._thread = threading.Thread(target=self._run, name="review-dispatcher", daemon=True)
243
+ self._thread.start()
244
+
245
+ def _run(self) -> None:
246
+ while not self._stop.is_set():
247
+ self.work_once(timeout=0.5)
248
+
249
+ def stop(self, *, timeout: float = 5.0) -> None:
250
+ self._stop.set()
251
+ if self._thread is not None:
252
+ self._thread.join(timeout=timeout)
253
+ self._thread = None
254
+
255
+ def __enter__(self) -> ReviewDispatcher:
256
+ self.start()
257
+ return self
258
+
259
+ def __exit__(self, *exc_info: object) -> None:
260
+ self.stop()
261
+
262
+
263
+ def _swallow(callback: Callable[[ReviewCandidate], Any], candidate: ReviewCandidate) -> None:
264
+ """Run an optional observer, ignoring whatever it does.
265
+
266
+ Used for the `on_open` hook, which exists so a caller can log or count open reviews
267
+ without that being able to break the callback.
268
+ """
269
+ with contextlib.suppress(Exception):
270
+ callback(candidate)
cctv/ffprobe.py ADDED
@@ -0,0 +1,246 @@
1
+ """Reading what `ffprobe` says about a clip.
2
+
3
+ The output of this module is the evidence for [INV-14](../../../docs/architecture/invariants.md).
4
+ Without it, "are the clips right?" is an opinion; with it, it is a query against the
5
+ `clips` table's quality columns.
6
+
7
+ ## `max_packet_gap_s` is advisory, and nothing may gate on it
8
+
9
+ The natural way to detect a hole in a clip is the largest gap between packet timestamps.
10
+ On this stack that detector **does not work**. Measured on a healthy 30-second clip cut
11
+ with `-c copy`: the largest gap was **3.55 s**, against a 2.55 s keyframe interval. The
12
+ likely cause is PTS discontinuities at the joins between recording segments, which
13
+ `-c copy` reproduces faithfully because it is not re-encoding anything.
14
+
15
+ So a threshold that looks reasonable — say 2.0 s — flags nearly every healthy clip. The
16
+ field is still recorded, because the data is real and a future detector may want it, but
17
+ **truncation is detected by coverage, not by gaps.**
18
+
19
+ See [ADR-0015](../../../docs/adr/0015-clip-boundaries-and-padding.md) for the three
20
+ candidate replacements, the preferred one being to read Frigate's own `recordings` table
21
+ rather than inferring anything from the media.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import subprocess
29
+ from collections.abc import Mapping, Sequence
30
+ from dataclasses import dataclass
31
+ from itertools import pairwise
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from .clip import DEFAULT_COVERAGE_OK
36
+
37
+ __all__ = [
38
+ "ClipFacts",
39
+ "ProbeError",
40
+ "build_probe_command",
41
+ "coverage_ratio",
42
+ "looks_truncated",
43
+ "max_packet_gap",
44
+ "parse_probe",
45
+ "run_probe",
46
+ ]
47
+
48
+ #: The binary, overridable because a container's ffmpeg is rarely on `PATH`. Frigate
49
+ #: itself vendors several builds under `/usr/lib/ffmpeg/<version>/bin/`, and our own image
50
+ #: will have its own arrangement — so the path is configuration, not a constant.
51
+ FFPROBE_ENV = "FFPROBE_BIN"
52
+
53
+
54
+ class ProbeError(RuntimeError):
55
+ """`ffprobe` could not read the file, or is not installed."""
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ClipFacts:
60
+ """What we learned about a clip by inspecting it rather than trusting the fetch."""
61
+
62
+ duration_s: float | None
63
+ video_codec: str | None
64
+ audio_codec: str | None
65
+ has_audio: bool
66
+ format_name: str | None
67
+ packet_count: int
68
+ #: Advisory only — see the module docstring. Never gate on this.
69
+ max_packet_gap_s: float | None
70
+ keyframe_interval_s: float | None
71
+
72
+ def coverage_against(self, requested_s: float) -> float | None:
73
+ return coverage_ratio(self.duration_s, requested_s)
74
+
75
+
76
+ def build_probe_command(path: str) -> list[str]:
77
+ """The `ffprobe` invocation, as a list so nothing is ever shell-interpreted.
78
+
79
+ `-v error` keeps stderr useful: warnings about non-monotonic DTS would otherwise
80
+ drown the actual error on a genuinely broken file.
81
+ """
82
+ return [
83
+ "ffprobe",
84
+ "-v",
85
+ "error",
86
+ "-show_format",
87
+ "-show_streams",
88
+ "-show_packets",
89
+ "-of",
90
+ "json",
91
+ path,
92
+ ]
93
+
94
+
95
+ def run_probe(
96
+ path: str | Path,
97
+ *,
98
+ ffprobe_bin: str | None = None,
99
+ timeout_s: float = 60.0,
100
+ ) -> ClipFacts:
101
+ """Run `ffprobe` on a finished file and return the facts.
102
+
103
+ Separate from [`parse_probe`][cctv.ffprobe.parse_probe] so the parsing — where the
104
+ interesting logic is — stays testable without a subprocess, and so every way the
105
+ subprocess can fail is handled in one place:
106
+
107
+ * the binary is missing (`FileNotFoundError`) — which is the normal case in a container
108
+ unless the image installs it, hence `FFPROBE_BIN`
109
+ * it hangs
110
+ * it exits non-zero on a file that is not really a video
111
+
112
+ Raises:
113
+ ProbeError: for all of the above. A clip we cannot measure is a clip we cannot
114
+ trust, so this is never swallowed.
115
+ """
116
+ binary = ffprobe_bin or os.environ.get(FFPROBE_ENV) or "ffprobe"
117
+ command = build_probe_command(str(path))
118
+ command[0] = binary
119
+
120
+ try:
121
+ result = subprocess.run(command, capture_output=True, timeout=timeout_s, check=False)
122
+ except FileNotFoundError as exc:
123
+ raise ProbeError(
124
+ f"{binary!r} is not installed or not on PATH. Set {FFPROBE_ENV} to its full "
125
+ f"path: a container image rarely puts ffmpeg on PATH, and Frigate itself "
126
+ f"vendors its builds under /usr/lib/ffmpeg/<version>/bin/."
127
+ ) from exc
128
+ except subprocess.TimeoutExpired as exc:
129
+ raise ProbeError(f"{binary} did not finish within {timeout_s}s for {path}.") from exc
130
+
131
+ if result.returncode != 0:
132
+ stderr = result.stderr.decode("utf-8", errors="replace").strip()[:400]
133
+ raise ProbeError(f"{binary} exited {result.returncode} for {path}: {stderr}")
134
+
135
+ try:
136
+ payload = json.loads(result.stdout.decode("utf-8"))
137
+ except json.JSONDecodeError as exc:
138
+ raise ProbeError(f"{binary} produced output that is not JSON for {path}: {exc}") from exc
139
+
140
+ return parse_probe(payload)
141
+
142
+
143
+ def parse_probe(payload: Mapping[str, Any]) -> ClipFacts:
144
+ """Turn `ffprobe -of json` output into facts.
145
+
146
+ Deliberately tolerant of missing fields. `ffprobe` omits keys rather than emitting
147
+ nulls, and a clip with no audio stream has no audio fields at all — which must be
148
+ distinguishable from "we failed to read the file", so a missing duration stays `None`
149
+ rather than becoming `0.0`.
150
+ """
151
+ fmt = payload.get("format") or {}
152
+ streams = payload.get("streams") or []
153
+ packets = payload.get("packets") or []
154
+
155
+ video = _first_stream(streams, "video")
156
+ audio = _first_stream(streams, "audio")
157
+
158
+ return ClipFacts(
159
+ duration_s=_as_float(fmt.get("duration")),
160
+ video_codec=video.get("codec_name") if video else None,
161
+ audio_codec=audio.get("codec_name") if audio else None,
162
+ has_audio=audio is not None,
163
+ format_name=fmt.get("format_name"),
164
+ packet_count=len(packets),
165
+ max_packet_gap_s=max_packet_gap(_packet_times(packets)),
166
+ keyframe_interval_s=_keyframe_interval(streams),
167
+ )
168
+
169
+
170
+ def coverage_ratio(actual_s: float | None, requested_s: float) -> float | None:
171
+ """`actual / requested`. Above 1.0 is normal, not an error — see `cctv.clip`."""
172
+ if actual_s is None or requested_s <= 0:
173
+ return None
174
+ return actual_s / requested_s
175
+
176
+
177
+ def looks_truncated(
178
+ facts: ClipFacts, requested_s: float, *, ok: float = DEFAULT_COVERAGE_OK
179
+ ) -> bool:
180
+ """Whether the clip is short enough to be worth another attempt.
181
+
182
+ Coverage-based by construction. **Do not add a gap check here** — the module docstring
183
+ explains why the gap detector flags healthy clips on this stack.
184
+ """
185
+ ratio = facts.coverage_against(requested_s)
186
+ if ratio is None:
187
+ return True # unmeasurable is not the same as fine
188
+ return ratio < ok
189
+
190
+
191
+ def max_packet_gap(times: Sequence[float]) -> float | None:
192
+ """Largest gap between consecutive packet timestamps, in seconds.
193
+
194
+ Advisory. Returns `None` for fewer than two packets, because a single packet has no
195
+ gaps and reporting `0.0` would look like a passing check.
196
+ """
197
+ if len(times) < 2:
198
+ return None
199
+ # `pairwise`, not `zip(times, times[1:])` — the latter needs `strict=False`, and
200
+ # `strict=True` on it raises because the second sequence is one element shorter. That
201
+ # off-by-one has been written twice here; `pairwise` cannot express it.
202
+ return max(b - a for a, b in pairwise(sorted(times)))
203
+
204
+
205
+ def _first_stream(streams: Sequence[Mapping[str, Any]], kind: str) -> Mapping[str, Any] | None:
206
+ for stream in streams:
207
+ if stream.get("codec_type") == kind:
208
+ return stream
209
+ return None
210
+
211
+
212
+ def _packet_times(packets: Sequence[Mapping[str, Any]]) -> list[float]:
213
+ times: list[float] = []
214
+ for packet in packets:
215
+ value = _as_float(packet.get("pts_time"))
216
+ if value is None:
217
+ value = _as_float(packet.get("dts_time"))
218
+ if value is not None:
219
+ times.append(value)
220
+ return times
221
+
222
+
223
+ def _as_float(value: object) -> float | None:
224
+ if value is None:
225
+ return None
226
+ try:
227
+ return float(value) # type: ignore[arg-type]
228
+ except (TypeError, ValueError):
229
+ # ffprobe emits "N/A" for an unreadable value.
230
+ return None
231
+
232
+
233
+ def _keyframe_interval(streams: Sequence[Mapping[str, Any]]) -> float | None:
234
+ """Mean spacing between video frames, used only to sanity-check a gap reading."""
235
+ video = _first_stream(streams, "video")
236
+ if not video:
237
+ return None
238
+ rate = video.get("avg_frame_rate") or video.get("r_frame_rate")
239
+ if not isinstance(rate, str) or "/" not in rate:
240
+ return None
241
+ numerator, _, denominator = rate.partition("/")
242
+ try:
243
+ frames = float(numerator) / float(denominator)
244
+ except (ValueError, ZeroDivisionError):
245
+ return None
246
+ return None if frames <= 0 else 1.0 / frames