homesec 0.1.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.
- homesec/__init__.py +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""ClipRepository for coordinating state + event persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
from homesec.models.clip import Clip, ClipStateData
|
|
11
|
+
from homesec.models.config import RetryConfig
|
|
12
|
+
from homesec.models.events import (
|
|
13
|
+
AlertDecisionMadeEvent,
|
|
14
|
+
ClipDeletedEvent,
|
|
15
|
+
ClipRecheckedEvent,
|
|
16
|
+
ClipLifecycleEvent,
|
|
17
|
+
ClipRecordedEvent,
|
|
18
|
+
FilterCompletedEvent,
|
|
19
|
+
FilterFailedEvent,
|
|
20
|
+
FilterStartedEvent,
|
|
21
|
+
NotificationFailedEvent,
|
|
22
|
+
NotificationSentEvent,
|
|
23
|
+
UploadCompletedEvent,
|
|
24
|
+
UploadFailedEvent,
|
|
25
|
+
UploadStartedEvent,
|
|
26
|
+
VLMCompletedEvent,
|
|
27
|
+
VLMFailedEvent,
|
|
28
|
+
VLMStartedEvent,
|
|
29
|
+
VLMSkippedEvent,
|
|
30
|
+
)
|
|
31
|
+
from homesec.state.postgres import is_retryable_pg_error
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from homesec.models.alert import AlertDecision
|
|
35
|
+
from homesec.models.filter import FilterResult
|
|
36
|
+
from homesec.models.vlm import AnalysisResult
|
|
37
|
+
from homesec.interfaces import EventStore, StateStore
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
TResult = TypeVar("TResult")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClipRepository:
|
|
45
|
+
"""Coordinates state + event writes with best-effort retries."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
state_store: StateStore,
|
|
50
|
+
event_store: EventStore,
|
|
51
|
+
retry: RetryConfig | None = None,
|
|
52
|
+
should_retry: Callable[[Exception], bool] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._state = state_store
|
|
55
|
+
self._events = event_store
|
|
56
|
+
self._retry = retry or RetryConfig()
|
|
57
|
+
self._should_retry = should_retry or is_retryable_pg_error
|
|
58
|
+
self._max_attempts = max(1, int(self._retry.max_attempts))
|
|
59
|
+
self._backoff_s = max(0.0, float(self._retry.backoff_s))
|
|
60
|
+
|
|
61
|
+
async def initialize_clip(self, clip: Clip) -> ClipStateData:
|
|
62
|
+
"""Create initial state + record clip received event."""
|
|
63
|
+
state = ClipStateData(
|
|
64
|
+
camera_name=clip.camera_name,
|
|
65
|
+
status="queued_local",
|
|
66
|
+
local_path=str(clip.local_path),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
event = ClipRecordedEvent(
|
|
70
|
+
clip_id=clip.clip_id,
|
|
71
|
+
timestamp=datetime.now(),
|
|
72
|
+
camera_name=clip.camera_name,
|
|
73
|
+
duration_s=clip.duration_s,
|
|
74
|
+
source_type=clip.source_type,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
await self._safe_upsert(clip.clip_id, state)
|
|
78
|
+
await self._safe_append(event)
|
|
79
|
+
return state
|
|
80
|
+
|
|
81
|
+
async def record_upload_started(self, clip_id: str, dest_key: str, attempt: int) -> None:
|
|
82
|
+
"""Record upload start event."""
|
|
83
|
+
await self._safe_append(
|
|
84
|
+
UploadStartedEvent(
|
|
85
|
+
clip_id=clip_id,
|
|
86
|
+
timestamp=datetime.now(),
|
|
87
|
+
dest_key=dest_key,
|
|
88
|
+
attempt=attempt,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def record_upload_completed(
|
|
93
|
+
self,
|
|
94
|
+
clip_id: str,
|
|
95
|
+
storage_uri: str,
|
|
96
|
+
view_url: str | None,
|
|
97
|
+
duration_ms: int,
|
|
98
|
+
attempt: int = 1,
|
|
99
|
+
) -> ClipStateData | None:
|
|
100
|
+
"""Record upload completion + update state."""
|
|
101
|
+
state = await self._load_state(clip_id, action="upload")
|
|
102
|
+
if state is None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
state.storage_uri = storage_uri
|
|
106
|
+
state.view_url = view_url
|
|
107
|
+
if state.status not in ("analyzed", "done", "error", "deleted"):
|
|
108
|
+
state.status = "uploaded"
|
|
109
|
+
|
|
110
|
+
event = UploadCompletedEvent(
|
|
111
|
+
clip_id=clip_id,
|
|
112
|
+
timestamp=datetime.now(),
|
|
113
|
+
storage_uri=storage_uri,
|
|
114
|
+
view_url=view_url,
|
|
115
|
+
attempt=attempt,
|
|
116
|
+
duration_ms=duration_ms,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await self._safe_upsert(clip_id, state)
|
|
120
|
+
await self._safe_append(event)
|
|
121
|
+
return state
|
|
122
|
+
|
|
123
|
+
async def record_upload_failed(
|
|
124
|
+
self,
|
|
125
|
+
clip_id: str,
|
|
126
|
+
error_message: str,
|
|
127
|
+
error_type: str,
|
|
128
|
+
*,
|
|
129
|
+
attempt: int = 1,
|
|
130
|
+
will_retry: bool = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Record upload failure event."""
|
|
133
|
+
await self._safe_append(
|
|
134
|
+
UploadFailedEvent(
|
|
135
|
+
clip_id=clip_id,
|
|
136
|
+
timestamp=datetime.now(),
|
|
137
|
+
attempt=attempt,
|
|
138
|
+
error_message=error_message,
|
|
139
|
+
error_type=error_type,
|
|
140
|
+
will_retry=will_retry,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def record_filter_started(self, clip_id: str, attempt: int) -> None:
|
|
145
|
+
"""Record filter start event."""
|
|
146
|
+
await self._safe_append(
|
|
147
|
+
FilterStartedEvent(
|
|
148
|
+
clip_id=clip_id,
|
|
149
|
+
timestamp=datetime.now(),
|
|
150
|
+
attempt=attempt,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def record_filter_completed(
|
|
155
|
+
self,
|
|
156
|
+
clip_id: str,
|
|
157
|
+
result: FilterResult,
|
|
158
|
+
duration_ms: int,
|
|
159
|
+
attempt: int = 1,
|
|
160
|
+
) -> ClipStateData | None:
|
|
161
|
+
"""Record filter completion + update state."""
|
|
162
|
+
state = await self._load_state(clip_id, action="filter")
|
|
163
|
+
if state is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
state.filter_result = result
|
|
167
|
+
|
|
168
|
+
event = FilterCompletedEvent(
|
|
169
|
+
clip_id=clip_id,
|
|
170
|
+
timestamp=datetime.now(),
|
|
171
|
+
detected_classes=result.detected_classes,
|
|
172
|
+
confidence=result.confidence,
|
|
173
|
+
model=result.model,
|
|
174
|
+
sampled_frames=result.sampled_frames,
|
|
175
|
+
attempt=attempt,
|
|
176
|
+
duration_ms=duration_ms,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
await self._safe_upsert(clip_id, state)
|
|
180
|
+
await self._safe_append(event)
|
|
181
|
+
return state
|
|
182
|
+
|
|
183
|
+
async def record_filter_failed(
|
|
184
|
+
self,
|
|
185
|
+
clip_id: str,
|
|
186
|
+
error_message: str,
|
|
187
|
+
error_type: str,
|
|
188
|
+
*,
|
|
189
|
+
attempt: int = 1,
|
|
190
|
+
will_retry: bool = False,
|
|
191
|
+
) -> ClipStateData | None:
|
|
192
|
+
"""Record filter failure + mark state as error."""
|
|
193
|
+
event = FilterFailedEvent(
|
|
194
|
+
clip_id=clip_id,
|
|
195
|
+
timestamp=datetime.now(),
|
|
196
|
+
attempt=attempt,
|
|
197
|
+
error_message=error_message,
|
|
198
|
+
error_type=error_type,
|
|
199
|
+
will_retry=will_retry,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
await self._safe_append(event)
|
|
203
|
+
if will_retry:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
state = await self._load_state(clip_id, action="filter failure")
|
|
207
|
+
if state is None:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
if state.status != "deleted":
|
|
211
|
+
state.status = "error"
|
|
212
|
+
await self._safe_upsert(clip_id, state)
|
|
213
|
+
return state
|
|
214
|
+
|
|
215
|
+
async def record_vlm_started(self, clip_id: str, attempt: int) -> None:
|
|
216
|
+
"""Record VLM start event."""
|
|
217
|
+
await self._safe_append(
|
|
218
|
+
VLMStartedEvent(
|
|
219
|
+
clip_id=clip_id,
|
|
220
|
+
timestamp=datetime.now(),
|
|
221
|
+
attempt=attempt,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def record_vlm_completed(
|
|
226
|
+
self,
|
|
227
|
+
clip_id: str,
|
|
228
|
+
result: AnalysisResult,
|
|
229
|
+
prompt_tokens: int | None,
|
|
230
|
+
completion_tokens: int | None,
|
|
231
|
+
duration_ms: int,
|
|
232
|
+
attempt: int = 1,
|
|
233
|
+
) -> ClipStateData | None:
|
|
234
|
+
"""Record VLM completion + update state."""
|
|
235
|
+
state = await self._load_state(clip_id, action="VLM")
|
|
236
|
+
if state is None:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
state.analysis_result = result
|
|
240
|
+
if state.status != "deleted":
|
|
241
|
+
state.status = "analyzed"
|
|
242
|
+
|
|
243
|
+
event = VLMCompletedEvent(
|
|
244
|
+
clip_id=clip_id,
|
|
245
|
+
timestamp=datetime.now(),
|
|
246
|
+
risk_level=result.risk_level,
|
|
247
|
+
activity_type=result.activity_type,
|
|
248
|
+
summary=result.summary,
|
|
249
|
+
analysis=result.analysis.model_dump(mode="json") if result.analysis else {},
|
|
250
|
+
prompt_tokens=prompt_tokens,
|
|
251
|
+
completion_tokens=completion_tokens,
|
|
252
|
+
attempt=attempt,
|
|
253
|
+
duration_ms=duration_ms,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
await self._safe_upsert(clip_id, state)
|
|
257
|
+
await self._safe_append(event)
|
|
258
|
+
return state
|
|
259
|
+
|
|
260
|
+
async def record_vlm_failed(
|
|
261
|
+
self,
|
|
262
|
+
clip_id: str,
|
|
263
|
+
error_message: str,
|
|
264
|
+
error_type: str,
|
|
265
|
+
*,
|
|
266
|
+
attempt: int = 1,
|
|
267
|
+
will_retry: bool = False,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Record VLM failure event."""
|
|
270
|
+
await self._safe_append(
|
|
271
|
+
VLMFailedEvent(
|
|
272
|
+
clip_id=clip_id,
|
|
273
|
+
timestamp=datetime.now(),
|
|
274
|
+
attempt=attempt,
|
|
275
|
+
error_message=error_message,
|
|
276
|
+
error_type=error_type,
|
|
277
|
+
will_retry=will_retry,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def record_vlm_skipped(self, clip_id: str, reason: str) -> None:
|
|
282
|
+
"""Record VLM skipped event."""
|
|
283
|
+
await self._safe_append(
|
|
284
|
+
VLMSkippedEvent(
|
|
285
|
+
clip_id=clip_id,
|
|
286
|
+
timestamp=datetime.now(),
|
|
287
|
+
reason=reason,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def record_alert_decision(
|
|
292
|
+
self,
|
|
293
|
+
clip_id: str,
|
|
294
|
+
decision: AlertDecision,
|
|
295
|
+
detected_classes: list[str] | None,
|
|
296
|
+
vlm_risk: str | None,
|
|
297
|
+
) -> ClipStateData | None:
|
|
298
|
+
"""Record alert decision + update state."""
|
|
299
|
+
state = await self._load_state(clip_id, action="alert decision")
|
|
300
|
+
if state is None:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
state.alert_decision = decision
|
|
304
|
+
|
|
305
|
+
event = AlertDecisionMadeEvent(
|
|
306
|
+
clip_id=clip_id,
|
|
307
|
+
timestamp=datetime.now(),
|
|
308
|
+
should_notify=decision.notify,
|
|
309
|
+
reason=decision.notify_reason,
|
|
310
|
+
detected_classes=detected_classes,
|
|
311
|
+
vlm_risk=vlm_risk,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
await self._safe_upsert(clip_id, state)
|
|
315
|
+
await self._safe_append(event)
|
|
316
|
+
return state
|
|
317
|
+
|
|
318
|
+
async def record_notification_sent(
|
|
319
|
+
self,
|
|
320
|
+
clip_id: str,
|
|
321
|
+
notifier_name: str,
|
|
322
|
+
dedupe_key: str,
|
|
323
|
+
attempt: int = 1,
|
|
324
|
+
) -> ClipStateData | None:
|
|
325
|
+
"""Record notification sent + mark state as done."""
|
|
326
|
+
state = await self._load_state(clip_id, action="notification")
|
|
327
|
+
if state is None:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
if state.status != "deleted":
|
|
331
|
+
state.status = "done"
|
|
332
|
+
|
|
333
|
+
event = NotificationSentEvent(
|
|
334
|
+
clip_id=clip_id,
|
|
335
|
+
timestamp=datetime.now(),
|
|
336
|
+
notifier_name=notifier_name,
|
|
337
|
+
dedupe_key=dedupe_key,
|
|
338
|
+
attempt=attempt,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
await self._safe_upsert(clip_id, state)
|
|
342
|
+
await self._safe_append(event)
|
|
343
|
+
return state
|
|
344
|
+
|
|
345
|
+
async def record_notification_failed(
|
|
346
|
+
self,
|
|
347
|
+
clip_id: str,
|
|
348
|
+
notifier_name: str,
|
|
349
|
+
error_message: str,
|
|
350
|
+
error_type: str,
|
|
351
|
+
*,
|
|
352
|
+
attempt: int = 1,
|
|
353
|
+
will_retry: bool = False,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Record notification failure event."""
|
|
356
|
+
await self._safe_append(
|
|
357
|
+
NotificationFailedEvent(
|
|
358
|
+
clip_id=clip_id,
|
|
359
|
+
timestamp=datetime.now(),
|
|
360
|
+
notifier_name=notifier_name,
|
|
361
|
+
error_message=error_message,
|
|
362
|
+
error_type=error_type,
|
|
363
|
+
attempt=attempt,
|
|
364
|
+
will_retry=will_retry,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
async def record_clip_deleted(
|
|
369
|
+
self,
|
|
370
|
+
clip_id: str,
|
|
371
|
+
*,
|
|
372
|
+
reason: str,
|
|
373
|
+
run_id: str,
|
|
374
|
+
deleted_local: bool,
|
|
375
|
+
deleted_storage: bool,
|
|
376
|
+
) -> ClipStateData | None:
|
|
377
|
+
"""Mark clip as deleted and append a clip_deleted event."""
|
|
378
|
+
state = await self._load_state(clip_id, action="clip delete")
|
|
379
|
+
if state is None:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
state.status = "deleted"
|
|
383
|
+
|
|
384
|
+
event = ClipDeletedEvent(
|
|
385
|
+
clip_id=clip_id,
|
|
386
|
+
timestamp=datetime.now(),
|
|
387
|
+
camera_name=state.camera_name,
|
|
388
|
+
reason=reason,
|
|
389
|
+
run_id=run_id,
|
|
390
|
+
local_path=state.local_path,
|
|
391
|
+
storage_uri=state.storage_uri,
|
|
392
|
+
deleted_local=deleted_local,
|
|
393
|
+
deleted_storage=deleted_storage,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
await self._safe_upsert(clip_id, state)
|
|
397
|
+
await self._safe_append(event)
|
|
398
|
+
return state
|
|
399
|
+
|
|
400
|
+
async def record_clip_rechecked(
|
|
401
|
+
self,
|
|
402
|
+
clip_id: str,
|
|
403
|
+
*,
|
|
404
|
+
result: FilterResult,
|
|
405
|
+
prior_filter: FilterResult | None,
|
|
406
|
+
reason: str,
|
|
407
|
+
run_id: str,
|
|
408
|
+
) -> ClipStateData | None:
|
|
409
|
+
"""Record a recheck result and update the clip state."""
|
|
410
|
+
state = await self._load_state(clip_id, action="clip recheck")
|
|
411
|
+
if state is None:
|
|
412
|
+
return None
|
|
413
|
+
if state.status == "deleted":
|
|
414
|
+
return state
|
|
415
|
+
|
|
416
|
+
state.filter_result = result
|
|
417
|
+
|
|
418
|
+
event = ClipRecheckedEvent(
|
|
419
|
+
clip_id=clip_id,
|
|
420
|
+
timestamp=datetime.now(),
|
|
421
|
+
camera_name=state.camera_name,
|
|
422
|
+
reason=reason,
|
|
423
|
+
run_id=run_id,
|
|
424
|
+
prior_filter=prior_filter,
|
|
425
|
+
recheck_filter=result,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
await self._safe_upsert(clip_id, state)
|
|
429
|
+
await self._safe_append(event)
|
|
430
|
+
return state
|
|
431
|
+
|
|
432
|
+
async def list_candidate_clips_for_cleanup(
|
|
433
|
+
self,
|
|
434
|
+
*,
|
|
435
|
+
older_than_days: int | None,
|
|
436
|
+
camera_name: str | None,
|
|
437
|
+
batch_size: int,
|
|
438
|
+
cursor: tuple[datetime, str] | None = None,
|
|
439
|
+
) -> list[tuple[str, ClipStateData, datetime]]:
|
|
440
|
+
"""List clip states eligible for cleanup."""
|
|
441
|
+
try:
|
|
442
|
+
return await self._run_with_retries(
|
|
443
|
+
label="State store cleanup list",
|
|
444
|
+
clip_id="cleanup",
|
|
445
|
+
op=lambda: self._state.list_candidate_clips_for_cleanup(
|
|
446
|
+
older_than_days=older_than_days,
|
|
447
|
+
camera_name=camera_name,
|
|
448
|
+
batch_size=batch_size,
|
|
449
|
+
cursor=cursor,
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
logger.error(
|
|
454
|
+
"State store cleanup list failed after retries: %s",
|
|
455
|
+
exc,
|
|
456
|
+
exc_info=exc,
|
|
457
|
+
)
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
async def mark_done(self, clip_id: str) -> ClipStateData | None:
|
|
461
|
+
"""Mark processing as done (no event)."""
|
|
462
|
+
state = await self._load_state(clip_id, action="completion")
|
|
463
|
+
if state is None:
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
if state.status in ("done", "deleted"):
|
|
467
|
+
return state
|
|
468
|
+
|
|
469
|
+
state.status = "done"
|
|
470
|
+
await self._safe_upsert(clip_id, state)
|
|
471
|
+
return state
|
|
472
|
+
|
|
473
|
+
async def _load_state(self, clip_id: str, *, action: str) -> ClipStateData | None:
|
|
474
|
+
state = await self._safe_get(clip_id)
|
|
475
|
+
if state is None:
|
|
476
|
+
logger.error("Cannot update %s: clip %s not found", action, clip_id)
|
|
477
|
+
return state
|
|
478
|
+
|
|
479
|
+
async def _safe_get(self, clip_id: str) -> ClipStateData | None:
|
|
480
|
+
try:
|
|
481
|
+
return await self._run_with_retries(
|
|
482
|
+
label="State store get",
|
|
483
|
+
clip_id=clip_id,
|
|
484
|
+
op=lambda: self._state.get(clip_id),
|
|
485
|
+
)
|
|
486
|
+
except Exception as exc:
|
|
487
|
+
logger.error(
|
|
488
|
+
"State store get failed for %s after retries: %s",
|
|
489
|
+
clip_id,
|
|
490
|
+
exc,
|
|
491
|
+
exc_info=exc,
|
|
492
|
+
)
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
async def _safe_upsert(self, clip_id: str, state: ClipStateData) -> None:
|
|
496
|
+
try:
|
|
497
|
+
await self._run_with_retries(
|
|
498
|
+
label="State store upsert",
|
|
499
|
+
clip_id=clip_id,
|
|
500
|
+
op=lambda: self._state.upsert(clip_id, state),
|
|
501
|
+
)
|
|
502
|
+
except Exception as exc:
|
|
503
|
+
logger.error(
|
|
504
|
+
"State store upsert failed for %s after retries: %s",
|
|
505
|
+
clip_id,
|
|
506
|
+
exc,
|
|
507
|
+
exc_info=exc,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
async def _safe_append(self, event: ClipLifecycleEvent) -> None:
|
|
511
|
+
clip_id = event.clip_id
|
|
512
|
+
try:
|
|
513
|
+
await self._run_with_retries(
|
|
514
|
+
label="Event store append",
|
|
515
|
+
clip_id=clip_id,
|
|
516
|
+
op=lambda: self._events.append(event),
|
|
517
|
+
)
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
logger.error(
|
|
520
|
+
"Event store append failed for %s after retries: %s",
|
|
521
|
+
clip_id,
|
|
522
|
+
exc,
|
|
523
|
+
exc_info=exc,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
async def _run_with_retries(
|
|
527
|
+
self,
|
|
528
|
+
*,
|
|
529
|
+
label: str,
|
|
530
|
+
clip_id: str,
|
|
531
|
+
op: Callable[[], Awaitable[TResult]],
|
|
532
|
+
) -> TResult:
|
|
533
|
+
attempt = 1
|
|
534
|
+
|
|
535
|
+
while True:
|
|
536
|
+
try:
|
|
537
|
+
return await op()
|
|
538
|
+
except Exception as exc:
|
|
539
|
+
if not self._should_retry(exc) or attempt >= self._max_attempts:
|
|
540
|
+
raise
|
|
541
|
+
logger.warning(
|
|
542
|
+
"%s failed for %s (attempt %d/%d): %s",
|
|
543
|
+
label,
|
|
544
|
+
clip_id,
|
|
545
|
+
attempt,
|
|
546
|
+
self._max_attempts,
|
|
547
|
+
exc,
|
|
548
|
+
)
|
|
549
|
+
delay = self._backoff_s * (2 ** (attempt - 1))
|
|
550
|
+
if delay > 0:
|
|
551
|
+
await asyncio.sleep(delay)
|
|
552
|
+
attempt += 1
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Clip source implementations."""
|
|
2
|
+
|
|
3
|
+
from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
|
|
4
|
+
from homesec.sources.base import ThreadedClipSource
|
|
5
|
+
from homesec.sources.ftp import FtpSource
|
|
6
|
+
from homesec.sources.local_folder import LocalFolderSource
|
|
7
|
+
from homesec.sources.rtsp import RTSPSource
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FtpSource",
|
|
11
|
+
"FtpSourceConfig",
|
|
12
|
+
"LocalFolderSource",
|
|
13
|
+
"LocalFolderSourceConfig",
|
|
14
|
+
"RTSPSource",
|
|
15
|
+
"RTSPSourceConfig",
|
|
16
|
+
"ThreadedClipSource",
|
|
17
|
+
]
|