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/frigate.py ADDED
@@ -0,0 +1,411 @@
1
+ """HTTP client for Frigate.
2
+
3
+ Small on purpose. We call exactly four things, and the interesting part is not the
4
+ calling — it is the **classification**: which failures are worth retrying and which mean
5
+ something is actually wrong.
6
+
7
+ That distinction is where clips get lost. A `400 "No recordings found"` looks like a
8
+ malformed request and is in fact the *opposite*: it means the footage has not been flushed
9
+ to disk yet. Treating it as permanent discards a clip that was seconds away from being
10
+ retrievable. So the classification lives here, in one place, with the contract's matrix
11
+ next to it, rather than being re-derived at each call site.
12
+
13
+ ```text
14
+ 401 / 403 -> FrigateAuthError no retry, UNHEALTHY
15
+ 400 "No recordings found" -> NoRecordingsYet retry, with a delay
16
+ 400 (anything else) -> FrigateRequestError no retry, our bug
17
+ 404 (range endpoint) -> ClipUnavailable caller falls back once
18
+ 404 (review / event) -> ReviewNotFound no retry
19
+ 5xx, connection error, timeout -> FrigateUnavailable retry with backoff
20
+ ```
21
+
22
+ The client makes **one attempt per call and never sleeps**. Retrying is the caller's job,
23
+ because only the caller knows the attempt budget and whether a shorter timeout is
24
+ affordable — and a client that retries internally makes the caller's backoff meaningless.
25
+
26
+ Auth is a non-issue by construction: we talk to Frigate's internal port, which resolves
27
+ every request as `admin` (contract §2). The port is never published.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import contextlib
33
+ import json
34
+ import urllib.error
35
+ import urllib.parse
36
+ import urllib.request
37
+ from collections.abc import Iterator, Mapping
38
+ from dataclasses import dataclass
39
+ from typing import Any, BinaryIO, Protocol
40
+
41
+ from .timeutil import Instant
42
+
43
+ __all__ = [
44
+ "NO_RECORDINGS_MARKER",
45
+ "ClipUnavailable",
46
+ "FrigateAuthError",
47
+ "FrigateClient",
48
+ "FrigateError",
49
+ "FrigateRequestError",
50
+ "FrigateUnavailable",
51
+ "NoRecordingsYet",
52
+ "ReviewNotFound",
53
+ "Sink",
54
+ ]
55
+
56
+ #: Frigate's wording for the flush race. Matched case-insensitively, because the
57
+ #: classification must not depend on the exact capitalisation of a string we do not own.
58
+ NO_RECORDINGS_MARKER = "no recordings found"
59
+
60
+ #: Bytes per read when streaming a clip. Large enough that a long clip is not dominated by
61
+ #: syscalls, small enough that memory stays flat regardless of clip length.
62
+ CHUNK_SIZE = 256 * 1024
63
+
64
+
65
+ class Sink(Protocol):
66
+ """Somewhere to write downloaded bytes. A file, or anything with `write`."""
67
+
68
+ def write(self, data: bytes) -> int: ...
69
+
70
+
71
+ class FrigateError(Exception):
72
+ """Base for every Frigate interaction failure.
73
+
74
+ `retryable` is a class attribute rather than something the caller infers from the
75
+ exception type, so adding a new error cannot silently default to "give up".
76
+ """
77
+
78
+ retryable: bool = False
79
+
80
+
81
+ class FrigateUnavailable(FrigateError):
82
+ """Frigate or the network is unhealthy. Worth retrying: nothing is wrong with us."""
83
+
84
+ retryable = True
85
+
86
+
87
+ class NoRecordingsYet(FrigateError):
88
+ """`400 "No recordings found"` — the footage is not on disk yet, or has expired.
89
+
90
+ **Retryable, and this is the single most important classification in the file.** It is
91
+ the flush race, measured: at 5 s after a review ended, 5 of 6 fetches raced the segment
92
+ flush and returned a short clip. The correct response is to wait and ask again.
93
+ """
94
+
95
+ retryable = True
96
+
97
+
98
+ class FrigateRequestError(FrigateError):
99
+ """A `400` that is not the flush race. Our request is malformed — a bug, not weather."""
100
+
101
+ retryable = False
102
+
103
+
104
+ class FrigateAuthError(FrigateError):
105
+ """`401` / `403`. Configuration or a revoked credential. Stop and alert."""
106
+
107
+ retryable = False
108
+
109
+
110
+ class ClipUnavailable(FrigateError):
111
+ """`404` on the range endpoint. The caller may fall back to the event endpoint once."""
112
+
113
+ retryable = False
114
+
115
+
116
+ class ReviewNotFound(FrigateError):
117
+ """`404` for a review or event we asked about. It was pruned before we got there."""
118
+
119
+ retryable = False
120
+
121
+
122
+ def _segment(value: str) -> str:
123
+ """Escape one path segment.
124
+
125
+ `safe=""` is the whole point. `urllib.parse.quote` leaves `/` unescaped by default,
126
+ because `/` is in its default safe set — so a camera name containing a slash would
127
+ silently inject an extra path segment rather than being encoded. Config validation
128
+ already forbids that name, but building the URL safely should not depend on a
129
+ validation rule enforced somewhere else.
130
+ """
131
+ return urllib.parse.quote(value, safe="")
132
+
133
+
134
+ def _timestamp(value: Instant) -> str:
135
+ """Format an instant for a URL path.
136
+
137
+ Six decimal places, not an integer: rounding a range start *up* would cut the pre-roll
138
+ short, and the whole point of ADR-0015 is that boundaries err outward.
139
+ """
140
+ return f"{value:.6f}"
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class FrigateClient:
145
+ """A client for the four endpoints the pipeline uses.
146
+
147
+ `base_url` defaults to the internal port. It is deliberately not configurable to the
148
+ authenticated port: see contract §2, and note that publishing that port is the exact
149
+ thing the contract forbids.
150
+ """
151
+
152
+ base_url: str = "http://frigate:5000"
153
+ timeout_s: float = 30.0
154
+ user_agent: str = "cctv-stack/0.1"
155
+
156
+ # -----------------------------------------------------------------------------------
157
+ # Transport
158
+ # -----------------------------------------------------------------------------------
159
+
160
+ def _request(
161
+ self,
162
+ method: str,
163
+ path: str,
164
+ *,
165
+ timeout_s: float | None = None,
166
+ accepts_json: bool = True,
167
+ not_found: type[FrigateError] = ReviewNotFound,
168
+ ) -> urllib.response.addinfourl: # type: ignore[name-defined]
169
+ """One attempt. Never retries, never sleeps — that is the caller's job.
170
+
171
+ `not_found` exists because a 404 does not mean one thing. On a clip range it means
172
+ the footage is not available and the caller may fall back; on an event it means the
173
+ event was pruned. Collapsing the two would make the fallback path unreachable.
174
+ """
175
+ url = self.base_url.rstrip("/") + path
176
+ request = urllib.request.Request(
177
+ url,
178
+ method=method,
179
+ headers={"User-Agent": self.user_agent, "Accept": "application/json"},
180
+ )
181
+ try:
182
+ response = urllib.request.urlopen(
183
+ request, timeout=self.timeout_s if timeout_s is None else timeout_s
184
+ )
185
+ except urllib.error.HTTPError as exc:
186
+ raise self._classify(exc, path, not_found) from exc
187
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
188
+ # A connection refused, a DNS failure, a socket timeout — all "Frigate is not
189
+ # answering", none of which we can fix by changing the request.
190
+ raise FrigateUnavailable(f"{method} {path} failed: {exc}") from exc
191
+
192
+ if accepts_json:
193
+ content_type = response.headers.get("Content-Type", "")
194
+ if "json" not in content_type:
195
+ response.close()
196
+ raise FrigateRequestError(
197
+ f"{method} {path} returned {content_type!r}, expected JSON. Reaching the "
198
+ f"wrong service, or a proxy is answering instead of Frigate."
199
+ )
200
+ return response
201
+
202
+ def _classify(
203
+ self, exc: urllib.error.HTTPError, path: str, not_found: type[FrigateError]
204
+ ) -> FrigateError:
205
+ """Turn an HTTP status into a decision. Contract §6, verbatim."""
206
+ body = ""
207
+ # A body we cannot read must not mask the status code, because the status code is
208
+ # the part that decides whether we retry.
209
+ with contextlib.suppress(Exception):
210
+ body = exc.read().decode("utf-8", errors="replace")
211
+
212
+ if exc.code in (401, 403):
213
+ return FrigateAuthError(
214
+ f"{path} returned {exc.code}. Frigate rejected us — a configuration or "
215
+ f"credential problem, not a transient one. Stop and alert."
216
+ )
217
+ if exc.code == 404:
218
+ return not_found(f"{path} returned 404.")
219
+ if exc.code == 400:
220
+ if NO_RECORDINGS_MARKER in body.lower():
221
+ return NoRecordingsYet(
222
+ f"{path} returned 400 'No recordings found'. The footage is not on disk "
223
+ f"yet. This is the flush race, not a bad request — wait and ask again."
224
+ )
225
+ return FrigateRequestError(f"{path} returned 400: {body[:200]}")
226
+ if exc.code >= 500:
227
+ return FrigateUnavailable(f"{path} returned {exc.code}: {body[:200]}")
228
+ return FrigateRequestError(f"{path} returned {exc.code}: {body[:200]}")
229
+
230
+ # -----------------------------------------------------------------------------------
231
+ # Reading
232
+ # -----------------------------------------------------------------------------------
233
+
234
+ def list_reviews(
235
+ self,
236
+ *,
237
+ after: Instant | None = None,
238
+ before: Instant | None = None,
239
+ cameras: str = "all",
240
+ limit: int = 100,
241
+ ) -> list[dict[str, Any]]:
242
+ """Reviews Frigate has stored. This is what makes the sweep possible.
243
+
244
+ The sweep exists because MQTT delivery is at-most-once from our side, and because
245
+ a Frigate restart closes an in-progress review **without publishing `end` at all**
246
+ — measured, not assumed. A review that MQTT never announced still appears here.
247
+ """
248
+ query: dict[str, str] = {"limit": str(limit)}
249
+ if cameras != "all":
250
+ query["cameras"] = cameras
251
+ if after is not None:
252
+ query["after"] = _timestamp(after)
253
+ if before is not None:
254
+ query["before"] = _timestamp(before)
255
+
256
+ path = "/api/review?" + urllib.parse.urlencode(query)
257
+ with self._request("GET", path) as response:
258
+ payload = json.loads(response.read().decode("utf-8"))
259
+ if not isinstance(payload, list):
260
+ raise FrigateRequestError(
261
+ f"{path} did not return a list. Got {type(payload).__name__}."
262
+ )
263
+ return payload
264
+
265
+ def open_range(
266
+ self,
267
+ camera: str,
268
+ start: Instant,
269
+ end: Instant,
270
+ *,
271
+ timeout_s: float | None = None,
272
+ ):
273
+ """Open the range endpoint as a readable stream.
274
+
275
+ The caller is expected to hand this to `shutil.copyfileobj`-style code writing into
276
+ an [`atomic_writer`][cctv.atomic.atomic_writer], so a failed fetch leaves no
277
+ half-written clip behind.
278
+ """
279
+ path = f"/api/{_segment(camera)}/start/{_timestamp(start)}/end/{_timestamp(end)}/clip.mp4"
280
+ return self._request(
281
+ "GET",
282
+ path,
283
+ timeout_s=timeout_s,
284
+ accepts_json=False,
285
+ not_found=ClipUnavailable,
286
+ )
287
+
288
+ def download_range(
289
+ self,
290
+ camera: str,
291
+ start: Instant,
292
+ end: Instant,
293
+ sink: Sink,
294
+ *,
295
+ timeout_s: float | None = None,
296
+ ) -> int:
297
+ """Stream a clip range into `sink`. Returns the number of bytes written.
298
+
299
+ Streamed rather than buffered: a long review can be hundreds of megabytes, and
300
+ holding it whole would make memory a function of clip length.
301
+ """
302
+ written = 0
303
+ with self.open_range(camera, start, end, timeout_s=timeout_s) as response:
304
+ while True:
305
+ chunk = response.read(CHUNK_SIZE)
306
+ if not chunk:
307
+ break
308
+ sink.write(chunk)
309
+ written += len(chunk)
310
+
311
+ if written == 0:
312
+ # A 200 with an empty body is not a clip. Treated as unavailable rather than as
313
+ # a zero-length clip, because a zero-length clip is indistinguishable from a
314
+ # corrupt one downstream.
315
+ raise ClipUnavailable(
316
+ f"{camera} {start}..{end}: the range endpoint returned 200 with no body."
317
+ )
318
+ return written
319
+
320
+ def iter_chunks(
321
+ self,
322
+ camera: str,
323
+ start: Instant,
324
+ end: Instant,
325
+ *,
326
+ timeout_s: float | None = None,
327
+ ) -> Iterator[bytes]:
328
+ """Chunk-wise form, for callers that would rather not pass a sink."""
329
+ with self.open_range(camera, start, end, timeout_s=timeout_s) as response:
330
+ while True:
331
+ chunk = response.read(CHUNK_SIZE)
332
+ if not chunk:
333
+ return
334
+ yield chunk
335
+
336
+ # -----------------------------------------------------------------------------------
337
+ # Pinning — INV-9
338
+ # -----------------------------------------------------------------------------------
339
+
340
+ def retain_event(self, event_id: str) -> None:
341
+ """Pin an event so Frigate's retention cannot expire its footage before we copy it.
342
+
343
+ **Failure here is fatal for that event** (contract §4.1). Downloading without a
344
+ successful pin risks losing the footage entirely, which is worse than delaying the
345
+ clip — so the caller must not proceed. Treated as idempotent: a repeated pin of an
346
+ already-pinned event is not an error.
347
+ """
348
+ if not event_id:
349
+ raise ValueError("retain_event needs an event id.")
350
+ path = f"/api/events/{_segment(event_id)}/retain"
351
+ with self._request("POST", path) as response:
352
+ self._require_success(response, path)
353
+
354
+ def unretain_event(self, event_id: str) -> None:
355
+ """Release a pin after our copy is durable.
356
+
357
+ **Failure here is not fatal** (contract §4.1). A stuck pin wastes Frigate's disk; it
358
+ does not lose data, and the pin reaper will collect it later.
359
+ """
360
+ if not event_id:
361
+ raise ValueError("unretain_event needs an event id.")
362
+ path = f"/api/events/{_segment(event_id)}/retain"
363
+ with self._request("DELETE", path) as response:
364
+ self._require_success(response, path)
365
+
366
+ def _require_success(self, response: Any, path: str) -> None:
367
+ payload = json.loads(response.read().decode("utf-8"))
368
+ if isinstance(payload, Mapping) and payload.get("success") is False:
369
+ # Frigate answers 200 with `"success": false` for some failures, so the status
370
+ # code alone is not enough. That is the trap this check exists for.
371
+ raise FrigateRequestError(
372
+ f"{path} returned 200 but success=false: {payload.get('message')!r}"
373
+ )
374
+
375
+ # -----------------------------------------------------------------------------------
376
+ # Diagnostics
377
+ # -----------------------------------------------------------------------------------
378
+
379
+ def stats(self) -> dict[str, Any]:
380
+ """`/api/stats`. Used by the health check and, notably, by anything that needs the
381
+ Frigate version — `/api/version` answers with an empty body, which is why a
382
+ readiness probe against it never passes."""
383
+ with self._request("GET", "/api/stats") as response:
384
+ return json.loads(response.read().decode("utf-8"))
385
+
386
+ def download_event_clip(
387
+ self, event_id: str, sink: Sink, *, timeout_s: float | None = None
388
+ ) -> int:
389
+ """The event-scoped fallback, used once when the range endpoint 404s.
390
+
391
+ Kept because the contract says to fall back once, and because it needs no time
392
+ range: it is the wrong endpoint for us in general (symmetric padding, no pre-roll
393
+ control) but a better answer than no clip at all.
394
+ """
395
+ path = f"/api/events/{_segment(event_id)}/clip.mp4"
396
+ written = 0
397
+ with self._request("GET", path, timeout_s=timeout_s, accepts_json=False) as response:
398
+ while True:
399
+ chunk = response.read(CHUNK_SIZE)
400
+ if not chunk:
401
+ break
402
+ sink.write(chunk)
403
+ written += len(chunk)
404
+ if written == 0:
405
+ raise ClipUnavailable(f"event {event_id}: returned 200 with no body.")
406
+ return written
407
+
408
+
409
+ def write_to(sink: BinaryIO) -> Sink:
410
+ """Identity helper, so `download_range(..., write_to(handle))` reads clearly."""
411
+ return sink