photovault 0.2.0__tar.gz

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,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: photovault
3
+ Version: 0.2.0
4
+ Summary: Zero-knowledge photo backup scanner: encrypt photos to your PGP key and ship them to your Photovault server.
5
+ Author-email: Jakob Holst <lyngknuden@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://photovault.traeck.it/
8
+ Project-URL: Source, https://bitbucket.org/team-nine/photovault
9
+ Project-URL: Bug Reports, https://bitbucket.org/team-nine/photovault/issues
10
+ Keywords: photos,backup,pgp,encryption,icloud,scanner
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Multimedia :: Graphics
27
+ Classifier: Topic :: Security :: Cryptography
28
+ Classifier: Topic :: System :: Archiving :: Backup
29
+ Requires-Python: >=3.10
30
+ Description-Content-Type: text/markdown
31
+ Requires-Dist: httpx>=0.27
32
+ Requires-Dist: Pillow>=10.0
33
+ Requires-Dist: pillow-heif>=0.16
34
+ Requires-Dist: python-gnupg>=0.5.2
35
+ Requires-Dist: platformdirs>=4.0
36
+ Requires-Dist: keyring>=24.0
37
+ Requires-Dist: pyicloud>=1.0
38
+ Requires-Dist: paramiko>=3.4
39
+ Requires-Dist: osxphotos>=0.69; sys_platform == "darwin"
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=8.0; extra == "dev"
42
+
43
+ # photovault-scanner
44
+
45
+ Cross-platform CLI that scans a computer for photos and uploads them to your
46
+ Photovault, encrypted client-side to your PGP public key.
47
+
48
+ Works on macOS, Linux, and Windows. Mac is the primary target.
49
+
50
+ ## Install
51
+
52
+ pipx install ./clients/scanner
53
+
54
+ ## Usage
55
+
56
+ photovault login --server https://vault.example.com
57
+ photovault status
58
+ photovault scan ~/Pictures
59
+ photovault scan --dry-run ~/Pictures ~/Downloads
60
+
61
+ The CLI fetches your registered PGP public key from the server and encrypts
62
+ each photo and its thumbnail locally before upload. The server only ever
63
+ sees ciphertext.
64
+
65
+ You must register a public key via the web UI (`/keygen/`) before scanning.
@@ -0,0 +1,23 @@
1
+ # photovault-scanner
2
+
3
+ Cross-platform CLI that scans a computer for photos and uploads them to your
4
+ Photovault, encrypted client-side to your PGP public key.
5
+
6
+ Works on macOS, Linux, and Windows. Mac is the primary target.
7
+
8
+ ## Install
9
+
10
+ pipx install ./clients/scanner
11
+
12
+ ## Usage
13
+
14
+ photovault login --server https://vault.example.com
15
+ photovault status
16
+ photovault scan ~/Pictures
17
+ photovault scan --dry-run ~/Pictures ~/Downloads
18
+
19
+ The CLI fetches your registered PGP public key from the server and encrypts
20
+ each photo and its thumbnail locally before upload. The server only ever
21
+ sees ciphertext.
22
+
23
+ You must register a public key via the web UI (`/keygen/`) before scanning.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: photovault
3
+ Version: 0.2.0
4
+ Summary: Zero-knowledge photo backup scanner: encrypt photos to your PGP key and ship them to your Photovault server.
5
+ Author-email: Jakob Holst <lyngknuden@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://photovault.traeck.it/
8
+ Project-URL: Source, https://bitbucket.org/team-nine/photovault
9
+ Project-URL: Bug Reports, https://bitbucket.org/team-nine/photovault/issues
10
+ Keywords: photos,backup,pgp,encryption,icloud,scanner
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Multimedia :: Graphics
27
+ Classifier: Topic :: Security :: Cryptography
28
+ Classifier: Topic :: System :: Archiving :: Backup
29
+ Requires-Python: >=3.10
30
+ Description-Content-Type: text/markdown
31
+ Requires-Dist: httpx>=0.27
32
+ Requires-Dist: Pillow>=10.0
33
+ Requires-Dist: pillow-heif>=0.16
34
+ Requires-Dist: python-gnupg>=0.5.2
35
+ Requires-Dist: platformdirs>=4.0
36
+ Requires-Dist: keyring>=24.0
37
+ Requires-Dist: pyicloud>=1.0
38
+ Requires-Dist: paramiko>=3.4
39
+ Requires-Dist: osxphotos>=0.69; sys_platform == "darwin"
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=8.0; extra == "dev"
42
+
43
+ # photovault-scanner
44
+
45
+ Cross-platform CLI that scans a computer for photos and uploads them to your
46
+ Photovault, encrypted client-side to your PGP public key.
47
+
48
+ Works on macOS, Linux, and Windows. Mac is the primary target.
49
+
50
+ ## Install
51
+
52
+ pipx install ./clients/scanner
53
+
54
+ ## Usage
55
+
56
+ photovault login --server https://vault.example.com
57
+ photovault status
58
+ photovault scan ~/Pictures
59
+ photovault scan --dry-run ~/Pictures ~/Downloads
60
+
61
+ The CLI fetches your registered PGP public key from the server and encrypts
62
+ each photo and its thumbnail locally before upload. The server only ever
63
+ sees ciphertext.
64
+
65
+ You must register a public key via the web UI (`/keygen/`) before scanning.
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ photovault.egg-info/PKG-INFO
4
+ photovault.egg-info/SOURCES.txt
5
+ photovault.egg-info/dependency_links.txt
6
+ photovault.egg-info/entry_points.txt
7
+ photovault.egg-info/requires.txt
8
+ photovault.egg-info/top_level.txt
9
+ photovault_scanner/__init__.py
10
+ photovault_scanner/__main__.py
11
+ photovault_scanner/cache.py
12
+ photovault_scanner/cli.py
13
+ photovault_scanner/client.py
14
+ photovault_scanner/config.py
15
+ photovault_scanner/crypto.py
16
+ photovault_scanner/gui.py
17
+ photovault_scanner/hetzner_storagebox.py
18
+ photovault_scanner/icloud_direct.py
19
+ photovault_scanner/imaging.py
20
+ photovault_scanner/macos_photos.py
21
+ photovault_scanner/scanner.py
22
+ photovault_scanner/uploader.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ photovault = photovault_scanner.cli:main
@@ -0,0 +1,14 @@
1
+ httpx>=0.27
2
+ Pillow>=10.0
3
+ pillow-heif>=0.16
4
+ python-gnupg>=0.5.2
5
+ platformdirs>=4.0
6
+ keyring>=24.0
7
+ pyicloud>=1.0
8
+ paramiko>=3.4
9
+
10
+ [:sys_platform == "darwin"]
11
+ osxphotos>=0.69
12
+
13
+ [dev]
14
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ photovault_scanner
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,45 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+
6
+ SCHEMA = """
7
+ CREATE TABLE IF NOT EXISTS hashes (
8
+ path TEXT PRIMARY KEY,
9
+ mtime REAL NOT NULL,
10
+ size INTEGER NOT NULL,
11
+ sha256 TEXT NOT NULL
12
+ );
13
+ CREATE INDEX IF NOT EXISTS hashes_sha256 ON hashes(sha256);
14
+ """
15
+
16
+
17
+ class HashCache:
18
+ def __init__(self, db_path: Path):
19
+ db_path.parent.mkdir(parents=True, exist_ok=True)
20
+ self.conn = sqlite3.connect(str(db_path))
21
+ self.conn.executescript(SCHEMA)
22
+ self.conn.commit()
23
+
24
+ def lookup(self, path: str, mtime: float, size: int) -> Optional[str]:
25
+ cur = self.conn.execute(
26
+ "SELECT mtime, size, sha256 FROM hashes WHERE path = ?",
27
+ (path,),
28
+ )
29
+ row = cur.fetchone()
30
+ if row is None:
31
+ return None
32
+ cached_mtime, cached_size, sha = row
33
+ if abs(cached_mtime - mtime) < 1e-3 and cached_size == size:
34
+ return sha
35
+ return None
36
+
37
+ def remember(self, path: str, mtime: float, size: int, sha256: str) -> None:
38
+ self.conn.execute(
39
+ "INSERT OR REPLACE INTO hashes(path, mtime, size, sha256) VALUES (?, ?, ?, ?)",
40
+ (path, mtime, size, sha256),
41
+ )
42
+ self.conn.commit()
43
+
44
+ def close(self) -> None:
45
+ self.conn.close()
@@ -0,0 +1,376 @@
1
+ import argparse
2
+ import getpass
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Sequence
6
+
7
+ from . import __version__
8
+ from .cache import HashCache
9
+ from .client import PhotovaultClient
10
+ from .config import Config, cache_db_path, config_path
11
+ from .crypto import PGPEncryptor
12
+ from .uploader import process_candidate_stream, scan_filesystem_and_upload
13
+
14
+
15
+ def _print(msg: str) -> None:
16
+ print(msg, flush=True)
17
+
18
+
19
+ def cmd_login(args: argparse.Namespace) -> int:
20
+ config = Config.load()
21
+ server = args.server or config.server
22
+ if not server:
23
+ _print("error: --server URL is required on first login")
24
+ return 2
25
+ username = args.username or input("Username: ").strip()
26
+ password = args.password or getpass.getpass("Password: ")
27
+
28
+ with PhotovaultClient(server) as client:
29
+ try:
30
+ token = client.login(username, password)
31
+ except Exception as e:
32
+ _print(f"login failed: {e}")
33
+ return 1
34
+ config.server = server
35
+ config.username = username
36
+ config.token = token
37
+
38
+ profile = client.get_public_key()
39
+ if profile:
40
+ config.public_key_armored = profile["public_key_armored"]
41
+ config.pgp_fingerprint = profile["pgp_fingerprint"]
42
+ else:
43
+ config.public_key_armored = ""
44
+ config.pgp_fingerprint = ""
45
+
46
+ config.save()
47
+ _print(f"logged in as {username} @ {server}")
48
+ _print(f"config: {config_path()}")
49
+ if not config.pgp_fingerprint:
50
+ _print("warning: no public key registered. Open the web UI and generate one.")
51
+ else:
52
+ _print(f"public key: {config.pgp_fingerprint}")
53
+ return 0
54
+
55
+
56
+ def cmd_status(args: argparse.Namespace) -> int:
57
+ config = Config.load()
58
+ if not config.token:
59
+ _print("not logged in")
60
+ return 0
61
+ _print(f"server: {config.server}")
62
+ _print(f"user: {config.username}")
63
+ _print(f"fingerprint: {config.pgp_fingerprint or '(none registered)'}")
64
+ _print(f"config: {config_path()}")
65
+ _print(f"hash cache: {cache_db_path()}")
66
+
67
+ if args.refresh:
68
+ with PhotovaultClient(config.server, token=config.token) as client:
69
+ profile = client.get_public_key()
70
+ if profile:
71
+ if profile["pgp_fingerprint"] != config.pgp_fingerprint:
72
+ _print(
73
+ f"public key changed on server: {config.pgp_fingerprint} -> {profile['pgp_fingerprint']}"
74
+ )
75
+ config.public_key_armored = profile["public_key_armored"]
76
+ config.pgp_fingerprint = profile["pgp_fingerprint"]
77
+ config.save()
78
+ _print("refreshed public key from server")
79
+ else:
80
+ _print("no public key registered on server")
81
+ return 0
82
+
83
+
84
+ def cmd_scan(args: argparse.Namespace) -> int:
85
+ config = Config.load()
86
+ config.require_login()
87
+ config.require_public_key()
88
+
89
+ encryptor = PGPEncryptor(config.public_key_armored)
90
+ if encryptor.fingerprint != config.pgp_fingerprint:
91
+ _print(
92
+ f"warning: cached fingerprint {config.pgp_fingerprint} disagrees with key blob {encryptor.fingerprint}"
93
+ )
94
+
95
+ paths = [Path(p).expanduser().resolve() for p in args.paths]
96
+ for p in paths:
97
+ if not p.exists():
98
+ _print(f"warning: {p} does not exist")
99
+
100
+ cache = HashCache(cache_db_path())
101
+ try:
102
+ with PhotovaultClient(config.server, token=config.token) as client:
103
+ stats = scan_filesystem_and_upload(
104
+ paths,
105
+ client=client,
106
+ encryptor=encryptor,
107
+ cache=cache,
108
+ dry_run=args.dry_run,
109
+ reporter=_print if args.verbose else None,
110
+ workers=args.workers,
111
+ )
112
+ finally:
113
+ cache.close()
114
+
115
+ _print("")
116
+ _print(f"seen: {stats.seen}")
117
+ _print(f"skipped (existing): {stats.skipped_existing}")
118
+ _print(f"skipped (unread): {stats.skipped_unreadable}")
119
+ _print(f"uploaded: {stats.uploaded}{' (dry run)' if args.dry_run else ''}")
120
+ _print(f"failed: {stats.failed}")
121
+ return 0 if stats.failed == 0 else 1
122
+
123
+
124
+ def cmd_scan_photos(args: argparse.Namespace) -> int:
125
+ if sys.platform != "darwin":
126
+ _print("scan-photos only works on macOS (it reads the system Photos library).")
127
+ return 2
128
+
129
+ from . import macos_photos
130
+
131
+ config = Config.load()
132
+ config.require_login()
133
+ config.require_public_key()
134
+
135
+ encryptor = PGPEncryptor(config.public_key_armored)
136
+ if encryptor.fingerprint != config.pgp_fingerprint:
137
+ _print(
138
+ f"warning: cached fingerprint {config.pgp_fingerprint} disagrees with key blob {encryptor.fingerprint}"
139
+ )
140
+
141
+ library = Path(args.library).expanduser().resolve() if args.library else None
142
+ cache = HashCache(cache_db_path())
143
+
144
+ try:
145
+ with PhotovaultClient(config.server, token=config.token) as client:
146
+ reporter = _print if args.verbose else None
147
+ candidates = macos_photos.iter_candidates(
148
+ cache,
149
+ library=library,
150
+ include_hidden=args.include_hidden,
151
+ include_trashed=args.include_trashed,
152
+ include_shared=args.include_shared,
153
+ download_missing=args.download_missing,
154
+ reporter=reporter,
155
+ )
156
+ stats = process_candidate_stream(
157
+ candidates,
158
+ client=client,
159
+ encryptor=encryptor,
160
+ dry_run=args.dry_run,
161
+ reporter=reporter,
162
+ workers=args.workers,
163
+ )
164
+ finally:
165
+ cache.close()
166
+
167
+ source_stats = getattr(macos_photos.iter_candidates, "stats", None)
168
+
169
+ _print("")
170
+ if source_stats is not None:
171
+ _print(f"in library: {source_stats.total_in_library}")
172
+ if source_stats.not_downloaded:
173
+ _print(f"icloud-only: {source_stats.not_downloaded} (rerun with --download-missing)")
174
+ if source_stats.skipped_hidden:
175
+ _print(f"hidden: {source_stats.skipped_hidden}")
176
+ if source_stats.skipped_trashed:
177
+ _print(f"trashed: {source_stats.skipped_trashed}")
178
+ if source_stats.skipped_shared:
179
+ _print(f"shared: {source_stats.skipped_shared}")
180
+ if source_stats.skipped_unsupported:
181
+ _print(f"unsupported fmt: {source_stats.skipped_unsupported}")
182
+ if source_stats.skipped_no_path:
183
+ _print(f"no on-disk path: {source_stats.skipped_no_path}")
184
+ if source_stats.skipped_unreadable:
185
+ _print(f"unreadable: {source_stats.skipped_unreadable}")
186
+ _print(f"considered: {stats.seen}")
187
+ _print(f"skipped (existing): {stats.skipped_existing}")
188
+ _print(f"uploaded: {stats.uploaded}{' (dry run)' if args.dry_run else ''}")
189
+ _print(f"failed: {stats.failed}")
190
+ return 0 if stats.failed == 0 else 1
191
+
192
+
193
+ def cmd_scan_storagebox(args: argparse.Namespace) -> int:
194
+ from . import hetzner_storagebox
195
+
196
+ config = Config.load()
197
+ config.require_login()
198
+ config.require_public_key()
199
+
200
+ host = args.host or config.sb_host
201
+ user = args.user or config.sb_user
202
+ port = args.port or config.sb_port or 23
203
+ root = args.path or config.sb_root or "."
204
+ if not host or not user:
205
+ _print("error: --host and --user are required (or run 'photovault gui' and use Configure Storage Box).")
206
+ return 2
207
+
208
+ password = hetzner_storagebox.load_password(host, user)
209
+ if not password:
210
+ password = getpass.getpass(f"Password for {user}@{host}: ")
211
+ if not password:
212
+ _print("aborted")
213
+ return 1
214
+ if args.save_password:
215
+ hetzner_storagebox.save_password(host, user, password)
216
+
217
+ config.sb_host = host
218
+ config.sb_user = user
219
+ config.sb_port = port
220
+ config.sb_root = root
221
+ config.save()
222
+
223
+ encryptor = PGPEncryptor(config.public_key_armored)
224
+ cache = HashCache(cache_db_path())
225
+ try:
226
+ with PhotovaultClient(config.server, token=config.token) as client:
227
+ reporter = _print if args.verbose else None
228
+ candidates = hetzner_storagebox.iter_candidates(
229
+ cache,
230
+ host=host,
231
+ user=user,
232
+ password=password,
233
+ port=port,
234
+ root=root,
235
+ include_hidden=args.include_hidden,
236
+ reporter=reporter,
237
+ )
238
+ stats = process_candidate_stream(
239
+ candidates,
240
+ client=client,
241
+ encryptor=encryptor,
242
+ dry_run=args.dry_run,
243
+ reporter=reporter,
244
+ workers=args.workers,
245
+ )
246
+ finally:
247
+ cache.close()
248
+
249
+ source_stats = getattr(hetzner_storagebox.iter_candidates, "stats", None)
250
+ _print("")
251
+ if source_stats is not None:
252
+ _print(f"directories walked: {source_stats.visited_dirs}")
253
+ _print(f"files discovered: {source_stats.total_files}")
254
+ if source_stats.skipped_hidden:
255
+ _print(f"skipped hidden: {source_stats.skipped_hidden}")
256
+ if source_stats.skipped_unsupported:
257
+ _print(f"unsupported fmt: {source_stats.skipped_unsupported}")
258
+ if source_stats.skipped_download_failed:
259
+ _print(f"download failed: {source_stats.skipped_download_failed}")
260
+ if source_stats.skipped_unreadable:
261
+ _print(f"unreadable: {source_stats.skipped_unreadable}")
262
+ _print(f"considered: {stats.seen}")
263
+ _print(f"skipped (existing): {stats.skipped_existing}")
264
+ _print(f"uploaded: {stats.uploaded}{' (dry run)' if args.dry_run else ''}")
265
+ _print(f"failed: {stats.failed}")
266
+ return 0 if stats.failed == 0 else 1
267
+
268
+
269
+ def cmd_gui(args: argparse.Namespace) -> int:
270
+ from . import gui
271
+
272
+ return gui.run()
273
+
274
+
275
+ def cmd_logout(args: argparse.Namespace) -> int:
276
+ config = Config.load()
277
+ config.token = ""
278
+ config.public_key_armored = ""
279
+ config.save()
280
+ _print("logged out (server, username, fingerprint kept for convenience)")
281
+ return 0
282
+
283
+
284
+ def build_parser() -> argparse.ArgumentParser:
285
+ parser = argparse.ArgumentParser(
286
+ prog="photovault",
287
+ description="Scan a computer and back up photos to Photovault, encrypted client-side.",
288
+ )
289
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
290
+ sub = parser.add_subparsers(dest="cmd", required=True)
291
+
292
+ p_login = sub.add_parser("login", help="Log in to a Photovault server")
293
+ p_login.add_argument("--server", help="Server URL (e.g. https://vault.example.com)")
294
+ p_login.add_argument("--username")
295
+ p_login.add_argument("--password")
296
+ p_login.set_defaults(func=cmd_login)
297
+
298
+ p_status = sub.add_parser("status", help="Show current login and public key")
299
+ p_status.add_argument("--refresh", action="store_true", help="Refetch public key from server")
300
+ p_status.set_defaults(func=cmd_status)
301
+
302
+ p_scan = sub.add_parser("scan", help="Scan one or more directories and upload new photos")
303
+ p_scan.add_argument("paths", nargs="+", help="Directories or files to scan")
304
+ p_scan.add_argument("--dry-run", action="store_true", help="Report what would be uploaded; don't upload")
305
+ p_scan.add_argument("-v", "--verbose", action="store_true", help="Per-file logging")
306
+ p_scan.add_argument(
307
+ "-w",
308
+ "--workers",
309
+ type=int,
310
+ default=4,
311
+ help="Parallel encrypt+upload workers (default: 4)",
312
+ )
313
+ p_scan.set_defaults(func=cmd_scan)
314
+
315
+ p_photos = sub.add_parser(
316
+ "scan-photos",
317
+ help="(macOS) Scan the system Photos.app library and upload new photos",
318
+ description=(
319
+ "Iterate every photo Apple's Photos.app knows about (including iCloud) and "
320
+ "upload originals that aren't already in your vault. The terminal app needs "
321
+ "'Full Disk Access' or 'Photos' permission in System Settings -> Privacy & "
322
+ "Security to read the library."
323
+ ),
324
+ )
325
+ p_photos.add_argument("--library", help="Path to a .photoslibrary (defaults to the system library)")
326
+ p_photos.add_argument(
327
+ "--download-missing",
328
+ action="store_true",
329
+ help="Pull originals from iCloud if not yet on disk (uses PhotoKit; slower).",
330
+ )
331
+ p_photos.add_argument("--include-hidden", action="store_true", help="Include hidden photos")
332
+ p_photos.add_argument("--include-trashed", action="store_true", help="Include recently-deleted photos")
333
+ p_photos.add_argument("--include-shared", action="store_true", help="Include shared-album photos")
334
+ p_photos.add_argument("--dry-run", action="store_true", help="Report what would be uploaded; don't upload")
335
+ p_photos.add_argument("-v", "--verbose", action="store_true", help="Per-file logging")
336
+ p_photos.add_argument(
337
+ "-w",
338
+ "--workers",
339
+ type=int,
340
+ default=4,
341
+ help="Parallel encrypt+upload workers (default: 4)",
342
+ )
343
+ p_photos.set_defaults(func=cmd_scan_photos)
344
+
345
+ p_sb = sub.add_parser(
346
+ "scan-storagebox",
347
+ help="Scan a Hetzner Storage Box over SFTP and upload new photos",
348
+ )
349
+ p_sb.add_argument("--host", help="Storage Box hostname (e.g. u123456.your-storagebox.de)")
350
+ p_sb.add_argument("--user", help="Storage Box username (e.g. u123456)")
351
+ p_sb.add_argument("--port", type=int, help="SSH/SFTP port (default 23)")
352
+ p_sb.add_argument("--path", help="Remote path to walk (default: '.')")
353
+ p_sb.add_argument("--include-hidden", action="store_true", help="Include dotfiles/folders")
354
+ p_sb.add_argument("--save-password", action="store_true", help="Save the password to the OS keychain")
355
+ p_sb.add_argument("--dry-run", action="store_true", help="Report what would be uploaded; don't upload")
356
+ p_sb.add_argument("-v", "--verbose", action="store_true", help="Per-file logging")
357
+ p_sb.add_argument("-w", "--workers", type=int, default=4, help="Parallel encrypt+upload workers (default 4)")
358
+ p_sb.set_defaults(func=cmd_scan_storagebox)
359
+
360
+ p_gui = sub.add_parser("gui", help="Open the desktop window for scanning and uploading")
361
+ p_gui.set_defaults(func=cmd_gui)
362
+
363
+ p_logout = sub.add_parser("logout", help="Forget the auth token")
364
+ p_logout.set_defaults(func=cmd_logout)
365
+
366
+ return parser
367
+
368
+
369
+ def main(argv: Sequence[str] | None = None) -> int:
370
+ parser = build_parser()
371
+ args = parser.parse_args(argv)
372
+ return args.func(args)
373
+
374
+
375
+ if __name__ == "__main__":
376
+ sys.exit(main())