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/clip.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Deciding *when* to fetch a clip, and whether what came back is good enough.
|
|
2
|
+
|
|
3
|
+
Two separate responsibilities, both pure, both here so that the clip-worker's
|
|
4
|
+
orchestration has nothing subtle left in it:
|
|
5
|
+
|
|
6
|
+
1. **When.** A clip must not be fetched until the last recording segment covering the
|
|
7
|
+
review has been flushed to disk. Fetching earlier does not fail loudly — it returns
|
|
8
|
+
`200` with a *short* clip, which is the worst kind of bug because the MP4 is valid and
|
|
9
|
+
plays fine. Measured on the real stack: at 5 s after the review ended, 5 of 6 samples
|
|
10
|
+
came back silently truncated; the first full-length result was at 10 s. That is why the
|
|
11
|
+
default grace is 15 s and why [`FetchPlan.not_before`][cctv.clip.FetchPlan.not_before]
|
|
12
|
+
is a value that must be waited for rather than a hint.
|
|
13
|
+
|
|
14
|
+
2. **Whether it is good enough.** A truncated clip, a clip with a hole, and a perfect clip
|
|
15
|
+
are all valid MP4s that upload successfully. Telling them apart is what
|
|
16
|
+
[INV-14](../../../docs/architecture/invariants.md) requires, and it is why these
|
|
17
|
+
decisions are pure functions with names rather than a chain of `if`s in a worker loop.
|
|
18
|
+
|
|
19
|
+
**A short clip is never discarded.** Retrying improves the outcome; discarding cannot. When
|
|
20
|
+
attempts run out the best result obtained is kept and marked degraded.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
|
|
29
|
+
from .timeutil import Instant
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_COVERAGE_OK",
|
|
33
|
+
"DEFAULT_COVERAGE_POLICY",
|
|
34
|
+
"DEFAULT_COVERAGE_RETRY",
|
|
35
|
+
"DEFAULT_FETCH_ATTEMPTS",
|
|
36
|
+
"DEFAULT_FETCH_TIMEOUT_BASE_S",
|
|
37
|
+
"DEFAULT_FETCH_TIMEOUT_CEILING_S",
|
|
38
|
+
"DEFAULT_LONG_CLIP_WARN_S",
|
|
39
|
+
"DEFAULT_POST_ROLL_S",
|
|
40
|
+
"DEFAULT_PRE_ROLL_S",
|
|
41
|
+
"DEFAULT_SEGMENT_FLUSH_GRACE_S",
|
|
42
|
+
"ClipPlanError",
|
|
43
|
+
"CoveragePolicy",
|
|
44
|
+
"FetchPlan",
|
|
45
|
+
"Verdict",
|
|
46
|
+
"fetch_timeout_s",
|
|
47
|
+
"judge_coverage",
|
|
48
|
+
"pick_best",
|
|
49
|
+
"plan_fetch",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
DEFAULT_PRE_ROLL_S = 5.0
|
|
53
|
+
DEFAULT_POST_ROLL_S = 10.0
|
|
54
|
+
|
|
55
|
+
#: Measured, not guessed. The floor for a reliable fetch was 10 s after the review ended;
|
|
56
|
+
#: 5 s raced Frigate's segment flush. See `spike/RESULTS.md`.
|
|
57
|
+
DEFAULT_SEGMENT_FLUSH_GRACE_S = 15.0
|
|
58
|
+
|
|
59
|
+
#: `actual_duration / requested_duration` at or above this is a good clip.
|
|
60
|
+
DEFAULT_COVERAGE_OK = 0.95
|
|
61
|
+
|
|
62
|
+
#: Below this, the clip is short enough that another attempt is worth making. Note the gap
|
|
63
|
+
#: between the two thresholds is the *degraded* band: kept, flagged, not retried forever.
|
|
64
|
+
DEFAULT_COVERAGE_RETRY = 0.80
|
|
65
|
+
|
|
66
|
+
DEFAULT_FETCH_ATTEMPTS = 3
|
|
67
|
+
|
|
68
|
+
#: A fetch is a range read plus an `ffmpeg` cut, so its cost is roughly linear in the
|
|
69
|
+
#: clip's duration. A fixed timeout is therefore not a safety net but a silent failure
|
|
70
|
+
#: mode: at 120 s, every review longer than about two minutes fails, and with a generous
|
|
71
|
+
#: `visit_gap_s` those are precisely the interesting ones.
|
|
72
|
+
DEFAULT_FETCH_TIMEOUT_BASE_S = 120.0
|
|
73
|
+
DEFAULT_FETCH_TIMEOUT_CEILING_S = 1800.0
|
|
74
|
+
|
|
75
|
+
#: Flag only. A long review is stored in full, never truncated and never split.
|
|
76
|
+
DEFAULT_LONG_CLIP_WARN_S = 600.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ClipPlanError(ValueError):
|
|
80
|
+
"""A fetch that cannot be planned as asked."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class FetchPlan:
|
|
85
|
+
"""The range to request, and the earliest instant it may be requested.
|
|
86
|
+
|
|
87
|
+
Asymmetric on purpose: `pre_roll_s` and `post_roll_s` differ, so the symmetric
|
|
88
|
+
`?padding=` on Frigate's review endpoint cannot express this — hence the range
|
|
89
|
+
endpoint ([ADR-0015](../../../docs/adr/0015-clip-boundaries-and-padding.md)).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
review_id: str
|
|
93
|
+
requested_start: Instant
|
|
94
|
+
requested_end: Instant
|
|
95
|
+
#: `end_time + post_roll_s + segment_flush_grace_s`. Waiting for this is what stops the
|
|
96
|
+
#: silent truncation the spike measured.
|
|
97
|
+
not_before: Instant
|
|
98
|
+
timeout_s: float
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def requested_s(self) -> float:
|
|
102
|
+
return self.requested_end - self.requested_start
|
|
103
|
+
|
|
104
|
+
def seconds_until_ready(self, now: Instant) -> float:
|
|
105
|
+
"""How long to wait before fetching. Negative means it is already safe."""
|
|
106
|
+
return self.not_before - now
|
|
107
|
+
|
|
108
|
+
def is_ready(self, now: Instant) -> bool:
|
|
109
|
+
return now >= self.not_before
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fetch_timeout_s(
|
|
113
|
+
requested_s: float,
|
|
114
|
+
*,
|
|
115
|
+
base_s: float = DEFAULT_FETCH_TIMEOUT_BASE_S,
|
|
116
|
+
ceiling_s: float = DEFAULT_FETCH_TIMEOUT_CEILING_S,
|
|
117
|
+
) -> float:
|
|
118
|
+
"""A timeout proportional to the clip's length, clamped.
|
|
119
|
+
|
|
120
|
+
Proportionality matters because the alternative is worse than it looks: a clip that
|
|
121
|
+
times out *is* retried, so a fixed timeout turns one long review into three doomed
|
|
122
|
+
attempts and then a permanent failure — losing the clip entirely rather than taking
|
|
123
|
+
longer over it. At the original 120 s, every review longer than about two minutes
|
|
124
|
+
failed that way, and with a generous `visit_gap_s` those are the interesting ones.
|
|
125
|
+
|
|
126
|
+
The ceiling exists for a different job: bounding a *hang* rather than a slow-but-working
|
|
127
|
+
fetch. A generous timeout is right for a large clip, but an unbounded one lets a stuck
|
|
128
|
+
connection occupy a fetch slot for as long as the clip is long, which is how a queue
|
|
129
|
+
quietly stops draining.
|
|
130
|
+
"""
|
|
131
|
+
if requested_s < 0:
|
|
132
|
+
raise ClipPlanError(f"requested duration cannot be negative, got {requested_s}.")
|
|
133
|
+
return min(base_s + requested_s, ceiling_s)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def plan_fetch(
|
|
137
|
+
review_id: str,
|
|
138
|
+
*,
|
|
139
|
+
start_time: Instant,
|
|
140
|
+
end_time: Instant,
|
|
141
|
+
pre_roll_s: float = DEFAULT_PRE_ROLL_S,
|
|
142
|
+
post_roll_s: float = DEFAULT_POST_ROLL_S,
|
|
143
|
+
segment_flush_grace_s: float = DEFAULT_SEGMENT_FLUSH_GRACE_S,
|
|
144
|
+
timeout_base_s: float = DEFAULT_FETCH_TIMEOUT_BASE_S,
|
|
145
|
+
timeout_ceiling_s: float = DEFAULT_FETCH_TIMEOUT_CEILING_S,
|
|
146
|
+
) -> FetchPlan:
|
|
147
|
+
"""Build the fetch plan for one review.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ClipPlanError: the review has no end, ends before it starts, or a roll is negative.
|
|
151
|
+
"""
|
|
152
|
+
if not review_id:
|
|
153
|
+
raise ClipPlanError("A fetch plan needs the review id it belongs to.")
|
|
154
|
+
if end_time is None: # type: ignore[comparison-overlap]
|
|
155
|
+
raise ClipPlanError(
|
|
156
|
+
f"Review {review_id!r} has no end_time, so it is still open. Fetching now would "
|
|
157
|
+
f"capture a partial visit, and the clip could never be extended afterwards."
|
|
158
|
+
)
|
|
159
|
+
if end_time < start_time:
|
|
160
|
+
raise ClipPlanError(
|
|
161
|
+
f"Review {review_id!r} ends ({end_time}) before it starts ({start_time})."
|
|
162
|
+
)
|
|
163
|
+
for name, value in (
|
|
164
|
+
("pre_roll_s", pre_roll_s),
|
|
165
|
+
("post_roll_s", post_roll_s),
|
|
166
|
+
("segment_flush_grace_s", segment_flush_grace_s),
|
|
167
|
+
):
|
|
168
|
+
if value < 0:
|
|
169
|
+
raise ClipPlanError(f"{name} cannot be negative, got {value}.")
|
|
170
|
+
|
|
171
|
+
requested_start = start_time - pre_roll_s
|
|
172
|
+
requested_end = end_time + post_roll_s
|
|
173
|
+
|
|
174
|
+
return FetchPlan(
|
|
175
|
+
review_id=review_id,
|
|
176
|
+
requested_start=requested_start,
|
|
177
|
+
requested_end=requested_end,
|
|
178
|
+
not_before=requested_end + segment_flush_grace_s,
|
|
179
|
+
timeout_s=fetch_timeout_s(
|
|
180
|
+
requested_end - requested_start, base_s=timeout_base_s, ceiling_s=timeout_ceiling_s
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------------------
|
|
186
|
+
# Is it good enough?
|
|
187
|
+
# ---------------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Verdict(StrEnum):
|
|
191
|
+
"""What to do with a fetched clip."""
|
|
192
|
+
|
|
193
|
+
#: Accept it. `coverage_ratio` at or above `coverage_ok`.
|
|
194
|
+
ACCEPT = "accept"
|
|
195
|
+
#: Short, and another attempt is affordable. Keep it in hand first.
|
|
196
|
+
RETRY = "retry"
|
|
197
|
+
#: Short, and attempts are exhausted. Store it, mark it degraded, alert.
|
|
198
|
+
ACCEPT_DEGRADED = "accept_degraded"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class CoveragePolicy:
|
|
203
|
+
ok: float = DEFAULT_COVERAGE_OK
|
|
204
|
+
retry: float = DEFAULT_COVERAGE_RETRY
|
|
205
|
+
max_attempts: int = DEFAULT_FETCH_ATTEMPTS
|
|
206
|
+
|
|
207
|
+
def __post_init__(self) -> None:
|
|
208
|
+
if not 0 < self.retry <= self.ok <= 2.0:
|
|
209
|
+
raise ClipPlanError(
|
|
210
|
+
f"Coverage thresholds must satisfy 0 < retry ({self.retry}) <= ok "
|
|
211
|
+
f"({self.ok}). A `retry` above `ok` would mean a clip that was already "
|
|
212
|
+
f"acceptable still gets re-fetched."
|
|
213
|
+
)
|
|
214
|
+
if self.max_attempts < 1:
|
|
215
|
+
raise ClipPlanError("max_attempts must be at least 1.")
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def degraded_band(self) -> tuple[float, float]:
|
|
219
|
+
"""The range that is stored and flagged rather than retried further."""
|
|
220
|
+
return (self.retry, self.ok)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
#: A module singleton, not a call in a signature. `CoveragePolicy` is frozen, so this
|
|
224
|
+
#: cannot be mutated by a caller that forgets to pass its own.
|
|
225
|
+
DEFAULT_COVERAGE_POLICY = CoveragePolicy()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def judge_coverage(
|
|
229
|
+
coverage_ratio: float | None,
|
|
230
|
+
*,
|
|
231
|
+
attempts_used: int,
|
|
232
|
+
policy: CoveragePolicy = DEFAULT_COVERAGE_POLICY,
|
|
233
|
+
) -> Verdict:
|
|
234
|
+
"""Classify a fetch result.
|
|
235
|
+
|
|
236
|
+
`coverage_ratio` is `actual_duration / requested_duration`, and it is **expected to
|
|
237
|
+
exceed 1.0 sometimes**. `-c copy` can only cut at keyframes, and the spike measured it
|
|
238
|
+
erring *outward* by roughly 1.9 s per 10 s of clip. Over-running is the intended
|
|
239
|
+
behaviour ([INV-14]): overshooting costs storage, undershooting loses evidence. So a
|
|
240
|
+
ratio above 1.0 is a success, not an anomaly to correct.
|
|
241
|
+
|
|
242
|
+
`None` means the duration could not be measured at all, which is treated as a failed
|
|
243
|
+
attempt rather than a perfect one.
|
|
244
|
+
"""
|
|
245
|
+
if attempts_used < 1:
|
|
246
|
+
raise ClipPlanError("attempts_used must be at least 1 after a fetch has happened.")
|
|
247
|
+
|
|
248
|
+
if coverage_ratio is None:
|
|
249
|
+
return Verdict.RETRY if attempts_used < policy.max_attempts else Verdict.ACCEPT_DEGRADED
|
|
250
|
+
|
|
251
|
+
if coverage_ratio >= policy.ok:
|
|
252
|
+
return Verdict.ACCEPT
|
|
253
|
+
|
|
254
|
+
if attempts_used < policy.max_attempts:
|
|
255
|
+
return Verdict.RETRY
|
|
256
|
+
|
|
257
|
+
return Verdict.ACCEPT_DEGRADED
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def pick_best(candidates: Sequence[object]) -> object | None:
|
|
261
|
+
"""The best of several attempts. Never discard a result in favour of nothing.
|
|
262
|
+
|
|
263
|
+
Ranked by coverage first, then by duration. Ties broken by duration because when two
|
|
264
|
+
attempts cover the same fraction of the request, the longer one contains more footage
|
|
265
|
+
— coverage alone can tie while the clips differ materially, which happens when the
|
|
266
|
+
second attempt was cut against different keyframes.
|
|
267
|
+
"""
|
|
268
|
+
scored = [c for c in candidates if getattr(c, "coverage_ratio", None) is not None]
|
|
269
|
+
if not scored:
|
|
270
|
+
# Nothing measurable. Return the longest if we can, else the last attempt.
|
|
271
|
+
with_duration = [c for c in candidates if getattr(c, "duration_s", None) is not None]
|
|
272
|
+
if with_duration:
|
|
273
|
+
return max(with_duration, key=lambda c: c.duration_s) # type: ignore[attr-defined]
|
|
274
|
+
return candidates[-1] if candidates else None
|
|
275
|
+
return max(
|
|
276
|
+
scored,
|
|
277
|
+
key=lambda c: (c.coverage_ratio, c.duration_s or 0.0), # type: ignore[attr-defined]
|
|
278
|
+
)
|