cctv-stack 0.3.1__tar.gz → 0.5.0__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.3.1 → cctv_stack-0.5.0}/.gitignore +10 -0
  2. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/PKG-INFO +13 -7
  3. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/README.md +12 -6
  4. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/pyproject.toml +3 -1
  5. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/__init__.py +1 -1
  6. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/db.py +106 -0
  7. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/health.py +39 -1
  8. cctv_stack-0.5.0/src/cctv/host.py +490 -0
  9. cctv_stack-0.5.0/src/cctv/host_cli.py +121 -0
  10. cctv_stack-0.5.0/src/cctv/migrations/0003_disk_samples.sql +16 -0
  11. cctv_stack-0.5.0/src/cctv/monitoring.py +332 -0
  12. cctv_stack-0.5.0/src/cctv/status.py +1164 -0
  13. cctv_stack-0.5.0/src/cctv/status_service.py +359 -0
  14. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/LICENSE +0 -0
  15. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/atomic.py +0 -0
  16. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/cleanup.py +0 -0
  17. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/clip.py +0 -0
  18. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/config.py +0 -0
  19. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/config_cli.py +0 -0
  20. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/dispatch.py +0 -0
  21. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/ffprobe.py +0 -0
  22. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/frigate.py +0 -0
  23. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/logging.py +0 -0
  24. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/materialise.py +0 -0
  25. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/migrations/0001_init.sql +0 -0
  26. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/migrations/0002_finding_kinds.sql +0 -0
  27. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/mqtt.py +0 -0
  28. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/paths.py +0 -0
  29. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/reviews.py +0 -0
  30. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/storage.py +0 -0
  31. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/sweep.py +0 -0
  32. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/timeutil.py +0 -0
  33. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/uploader.py +0 -0
  34. {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/worker.py +0 -0
@@ -5,6 +5,9 @@ __pycache__/
5
5
  build/
6
6
  dist/
7
7
 
8
+ # mkdocs output. Generated by `uv run mkdocs build`; the published copy is built by CI.
9
+ site/
10
+
8
11
  # Environments
9
12
  .venv/
10
13
  venv/
@@ -39,6 +42,13 @@ data/
39
42
  # is the site's file, not the repository's. `cameras.example.yml` is committed; this is not.
40
43
  config/cameras.yml
41
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
+ # Generated by install.sh from probes of this machine (lld/07 §5).
50
+ config/host.yml
51
+
42
52
  # Generated Frigate config, plus Frigate's own runtime state, because the directory is
43
53
  # mounted as its /config.
44
54
  #
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cctv-stack
3
- Version: 0.3.1
3
+ Version: 0.5.0
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,9 +252,9 @@ 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.3.0`.** Clips, B2 upload, local retention and health are
256
- implemented. Cloud retention, an aggregate status page and the one-command installer are
257
- not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
255
+ **Current release: `0.5.0`.** Clips, B2 upload, local retention, the aggregate status page
256
+ and alerting through Gatus/ntfy are implemented. Cloud retention and the one-command
257
+ installer are not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
258
258
 
259
259
  ---
260
260
 
@@ -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.3.0
284
+ docker pull ghcr.io/aravindmyd/cctv-stack:0.5.0
285
285
  ```
286
286
 
287
- Tags published per release: `:0.3.0`, `:0.3`, and the commit tag `:sha-<short>`.
287
+ Tags published per release: `:0.5.0`, `: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
 
@@ -297,10 +297,16 @@ A moving `:edge` tag follows `master`. If the registry package is private, run
297
297
  | `cctv-clip-worker` | Turn completed Frigate reviews into canonical clips |
298
298
  | `cctv-uploader` | Upload clips to B2 through the persisted retry state machine |
299
299
  | `cctv-cleanup` | Enforce local retention after a successful upload |
300
+ | `cctv-status` | Serve the aggregate status page (`:8084/status`), health and metrics |
301
+ | `cctv-host` | Detect this machine's host profile and Frigate's `shm_size` |
300
302
 
301
- The three services are long-running and configuration-driven; they take no command-line
303
+ The four services are long-running and configuration-driven; they take no command-line
302
304
  arguments. Run them under systemd, Docker, or Compose.
303
305
 
306
+ With Compose, the answer to *is my CCTV working?* is at
307
+ `http://<host>:8084/status`; the dashboard and history are at `http://<host>:3001` (Gatus),
308
+ and alerts arrive through ntfy at `http://<host>:8085`.
309
+
304
310
  ## Quick start
305
311
 
306
312
  The only required input is a `cameras.yml`. This minimal file is valid; `${VAR}` values
@@ -18,9 +18,9 @@ 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.3.0`.** Clips, B2 upload, local retention and health are
22
- implemented. Cloud retention, an aggregate status page and the one-command installer are
23
- not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
21
+ **Current release: `0.5.0`.** Clips, B2 upload, local retention, the aggregate status page
22
+ and alerting through Gatus/ntfy are implemented. Cloud retention and the one-command
23
+ installer are not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
24
24
 
25
25
  ---
26
26
 
@@ -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.3.0
50
+ docker pull ghcr.io/aravindmyd/cctv-stack:0.5.0
51
51
  ```
52
52
 
53
- Tags published per release: `:0.3.0`, `:0.3`, and the commit tag `:sha-<short>`.
53
+ Tags published per release: `:0.5.0`, `: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
 
@@ -63,10 +63,16 @@ A moving `:edge` tag follows `master`. If the registry package is private, run
63
63
  | `cctv-clip-worker` | Turn completed Frigate reviews into canonical clips |
64
64
  | `cctv-uploader` | Upload clips to B2 through the persisted retry state machine |
65
65
  | `cctv-cleanup` | Enforce local retention after a successful upload |
66
+ | `cctv-status` | Serve the aggregate status page (`:8084/status`), health and metrics |
67
+ | `cctv-host` | Detect this machine's host profile and Frigate's `shm_size` |
66
68
 
67
- The three services are long-running and configuration-driven; they take no command-line
69
+ The four services are long-running and configuration-driven; they take no command-line
68
70
  arguments. Run them under systemd, Docker, or Compose.
69
71
 
72
+ With Compose, the answer to *is my CCTV working?* is at
73
+ `http://<host>:8084/status`; the dashboard and history are at `http://<host>:3001` (Gatus),
74
+ and alerts arrive through ntfy at `http://<host>:8085`.
75
+
70
76
  ## Quick start
71
77
 
72
78
  The only required input is a `cameras.yml`. This minimal file is valid; `${VAR}` values
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctv-stack"
7
- version = "0.3.1"
7
+ version = "0.5.0"
8
8
  description = "A packaging-first self-hosted CCTV stack built on Frigate."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -74,6 +74,8 @@ cctv-clip-worker = "cctv.worker:main"
74
74
  cctv-config = "cctv.config_cli:main"
75
75
  cctv-uploader = "cctv.uploader:main"
76
76
  cctv-cleanup = "cctv.cleanup:main"
77
+ cctv-status = "cctv.status_service:main"
78
+ cctv-host = "cctv.host_cli:main"
77
79
 
78
80
  [project.urls]
79
81
  Homepage = "https://github.com/aravindmyd/cctv-stack"
@@ -16,4 +16,4 @@ from __future__ import annotations
16
16
 
17
17
  __all__ = ["__version__"]
18
18
 
19
- __version__ = "0.3.1"
19
+ __version__ = "0.5.0"
@@ -1476,3 +1476,109 @@ def overdue_local(conn: sqlite3.Connection, *, camera: str, cutoff: Instant) ->
1476
1476
  None if row["oldest"] is None else max(0.0, cutoff - float(row["oldest"]))
1477
1477
  ),
1478
1478
  )
1479
+
1480
+
1481
+ # ---------------------------------------------------------------------------------------
1482
+ # Status-collector reads. All read-only, all bounded, none of them a table scan on every
1483
+ # scrape: the collector polls these on its own cadence, and a status page that is expensive
1484
+ # to render is a status page that hides problems behind its own latency.
1485
+ # ---------------------------------------------------------------------------------------
1486
+
1487
+
1488
+ def last_clip_per_camera(conn: sqlite3.Connection) -> dict[str, Instant]:
1489
+ """The most recent clip start time for each camera that has ever produced one.
1490
+
1491
+ A camera absent from this mapping has never produced a clip. That is different from a
1492
+ camera whose last clip is old, and the status page must not conflate them: a camera added
1493
+ ten minutes ago is not "quiet since 1970".
1494
+ """
1495
+ return {
1496
+ str(row["camera"]): float(row["last"])
1497
+ for row in conn.execute("SELECT camera, MAX(start_time) AS last FROM clips GROUP BY camera")
1498
+ }
1499
+
1500
+
1501
+ def clip_start_times_since(
1502
+ conn: sqlite3.Connection, *, since: Instant, limit_per_camera: int = 500
1503
+ ) -> dict[str, list[Instant]]:
1504
+ """Clip start times per camera since `since`, newest first.
1505
+
1506
+ The silent-camera check (lld/06 §3.4) needs the *typical* gap between clips, which is a
1507
+ median over history rather than a count. One clip per minute for a busy doorbell would
1508
+ make an unbounded query grow with footage; `limit_per_camera` keeps the query's cost flat
1509
+ while leaving far more samples than a median needs.
1510
+ """
1511
+ out: dict[str, list[Instant]] = {}
1512
+ for row in conn.execute(
1513
+ "SELECT camera, start_time FROM ("
1514
+ " SELECT camera, start_time,"
1515
+ " ROW_NUMBER() OVER (PARTITION BY camera ORDER BY start_time DESC) AS n"
1516
+ " FROM clips WHERE start_time >= ?"
1517
+ ") WHERE n <= ? ORDER BY camera, start_time DESC",
1518
+ (since, limit_per_camera),
1519
+ ):
1520
+ out.setdefault(str(row["camera"]), []).append(float(row["start_time"]))
1521
+ return out
1522
+
1523
+
1524
+ @dataclass(frozen=True, slots=True)
1525
+ class DiskSample:
1526
+ at: Instant
1527
+ free_bytes: int
1528
+ clips_bytes: int
1529
+ media_bytes: int
1530
+
1531
+
1532
+ def record_disk_sample(
1533
+ conn: sqlite3.Connection,
1534
+ *,
1535
+ at: Instant,
1536
+ free_bytes: int,
1537
+ clips_bytes: int,
1538
+ media_bytes: int,
1539
+ ) -> None:
1540
+ """Upsert one sample. Keyed on `at`, so a retried collection cannot duplicate history."""
1541
+ with write_txn(conn):
1542
+ conn.execute(
1543
+ "INSERT OR REPLACE INTO disk_samples (at, free_bytes, clips_bytes, media_bytes) "
1544
+ "VALUES (?, ?, ?, ?)",
1545
+ (at, free_bytes, clips_bytes, media_bytes),
1546
+ )
1547
+
1548
+
1549
+ def disk_samples_since(conn: sqlite3.Connection, *, since: Instant) -> list[DiskSample]:
1550
+ """Samples from `since` onwards, oldest first. The storage projection's only input."""
1551
+ return [
1552
+ DiskSample(
1553
+ at=float(row["at"]),
1554
+ free_bytes=int(row["free_bytes"]),
1555
+ clips_bytes=int(row["clips_bytes"]),
1556
+ media_bytes=int(row["media_bytes"]),
1557
+ )
1558
+ for row in conn.execute(
1559
+ "SELECT at, free_bytes, clips_bytes, media_bytes FROM disk_samples "
1560
+ "WHERE at >= ? ORDER BY at",
1561
+ (since,),
1562
+ )
1563
+ ]
1564
+
1565
+
1566
+ def prune_disk_samples(conn: sqlite3.Connection, *, before: Instant) -> int:
1567
+ """Delete samples older than `before`. Returns how many rows went."""
1568
+ with write_txn(conn):
1569
+ cur = conn.execute("DELETE FROM disk_samples WHERE at < ?", (before,))
1570
+ return int(cur.rowcount or 0)
1571
+
1572
+
1573
+ def last_cleanup_run_at(conn: sqlite3.Connection) -> Instant | None:
1574
+ """When retention last completed a pass, from the row `cctv-cleanup` writes.
1575
+
1576
+ Read here rather than derived from `clips`, because "retention is running" is a fact only
1577
+ the cleanup service knows; a database with nothing to delete looks identical whether
1578
+ cleanup is working or dead.
1579
+ """
1580
+ row = conn.execute(
1581
+ "SELECT COALESCE(last_success_at, last_run_at) AS at FROM sweep_state "
1582
+ "WHERE name = 'cleanup_local'"
1583
+ ).fetchone()
1584
+ return None if row is None or row["at"] is None else float(row["at"])
@@ -19,6 +19,7 @@ Endpoints, per
19
19
  ```text
20
20
  GET /health JSON report; ?strict=1 makes DEGRADED/UNKNOWN return 503
21
21
  GET /metrics Prometheus text exposition
22
+ GET /status aggregate status (status-collector only): JSON, or HTML to a browser
22
23
  GET /admin/queue backlog summary (requires INTERNAL_API_TOKEN)
23
24
  POST /admin/retry requeue failed clips (requires INTERNAL_API_TOKEN and a reason)
24
25
  ```
@@ -76,11 +77,13 @@ CODES = frozenset(
76
77
  {
77
78
  "BACKLOG_AGING",
78
79
  "CAMERA_OFFLINE",
80
+ "CAMERA_QUIET",
79
81
  "CLEANUP_STALE",
80
82
  "CLIPS_OVERDUE_RETENTION",
81
83
  "CLIP_COVERAGE_LOW",
82
84
  "CLIP_FLUSH_PRESSURE",
83
85
  "CLIP_HAS_GAPS",
86
+ "COLLECTOR_STALE",
84
87
  "DB_INTEGRITY_FAILED",
85
88
  "DB_UNAVAILABLE",
86
89
  "DETECTION_FALLING_BEHIND",
@@ -304,6 +307,9 @@ class _Handler(BaseHTTPRequestHandler):
304
307
  if path.path == "/metrics":
305
308
  self._metrics()
306
309
  return
310
+ if path.path == "/status":
311
+ self._status()
312
+ return
307
313
  if path.path == "/admin/queue":
308
314
  self._admin_queue()
309
315
  return
@@ -326,6 +332,24 @@ class _Handler(BaseHTTPRequestHandler):
326
332
  gauges = self._config.gauges() if self._config.gauges else None
327
333
  self._write(200, render_metrics(report, gauges=gauges).encode("utf-8"), "text/plain")
328
334
 
335
+ def _status(self) -> None:
336
+ """Aggregate status: HTML for a browser, JSON for everything else.
337
+
338
+ Negotiated on `Accept` rather than a separate path, so a monitor and a human can be
339
+ given the same URL and neither has to know the other exists. A service that did not
340
+ mount the route (every service but `status-collector`) answers 404 rather than an
341
+ empty page, which is the honest thing to say.
342
+ """
343
+ if self._config.status is None:
344
+ self._json(HTTPStatus.NOT_FOUND, {"error": "no aggregate status on this service"})
345
+ return
346
+ wants_html = "text/html" in (self.headers.get("Accept") or "")
347
+ if wants_html and self._config.status_html is not None:
348
+ body = self._config.status_html().encode("utf-8")
349
+ self._write(200, body, "text/html; charset=utf-8")
350
+ return
351
+ self._json(200, dict(self._config.status()))
352
+
329
353
  def _admin_guard(self) -> bool:
330
354
  if self._config.admin is None or not self._config.token:
331
355
  self._json(
@@ -385,12 +409,16 @@ class HealthServer(ThreadingHTTPServer):
385
409
  admin: AdminRoutes | None = None,
386
410
  token: str | None = None,
387
411
  gauges: Callable[[], Mapping[str, float | int]] | None = None,
412
+ status: Callable[[], Mapping[str, Any]] | None = None,
413
+ status_html: Callable[[], str] | None = None,
388
414
  ) -> None:
389
415
  super().__init__(address, _Handler)
390
416
  self.snapshot = snapshot
391
417
  self.admin = admin
392
418
  self.token = token
393
419
  self.gauges = gauges
420
+ self.status = status
421
+ self.status_html = status_html
394
422
 
395
423
 
396
424
  def start_health_server(
@@ -401,13 +429,23 @@ def start_health_server(
401
429
  admin: AdminRoutes | None = None,
402
430
  token: str | None = None,
403
431
  gauges: Callable[[], Mapping[str, float | int]] | None = None,
432
+ status: Callable[[], Mapping[str, Any]] | None = None,
433
+ status_html: Callable[[], str] | None = None,
404
434
  ) -> tuple[HealthServer, threading.Thread]:
405
435
  """Start serving in a background thread. Returns the server and its thread.
406
436
 
407
437
  Port `0` means "disabled" — a deployment that does not want the surface pays nothing for
408
438
  it, and the caller logs that choice rather than leaving it implicit.
409
439
  """
410
- server = HealthServer((host, port), snapshot=snapshot, admin=admin, token=token, gauges=gauges)
440
+ server = HealthServer(
441
+ (host, port),
442
+ snapshot=snapshot,
443
+ admin=admin,
444
+ token=token,
445
+ gauges=gauges,
446
+ status=status,
447
+ status_html=status_html,
448
+ )
411
449
  thread = threading.Thread(target=server.serve_forever, name="health", daemon=True)
412
450
  thread.start()
413
451
  return server, thread