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/materialise.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Turning a review item into a canonical clip, on disk and in the database.
|
|
2
|
+
|
|
3
|
+
This is the composition the rest of Phase 3 was built for. Everything subtle already lives
|
|
4
|
+
somewhere tested — the fetch range, the coverage decision, the atomic write, the probe
|
|
5
|
+
parsing, the trigger verdict — so this module is mostly *ordering*. The ordering is the
|
|
6
|
+
part that matters, though, and three of its rules are load-bearing:
|
|
7
|
+
|
|
8
|
+
1. **Pin before downloading, unpin after.** [INV-9]. A failed pin is fatal for that review,
|
|
9
|
+
because downloading without a pin risks Frigate expiring the footage underneath us, and
|
|
10
|
+
a missing clip is worse than a late one. Unpinning is best-effort: a stuck pin wastes
|
|
11
|
+
Frigate's disk, it does not lose data, and the reaper collects it later.
|
|
12
|
+
2. **A row exists only once the file does.** The database is the record of what is on disk,
|
|
13
|
+
so the insert happens after the rename. The reverse order would let a crash leave a row
|
|
14
|
+
pointing at nothing, and every consumer of that table would believe it.
|
|
15
|
+
3. **A short clip is kept, not discarded.** [INV-14]. Attempts are ranked and the best one
|
|
16
|
+
is promoted; retrying improves the outcome and discarding cannot. Each attempt lands on
|
|
17
|
+
its own scratch path for exactly that reason — writing retries straight onto the
|
|
18
|
+
canonical path would let a worse second attempt overwrite a better first one, which is
|
|
19
|
+
the opposite of keeping the best.
|
|
20
|
+
|
|
21
|
+
**No network or subprocess work happens inside a database transaction.** The claim in
|
|
22
|
+
[`cctv.db`][cctv.db] commits before returning and the insert here opens its own short one,
|
|
23
|
+
so a slow fetch cannot hold the write lock.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import sqlite3
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import Callable, Sequence
|
|
33
|
+
from contextlib import suppress
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path, PurePosixPath
|
|
36
|
+
from typing import Protocol
|
|
37
|
+
from zoneinfo import ZoneInfo
|
|
38
|
+
|
|
39
|
+
from . import atomic, db
|
|
40
|
+
from .clip import (
|
|
41
|
+
DEFAULT_COVERAGE_POLICY,
|
|
42
|
+
DEFAULT_LONG_CLIP_WARN_S,
|
|
43
|
+
DEFAULT_SEGMENT_FLUSH_GRACE_S,
|
|
44
|
+
CoveragePolicy,
|
|
45
|
+
FetchPlan,
|
|
46
|
+
Verdict,
|
|
47
|
+
judge_coverage,
|
|
48
|
+
pick_best,
|
|
49
|
+
plan_fetch,
|
|
50
|
+
)
|
|
51
|
+
from .ffprobe import ClipFacts, ProbeError, run_probe
|
|
52
|
+
from .frigate import FrigateClient, FrigateError, FrigateUnavailable, NoRecordingsYet
|
|
53
|
+
from .paths import candidate_relpaths, clip_cloud_key, sidecar_relpath
|
|
54
|
+
from .reviews import ReviewCandidate
|
|
55
|
+
from .timeutil import Instant
|
|
56
|
+
from .timeutil import now as _now
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"CREATED",
|
|
60
|
+
"DEFERRED",
|
|
61
|
+
"FAILED",
|
|
62
|
+
"SKIPPED",
|
|
63
|
+
"ClipMaterialiser",
|
|
64
|
+
"ClipSource",
|
|
65
|
+
"FrigateSource",
|
|
66
|
+
"MaterialiseOutcome",
|
|
67
|
+
"Prober",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
CREATED = "created"
|
|
71
|
+
SKIPPED = "skipped"
|
|
72
|
+
#: Retryable — the caller should requeue this review rather than treat it as lost.
|
|
73
|
+
DEFERRED = "deferred"
|
|
74
|
+
FAILED = "failed"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ClipSource(Protocol):
|
|
78
|
+
"""Where clips come from. Narrow on purpose, so a fake is three methods."""
|
|
79
|
+
|
|
80
|
+
def retain(self, event_id: str) -> None: ...
|
|
81
|
+
|
|
82
|
+
def unretain(self, event_id: str) -> None: ...
|
|
83
|
+
|
|
84
|
+
def download(
|
|
85
|
+
self,
|
|
86
|
+
camera: str,
|
|
87
|
+
start: Instant,
|
|
88
|
+
end: Instant,
|
|
89
|
+
sink: object,
|
|
90
|
+
*,
|
|
91
|
+
timeout_s: float,
|
|
92
|
+
) -> int: ...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Prober(Protocol):
|
|
96
|
+
def __call__(self, path: Path) -> ClipFacts: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class FrigateSource:
|
|
101
|
+
"""Adapts [`cctv.frigate.FrigateClient`][cctv.frigate.FrigateClient] to `ClipSource`.
|
|
102
|
+
|
|
103
|
+
The indirection exists so the materialiser can be tested without a network, and so
|
|
104
|
+
nothing in this module needs to know the client's exception hierarchy beyond
|
|
105
|
+
`FrigateError`.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
client: FrigateClient
|
|
109
|
+
|
|
110
|
+
def retain(self, event_id: str) -> None:
|
|
111
|
+
self.client.retain_event(event_id)
|
|
112
|
+
|
|
113
|
+
def unretain(self, event_id: str) -> None:
|
|
114
|
+
self.client.unretain_event(event_id)
|
|
115
|
+
|
|
116
|
+
def download(
|
|
117
|
+
self,
|
|
118
|
+
camera: str,
|
|
119
|
+
start: Instant,
|
|
120
|
+
end: Instant,
|
|
121
|
+
sink: object,
|
|
122
|
+
*,
|
|
123
|
+
timeout_s: float,
|
|
124
|
+
) -> int:
|
|
125
|
+
return self.client.download_range(camera, start, end, sink, timeout_s=timeout_s)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class MaterialiseOutcome:
|
|
130
|
+
"""What happened, and why.
|
|
131
|
+
|
|
132
|
+
`status` distinguishes four things that a boolean would conflate: a clip was made; it
|
|
133
|
+
was deliberately not made; it could not be made and should be retried; it could not be
|
|
134
|
+
made and will not be.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
review_id: str
|
|
138
|
+
status: str
|
|
139
|
+
reason: str
|
|
140
|
+
clip_id: int | None = None
|
|
141
|
+
path: str | None = None
|
|
142
|
+
coverage_ratio: float | None = None
|
|
143
|
+
degraded: bool = False
|
|
144
|
+
long_clip: bool = False
|
|
145
|
+
fetch_attempts: int = 0
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def materialised(self) -> bool:
|
|
149
|
+
return self.status == CREATED
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class _Ranked:
|
|
154
|
+
"""A finished attempt, shaped so `pick_best` can rank it.
|
|
155
|
+
|
|
156
|
+
Exists so the ranking rule lives in exactly one place. Re-deriving "best" here would
|
|
157
|
+
mean two implementations of INV-14 that could disagree.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
coverage_ratio: float | None
|
|
161
|
+
duration_s: float | None
|
|
162
|
+
scratch: Path
|
|
163
|
+
facts: ClipFacts
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ClipMaterialiser:
|
|
167
|
+
"""Materialises review items into canonical clips."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
conn: sqlite3.Connection,
|
|
173
|
+
source: ClipSource,
|
|
174
|
+
data_dir: Path,
|
|
175
|
+
location: str,
|
|
176
|
+
zone: ZoneInfo,
|
|
177
|
+
prober: Prober = run_probe,
|
|
178
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
179
|
+
clock: Callable[[], Instant] = _now,
|
|
180
|
+
policy: CoveragePolicy = DEFAULT_COVERAGE_POLICY,
|
|
181
|
+
flush_grace_s: float = DEFAULT_SEGMENT_FLUSH_GRACE_S,
|
|
182
|
+
long_clip_warn_s: float = DEFAULT_LONG_CLIP_WARN_S,
|
|
183
|
+
backoff_s: Sequence[float] = (5.0, 15.0, 45.0),
|
|
184
|
+
clips_dirname: str = "clips",
|
|
185
|
+
) -> None:
|
|
186
|
+
self.conn = conn
|
|
187
|
+
self.source = source
|
|
188
|
+
self.data_dir = Path(data_dir)
|
|
189
|
+
self.location = location
|
|
190
|
+
self.zone = zone
|
|
191
|
+
self.prober = prober
|
|
192
|
+
self._sleep = sleep
|
|
193
|
+
self._clock = clock
|
|
194
|
+
self.policy = policy
|
|
195
|
+
self.flush_grace_s = flush_grace_s
|
|
196
|
+
self.long_clip_warn_s = long_clip_warn_s
|
|
197
|
+
self.backoff_s = tuple(backoff_s)
|
|
198
|
+
self.clips_dirname = clips_dirname
|
|
199
|
+
|
|
200
|
+
# -----------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def materialise(
|
|
203
|
+
self,
|
|
204
|
+
candidate: ReviewCandidate,
|
|
205
|
+
*,
|
|
206
|
+
pre_roll_s: float = 5.0,
|
|
207
|
+
post_roll_s: float = 10.0,
|
|
208
|
+
) -> MaterialiseOutcome:
|
|
209
|
+
"""Fetch, verify, and store one review as a canonical clip."""
|
|
210
|
+
if candidate.is_open:
|
|
211
|
+
return MaterialiseOutcome(candidate.review_id, SKIPPED, "review is still open")
|
|
212
|
+
|
|
213
|
+
plan = plan_fetch(
|
|
214
|
+
candidate.review_id,
|
|
215
|
+
start_time=candidate.start_time,
|
|
216
|
+
end_time=candidate.end_time,
|
|
217
|
+
pre_roll_s=pre_roll_s,
|
|
218
|
+
post_roll_s=post_roll_s,
|
|
219
|
+
segment_flush_grace_s=self.flush_grace_s,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
delay = plan.seconds_until_ready(self._clock())
|
|
223
|
+
if delay > 0:
|
|
224
|
+
self._sleep(delay)
|
|
225
|
+
|
|
226
|
+
pinned: list[str] = []
|
|
227
|
+
try:
|
|
228
|
+
for event_id in candidate.event_ids:
|
|
229
|
+
try:
|
|
230
|
+
self.source.retain(event_id)
|
|
231
|
+
except FrigateError as exc:
|
|
232
|
+
# Fatal for this review. Downloading without a pin risks Frigate
|
|
233
|
+
# expiring the footage first, and a missing clip beats a late one only
|
|
234
|
+
# if it is deliberate — which this is.
|
|
235
|
+
return MaterialiseOutcome(
|
|
236
|
+
candidate.review_id,
|
|
237
|
+
FAILED,
|
|
238
|
+
f"could not pin event {event_id}: {exc}",
|
|
239
|
+
)
|
|
240
|
+
pinned.append(event_id)
|
|
241
|
+
|
|
242
|
+
return self._fetch_and_store(candidate, plan, post_roll_s=post_roll_s)
|
|
243
|
+
finally:
|
|
244
|
+
for event_id in pinned:
|
|
245
|
+
with suppress(FrigateError):
|
|
246
|
+
# Non-fatal by design: a stuck pin wastes disk, it does not lose data.
|
|
247
|
+
self.source.unretain(event_id)
|
|
248
|
+
|
|
249
|
+
# -----------------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _fetch_and_store(
|
|
252
|
+
self, candidate: ReviewCandidate, plan: FetchPlan, *, post_roll_s: float
|
|
253
|
+
) -> MaterialiseOutcome:
|
|
254
|
+
target = self._choose_target(candidate)
|
|
255
|
+
ranked: list[_Ranked] = []
|
|
256
|
+
last_error: str | None = None
|
|
257
|
+
|
|
258
|
+
for attempt in range(1, self.policy.max_attempts + 1):
|
|
259
|
+
scratch = target.with_name(f"{target.name}.tmp-attempt{attempt}")
|
|
260
|
+
try:
|
|
261
|
+
with atomic.atomic_writer(scratch) as handle:
|
|
262
|
+
self.source.download(
|
|
263
|
+
candidate.camera,
|
|
264
|
+
plan.requested_start,
|
|
265
|
+
plan.requested_end,
|
|
266
|
+
handle,
|
|
267
|
+
timeout_s=plan.timeout_s,
|
|
268
|
+
)
|
|
269
|
+
facts = self.prober(scratch)
|
|
270
|
+
except (NoRecordingsYet, FrigateUnavailable) as exc:
|
|
271
|
+
# The flush race and transient outages. Both mean "ask again later".
|
|
272
|
+
atomic.discard(scratch)
|
|
273
|
+
last_error = str(exc)
|
|
274
|
+
if attempt < self.policy.max_attempts:
|
|
275
|
+
self._sleep(self._backoff(attempt))
|
|
276
|
+
continue
|
|
277
|
+
except ProbeError as exc:
|
|
278
|
+
atomic.discard(scratch)
|
|
279
|
+
last_error = str(exc)
|
|
280
|
+
if attempt < self.policy.max_attempts:
|
|
281
|
+
self._sleep(self._backoff(attempt))
|
|
282
|
+
continue
|
|
283
|
+
except FrigateError as exc:
|
|
284
|
+
atomic.discard(scratch)
|
|
285
|
+
return MaterialiseOutcome(candidate.review_id, FAILED, f"fetch failed: {exc}")
|
|
286
|
+
|
|
287
|
+
coverage = facts.coverage_against(plan.requested_s)
|
|
288
|
+
ranked.append(
|
|
289
|
+
_Ranked(
|
|
290
|
+
coverage_ratio=coverage,
|
|
291
|
+
duration_s=facts.duration_s,
|
|
292
|
+
scratch=scratch,
|
|
293
|
+
facts=facts,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
verdict = judge_coverage(coverage, attempts_used=attempt, policy=self.policy)
|
|
298
|
+
if verdict is Verdict.ACCEPT:
|
|
299
|
+
break
|
|
300
|
+
if verdict is Verdict.ACCEPT_DEGRADED:
|
|
301
|
+
# Attempts exhausted and it is still short. Keep it, flag it, move on.
|
|
302
|
+
break
|
|
303
|
+
if attempt < self.policy.max_attempts:
|
|
304
|
+
self._sleep(self._backoff(attempt))
|
|
305
|
+
|
|
306
|
+
if not ranked:
|
|
307
|
+
return MaterialiseOutcome(
|
|
308
|
+
candidate.review_id,
|
|
309
|
+
DEFERRED,
|
|
310
|
+
last_error or "no attempt produced a clip",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
chosen = pick_best(ranked)
|
|
314
|
+
assert isinstance(chosen, _Ranked) # pick_best returns from the input sequence
|
|
315
|
+
for item in ranked:
|
|
316
|
+
if item.scratch != chosen.scratch:
|
|
317
|
+
atomic.discard(item.scratch)
|
|
318
|
+
|
|
319
|
+
atomic.promote(chosen.scratch, target)
|
|
320
|
+
return self._record(
|
|
321
|
+
candidate,
|
|
322
|
+
plan,
|
|
323
|
+
target,
|
|
324
|
+
chosen,
|
|
325
|
+
post_roll_s=post_roll_s,
|
|
326
|
+
fetch_attempts=len(ranked),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# -----------------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def _record(
|
|
332
|
+
self,
|
|
333
|
+
candidate: ReviewCandidate,
|
|
334
|
+
plan: FetchPlan,
|
|
335
|
+
target: Path,
|
|
336
|
+
chosen: _Ranked,
|
|
337
|
+
*,
|
|
338
|
+
post_roll_s: float,
|
|
339
|
+
fetch_attempts: int,
|
|
340
|
+
) -> MaterialiseOutcome:
|
|
341
|
+
# Two related-but-different paths. `relative` is relative to DATA_DIR, which is
|
|
342
|
+
# what the database stores. The cloud key is built from the path relative to the
|
|
343
|
+
# *clips* directory, because `clip_cloud_key` adds the `clips` segment itself —
|
|
344
|
+
# passing the DATA_DIR-relative form doubled it.
|
|
345
|
+
relative = PurePosixPath(self.clips_dirname) / self._relative(target)
|
|
346
|
+
relative_str = relative.as_posix()
|
|
347
|
+
below_clips = self._relative(target)
|
|
348
|
+
|
|
349
|
+
size_bytes = target.stat().st_size
|
|
350
|
+
digest = _sha256(target)
|
|
351
|
+
coverage = chosen.coverage_ratio
|
|
352
|
+
degraded = coverage is None or coverage < self.policy.ok
|
|
353
|
+
long_clip = (chosen.duration_s or 0.0) > self.long_clip_warn_s
|
|
354
|
+
cloud_key = clip_cloud_key(self.location, below_clips)
|
|
355
|
+
|
|
356
|
+
sidecar_payload = {
|
|
357
|
+
"review_id": candidate.review_id,
|
|
358
|
+
"camera": candidate.camera,
|
|
359
|
+
"severity": candidate.severity,
|
|
360
|
+
"review_start_time": candidate.start_time,
|
|
361
|
+
"review_end_time": candidate.end_time,
|
|
362
|
+
"requested_start_ts": plan.requested_start,
|
|
363
|
+
"requested_end_ts": plan.requested_end,
|
|
364
|
+
"requested_s": plan.requested_s,
|
|
365
|
+
"duration_s": chosen.facts.duration_s,
|
|
366
|
+
"coverage_ratio": coverage,
|
|
367
|
+
"max_gap_s": chosen.facts.max_packet_gap_s,
|
|
368
|
+
"has_gaps": False, # see the note on ClipRecord below — the detector is unusable
|
|
369
|
+
"video_codec": chosen.facts.video_codec,
|
|
370
|
+
"audio_codec": chosen.facts.audio_codec,
|
|
371
|
+
"has_audio": chosen.facts.has_audio,
|
|
372
|
+
"event_ids": list(candidate.event_ids),
|
|
373
|
+
"objects": list(candidate.objects),
|
|
374
|
+
"zones": list(candidate.zones),
|
|
375
|
+
"sub_labels": list(candidate.sub_labels),
|
|
376
|
+
"post_roll_s": post_roll_s,
|
|
377
|
+
"degraded": degraded,
|
|
378
|
+
"long_clip": long_clip,
|
|
379
|
+
"path": relative_str,
|
|
380
|
+
"cloud_key": cloud_key,
|
|
381
|
+
"size_bytes": size_bytes,
|
|
382
|
+
"sha256": digest,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
sidecar = sidecar_relpath(target)
|
|
386
|
+
atomic.write_atomic(
|
|
387
|
+
sidecar,
|
|
388
|
+
json.dumps(sidecar_payload, indent=2, sort_keys=True).encode("utf-8"),
|
|
389
|
+
)
|
|
390
|
+
sidecar_relative = (PurePosixPath(self.clips_dirname) / self._relative(sidecar)).as_posix()
|
|
391
|
+
|
|
392
|
+
clip_id, _created = db.insert_clip(
|
|
393
|
+
self.conn,
|
|
394
|
+
db.ClipRecord(
|
|
395
|
+
review_id=candidate.review_id,
|
|
396
|
+
camera=candidate.camera,
|
|
397
|
+
severity=candidate.severity,
|
|
398
|
+
start_time=candidate.start_time,
|
|
399
|
+
end_time=candidate.end_time or candidate.start_time,
|
|
400
|
+
path=relative_str,
|
|
401
|
+
event_ids=candidate.event_ids,
|
|
402
|
+
zones=candidate.zones,
|
|
403
|
+
label=candidate.objects[0] if candidate.objects else None,
|
|
404
|
+
duration_s=chosen.facts.duration_s,
|
|
405
|
+
requested_start_ts=plan.requested_start,
|
|
406
|
+
requested_end_ts=plan.requested_end,
|
|
407
|
+
pre_roll_s=candidate.start_time - plan.requested_start,
|
|
408
|
+
post_roll_s=post_roll_s,
|
|
409
|
+
# Recorded because the data is real and a future detector may want it.
|
|
410
|
+
# `has_gaps` stays false: packet-gap detection flags healthy clips on this
|
|
411
|
+
# stack (a measured 3.55 s gap in a 30 s clip with a 2.55 s keyframe
|
|
412
|
+
# interval), so setting it here would mark almost every clip as damaged and
|
|
413
|
+
# make the flag meaningless. ADR-0015.
|
|
414
|
+
max_gap_s=chosen.facts.max_packet_gap_s,
|
|
415
|
+
has_gaps=False,
|
|
416
|
+
has_audio=chosen.facts.has_audio,
|
|
417
|
+
video_codec=chosen.facts.video_codec,
|
|
418
|
+
audio_codec=chosen.facts.audio_codec,
|
|
419
|
+
fetch_attempts=fetch_attempts,
|
|
420
|
+
degraded=degraded,
|
|
421
|
+
long_clip=long_clip,
|
|
422
|
+
size_bytes=size_bytes,
|
|
423
|
+
sha256=digest,
|
|
424
|
+
sidecar_path=sidecar_relative,
|
|
425
|
+
materialised_at=_now(),
|
|
426
|
+
),
|
|
427
|
+
now=_now(),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return MaterialiseOutcome(
|
|
431
|
+
review_id=candidate.review_id,
|
|
432
|
+
status=CREATED,
|
|
433
|
+
reason="ok" if not degraded else f"kept a short clip (coverage {coverage})",
|
|
434
|
+
clip_id=clip_id,
|
|
435
|
+
path=relative_str,
|
|
436
|
+
coverage_ratio=coverage,
|
|
437
|
+
degraded=degraded,
|
|
438
|
+
long_clip=long_clip,
|
|
439
|
+
fetch_attempts=fetch_attempts,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# -----------------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def _relative(self, target: Path) -> PurePosixPath:
|
|
445
|
+
"""`<camera>/YYYY/MM/DD/HH-MM-SS.mp4` — the part below the clips directory."""
|
|
446
|
+
return PurePosixPath(target.relative_to(self.data_dir / self.clips_dirname).as_posix())
|
|
447
|
+
|
|
448
|
+
def _choose_target(self, candidate: ReviewCandidate) -> Path:
|
|
449
|
+
"""The path to write, resolving the collision case.
|
|
450
|
+
|
|
451
|
+
Two reviews on one camera can start in the same second, and a DST fallback makes it
|
|
452
|
+
certain rather than rare. The colliding form is used when the primary path belongs
|
|
453
|
+
to a *different* review, or when a file is there that no row accounts for — an
|
|
454
|
+
orphan from a crash. Overwriting either would destroy a clip to make room, which is
|
|
455
|
+
the one thing the design never does.
|
|
456
|
+
"""
|
|
457
|
+
primary, colliding = candidate_relpaths(
|
|
458
|
+
candidate.camera, candidate.start_time, self.zone, candidate.review_id
|
|
459
|
+
)
|
|
460
|
+
chosen = primary
|
|
461
|
+
|
|
462
|
+
row = self.conn.execute(
|
|
463
|
+
"SELECT review_id FROM clips WHERE path = ?",
|
|
464
|
+
((PurePosixPath(self.clips_dirname) / primary).as_posix(),),
|
|
465
|
+
).fetchone()
|
|
466
|
+
if row is not None:
|
|
467
|
+
if row["review_id"] != candidate.review_id:
|
|
468
|
+
chosen = colliding
|
|
469
|
+
elif (self.data_dir / self.clips_dirname / primary).exists():
|
|
470
|
+
chosen = colliding
|
|
471
|
+
|
|
472
|
+
return self.data_dir / self.clips_dirname / chosen
|
|
473
|
+
|
|
474
|
+
def _backoff(self, attempt: int) -> float:
|
|
475
|
+
if not self.backoff_s:
|
|
476
|
+
return 0.0
|
|
477
|
+
return self.backoff_s[min(attempt - 1, len(self.backoff_s) - 1)]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _sha256(path: Path, *, chunk: int = 1024 * 1024) -> str:
|
|
481
|
+
digest = hashlib.sha256()
|
|
482
|
+
with open(path, "rb") as handle:
|
|
483
|
+
while True:
|
|
484
|
+
block = handle.read(chunk)
|
|
485
|
+
if not block:
|
|
486
|
+
break
|
|
487
|
+
digest.update(block)
|
|
488
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
-- Migration 0001 — the initial schema.
|
|
2
|
+
--
|
|
3
|
+
-- Forward-only. There are no down migrations: a bad migration is corrected by a new one
|
|
4
|
+
-- (INV-8, docs/architecture/lld/03-state-store.md §4.1).
|
|
5
|
+
--
|
|
6
|
+
-- Two properties are enforced here rather than in application code, because a constraint
|
|
7
|
+
-- cannot be forgotten:
|
|
8
|
+
--
|
|
9
|
+
-- * `UNIQUE(review_id)` is the idempotency mechanism. Materialising the same review
|
|
10
|
+
-- twice cannot produce two rows, which is what makes INV-7 structural.
|
|
11
|
+
-- * The `upload_state` CHECK is closed. A typo in a state name fails at write time
|
|
12
|
+
-- instead of creating a row that no query will ever find again.
|
|
13
|
+
--
|
|
14
|
+
-- Timestamps are `REAL` epoch seconds — an instant, which carries no timezone (INV-12,
|
|
15
|
+
-- ADR-0017). No column stores a rendered local time.
|
|
16
|
+
|
|
17
|
+
CREATE TABLE cameras (
|
|
18
|
+
name TEXT PRIMARY KEY,
|
|
19
|
+
display_name TEXT NOT NULL,
|
|
20
|
+
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
21
|
+
first_seen_at REAL NOT NULL,
|
|
22
|
+
retired_at REAL,
|
|
23
|
+
config_hash TEXT,
|
|
24
|
+
updated_at REAL NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE clips (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
|
|
30
|
+
-- Identity. The review id is Frigate's review item id and the dedup key
|
|
31
|
+
-- (ADR-0016). event_ids preserves event-level searchability for a per-visit file.
|
|
32
|
+
review_id TEXT NOT NULL UNIQUE,
|
|
33
|
+
event_ids TEXT NOT NULL DEFAULT '[]',
|
|
34
|
+
severity TEXT NOT NULL CHECK (severity IN ('alert', 'detection')),
|
|
35
|
+
camera TEXT NOT NULL REFERENCES cameras (name) ON UPDATE CASCADE,
|
|
36
|
+
label TEXT,
|
|
37
|
+
zones TEXT NOT NULL DEFAULT '[]',
|
|
38
|
+
|
|
39
|
+
-- What Frigate called the activity.
|
|
40
|
+
start_time REAL NOT NULL,
|
|
41
|
+
end_time REAL NOT NULL,
|
|
42
|
+
|
|
43
|
+
-- What we actually got. These are the columns that make INV-14 auditable: a
|
|
44
|
+
-- truncated clip, a clip with a hole, and a perfect clip are all valid MP4s that
|
|
45
|
+
-- play fine and upload fine, and are otherwise indistinguishable.
|
|
46
|
+
duration_s REAL,
|
|
47
|
+
requested_start_ts REAL,
|
|
48
|
+
requested_end_ts REAL,
|
|
49
|
+
requested_s REAL,
|
|
50
|
+
coverage_ratio REAL,
|
|
51
|
+
pre_roll_s REAL,
|
|
52
|
+
post_roll_s REAL,
|
|
53
|
+
max_gap_s REAL,
|
|
54
|
+
has_gaps INTEGER NOT NULL DEFAULT 0 CHECK (has_gaps IN (0, 1)),
|
|
55
|
+
has_audio INTEGER NOT NULL DEFAULT 0 CHECK (has_audio IN (0, 1)),
|
|
56
|
+
video_codec TEXT,
|
|
57
|
+
audio_codec TEXT,
|
|
58
|
+
fetch_attempts INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
degraded INTEGER NOT NULL DEFAULT 0 CHECK (degraded IN (0, 1)),
|
|
60
|
+
long_clip INTEGER NOT NULL DEFAULT 0 CHECK (long_clip IN (0, 1)),
|
|
61
|
+
|
|
62
|
+
-- The artefact. `path` is relative to DATA_DIR, never absolute, so a restore to a
|
|
63
|
+
-- different mount point still works.
|
|
64
|
+
path TEXT NOT NULL,
|
|
65
|
+
sidecar_path TEXT,
|
|
66
|
+
size_bytes INTEGER,
|
|
67
|
+
sha256 TEXT,
|
|
68
|
+
|
|
69
|
+
-- Upload state. See the contract §3 for the machine, and INV-1 for the one rule
|
|
70
|
+
-- that matters most: PENDING, UPLOADING, RETRY_WAIT and FAILED_PERMANENT all hold
|
|
71
|
+
-- the local file.
|
|
72
|
+
upload_state TEXT NOT NULL DEFAULT 'PENDING'
|
|
73
|
+
CHECK (upload_state IN (
|
|
74
|
+
'PENDING', 'UPLOADING', 'RETRY_WAIT', 'UPLOADED',
|
|
75
|
+
'FAILED_PERMANENT', 'SKIPPED_BY_POLICY',
|
|
76
|
+
'DELETED_LOCAL', 'EXPIRED_CLOUD', 'FORCE_DELETED'
|
|
77
|
+
)),
|
|
78
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
next_attempt_at REAL,
|
|
80
|
+
lease_owner TEXT,
|
|
81
|
+
lease_expires_at REAL,
|
|
82
|
+
last_error_class TEXT CHECK (last_error_class IS NULL OR last_error_class IN (
|
|
83
|
+
'transient', 'auth', 'not_found', 'quota', 'checksum',
|
|
84
|
+
'unknown'
|
|
85
|
+
)),
|
|
86
|
+
-- Truncated to 2000 characters on write. Never contains credentials (INV-2).
|
|
87
|
+
last_error TEXT,
|
|
88
|
+
|
|
89
|
+
cloud_key TEXT,
|
|
90
|
+
cloud_etag TEXT,
|
|
91
|
+
cloud_size_bytes INTEGER,
|
|
92
|
+
uploaded_at REAL,
|
|
93
|
+
local_deleted_at REAL,
|
|
94
|
+
cloud_deleted_at REAL,
|
|
95
|
+
|
|
96
|
+
-- A pin protects Frigate's source footage until our copy is durable (INV-9).
|
|
97
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
|
|
98
|
+
pinned_at REAL,
|
|
99
|
+
|
|
100
|
+
materialised_at REAL,
|
|
101
|
+
created_at REAL NOT NULL,
|
|
102
|
+
updated_at REAL NOT NULL,
|
|
103
|
+
|
|
104
|
+
CHECK (last_error IS NULL OR length(last_error) <= 2000)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
-- Each index serves one named query. No speculative indexes: every one costs write
|
|
108
|
+
-- amplification on a table that is written on every clip.
|
|
109
|
+
CREATE INDEX clips_claim_query ON clips (upload_state, next_attempt_at, id);
|
|
110
|
+
CREATE INDEX clips_local_retention ON clips (upload_state, start_time);
|
|
111
|
+
CREATE INDEX clips_cloud_retention ON clips (upload_state, uploaded_at);
|
|
112
|
+
CREATE INDEX clips_camera_start ON clips (camera, start_time DESC);
|
|
113
|
+
CREATE INDEX clips_pin_reaper ON clips (pinned, pinned_at);
|
|
114
|
+
CREATE INDEX clips_by_checksum ON clips (sha256);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE upload_attempts (
|
|
117
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
clip_id INTEGER NOT NULL REFERENCES clips (id) ON DELETE CASCADE,
|
|
119
|
+
attempt_no INTEGER NOT NULL,
|
|
120
|
+
started_at REAL NOT NULL,
|
|
121
|
+
finished_at REAL,
|
|
122
|
+
outcome TEXT CHECK (outcome IS NULL OR outcome IN (
|
|
123
|
+
'success', 'transient_error', 'permanent_error', 'checksum_mismatch'
|
|
124
|
+
)),
|
|
125
|
+
http_status INTEGER,
|
|
126
|
+
bytes_sent INTEGER,
|
|
127
|
+
error_class TEXT,
|
|
128
|
+
error_message TEXT,
|
|
129
|
+
duration_ms INTEGER
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE sweep_state (
|
|
133
|
+
name TEXT PRIMARY KEY,
|
|
134
|
+
watermark REAL,
|
|
135
|
+
last_run_at REAL,
|
|
136
|
+
last_success_at REAL,
|
|
137
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
notes TEXT
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE pinned_events (
|
|
142
|
+
event_id TEXT PRIMARY KEY,
|
|
143
|
+
camera TEXT NOT NULL,
|
|
144
|
+
pinned_at REAL NOT NULL,
|
|
145
|
+
unpinned_at REAL,
|
|
146
|
+
reason TEXT NOT NULL CHECK (reason IN ('materialisation', 'manual')),
|
|
147
|
+
state TEXT NOT NULL CHECK (state IN ('held', 'released', 'expired', 'failed'))
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
CREATE INDEX pinned_events_open ON pinned_events (state, pinned_at);
|
|
151
|
+
|
|
152
|
+
CREATE TABLE component_health (
|
|
153
|
+
component TEXT PRIMARY KEY,
|
|
154
|
+
state TEXT NOT NULL CHECK (state IN (
|
|
155
|
+
'HEALTHY', 'DEGRADED', 'UNHEALTHY', 'UNKNOWN', 'DISABLED'
|
|
156
|
+
)),
|
|
157
|
+
detail TEXT,
|
|
158
|
+
version TEXT,
|
|
159
|
+
observed_at REAL NOT NULL
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE health_history (
|
|
163
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
164
|
+
subject TEXT NOT NULL,
|
|
165
|
+
state TEXT NOT NULL,
|
|
166
|
+
observed_at REAL NOT NULL
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
CREATE INDEX health_history_subject ON health_history (subject, observed_at DESC);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE reconciliation_findings (
|
|
172
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
173
|
+
run_at REAL NOT NULL,
|
|
174
|
+
kind TEXT NOT NULL CHECK (kind IN (
|
|
175
|
+
'orphan_object', 'dangling_row', 'checksum_mismatch',
|
|
176
|
+
'unexpected_local_file'
|
|
177
|
+
)),
|
|
178
|
+
subject TEXT NOT NULL,
|
|
179
|
+
detail TEXT,
|
|
180
|
+
resolved_at REAL
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE INDEX reconciliation_open ON reconciliation_findings (resolved_at, run_at);
|
|
184
|
+
|
|
185
|
+
CREATE TABLE system_events (
|
|
186
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
187
|
+
at REAL NOT NULL,
|
|
188
|
+
severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'error', 'critical')),
|
|
189
|
+
component TEXT NOT NULL,
|
|
190
|
+
-- A stable machine API: alerting rules reference these, so changing one is a
|
|
191
|
+
-- breaking change.
|
|
192
|
+
code TEXT NOT NULL,
|
|
193
|
+
message TEXT,
|
|
194
|
+
detail TEXT,
|
|
195
|
+
dedupe_key TEXT
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
CREATE INDEX system_events_recent ON system_events (at DESC);
|
|
199
|
+
CREATE INDEX system_events_dedupe ON system_events (dedupe_key, at DESC);
|