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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Migration 0002 — widen reconciliation_findings.kind.
|
|
2
|
+
--
|
|
3
|
+
-- `lld/05` §5.1 documents six finding kinds. Migration 0001's CHECK accepted four: it was
|
|
4
|
+
-- missing `missing_local_file`, which J1 records when a row points at a file that is already
|
|
5
|
+
-- gone, and `key_mismatch`, J3's check that `cloud_key` still equals the derived key. J1
|
|
6
|
+
-- cannot be implemented faithfully without the first, and the second is the same omission.
|
|
7
|
+
--
|
|
8
|
+
-- SQLite cannot alter a CHECK constraint, so the table is rebuilt. That is safe here because
|
|
9
|
+
-- the table holds only findings: nothing references it, and it has no foreign keys. The
|
|
10
|
+
-- alternative — loosening the constraint, or dropping it — would trade a schema guarantee for
|
|
11
|
+
-- convenience: the CHECK is what stops a typo creating a finding that no query will ever find
|
|
12
|
+
-- again.
|
|
13
|
+
--
|
|
14
|
+
-- Forward-only, per INV-8. There is no down migration; a mistake here is corrected by 0003.
|
|
15
|
+
|
|
16
|
+
CREATE TABLE reconciliation_findings_new (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
run_at REAL NOT NULL,
|
|
19
|
+
kind TEXT NOT NULL CHECK (kind IN (
|
|
20
|
+
'orphan_object', 'dangling_row', 'checksum_mismatch',
|
|
21
|
+
'unexpected_local_file', 'missing_local_file', 'key_mismatch'
|
|
22
|
+
)),
|
|
23
|
+
subject TEXT NOT NULL,
|
|
24
|
+
detail TEXT,
|
|
25
|
+
resolved_at REAL
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
INSERT INTO reconciliation_findings_new (id, run_at, kind, subject, detail, resolved_at)
|
|
29
|
+
SELECT id, run_at, kind, subject, detail, resolved_at FROM reconciliation_findings;
|
|
30
|
+
|
|
31
|
+
DROP TABLE reconciliation_findings;
|
|
32
|
+
|
|
33
|
+
ALTER TABLE reconciliation_findings_new RENAME TO reconciliation_findings;
|
|
34
|
+
|
|
35
|
+
CREATE INDEX reconciliation_open ON reconciliation_findings (resolved_at, run_at);
|
cctv/mqtt.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The paho-mqtt adapter — the only part of the pipeline that needs a broker.
|
|
2
|
+
|
|
3
|
+
Deliberately thin. *What* a review means lives in [`cctv.reviews`][cctv.reviews] and *when*
|
|
4
|
+
it gets worked on lives in [`cctv.dispatch`][cctv.dispatch], so this module only has to do
|
|
5
|
+
one thing: turn a socket message into a call to
|
|
6
|
+
[`ReviewDispatcher.dispatch`][cctv.dispatch.ReviewDispatcher.dispatch]. It is also the one
|
|
7
|
+
layer that cannot be unit-tested without a broker, which is a good reason to keep it small
|
|
8
|
+
enough to read in one sitting.
|
|
9
|
+
|
|
10
|
+
Two decisions worth naming:
|
|
11
|
+
|
|
12
|
+
**`paho` is imported inside [`start`][cctv.mqtt.ReviewSubscriber.start], not at module
|
|
13
|
+
import.** A deployment that runs sweep-only — no broker at all — must not need the
|
|
14
|
+
dependency installed. Importing at the top would make an optional component mandatory,
|
|
15
|
+
which is the kind of coupling that is invisible until someone builds a minimal image.
|
|
16
|
+
|
|
17
|
+
**`connect_async`, not `connect`.** `docker compose up` starts containers in whatever order
|
|
18
|
+
it likes, so the broker is frequently not listening yet. A synchronous connect would make
|
|
19
|
+
that a crash loop that resolves itself only because of `restart: unless-stopped`, and a
|
|
20
|
+
service that depends on being restarted to become healthy is a service whose first failure
|
|
21
|
+
looks like a real one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .dispatch import ReviewDispatcher
|
|
32
|
+
|
|
33
|
+
__all__ = ["MqttUnavailable", "ReviewSubscriber"]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MqttUnavailable(RuntimeError):
|
|
39
|
+
"""`paho-mqtt` is not installed, so no subscription can be made."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ReviewSubscriber:
|
|
44
|
+
"""Subscribes to `frigate/reviews` and hands every message to the dispatcher."""
|
|
45
|
+
|
|
46
|
+
dispatcher: ReviewDispatcher
|
|
47
|
+
host: str = "mosquitto"
|
|
48
|
+
port: int = 1883
|
|
49
|
+
topic_prefix: str = "frigate"
|
|
50
|
+
client_id: str = "cctv-clip-worker"
|
|
51
|
+
keepalive_s: int = 60
|
|
52
|
+
username: str | None = None
|
|
53
|
+
password: str | None = None
|
|
54
|
+
#: Counted rather than only logged. A message that is not JSON is a message nothing
|
|
55
|
+
#: downstream will ever see, and a discard that exists only as a log line is a discard
|
|
56
|
+
#: nobody can alert on.
|
|
57
|
+
malformed: int = field(default=0, init=False)
|
|
58
|
+
_client: Any = field(default=None, init=False, repr=False)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def topic(self) -> str:
|
|
62
|
+
return f"{self.topic_prefix}/reviews"
|
|
63
|
+
|
|
64
|
+
# -----------------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def start(self) -> None:
|
|
67
|
+
"""Connect and begin consuming. Returns immediately; paho runs its own thread."""
|
|
68
|
+
try:
|
|
69
|
+
import paho.mqtt.client as mqtt
|
|
70
|
+
except ImportError as exc: # pragma: no cover - depends on install extras
|
|
71
|
+
raise MqttUnavailable(
|
|
72
|
+
"paho-mqtt is not installed, so no broker subscription is possible. "
|
|
73
|
+
"Install the `mqtt` extra, or run sweep-only by leaving CCTV_MQTT_HOST "
|
|
74
|
+
"unset."
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.client_id)
|
|
78
|
+
client.on_connect = self._on_connect
|
|
79
|
+
client.on_message = self._on_message
|
|
80
|
+
client.on_disconnect = self._on_disconnect
|
|
81
|
+
|
|
82
|
+
if self.username is not None:
|
|
83
|
+
client.username_pw_set(self.username, self.password)
|
|
84
|
+
|
|
85
|
+
# Bounded backoff: a broker that is down should not be hammered, and one that comes
|
|
86
|
+
# back should be picked up within seconds rather than minutes.
|
|
87
|
+
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
|
88
|
+
|
|
89
|
+
client.connect_async(self.host, self.port, keepalive=self.keepalive_s)
|
|
90
|
+
client.loop_start()
|
|
91
|
+
self._client = client
|
|
92
|
+
|
|
93
|
+
logger.info("mqtt subscribing", extra={"topic": self.topic, "host": self.host})
|
|
94
|
+
|
|
95
|
+
def stop(self) -> None:
|
|
96
|
+
client = self._client
|
|
97
|
+
self._client = None
|
|
98
|
+
if client is None:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
client.disconnect()
|
|
102
|
+
client.loop_stop()
|
|
103
|
+
except Exception:
|
|
104
|
+
# Shutdown must not raise: this runs in a `finally`, and an exception here
|
|
105
|
+
# would replace whatever real error was already propagating.
|
|
106
|
+
logger.debug("mqtt shutdown raised; ignoring", exc_info=True)
|
|
107
|
+
|
|
108
|
+
# -----------------------------------------------------------------------------------
|
|
109
|
+
# Callbacks. These run on paho's network thread: they must not block and must not raise.
|
|
110
|
+
# -----------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _on_connect(
|
|
113
|
+
self,
|
|
114
|
+
client: Any,
|
|
115
|
+
userdata: Any,
|
|
116
|
+
flags: Any,
|
|
117
|
+
reason_code: Any,
|
|
118
|
+
properties: Any = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
# `is_failure` exists on paho 2.x reason codes; the `!= 0` fallback keeps this
|
|
121
|
+
# working if a plain int arrives instead.
|
|
122
|
+
failed = getattr(reason_code, "is_failure", reason_code != 0)
|
|
123
|
+
if failed:
|
|
124
|
+
logger.error("mqtt connection refused", extra={"reason": str(reason_code)})
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Subscribing here, not in `start()`, is what makes a *re*connection work. paho
|
|
128
|
+
# re-establishes the transport by itself, but subscriptions live on the session,
|
|
129
|
+
# which a reconnect does not restore unless the broker was told to keep it.
|
|
130
|
+
client.subscribe(self.topic, qos=0)
|
|
131
|
+
logger.info("mqtt connected", extra={"topic": self.topic, "host": self.host})
|
|
132
|
+
|
|
133
|
+
def _on_disconnect(
|
|
134
|
+
self,
|
|
135
|
+
client: Any,
|
|
136
|
+
userdata: Any,
|
|
137
|
+
flags: Any,
|
|
138
|
+
reason_code: Any = None,
|
|
139
|
+
properties: Any = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
# Not an error: paho reconnects on its own. Logged because a broker that flaps is
|
|
142
|
+
# a real problem, and the only evidence of it is this line repeating.
|
|
143
|
+
logger.warning("mqtt disconnected", extra={"reason": str(reason_code)})
|
|
144
|
+
|
|
145
|
+
def _on_message(self, client: Any, userdata: Any, message: Any) -> None:
|
|
146
|
+
try:
|
|
147
|
+
raw = message.payload.decode("utf-8", "replace")
|
|
148
|
+
except Exception:
|
|
149
|
+
# An exception escaping a paho callback is swallowed by the network loop, so
|
|
150
|
+
# this is not defensiveness for its own sake — it is the only place the failure
|
|
151
|
+
# can be observed at all.
|
|
152
|
+
self.malformed += 1
|
|
153
|
+
logger.warning("mqtt message could not be decoded", extra={"topic": self.topic})
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
payload = json.loads(raw)
|
|
158
|
+
except ValueError:
|
|
159
|
+
self.malformed += 1
|
|
160
|
+
logger.warning("mqtt message was not JSON", extra={"topic": self.topic})
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if not isinstance(payload, dict):
|
|
164
|
+
self.malformed += 1
|
|
165
|
+
logger.warning("mqtt message was not an object", extra={"topic": self.topic})
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# `dispatch` never raises and never blocks: it parses, enqueues, and returns. Doing
|
|
169
|
+
# the work here would stall the network loop for the tens of seconds a fetch takes,
|
|
170
|
+
# and the messages lost during that window are lost silently.
|
|
171
|
+
self.dispatcher.dispatch(payload)
|
cctv/paths.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""The single clip-path function.
|
|
2
|
+
|
|
3
|
+
Every component that needs a clip's path calls this module. Nothing re-implements it —
|
|
4
|
+
because the moment cleanup and clip-worker disagree about a path, cleanup stops being
|
|
5
|
+
able to find clips, and it fails *silently*, by not deleting. That is the failure
|
|
6
|
+
[INV-5](../../../docs/architecture/invariants.md) exists to prevent, which is why
|
|
7
|
+
[ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md) puts this in one place.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
local: data/clips/<camera>/YYYY/MM/DD/HH-MM-SS.mp4
|
|
11
|
+
cloud: <location>/clips/<camera>/YYYY/MM/DD/HH-MM-SS.mp4
|
|
12
|
+
sidecar: <camera>/YYYY/MM/DD/HH-MM-SS.mp4.json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What "pure function" means here, and what it does not
|
|
16
|
+
|
|
17
|
+
The path is derived from `camera`, the **review** start instant, and a timezone — never
|
|
18
|
+
from wall-clock-at-write-time and never from a counter. So materialisation is idempotent:
|
|
19
|
+
run it twice, get the same path.
|
|
20
|
+
|
|
21
|
+
Two things are worth being precise about, because both are easy to get wrong:
|
|
22
|
+
|
|
23
|
+
**The path is forward-derivable but not invertible.** Given the components you cannot
|
|
24
|
+
recover the instant, for two independent reasons: the timezone is not in the path, and
|
|
25
|
+
during a DST fallback two distinct instants render to the *same* components. This is
|
|
26
|
+
exactly why the database stores the instant rather than re-deriving it from filenames.
|
|
27
|
+
|
|
28
|
+
**A path can be taken, and that is not knowable from the arguments.** Two reviews on one
|
|
29
|
+
camera can start in the same second. So there is no single "the" path function: there is a
|
|
30
|
+
*primary* candidate and a *colliding* candidate, both pure, and the writer tries them in
|
|
31
|
+
order ([ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md)). The colliding form
|
|
32
|
+
appends a deterministic tail of the review id — deterministic being the whole point, so
|
|
33
|
+
the collision is still a pure function of the metadata.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import datetime as _dt
|
|
39
|
+
import hashlib
|
|
40
|
+
import re
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import PurePosixPath
|
|
43
|
+
from zoneinfo import ZoneInfo
|
|
44
|
+
|
|
45
|
+
from .timeutil import Instant, render
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"CLIP_EXT",
|
|
49
|
+
"CLOUD_CLIPS_PREFIX",
|
|
50
|
+
"COLLISION_SUFFIX_LEN",
|
|
51
|
+
"ParsedClipPath",
|
|
52
|
+
"PathError",
|
|
53
|
+
"candidate_relpaths",
|
|
54
|
+
"clip_cloud_key",
|
|
55
|
+
"colliding_relpath",
|
|
56
|
+
"collision_suffix",
|
|
57
|
+
"date_parts",
|
|
58
|
+
"is_canonical_relpath",
|
|
59
|
+
"parse_clip_relpath",
|
|
60
|
+
"primary_relpath",
|
|
61
|
+
"sidecar_relpath",
|
|
62
|
+
"validate_camera_name",
|
|
63
|
+
"validate_location",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
CLIP_EXT = ".mp4"
|
|
67
|
+
SIDECAR_EXT = ".mp4.json"
|
|
68
|
+
CLOUD_CLIPS_PREFIX = "clips"
|
|
69
|
+
|
|
70
|
+
#: Frigate appends six random base-36 characters to a review id, so the last six are
|
|
71
|
+
#: already a unique, filesystem-safe tail. We do not depend on that format — see
|
|
72
|
+
#: [`collision_suffix`][cctv.paths.collision_suffix].
|
|
73
|
+
COLLISION_SUFFIX_LEN = 6
|
|
74
|
+
|
|
75
|
+
# 1-32 characters, lowercase alphanumeric with interior hyphens. Deliberately *not*
|
|
76
|
+
# ADR-0006's regex, which requires at least two characters and would reject the
|
|
77
|
+
# perfectly reasonable camera name `a`; the ADR needs the same one-character fix.
|
|
78
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$")
|
|
79
|
+
|
|
80
|
+
_CLIP_NAME_RE = re.compile(
|
|
81
|
+
r"^(?P<h>[01][0-9]|2[0-3])-(?P<mi>[0-5][0-9])-(?P<s>[0-5][0-9])"
|
|
82
|
+
rf"(?:-(?P<suffix>[a-z0-9]{{{COLLISION_SUFFIX_LEN}}}))?{re.escape(CLIP_EXT)}$"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PathError(ValueError):
|
|
87
|
+
"""A path that cannot be derived or parsed. Raised at config load or on read."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_slug(value: str, *, what: str) -> str:
|
|
91
|
+
"""Validate a name that appears in filesystem paths and cloud object keys.
|
|
92
|
+
|
|
93
|
+
Uppercase is rejected rather than lowercased: silently rewriting the operator's
|
|
94
|
+
camera name means the name in `cameras.yml`, the name in the path, and the name in
|
|
95
|
+
Frigate's config would be three different strings, and debugging that is worse than
|
|
96
|
+
a validation error at install time.
|
|
97
|
+
"""
|
|
98
|
+
if not isinstance(value, str) or not value:
|
|
99
|
+
raise PathError(f"{what} is required.")
|
|
100
|
+
if not _SLUG_RE.match(value):
|
|
101
|
+
raise PathError(
|
|
102
|
+
f"{what} {value!r} is not valid. It must match {_SLUG_RE.pattern}: "
|
|
103
|
+
f"lowercase letters, digits and interior hyphens, 1-32 characters. "
|
|
104
|
+
f"{what}s appear in filesystem paths and cloud object keys, so uppercase "
|
|
105
|
+
f"causes case-collision bugs on macOS and a leading or trailing hyphen "
|
|
106
|
+
f"breaks round trips through some archive tools."
|
|
107
|
+
)
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_camera_name(camera: str) -> str:
|
|
112
|
+
"""Validate a camera name. Cameras are data, never code."""
|
|
113
|
+
return validate_slug(camera, what="Camera name")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_location(location: str) -> str:
|
|
117
|
+
"""Validate a site location name, which becomes the cloud key prefix."""
|
|
118
|
+
return validate_slug(location, what="Site location")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def date_parts(start: Instant, zone: ZoneInfo) -> tuple[str, str, str, str]:
|
|
122
|
+
"""Render an instant into the four path components.
|
|
123
|
+
|
|
124
|
+
This is the only place in the codebase where a path component is produced from an
|
|
125
|
+
instant, which is what makes the timezone policy auditable rather than distributed.
|
|
126
|
+
"""
|
|
127
|
+
local = render(start, zone)
|
|
128
|
+
return (
|
|
129
|
+
f"{local.year:04d}",
|
|
130
|
+
f"{local.month:02d}",
|
|
131
|
+
f"{local.day:02d}",
|
|
132
|
+
f"{local.hour:02d}-{local.minute:02d}-{local.second:02d}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def collision_suffix(review_id: str) -> str:
|
|
137
|
+
"""A deterministic tail for a colliding path.
|
|
138
|
+
|
|
139
|
+
Frigate's review ids end in six random base-36 characters, so taking the last six
|
|
140
|
+
alphanumerics gives exactly the documented suffix while staying independent of the
|
|
141
|
+
id's overall format. An id that is too short, or whose tail is not alphanumeric,
|
|
142
|
+
falls back to a hash rather than to a counter — a counter would be stateful, and a
|
|
143
|
+
stateful suffix is not derivable from metadata, which would break
|
|
144
|
+
[INV-5](../../../docs/architecture/invariants.md).
|
|
145
|
+
"""
|
|
146
|
+
if not isinstance(review_id, str) or not review_id:
|
|
147
|
+
raise PathError("A collision suffix requires a non-empty review id.")
|
|
148
|
+
|
|
149
|
+
tail = "".join(ch for ch in review_id.lower() if ch.isascii() and ch.isalnum())
|
|
150
|
+
if len(tail) < COLLISION_SUFFIX_LEN:
|
|
151
|
+
digest = hashlib.sha256(review_id.encode("utf-8")).hexdigest()
|
|
152
|
+
return digest[:COLLISION_SUFFIX_LEN]
|
|
153
|
+
return tail[-COLLISION_SUFFIX_LEN:]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def primary_relpath(camera: str, start: Instant, zone: ZoneInfo) -> PurePosixPath:
|
|
157
|
+
"""The path to try first: `HH-MM-SS.mp4`.
|
|
158
|
+
|
|
159
|
+
Always POSIX, never `Path`. A clip path is also a cloud object key, and object keys
|
|
160
|
+
use forward slashes regardless of the host OS — so building it with a Windows-aware
|
|
161
|
+
type would work on Linux and quietly produce backslashes on a contributor's laptop.
|
|
162
|
+
"""
|
|
163
|
+
camera = validate_camera_name(camera)
|
|
164
|
+
year, month, day, hms = date_parts(start, zone)
|
|
165
|
+
return PurePosixPath(camera, year, month, day, f"{hms}{CLIP_EXT}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def colliding_relpath(camera: str, start: Instant, zone: ZoneInfo, review_id: str) -> PurePosixPath:
|
|
169
|
+
"""The path to use when the primary one is already taken: `HH-MM-SS-xxxxxx.mp4`."""
|
|
170
|
+
base = primary_relpath(camera, start, zone)
|
|
171
|
+
return base.with_name(f"{base.stem}-{collision_suffix(review_id)}{CLIP_EXT}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def candidate_relpaths(
|
|
175
|
+
camera: str, start: Instant, zone: ZoneInfo, review_id: str
|
|
176
|
+
) -> tuple[PurePosixPath, PurePosixPath]:
|
|
177
|
+
"""Both candidates, in the order the writer must try them.
|
|
178
|
+
|
|
179
|
+
Exposed as one function so that the collision protocol lives in a single place. A
|
|
180
|
+
caller that builds the colliding form itself has re-implemented part of this
|
|
181
|
+
module, which is what [INV-5](../../../docs/architecture/invariants.md) forbids.
|
|
182
|
+
"""
|
|
183
|
+
return (
|
|
184
|
+
primary_relpath(camera, start, zone),
|
|
185
|
+
colliding_relpath(camera, start, zone, review_id),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sidecar_relpath(clip: PurePosixPath) -> PurePosixPath:
|
|
190
|
+
"""`HH-MM-SS.mp4` → `HH-MM-SS.mp4.json`, beside the clip.
|
|
191
|
+
|
|
192
|
+
Suffixing rather than replacing the extension keeps the two files adjacent in a
|
|
193
|
+
directory listing and makes it impossible to mistake a sidecar for media.
|
|
194
|
+
"""
|
|
195
|
+
return clip.with_name(clip.name + ".json")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clip_cloud_key(location: str, relpath: PurePosixPath) -> str:
|
|
199
|
+
"""Derive the object key from the local path.
|
|
200
|
+
|
|
201
|
+
Deliberately a concatenation rather than a parallel scheme: if the key were built
|
|
202
|
+
independently it could drift from the path, and then restore would need a mapping
|
|
203
|
+
layer that can itself drift. As it is, recovery is "strip the prefix, write the
|
|
204
|
+
file" ([ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md)).
|
|
205
|
+
"""
|
|
206
|
+
location = validate_location(location)
|
|
207
|
+
return f"{location}/{CLOUD_CLIPS_PREFIX}/{_as_posix(relpath)}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _as_posix(value: PurePosixPath | str) -> str:
|
|
211
|
+
return value.as_posix() if isinstance(value, PurePosixPath) else PurePosixPath(value).as_posix()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True, slots=True)
|
|
215
|
+
class ParsedClipPath:
|
|
216
|
+
"""The components recovered from a path.
|
|
217
|
+
|
|
218
|
+
Note the absence of an `instant` field. Recovering one is impossible without the
|
|
219
|
+
timezone, and ambiguous even with it during a DST fallback — see the module
|
|
220
|
+
docstring. Callers that need the instant read it from the database.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
camera: str
|
|
224
|
+
date_parts: tuple[str, str, str]
|
|
225
|
+
time_parts: tuple[str, str, str]
|
|
226
|
+
collision_suffix: str | None
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def collided(self) -> bool:
|
|
230
|
+
return self.collision_suffix is not None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_clip_relpath(relpath: PurePosixPath | str) -> ParsedClipPath:
|
|
234
|
+
"""Parse `camera/YYYY/MM/DD/HH-MM-SS[-xxxxxx].mp4`.
|
|
235
|
+
|
|
236
|
+
Used by restore and by the verification pass, which walk a tree they did not write —
|
|
237
|
+
a `restic` restore, or a bucket listing. It validates rather than trusting, because
|
|
238
|
+
a file that does not parse means either a bug or something a human put there.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
PathError: the path is not a canonical clip path.
|
|
242
|
+
"""
|
|
243
|
+
path = PurePosixPath(_as_posix(relpath))
|
|
244
|
+
|
|
245
|
+
if len(path.parts) != 5:
|
|
246
|
+
raise PathError(
|
|
247
|
+
f"{path} is not a canonical clip path. Expected "
|
|
248
|
+
f"<camera>/YYYY/MM/DD/HH-MM-SS{CLIP_EXT}, found {len(path.parts)} components."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
camera, year, month, day, name = path.parts
|
|
252
|
+
validate_camera_name(camera)
|
|
253
|
+
|
|
254
|
+
if not (
|
|
255
|
+
re.fullmatch(r"\d{4}", year)
|
|
256
|
+
and re.fullmatch(r"\d{2}", month)
|
|
257
|
+
and re.fullmatch(r"\d{2}", day)
|
|
258
|
+
):
|
|
259
|
+
raise PathError(f"{path} has a malformed date component.")
|
|
260
|
+
|
|
261
|
+
# Range-check via the calendar rather than by comparing against 12 and 31. A clip is
|
|
262
|
+
# always derived from an instant, so its date always exists — which means `02-30` is
|
|
263
|
+
# proof that something other than this module wrote the file.
|
|
264
|
+
try:
|
|
265
|
+
_dt.date(int(year), int(month), int(day))
|
|
266
|
+
except ValueError as exc:
|
|
267
|
+
raise PathError(f"{path} has a date that does not exist: {exc}") from exc
|
|
268
|
+
|
|
269
|
+
match = _CLIP_NAME_RE.match(name)
|
|
270
|
+
if match is None:
|
|
271
|
+
raise PathError(
|
|
272
|
+
f"{path} has a malformed filename. Expected HH-MM-SS{CLIP_EXT}, optionally "
|
|
273
|
+
f"with a {COLLISION_SUFFIX_LEN}-character collision suffix."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return ParsedClipPath(
|
|
277
|
+
camera=camera,
|
|
278
|
+
date_parts=(year, month, day),
|
|
279
|
+
time_parts=(match["h"], match["mi"], match["s"]),
|
|
280
|
+
collision_suffix=match["suffix"],
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def is_canonical_relpath(relpath: PurePosixPath | str) -> bool:
|
|
285
|
+
"""Non-raising form of [`parse_clip_relpath`][cctv.paths.parse_clip_relpath]."""
|
|
286
|
+
try:
|
|
287
|
+
parse_clip_relpath(relpath)
|
|
288
|
+
except PathError:
|
|
289
|
+
return False
|
|
290
|
+
return True
|