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/sweep.py ADDED
@@ -0,0 +1,254 @@
1
+ """The reconciliation sweep: find reviews we have not turned into clips, and turn them.
2
+
3
+ MQTT is the fast path and it is lossy — at-most-once from our side, and worse than that:
4
+ Frigate closes a review at start-up **without publishing `end` at all**. Measured, with
5
+ the subscriber on a broker that never restarted. A deployment trusting `end` loses that
6
+ clip silently. So the sweep is not a backstop for a rare failure; it is the mechanism
7
+ that makes materialisation complete, and MQTT is only the latency optimisation.
8
+
9
+ ## `after` filters on `end_time`, not `start_time` — and the watermark depends on it
10
+
11
+ Frigate's `/api/review` applies the window as:
12
+
13
+ ```python
14
+ clauses = [(start_time < before) & ((end_time.is_null()) | (end_time > after))]
15
+ ```
16
+
17
+ So a review that *started* before the watermark but *ended* after it is still returned.
18
+ That is why the watermark is the highest `end_time` fully processed rather than a
19
+ `start_time`: advancing past a start time would skip any review that was still open when
20
+ we last looked, which is exactly when a restart happens.
21
+
22
+ ## Progressing without losing work
23
+
24
+ If a review defers — the flush race, a transient Frigate failure — the watermark must not
25
+ move past it, or the next sweep never sees it again and the clip is lost by *retrying*.
26
+ But it also cannot stay put, or one persistently failing review blocks the sweep forever.
27
+
28
+ So the watermark advances to the highest `end_time` terminally decided, and then retreats
29
+ to just before the earliest *deferred* review. Deferrals are therefore revisited, and
30
+ everything else progresses.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import sqlite3
36
+ from collections.abc import Callable, Mapping
37
+ from dataclasses import dataclass, field
38
+ from typing import Protocol
39
+
40
+ from . import db
41
+ from .config import CameraConfig, CamerasConfig
42
+ from .frigate import FrigateError
43
+ from .materialise import CREATED, DEFERRED, FAILED, SKIPPED, MaterialiseOutcome
44
+ from .reviews import ReviewCandidate, candidates_from_reviews, decide
45
+ from .timeutil import Instant
46
+ from .timeutil import now as _now
47
+
48
+ __all__ = [
49
+ "DEFAULT_LOOKBACK_S",
50
+ "WATERMARK_EPSILON",
51
+ "WATERMARK_NAME",
52
+ "ReviewLister",
53
+ "ReviewSweep",
54
+ "SweepResult",
55
+ ]
56
+
57
+ #: Where the watermark lives in `sweep_state`. Named for reviews rather than events,
58
+ #: since ADR-0016.
59
+ WATERMARK_NAME = "reviews_sweep"
60
+
61
+ #: On a first run there is no watermark, and Frigate may hold months of history whose
62
+ #: footage has long expired. Sweeping all of it would produce a burst of doomed fetches, so
63
+ #: a fresh install starts from a bounded lookback instead.
64
+ DEFAULT_LOOKBACK_S = 3600.0
65
+
66
+ #: The `after` filter is strictly `end_time > after`, so a deferred review is only
67
+ #: re-fetched if the watermark sits *just* before it.
68
+ WATERMARK_EPSILON = 0.001
69
+
70
+
71
+ class ReviewLister(Protocol):
72
+ def list_reviews(
73
+ self, *, after: Instant | None = None, before: Instant | None = None, limit: int = ...
74
+ ) -> list[dict]: ...
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class SweepResult:
79
+ """What one sweep did. Counts rather than a boolean, because "how far behind am I" is
80
+ the question the health check and the dashboard both need answered.
81
+ """
82
+
83
+ considered: int = 0
84
+ materialised: int = 0
85
+ skipped: int = 0
86
+ deferred: int = 0
87
+ failed: int = 0
88
+ unknown_camera: int = 0
89
+ watermark: float | None = None
90
+ #: Set only when the sweep itself could not run — Frigate unreachable, for instance.
91
+ #: Not for per-review failures, which are counted instead.
92
+ failure: str | None = None
93
+ outcomes: tuple[MaterialiseOutcome, ...] = field(default=(), repr=False)
94
+
95
+ @property
96
+ def ok(self) -> bool:
97
+ return self.failure is None
98
+
99
+ @property
100
+ def needs_another_run(self) -> bool:
101
+ """True when something is waiting to be retried."""
102
+ return self.deferred > 0
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class _Decision:
107
+ candidate: ReviewCandidate
108
+ camera: CameraConfig | None
109
+ outcome: MaterialiseOutcome | None = None
110
+ skip_reason: str | None = None
111
+
112
+
113
+ class ReviewSweep:
114
+ """Walks the reviews Frigate holds and materialises the ones we are missing."""
115
+
116
+ def __init__(
117
+ self,
118
+ *,
119
+ conn: sqlite3.Connection,
120
+ client: ReviewLister,
121
+ materialiser,
122
+ clock: Callable[[], Instant] = _now,
123
+ lookback_s: float = DEFAULT_LOOKBACK_S,
124
+ limit: int = 200,
125
+ watermark_name: str = WATERMARK_NAME,
126
+ ) -> None:
127
+ self.conn = conn
128
+ self.client = client
129
+ self.materialiser = materialiser
130
+ self._clock = clock
131
+ self.lookback_s = lookback_s
132
+ self.limit = limit
133
+ self.watermark_name = watermark_name
134
+
135
+ # -----------------------------------------------------------------------------------
136
+
137
+ def run(self, *, config: CamerasConfig) -> SweepResult:
138
+ """One pass. Intended to be called on a timer, not in a tight loop."""
139
+ now = self._clock()
140
+ after = self._resume_point(now)
141
+
142
+ try:
143
+ raw = self.client.list_reviews(after=after, before=now + 1, limit=self.limit)
144
+ except FrigateError as exc:
145
+ # Frigate unreachable. The watermark does not move and the failure count rises,
146
+ # so health can tell "idle" apart from "failing for an hour".
147
+ db.set_watermark(
148
+ self.conn,
149
+ name=self.watermark_name,
150
+ watermark=after,
151
+ succeeded=False,
152
+ note=str(exc)[:200],
153
+ now=now,
154
+ )
155
+ return SweepResult(watermark=after, failure=f"could not list reviews: {exc}")
156
+
157
+ candidates = candidates_from_reviews(raw)
158
+ if not candidates:
159
+ db.set_watermark(
160
+ self.conn, name=self.watermark_name, watermark=after, succeeded=True, now=now
161
+ )
162
+ return SweepResult(watermark=after)
163
+
164
+ known = db.known_review_ids(self.conn, [c.review_id for c in candidates])
165
+ decisions = [
166
+ self._handle(candidate, config=config, known=known) for candidate in candidates
167
+ ]
168
+
169
+ watermark = self._advance(decisions, resume_point=after)
170
+ db.set_watermark(
171
+ self.conn, name=self.watermark_name, watermark=watermark, succeeded=True, now=now
172
+ )
173
+
174
+ outcomes = tuple(d.outcome for d in decisions if d.outcome is not None)
175
+ return SweepResult(
176
+ considered=len(decisions),
177
+ materialised=sum(1 for o in outcomes if o.status == CREATED),
178
+ skipped=sum(1 for d in decisions if d.skip_reason is not None)
179
+ + sum(1 for o in outcomes if o.status == SKIPPED),
180
+ deferred=sum(1 for o in outcomes if o.status == DEFERRED),
181
+ failed=sum(1 for o in outcomes if o.status == FAILED),
182
+ unknown_camera=sum(1 for d in decisions if d.skip_reason == "unknown camera"),
183
+ watermark=watermark,
184
+ outcomes=outcomes,
185
+ )
186
+
187
+ # -----------------------------------------------------------------------------------
188
+
189
+ def _handle(
190
+ self,
191
+ candidate: ReviewCandidate,
192
+ *,
193
+ config: CamerasConfig,
194
+ known: set[str],
195
+ ) -> _Decision:
196
+ camera = config.cameras.get(candidate.camera)
197
+ if camera is None or not camera.enabled:
198
+ # A review for a camera that is not in `cameras.yml`, or was retired. Not an
199
+ # error: Frigate may still hold history for a camera we no longer manage.
200
+ return _Decision(candidate, None, skip_reason="unknown camera")
201
+
202
+ verdict = decide(
203
+ candidate,
204
+ severities=camera.clip.severities,
205
+ objects=camera.clip.objects,
206
+ known_review_ids=known,
207
+ )
208
+ if not verdict.materialise:
209
+ return _Decision(candidate, camera, skip_reason=verdict.reason)
210
+
211
+ outcome = self.materialiser.materialise(
212
+ candidate,
213
+ pre_roll_s=camera.clip.pre_roll_s,
214
+ post_roll_s=camera.clip.post_roll_s,
215
+ )
216
+ return _Decision(candidate, camera, outcome=outcome)
217
+
218
+ def _resume_point(self, now: Instant) -> Instant:
219
+ state = db.get_watermark(self.conn, self.watermark_name)
220
+ if state is None or state["watermark"] is None:
221
+ return now - self.lookback_s
222
+ return float(state["watermark"])
223
+
224
+ def _advance(self, decisions: list[_Decision], *, resume_point: Instant) -> Instant:
225
+ """The new watermark: past everything settled, but not past anything deferred."""
226
+ settled: list[float] = []
227
+ deferred: list[float] = []
228
+
229
+ for decision in decisions:
230
+ end = decision.candidate.end_time
231
+ if end is None:
232
+ # An open review has no end to advance to, and it will come back anyway:
233
+ # the listing treats a null `end_time` as "inside the window" always.
234
+ continue
235
+ if decision.outcome is not None and decision.outcome.status == DEFERRED:
236
+ deferred.append(end)
237
+ else:
238
+ # Created, skipped or permanently failed — all terminal. A failure does not
239
+ # hold the watermark back, or one un-fetchable review would freeze the
240
+ # sweep forever; it is alerted on instead.
241
+ settled.append(end)
242
+
243
+ watermark = max(settled) if settled else resume_point
244
+ if deferred:
245
+ # Retreat to just before the earliest deferral, because the listing's filter is
246
+ # strictly `end_time > after` — sitting exactly on it would hide it.
247
+ watermark = min(watermark, min(deferred) - WATERMARK_EPSILON)
248
+ # Never backwards: redoing work is safe, skipping it is not.
249
+ return max(watermark, resume_point)
250
+
251
+
252
+ def cameras_for(config: CamerasConfig) -> Mapping[str, CameraConfig]:
253
+ """Convenience for callers that want the enabled cameras only."""
254
+ return config.enabled_cameras()
cctv/timeutil.py ADDED
@@ -0,0 +1,154 @@
1
+ """Instants and their rendering.
2
+
3
+ Two kinds of time value exist in this system and confusing them is the single most
4
+ common bug of this kind. See [ADR-0017](../../../docs/adr/0017-time-handling.md) and
5
+ [INV-12](../../../docs/architecture/invariants.md).
6
+
7
+ * An **instant** is epoch seconds — a bare `float`. It is the same instant everywhere
8
+ on earth, so it carries no timezone and cannot be "in UTC" or "in IST". This is what
9
+ Frigate sends, what the database stores, and what every comparison uses.
10
+ * A **rendered** time is a wall-clock value, produced from an instant plus a timezone.
11
+ This is what a human reads: path components, log lines, the status page.
12
+
13
+ **Storage is never rendered.** Rendering happens only at the edges, only through this
14
+ module and [`cctv.paths`][cctv.paths], and always with an explicitly supplied timezone.
15
+
16
+ Why not just store local-time strings: an instant cannot be wrong and cannot lose
17
+ information, so any timezone stays derivable from it. A rendered string *can* be wrong,
18
+ and during a DST fallback two distinct instants render to the same string — which would
19
+ mean a silent overwrite.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime as _dt
25
+ import re
26
+ import time
27
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
28
+
29
+ __all__ = [
30
+ "Instant",
31
+ "TimeConfigError",
32
+ "format_rfc3339",
33
+ "has_dst",
34
+ "now",
35
+ "render",
36
+ "resolve_zone",
37
+ "to_instant",
38
+ ]
39
+
40
+ #: Epoch seconds. An alias, not a class — the point is that a bare `float` is the
41
+ #: correct representation, and wrapping it would invite arithmetic on the wrapper.
42
+ type Instant = float
43
+
44
+
45
+ class TimeConfigError(ValueError):
46
+ """A timezone name that cannot be used. Raised at config load, never at request time."""
47
+
48
+
49
+ # `+05:30`, `-0800`, `+05` — a fixed offset. These parse as "valid" but carry no DST
50
+ # history, so a site that observes DST would render wrongly for part of the year.
51
+ _OFFSET_LIKE = re.compile(r"^[+-]\d{2}:?\d{2}$|^[+-]\d{2}$")
52
+
53
+
54
+ def resolve_zone(name: str) -> ZoneInfo:
55
+ """Resolve an IANA timezone name, once, at config load.
56
+
57
+ Resolving here and passing `ZoneInfo` around — rather than passing a string and
58
+ resolving per call — is what makes "resolved once at config load" enforceable
59
+ ([INV-12](../../../docs/architecture/invariants.md)) instead of a convention.
60
+
61
+ Rejects bare offsets, because they cannot express DST history. Rejects names
62
+ absent from tzdata, because `ZoneInfo` would otherwise fail deep inside a worker
63
+ on the first clip of the day rather than at start-up.
64
+
65
+ Raises:
66
+ TimeConfigError: the name is missing, is an offset, or is unknown.
67
+ """
68
+ if not isinstance(name, str) or not name.strip():
69
+ raise TimeConfigError(
70
+ "site.timezone is required and must be a non-empty IANA name. "
71
+ "It is never guessed from the host: a container's TZ is easy to get "
72
+ "wrong across six services, and wrong here means silently misplaced clips."
73
+ )
74
+
75
+ name = name.strip()
76
+
77
+ if _OFFSET_LIKE.match(name):
78
+ raise TimeConfigError(
79
+ f"{name!r} is a UTC offset, not a timezone. Offsets carry no DST history, "
80
+ f"so a site that observes DST would render wrongly for part of the year. "
81
+ f"Use an IANA name such as 'Asia/Kolkata'."
82
+ )
83
+
84
+ try:
85
+ return ZoneInfo(name)
86
+ except (ZoneInfoNotFoundError, ValueError) as exc:
87
+ raise TimeConfigError(
88
+ f"Unknown timezone {name!r}. It must be an IANA name present in tzdata — "
89
+ f"either the system zoneinfo database or the 'tzdata' package."
90
+ ) from exc
91
+
92
+
93
+ def now() -> Instant:
94
+ """The current instant.
95
+
96
+ Use this instead of `datetime.now()`. `datetime.now()` without a timezone returns a
97
+ naive local value, which is neither an instant nor a rendering — it is a wart, and
98
+ a lint rule forbids it in service code.
99
+ """
100
+ return time.time()
101
+
102
+
103
+ def is_aware(value: _dt.datetime) -> bool:
104
+ """Whether a datetime carries timezone information."""
105
+ return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None
106
+
107
+
108
+ def to_instant(value: _dt.datetime) -> Instant:
109
+ """Convert an **aware** datetime to an instant.
110
+
111
+ A naive datetime is rejected rather than assumed to be UTC or local. Both
112
+ assumptions have shipped as production bugs; refusing to guess is cheaper.
113
+ """
114
+ if not is_aware(value):
115
+ raise ValueError(
116
+ "A naive datetime has no defined instant. Attach a timezone (usually "
117
+ "`cctv.timeutil.UTC`) before converting. Guessing between UTC and local "
118
+ "time is the bug this check exists to prevent."
119
+ )
120
+ return value.timestamp()
121
+
122
+
123
+ def render(instant: Instant, zone: ZoneInfo) -> _dt.datetime:
124
+ """Render an instant as an aware wall-clock value in `zone`.
125
+
126
+ Total: every instant renders. That is the direction that matters, because
127
+ instants are what we start from. Going the other way — a wall-clock value to an
128
+ instant — is *partial*, since a DST spring-forward skips an hour entirely and a
129
+ fallback repeats one. Nothing in this system does it.
130
+ """
131
+ return _dt.datetime.fromtimestamp(instant, tz=zone)
132
+
133
+
134
+ def format_rfc3339(instant: Instant, zone: ZoneInfo) -> str:
135
+ """Render an instant for a human: logs, the status page, notifications."""
136
+ return render(instant, zone).isoformat(timespec="milliseconds")
137
+
138
+
139
+ def has_dst(zone: ZoneInfo, year: int = 2026) -> bool:
140
+ """Whether `zone` observes DST in `year`.
141
+
142
+ Used by tests and by an installer warning. A DST-observing site cannot be treated
143
+ as though it behaves like IST: Indian Standard Time is UTC+05:30 with no DST, so
144
+ the fallback-collision case in
145
+ [ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md) cannot arise there —
146
+ but it certainly can in Sydney, which is the value in the example config.
147
+ """
148
+ early = render(_dt.datetime(year, 1, 15, 12, tzinfo=_dt.UTC).timestamp(), zone)
149
+ mid = render(_dt.datetime(year, 7, 15, 12, tzinfo=_dt.UTC).timestamp(), zone)
150
+ return early.utcoffset() != mid.utcoffset()
151
+
152
+
153
+ #: Convenience alias, since a great deal of test and generator code needs it.
154
+ UTC = _dt.UTC