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