cctv-stack 0.5.0__tar.gz → 0.5.1__tar.gz
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_stack-0.5.0 → cctv_stack-0.5.1}/.gitignore +0 -4
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/PKG-INFO +4 -4
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/README.md +3 -3
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/pyproject.toml +1 -1
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/__init__.py +1 -1
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/monitoring.py +6 -3
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/status.py +41 -4
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/uploader.py +27 -3
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/LICENSE +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/atomic.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/cleanup.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/clip.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/config.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/config_cli.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/db.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/dispatch.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/ffprobe.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/frigate.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/health.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/host.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/host_cli.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/logging.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/materialise.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/migrations/0001_init.sql +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/migrations/0002_finding_kinds.sql +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/migrations/0003_disk_samples.sql +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/mqtt.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/paths.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/reviews.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/status_service.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/storage.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/sweep.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/timeutil.py +0 -0
- {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/worker.py +0 -0
|
@@ -42,10 +42,6 @@ data/
|
|
|
42
42
|
# is the site's file, not the repository's. `cameras.example.yml` is committed; this is not.
|
|
43
43
|
config/cameras.yml
|
|
44
44
|
|
|
45
|
-
# The generated monitoring config. It carries the heartbeat token and is derived from
|
|
46
|
-
# cameras.yml at start-up, so editing it by hand is wasted work as well.
|
|
47
|
-
config/gatus/
|
|
48
|
-
|
|
49
45
|
# Generated by install.sh from probes of this machine (lld/07 §5).
|
|
50
46
|
config/host.yml
|
|
51
47
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cctv-stack
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: A packaging-first self-hosted CCTV stack built on Frigate.
|
|
5
5
|
Project-URL: Homepage, https://github.com/aravindmyd/cctv-stack
|
|
6
6
|
Project-URL: Repository, https://github.com/aravindmyd/cctv-stack
|
|
@@ -252,7 +252,7 @@ adds the four pieces it leaves to the operator:
|
|
|
252
252
|
|
|
253
253
|
It is not a replacement for Frigate. It is the missing layer around it.
|
|
254
254
|
|
|
255
|
-
**Current release: `0.5.
|
|
255
|
+
**Current release: `0.5.1`.** Clips, B2 upload, local retention, the aggregate status page
|
|
256
256
|
and alerting through Gatus/ntfy are implemented. Cloud retention and the one-command
|
|
257
257
|
installer are not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
|
|
258
258
|
|
|
@@ -281,10 +281,10 @@ pip install "cctv-stack[mqtt,storage]"
|
|
|
281
281
|
### Container image
|
|
282
282
|
|
|
283
283
|
```bash
|
|
284
|
-
docker pull ghcr.io/aravindmyd/cctv-stack:0.5.
|
|
284
|
+
docker pull ghcr.io/aravindmyd/cctv-stack:0.5.1
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
-
Tags published per release: `:0.5.
|
|
287
|
+
Tags published per release: `:0.5.1`, `:0.5`, and the commit tag `:sha-<short>`.
|
|
288
288
|
A moving `:edge` tag follows `master`. If the registry package is private, run
|
|
289
289
|
`docker login ghcr.io` first. The image includes `ffmpeg`/`ffprobe`.
|
|
290
290
|
|
|
@@ -18,7 +18,7 @@ adds the four pieces it leaves to the operator:
|
|
|
18
18
|
|
|
19
19
|
It is not a replacement for Frigate. It is the missing layer around it.
|
|
20
20
|
|
|
21
|
-
**Current release: `0.5.
|
|
21
|
+
**Current release: `0.5.1`.** Clips, B2 upload, local retention, the aggregate status page
|
|
22
22
|
and alerting through Gatus/ntfy are implemented. Cloud retention and the one-command
|
|
23
23
|
installer are not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
|
|
24
24
|
|
|
@@ -47,10 +47,10 @@ pip install "cctv-stack[mqtt,storage]"
|
|
|
47
47
|
### Container image
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
docker pull ghcr.io/aravindmyd/cctv-stack:0.5.
|
|
50
|
+
docker pull ghcr.io/aravindmyd/cctv-stack:0.5.1
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Tags published per release: `:0.5.
|
|
53
|
+
Tags published per release: `:0.5.1`, `:0.5`, and the commit tag `:sha-<short>`.
|
|
54
54
|
A moving `:edge` tag follows `master`. If the registry package is private, run
|
|
55
55
|
`docker login ghcr.io` first. The image includes `ffmpeg`/`ffprobe`.
|
|
56
56
|
|
|
@@ -137,7 +137,7 @@ def monitors_for(
|
|
|
137
137
|
interval_s=collect_interval_s,
|
|
138
138
|
)
|
|
139
139
|
]
|
|
140
|
-
for name in ("
|
|
140
|
+
for name in ("uploader", "cleanup"):
|
|
141
141
|
url = component_urls.get(name)
|
|
142
142
|
if url:
|
|
143
143
|
probes.append(
|
|
@@ -203,7 +203,7 @@ class GatusAdapter:
|
|
|
203
203
|
ingests = [monitor for monitor in monitors if isinstance(monitor, IngestMonitor)]
|
|
204
204
|
|
|
205
205
|
config: dict[str, Any] = {
|
|
206
|
-
"storage": {"type": "sqlite", "path": "/data/gatus.db"},
|
|
206
|
+
"storage": {"type": "sqlite", "path": "/gatus-data/gatus.db"},
|
|
207
207
|
"alerting": {
|
|
208
208
|
"ntfy": {
|
|
209
209
|
"url": self.ntfy_url,
|
|
@@ -281,7 +281,10 @@ def heartbeats_for(summary: StatusSummary) -> dict[str, tuple[bool, str]]:
|
|
|
281
281
|
)
|
|
282
282
|
|
|
283
283
|
cleanup = summary.component("cleanup")
|
|
284
|
-
retention_ok = cleanup is not None and cleanup.state in {
|
|
284
|
+
retention_ok = cleanup is not None and cleanup.state in {
|
|
285
|
+
health.HEALTHY,
|
|
286
|
+
health.DISABLED,
|
|
287
|
+
}
|
|
285
288
|
out[heartbeat_key("retention")] = (
|
|
286
289
|
retention_ok,
|
|
287
290
|
"unobserved" if cleanup is None else cleanup.state,
|
|
@@ -66,10 +66,14 @@ logger = logging.getLogger(__name__)
|
|
|
66
66
|
#: The collector's own component name, in logs, health and `system_events`.
|
|
67
67
|
COMPONENT = "status-collector"
|
|
68
68
|
|
|
69
|
-
#: Everything the collector expects to hear from, in the order the page lists
|
|
70
|
-
#: storage are not here: neither speaks our `/health` contract, and both are
|
|
71
|
-
#: differently.
|
|
72
|
-
|
|
69
|
+
#: Everything the collector expects to hear from over HTTP, in the order the page lists it.
|
|
70
|
+
#: Frigate and storage are not here: neither speaks our `/health` contract, and both are
|
|
71
|
+
#: observed differently. **`clip-worker` is not here either** — it has no health endpoint by
|
|
72
|
+
#: design (`docs/health.md`): it keeps no state beyond the database, so there is nothing for
|
|
73
|
+
#: it to report. Probing a URL that does not exist made a healthy stack look `UNKNOWN`
|
|
74
|
+
#: forever, which is the opposite of what a status page is for. Its contribution is derived
|
|
75
|
+
#: from the clip history below.
|
|
76
|
+
COMPONENT_NAMES: tuple[str, ...] = ("uploader", "cleanup")
|
|
73
77
|
|
|
74
78
|
#: Camera states. Separate from the component states on purpose: a camera is `ONLINE`,
|
|
75
79
|
#: `OFFLINE` or `QUIET`, and calling it `HEALTHY` would invite somebody to alert on the wrong
|
|
@@ -485,6 +489,11 @@ def answer(summary: StatusSummary) -> str:
|
|
|
485
489
|
else f"Recording is working. Cloud backup stalled {_human_age(stale)} ago."
|
|
486
490
|
)
|
|
487
491
|
|
|
492
|
+
if summary.cameras_quiet:
|
|
493
|
+
names = ", ".join(c.name for c in summary.cameras_quiet)
|
|
494
|
+
verb = "has" if len(summary.cameras_quiet) == 1 else "have"
|
|
495
|
+
return f"Recording is working, but {names} {verb} produced no clips recently."
|
|
496
|
+
|
|
488
497
|
worker = summary.component("clip-worker")
|
|
489
498
|
if worker is not None and worker.state in {health.UNHEALTHY, health.UNKNOWN}:
|
|
490
499
|
return "Cameras are recording, but clips are not being created."
|
|
@@ -663,6 +672,33 @@ def camera_status(
|
|
|
663
672
|
)
|
|
664
673
|
|
|
665
674
|
|
|
675
|
+
def _clip_worker_component(cameras: tuple[CameraStatus, ...]) -> ComponentStatus:
|
|
676
|
+
"""Clip production, judged from the database rather than from an endpoint it lacks.
|
|
677
|
+
|
|
678
|
+
A fresh install with no clips is healthy: there is nothing to judge, and the documented
|
|
679
|
+
silent-camera rule exempts cameras without history. Once a camera *has* produced clips,
|
|
680
|
+
"every camera with history has gone QUIET" is what a broken clip-worker looks like from
|
|
681
|
+
outside — and it is also what a stopped camera looks like, which the camera's own state
|
|
682
|
+
already reports.
|
|
683
|
+
"""
|
|
684
|
+
with_history = [camera for camera in cameras if camera.last_clip_at is not None]
|
|
685
|
+
if not with_history:
|
|
686
|
+
return ComponentStatus(name="clip-worker", state=health.HEALTHY, summary="no clips yet")
|
|
687
|
+
if all(camera.state == QUIET for camera in with_history):
|
|
688
|
+
return ComponentStatus(
|
|
689
|
+
name="clip-worker",
|
|
690
|
+
state=health.DEGRADED,
|
|
691
|
+
code="CAMERA_QUIET",
|
|
692
|
+
summary="no clips are being produced",
|
|
693
|
+
)
|
|
694
|
+
producing = sum(1 for camera in with_history if camera.state == ONLINE)
|
|
695
|
+
return ComponentStatus(
|
|
696
|
+
name="clip-worker",
|
|
697
|
+
state=health.HEALTHY,
|
|
698
|
+
summary=f"{producing} camera(s) producing clips",
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
666
702
|
def _retention_days(config: CamerasConfig, *, key: str) -> int:
|
|
667
703
|
"""The strictest retention window across the cameras, for the page's one-line summary.
|
|
668
704
|
|
|
@@ -927,6 +963,7 @@ class Collector:
|
|
|
927
963
|
for name in sorted(self.config.enabled_cameras())
|
|
928
964
|
)
|
|
929
965
|
self._track_offline(cameras, now=now, stats=frigate)
|
|
966
|
+
components.append(_clip_worker_component(cameras))
|
|
930
967
|
|
|
931
968
|
return StatusSummary(
|
|
932
969
|
site=self.config.site.location,
|
|
@@ -392,6 +392,7 @@ class Uploader:
|
|
|
392
392
|
self._rng = rng
|
|
393
393
|
self._lock = threading.Lock()
|
|
394
394
|
self._unverified = 0
|
|
395
|
+
self._size_verified = 0
|
|
395
396
|
self._uploaded = 0
|
|
396
397
|
self._failed_permanent = 0
|
|
397
398
|
|
|
@@ -399,9 +400,15 @@ class Uploader:
|
|
|
399
400
|
|
|
400
401
|
@property
|
|
401
402
|
def unverified_uploads(self) -> int:
|
|
403
|
+
"""Uploads with no provider confirmation at all — the only case that degrades."""
|
|
402
404
|
with self._lock:
|
|
403
405
|
return self._unverified
|
|
404
406
|
|
|
407
|
+
@property
|
|
408
|
+
def size_verified_uploads(self) -> int:
|
|
409
|
+
with self._lock:
|
|
410
|
+
return self._size_verified
|
|
411
|
+
|
|
405
412
|
@property
|
|
406
413
|
def uploaded_uploads(self) -> int:
|
|
407
414
|
with self._lock:
|
|
@@ -517,8 +524,14 @@ class Uploader:
|
|
|
517
524
|
|
|
518
525
|
with self._lock:
|
|
519
526
|
self._uploaded += 1
|
|
520
|
-
|
|
527
|
+
# `size` is not a failure. Multipart uploads return a composite digest, so the
|
|
528
|
+
# adapter compares size plus our own sha256 metadata instead — documented in
|
|
529
|
+
# `cloud-object-layout` §4.2 and expected above 100 MB. Only `none`, where no
|
|
530
|
+
# confirmation exists at all, is the condition worth degrading over.
|
|
531
|
+
if result.verified_by == "none":
|
|
521
532
|
self._unverified += 1
|
|
533
|
+
elif result.verified_by == "size":
|
|
534
|
+
self._size_verified += 1
|
|
522
535
|
|
|
523
536
|
logger.info(
|
|
524
537
|
"uploaded clip",
|
|
@@ -910,11 +923,22 @@ class UploaderHealth:
|
|
|
910
923
|
code="UPLOAD_UNVERIFIED",
|
|
911
924
|
detail=(
|
|
912
925
|
f"{self._uploader.unverified_uploads} upload(s) since start could not "
|
|
913
|
-
f"be verified
|
|
914
|
-
f"a backup"
|
|
926
|
+
f"be verified at all. An unverified backup is not a backup"
|
|
915
927
|
),
|
|
916
928
|
)
|
|
917
929
|
)
|
|
930
|
+
else:
|
|
931
|
+
# Reported rather than hidden: size verification is the documented outcome for
|
|
932
|
+
# multipart uploads, but the operator should still be able to see it happened.
|
|
933
|
+
size_verified = self._uploader.size_verified_uploads
|
|
934
|
+
detail = (
|
|
935
|
+
"all verified by provider checksum"
|
|
936
|
+
if size_verified == 0
|
|
937
|
+
else f"{size_verified} multipart upload(s) verified by size and local sha256"
|
|
938
|
+
)
|
|
939
|
+
checks.append(
|
|
940
|
+
health.Check(name="upload_verification", state=health.HEALTHY, detail=detail)
|
|
941
|
+
)
|
|
918
942
|
return checks
|
|
919
943
|
|
|
920
944
|
def _backlog_age_check(self, queue: db.QueueSummary) -> health.Check:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|