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/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The shared library for the CCTV stack.
|
|
2
|
+
|
|
3
|
+
Four services import this one package — clip-worker, uploader, cleanup, and
|
|
4
|
+
status-collector — and its job is to make the invariants *structural* rather than
|
|
5
|
+
aspirational. If there is literally one implementation of a rule, the rule cannot
|
|
6
|
+
drift between components.
|
|
7
|
+
|
|
8
|
+
See `docs/architecture/lld/00-overview.md` §2 for the module map and the invariants
|
|
9
|
+
each module enforces.
|
|
10
|
+
|
|
11
|
+
Nothing in this package reaches for the network at import time. It is tested without
|
|
12
|
+
Frigate, without Docker, and without B2.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__"]
|
|
18
|
+
|
|
19
|
+
__version__ = "0.3.0"
|
cctv/atomic.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Writing files so that a crash never leaves a half-written clip.
|
|
2
|
+
|
|
3
|
+
A clip appears in the canonical tree only by `rename`, which is atomic within a
|
|
4
|
+
filesystem. Everything else — the download, the remux — happens at a temporary path that
|
|
5
|
+
no other component looks at. This is what makes a killed clip-worker leave a `.tmp` file
|
|
6
|
+
to be reaped rather than a truncated `.mp4` that every later check will believe.
|
|
7
|
+
|
|
8
|
+
The ordering matters and is easy to get subtly wrong:
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
open tmp → write → flush → fsync(file) → close → rename → fsync(dir)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`fsync` on the file is not enough on its own. The rename itself lives in the directory
|
|
15
|
+
entry, so without the final `fsync` on the directory a power cut can leave the file
|
|
16
|
+
present with its old name — or absent entirely — even though its contents were durable.
|
|
17
|
+
|
|
18
|
+
[INV-4](../../../docs/architecture/invariants.md) puts all state under the host data
|
|
19
|
+
directory, and [INV-1](../../../docs/architecture/invariants.md) means an interrupted
|
|
20
|
+
materialisation is a retry rather than a loss, which only holds if a partial file cannot
|
|
21
|
+
be mistaken for a complete one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import BinaryIO
|
|
31
|
+
|
|
32
|
+
from .timeutil import Instant
|
|
33
|
+
from .timeutil import now as _now
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"TMP_MARKER",
|
|
37
|
+
"atomic_writer",
|
|
38
|
+
"discard",
|
|
39
|
+
"fsync_directory",
|
|
40
|
+
"is_tmp",
|
|
41
|
+
"promote",
|
|
42
|
+
"reap_tmp",
|
|
43
|
+
"tmp_path_for",
|
|
44
|
+
"write_atomic",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#: The documented temporary suffix. The pid is appended so that two workers fetching the
|
|
48
|
+
#: same clip — which the lease should prevent, but should is not cannot — do not write to
|
|
49
|
+
#: the same scratch file.
|
|
50
|
+
TMP_MARKER = ".tmp"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tmp_path_for(path: Path) -> Path:
|
|
54
|
+
return path.with_name(f"{path.name}{TMP_MARKER}-{os.getpid()}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_tmp(path: Path) -> bool:
|
|
58
|
+
return TMP_MARKER in path.name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fsync_directory(directory: Path) -> None:
|
|
62
|
+
"""Flush a directory entry, so a rename survives a power cut.
|
|
63
|
+
|
|
64
|
+
Best-effort by design: some filesystems and platforms refuse `O_DIRECTORY`, and
|
|
65
|
+
failing here would turn a working system into a broken one for a durability
|
|
66
|
+
improvement rather than a correctness requirement.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
70
|
+
except OSError:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
os.fsync(fd)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
finally:
|
|
77
|
+
os.close(fd)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def atomic_writer(path: Path, *, mode: int = 0o644) -> Iterator[BinaryIO]:
|
|
82
|
+
"""Yield a temporary file that becomes `path` on a clean exit.
|
|
83
|
+
|
|
84
|
+
Streaming rather than buffering in memory, because a long review can be hundreds of
|
|
85
|
+
megabytes and the fetch should never have to be held whole.
|
|
86
|
+
|
|
87
|
+
On any exception the temporary file is removed, so a failed fetch leaves nothing
|
|
88
|
+
behind for the reaper to find.
|
|
89
|
+
"""
|
|
90
|
+
path = Path(path)
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
tmp = tmp_path_for(path)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# A `with` block rather than a bare handle. The file must be closed *before* the
|
|
96
|
+
# rename, and a bare handle turns that ordering into something a reader has to
|
|
97
|
+
# verify rather than something the code states.
|
|
98
|
+
with open(tmp, "wb") as handle:
|
|
99
|
+
yield handle
|
|
100
|
+
handle.flush()
|
|
101
|
+
os.fsync(handle.fileno())
|
|
102
|
+
except BaseException:
|
|
103
|
+
tmp.unlink(missing_ok=True)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
os.chmod(tmp, mode)
|
|
107
|
+
os.replace(tmp, path)
|
|
108
|
+
fsync_directory(path.parent)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def write_atomic(path: Path, data: bytes, *, mode: int = 0o644) -> None:
|
|
112
|
+
"""The whole-buffer form, for sidecars and other small files."""
|
|
113
|
+
with atomic_writer(path, mode=mode) as handle:
|
|
114
|
+
handle.write(data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def promote(scratch: Path, target: Path, *, mode: int = 0o644) -> None:
|
|
118
|
+
"""Publish a finished scratch file as `target`.
|
|
119
|
+
|
|
120
|
+
Exists because a clip may be fetched more than once and the *best* attempt wins, not
|
|
121
|
+
the last one ([INV-14](../../../docs/architecture/invariants.md)). Retrying straight
|
|
122
|
+
onto the canonical path would let a worse second attempt overwrite a better first one,
|
|
123
|
+
which is the opposite of retry-then-keep-best. So each attempt lands on its own scratch
|
|
124
|
+
path and only the winner is promoted — and it still appears in the tree solely via a
|
|
125
|
+
rename, never by being written there.
|
|
126
|
+
"""
|
|
127
|
+
scratch, target = Path(scratch), Path(target)
|
|
128
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
os.chmod(scratch, mode)
|
|
130
|
+
os.replace(scratch, target)
|
|
131
|
+
fsync_directory(target.parent)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def discard(path: Path) -> bool:
|
|
135
|
+
"""Remove a scratch file. Returns whether it is now gone.
|
|
136
|
+
|
|
137
|
+
Deliberately forgiving: a scratch file that another process already removed is a
|
|
138
|
+
success, not an error.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
Path(path).unlink()
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
return True
|
|
144
|
+
except OSError:
|
|
145
|
+
return False
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reap_tmp(directory: Path, *, max_age_s: float, now: Instant | None = None) -> list[Path]:
|
|
150
|
+
"""Delete stale temporary files, and return what was removed.
|
|
151
|
+
|
|
152
|
+
**Age-gated, and that is the point.** A temporary file that appeared seconds ago may
|
|
153
|
+
belong to a fetch happening right now; deleting on sight would corrupt work in
|
|
154
|
+
progress. `max_age_s` must therefore exceed the longest plausible fetch — which for a
|
|
155
|
+
long review is minutes, not seconds, so the reaper's default is generous rather than
|
|
156
|
+
tidy.
|
|
157
|
+
|
|
158
|
+
Unlink failures are swallowed: on a shared filesystem another worker may have got there
|
|
159
|
+
first, and that is a success, not an error.
|
|
160
|
+
"""
|
|
161
|
+
now = _now() if now is None else now
|
|
162
|
+
if max_age_s <= 0:
|
|
163
|
+
raise ValueError("max_age_s must be positive, or the reaper races live fetches.")
|
|
164
|
+
|
|
165
|
+
removed: list[Path] = []
|
|
166
|
+
if not directory.is_dir():
|
|
167
|
+
return removed
|
|
168
|
+
|
|
169
|
+
for candidate in directory.rglob(f"*{TMP_MARKER}-*"):
|
|
170
|
+
if not candidate.is_file():
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
age = now - candidate.stat().st_mtime
|
|
174
|
+
except OSError:
|
|
175
|
+
continue
|
|
176
|
+
if age < max_age_s:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
candidate.unlink()
|
|
180
|
+
except OSError:
|
|
181
|
+
continue
|
|
182
|
+
removed.append(candidate)
|
|
183
|
+
|
|
184
|
+
return removed
|