updl 3.0.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 (44) hide show
  1. protect_archiver/__init__.py +0 -0
  2. protect_archiver/cli/__init__.py +43 -0
  3. protect_archiver/cli/base.py +13 -0
  4. protect_archiver/cli/download.py +333 -0
  5. protect_archiver/cli/events.py +297 -0
  6. protect_archiver/cli/resolve.py +27 -0
  7. protect_archiver/cli/sync.py +319 -0
  8. protect_archiver/cli/verify.py +263 -0
  9. protect_archiver/client/__init__.py +109 -0
  10. protect_archiver/client/legacy.py +72 -0
  11. protect_archiver/client/unifi_os.py +214 -0
  12. protect_archiver/config.py +36 -0
  13. protect_archiver/dataclasses.py +24 -0
  14. protect_archiver/downloader/__init__.py +81 -0
  15. protect_archiver/downloader/download_file.py +262 -0
  16. protect_archiver/downloader/download_footage.py +162 -0
  17. protect_archiver/downloader/download_motion_event.py +61 -0
  18. protect_archiver/downloader/download_snapshot.py +48 -0
  19. protect_archiver/downloader/get_camera_list.py +57 -0
  20. protect_archiver/downloader/get_motion_event_list.py +85 -0
  21. protect_archiver/errors.py +44 -0
  22. protect_archiver/manifest.py +353 -0
  23. protect_archiver/reconcile.py +148 -0
  24. protect_archiver/session_store.py +161 -0
  25. protect_archiver/settings.py +160 -0
  26. protect_archiver/sync.py +175 -0
  27. protect_archiver/test_client.py +116 -0
  28. protect_archiver/test_download_file.py +244 -0
  29. protect_archiver/test_manifest.py +100 -0
  30. protect_archiver/test_reconcile.py +101 -0
  31. protect_archiver/test_session_store.py +96 -0
  32. protect_archiver/test_settings.py +126 -0
  33. protect_archiver/test_sync.py +346 -0
  34. protect_archiver/test_unifi_os_client.py +212 -0
  35. protect_archiver/test_utils.py +129 -0
  36. protect_archiver/test_verify.py +183 -0
  37. protect_archiver/utils.py +205 -0
  38. protect_archiver/verify.py +255 -0
  39. updl-3.0.0.dist-info/METADATA +214 -0
  40. updl-3.0.0.dist-info/RECORD +44 -0
  41. updl-3.0.0.dist-info/WHEEL +4 -0
  42. updl-3.0.0.dist-info/entry_points.txt +4 -0
  43. updl-3.0.0.dist-info/licenses/LICENSE +22 -0
  44. updl-3.0.0.dist-info/licenses/NOTICE +35 -0
File without changes
@@ -0,0 +1,43 @@
1
+ from typing import List
2
+ from typing import Sequence
3
+ from typing import Set
4
+
5
+ from .download import * # NOQA
6
+ from .events import * # NOQA
7
+ from .sync import * # NOQA
8
+ from .verify import * # NOQA
9
+
10
+
11
+ def main() -> None:
12
+ import logging
13
+ import os
14
+
15
+ import urllib3
16
+
17
+ logging.basicConfig(format="%(message)s", level=logging.INFO)
18
+
19
+ os.environ.setdefault("PYTHONUNBUFFERED", "true")
20
+
21
+ # disable InsecureRequestWarning for unverified HTTPS requests
22
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
23
+
24
+ import sys
25
+
26
+ from .base import cli
27
+
28
+ cli.main(args=resolve_argv(sys.argv[1:], set(cli.commands)))
29
+
30
+
31
+ def resolve_argv(argv: Sequence[str], commands: Set[str]) -> List[str]:
32
+ """Let `updl` with no sub-command mean `updl sync`.
33
+
34
+ A machine that has run once before has its address, account and destination
35
+ remembered, so the useful default for a bare invocation is to get on with the sync.
36
+ Anything that already names a sub-command is left alone, and so is the top-level
37
+ help, which must not turn into `sync --help`.
38
+ """
39
+ if not argv:
40
+ return ["sync"]
41
+ if argv[0] in commands or argv[0] in ("--help", "-h"):
42
+ return list(argv)
43
+ return ["sync", *argv]
@@ -0,0 +1,13 @@
1
+ import click
2
+
3
+ from protect_archiver import settings
4
+
5
+
6
+ @click.group(invoke_without_command=True)
7
+ @click.pass_context
8
+ def cli(ctx: click.Context) -> None:
9
+ # Values remembered by a previous run become defaults for the options that were not
10
+ # supplied on the command line or through the environment. Click applies a
11
+ # default_map only after those two, which gives the precedence documented in
12
+ # protect_archiver.settings.
13
+ ctx.default_map = settings.as_default_map()
@@ -0,0 +1,333 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ import click
5
+
6
+ from protect_archiver.cli.base import cli
7
+ from protect_archiver.client import ProtectClient
8
+ from protect_archiver.config import Config
9
+ from protect_archiver.downloader import Downloader
10
+ from protect_archiver.errors import ProtectError
11
+ from protect_archiver.utils import print_download_stats
12
+
13
+
14
+ @cli.command("download", help="Download footage from a local UniFi Protect system")
15
+ @click.argument("dest", type=click.Path(exists=True, writable=True, resolve_path=True))
16
+ @click.option(
17
+ "--address",
18
+ default=Config.ADDRESS,
19
+ show_default=True,
20
+ required=True,
21
+ help="IP address or hostname of the UniFi Protect Server",
22
+ envvar="PROTECT_ADDRESS",
23
+ show_envvar=True,
24
+ )
25
+ @click.option(
26
+ "--port",
27
+ default=Config.PORT,
28
+ show_default=True,
29
+ required=False,
30
+ help="The port of the UniFi Protect Server",
31
+ envvar="PROTECT_PORT",
32
+ show_envvar=True,
33
+ )
34
+ @click.option(
35
+ "--not-unifi-os",
36
+ is_flag=True,
37
+ default=False,
38
+ show_default=True,
39
+ help="Use this for systems without UniFi OS",
40
+ envvar="PROTECT_NOT_UNIFI_OS",
41
+ show_envvar=True,
42
+ )
43
+ @click.option(
44
+ "--username",
45
+ required=True,
46
+ help="Username of user with local access.",
47
+ prompt="Username of local Protect user",
48
+ envvar="PROTECT_USERNAME",
49
+ show_envvar=True,
50
+ )
51
+ @click.option(
52
+ "--password",
53
+ required=True,
54
+ help="Password of user with local access",
55
+ prompt="Password for local Protect user",
56
+ hide_input=True,
57
+ envvar="PROTECT_PASSWORD",
58
+ show_envvar=True,
59
+ )
60
+ @click.option(
61
+ "--mfa-code",
62
+ required=False,
63
+ default=None,
64
+ help=(
65
+ "One-time multi-factor code, for accounts backed by Ubiquiti SSO. "
66
+ "If omitted and a code is required, it is prompted for. The resulting session "
67
+ "is cached, so this is normally only needed occasionally."
68
+ ),
69
+ envvar="PROTECT_MFA_CODE",
70
+ show_envvar=True,
71
+ )
72
+ @click.option(
73
+ "--no-session-store",
74
+ is_flag=True,
75
+ default=False,
76
+ show_default=True,
77
+ help=(
78
+ "Do not cache the session token between runs. Every run will then need a fresh "
79
+ "login, and a fresh multi-factor code if the account requires one."
80
+ ),
81
+ envvar="PROTECT_NO_SESSION_STORE",
82
+ show_envvar=True,
83
+ )
84
+ @click.option(
85
+ "--verify-ssl",
86
+ is_flag=True,
87
+ default=False,
88
+ show_default=True,
89
+ help="Verify Protect SSL certificate",
90
+ envvar="PROTECT_VERIFY_SSL",
91
+ show_envvar=True,
92
+ )
93
+ @click.option(
94
+ "--cameras",
95
+ default="all",
96
+ show_default=True,
97
+ help=(
98
+ "Comma-separated list of one or more camera IDs ('--cameras=\"id_1,id_2,id_3,...\"'). "
99
+ "Use '--cameras=all' to download footage of all available cameras."
100
+ ),
101
+ envvar="PROTECT_CAMERAS",
102
+ show_envvar=True,
103
+ )
104
+ @click.option(
105
+ "--wait-between-downloads",
106
+ "download_wait",
107
+ default=0,
108
+ show_default=True,
109
+ help="Time to wait between file downloads, in seconds",
110
+ envvar="PROTECT_WAIT_BETWEEN_DOWNLOADS",
111
+ show_envvar=True,
112
+ )
113
+ @click.option(
114
+ "--ignore-failed-downloads",
115
+ is_flag=True,
116
+ default=False,
117
+ show_default=True,
118
+ help="Ignore failed downloads and continue with next download",
119
+ envvar="PROTECT_IGNORE_FAILED_DOWNLOADS",
120
+ show_envvar=True,
121
+ )
122
+ @click.option(
123
+ "--skip-existing-files",
124
+ is_flag=True,
125
+ default=False,
126
+ show_default=True,
127
+ help="Skip downloading files which already exist on disk",
128
+ envvar="PROTECT_SKIP_EXISTING",
129
+ show_envvar=True,
130
+ )
131
+ @click.option(
132
+ "--touch-files",
133
+ is_flag=True,
134
+ default=False,
135
+ show_default=True,
136
+ help=(
137
+ "Create local file without content for current download - "
138
+ "useful in combination with '--skip-existing-files' to skip problematic segments"
139
+ ),
140
+ envvar="PROTECT_TOUCH_FILES",
141
+ show_envvar=True,
142
+ )
143
+ @click.option(
144
+ "--use-subfolders/--no-use-subfolders",
145
+ default=True,
146
+ show_default=True,
147
+ help="Save footage to folder structure with format 'YYYY/MM/DD/camera_name/'",
148
+ envvar="PROTECT_USE_SUBFOLDERS",
149
+ show_envvar=True,
150
+ )
151
+ @click.option(
152
+ "--download-request-timeout",
153
+ "download_timeout",
154
+ default=60.0,
155
+ show_default=True,
156
+ help="Time to wait before aborting download request, in seconds",
157
+ envvar="PROTECT_DOWNLOAD_TIMEOUT",
158
+ show_envvar=True,
159
+ )
160
+ @click.option(
161
+ "--start",
162
+ type=click.DateTime(
163
+ formats=[
164
+ "%Y-%m-%d",
165
+ "%Y-%m-%dT%H:%M:%S",
166
+ "%Y-%m-%d %H:%M:%S",
167
+ "%Y-%m-%d %H:%M:%S%z",
168
+ ]
169
+ ),
170
+ required=False,
171
+ help=(
172
+ "Download range start time. "
173
+ # TODO(danielfernau): uncomment the next line as soon as the feature is implemented
174
+ # "If omitted, the time of the first available recording for each camera will be used."
175
+ ),
176
+ envvar="PROTECT_START_TIME",
177
+ show_envvar=True,
178
+ )
179
+ @click.option(
180
+ "--end",
181
+ type=click.DateTime(
182
+ formats=[
183
+ "%Y-%m-%d",
184
+ "%Y-%m-%dT%H:%M:%S",
185
+ "%Y-%m-%d %H:%M:%S",
186
+ "%Y-%m-%d %H:%M:%S%z",
187
+ ]
188
+ ),
189
+ required=False,
190
+ help=(
191
+ "Download range end time. "
192
+ # TODO(danielfernau): uncomment the next line as soon as the feature is implemented
193
+ # "If omitted, the time of the last available recording for each camera will be used."
194
+ ),
195
+ envvar="PROTECT_END_TIME",
196
+ show_envvar=True,
197
+ )
198
+ @click.option(
199
+ "--disable-alignment",
200
+ "disable_alignment",
201
+ is_flag=True,
202
+ default=False,
203
+ show_default=True,
204
+ help=(
205
+ "Disables alignment of the 1-hour segments to absolute hours. "
206
+ "If set, results in 8:45, 9:45, 10:45 instead of 8:45, 9:00, 10:00, 10:45."
207
+ ),
208
+ envvar="PROTECT_DISABLE_ALIGNMENT",
209
+ show_envvar=True,
210
+ )
211
+ @click.option(
212
+ "--disable-splitting",
213
+ "disable_splitting",
214
+ is_flag=True,
215
+ default=False,
216
+ show_default=True,
217
+ help=(
218
+ "Disables splitting the datetime selection in 1-hour segments. "
219
+ "USE WITH CAUTION: requesting segments longer than 1 hour via the "
220
+ "API can cause the Protect console to crash and restart unexpectedly."
221
+ ),
222
+ envvar="PROTECT_DISABLE_SPLITTING",
223
+ show_envvar=True,
224
+ )
225
+ @click.option(
226
+ "--snapshot",
227
+ "create_snapshot",
228
+ is_flag=True,
229
+ default=False,
230
+ show_default=True,
231
+ help=(
232
+ "Capture and download a snapshot from the specified camera(s). "
233
+ "This flag cannot be used in combination with the normal video download mode."
234
+ ),
235
+ envvar="PROTECT_CREATE_SNAPSHOT",
236
+ show_envvar=True,
237
+ )
238
+ @click.option(
239
+ "--use-utc-filenames",
240
+ is_flag=True,
241
+ default=False,
242
+ show_default=True,
243
+ help="Use UTC timestamp in file names instead of local time",
244
+ envvar="PROTECT_USE_UTC",
245
+ show_envvar=True,
246
+ )
247
+ def download(
248
+ dest: str,
249
+ address: str,
250
+ port: int,
251
+ not_unifi_os: bool,
252
+ username: str,
253
+ password: str,
254
+ mfa_code: Optional[str],
255
+ no_session_store: bool,
256
+ verify_ssl: bool,
257
+ cameras: str,
258
+ download_wait: int,
259
+ download_timeout: int,
260
+ use_subfolders: bool,
261
+ touch_files: bool,
262
+ skip_existing_files: bool,
263
+ ignore_failed_downloads: bool,
264
+ start: datetime,
265
+ end: datetime,
266
+ disable_alignment: bool,
267
+ disable_splitting: bool,
268
+ create_snapshot: bool,
269
+ use_utc_filenames: bool,
270
+ ) -> None:
271
+ # check the provided command line arguments
272
+ # TODO(danielfernau): remove exit codes 1 (path invalid) and 6 (start/end/snapshot) from docs: no longer valid
273
+
274
+ if create_snapshot:
275
+ if start or end:
276
+ click.echo(
277
+ "The arguments --start and --end are ignored when using the --snapshot option"
278
+ )
279
+ start = datetime.now()
280
+
281
+ client = ProtectClient(
282
+ address=address,
283
+ port=port,
284
+ not_unifi_os=not_unifi_os,
285
+ username=username,
286
+ password=password,
287
+ mfa_code=mfa_code,
288
+ use_session_store=not no_session_store,
289
+ verify_ssl=verify_ssl,
290
+ ignore_failed_downloads=ignore_failed_downloads,
291
+ destination_path=dest,
292
+ use_subfolders=use_subfolders,
293
+ download_wait=download_wait,
294
+ skip_existing_files=skip_existing_files,
295
+ touch_files=touch_files,
296
+ download_timeout=download_timeout,
297
+ use_utc_filenames=use_utc_filenames,
298
+ )
299
+
300
+ try:
301
+ # get camera list
302
+ click.echo("Getting camera list")
303
+ camera_list = client.get_camera_list()
304
+ session = client.get_session()
305
+
306
+ if cameras != "all":
307
+ camera_s = set(cameras.split(","))
308
+ camera_list = [c for c in camera_list if c["id"] in camera_s]
309
+
310
+ if not create_snapshot:
311
+ for camera in camera_list:
312
+ # noinspection PyUnboundLocalVariable
313
+ click.echo(
314
+ f"Downloading video files between {start} and {end} from"
315
+ f" '{session.authority}{session.base_path}/video/export' for camera"
316
+ f" {camera.name}"
317
+ )
318
+
319
+ Downloader.download_footage(
320
+ client, start, end, camera, disable_alignment, disable_splitting
321
+ )
322
+ else:
323
+ click.echo(
324
+ f"Downloading snapshot files for {start.ctime()}"
325
+ f" from '{session.authority}{session.base_path}/cameras/[camera_id]/snapshot'"
326
+ )
327
+ for camera in camera_list:
328
+ Downloader.download_snapshot(client, start, camera)
329
+
330
+ print_download_stats(client)
331
+
332
+ except ProtectError as e:
333
+ exit(e.code)
@@ -0,0 +1,297 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ import click
5
+
6
+ from protect_archiver.cli.base import cli
7
+ from protect_archiver.client import ProtectClient
8
+ from protect_archiver.config import Config
9
+ from protect_archiver.downloader import Downloader
10
+ from protect_archiver.errors import ProtectError
11
+ from protect_archiver.utils import print_download_stats
12
+
13
+
14
+ @cli.command("events", help="Download event recordings from UniFi Protect to a local destination")
15
+ @click.argument("dest", type=click.Path(exists=True, writable=True, resolve_path=True))
16
+ @click.option(
17
+ "--address",
18
+ default=Config.ADDRESS,
19
+ show_default=True,
20
+ required=True,
21
+ help="IP address or hostname of the UniFi Protect Server",
22
+ envvar="PROTECT_ADDRESS",
23
+ show_envvar=True,
24
+ )
25
+ @click.option(
26
+ "--port",
27
+ default=Config.PORT,
28
+ show_default=True,
29
+ required=False,
30
+ help="The port of the UniFi Protect Server",
31
+ envvar="PROTECT_PORT",
32
+ show_envvar=True,
33
+ )
34
+ @click.option(
35
+ "--not-unifi-os",
36
+ is_flag=True,
37
+ default=False,
38
+ show_default=True,
39
+ help="Use this for systems without UniFi OS",
40
+ envvar="PROTECT_NOT_UNIFI_OS",
41
+ show_envvar=True,
42
+ )
43
+ @click.option(
44
+ "--username",
45
+ required=True,
46
+ help="Username of user with local access",
47
+ prompt="Username of local Protect user",
48
+ envvar="PROTECT_USERNAME",
49
+ show_envvar=True,
50
+ )
51
+ @click.option(
52
+ "--password",
53
+ required=True,
54
+ help="Password of user with local access",
55
+ prompt="Password for local Protect user",
56
+ hide_input=True,
57
+ envvar="PROTECT_PASSWORD",
58
+ show_envvar=True,
59
+ )
60
+ @click.option(
61
+ "--mfa-code",
62
+ required=False,
63
+ default=None,
64
+ help=(
65
+ "One-time multi-factor code, for accounts backed by Ubiquiti SSO. "
66
+ "If omitted and a code is required, it is prompted for. The resulting session "
67
+ "is cached, so this is normally only needed occasionally."
68
+ ),
69
+ envvar="PROTECT_MFA_CODE",
70
+ show_envvar=True,
71
+ )
72
+ @click.option(
73
+ "--no-session-store",
74
+ is_flag=True,
75
+ default=False,
76
+ show_default=True,
77
+ help=(
78
+ "Do not cache the session token between runs. Every run will then need a fresh "
79
+ "login, and a fresh multi-factor code if the account requires one."
80
+ ),
81
+ envvar="PROTECT_NO_SESSION_STORE",
82
+ show_envvar=True,
83
+ )
84
+ @click.option(
85
+ "--verify-ssl",
86
+ is_flag=True,
87
+ default=False,
88
+ show_default=True,
89
+ help="Verify Protect SSL certificate",
90
+ envvar="PROTECT_VERIFY_SSL",
91
+ show_envvar=True,
92
+ )
93
+ @click.option(
94
+ "--cameras",
95
+ default="all",
96
+ show_default=True,
97
+ help=(
98
+ "Comma-separated list of one or more camera IDs ('--cameras=\"id_1,id_2,id_3,...\"'). "
99
+ "Use '--cameras=all' to download footage of all available cameras."
100
+ ),
101
+ envvar="PROTECT_CAMERAS",
102
+ show_envvar=True,
103
+ )
104
+ @click.option(
105
+ "--wait-between-downloads",
106
+ "download_wait",
107
+ default=0,
108
+ show_default=True,
109
+ help="Time to wait between file downloads, in seconds",
110
+ envvar="PROTECT_WAIT_BETWEEN_DOWNLOADS",
111
+ show_envvar=True,
112
+ )
113
+ @click.option(
114
+ "--ignore-failed-downloads",
115
+ is_flag=True,
116
+ default=False,
117
+ show_default=True,
118
+ help="Ignore failed downloads and continue with next download",
119
+ envvar="PROTECT_IGNORE_FAILED_DOWNLOADS",
120
+ show_envvar=True,
121
+ )
122
+ @click.option(
123
+ "--skip-existing-files",
124
+ is_flag=True,
125
+ default=False,
126
+ show_default=True,
127
+ help="Skip downloading files which already exist on disk",
128
+ envvar="PROTECT_SKIP_EXISTING",
129
+ show_envvar=True,
130
+ )
131
+ @click.option(
132
+ "--touch-files",
133
+ is_flag=True,
134
+ default=False,
135
+ show_default=True,
136
+ help=(
137
+ "Create local file without content for current download - "
138
+ "useful in combination with '--skip-existing-files' to skip problematic segments"
139
+ ),
140
+ envvar="PROTECT_TOUCH_FILES",
141
+ show_envvar=True,
142
+ )
143
+ @click.option(
144
+ "--use-subfolders/--no-use-subfolders",
145
+ default=True,
146
+ show_default=True,
147
+ help="Save footage to folder structure with format 'YYYY/MM/DD/camera_name/'",
148
+ envvar="PROTECT_USE_SUBFOLDERS",
149
+ show_envvar=True,
150
+ )
151
+ @click.option(
152
+ "--download-request-timeout",
153
+ "download_timeout",
154
+ default=60.0,
155
+ show_default=True,
156
+ help="Time to wait before aborting download request, in seconds",
157
+ envvar="PROTECT_DOWNLOAD_TIMEOUT",
158
+ show_envvar=True,
159
+ )
160
+ @click.option(
161
+ "--start",
162
+ type=click.DateTime(
163
+ formats=[
164
+ "%Y-%m-%d",
165
+ "%Y-%m-%dT%H:%M:%S",
166
+ "%Y-%m-%d %H:%M:%S",
167
+ "%Y-%m-%d %H:%M:%S%z",
168
+ ]
169
+ ),
170
+ required=True,
171
+ help=(
172
+ "Download range start time. "
173
+ # TODO(danielfernau): uncomment the next line as soon as the feature is implemented
174
+ # "If omitted, the time of the first available recording for each camera will be used."
175
+ ),
176
+ envvar="PROTECT_START_TIME",
177
+ show_envvar=True,
178
+ )
179
+ @click.option(
180
+ "--end",
181
+ type=click.DateTime(
182
+ formats=[
183
+ "%Y-%m-%d",
184
+ "%Y-%m-%dT%H:%M:%S",
185
+ "%Y-%m-%d %H:%M:%S",
186
+ "%Y-%m-%d %H:%M:%S%z",
187
+ ]
188
+ ),
189
+ required=True,
190
+ help=(
191
+ "Download range end time. "
192
+ # TODO(danielfernau): uncomment the next line as soon as the feature is implemented
193
+ # "If omitted, the time of the last available recording for each camera will be used."
194
+ ),
195
+ envvar="PROTECT_END_TIME",
196
+ show_envvar=True,
197
+ )
198
+ @click.option(
199
+ "--download-motion-heatmaps",
200
+ is_flag=True,
201
+ default=False,
202
+ show_default=True,
203
+ help="Also download motion heatmaps for event recordings",
204
+ envvar="PROTECT_DOWNLOAD_MOTION_HEATMAPS",
205
+ show_envvar=True,
206
+ )
207
+ @click.option(
208
+ "--use-utc-filenames",
209
+ is_flag=True,
210
+ default=False,
211
+ show_default=True,
212
+ help="Use UTC timestamp in file names instead of local time",
213
+ envvar="PROTECT_USE_UTC",
214
+ show_envvar=True,
215
+ )
216
+ def events(
217
+ dest: str,
218
+ address: str,
219
+ port: int,
220
+ not_unifi_os: bool,
221
+ username: str,
222
+ password: str,
223
+ mfa_code: Optional[str],
224
+ no_session_store: bool,
225
+ verify_ssl: bool,
226
+ cameras: str,
227
+ download_wait: int,
228
+ download_timeout: int,
229
+ use_subfolders: bool,
230
+ touch_files: bool,
231
+ skip_existing_files: bool,
232
+ ignore_failed_downloads: bool,
233
+ start: datetime,
234
+ end: datetime,
235
+ download_motion_heatmaps: bool,
236
+ use_utc_filenames: bool,
237
+ ) -> None:
238
+ client = ProtectClient(
239
+ address=address,
240
+ port=port,
241
+ not_unifi_os=not_unifi_os,
242
+ username=username,
243
+ password=password,
244
+ mfa_code=mfa_code,
245
+ use_session_store=not no_session_store,
246
+ verify_ssl=verify_ssl,
247
+ ignore_failed_downloads=ignore_failed_downloads,
248
+ destination_path=dest,
249
+ use_subfolders=use_subfolders,
250
+ download_wait=download_wait,
251
+ skip_existing_files=skip_existing_files,
252
+ touch_files=touch_files,
253
+ download_timeout=download_timeout,
254
+ use_utc_filenames=use_utc_filenames,
255
+ )
256
+
257
+ try:
258
+ # get camera list
259
+ click.echo("Getting camera list")
260
+ camera_list = client.get_camera_list()
261
+
262
+ # get motion event list
263
+ click.echo("Getting motion event list")
264
+ motion_event_list = client.get_motion_event_list(start, end, camera_list)
265
+
266
+ if cameras != "all":
267
+ camera_s = set(cameras.split(","))
268
+ # keep only selected cameras in list
269
+ camera_list = [camera for camera in camera_list if camera["id"] in camera_s]
270
+ # keep only events for selected cameras
271
+ motion_event_list = [event for event in motion_event_list if event.camera_id in cameras]
272
+
273
+ click.echo(
274
+ f"Downloading motion event video files between {start} and {end}"
275
+ f" from '{client.session.authority}{client.session.base_path}/video/export'"
276
+ )
277
+
278
+ for motion_event in motion_event_list:
279
+ # client.download_event(MotionEvent, Camera, bool)
280
+ if not ([camera for camera in camera_list if camera["id"] == motion_event.camera_id]):
281
+ click.echo(
282
+ f"Unable to download event {motion_event.id[-4:]} at {motion_event.start}:"
283
+ " camera is not available"
284
+ )
285
+ continue
286
+
287
+ Downloader.download_motion_event(
288
+ client,
289
+ motion_event,
290
+ [camera for camera in camera_list if camera["id"] == motion_event.camera_id][0],
291
+ download_motion_heatmaps,
292
+ )
293
+
294
+ print_download_stats(client)
295
+
296
+ except ProtectError as e:
297
+ exit(e.code)