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.
Files changed (62) hide show
  1. homesec/__init__.py +20 -0
  2. homesec/app.py +393 -0
  3. homesec/cli.py +159 -0
  4. homesec/config/__init__.py +18 -0
  5. homesec/config/loader.py +109 -0
  6. homesec/config/validation.py +82 -0
  7. homesec/errors.py +71 -0
  8. homesec/health/__init__.py +5 -0
  9. homesec/health/server.py +226 -0
  10. homesec/interfaces.py +249 -0
  11. homesec/logging_setup.py +176 -0
  12. homesec/maintenance/__init__.py +1 -0
  13. homesec/maintenance/cleanup_clips.py +632 -0
  14. homesec/models/__init__.py +79 -0
  15. homesec/models/alert.py +32 -0
  16. homesec/models/clip.py +71 -0
  17. homesec/models/config.py +362 -0
  18. homesec/models/events.py +184 -0
  19. homesec/models/filter.py +62 -0
  20. homesec/models/source.py +77 -0
  21. homesec/models/storage.py +12 -0
  22. homesec/models/vlm.py +99 -0
  23. homesec/pipeline/__init__.py +6 -0
  24. homesec/pipeline/alert_policy.py +5 -0
  25. homesec/pipeline/core.py +639 -0
  26. homesec/plugins/__init__.py +62 -0
  27. homesec/plugins/alert_policies/__init__.py +80 -0
  28. homesec/plugins/alert_policies/default.py +111 -0
  29. homesec/plugins/alert_policies/noop.py +60 -0
  30. homesec/plugins/analyzers/__init__.py +126 -0
  31. homesec/plugins/analyzers/openai.py +446 -0
  32. homesec/plugins/filters/__init__.py +124 -0
  33. homesec/plugins/filters/yolo.py +317 -0
  34. homesec/plugins/notifiers/__init__.py +80 -0
  35. homesec/plugins/notifiers/mqtt.py +189 -0
  36. homesec/plugins/notifiers/multiplex.py +106 -0
  37. homesec/plugins/notifiers/sendgrid_email.py +228 -0
  38. homesec/plugins/storage/__init__.py +116 -0
  39. homesec/plugins/storage/dropbox.py +272 -0
  40. homesec/plugins/storage/local.py +108 -0
  41. homesec/plugins/utils.py +63 -0
  42. homesec/py.typed +0 -0
  43. homesec/repository/__init__.py +5 -0
  44. homesec/repository/clip_repository.py +552 -0
  45. homesec/sources/__init__.py +17 -0
  46. homesec/sources/base.py +224 -0
  47. homesec/sources/ftp.py +209 -0
  48. homesec/sources/local_folder.py +238 -0
  49. homesec/sources/rtsp.py +1251 -0
  50. homesec/state/__init__.py +10 -0
  51. homesec/state/postgres.py +501 -0
  52. homesec/storage_paths.py +46 -0
  53. homesec/telemetry/__init__.py +0 -0
  54. homesec/telemetry/db/__init__.py +1 -0
  55. homesec/telemetry/db/log_table.py +16 -0
  56. homesec/telemetry/db_log_handler.py +246 -0
  57. homesec/telemetry/postgres_settings.py +42 -0
  58. homesec-0.1.0.dist-info/METADATA +446 -0
  59. homesec-0.1.0.dist-info/RECORD +62 -0
  60. homesec-0.1.0.dist-info/WHEEL +4 -0
  61. homesec-0.1.0.dist-info/entry_points.txt +2 -0
  62. homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -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
+ )