pix-cli 0.1.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.
- pix/__init__.py +9 -0
- pix/cli.py +116 -0
- pix/config.py +172 -0
- pix/default_config.toml +32 -0
- pix/doctor.py +54 -0
- pix/logger.py +26 -0
- pix/py.typed +0 -0
- pix/rclone.py +72 -0
- pix/shrink.py +368 -0
- pix/sync.py +142 -0
- pix_cli-0.1.0.dist-info/METADATA +40 -0
- pix_cli-0.1.0.dist-info/RECORD +14 -0
- pix_cli-0.1.0.dist-info/WHEEL +4 -0
- pix_cli-0.1.0.dist-info/entry_points.txt +2 -0
pix/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""pix package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _package_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _package_version("pix-cli")
|
|
8
|
+
except PackageNotFoundError: # pragma: no cover - only when running uninstalled
|
|
9
|
+
__version__ = "0.1.0"
|
pix/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""pix command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import ConfigError, init_config, load_settings
|
|
11
|
+
from .doctor import doctor
|
|
12
|
+
from .logger import Logger
|
|
13
|
+
from .shrink import shrink
|
|
14
|
+
from .sync import backup, publish
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _add_logging_args(parser: argparse.ArgumentParser) -> None:
|
|
18
|
+
group = parser.add_mutually_exclusive_group()
|
|
19
|
+
group.add_argument("-v", "--verbose", action="store_true", default=None)
|
|
20
|
+
group.add_argument("--quiet", action="store_true", default=None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(prog="pix", description="Batch AVIF shrink and image-host sync.")
|
|
25
|
+
parser.add_argument("--config", type=Path, default=None, metavar="PATH")
|
|
26
|
+
parser.add_argument("--version", action="version", version=f"pix {__version__}")
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
|
|
29
|
+
shrink = subparsers.add_parser("shrink", help="compress images to AVIF")
|
|
30
|
+
shrink.add_argument("-s", "--source", type=Path, required=True)
|
|
31
|
+
shrink.add_argument("-d", "--dest", type=Path, required=True)
|
|
32
|
+
shrink.add_argument("-w", "--width", type=int, default=None)
|
|
33
|
+
shrink.add_argument("-q", "--quality", type=int, default=None)
|
|
34
|
+
shrink.add_argument("-e", "--effort", type=int, default=None)
|
|
35
|
+
shrink.add_argument("-m", "--min-size", type=str, default=None)
|
|
36
|
+
shrink.add_argument("--force", action="store_true")
|
|
37
|
+
shrink.add_argument("--prune", action="store_true")
|
|
38
|
+
shrink.add_argument("--allow-empty", action="store_true")
|
|
39
|
+
shrink.add_argument("--dry-run", action="store_true")
|
|
40
|
+
_add_logging_args(shrink)
|
|
41
|
+
|
|
42
|
+
backup = subparsers.add_parser("backup", help="bidirectionally mirror originals")
|
|
43
|
+
backup.add_argument("-s", "--source", type=Path, required=True)
|
|
44
|
+
backup.add_argument("-d", "--dest", required=True)
|
|
45
|
+
backup.add_argument("--exclude", action="append", default=None, metavar="GLOB")
|
|
46
|
+
backup.add_argument(
|
|
47
|
+
"--max-delete",
|
|
48
|
+
type=int,
|
|
49
|
+
default=None,
|
|
50
|
+
metavar="PERCENT",
|
|
51
|
+
help="abort if more than PERCENT%% of files are deleted (default: 10)",
|
|
52
|
+
)
|
|
53
|
+
backup.add_argument("--allow-empty", action="store_true")
|
|
54
|
+
backup.add_argument("--resync", action="store_true")
|
|
55
|
+
backup.add_argument("--dry-run", action="store_true")
|
|
56
|
+
_add_logging_args(backup)
|
|
57
|
+
|
|
58
|
+
publish = subparsers.add_parser("publish", help="bidirectionally mirror compressed images")
|
|
59
|
+
publish.add_argument("-s", "--source", type=Path, required=True)
|
|
60
|
+
publish.add_argument("-d", "--dest", required=True)
|
|
61
|
+
publish.add_argument("--base-url", default=None)
|
|
62
|
+
publish.add_argument("--urls", action=argparse.BooleanOptionalAction, default=None)
|
|
63
|
+
publish.add_argument("--exclude", action="append", default=None, metavar="GLOB")
|
|
64
|
+
publish.add_argument(
|
|
65
|
+
"--max-delete",
|
|
66
|
+
type=int,
|
|
67
|
+
default=None,
|
|
68
|
+
metavar="PERCENT",
|
|
69
|
+
help="abort if more than PERCENT%% of files are deleted (default: 10)",
|
|
70
|
+
)
|
|
71
|
+
publish.add_argument("--allow-empty", action="store_true")
|
|
72
|
+
publish.add_argument("--resync", action="store_true")
|
|
73
|
+
publish.add_argument("--dry-run", action="store_true")
|
|
74
|
+
_add_logging_args(publish)
|
|
75
|
+
|
|
76
|
+
subparsers.add_parser("doctor", help="check runtime dependencies and configuration")
|
|
77
|
+
|
|
78
|
+
config = subparsers.add_parser("config", help="manage the configuration file")
|
|
79
|
+
config_sub = config.add_subparsers(dest="config_command", required=True)
|
|
80
|
+
init = config_sub.add_parser("init", help="write the default config file")
|
|
81
|
+
init.add_argument("--force", action="store_true")
|
|
82
|
+
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(argv: list[str] | None = None) -> int:
|
|
87
|
+
parser = build_parser()
|
|
88
|
+
try:
|
|
89
|
+
args = parser.parse_args(argv)
|
|
90
|
+
except SystemExit as exc: # --help/--version and argparse errors
|
|
91
|
+
return int(exc.code) if isinstance(exc.code, int) else 0
|
|
92
|
+
|
|
93
|
+
if args.command == "config":
|
|
94
|
+
return init_config(args)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
settings = load_settings(args.config)
|
|
98
|
+
except ConfigError as exc:
|
|
99
|
+
print(f"pix: error: {exc}", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
|
|
102
|
+
log = Logger(
|
|
103
|
+
verbose=bool(getattr(args, "verbose", False)),
|
|
104
|
+
quiet=bool(getattr(args, "quiet", False)),
|
|
105
|
+
)
|
|
106
|
+
handlers = {
|
|
107
|
+
"shrink": shrink,
|
|
108
|
+
"backup": backup,
|
|
109
|
+
"publish": publish,
|
|
110
|
+
"doctor": doctor,
|
|
111
|
+
}
|
|
112
|
+
return handlers[args.command](args, settings, log)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
raise SystemExit(main())
|
pix/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import copy
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
DEFAULTS: dict[str, Any] = {
|
|
15
|
+
"shrink": {
|
|
16
|
+
"width": 1920,
|
|
17
|
+
"quality": 85,
|
|
18
|
+
"effort": 5,
|
|
19
|
+
"min_size": 0,
|
|
20
|
+
"include": ["jpg", "jpeg", "png", "webp"],
|
|
21
|
+
},
|
|
22
|
+
"backup": {
|
|
23
|
+
"exclude": [],
|
|
24
|
+
"max_delete": 10,
|
|
25
|
+
},
|
|
26
|
+
"publish": {
|
|
27
|
+
"base_url": "",
|
|
28
|
+
"urls": False,
|
|
29
|
+
"exclude": [],
|
|
30
|
+
"max_delete": 10,
|
|
31
|
+
},
|
|
32
|
+
"rclone": {
|
|
33
|
+
"binary": "rclone",
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_SIZE_RE = re.compile(r"^(\d+)([KMG]?)$", re.IGNORECASE)
|
|
38
|
+
_SIZE_UNITS = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigError(Exception):
|
|
42
|
+
"""Invalid or unreadable configuration."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_size(value: str | int) -> int:
|
|
46
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
47
|
+
if value < 0:
|
|
48
|
+
raise ValueError("size must not be negative")
|
|
49
|
+
return value
|
|
50
|
+
if not isinstance(value, str):
|
|
51
|
+
raise ValueError(f"invalid size: {value!r}")
|
|
52
|
+
match = _SIZE_RE.fullmatch(value.strip())
|
|
53
|
+
if match is None:
|
|
54
|
+
raise ValueError(f"invalid size: {value!r}")
|
|
55
|
+
return int(match.group(1)) * _SIZE_UNITS[match.group(2).upper()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_config_path() -> Path:
|
|
59
|
+
if os.name == "nt":
|
|
60
|
+
base = os.environ.get("APPDATA")
|
|
61
|
+
if base:
|
|
62
|
+
return Path(base) / "pix" / "config.toml"
|
|
63
|
+
return Path.home() / "AppData" / "Roaming" / "pix" / "config.toml"
|
|
64
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
65
|
+
if base:
|
|
66
|
+
return Path(base) / "pix" / "config.toml"
|
|
67
|
+
return Path.home() / ".config" / "pix" / "config.toml"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_config(path: Path) -> dict[str, Any]:
|
|
71
|
+
"""Read a TOML config file; a missing file is an empty override."""
|
|
72
|
+
if not path.is_file():
|
|
73
|
+
return {}
|
|
74
|
+
try:
|
|
75
|
+
with path.open("rb") as handle:
|
|
76
|
+
data = tomllib.load(handle)
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
79
|
+
except tomllib.TOMLDecodeError as exc:
|
|
80
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise ConfigError(f"{path}: top level must be a table")
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _deep_update(target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
87
|
+
for key, value in source.items():
|
|
88
|
+
current = target.get(key)
|
|
89
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
90
|
+
_deep_update(current, value)
|
|
91
|
+
else:
|
|
92
|
+
target[key] = value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_settings(explicit: Path | None = None) -> dict[str, Any]:
|
|
96
|
+
path = explicit if explicit is not None else default_config_path()
|
|
97
|
+
if explicit is not None and not path.is_file():
|
|
98
|
+
raise ConfigError(f"config file not found: {path}")
|
|
99
|
+
overrides = load_config(path)
|
|
100
|
+
settings = copy.deepcopy(DEFAULTS)
|
|
101
|
+
_deep_update(settings, overrides)
|
|
102
|
+
validate_settings(settings)
|
|
103
|
+
return settings
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def require_int(name: str, value: Any, minimum: int, maximum: int | None = None) -> int:
|
|
107
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
108
|
+
raise ConfigError(f"{name} must be an integer")
|
|
109
|
+
if value < minimum or (maximum is not None and value > maximum):
|
|
110
|
+
if maximum is None:
|
|
111
|
+
raise ConfigError(f"{name} must be >= {minimum}")
|
|
112
|
+
raise ConfigError(f"{name} must be between {minimum} and {maximum}")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _require_excludes(name: str, value: Any) -> list[str]:
|
|
117
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
118
|
+
raise ConfigError(f"{name} must be a list of strings")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_settings(settings: dict[str, Any]) -> None:
|
|
123
|
+
for section in ("shrink", "backup", "publish", "rclone"):
|
|
124
|
+
if not isinstance(settings.get(section), dict):
|
|
125
|
+
raise ConfigError(f"[{section}] must be a table")
|
|
126
|
+
|
|
127
|
+
shrink = settings["shrink"]
|
|
128
|
+
require_int("shrink.width", shrink["width"], 1)
|
|
129
|
+
require_int("shrink.quality", shrink["quality"], 1, 100)
|
|
130
|
+
require_int("shrink.effort", shrink["effort"], 0, 9)
|
|
131
|
+
try:
|
|
132
|
+
shrink["min_size"] = parse_size(shrink["min_size"])
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise ConfigError(f"shrink.min_size: {exc}") from exc
|
|
135
|
+
|
|
136
|
+
include = shrink["include"]
|
|
137
|
+
if not isinstance(include, list) or not all(
|
|
138
|
+
isinstance(item, str) and item and not item.startswith(".") for item in include
|
|
139
|
+
):
|
|
140
|
+
raise ConfigError("shrink.include must be a list of extensions without dots")
|
|
141
|
+
|
|
142
|
+
for section in ("backup", "publish"):
|
|
143
|
+
_require_excludes(f"{section}.exclude", settings[section]["exclude"])
|
|
144
|
+
require_int(f"{section}.max_delete", settings[section]["max_delete"], -1, 100)
|
|
145
|
+
|
|
146
|
+
publish = settings["publish"]
|
|
147
|
+
if not isinstance(publish["base_url"], str):
|
|
148
|
+
raise ConfigError("publish.base_url must be a string")
|
|
149
|
+
if not isinstance(publish["urls"], bool):
|
|
150
|
+
raise ConfigError("publish.urls must be a boolean")
|
|
151
|
+
|
|
152
|
+
if not isinstance(settings["rclone"]["binary"], str) or not settings["rclone"]["binary"]:
|
|
153
|
+
raise ConfigError("rclone.binary must be a non-empty string")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def init_config(args: argparse.Namespace) -> int:
|
|
157
|
+
path = args.config if args.config is not None else default_config_path()
|
|
158
|
+
if path.exists() and not args.force:
|
|
159
|
+
print(
|
|
160
|
+
f"pix: error: config file already exists: {path} (use --force to overwrite)",
|
|
161
|
+
file=sys.stderr,
|
|
162
|
+
)
|
|
163
|
+
return 2
|
|
164
|
+
try:
|
|
165
|
+
template = Path(__file__).with_name("default_config.toml").read_text(encoding="utf-8")
|
|
166
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
path.write_text(template, encoding="utf-8")
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
print(f"pix: error: {path}: {exc}", file=sys.stderr)
|
|
170
|
+
return 2
|
|
171
|
+
print(f"wrote {path}")
|
|
172
|
+
return 0
|
pix/default_config.toml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pix configuration
|
|
2
|
+
# Command-line flags override these values.
|
|
3
|
+
|
|
4
|
+
[shrink]
|
|
5
|
+
# Longest edge in pixels.
|
|
6
|
+
width = 1920
|
|
7
|
+
# AVIF quality, 1-100.
|
|
8
|
+
quality = 85
|
|
9
|
+
# libvips effort, 0-9 (higher is slower).
|
|
10
|
+
effort = 5
|
|
11
|
+
# Skip sources smaller than this; accepts bytes or 0, 500K, 1M, 2G.
|
|
12
|
+
min_size = 0
|
|
13
|
+
# Extensions to scan, written without dots.
|
|
14
|
+
include = ["jpg", "jpeg", "png", "webp"]
|
|
15
|
+
|
|
16
|
+
[backup]
|
|
17
|
+
# rclone exclude globs; command-line --exclude is appended after these.
|
|
18
|
+
exclude = []
|
|
19
|
+
# Maximum percent of files rclone may delete (-1 disables, 0 forbids).
|
|
20
|
+
max_delete = 10
|
|
21
|
+
|
|
22
|
+
[publish]
|
|
23
|
+
# Public URL prefix; must start with http:// or https://.
|
|
24
|
+
base_url = ""
|
|
25
|
+
# Emit a JSON URL manifest.
|
|
26
|
+
urls = false
|
|
27
|
+
exclude = []
|
|
28
|
+
max_delete = 10
|
|
29
|
+
|
|
30
|
+
[rclone]
|
|
31
|
+
# rclone executable name or path.
|
|
32
|
+
binary = "rclone"
|
pix/doctor.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""doctor subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import default_config_path
|
|
13
|
+
from .logger import Logger
|
|
14
|
+
from .rclone import RCLONE_HINT, RCLONE_MIN, probe_rclone
|
|
15
|
+
from .shrink import PYVIPS_HINT
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def doctor(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
|
|
19
|
+
issues = 0
|
|
20
|
+
print(f"pix {__version__}")
|
|
21
|
+
print(f"python {platform.python_version()} ({platform.system().lower()})")
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import pyvips
|
|
25
|
+
|
|
26
|
+
libvips = ".".join(str(pyvips.version(index)) for index in range(3))
|
|
27
|
+
print(f"vips {libvips} via pyvips {pyvips.__version__} (API mode)")
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
print("vips unavailable")
|
|
30
|
+
print(PYVIPS_HINT, file=sys.stderr)
|
|
31
|
+
print(f"pix: error: {exc}", file=sys.stderr)
|
|
32
|
+
issues = 1
|
|
33
|
+
|
|
34
|
+
binary = settings["rclone"]["binary"]
|
|
35
|
+
version, parsed, _error = probe_rclone(binary)
|
|
36
|
+
location = shutil.which(binary) or binary
|
|
37
|
+
if parsed is None:
|
|
38
|
+
print(f"rclone missing ({binary})")
|
|
39
|
+
print(RCLONE_HINT, file=sys.stderr)
|
|
40
|
+
issues = 1
|
|
41
|
+
elif parsed[:2] < RCLONE_MIN:
|
|
42
|
+
print(f"rclone {version} ({location}) too old; need >= 1.66")
|
|
43
|
+
print(RCLONE_HINT, file=sys.stderr)
|
|
44
|
+
issues = 1
|
|
45
|
+
else:
|
|
46
|
+
print(f"rclone {version} ({location}) bisync ok")
|
|
47
|
+
|
|
48
|
+
config_path = args.config if args.config is not None else default_config_path()
|
|
49
|
+
if config_path.is_file():
|
|
50
|
+
print(f"config {config_path} (ok)")
|
|
51
|
+
else:
|
|
52
|
+
print(f"config {config_path} (missing (using defaults))")
|
|
53
|
+
|
|
54
|
+
return 1 if issues else 0
|
pix/logger.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""stderr logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Logger:
|
|
9
|
+
def __init__(self, verbose: bool = False, quiet: bool = False) -> None:
|
|
10
|
+
self.verbose = verbose
|
|
11
|
+
self.quiet = quiet
|
|
12
|
+
|
|
13
|
+
def info(self, message: str) -> None:
|
|
14
|
+
if not self.quiet:
|
|
15
|
+
print(message, file=sys.stderr)
|
|
16
|
+
|
|
17
|
+
def detail(self, message: str) -> None:
|
|
18
|
+
if self.verbose and not self.quiet:
|
|
19
|
+
print(message, file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
def warning(self, message: str) -> None:
|
|
22
|
+
if not self.quiet:
|
|
23
|
+
print(f"pix: warning: {message}", file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
def error(self, message: str) -> None:
|
|
26
|
+
print(f"pix: error: {message}", file=sys.stderr)
|
pix/py.typed
ADDED
|
File without changes
|
pix/rclone.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""rclone probing and execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .logger import Logger
|
|
11
|
+
|
|
12
|
+
RCLONE_MIN = (1, 66)
|
|
13
|
+
RCLONE_HINT = (
|
|
14
|
+
"install rclone >= 1.66: https://rclone.org/downloads/ "
|
|
15
|
+
"(Windows: winget search rclone)"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def merge_excludes(*groups: list[str] | None) -> list[str]:
|
|
20
|
+
merged: list[str] = []
|
|
21
|
+
seen: set[str] = set()
|
|
22
|
+
for group in groups:
|
|
23
|
+
for item in group or []:
|
|
24
|
+
if item not in seen:
|
|
25
|
+
seen.add(item)
|
|
26
|
+
merged.append(item)
|
|
27
|
+
return merged
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_files(path: Path) -> bool:
|
|
31
|
+
return any(item.is_file() for item in path.rglob("*"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def probe_rclone(binary: str) -> tuple[str | None, tuple[int, int, int] | None, str | None]:
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run([binary, "version"], capture_output=True, text=True)
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return None, None, f"rclone not found: {binary}"
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
return None, None, f"cannot run rclone: {exc}"
|
|
41
|
+
output = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
|
|
42
|
+
match = re.search(r"rclone v(\d+)\.(\d+)(?:\.(\d+))?", output)
|
|
43
|
+
if match is None:
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
return None, None, f"rclone version failed with exit code {result.returncode}"
|
|
46
|
+
return None, None, "cannot parse rclone version"
|
|
47
|
+
parsed = (int(match.group(1)), int(match.group(2)), int(match.group(3) or 0))
|
|
48
|
+
return f"{parsed[0]}.{parsed[1]}.{parsed[2]}", parsed, None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_rclone(binary: str, log: Logger) -> str | None:
|
|
52
|
+
version, parsed, error = probe_rclone(binary)
|
|
53
|
+
if parsed is None:
|
|
54
|
+
log.error(error or "rclone unavailable")
|
|
55
|
+
log.error(RCLONE_HINT)
|
|
56
|
+
return None
|
|
57
|
+
if parsed[:2] < RCLONE_MIN:
|
|
58
|
+
log.error(f"rclone {version} is too old; need >= 1.66")
|
|
59
|
+
log.error(f"upgrade rclone: {RCLONE_HINT}")
|
|
60
|
+
return None
|
|
61
|
+
return version
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_rclone(cmd: list[str]) -> int:
|
|
65
|
+
try:
|
|
66
|
+
return subprocess.run(cmd).returncode
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
print(f"pix: error: rclone not found: {cmd[0]}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
print(f"pix: error: cannot run rclone: {exc}", file=sys.stderr)
|
|
72
|
+
return 1
|
pix/shrink.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""shrink subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import ConfigError, parse_size, require_int
|
|
13
|
+
from .logger import Logger
|
|
14
|
+
|
|
15
|
+
STATE_VERSION = 1
|
|
16
|
+
STATE_DIR = ".pix"
|
|
17
|
+
|
|
18
|
+
PYVIPS_HINT = """pyvips/libvips 不可用。
|
|
19
|
+
|
|
20
|
+
推荐重装(会自带 libvips):
|
|
21
|
+
uv tool install --force pix-cli
|
|
22
|
+
|
|
23
|
+
也可以手动安装 libvips:
|
|
24
|
+
Windows: 从 https://github.com/libvips/libvips/releases 下载
|
|
25
|
+
vips-dev-w64-web-*.zip,解压后把 <vips>\\bin 加入 PATH
|
|
26
|
+
Linux: sudo apt install libvips42
|
|
27
|
+
|
|
28
|
+
注意:uv 只能安装 Python 包,不能单独安装原生 libvips。
|
|
29
|
+
pyvips[binary] 的作用就是把 libvips 一起打包进 Python 环境。"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scan_sources(source: Path, include: list[str]) -> list[Path]:
|
|
33
|
+
allowed = {item.lower() for item in include}
|
|
34
|
+
paths = [
|
|
35
|
+
path
|
|
36
|
+
for path in source.rglob("*")
|
|
37
|
+
if path.is_file() and path.suffix.lower().lstrip(".") in allowed
|
|
38
|
+
]
|
|
39
|
+
paths.sort(key=lambda path: path.relative_to(source).as_posix())
|
|
40
|
+
return paths
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _target_map(
|
|
44
|
+
sources: list[Path], source: Path, dest: Path
|
|
45
|
+
) -> tuple[dict[Path, Path], dict[str, list[str]]]:
|
|
46
|
+
targets: dict[Path, Path] = {}
|
|
47
|
+
owners: dict[str, list[str]] = {}
|
|
48
|
+
for src in sources:
|
|
49
|
+
rel = src.relative_to(source)
|
|
50
|
+
out_rel = rel.with_suffix(".avif")
|
|
51
|
+
key = out_rel.as_posix()
|
|
52
|
+
owners.setdefault(key, []).append(rel.as_posix())
|
|
53
|
+
targets[src] = dest / out_rel
|
|
54
|
+
conflicts = {key: rels for key, rels in owners.items() if len(rels) > 1}
|
|
55
|
+
return targets, conflicts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_state(path: Path, params: str, log: Logger) -> dict[str, Any]:
|
|
59
|
+
empty = {"version": STATE_VERSION, "params": params, "files": {}}
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
return empty
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, ValueError) as exc:
|
|
65
|
+
log.warning(f"cannot read state {path}: {exc}; rebuilding")
|
|
66
|
+
return empty
|
|
67
|
+
if (
|
|
68
|
+
not isinstance(data, dict)
|
|
69
|
+
or data.get("version") != STATE_VERSION
|
|
70
|
+
or not isinstance(data.get("files"), dict)
|
|
71
|
+
or not all(
|
|
72
|
+
isinstance(key, str) and isinstance(value, dict)
|
|
73
|
+
for key, value in data["files"].items()
|
|
74
|
+
)
|
|
75
|
+
):
|
|
76
|
+
log.warning(f"unrecognized state {path}; rebuilding")
|
|
77
|
+
return empty
|
|
78
|
+
return {
|
|
79
|
+
"version": STATE_VERSION,
|
|
80
|
+
"params": data.get("params") if isinstance(data.get("params"), str) else "",
|
|
81
|
+
"files": data["files"],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _save_state(path: Path, state: dict[str, Any]) -> None:
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
88
|
+
try:
|
|
89
|
+
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
90
|
+
os.replace(tmp, path)
|
|
91
|
+
except OSError:
|
|
92
|
+
try:
|
|
93
|
+
tmp.unlink()
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _acquire_lock(path: Path) -> bool:
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
try:
|
|
102
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
103
|
+
except FileExistsError:
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
os.write(fd, f"{os.getpid()}\n".encode())
|
|
107
|
+
finally:
|
|
108
|
+
os.close(fd)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _release_lock(path: Path) -> None:
|
|
113
|
+
try:
|
|
114
|
+
path.unlink()
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _skip_reason(
|
|
120
|
+
src_stat: os.stat_result,
|
|
121
|
+
dest_path: Path,
|
|
122
|
+
entry: dict[str, Any] | None,
|
|
123
|
+
params_match: bool,
|
|
124
|
+
min_size: int,
|
|
125
|
+
) -> str | None:
|
|
126
|
+
if min_size > 0 and src_stat.st_size < min_size:
|
|
127
|
+
return "small"
|
|
128
|
+
if entry is None or not params_match:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
out_stat = dest_path.stat()
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
if (
|
|
135
|
+
entry.get("src_size") == src_stat.st_size
|
|
136
|
+
and entry.get("src_mtime_ns") == src_stat.st_mtime_ns
|
|
137
|
+
and entry.get("out_size") == out_stat.st_size
|
|
138
|
+
):
|
|
139
|
+
return "state"
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _prune(
|
|
144
|
+
state: dict[str, Any],
|
|
145
|
+
source: Path,
|
|
146
|
+
dest: Path,
|
|
147
|
+
source_count: int,
|
|
148
|
+
dry_run: bool,
|
|
149
|
+
allow_empty: bool,
|
|
150
|
+
log: Logger,
|
|
151
|
+
) -> tuple[int, int, int]:
|
|
152
|
+
files = state["files"]
|
|
153
|
+
if source_count == 0 and files:
|
|
154
|
+
if not allow_empty:
|
|
155
|
+
log.error(
|
|
156
|
+
"source has no images but state has entries; refusing to prune "
|
|
157
|
+
"(use --allow-empty to override)"
|
|
158
|
+
)
|
|
159
|
+
return 0, 0, len(files)
|
|
160
|
+
stale = sorted(rel for rel in files if not (source / rel).is_file())
|
|
161
|
+
deleted = 0
|
|
162
|
+
failed = 0
|
|
163
|
+
for rel in stale:
|
|
164
|
+
if dry_run:
|
|
165
|
+
print(f"prune {rel}")
|
|
166
|
+
deleted += 1
|
|
167
|
+
continue
|
|
168
|
+
out_path = dest / Path(rel).with_suffix(".avif")
|
|
169
|
+
try:
|
|
170
|
+
out_path.unlink()
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
pass
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
log.error(f"prune {rel}: {exc}")
|
|
175
|
+
failed += 1
|
|
176
|
+
continue
|
|
177
|
+
del files[rel]
|
|
178
|
+
deleted += 1
|
|
179
|
+
log.detail(f"pruned {rel}")
|
|
180
|
+
return deleted, failed, 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _log_summary(log: Logger, counters: dict[str, int]) -> None:
|
|
184
|
+
log.info(
|
|
185
|
+
"summary: converted={converted} skipped={state} too-small={small} "
|
|
186
|
+
"empty-protected={protected} failed={failed} deleted={deleted}".format(**counters)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def shrink(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
|
|
191
|
+
config = settings["shrink"]
|
|
192
|
+
try:
|
|
193
|
+
width = require_int(
|
|
194
|
+
"width", args.width if args.width is not None else config["width"], 1
|
|
195
|
+
)
|
|
196
|
+
quality = require_int(
|
|
197
|
+
"quality", args.quality if args.quality is not None else config["quality"], 1, 100
|
|
198
|
+
)
|
|
199
|
+
effort = require_int(
|
|
200
|
+
"effort", args.effort if args.effort is not None else config["effort"], 0, 9
|
|
201
|
+
)
|
|
202
|
+
min_size = (
|
|
203
|
+
parse_size(args.min_size) if args.min_size is not None else config["min_size"]
|
|
204
|
+
)
|
|
205
|
+
except (ConfigError, ValueError) as exc:
|
|
206
|
+
log.error(str(exc))
|
|
207
|
+
return 2
|
|
208
|
+
|
|
209
|
+
source = args.source
|
|
210
|
+
dest = args.dest
|
|
211
|
+
if not source.is_dir():
|
|
212
|
+
log.error(f"source is not a directory: {source}")
|
|
213
|
+
return 2
|
|
214
|
+
if dest.exists() and not dest.is_dir():
|
|
215
|
+
log.error(f"dest is not a directory: {dest}")
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
sources = _scan_sources(source, config["include"])
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
log.error(f"cannot scan {source}: {exc}")
|
|
222
|
+
return 1
|
|
223
|
+
targets, conflicts = _target_map(sources, source, dest)
|
|
224
|
+
if conflicts:
|
|
225
|
+
log.error("multiple sources map to the same output:")
|
|
226
|
+
for key, rels in sorted(conflicts.items()):
|
|
227
|
+
print(f" {key} <- {', '.join(rels)}", file=sys.stderr)
|
|
228
|
+
return 2
|
|
229
|
+
|
|
230
|
+
params = f"w={width};q={quality};e={effort}"
|
|
231
|
+
state_file = dest / STATE_DIR / "state.json"
|
|
232
|
+
lock_file = dest / STATE_DIR / "lock"
|
|
233
|
+
|
|
234
|
+
if not args.dry_run:
|
|
235
|
+
try:
|
|
236
|
+
if not _acquire_lock(lock_file):
|
|
237
|
+
log.error(
|
|
238
|
+
f"another shrink appears to be running ({lock_file}); "
|
|
239
|
+
"remove the file if it is stale"
|
|
240
|
+
)
|
|
241
|
+
return 1
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
log.error(f"cannot create lock {lock_file}: {exc}")
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
log.info(f"scanning {source}: {len(sources)} image(s)")
|
|
247
|
+
state = _load_state(state_file, params, log)
|
|
248
|
+
params_match = state["params"] == params
|
|
249
|
+
files = state["files"]
|
|
250
|
+
counters = {
|
|
251
|
+
"converted": 0,
|
|
252
|
+
"state": 0,
|
|
253
|
+
"small": 0,
|
|
254
|
+
"protected": 0,
|
|
255
|
+
"failed": 0,
|
|
256
|
+
"deleted": 0,
|
|
257
|
+
}
|
|
258
|
+
status = 0
|
|
259
|
+
pyvips = None
|
|
260
|
+
encoded: set[str] = set()
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
for src in sources:
|
|
264
|
+
rel = src.relative_to(source).as_posix()
|
|
265
|
+
try:
|
|
266
|
+
src_stat = src.stat()
|
|
267
|
+
except OSError as exc:
|
|
268
|
+
log.error(f"{rel}: {exc}")
|
|
269
|
+
counters["failed"] += 1
|
|
270
|
+
status = 1
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
reason = None if args.force else _skip_reason(
|
|
274
|
+
src_stat, targets[src], files.get(rel), params_match, min_size
|
|
275
|
+
)
|
|
276
|
+
if reason == "small":
|
|
277
|
+
counters["small"] += 1
|
|
278
|
+
log.detail(f"skip {rel} (too small)")
|
|
279
|
+
continue
|
|
280
|
+
if reason == "state":
|
|
281
|
+
counters["state"] += 1
|
|
282
|
+
log.detail(f"skip {rel} (unchanged)")
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
if args.dry_run:
|
|
286
|
+
print(rel)
|
|
287
|
+
counters["converted"] += 1
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if pyvips is None:
|
|
291
|
+
try:
|
|
292
|
+
import pyvips as pyvips_module
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
log.error(PYVIPS_HINT)
|
|
295
|
+
log.error(str(exc))
|
|
296
|
+
status = 1
|
|
297
|
+
break
|
|
298
|
+
pyvips = pyvips_module
|
|
299
|
+
|
|
300
|
+
dest_path = targets[src]
|
|
301
|
+
tmp = dest_path.with_name(f".{dest_path.name}.{os.getpid()}.tmp.avif")
|
|
302
|
+
try:
|
|
303
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
try:
|
|
305
|
+
img = pyvips.Image.thumbnail(str(src), width, size="down", linear=True)
|
|
306
|
+
img.write_to_file(str(tmp), Q=quality, keep="none", effort=effort)
|
|
307
|
+
os.replace(tmp, dest_path)
|
|
308
|
+
except Exception:
|
|
309
|
+
try:
|
|
310
|
+
tmp.unlink()
|
|
311
|
+
except OSError:
|
|
312
|
+
pass
|
|
313
|
+
raise
|
|
314
|
+
out_stat = dest_path.stat()
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
log.error(f"{rel}: {exc}")
|
|
317
|
+
counters["failed"] += 1
|
|
318
|
+
status = 1
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
files[rel] = {
|
|
322
|
+
"src_size": src_stat.st_size,
|
|
323
|
+
"src_mtime_ns": src_stat.st_mtime_ns,
|
|
324
|
+
"out_size": out_stat.st_size,
|
|
325
|
+
"out_mtime_ns": out_stat.st_mtime_ns,
|
|
326
|
+
}
|
|
327
|
+
encoded.add(rel)
|
|
328
|
+
counters["converted"] += 1
|
|
329
|
+
log.detail(f"encoded {rel}")
|
|
330
|
+
if counters["converted"] % 200 == 0:
|
|
331
|
+
try:
|
|
332
|
+
_save_state(state_file, state)
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
log.error(f"cannot write state {state_file}: {exc}")
|
|
335
|
+
status = 1
|
|
336
|
+
|
|
337
|
+
if args.prune:
|
|
338
|
+
deleted, failed, protected = _prune(
|
|
339
|
+
state, source, dest, len(sources), args.dry_run, args.allow_empty, log
|
|
340
|
+
)
|
|
341
|
+
counters["deleted"] += deleted
|
|
342
|
+
counters["failed"] += failed
|
|
343
|
+
counters["protected"] += protected
|
|
344
|
+
if failed or protected:
|
|
345
|
+
status = 1
|
|
346
|
+
|
|
347
|
+
if not params_match and status == 0 and not args.dry_run:
|
|
348
|
+
# Only keep entries written under the new parameters; stale entries
|
|
349
|
+
# would otherwise be skipped after min_size or include changes.
|
|
350
|
+
state["files"] = {
|
|
351
|
+
rel: entry for rel, entry in state["files"].items() if rel in encoded
|
|
352
|
+
}
|
|
353
|
+
except KeyboardInterrupt:
|
|
354
|
+
log.error("interrupted")
|
|
355
|
+
status = 1
|
|
356
|
+
finally:
|
|
357
|
+
if not args.dry_run:
|
|
358
|
+
if params_match or status == 0:
|
|
359
|
+
state["params"] = params
|
|
360
|
+
try:
|
|
361
|
+
_save_state(state_file, state)
|
|
362
|
+
except OSError as exc:
|
|
363
|
+
log.error(f"cannot write state {state_file}: {exc}")
|
|
364
|
+
status = 1
|
|
365
|
+
_release_lock(lock_file)
|
|
366
|
+
|
|
367
|
+
_log_summary(log, counters)
|
|
368
|
+
return status
|
pix/sync.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""backup and publish subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from .config import ConfigError, require_int
|
|
12
|
+
from .logger import Logger
|
|
13
|
+
from .rclone import check_rclone, has_files, merge_excludes, run_rclone
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def backup(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
|
|
17
|
+
config = settings["backup"]
|
|
18
|
+
source = args.source
|
|
19
|
+
if not source.is_dir():
|
|
20
|
+
log.error(f"source is not a directory: {source}")
|
|
21
|
+
return 2
|
|
22
|
+
try:
|
|
23
|
+
max_delete = require_int(
|
|
24
|
+
"max-delete",
|
|
25
|
+
args.max_delete if args.max_delete is not None else config["max_delete"],
|
|
26
|
+
-1,
|
|
27
|
+
100,
|
|
28
|
+
)
|
|
29
|
+
except ConfigError as exc:
|
|
30
|
+
log.error(str(exc))
|
|
31
|
+
return 2
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
has_files_found = has_files(source)
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
log.error(f"cannot scan {source}: {exc}")
|
|
37
|
+
return 1
|
|
38
|
+
if not has_files_found and not (args.allow_empty and args.resync):
|
|
39
|
+
log.error("source is empty; use --allow-empty together with --resync to proceed")
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
binary = settings["rclone"]["binary"]
|
|
43
|
+
version = check_rclone(binary, log)
|
|
44
|
+
if version is None:
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
excludes = merge_excludes(config["exclude"], args.exclude)
|
|
48
|
+
cmd = [binary, "bisync", str(source), args.dest]
|
|
49
|
+
for pattern in excludes:
|
|
50
|
+
cmd += ["--exclude", pattern]
|
|
51
|
+
cmd += ["--max-delete", str(max_delete)]
|
|
52
|
+
if args.resync:
|
|
53
|
+
cmd.append("--resync")
|
|
54
|
+
if args.dry_run:
|
|
55
|
+
cmd.append("--dry-run")
|
|
56
|
+
cmd.append("--progress" if args.verbose else "--quiet")
|
|
57
|
+
|
|
58
|
+
log.info(f"bisync {source} <-> {args.dest} (rclone {version})")
|
|
59
|
+
log.detail(" ".join(cmd))
|
|
60
|
+
return run_rclone(cmd)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _collect_avif(source: Path) -> list[Path]:
|
|
64
|
+
files = [
|
|
65
|
+
path for path in source.rglob("*") if path.is_file() and path.suffix.lower() == ".avif"
|
|
66
|
+
]
|
|
67
|
+
files.sort(key=lambda path: path.relative_to(source).as_posix())
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def publish(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
|
|
72
|
+
config = settings["publish"]
|
|
73
|
+
source = args.source
|
|
74
|
+
if not source.is_dir():
|
|
75
|
+
log.error(f"source is not a directory: {source}")
|
|
76
|
+
return 2
|
|
77
|
+
try:
|
|
78
|
+
max_delete = require_int(
|
|
79
|
+
"max-delete",
|
|
80
|
+
args.max_delete if args.max_delete is not None else config["max_delete"],
|
|
81
|
+
-1,
|
|
82
|
+
100,
|
|
83
|
+
)
|
|
84
|
+
except ConfigError as exc:
|
|
85
|
+
log.error(str(exc))
|
|
86
|
+
return 2
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
avifs = _collect_avif(source)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
log.error(f"cannot scan {source}: {exc}")
|
|
92
|
+
return 1
|
|
93
|
+
if not avifs and not (args.allow_empty and args.resync):
|
|
94
|
+
log.error(
|
|
95
|
+
"source contains no .avif files; use --allow-empty together with --resync "
|
|
96
|
+
"to proceed"
|
|
97
|
+
)
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
base_url = args.base_url if args.base_url is not None else config["base_url"]
|
|
101
|
+
urls = args.urls if args.urls is not None else config["urls"]
|
|
102
|
+
if base_url and not base_url.startswith(("http://", "https://")):
|
|
103
|
+
log.error("base-url must start with http:// or https://")
|
|
104
|
+
return 2
|
|
105
|
+
if urls and not base_url:
|
|
106
|
+
log.error("--urls requires a base URL (--base-url or [publish].base_url)")
|
|
107
|
+
return 2
|
|
108
|
+
|
|
109
|
+
binary = settings["rclone"]["binary"]
|
|
110
|
+
version = check_rclone(binary, log)
|
|
111
|
+
if version is None:
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
excludes = merge_excludes([".pix/**"], config["exclude"], args.exclude)
|
|
115
|
+
cmd = [binary, "bisync", str(source), args.dest]
|
|
116
|
+
for pattern in excludes:
|
|
117
|
+
cmd += ["--exclude", pattern]
|
|
118
|
+
cmd += ["--max-delete", str(max_delete)]
|
|
119
|
+
if args.resync:
|
|
120
|
+
cmd.append("--resync")
|
|
121
|
+
if args.dry_run:
|
|
122
|
+
cmd.append("--dry-run")
|
|
123
|
+
cmd.append("--progress" if args.verbose else "--quiet")
|
|
124
|
+
|
|
125
|
+
log.info(f"bisync {source} <-> {args.dest} (rclone {version})")
|
|
126
|
+
log.detail(" ".join(cmd))
|
|
127
|
+
code = run_rclone(cmd)
|
|
128
|
+
if code != 0 or args.dry_run or not urls:
|
|
129
|
+
return code
|
|
130
|
+
|
|
131
|
+
root = base_url.rstrip("/")
|
|
132
|
+
try:
|
|
133
|
+
files = _collect_avif(source)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
log.error(f"cannot scan {source}: {exc}")
|
|
136
|
+
return 1
|
|
137
|
+
manifest = {
|
|
138
|
+
rel: f"{root}/{quote(rel, safe='/')}"
|
|
139
|
+
for rel in (path.relative_to(source).as_posix() for path in files)
|
|
140
|
+
}
|
|
141
|
+
print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
|
|
142
|
+
return code
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pix-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Batch AVIF shrink and image-host sync CLI
|
|
5
|
+
Author-email: uzvg <uzvg@qq.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: pyvips[binary]>=3.2
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# pix
|
|
11
|
+
|
|
12
|
+
Batch AVIF shrink and image-host sync CLI.
|
|
13
|
+
|
|
14
|
+
`pix` 把本地图片目录增量压缩为 AVIF,再通过 rclone bisync 备份原图、发布压缩图到图床。只处理显式传入的路径和远端。
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pix shrink -s ./photos -d ./out -w 1920 -q 85
|
|
18
|
+
pix backup -s ./photos -d onedrive:photos --resync
|
|
19
|
+
pix publish -s ./out -d r2:images --base-url https://img.example.com --urls
|
|
20
|
+
pix doctor
|
|
21
|
+
pix config init
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`backup` / `publish` 是双向镜像,不是单向备份:删除会双向传播。默认 `--max-delete 10`(删除百分比,即 10%),超过该比例时 rclone 会拒绝;首次运行或修改排除项后必须显式 `--resync`,先跑 `--dry-run` 更安全。
|
|
25
|
+
|
|
26
|
+
> Status: implementation complete; real R2 and Windows smoke tests pending (see roadmap).
|
|
27
|
+
|
|
28
|
+
## Docs
|
|
29
|
+
|
|
30
|
+
- [Architecture](docs/architecture.md) — behavior spec
|
|
31
|
+
- [Development](docs/development.md) — setup, tests, build
|
|
32
|
+
- [Decisions](docs/decisions.md) — technical decisions and rationale
|
|
33
|
+
- [Roadmap](docs/roadmap.md) — status and tasks
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
uv run pytest
|
|
40
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pix/__init__.py,sha256=gRYT_Cw6vsRunTh7WKL9SZSCZE0wcOdWrecSDfDYgbg,290
|
|
2
|
+
pix/cli.py,sha256=vcrih_Kv8O4E0AC6dCkqd8qWS5rBZWwkiGfvy7QvoAk,4539
|
|
3
|
+
pix/config.py,sha256=FTbwpUlCJlBlm0V1yt5Zt1mu9v2uveckCBnioImXIhs,5872
|
|
4
|
+
pix/default_config.toml,sha256=q6y5sm_b90LGhRC-bQMd978qheSPO9Xx4abcmJqTIsE,765
|
|
5
|
+
pix/doctor.py,sha256=z2ZrS-dsVPh2grdFda4XRze-0xUcGEd4rwxBZY6rl18,1676
|
|
6
|
+
pix/logger.py,sha256=QUijBqc6CHFUKtF9L_1PcuTRCOS_FDt6eVzq3XD_Dik,704
|
|
7
|
+
pix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pix/rclone.py,sha256=vvVReBo_XFHjmfoKBjPoFVUGaJxwe1zBGD4klLNpNgE,2290
|
|
9
|
+
pix/shrink.py,sha256=DcptFJhDZ5j8zZrd-iFUhPHPp4uuoQdEHimdCjh446Y,11686
|
|
10
|
+
pix/sync.py,sha256=yhlkl9OCCm0FsLwLNius7B9BNkpWVjevScNVcF6kKSg,4517
|
|
11
|
+
pix_cli-0.1.0.dist-info/METADATA,sha256=yB9ZwSA6v4mzPTgtHH9ETGt3eMqnbn8LOBWuyTpGZJo,1323
|
|
12
|
+
pix_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
pix_cli-0.1.0.dist-info/entry_points.txt,sha256=1TW3gP0Yaf19T624ac9xUG9x8tLkQgPnfWlj3yNgOsY,37
|
|
14
|
+
pix_cli-0.1.0.dist-info/RECORD,,
|