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.
Files changed (34) hide show
  1. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/.gitignore +0 -4
  2. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/PKG-INFO +4 -4
  3. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/README.md +3 -3
  4. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/pyproject.toml +1 -1
  5. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/__init__.py +1 -1
  6. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/monitoring.py +6 -3
  7. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/status.py +41 -4
  8. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/uploader.py +27 -3
  9. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/LICENSE +0 -0
  10. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/atomic.py +0 -0
  11. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/cleanup.py +0 -0
  12. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/clip.py +0 -0
  13. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/config.py +0 -0
  14. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/config_cli.py +0 -0
  15. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/db.py +0 -0
  16. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/dispatch.py +0 -0
  17. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/ffprobe.py +0 -0
  18. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/frigate.py +0 -0
  19. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/health.py +0 -0
  20. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/host.py +0 -0
  21. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/host_cli.py +0 -0
  22. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/logging.py +0 -0
  23. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/materialise.py +0 -0
  24. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/migrations/0001_init.sql +0 -0
  25. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/migrations/0002_finding_kinds.sql +0 -0
  26. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/migrations/0003_disk_samples.sql +0 -0
  27. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/mqtt.py +0 -0
  28. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/paths.py +0 -0
  29. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/reviews.py +0 -0
  30. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/status_service.py +0 -0
  31. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/storage.py +0 -0
  32. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/sweep.py +0 -0
  33. {cctv_stack-0.5.0 → cctv_stack-0.5.1}/src/cctv/timeutil.py +0 -0
  34. {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.0
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.0`.** Clips, B2 upload, local retention, the aggregate status page
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.0
284
+ docker pull ghcr.io/aravindmyd/cctv-stack:0.5.1
285
285
  ```
286
286
 
287
- Tags published per release: `:0.5.0`, `:0.5`, and the commit tag `:sha-<short>`.
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.0`.** Clips, B2 upload, local retention, the aggregate status page
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.0
50
+ docker pull ghcr.io/aravindmyd/cctv-stack:0.5.1
51
51
  ```
52
52
 
53
- Tags published per release: `:0.5.0`, `:0.5`, and the commit tag `:sha-<short>`.
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctv-stack"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "A packaging-first self-hosted CCTV stack built on Frigate."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -16,4 +16,4 @@ from __future__ import annotations
16
16
 
17
17
  __all__ = ["__version__"]
18
18
 
19
- __version__ = "0.5.0"
19
+ __version__ = "0.5.1"
@@ -137,7 +137,7 @@ def monitors_for(
137
137
  interval_s=collect_interval_s,
138
138
  )
139
139
  ]
140
- for name in ("clip-worker", "uploader", "cleanup"):
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 {health.HEALTHY, health.DISABLED}
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 them. Frigate and
70
- #: storage are not here: neither speaks our `/health` contract, and both are observed
71
- #: differently.
72
- COMPONENT_NAMES: tuple[str, ...] = ("clip-worker", "uploader", "cleanup")
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
- if result.verified_by != "sha256":
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 against a provider checksum. An unverified backup is not "
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