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.
- protect_archiver/__init__.py +0 -0
- protect_archiver/cli/__init__.py +43 -0
- protect_archiver/cli/base.py +13 -0
- protect_archiver/cli/download.py +333 -0
- protect_archiver/cli/events.py +297 -0
- protect_archiver/cli/resolve.py +27 -0
- protect_archiver/cli/sync.py +319 -0
- protect_archiver/cli/verify.py +263 -0
- protect_archiver/client/__init__.py +109 -0
- protect_archiver/client/legacy.py +72 -0
- protect_archiver/client/unifi_os.py +214 -0
- protect_archiver/config.py +36 -0
- protect_archiver/dataclasses.py +24 -0
- protect_archiver/downloader/__init__.py +81 -0
- protect_archiver/downloader/download_file.py +262 -0
- protect_archiver/downloader/download_footage.py +162 -0
- protect_archiver/downloader/download_motion_event.py +61 -0
- protect_archiver/downloader/download_snapshot.py +48 -0
- protect_archiver/downloader/get_camera_list.py +57 -0
- protect_archiver/downloader/get_motion_event_list.py +85 -0
- protect_archiver/errors.py +44 -0
- protect_archiver/manifest.py +353 -0
- protect_archiver/reconcile.py +148 -0
- protect_archiver/session_store.py +161 -0
- protect_archiver/settings.py +160 -0
- protect_archiver/sync.py +175 -0
- protect_archiver/test_client.py +116 -0
- protect_archiver/test_download_file.py +244 -0
- protect_archiver/test_manifest.py +100 -0
- protect_archiver/test_reconcile.py +101 -0
- protect_archiver/test_session_store.py +96 -0
- protect_archiver/test_settings.py +126 -0
- protect_archiver/test_sync.py +346 -0
- protect_archiver/test_unifi_os_client.py +212 -0
- protect_archiver/test_utils.py +129 -0
- protect_archiver/test_verify.py +183 -0
- protect_archiver/utils.py +205 -0
- protect_archiver/verify.py +255 -0
- updl-3.0.0.dist-info/METADATA +214 -0
- updl-3.0.0.dist-info/RECORD +44 -0
- updl-3.0.0.dist-info/WHEEL +4 -0
- updl-3.0.0.dist-info/entry_points.txt +4 -0
- updl-3.0.0.dist-info/licenses/LICENSE +22 -0
- 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)
|