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
homesec/sources/base.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Shared helpers for threaded and async clip sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from threading import Event, Thread
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from homesec.models.clip import Clip
|
|
13
|
+
from homesec.interfaces import ClipSource
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ThreadedClipSource(ClipSource, ABC):
|
|
19
|
+
"""Base class for clip sources that run in a background thread."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._callback: Callable[[Clip], None] | None = None
|
|
23
|
+
self._thread: Thread | None = None
|
|
24
|
+
self._stop_event = Event()
|
|
25
|
+
self._last_heartbeat = time.monotonic()
|
|
26
|
+
|
|
27
|
+
def register_callback(self, callback: Callable[[Clip], None]) -> None:
|
|
28
|
+
"""Register callback to be invoked when a new clip is ready."""
|
|
29
|
+
self._callback = callback
|
|
30
|
+
|
|
31
|
+
async def start(self) -> None:
|
|
32
|
+
"""Start producing clips in a background thread."""
|
|
33
|
+
if self._thread is not None:
|
|
34
|
+
logger.warning("%s already started", self.__class__.__name__)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
self._stop_event.clear()
|
|
38
|
+
self._on_start()
|
|
39
|
+
self._thread = Thread(target=self._run_wrapper, daemon=True)
|
|
40
|
+
self._thread.start()
|
|
41
|
+
self._on_started()
|
|
42
|
+
|
|
43
|
+
def stop(self, timeout: float | None = None) -> None:
|
|
44
|
+
"""Stop the background thread and cleanup resources."""
|
|
45
|
+
thread = self._thread
|
|
46
|
+
if thread is None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
self._stop_event.set()
|
|
50
|
+
self._on_stop()
|
|
51
|
+
|
|
52
|
+
if thread.is_alive():
|
|
53
|
+
thread.join(timeout=timeout or self._stop_timeout())
|
|
54
|
+
|
|
55
|
+
if self._thread is thread:
|
|
56
|
+
self._thread = None
|
|
57
|
+
self._on_stopped()
|
|
58
|
+
|
|
59
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
60
|
+
"""Async wrapper for stopping the background thread."""
|
|
61
|
+
await asyncio.to_thread(self.stop, timeout)
|
|
62
|
+
|
|
63
|
+
def is_healthy(self) -> bool:
|
|
64
|
+
"""Default health check: thread is alive (if started)."""
|
|
65
|
+
return self._thread_is_healthy()
|
|
66
|
+
|
|
67
|
+
def last_heartbeat(self) -> float:
|
|
68
|
+
"""Return monotonic timestamp of last heartbeat update."""
|
|
69
|
+
return self._last_heartbeat
|
|
70
|
+
|
|
71
|
+
def _touch_heartbeat(self) -> None:
|
|
72
|
+
self._last_heartbeat = time.monotonic()
|
|
73
|
+
|
|
74
|
+
def _thread_is_healthy(self) -> bool:
|
|
75
|
+
if self._thread is None:
|
|
76
|
+
return True
|
|
77
|
+
return self._thread.is_alive()
|
|
78
|
+
|
|
79
|
+
def _emit_clip(self, clip: Clip) -> None:
|
|
80
|
+
if not self._callback:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
self._callback(clip)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.error(
|
|
86
|
+
"Callback failed for %s: %s",
|
|
87
|
+
clip.clip_id,
|
|
88
|
+
exc,
|
|
89
|
+
exc_info=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _run_wrapper(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
self._run()
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.exception("%s stopped unexpectedly", self.__class__.__name__)
|
|
97
|
+
finally:
|
|
98
|
+
self._thread = None
|
|
99
|
+
|
|
100
|
+
def _stop_timeout(self) -> float:
|
|
101
|
+
return 5.0
|
|
102
|
+
|
|
103
|
+
def _on_start(self) -> None:
|
|
104
|
+
"""Hook called before starting the background thread."""
|
|
105
|
+
|
|
106
|
+
def _on_started(self) -> None:
|
|
107
|
+
"""Hook called after starting the background thread."""
|
|
108
|
+
|
|
109
|
+
def _on_stop(self) -> None:
|
|
110
|
+
"""Hook called before stopping the background thread."""
|
|
111
|
+
|
|
112
|
+
def _on_stopped(self) -> None:
|
|
113
|
+
"""Hook called after stopping the background thread."""
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def _run(self) -> None:
|
|
117
|
+
"""Thread entrypoint (blocking)."""
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncClipSource(ClipSource, ABC):
|
|
122
|
+
"""Base class for clip sources that run as async tasks."""
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._callback: Callable[[Clip], None] | None = None
|
|
126
|
+
self._task: asyncio.Task[None] | None = None
|
|
127
|
+
self._stop_event = asyncio.Event()
|
|
128
|
+
self._last_heartbeat = time.monotonic()
|
|
129
|
+
|
|
130
|
+
def register_callback(self, callback: Callable[[Clip], None]) -> None:
|
|
131
|
+
"""Register callback to be invoked when a new clip is ready."""
|
|
132
|
+
self._callback = callback
|
|
133
|
+
|
|
134
|
+
async def start(self) -> None:
|
|
135
|
+
"""Start producing clips in a background task."""
|
|
136
|
+
if self._task is not None:
|
|
137
|
+
logger.warning("%s already started", self.__class__.__name__)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
self._stop_event.clear()
|
|
141
|
+
self._on_start()
|
|
142
|
+
self._task = asyncio.create_task(self._run_wrapper())
|
|
143
|
+
self._on_started()
|
|
144
|
+
|
|
145
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
146
|
+
"""Stop the background task and cleanup resources."""
|
|
147
|
+
task = self._task
|
|
148
|
+
if task is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
self._stop_event.set()
|
|
152
|
+
self._on_stop()
|
|
153
|
+
|
|
154
|
+
if not task.done():
|
|
155
|
+
try:
|
|
156
|
+
await asyncio.wait_for(task, timeout=timeout or self._stop_timeout())
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
logger.warning("%s shutdown timed out, cancelling task", self.__class__.__name__)
|
|
159
|
+
task.cancel()
|
|
160
|
+
try:
|
|
161
|
+
await task
|
|
162
|
+
except asyncio.CancelledError:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
if self._task is task:
|
|
166
|
+
self._task = None
|
|
167
|
+
self._on_stopped()
|
|
168
|
+
|
|
169
|
+
def is_healthy(self) -> bool:
|
|
170
|
+
"""Default health check: task is running (if started)."""
|
|
171
|
+
return self._task_is_healthy()
|
|
172
|
+
|
|
173
|
+
def last_heartbeat(self) -> float:
|
|
174
|
+
"""Return timestamp (monotonic) of last successful operation."""
|
|
175
|
+
return self._last_heartbeat
|
|
176
|
+
|
|
177
|
+
def _touch_heartbeat(self) -> None:
|
|
178
|
+
self._last_heartbeat = time.monotonic()
|
|
179
|
+
|
|
180
|
+
def _task_is_healthy(self) -> bool:
|
|
181
|
+
if self._task is None:
|
|
182
|
+
return True
|
|
183
|
+
return not self._task.done()
|
|
184
|
+
|
|
185
|
+
def _emit_clip(self, clip: Clip) -> None:
|
|
186
|
+
if not self._callback:
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
self._callback(clip)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
logger.error(
|
|
192
|
+
"Callback failed for %s: %s",
|
|
193
|
+
clip.clip_id,
|
|
194
|
+
exc,
|
|
195
|
+
exc_info=True,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def _run_wrapper(self) -> None:
|
|
199
|
+
try:
|
|
200
|
+
await self._run()
|
|
201
|
+
except Exception:
|
|
202
|
+
logger.exception("%s stopped unexpectedly", self.__class__.__name__)
|
|
203
|
+
finally:
|
|
204
|
+
self._task = None
|
|
205
|
+
|
|
206
|
+
def _stop_timeout(self) -> float:
|
|
207
|
+
return 5.0
|
|
208
|
+
|
|
209
|
+
def _on_start(self) -> None:
|
|
210
|
+
"""Hook called before starting the background task."""
|
|
211
|
+
|
|
212
|
+
def _on_started(self) -> None:
|
|
213
|
+
"""Hook called after starting the background task."""
|
|
214
|
+
|
|
215
|
+
def _on_stop(self) -> None:
|
|
216
|
+
"""Hook called before stopping the background task."""
|
|
217
|
+
|
|
218
|
+
def _on_stopped(self) -> None:
|
|
219
|
+
"""Hook called after stopping the background task."""
|
|
220
|
+
|
|
221
|
+
@abstractmethod
|
|
222
|
+
async def _run(self) -> None:
|
|
223
|
+
"""Async task entrypoint."""
|
|
224
|
+
raise NotImplementedError
|
homesec/sources/ftp.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""FTP clip source for camera uploads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from threading import Thread
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from homesec.models.clip import Clip
|
|
13
|
+
from homesec.models.source import FtpSourceConfig
|
|
14
|
+
from homesec.sources.base import ThreadedClipSource
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_passive_ports(spec: str | None) -> list[int] | None:
|
|
20
|
+
if not spec:
|
|
21
|
+
return None
|
|
22
|
+
spec = str(spec).strip()
|
|
23
|
+
if not spec:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
if "-" in spec:
|
|
27
|
+
start_s, end_s = spec.split("-", 1)
|
|
28
|
+
start = int(start_s)
|
|
29
|
+
end = int(end_s)
|
|
30
|
+
if end < start:
|
|
31
|
+
raise ValueError(f"Invalid passive_ports range: {spec!r}")
|
|
32
|
+
return list(range(start, end + 1))
|
|
33
|
+
|
|
34
|
+
ports: list[int] = []
|
|
35
|
+
for part in spec.split(","):
|
|
36
|
+
part = part.strip()
|
|
37
|
+
if part:
|
|
38
|
+
ports.append(int(part))
|
|
39
|
+
return ports or None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FtpSource(ThreadedClipSource):
|
|
43
|
+
"""FTP server clip source."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: FtpSourceConfig, camera_name: str) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self._config = config
|
|
48
|
+
self.camera_name = camera_name
|
|
49
|
+
self.root_dir = Path(config.root_dir).expanduser().resolve()
|
|
50
|
+
if config.ftp_subdir:
|
|
51
|
+
self.root_dir = self.root_dir / config.ftp_subdir
|
|
52
|
+
self._allowed_extensions = set(config.allowed_extensions)
|
|
53
|
+
self._username = self._resolve_env(config.username_env)
|
|
54
|
+
self._password = self._resolve_env(config.password_env)
|
|
55
|
+
|
|
56
|
+
if not config.anonymous and (not self._username or not self._password):
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"FTP auth requires username/password env vars when anonymous is False"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self._server: Any | None = None
|
|
62
|
+
self._heartbeat_thread: Thread | None = None
|
|
63
|
+
|
|
64
|
+
def is_healthy(self) -> bool:
|
|
65
|
+
"""Check if source is healthy."""
|
|
66
|
+
return self._thread_is_healthy()
|
|
67
|
+
|
|
68
|
+
def _heartbeat_loop(self) -> None:
|
|
69
|
+
while not self._stop_event.wait(self._config.heartbeat_s):
|
|
70
|
+
self._touch_heartbeat()
|
|
71
|
+
|
|
72
|
+
def _run(self) -> None:
|
|
73
|
+
if self._server is None:
|
|
74
|
+
return
|
|
75
|
+
self._touch_heartbeat()
|
|
76
|
+
try:
|
|
77
|
+
self._server.serve_forever()
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.exception("FTP server stopped unexpectedly")
|
|
80
|
+
|
|
81
|
+
def _create_server(self) -> Any:
|
|
82
|
+
try:
|
|
83
|
+
from pyftpdlib.authorizers import DummyAuthorizer # type: ignore[import-untyped]
|
|
84
|
+
from pyftpdlib.handlers import FTPHandler # type: ignore[import-untyped]
|
|
85
|
+
from pyftpdlib.servers import FTPServer # type: ignore[import-untyped]
|
|
86
|
+
except ImportError as exc:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"Missing dependency: pyftpdlib. Install with: uv pip install pyftpdlib"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
authorizer = DummyAuthorizer()
|
|
92
|
+
if self._config.anonymous:
|
|
93
|
+
authorizer.add_anonymous(str(self.root_dir), perm=self._config.perms)
|
|
94
|
+
else:
|
|
95
|
+
assert self._username is not None
|
|
96
|
+
assert self._password is not None
|
|
97
|
+
authorizer.add_user(
|
|
98
|
+
self._username, self._password, str(self.root_dir), perm=self._config.perms
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
source = self
|
|
102
|
+
|
|
103
|
+
class UploadHandler(FTPHandler): # type: ignore[misc]
|
|
104
|
+
def on_connect(self) -> None:
|
|
105
|
+
source._touch_heartbeat()
|
|
106
|
+
|
|
107
|
+
def on_login(self, username: str) -> None:
|
|
108
|
+
source._touch_heartbeat()
|
|
109
|
+
|
|
110
|
+
def on_file_received(self, file: str) -> None:
|
|
111
|
+
source._handle_file_received(Path(file))
|
|
112
|
+
|
|
113
|
+
def on_incomplete_file_received(self, file: str) -> None:
|
|
114
|
+
source._handle_incomplete_file(Path(file))
|
|
115
|
+
|
|
116
|
+
UploadHandler.authorizer = authorizer
|
|
117
|
+
|
|
118
|
+
parsed_passive_ports = _parse_passive_ports(self._config.passive_ports)
|
|
119
|
+
if parsed_passive_ports is not None:
|
|
120
|
+
UploadHandler.passive_ports = parsed_passive_ports
|
|
121
|
+
if self._config.masquerade_address:
|
|
122
|
+
UploadHandler.masquerade_address = self._config.masquerade_address
|
|
123
|
+
|
|
124
|
+
return FTPServer((self._config.host, int(self._config.port)), UploadHandler)
|
|
125
|
+
|
|
126
|
+
def _is_extension_allowed(self, file_path: Path) -> bool:
|
|
127
|
+
if not self._allowed_extensions:
|
|
128
|
+
return True
|
|
129
|
+
return file_path.suffix.lower() in self._allowed_extensions
|
|
130
|
+
|
|
131
|
+
def _handle_file_received(self, file_path: Path) -> None:
|
|
132
|
+
self._touch_heartbeat()
|
|
133
|
+
logger.info("Received upload: %s", file_path)
|
|
134
|
+
if not self._is_extension_allowed(file_path):
|
|
135
|
+
logger.info("Rejecting upload with unsupported extension: %s", file_path)
|
|
136
|
+
if self._config.delete_non_matching:
|
|
137
|
+
try:
|
|
138
|
+
file_path.unlink(missing_ok=True)
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.exception("Failed to delete non-matching upload: %s", file_path)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
clip = self._create_clip(file_path)
|
|
144
|
+
self._emit_clip(clip)
|
|
145
|
+
|
|
146
|
+
def _handle_incomplete_file(self, file_path: Path) -> None:
|
|
147
|
+
self._touch_heartbeat()
|
|
148
|
+
logger.warning("Incomplete upload (deleting): %s", file_path)
|
|
149
|
+
if not self._config.delete_incomplete:
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
file_path.unlink(missing_ok=True)
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception("Failed to delete incomplete upload: %s", file_path)
|
|
155
|
+
|
|
156
|
+
def _create_clip(self, file_path: Path) -> Clip:
|
|
157
|
+
stat = file_path.stat()
|
|
158
|
+
mtime = datetime.fromtimestamp(stat.st_mtime)
|
|
159
|
+
duration_s = float(self._config.default_duration_s)
|
|
160
|
+
return Clip(
|
|
161
|
+
clip_id=file_path.stem,
|
|
162
|
+
camera_name=self.camera_name,
|
|
163
|
+
local_path=file_path,
|
|
164
|
+
start_ts=mtime - timedelta(seconds=duration_s),
|
|
165
|
+
end_ts=mtime,
|
|
166
|
+
duration_s=duration_s,
|
|
167
|
+
source_type="ftp",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _resolve_env(self, name: str | None) -> str | None:
|
|
171
|
+
if not name:
|
|
172
|
+
return None
|
|
173
|
+
value = os.getenv(name)
|
|
174
|
+
if value:
|
|
175
|
+
return value
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _on_start(self) -> None:
|
|
179
|
+
logger.setLevel(getattr(logging, str(self._config.log_level).upper(), logging.INFO))
|
|
180
|
+
self.root_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
self._server = self._create_server()
|
|
182
|
+
|
|
183
|
+
if self._config.heartbeat_s > 0:
|
|
184
|
+
self._heartbeat_thread = Thread(target=self._heartbeat_loop, daemon=True)
|
|
185
|
+
self._heartbeat_thread.start()
|
|
186
|
+
|
|
187
|
+
logger.info(
|
|
188
|
+
"FTP source started: %s:%s (root=%s)",
|
|
189
|
+
self._config.host,
|
|
190
|
+
self._config.port,
|
|
191
|
+
self.root_dir,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _on_stop(self) -> None:
|
|
195
|
+
logger.info("Stopping FtpSource...")
|
|
196
|
+
if self._server is not None:
|
|
197
|
+
try:
|
|
198
|
+
self._server.close_all()
|
|
199
|
+
except Exception:
|
|
200
|
+
logger.exception("Failed to close FTP server")
|
|
201
|
+
|
|
202
|
+
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
|
203
|
+
self._heartbeat_thread.join(timeout=5.0)
|
|
204
|
+
|
|
205
|
+
self._heartbeat_thread = None
|
|
206
|
+
self._server = None
|
|
207
|
+
|
|
208
|
+
def _on_stopped(self) -> None:
|
|
209
|
+
logger.info("FtpSource stopped")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Local folder clip source for production and development."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from anyio import Path as AsyncPath
|
|
14
|
+
|
|
15
|
+
from homesec.models.clip import Clip
|
|
16
|
+
from homesec.models.source import LocalFolderSourceConfig
|
|
17
|
+
from homesec.sources.base import AsyncClipSource
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from homesec.interfaces import StateStore
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalFolderSource(AsyncClipSource):
|
|
26
|
+
"""Watches a local folder for new video clips.
|
|
27
|
+
|
|
28
|
+
Production-ready async clip source that monitors a directory for new .mp4 files.
|
|
29
|
+
Uses anyio for non-blocking filesystem operations (glob, stat).
|
|
30
|
+
Suitable for both testing and production use with local camera storage.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: LocalFolderSourceConfig,
|
|
36
|
+
camera_name: str = "local",
|
|
37
|
+
state_store: "StateStore | None" = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize folder watcher.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: LocalFolder source configuration
|
|
43
|
+
camera_name: Name of the camera (used in Clip objects)
|
|
44
|
+
state_store: Optional state store for deduplication via clip_states table.
|
|
45
|
+
If None, falls back to in-memory cache only (may reprocess files after restart).
|
|
46
|
+
"""
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.watch_dir = Path(config.watch_dir)
|
|
49
|
+
self.camera_name = camera_name
|
|
50
|
+
self.poll_interval = float(config.poll_interval)
|
|
51
|
+
self.stability_threshold_s = float(config.stability_threshold_s)
|
|
52
|
+
self._state_store = state_store
|
|
53
|
+
|
|
54
|
+
# Ensure watch dir exists
|
|
55
|
+
self.watch_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
# Bounded in-memory cache for performance (avoid DB query on every scan)
|
|
58
|
+
# 10,000 files ≈ 100 days for 1 camera @ 100 clips/day (≈500 KB memory)
|
|
59
|
+
# When limit exceeded, oldest half are removed (FIFO eviction)
|
|
60
|
+
# This is just an optimization - clip_states table is source of truth
|
|
61
|
+
self._seen_files: OrderedDict[str, None] = OrderedDict()
|
|
62
|
+
self._max_seen_files = 10000
|
|
63
|
+
|
|
64
|
+
logger.info("LocalFolderSource initialized: watch_dir=%s, has_state_store=%s",
|
|
65
|
+
self.watch_dir, state_store is not None)
|
|
66
|
+
|
|
67
|
+
def register_callback(self, callback: Callable[[Clip], None]) -> None:
|
|
68
|
+
"""Register callback to be invoked when new clip is ready."""
|
|
69
|
+
super().register_callback(callback)
|
|
70
|
+
logger.debug("Callback registered for %s", self.camera_name)
|
|
71
|
+
|
|
72
|
+
def is_healthy(self) -> bool:
|
|
73
|
+
"""Check if source is healthy.
|
|
74
|
+
|
|
75
|
+
Returns True if:
|
|
76
|
+
- Watch directory exists and is readable
|
|
77
|
+
- Watch task is alive (if started)
|
|
78
|
+
"""
|
|
79
|
+
if not self.watch_dir.exists():
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
if not self.watch_dir.is_dir():
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# If started, task should be running
|
|
86
|
+
if not self._task_is_healthy():
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def _on_start(self) -> None:
|
|
92
|
+
logger.info("Starting LocalFolderSource: %s", self.watch_dir)
|
|
93
|
+
|
|
94
|
+
def _on_stop(self) -> None:
|
|
95
|
+
logger.info("Stopping LocalFolderSource...")
|
|
96
|
+
|
|
97
|
+
def _on_stopped(self) -> None:
|
|
98
|
+
logger.info("LocalFolderSource stopped")
|
|
99
|
+
|
|
100
|
+
async def _run(self) -> None:
|
|
101
|
+
"""Background task that polls for new files.
|
|
102
|
+
|
|
103
|
+
Uses anyio.Path for non-blocking filesystem operations to avoid
|
|
104
|
+
stalling the event loop on slow/network filesystems.
|
|
105
|
+
"""
|
|
106
|
+
logger.info("Watch loop started")
|
|
107
|
+
|
|
108
|
+
# Create async path wrapper for watch directory
|
|
109
|
+
async_watch_dir = AsyncPath(self.watch_dir)
|
|
110
|
+
|
|
111
|
+
while not self._stop_event.is_set():
|
|
112
|
+
try:
|
|
113
|
+
# Update heartbeat
|
|
114
|
+
self._touch_heartbeat()
|
|
115
|
+
|
|
116
|
+
# Scan for new .mp4 files (async to avoid blocking event loop)
|
|
117
|
+
async for async_file_path in async_watch_dir.glob("*.mp4"):
|
|
118
|
+
file_path = Path(async_file_path) # Convert to regular Path for Clip
|
|
119
|
+
file_id = str(file_path)
|
|
120
|
+
clip_id = file_path.stem
|
|
121
|
+
|
|
122
|
+
# Check in-memory cache first (fast path)
|
|
123
|
+
if file_id in self._seen_files:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Check file stability (avoid processing while still being written)
|
|
127
|
+
# Use async stat to avoid blocking event loop
|
|
128
|
+
try:
|
|
129
|
+
stat_info = await async_file_path.stat()
|
|
130
|
+
mtime = stat_info.st_mtime
|
|
131
|
+
age_s = time.time() - mtime
|
|
132
|
+
if age_s < self.stability_threshold_s:
|
|
133
|
+
logger.debug(
|
|
134
|
+
"Skipping unstable file: %s (modified %.1fs ago)",
|
|
135
|
+
file_path.name,
|
|
136
|
+
age_s,
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
except OSError as e:
|
|
140
|
+
logger.warning("Failed to stat file %s: %s", file_path, e, exc_info=True)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Check clip_states table (source of truth for deduplication)
|
|
144
|
+
# This prevents reprocessing files even after cache eviction or restart
|
|
145
|
+
if await self._has_clip_state(clip_id):
|
|
146
|
+
# File was already processed - add to cache and skip
|
|
147
|
+
self._add_to_cache(file_id)
|
|
148
|
+
logger.debug("Skipping already-processed file: %s", file_path.name)
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Mark as seen in cache
|
|
152
|
+
self._add_to_cache(file_id)
|
|
153
|
+
|
|
154
|
+
# Create Clip object (reuse mtime from async stat to avoid blocking)
|
|
155
|
+
clip = self._create_clip(file_path, mtime=mtime)
|
|
156
|
+
|
|
157
|
+
# Invoke callback
|
|
158
|
+
logger.info("New clip detected: %s", file_path.name)
|
|
159
|
+
self._emit_clip(clip)
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error("Error in watch loop: %s", e, exc_info=True)
|
|
163
|
+
|
|
164
|
+
# Sleep before next poll
|
|
165
|
+
try:
|
|
166
|
+
await asyncio.wait_for(
|
|
167
|
+
self._stop_event.wait(), timeout=self.poll_interval
|
|
168
|
+
)
|
|
169
|
+
except asyncio.TimeoutError:
|
|
170
|
+
pass # Normal - just means poll_interval elapsed
|
|
171
|
+
|
|
172
|
+
logger.info("Watch loop exited")
|
|
173
|
+
|
|
174
|
+
def _add_to_cache(self, file_id: str) -> None:
|
|
175
|
+
"""Add file to in-memory cache with FIFO eviction."""
|
|
176
|
+
self._seen_files[file_id] = None
|
|
177
|
+
if len(self._seen_files) > self._max_seen_files:
|
|
178
|
+
# Remove oldest half (FIFO eviction)
|
|
179
|
+
evict_count = self._max_seen_files // 2
|
|
180
|
+
for _ in range(evict_count):
|
|
181
|
+
self._seen_files.popitem(last=False)
|
|
182
|
+
logger.debug("Evicted %d old entries from seen files cache", evict_count)
|
|
183
|
+
|
|
184
|
+
async def _has_clip_state(self, clip_id: str) -> bool:
|
|
185
|
+
"""Check if clip_id exists in clip_states table.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if clip_state exists (any status including 'deleted'), False otherwise
|
|
189
|
+
"""
|
|
190
|
+
if self._state_store is None:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
# Direct async DB access - no threading complexity!
|
|
195
|
+
state = await asyncio.wait_for(
|
|
196
|
+
self._state_store.get(clip_id),
|
|
197
|
+
timeout=5.0,
|
|
198
|
+
)
|
|
199
|
+
return state is not None
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
logger.warning(
|
|
202
|
+
"DB query timeout for clip_states check: %s (assuming not seen)",
|
|
203
|
+
clip_id,
|
|
204
|
+
)
|
|
205
|
+
return False
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.warning(
|
|
208
|
+
"Error checking clip_states for %s: %s (assuming not seen)",
|
|
209
|
+
clip_id,
|
|
210
|
+
e,
|
|
211
|
+
exc_info=True,
|
|
212
|
+
)
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
def _create_clip(self, file_path: Path, mtime: float) -> Clip:
|
|
216
|
+
"""Create Clip object from file path.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
file_path: Path to the video file
|
|
220
|
+
mtime: File modification timestamp (from async stat)
|
|
221
|
+
|
|
222
|
+
Estimates timestamps based on file modification time.
|
|
223
|
+
"""
|
|
224
|
+
mtime_dt = datetime.fromtimestamp(mtime)
|
|
225
|
+
|
|
226
|
+
# Estimate clip duration (assume 10s if we can't determine)
|
|
227
|
+
# In production, would parse from filename or video metadata
|
|
228
|
+
duration_s = 10.0
|
|
229
|
+
|
|
230
|
+
return Clip(
|
|
231
|
+
clip_id=file_path.stem,
|
|
232
|
+
camera_name=self.camera_name,
|
|
233
|
+
local_path=file_path,
|
|
234
|
+
start_ts=mtime_dt - timedelta(seconds=duration_s),
|
|
235
|
+
end_ts=mtime_dt,
|
|
236
|
+
duration_s=duration_s,
|
|
237
|
+
source_type="local_folder",
|
|
238
|
+
)
|