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.
@@ -0,0 +1,35 @@
1
+ -- Migration 0002 — widen reconciliation_findings.kind.
2
+ --
3
+ -- `lld/05` §5.1 documents six finding kinds. Migration 0001's CHECK accepted four: it was
4
+ -- missing `missing_local_file`, which J1 records when a row points at a file that is already
5
+ -- gone, and `key_mismatch`, J3's check that `cloud_key` still equals the derived key. J1
6
+ -- cannot be implemented faithfully without the first, and the second is the same omission.
7
+ --
8
+ -- SQLite cannot alter a CHECK constraint, so the table is rebuilt. That is safe here because
9
+ -- the table holds only findings: nothing references it, and it has no foreign keys. The
10
+ -- alternative — loosening the constraint, or dropping it — would trade a schema guarantee for
11
+ -- convenience: the CHECK is what stops a typo creating a finding that no query will ever find
12
+ -- again.
13
+ --
14
+ -- Forward-only, per INV-8. There is no down migration; a mistake here is corrected by 0003.
15
+
16
+ CREATE TABLE reconciliation_findings_new (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ run_at REAL NOT NULL,
19
+ kind TEXT NOT NULL CHECK (kind IN (
20
+ 'orphan_object', 'dangling_row', 'checksum_mismatch',
21
+ 'unexpected_local_file', 'missing_local_file', 'key_mismatch'
22
+ )),
23
+ subject TEXT NOT NULL,
24
+ detail TEXT,
25
+ resolved_at REAL
26
+ );
27
+
28
+ INSERT INTO reconciliation_findings_new (id, run_at, kind, subject, detail, resolved_at)
29
+ SELECT id, run_at, kind, subject, detail, resolved_at FROM reconciliation_findings;
30
+
31
+ DROP TABLE reconciliation_findings;
32
+
33
+ ALTER TABLE reconciliation_findings_new RENAME TO reconciliation_findings;
34
+
35
+ CREATE INDEX reconciliation_open ON reconciliation_findings (resolved_at, run_at);
cctv/mqtt.py ADDED
@@ -0,0 +1,171 @@
1
+ """The paho-mqtt adapter — the only part of the pipeline that needs a broker.
2
+
3
+ Deliberately thin. *What* a review means lives in [`cctv.reviews`][cctv.reviews] and *when*
4
+ it gets worked on lives in [`cctv.dispatch`][cctv.dispatch], so this module only has to do
5
+ one thing: turn a socket message into a call to
6
+ [`ReviewDispatcher.dispatch`][cctv.dispatch.ReviewDispatcher.dispatch]. It is also the one
7
+ layer that cannot be unit-tested without a broker, which is a good reason to keep it small
8
+ enough to read in one sitting.
9
+
10
+ Two decisions worth naming:
11
+
12
+ **`paho` is imported inside [`start`][cctv.mqtt.ReviewSubscriber.start], not at module
13
+ import.** A deployment that runs sweep-only — no broker at all — must not need the
14
+ dependency installed. Importing at the top would make an optional component mandatory,
15
+ which is the kind of coupling that is invisible until someone builds a minimal image.
16
+
17
+ **`connect_async`, not `connect`.** `docker compose up` starts containers in whatever order
18
+ it likes, so the broker is frequently not listening yet. A synchronous connect would make
19
+ that a crash loop that resolves itself only because of `restart: unless-stopped`, and a
20
+ service that depends on being restarted to become healthy is a service whose first failure
21
+ looks like a real one.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from .dispatch import ReviewDispatcher
32
+
33
+ __all__ = ["MqttUnavailable", "ReviewSubscriber"]
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class MqttUnavailable(RuntimeError):
39
+ """`paho-mqtt` is not installed, so no subscription can be made."""
40
+
41
+
42
+ @dataclass
43
+ class ReviewSubscriber:
44
+ """Subscribes to `frigate/reviews` and hands every message to the dispatcher."""
45
+
46
+ dispatcher: ReviewDispatcher
47
+ host: str = "mosquitto"
48
+ port: int = 1883
49
+ topic_prefix: str = "frigate"
50
+ client_id: str = "cctv-clip-worker"
51
+ keepalive_s: int = 60
52
+ username: str | None = None
53
+ password: str | None = None
54
+ #: Counted rather than only logged. A message that is not JSON is a message nothing
55
+ #: downstream will ever see, and a discard that exists only as a log line is a discard
56
+ #: nobody can alert on.
57
+ malformed: int = field(default=0, init=False)
58
+ _client: Any = field(default=None, init=False, repr=False)
59
+
60
+ @property
61
+ def topic(self) -> str:
62
+ return f"{self.topic_prefix}/reviews"
63
+
64
+ # -----------------------------------------------------------------------------------
65
+
66
+ def start(self) -> None:
67
+ """Connect and begin consuming. Returns immediately; paho runs its own thread."""
68
+ try:
69
+ import paho.mqtt.client as mqtt
70
+ except ImportError as exc: # pragma: no cover - depends on install extras
71
+ raise MqttUnavailable(
72
+ "paho-mqtt is not installed, so no broker subscription is possible. "
73
+ "Install the `mqtt` extra, or run sweep-only by leaving CCTV_MQTT_HOST "
74
+ "unset."
75
+ ) from exc
76
+
77
+ client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.client_id)
78
+ client.on_connect = self._on_connect
79
+ client.on_message = self._on_message
80
+ client.on_disconnect = self._on_disconnect
81
+
82
+ if self.username is not None:
83
+ client.username_pw_set(self.username, self.password)
84
+
85
+ # Bounded backoff: a broker that is down should not be hammered, and one that comes
86
+ # back should be picked up within seconds rather than minutes.
87
+ client.reconnect_delay_set(min_delay=1, max_delay=30)
88
+
89
+ client.connect_async(self.host, self.port, keepalive=self.keepalive_s)
90
+ client.loop_start()
91
+ self._client = client
92
+
93
+ logger.info("mqtt subscribing", extra={"topic": self.topic, "host": self.host})
94
+
95
+ def stop(self) -> None:
96
+ client = self._client
97
+ self._client = None
98
+ if client is None:
99
+ return
100
+ try:
101
+ client.disconnect()
102
+ client.loop_stop()
103
+ except Exception:
104
+ # Shutdown must not raise: this runs in a `finally`, and an exception here
105
+ # would replace whatever real error was already propagating.
106
+ logger.debug("mqtt shutdown raised; ignoring", exc_info=True)
107
+
108
+ # -----------------------------------------------------------------------------------
109
+ # Callbacks. These run on paho's network thread: they must not block and must not raise.
110
+ # -----------------------------------------------------------------------------------
111
+
112
+ def _on_connect(
113
+ self,
114
+ client: Any,
115
+ userdata: Any,
116
+ flags: Any,
117
+ reason_code: Any,
118
+ properties: Any = None,
119
+ ) -> None:
120
+ # `is_failure` exists on paho 2.x reason codes; the `!= 0` fallback keeps this
121
+ # working if a plain int arrives instead.
122
+ failed = getattr(reason_code, "is_failure", reason_code != 0)
123
+ if failed:
124
+ logger.error("mqtt connection refused", extra={"reason": str(reason_code)})
125
+ return
126
+
127
+ # Subscribing here, not in `start()`, is what makes a *re*connection work. paho
128
+ # re-establishes the transport by itself, but subscriptions live on the session,
129
+ # which a reconnect does not restore unless the broker was told to keep it.
130
+ client.subscribe(self.topic, qos=0)
131
+ logger.info("mqtt connected", extra={"topic": self.topic, "host": self.host})
132
+
133
+ def _on_disconnect(
134
+ self,
135
+ client: Any,
136
+ userdata: Any,
137
+ flags: Any,
138
+ reason_code: Any = None,
139
+ properties: Any = None,
140
+ ) -> None:
141
+ # Not an error: paho reconnects on its own. Logged because a broker that flaps is
142
+ # a real problem, and the only evidence of it is this line repeating.
143
+ logger.warning("mqtt disconnected", extra={"reason": str(reason_code)})
144
+
145
+ def _on_message(self, client: Any, userdata: Any, message: Any) -> None:
146
+ try:
147
+ raw = message.payload.decode("utf-8", "replace")
148
+ except Exception:
149
+ # An exception escaping a paho callback is swallowed by the network loop, so
150
+ # this is not defensiveness for its own sake — it is the only place the failure
151
+ # can be observed at all.
152
+ self.malformed += 1
153
+ logger.warning("mqtt message could not be decoded", extra={"topic": self.topic})
154
+ return
155
+
156
+ try:
157
+ payload = json.loads(raw)
158
+ except ValueError:
159
+ self.malformed += 1
160
+ logger.warning("mqtt message was not JSON", extra={"topic": self.topic})
161
+ return
162
+
163
+ if not isinstance(payload, dict):
164
+ self.malformed += 1
165
+ logger.warning("mqtt message was not an object", extra={"topic": self.topic})
166
+ return
167
+
168
+ # `dispatch` never raises and never blocks: it parses, enqueues, and returns. Doing
169
+ # the work here would stall the network loop for the tens of seconds a fetch takes,
170
+ # and the messages lost during that window are lost silently.
171
+ self.dispatcher.dispatch(payload)
cctv/paths.py ADDED
@@ -0,0 +1,290 @@
1
+ """The single clip-path function.
2
+
3
+ Every component that needs a clip's path calls this module. Nothing re-implements it —
4
+ because the moment cleanup and clip-worker disagree about a path, cleanup stops being
5
+ able to find clips, and it fails *silently*, by not deleting. That is the failure
6
+ [INV-5](../../../docs/architecture/invariants.md) exists to prevent, which is why
7
+ [ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md) puts this in one place.
8
+
9
+ ```text
10
+ local: data/clips/<camera>/YYYY/MM/DD/HH-MM-SS.mp4
11
+ cloud: <location>/clips/<camera>/YYYY/MM/DD/HH-MM-SS.mp4
12
+ sidecar: <camera>/YYYY/MM/DD/HH-MM-SS.mp4.json
13
+ ```
14
+
15
+ ## What "pure function" means here, and what it does not
16
+
17
+ The path is derived from `camera`, the **review** start instant, and a timezone — never
18
+ from wall-clock-at-write-time and never from a counter. So materialisation is idempotent:
19
+ run it twice, get the same path.
20
+
21
+ Two things are worth being precise about, because both are easy to get wrong:
22
+
23
+ **The path is forward-derivable but not invertible.** Given the components you cannot
24
+ recover the instant, for two independent reasons: the timezone is not in the path, and
25
+ during a DST fallback two distinct instants render to the *same* components. This is
26
+ exactly why the database stores the instant rather than re-deriving it from filenames.
27
+
28
+ **A path can be taken, and that is not knowable from the arguments.** Two reviews on one
29
+ camera can start in the same second. So there is no single "the" path function: there is a
30
+ *primary* candidate and a *colliding* candidate, both pure, and the writer tries them in
31
+ order ([ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md)). The colliding form
32
+ appends a deterministic tail of the review id — deterministic being the whole point, so
33
+ the collision is still a pure function of the metadata.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import datetime as _dt
39
+ import hashlib
40
+ import re
41
+ from dataclasses import dataclass
42
+ from pathlib import PurePosixPath
43
+ from zoneinfo import ZoneInfo
44
+
45
+ from .timeutil import Instant, render
46
+
47
+ __all__ = [
48
+ "CLIP_EXT",
49
+ "CLOUD_CLIPS_PREFIX",
50
+ "COLLISION_SUFFIX_LEN",
51
+ "ParsedClipPath",
52
+ "PathError",
53
+ "candidate_relpaths",
54
+ "clip_cloud_key",
55
+ "colliding_relpath",
56
+ "collision_suffix",
57
+ "date_parts",
58
+ "is_canonical_relpath",
59
+ "parse_clip_relpath",
60
+ "primary_relpath",
61
+ "sidecar_relpath",
62
+ "validate_camera_name",
63
+ "validate_location",
64
+ ]
65
+
66
+ CLIP_EXT = ".mp4"
67
+ SIDECAR_EXT = ".mp4.json"
68
+ CLOUD_CLIPS_PREFIX = "clips"
69
+
70
+ #: Frigate appends six random base-36 characters to a review id, so the last six are
71
+ #: already a unique, filesystem-safe tail. We do not depend on that format — see
72
+ #: [`collision_suffix`][cctv.paths.collision_suffix].
73
+ COLLISION_SUFFIX_LEN = 6
74
+
75
+ # 1-32 characters, lowercase alphanumeric with interior hyphens. Deliberately *not*
76
+ # ADR-0006's regex, which requires at least two characters and would reject the
77
+ # perfectly reasonable camera name `a`; the ADR needs the same one-character fix.
78
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$")
79
+
80
+ _CLIP_NAME_RE = re.compile(
81
+ r"^(?P<h>[01][0-9]|2[0-3])-(?P<mi>[0-5][0-9])-(?P<s>[0-5][0-9])"
82
+ rf"(?:-(?P<suffix>[a-z0-9]{{{COLLISION_SUFFIX_LEN}}}))?{re.escape(CLIP_EXT)}$"
83
+ )
84
+
85
+
86
+ class PathError(ValueError):
87
+ """A path that cannot be derived or parsed. Raised at config load or on read."""
88
+
89
+
90
+ def validate_slug(value: str, *, what: str) -> str:
91
+ """Validate a name that appears in filesystem paths and cloud object keys.
92
+
93
+ Uppercase is rejected rather than lowercased: silently rewriting the operator's
94
+ camera name means the name in `cameras.yml`, the name in the path, and the name in
95
+ Frigate's config would be three different strings, and debugging that is worse than
96
+ a validation error at install time.
97
+ """
98
+ if not isinstance(value, str) or not value:
99
+ raise PathError(f"{what} is required.")
100
+ if not _SLUG_RE.match(value):
101
+ raise PathError(
102
+ f"{what} {value!r} is not valid. It must match {_SLUG_RE.pattern}: "
103
+ f"lowercase letters, digits and interior hyphens, 1-32 characters. "
104
+ f"{what}s appear in filesystem paths and cloud object keys, so uppercase "
105
+ f"causes case-collision bugs on macOS and a leading or trailing hyphen "
106
+ f"breaks round trips through some archive tools."
107
+ )
108
+ return value
109
+
110
+
111
+ def validate_camera_name(camera: str) -> str:
112
+ """Validate a camera name. Cameras are data, never code."""
113
+ return validate_slug(camera, what="Camera name")
114
+
115
+
116
+ def validate_location(location: str) -> str:
117
+ """Validate a site location name, which becomes the cloud key prefix."""
118
+ return validate_slug(location, what="Site location")
119
+
120
+
121
+ def date_parts(start: Instant, zone: ZoneInfo) -> tuple[str, str, str, str]:
122
+ """Render an instant into the four path components.
123
+
124
+ This is the only place in the codebase where a path component is produced from an
125
+ instant, which is what makes the timezone policy auditable rather than distributed.
126
+ """
127
+ local = render(start, zone)
128
+ return (
129
+ f"{local.year:04d}",
130
+ f"{local.month:02d}",
131
+ f"{local.day:02d}",
132
+ f"{local.hour:02d}-{local.minute:02d}-{local.second:02d}",
133
+ )
134
+
135
+
136
+ def collision_suffix(review_id: str) -> str:
137
+ """A deterministic tail for a colliding path.
138
+
139
+ Frigate's review ids end in six random base-36 characters, so taking the last six
140
+ alphanumerics gives exactly the documented suffix while staying independent of the
141
+ id's overall format. An id that is too short, or whose tail is not alphanumeric,
142
+ falls back to a hash rather than to a counter — a counter would be stateful, and a
143
+ stateful suffix is not derivable from metadata, which would break
144
+ [INV-5](../../../docs/architecture/invariants.md).
145
+ """
146
+ if not isinstance(review_id, str) or not review_id:
147
+ raise PathError("A collision suffix requires a non-empty review id.")
148
+
149
+ tail = "".join(ch for ch in review_id.lower() if ch.isascii() and ch.isalnum())
150
+ if len(tail) < COLLISION_SUFFIX_LEN:
151
+ digest = hashlib.sha256(review_id.encode("utf-8")).hexdigest()
152
+ return digest[:COLLISION_SUFFIX_LEN]
153
+ return tail[-COLLISION_SUFFIX_LEN:]
154
+
155
+
156
+ def primary_relpath(camera: str, start: Instant, zone: ZoneInfo) -> PurePosixPath:
157
+ """The path to try first: `HH-MM-SS.mp4`.
158
+
159
+ Always POSIX, never `Path`. A clip path is also a cloud object key, and object keys
160
+ use forward slashes regardless of the host OS — so building it with a Windows-aware
161
+ type would work on Linux and quietly produce backslashes on a contributor's laptop.
162
+ """
163
+ camera = validate_camera_name(camera)
164
+ year, month, day, hms = date_parts(start, zone)
165
+ return PurePosixPath(camera, year, month, day, f"{hms}{CLIP_EXT}")
166
+
167
+
168
+ def colliding_relpath(camera: str, start: Instant, zone: ZoneInfo, review_id: str) -> PurePosixPath:
169
+ """The path to use when the primary one is already taken: `HH-MM-SS-xxxxxx.mp4`."""
170
+ base = primary_relpath(camera, start, zone)
171
+ return base.with_name(f"{base.stem}-{collision_suffix(review_id)}{CLIP_EXT}")
172
+
173
+
174
+ def candidate_relpaths(
175
+ camera: str, start: Instant, zone: ZoneInfo, review_id: str
176
+ ) -> tuple[PurePosixPath, PurePosixPath]:
177
+ """Both candidates, in the order the writer must try them.
178
+
179
+ Exposed as one function so that the collision protocol lives in a single place. A
180
+ caller that builds the colliding form itself has re-implemented part of this
181
+ module, which is what [INV-5](../../../docs/architecture/invariants.md) forbids.
182
+ """
183
+ return (
184
+ primary_relpath(camera, start, zone),
185
+ colliding_relpath(camera, start, zone, review_id),
186
+ )
187
+
188
+
189
+ def sidecar_relpath(clip: PurePosixPath) -> PurePosixPath:
190
+ """`HH-MM-SS.mp4` → `HH-MM-SS.mp4.json`, beside the clip.
191
+
192
+ Suffixing rather than replacing the extension keeps the two files adjacent in a
193
+ directory listing and makes it impossible to mistake a sidecar for media.
194
+ """
195
+ return clip.with_name(clip.name + ".json")
196
+
197
+
198
+ def clip_cloud_key(location: str, relpath: PurePosixPath) -> str:
199
+ """Derive the object key from the local path.
200
+
201
+ Deliberately a concatenation rather than a parallel scheme: if the key were built
202
+ independently it could drift from the path, and then restore would need a mapping
203
+ layer that can itself drift. As it is, recovery is "strip the prefix, write the
204
+ file" ([ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md)).
205
+ """
206
+ location = validate_location(location)
207
+ return f"{location}/{CLOUD_CLIPS_PREFIX}/{_as_posix(relpath)}"
208
+
209
+
210
+ def _as_posix(value: PurePosixPath | str) -> str:
211
+ return value.as_posix() if isinstance(value, PurePosixPath) else PurePosixPath(value).as_posix()
212
+
213
+
214
+ @dataclass(frozen=True, slots=True)
215
+ class ParsedClipPath:
216
+ """The components recovered from a path.
217
+
218
+ Note the absence of an `instant` field. Recovering one is impossible without the
219
+ timezone, and ambiguous even with it during a DST fallback — see the module
220
+ docstring. Callers that need the instant read it from the database.
221
+ """
222
+
223
+ camera: str
224
+ date_parts: tuple[str, str, str]
225
+ time_parts: tuple[str, str, str]
226
+ collision_suffix: str | None
227
+
228
+ @property
229
+ def collided(self) -> bool:
230
+ return self.collision_suffix is not None
231
+
232
+
233
+ def parse_clip_relpath(relpath: PurePosixPath | str) -> ParsedClipPath:
234
+ """Parse `camera/YYYY/MM/DD/HH-MM-SS[-xxxxxx].mp4`.
235
+
236
+ Used by restore and by the verification pass, which walk a tree they did not write —
237
+ a `restic` restore, or a bucket listing. It validates rather than trusting, because
238
+ a file that does not parse means either a bug or something a human put there.
239
+
240
+ Raises:
241
+ PathError: the path is not a canonical clip path.
242
+ """
243
+ path = PurePosixPath(_as_posix(relpath))
244
+
245
+ if len(path.parts) != 5:
246
+ raise PathError(
247
+ f"{path} is not a canonical clip path. Expected "
248
+ f"<camera>/YYYY/MM/DD/HH-MM-SS{CLIP_EXT}, found {len(path.parts)} components."
249
+ )
250
+
251
+ camera, year, month, day, name = path.parts
252
+ validate_camera_name(camera)
253
+
254
+ if not (
255
+ re.fullmatch(r"\d{4}", year)
256
+ and re.fullmatch(r"\d{2}", month)
257
+ and re.fullmatch(r"\d{2}", day)
258
+ ):
259
+ raise PathError(f"{path} has a malformed date component.")
260
+
261
+ # Range-check via the calendar rather than by comparing against 12 and 31. A clip is
262
+ # always derived from an instant, so its date always exists — which means `02-30` is
263
+ # proof that something other than this module wrote the file.
264
+ try:
265
+ _dt.date(int(year), int(month), int(day))
266
+ except ValueError as exc:
267
+ raise PathError(f"{path} has a date that does not exist: {exc}") from exc
268
+
269
+ match = _CLIP_NAME_RE.match(name)
270
+ if match is None:
271
+ raise PathError(
272
+ f"{path} has a malformed filename. Expected HH-MM-SS{CLIP_EXT}, optionally "
273
+ f"with a {COLLISION_SUFFIX_LEN}-character collision suffix."
274
+ )
275
+
276
+ return ParsedClipPath(
277
+ camera=camera,
278
+ date_parts=(year, month, day),
279
+ time_parts=(match["h"], match["mi"], match["s"]),
280
+ collision_suffix=match["suffix"],
281
+ )
282
+
283
+
284
+ def is_canonical_relpath(relpath: PurePosixPath | str) -> bool:
285
+ """Non-raising form of [`parse_clip_relpath`][cctv.paths.parse_clip_relpath]."""
286
+ try:
287
+ parse_clip_relpath(relpath)
288
+ except PathError:
289
+ return False
290
+ return True