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.
- photovault-0.2.0/PKG-INFO +65 -0
- photovault-0.2.0/README.md +23 -0
- photovault-0.2.0/photovault.egg-info/PKG-INFO +65 -0
- photovault-0.2.0/photovault.egg-info/SOURCES.txt +22 -0
- photovault-0.2.0/photovault.egg-info/dependency_links.txt +1 -0
- photovault-0.2.0/photovault.egg-info/entry_points.txt +2 -0
- photovault-0.2.0/photovault.egg-info/requires.txt +14 -0
- photovault-0.2.0/photovault.egg-info/top_level.txt +1 -0
- photovault-0.2.0/photovault_scanner/__init__.py +1 -0
- photovault-0.2.0/photovault_scanner/__main__.py +4 -0
- photovault-0.2.0/photovault_scanner/cache.py +45 -0
- photovault-0.2.0/photovault_scanner/cli.py +376 -0
- photovault-0.2.0/photovault_scanner/client.py +89 -0
- photovault-0.2.0/photovault_scanner/config.py +61 -0
- photovault-0.2.0/photovault_scanner/crypto.py +47 -0
- photovault-0.2.0/photovault_scanner/gui.py +794 -0
- photovault-0.2.0/photovault_scanner/hetzner_storagebox.py +230 -0
- photovault-0.2.0/photovault_scanner/icloud_direct.py +225 -0
- photovault-0.2.0/photovault_scanner/imaging.py +76 -0
- photovault-0.2.0/photovault_scanner/macos_photos.py +169 -0
- photovault-0.2.0/photovault_scanner/scanner.py +108 -0
- photovault-0.2.0/photovault_scanner/uploader.py +173 -0
- photovault-0.2.0/pyproject.toml +59 -0
- photovault-0.2.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
photovault_scanner
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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())
|