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/worker.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""The clip-worker service — a review becomes a file at the canonical path.
|
|
2
|
+
|
|
3
|
+
This is the v0.1 happy path and deliberately nothing else. No upload, no retention, no
|
|
4
|
+
health surface. That narrowing is safe rather than merely convenient: with nothing deleting
|
|
5
|
+
anything, [INV-1](../../../docs/architecture/invariants.md) holds trivially and the two
|
|
6
|
+
riskiest failure modes in the design — deleting a clip that was never backed up, and a disk
|
|
7
|
+
that fills with no signal — are simply not present yet.
|
|
8
|
+
|
|
9
|
+
## Two inputs, one worker
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
frigate/reviews (MQTT) ──→ ReviewDispatcher ──┐
|
|
13
|
+
├──→ decide() ──→ ClipMaterialiser
|
|
14
|
+
GET /api/review (sweep) ────────────────────────┘
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Neither alone is sufficient. MQTT alone loses clips whenever the broker or this process is
|
|
18
|
+
down, and Frigate closes a review on restart **without publishing `end` at all** — measured,
|
|
19
|
+
with the subscriber attached to a broker that never restarted (integration contract §3.5).
|
|
20
|
+
The sweep alone would work, but would delay every clip by its interval. So MQTT is the
|
|
21
|
+
latency optimisation and **the sweep is the guarantee**.
|
|
22
|
+
|
|
23
|
+
## Why both run on one thread
|
|
24
|
+
|
|
25
|
+
They share a `sqlite3` connection and a materialiser, and a connection is not something to
|
|
26
|
+
hand between threads and hope. Rather than sprinkle locks or open a second write connection,
|
|
27
|
+
the MQTT callback does one thing — `queue.Queue.put_nowait`, which *is* thread-safe — and
|
|
28
|
+
**this thread owns every database write**.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import signal
|
|
36
|
+
import socket
|
|
37
|
+
import sqlite3
|
|
38
|
+
import sys
|
|
39
|
+
import threading
|
|
40
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from . import db, timeutil
|
|
45
|
+
from .config import CamerasConfig, ConfigError, load_cameras_config
|
|
46
|
+
from .dispatch import ReviewDispatcher
|
|
47
|
+
from .frigate import FrigateClient
|
|
48
|
+
from .logging import configure_logging
|
|
49
|
+
from .materialise import ClipMaterialiser, FrigateSource
|
|
50
|
+
from .mqtt import MqttUnavailable, ReviewSubscriber
|
|
51
|
+
from .reviews import ReviewCandidate, decide
|
|
52
|
+
from .sweep import ReviewSweep, SweepResult
|
|
53
|
+
|
|
54
|
+
__all__ = ["EXIT_CONFIG", "EXIT_UNAVAILABLE", "ClipWorker", "Settings", "main"]
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
#: `EX_CONFIG` / `EX_TEMPFAIL` from `sysexits.h`, per LLD 00 §3. The distinction is what
|
|
59
|
+
#: lets a supervisor decide whether restarting could help: a bad config file never becomes
|
|
60
|
+
#: good, a database that is briefly locked might.
|
|
61
|
+
EXIT_CONFIG = 78
|
|
62
|
+
EXIT_UNAVAILABLE = 75
|
|
63
|
+
|
|
64
|
+
DEFAULT_SWEEP_INTERVAL_S = 60.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Settings:
|
|
69
|
+
"""Every input is an environment variable, so there are no CLI arguments to get wrong."""
|
|
70
|
+
|
|
71
|
+
cameras_path: Path
|
|
72
|
+
data_dir: Path
|
|
73
|
+
db_path: Path
|
|
74
|
+
frigate_url: str
|
|
75
|
+
#: `None` means sweep-only. Expressed as "no host configured" rather than
|
|
76
|
+
#: "mqtt_enabled: false" because an empty value and a false value would otherwise be
|
|
77
|
+
#: three states to reason about instead of two.
|
|
78
|
+
mqtt_host: str | None
|
|
79
|
+
mqtt_port: int
|
|
80
|
+
mqtt_topic_prefix: str
|
|
81
|
+
mqtt_username: str | None
|
|
82
|
+
mqtt_password: str | None
|
|
83
|
+
sweep_interval_s: float
|
|
84
|
+
log_level: str | None
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_environ(cls, environ: Mapping[str, str] | None = None) -> Settings:
|
|
88
|
+
env = os.environ if environ is None else environ
|
|
89
|
+
|
|
90
|
+
config_dir = Path(env.get("CCTV_CONFIG_DIR", "/config"))
|
|
91
|
+
data_dir = Path(env.get("CCTV_DATA_DIR", "/data"))
|
|
92
|
+
# An empty string is treated as absent rather than as a host named "" — which is
|
|
93
|
+
# the shape `CCTV_MQTT_HOST=` takes in a `.env` file when someone comments it out
|
|
94
|
+
# with a value still on the line.
|
|
95
|
+
mqtt_host = (env.get("CCTV_MQTT_HOST") or "").strip() or None
|
|
96
|
+
|
|
97
|
+
return cls(
|
|
98
|
+
cameras_path=Path(env.get("CCTV_CAMERAS", str(config_dir / "cameras.yml"))),
|
|
99
|
+
data_dir=data_dir,
|
|
100
|
+
db_path=Path(env.get("CCTV_DB", str(data_dir / "cctv.db"))),
|
|
101
|
+
frigate_url=env.get("CCTV_FRIGATE_URL", "http://frigate:5000"),
|
|
102
|
+
mqtt_host=mqtt_host,
|
|
103
|
+
mqtt_port=int(env.get("CCTV_MQTT_PORT", "1883")),
|
|
104
|
+
mqtt_topic_prefix=env.get("CCTV_MQTT_TOPIC_PREFIX", "frigate"),
|
|
105
|
+
mqtt_username=(env.get("CCTV_MQTT_USERNAME") or "").strip() or None,
|
|
106
|
+
mqtt_password=env.get("CCTV_MQTT_PASSWORD") or None,
|
|
107
|
+
sweep_interval_s=float(env.get("CCTV_SWEEP_INTERVAL_S", str(DEFAULT_SWEEP_INTERVAL_S))),
|
|
108
|
+
log_level=env.get("CCTV_LOG_LEVEL") or None,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ClipWorker:
|
|
113
|
+
"""One thread that turns reviews into clips, from two sources."""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
config: CamerasConfig,
|
|
119
|
+
conn: sqlite3.Connection,
|
|
120
|
+
sweep: ReviewSweep,
|
|
121
|
+
materialiser: ClipMaterialiser,
|
|
122
|
+
sweep_interval_s: float = DEFAULT_SWEEP_INTERVAL_S,
|
|
123
|
+
clock: Callable[[], float] = timeutil.now,
|
|
124
|
+
max_pending: int = 1000,
|
|
125
|
+
) -> None:
|
|
126
|
+
if sweep_interval_s <= 0:
|
|
127
|
+
raise ValueError("sweep_interval_s must be positive.")
|
|
128
|
+
|
|
129
|
+
self.config = config
|
|
130
|
+
self.conn = conn
|
|
131
|
+
self.sweep = sweep
|
|
132
|
+
self.materialiser = materialiser
|
|
133
|
+
self.sweep_interval_s = sweep_interval_s
|
|
134
|
+
self._clock = clock
|
|
135
|
+
self._last_sweep: float | None = None
|
|
136
|
+
|
|
137
|
+
# The worker owns the dispatcher so the handler and the queue cannot be wired to
|
|
138
|
+
# different objects. The alternative — building the dispatcher in `main()` and the
|
|
139
|
+
# worker around it — needs the worker to exist first, which is circular.
|
|
140
|
+
self.dispatcher = ReviewDispatcher(handler=self.handle, max_pending=max_pending)
|
|
141
|
+
|
|
142
|
+
# -----------------------------------------------------------------------------------
|
|
143
|
+
# The handler. Runs on this thread, never on paho's.
|
|
144
|
+
# -----------------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def handle(self, candidate: ReviewCandidate) -> None:
|
|
147
|
+
"""Decide, then materialise, one candidate."""
|
|
148
|
+
camera = self.config.cameras.get(candidate.camera)
|
|
149
|
+
if camera is None or not camera.enabled:
|
|
150
|
+
# An unknown camera is dropped with a warning and no retry loop (M2.7).
|
|
151
|
+
# Retrying would not help: it will not be in `cameras.yml` on the next attempt
|
|
152
|
+
# either, and a retry loop here is one that runs forever.
|
|
153
|
+
logger.warning(
|
|
154
|
+
"review for a camera that is not enabled",
|
|
155
|
+
extra={"camera": candidate.camera, "review_id": candidate.review_id},
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
verdict = decide(
|
|
160
|
+
candidate,
|
|
161
|
+
severities=camera.clip.severities,
|
|
162
|
+
objects=camera.clip.objects,
|
|
163
|
+
known_review_ids=db.known_review_ids(self.conn, [candidate.review_id]),
|
|
164
|
+
)
|
|
165
|
+
if not verdict.materialise:
|
|
166
|
+
logger.info(
|
|
167
|
+
"review skipped",
|
|
168
|
+
extra={
|
|
169
|
+
"camera": candidate.camera,
|
|
170
|
+
"review_id": candidate.review_id,
|
|
171
|
+
"reason": verdict.reason,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
outcome = self.materialiser.materialise(
|
|
177
|
+
candidate,
|
|
178
|
+
pre_roll_s=camera.clip.pre_roll_s,
|
|
179
|
+
post_roll_s=camera.clip.post_roll_s,
|
|
180
|
+
)
|
|
181
|
+
logger.info(
|
|
182
|
+
"review handled",
|
|
183
|
+
extra={
|
|
184
|
+
"camera": candidate.camera,
|
|
185
|
+
"review_id": candidate.review_id,
|
|
186
|
+
"status": outcome.status,
|
|
187
|
+
"reason": outcome.reason,
|
|
188
|
+
"path": outcome.path,
|
|
189
|
+
"coverage": outcome.coverage_ratio,
|
|
190
|
+
"attempts": outcome.fetch_attempts,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# -----------------------------------------------------------------------------------
|
|
195
|
+
# Scheduling
|
|
196
|
+
# -----------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def sweep_once(self) -> SweepResult | None:
|
|
199
|
+
"""One reconciliation pass. Never raises: a failed sweep must not stop the worker."""
|
|
200
|
+
try:
|
|
201
|
+
result = self.sweep.run(config=self.config)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
# A sweep that raises must not stop the worker: the sweep is the guarantee, and
|
|
204
|
+
# a worker that exits because the guarantee failed guarantees nothing.
|
|
205
|
+
logger.exception("sweep raised", extra={"reason": str(exc)})
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
if result.failure is not None:
|
|
209
|
+
logger.warning("sweep could not complete", extra={"reason": result.failure})
|
|
210
|
+
else:
|
|
211
|
+
logger.info(
|
|
212
|
+
"sweep: %d considered, %d materialised, %d skipped, %d deferred, %d failed",
|
|
213
|
+
result.considered,
|
|
214
|
+
result.materialised,
|
|
215
|
+
result.skipped,
|
|
216
|
+
result.deferred,
|
|
217
|
+
result.failed,
|
|
218
|
+
)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def tick(self, *, timeout: float = 1.0) -> None:
|
|
222
|
+
"""One unit of work: drain one queued candidate, and sweep when due.
|
|
223
|
+
|
|
224
|
+
`work_once` blocks for up to `timeout`, which is what stops this loop from spinning
|
|
225
|
+
when there is nothing to do — so the sweep is checked at most `timeout` late.
|
|
226
|
+
"""
|
|
227
|
+
self.dispatcher.work_once(timeout=timeout)
|
|
228
|
+
|
|
229
|
+
now = self._clock()
|
|
230
|
+
if self._last_sweep is None or (now - self._last_sweep) >= self.sweep_interval_s:
|
|
231
|
+
# Recorded *before* running, so a sweep slower than the interval does not queue
|
|
232
|
+
# up a second one the moment it returns.
|
|
233
|
+
self._last_sweep = now
|
|
234
|
+
self.sweep_once()
|
|
235
|
+
|
|
236
|
+
def run(self, stop: threading.Event, *, timeout: float = 1.0) -> None:
|
|
237
|
+
"""Block until `stop` is set.
|
|
238
|
+
|
|
239
|
+
A sweep runs immediately, before the loop. Anything that happened while this process
|
|
240
|
+
was down is already late, and MQTT takes a moment to establish — waiting a full
|
|
241
|
+
interval before recovering would mean every restart has a hole in it.
|
|
242
|
+
"""
|
|
243
|
+
self.sweep_once()
|
|
244
|
+
self._last_sweep = self._clock()
|
|
245
|
+
|
|
246
|
+
while not stop.is_set():
|
|
247
|
+
try:
|
|
248
|
+
self.tick(timeout=timeout)
|
|
249
|
+
except Exception:
|
|
250
|
+
# Deliberately broad and deliberately not fatal. This is the outermost loop
|
|
251
|
+
# of a service that is expected to run for years; the alternative is that an
|
|
252
|
+
# unhandled edge case in one review takes down every camera until someone
|
|
253
|
+
# notices. The traceback is logged, so the bug is not hidden — only survived.
|
|
254
|
+
logger.exception("worker tick failed; continuing")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------------------
|
|
258
|
+
# Process
|
|
259
|
+
# ---------------------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _install_signal_handlers(stop: threading.Event) -> None:
|
|
263
|
+
"""Turn SIGTERM/SIGINT into a cooperative shutdown.
|
|
264
|
+
|
|
265
|
+
Cooperative because the alternative is a `KeyboardInterrupt` raised *inside* a fetch,
|
|
266
|
+
which leaves a `.tmp` file and a half-written clip. The reaper would eventually clean it
|
|
267
|
+
up; stopping between units of work means it never happens.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def request_stop(signum: int, _frame: object) -> None:
|
|
271
|
+
logger.info("shutdown requested", extra={"reason": signal.Signals(signum).name})
|
|
272
|
+
stop.set()
|
|
273
|
+
|
|
274
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
275
|
+
signal.signal(signum, request_stop)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_worker(settings: Settings, config: CamerasConfig, conn: sqlite3.Connection) -> ClipWorker:
|
|
279
|
+
"""Wire the client, source, materialiser and sweep into one worker.
|
|
280
|
+
|
|
281
|
+
Separate from `main()` so a test can build the whole graph without a process.
|
|
282
|
+
"""
|
|
283
|
+
client = FrigateClient(base_url=settings.frigate_url)
|
|
284
|
+
materialiser = ClipMaterialiser(
|
|
285
|
+
conn=conn,
|
|
286
|
+
source=FrigateSource(client),
|
|
287
|
+
data_dir=settings.data_dir,
|
|
288
|
+
location=config.site.location,
|
|
289
|
+
zone=config.site.timezone,
|
|
290
|
+
)
|
|
291
|
+
sweep = ReviewSweep(conn=conn, client=client, materialiser=materialiser)
|
|
292
|
+
return ClipWorker(
|
|
293
|
+
config=config,
|
|
294
|
+
conn=conn,
|
|
295
|
+
sweep=sweep,
|
|
296
|
+
materialiser=materialiser,
|
|
297
|
+
sweep_interval_s=settings.sweep_interval_s,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
302
|
+
"""LLD 00 §3's common service skeleton, minus the parts v0.1 does not have."""
|
|
303
|
+
del argv # every input is an environment variable; see Settings
|
|
304
|
+
|
|
305
|
+
settings = Settings.from_environ()
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
configure_logging(settings.log_level)
|
|
309
|
+
except ValueError as exc:
|
|
310
|
+
print(f"cctv-clip-worker: {exc}", file=sys.stderr)
|
|
311
|
+
return EXIT_CONFIG
|
|
312
|
+
|
|
313
|
+
logger.info("starting", extra={"host": socket.gethostname()})
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
config = load_cameras_config(settings.cameras_path)
|
|
317
|
+
except (ConfigError, OSError) as exc:
|
|
318
|
+
logger.error(
|
|
319
|
+
"configuration is unusable",
|
|
320
|
+
extra={"path": str(settings.cameras_path), "reason": str(exc)},
|
|
321
|
+
)
|
|
322
|
+
return EXIT_CONFIG
|
|
323
|
+
|
|
324
|
+
for warning in config.warnings:
|
|
325
|
+
logger.warning(warning)
|
|
326
|
+
|
|
327
|
+
if not config.enabled_cameras():
|
|
328
|
+
logger.error("no cameras are enabled, so nothing can ever be recorded")
|
|
329
|
+
return EXIT_CONFIG
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
conn = db.open_db(settings.db_path)
|
|
333
|
+
version = db.migrate(conn)
|
|
334
|
+
except (db.DbError, sqlite3.Error) as exc:
|
|
335
|
+
logger.error(
|
|
336
|
+
"state store is unusable",
|
|
337
|
+
extra={"path": str(settings.db_path), "reason": str(exc)},
|
|
338
|
+
)
|
|
339
|
+
return EXIT_UNAVAILABLE
|
|
340
|
+
|
|
341
|
+
logger.info("state store ready", extra={"state": f"schema-{version}"})
|
|
342
|
+
|
|
343
|
+
for name, camera in config.enabled_cameras().items():
|
|
344
|
+
db.ensure_camera(conn, name=name, display_name=camera.display_name, enabled=True)
|
|
345
|
+
|
|
346
|
+
worker = build_worker(settings, config, conn)
|
|
347
|
+
|
|
348
|
+
stop = threading.Event()
|
|
349
|
+
_install_signal_handlers(stop)
|
|
350
|
+
|
|
351
|
+
subscriber: ReviewSubscriber | None = None
|
|
352
|
+
if settings.mqtt_host is not None:
|
|
353
|
+
subscriber = ReviewSubscriber(
|
|
354
|
+
dispatcher=worker.dispatcher,
|
|
355
|
+
host=settings.mqtt_host,
|
|
356
|
+
port=settings.mqtt_port,
|
|
357
|
+
topic_prefix=settings.mqtt_topic_prefix,
|
|
358
|
+
username=settings.mqtt_username,
|
|
359
|
+
password=settings.mqtt_password,
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
subscriber.start()
|
|
363
|
+
except MqttUnavailable as exc:
|
|
364
|
+
logger.error("cannot subscribe to the broker", extra={"reason": str(exc)})
|
|
365
|
+
conn.close()
|
|
366
|
+
return EXIT_UNAVAILABLE
|
|
367
|
+
else:
|
|
368
|
+
logger.warning(
|
|
369
|
+
"CCTV_MQTT_HOST is unset: sweep-only, so every clip waits up to %s s to be noticed",
|
|
370
|
+
settings.sweep_interval_s,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
worker.run(stop)
|
|
375
|
+
finally:
|
|
376
|
+
if subscriber is not None:
|
|
377
|
+
subscriber.stop()
|
|
378
|
+
conn.close()
|
|
379
|
+
logger.info("stopped")
|
|
380
|
+
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__": # pragma: no cover
|
|
385
|
+
raise SystemExit(main())
|