homesec 1.2.1__py3-none-any.whl → 1.2.2__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.
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from homesec.sources.rtsp.clock import Clock
9
+ from homesec.sources.rtsp.utils import _format_cmd, _is_timeout_option_error, _redact_rtsp_url
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Recorder(Protocol):
15
+ def start(self, output_file: Path, stderr_log: Path) -> subprocess.Popen[bytes] | None: ...
16
+
17
+ def stop(self, proc: subprocess.Popen[bytes], output_file: Path | None) -> None: ...
18
+
19
+ def is_alive(self, proc: subprocess.Popen[bytes]) -> bool: ...
20
+
21
+
22
+ class FfmpegRecorder:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ rtsp_url: str,
27
+ ffmpeg_flags: list[str],
28
+ rtsp_connect_timeout_s: float,
29
+ rtsp_io_timeout_s: float,
30
+ clock: Clock,
31
+ ) -> None:
32
+ self._rtsp_url = rtsp_url
33
+ self._ffmpeg_flags = ffmpeg_flags
34
+ self._rtsp_connect_timeout_s = rtsp_connect_timeout_s
35
+ self._rtsp_io_timeout_s = rtsp_io_timeout_s
36
+ self._clock = clock
37
+
38
+ def start(self, output_file: Path, stderr_log: Path) -> subprocess.Popen[bytes] | None:
39
+ def _read_tail(path: Path, max_bytes: int = 4000) -> str:
40
+ try:
41
+ data = path.read_bytes()
42
+ except Exception as exc:
43
+ logger.warning("Failed to read recording stderr tail: %s", exc, exc_info=True)
44
+ return ""
45
+ if len(data) <= max_bytes:
46
+ return data.decode(errors="replace")
47
+ return data[-max_bytes:].decode(errors="replace")
48
+
49
+ cmd_base = [
50
+ "ffmpeg",
51
+ "-rtsp_transport",
52
+ "tcp",
53
+ "-rtsp_flags",
54
+ "prefer_tcp",
55
+ "-user_agent",
56
+ "Lavf",
57
+ ]
58
+
59
+ user_flags = self._ffmpeg_flags
60
+ has_stimeout = any(x == "-stimeout" for x in user_flags)
61
+ has_rw_timeout = any(x == "-rw_timeout" for x in user_flags)
62
+ timeout_us_connect = str(int(max(0.1, self._rtsp_connect_timeout_s) * 1_000_000))
63
+ timeout_us_io = str(int(max(0.1, self._rtsp_io_timeout_s) * 1_000_000))
64
+
65
+ timeout_args: list[str] = []
66
+ if not has_stimeout and self._rtsp_connect_timeout_s > 0:
67
+ timeout_args.extend(["-stimeout", timeout_us_connect])
68
+ if not has_rw_timeout and self._rtsp_io_timeout_s > 0:
69
+ timeout_args.extend(["-rw_timeout", timeout_us_io])
70
+
71
+ cmd_tail = ["-i", self._rtsp_url, "-c", "copy", "-f", "mp4", "-y"]
72
+
73
+ # Naive check to see if user overrode defaults
74
+ # If user supplies ANY -loglevel, we don't add ours.
75
+ # If user supplies ANY -fflags, we don't add ours (to avoid concatenation complexity).
76
+ # This allows full user control.
77
+ has_loglevel = any(x == "-loglevel" for x in user_flags)
78
+ if not has_loglevel:
79
+ cmd_tail.extend(["-loglevel", "warning"])
80
+
81
+ has_fflags = any(x == "-fflags" for x in user_flags)
82
+ if not has_fflags:
83
+ cmd_tail.extend(["-fflags", "+genpts+igndts"])
84
+
85
+ has_fps_mode = any(x == "-fps_mode" or x == "-vsync" for x in user_flags)
86
+ if not has_fps_mode:
87
+ cmd_tail.extend(["-vsync", "0"])
88
+
89
+ # Add user flags last so they can potentially override or add to the above
90
+ cmd_tail.extend(user_flags)
91
+ cmd_tail.extend([str(output_file)])
92
+
93
+ attempts: list[tuple[str, list[str]]] = []
94
+ if timeout_args:
95
+ attempts.append(("timeouts", timeout_args))
96
+ attempts.append(("no_timeouts" if timeout_args else "default", []))
97
+
98
+ for label, extra_args in attempts:
99
+ cmd = list(cmd_base) + list(extra_args) + cmd_tail
100
+
101
+ safe_cmd = list(cmd)
102
+ try:
103
+ idx = safe_cmd.index("-i")
104
+ safe_cmd[idx + 1] = _redact_rtsp_url(str(safe_cmd[idx + 1]))
105
+ except Exception as exc:
106
+ logger.warning("Failed to redact recording RTSP URL: %s", exc, exc_info=True)
107
+ logger.debug("Recording ffmpeg (%s): %s", label, _format_cmd(safe_cmd))
108
+
109
+ try:
110
+ with open(stderr_log, "w") as stderr_file:
111
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=stderr_file)
112
+
113
+ self._clock.sleep(0.5)
114
+ if proc.poll() is None:
115
+ return proc
116
+
117
+ stderr_tail = _read_tail(stderr_log)
118
+ timeout_option_error = (
119
+ label == "timeouts"
120
+ and bool(stderr_tail)
121
+ and _is_timeout_option_error(stderr_tail)
122
+ )
123
+
124
+ if timeout_option_error:
125
+ logger.warning(
126
+ "Recording process died immediately (%s, exit code: %s); timeout options unsupported",
127
+ label,
128
+ proc.returncode,
129
+ )
130
+ logger.warning("Check logs at: %s", stderr_log)
131
+ else:
132
+ logger.error(
133
+ "Recording process died immediately (%s, exit code: %s)",
134
+ label,
135
+ proc.returncode,
136
+ )
137
+ logger.error("Check logs at: %s", stderr_log)
138
+
139
+ if stderr_tail:
140
+ redacted_tail = stderr_tail.replace(
141
+ self._rtsp_url, _redact_rtsp_url(self._rtsp_url)
142
+ )
143
+ if timeout_option_error:
144
+ logger.warning("Recording stderr tail (%s):\n%s", label, redacted_tail)
145
+ logger.warning(
146
+ "Recording ffmpeg missing timeout options; retrying without timeouts"
147
+ )
148
+ continue
149
+ logger.error("Recording stderr tail (%s):\n%s", label, redacted_tail)
150
+ if label == "timeouts":
151
+ return None
152
+ except Exception:
153
+ logger.exception("Failed to start recording")
154
+ return None
155
+
156
+ return None
157
+
158
+ def stop(self, proc: subprocess.Popen[bytes], output_file: Path | None) -> None:
159
+ try:
160
+ if proc.poll() is None:
161
+ proc.terminate()
162
+ proc.wait(timeout=5)
163
+ except subprocess.TimeoutExpired:
164
+ logger.warning("Recording process did not terminate, killing (PID: %s)", proc.pid)
165
+ try:
166
+ proc.kill()
167
+ proc.wait(timeout=2)
168
+ except Exception:
169
+ logger.exception("Failed to kill recording process (PID: %s)", proc.pid)
170
+ except Exception:
171
+ logger.exception("Failed while stopping recording process (PID: %s)", proc.pid)
172
+
173
+ logger.debug(
174
+ "Stopped recording: %s",
175
+ output_file,
176
+ extra={"recording_id": output_file.name if output_file else None},
177
+ )
178
+
179
+ def is_alive(self, proc: subprocess.Popen[bytes]) -> bool:
180
+ return proc.poll() is None
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shlex
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def _redact_rtsp_url(url: str) -> str:
10
+ if "://" not in url:
11
+ return url
12
+ scheme, rest = url.split("://", 1)
13
+ if "@" not in rest:
14
+ return url
15
+ _creds, host = rest.split("@", 1)
16
+ return f"{scheme}://***:***@{host}"
17
+
18
+
19
+ def _format_cmd(cmd: list[str]) -> str:
20
+ try:
21
+ return shlex.join([str(x) for x in cmd])
22
+ except Exception as exc:
23
+ logger.warning("Failed to format command with shlex.join: %s", exc, exc_info=True)
24
+ return " ".join([str(x) for x in cmd])
25
+
26
+
27
+ def _is_timeout_option_error(stderr_text: str) -> bool:
28
+ text = stderr_text.lower()
29
+ return ("rw_timeout" in text and ("not found" in text or "unrecognized option" in text)) or (
30
+ "stimeout" in text and ("not found" in text or "unrecognized option" in text)
31
+ )
32
+
33
+
34
+ def _next_backoff(backoff_s: float, cap_s: float, *, factor: float = 1.6) -> float:
35
+ return min(backoff_s * factor, cap_s)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: homesec
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Pluggable async home security camera pipeline with detection, VLM analysis, and alerts.
5
5
  Project-URL: Homepage, https://github.com/lan17/homesec
6
6
  Project-URL: Source, https://github.com/lan17/homesec
@@ -404,9 +404,11 @@ cameras:
404
404
  config:
405
405
  rtsp_url_env: DRIVEWAY_RTSP_URL
406
406
  output_dir: "./recordings"
407
- # Critical for camera compatibility:
408
- ffmpeg_flags: ["-rtsp_transport", "tcp", "-vsync", "0"]
409
- reconnect_backoff_s: 5
407
+ stream:
408
+ # Critical for camera compatibility:
409
+ ffmpeg_flags: ["-rtsp_transport", "tcp", "-vsync", "0"]
410
+ reconnect:
411
+ backoff_s: 5
410
412
 
411
413
  filter:
412
414
  plugin: yolo
@@ -488,7 +490,7 @@ HomeSec uses a plugin architecture where every component is discovered at runtim
488
490
 
489
491
  | Type | Plugins |
490
492
  |------|---------|
491
- | Sources | [`rtsp`](src/homesec/sources/rtsp.py), [`ftp`](src/homesec/sources/ftp.py), [`local_folder`](src/homesec/sources/local_folder.py) |
493
+ | Sources | [`rtsp`](src/homesec/sources/rtsp/core.py), [`ftp`](src/homesec/sources/ftp.py), [`local_folder`](src/homesec/sources/local_folder.py) |
492
494
  | Filters | [`yolo`](src/homesec/plugins/filters/yolo.py) |
493
495
  | Storage | [`dropbox`](src/homesec/plugins/storage/dropbox.py), [`local`](src/homesec/plugins/storage/local.py) |
494
496
  | VLM analyzers | [`openai`](src/homesec/plugins/analyzers/openai.py) |
@@ -13,16 +13,19 @@ homesec/health/__init__.py,sha256=fbndfsLOR9aA7d_5I1mEZN0oM5IYMmcJNjOt0iaXKZc,10
13
13
  homesec/health/server.py,sha256=VP-4XmZ0K3ooFyd000AFOZohZ5R7QcnZC4n7oj0RMqI,7014
14
14
  homesec/maintenance/__init__.py,sha256=6a5W2x8oUgnoWaK374-Wq_nrOD5UDAUqUtSANaEck2M,60
15
15
  homesec/maintenance/cleanup_clips.py,sha256=sqeWLE9GrJfe29ntCasgvT2M-FWTymydHDQG-fZ3khg,23451
16
- homesec/models/__init__.py,sha256=IVmkIEgTe1SvOzXilWw6BwSRQezAeBbqAt5y6EdZV5o,1955
16
+ homesec/models/__init__.py,sha256=0gcTdSg0CW77Hy3D7wlCyyYvvBJMdD51BL5uIiz4Vfc,2043
17
17
  homesec/models/alert.py,sha256=cMFr4NUGygq0-m2ep0jhjzVrFKM7kN1wJON1J0XwGY4,1010
18
18
  homesec/models/clip.py,sha256=QaftnY7q20fQX4BTX6j-FBZLaoVSgk0DbpNf8kdnT60,2225
19
- homesec/models/config.py,sha256=Z70xKrbDHi96j8IoRXPBw7DYAI7sjHR4M6nqReHTNds,14184
19
+ homesec/models/config.py,sha256=5V0_RNIh-hqKA8OCU5EdBwQVRbZ3nmkqoay54V6vgVE,14272
20
20
  homesec/models/enums.py,sha256=WQk1PvYnJd5iaz51P5GM3XayqCf0VpQnLYmSuw1hrZk,3293
21
21
  homesec/models/events.py,sha256=sgPDCSp9w60VUKKREYxNBZaxrFWW_bYyLwMFGHBawjk,4942
22
22
  homesec/models/filter.py,sha256=6NS1rBI2zmrswK9NtMn0vZlbwbeMGCPuLCDKP9XWN0I,2259
23
- homesec/models/source.py,sha256=F8ksGrOa09bxx5IEgQmxPWJNWOm8zvFLsRJxihJTlM4,2367
24
23
  homesec/models/storage.py,sha256=63wyHdDt3QrfdsP0SmhrxtOeWRllZ1O2GPrA4jI7XmU,235
25
24
  homesec/models/vlm.py,sha256=Uk6TPwqbKxzyAsOlBSzZru74nKjp2-LLyzIp5b3wM_c,3293
25
+ homesec/models/source/__init__.py,sha256=vZFpll5ftShIGe_VOoqQ6XiFZKKAKqgZ3bOW9feB_VM,60
26
+ homesec/models/source/ftp.py,sha256=ZRU0YxNa99qTlCS2pIXKl8kmM1KQyIHOHyaFox6XPKk,2871
27
+ homesec/models/source/local_folder.py,sha256=ymneTjcCzvLzFmen3GF8kYfGIlxWgb10BDvgdXnWFcQ,803
28
+ homesec/models/source/rtsp.py,sha256=h_XzIM22JKHA0Mz4IUZMTPlzKkU_U7sSF6p5PPIVoWw,4852
26
29
  homesec/pipeline/__init__.py,sha256=kiQLECc6JIPmeIdBJrVpTApPs0GBAgWoZ1kU4XZyJVY,214
27
30
  homesec/pipeline/alert_policy.py,sha256=gFl5SJ96fgEfEUnhSL51YA6O2GPGXTXmxaDC-q3h1rs,152
28
31
  homesec/pipeline/core.py,sha256=D552e-xpIpom5C-Y_TkWB9Ufbm8xClGmCdo_4tseXuU,23840
@@ -41,19 +44,26 @@ homesec/plugins/notifiers/mqtt.py,sha256=1zUKUHFvT65ysawFXEwWHvY8rg310fRsSSKhIFe
41
44
  homesec/plugins/notifiers/multiplex.py,sha256=LlnwozjkMDQwz7__v7mT4AohZbiWZK39CZunamRp7FM,3676
42
45
  homesec/plugins/notifiers/sendgrid_email.py,sha256=gZSv3FRaN8qCMO6D-MX8b6XVz-gSgrFhkFV6j1ILdi4,8682
43
46
  homesec/plugins/sources/__init__.py,sha256=weLYuCLrmWIUvRTYmfgqVcOFHonZgTngDKFSks4yg8s,1025
44
- homesec/plugins/sources/ftp.py,sha256=hdMcjC3lhCAdn9ZnuYNZbKbUWsXUOcql1r_OJXoCPso,849
45
- homesec/plugins/sources/local_folder.py,sha256=nCAJr0l8FpUpI3amkOPDN69XkAYtxgez3utq1jNRdPQ,1214
46
- homesec/plugins/sources/rtsp.py,sha256=PbSrDEcvbTPq6xnA6i87DLCEtxHWdMVBjd6QQm1DAsc,814
47
+ homesec/plugins/sources/ftp.py,sha256=c2HktqWlzQyRDMVMVEb6R9zIK-Q1AyJMHPUp92jHhyM,853
48
+ homesec/plugins/sources/local_folder.py,sha256=zHyMT-PKEJCaf4mw29TBjj-jK_oQmO3zPyOAt4n5HWI,1227
49
+ homesec/plugins/sources/rtsp.py,sha256=rqWtOOKgxGqC2lwdgkEdpH_xMatH2-3xn_Kt_biAA20,824
47
50
  homesec/plugins/storage/__init__.py,sha256=oJPrjgpke7VK3MY8L-GGmDVB4eWx7IgXcmVQXMcMw7g,1235
48
51
  homesec/plugins/storage/dropbox.py,sha256=AQkEFV4lXqS1pbVazNuskEaEpr5CvLkrYdUK8EE7eAM,9992
49
52
  homesec/plugins/storage/local.py,sha256=CbuSaWEi9ft1zxOURcmOKzwyq0UXIyHqyHoQD6sxivI,3231
50
53
  homesec/repository/__init__.py,sha256=6cye2uQIA2v6jeLk5D2S9y3rlkfzJH5GceqdOroF3hU,160
51
54
  homesec/repository/clip_repository.py,sha256=nRcswsIX--Z9p7J33FsqNlDtMAmt438VUbfvZHX0FlY,17090
52
- homesec/sources/__init__.py,sha256=wuCtiF44ceo7n3wJN51VHHcDavko3ubUDICtFbWmaRI,505
55
+ homesec/sources/__init__.py,sha256=zjjoGbpha4Aqx2Y3fca743gs6gEyYDtRjUbCCwJ0NG0,598
53
56
  homesec/sources/base.py,sha256=dKTxJxcDwJtykWDN3WYzkW5mtkRqlOJxJLWcLy82_Zo,7582
54
- homesec/sources/ftp.py,sha256=ynIPbgcbIi1jub8yr4H1259Y1HbNM42RFDBBivXD4mg,7308
55
- homesec/sources/local_folder.py,sha256=eW7ghgRsqTnZ5ZMPbsXh9ntqfue1UeM29ZpvRvLthPA,8461
56
- homesec/sources/rtsp.py,sha256=T_DoAExu58pukRDjPNZ7wELDIebbQpVFrg29L9xACa8,48866
57
+ homesec/sources/ftp.py,sha256=t1XFo7aN31mN4wFfSVJuJhA2o_nFi1zQQZCl4VZGQlA,7312
58
+ homesec/sources/local_folder.py,sha256=UduSyDKesT5DBoBx8Qjp7tAZatUR6bvXTEHzC7VoLWQ,8474
59
+ homesec/sources/rtsp/__init__.py,sha256=wtBzdwzL7Cg0HyIGIpS3lBagekCAi_EYJOyCZFbT7K0,103
60
+ homesec/sources/rtsp/clock.py,sha256=Gf-CLBfgUmxfajmZim89vmWRG14hnc7iUUBVNNiUz6w,338
61
+ homesec/sources/rtsp/core.py,sha256=tX6FH_ete2Xop-6O7CzH-a0A42OaCa2-jUQXjl5oQ0E,48235
62
+ homesec/sources/rtsp/frame_pipeline.py,sha256=cIGrH5z4WE8swoAThTl7Wu3SViNq-00YyCcKMLBrCso,11620
63
+ homesec/sources/rtsp/hardware.py,sha256=sKCJhoVdmkDfCk2s5RL0lDPX_CuvcwlgVUESgRJK3us,4892
64
+ homesec/sources/rtsp/motion.py,sha256=FKDa1hvD_H3uxgnQ-Z0c7MhVd6u_35cO6XKZRVrXpaY,2870
65
+ homesec/sources/rtsp/recorder.py,sha256=fqL-zr-vtnFrDQc87zZFYeM-6JSUfMwQsc0zfR-mgy4,7009
66
+ homesec/sources/rtsp/utils.py,sha256=aufPAP6oc39kyFAiN0HTDhdXf3bQqWSVeVWNIHx5MmI,1021
57
67
  homesec/state/__init__.py,sha256=Evt1jqTebmpJD1NUzNh3vwt5pbjDlLjQ0DgMCSAZOuM,255
58
68
  homesec/state/postgres.py,sha256=I-cXqW5cgz-hpaHc0JIv3DnIBTmGxE28P8ZxBAGabSw,17765
59
69
  homesec/telemetry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -61,8 +71,8 @@ homesec/telemetry/db_log_handler.py,sha256=KM8g4kcOyPzFJbpGxpSzecx_hrEWY0YfpoIKy
61
71
  homesec/telemetry/postgres_settings.py,sha256=EVD2_oi_KReFJvQmXxW026aurl_YD-KexT7rkbGQPHc,1198
62
72
  homesec/telemetry/db/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
63
73
  homesec/telemetry/db/log_table.py,sha256=wcZLwRht7FMa0z2gf37f_RxdVTNIdDiK4i_N3c_ibwg,473
64
- homesec-1.2.1.dist-info/METADATA,sha256=UqWtHMZ6XjaykxI7ya_Fu-fwUTHVAEMl15sBfEbysjI,25121
65
- homesec-1.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
66
- homesec-1.2.1.dist-info/entry_points.txt,sha256=8ocCj_fP1qxIuL-DVDAUiaUbEdTMX_kg_BzVrJsbQYg,45
67
- homesec-1.2.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
68
- homesec-1.2.1.dist-info/RECORD,,
74
+ homesec-1.2.2.dist-info/METADATA,sha256=dYxpcVPEWLuX_TLZIEZZywjcgYgJO--J1IGgDu8aUyQ,25157
75
+ homesec-1.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
76
+ homesec-1.2.2.dist-info/entry_points.txt,sha256=8ocCj_fP1qxIuL-DVDAUiaUbEdTMX_kg_BzVrJsbQYg,45
77
+ homesec-1.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
78
+ homesec-1.2.2.dist-info/RECORD,,
homesec/models/source.py DELETED
@@ -1,81 +0,0 @@
1
- """Source configuration models."""
2
-
3
- from __future__ import annotations
4
-
5
- from pydantic import BaseModel, Field, field_validator
6
-
7
-
8
- class RTSPSourceConfig(BaseModel):
9
- """RTSP source configuration."""
10
-
11
- model_config = {"extra": "forbid"}
12
-
13
- camera_name: str | None = None
14
- rtsp_url_env: str | None = None
15
- rtsp_url: str | None = None
16
- detect_rtsp_url_env: str | None = None
17
- detect_rtsp_url: str | None = None
18
- output_dir: str = "./recordings"
19
- pixel_threshold: int = 45
20
- min_changed_pct: float = 1.0
21
- blur_kernel: int = 5
22
- stop_delay: float = 10.0
23
- max_recording_s: float = 60.0
24
- max_reconnect_attempts: int = 20
25
- disable_hwaccel: bool = False
26
- frame_timeout_s: float = 2.0
27
- frame_queue_size: int = 20
28
- reconnect_backoff_s: float = 1.0
29
- debug_motion: bool = False
30
- heartbeat_s: float = 30.0
31
- rtsp_connect_timeout_s: float = 2.0
32
- rtsp_io_timeout_s: float = 2.0
33
- ffmpeg_flags: list[str] = Field(default_factory=list)
34
-
35
-
36
- class LocalFolderSourceConfig(BaseModel):
37
- """Local folder source configuration."""
38
-
39
- model_config = {"extra": "forbid"}
40
-
41
- camera_name: str | None = None
42
- watch_dir: str = "recordings"
43
- poll_interval: float = 1.0
44
- stability_threshold_s: float = 3.0
45
-
46
-
47
- class FtpSourceConfig(BaseModel):
48
- """FTP source configuration."""
49
-
50
- model_config = {"extra": "forbid"}
51
-
52
- camera_name: str | None = None
53
- host: str = "0.0.0.0"
54
- port: int = 2121
55
- root_dir: str = "./ftp_incoming"
56
- ftp_subdir: str | None = None
57
- anonymous: bool = True
58
- username_env: str | None = None
59
- password_env: str | None = None
60
- perms: str = "elw"
61
- passive_ports: str | None = None
62
- masquerade_address: str | None = None
63
- heartbeat_s: float = 30.0
64
- allowed_extensions: list[str] = Field(default_factory=lambda: [".mp4"])
65
- delete_non_matching: bool = True
66
- delete_incomplete: bool = True
67
- default_duration_s: float = 10.0
68
- log_level: str = "INFO"
69
-
70
- @field_validator("allowed_extensions")
71
- @classmethod
72
- def _normalize_extensions(cls, value: list[str]) -> list[str]:
73
- cleaned: list[str] = []
74
- for item in value:
75
- ext = str(item).strip().lower()
76
- if not ext:
77
- continue
78
- if not ext.startswith("."):
79
- ext = f".{ext}"
80
- cleaned.append(ext)
81
- return cleaned