Added
-
Provider-agnostic metadata capture. Every asset now stores a full
AssetMetadatarecord alongside the binary: favorite, rating, GPS (latitude/longitude/altitude), orientation, keywords, title, description, duration, dimensions, timezone offset, is_hidden, is_deleted, modified_at, plus aprovider_dataJSON column for fields without a neutral slot. iCloud fields decode throughsrc/icloud/photos/metadata.rs, which handles Apple's plist-encoded location, keywords, and caption fields. Stored via a v5 schema migration with ametadata_hashcolumn for drift detection. CloudKitSoftDeleted/HardDeletedevents fromchanges/zoneflipis_deletedand stampdeleted_at; local files are left alone. (#239, #19, #83) -
EXIF and XMP write-through (opt-in, default false). New flags embed captured metadata after the file lands on disk:
--set-exif-ratingmaps iCloud's favorite flag to rating=5, otherwise writes the explicit rating.--set-exif-gpsembeds latitude / longitude / altitude.--set-exif-descriptionwritesdc:description.--embed-xmpwrites a full XMP packet. JPEG / PNG / TIFF / MP4 / MOV go through Adobe's XMP Toolkit (vendored C++). HEIC / HEIF / AVIF go through a pure-Rustmp4-atomwriter that inserts the packet as a MIME item inside themetabox without touching the encoded image bytes.--xmp-sidecarwrites a.xmpsidecar next to each media file. Existing sidecars from Darktable, digiKam, or Lightroom are parsed and kei's fields are layered on top rather than overwriting.
Metadata-only drift on the server (rating change, keyword edit) is detected via
metadata_hashcomparison and queues a rewrite pass that patches EXIF/XMP on the already-downloaded file without re-fetching bytes. (#239, #84, #85) -
kei reconcilesubcommand. Scans the state DB fordownloadedrows whoselocal_pathno longer exists and marks them failed so the next sync re-downloads.--dry-runpreviews without writing. Never deletes files or DB rows. (#239, #230) -
--bandwidth-limitflag to cap total download throughput. Accepts human-readable values (10M,500K,2Mi, bare integer = bytes/sec). The cap is global across all concurrent downloads, so total throughput stays within budget regardless of--threads-num. Also configurable via[download] bandwidth_limitin the TOML config andKEI_BANDWIDTH_LIMITenv var. When set without an explicit--threads-num, concurrency defaults to 1 so the capped budget isn't fragmented across many starved connections. (#53) -
-a allto sync every user-created album in one run. Case-insensitive, works as a CLI flag,KEI_ALBUMenv var, orfilters.albums = ["all"]in TOML. Apple's smart folders (Favorites, Screenshots, Videos, Hidden, Recently Deleted, etc.) are skipped - list them explicitly with-a Favoritesif you want them. Combining-a allwith specific names is rejected with "cannot combine 'all' with specific album names". (#215) -
Smart
{album}auto-expansion in--folder-structure. When the template contains{album}and no-aflag is passed, kei implicitly runs-a all. In either mode (explicit-a allor implicit via template), using{album}in the template adds a library-wide pass for photos that aren't in any user-created album -{album}collapses to empty for those, so{album}/%Y/%m/%dputs unfiled photos at%Y/%m/%d/. Without{album}in the template,-a allskips unfiled photos entirely. Photos that belong to multiple albums are copied into each album folder. (#215) -
{album}placement validation.{album}must be the first path segment in--folder-structureand may only appear once.{album}/%Y/%mis fine;Photos/{album}/%Y,%Y/{album}/%m, and{album}/%Y/{album}are rejected at startup with a quoted error message. The restriction keeps unfiled-photo paths stable - without it, collapsing{album}shifts segments around and the unfiled tree no longer matches the album tree. (#215) -
DB-backed asset gauges on
/metrics. Three new Prometheus gauges updated once per real sync cycle:kei_db_assets_total{status="downloaded|pending|failed"},kei_db_assets_size_bytes{status="downloaded"}, andkei_db_last_sync_assets_seen. The gap betweenlast_sync_assets_seenand thedownloadedcount is the most actionable derived metric for "how far behind is the local copy?". No-op when--metrics-portis not set. (#234)
Changed
-
CLI and TOML bounds validation.
--threads-numclamped to 1..=64,--watch-with-intervalto 60..=86400,--max-retriesto 0..=100,--retry-delayto 1..=3600. Validation runs on both CLI and TOML paths so hand-written configs can't bypass the bounds. (#239) -
/healthzflips to 503 when sync is stale. Returns 503 whenlast_success_atexceedswatch-interval * 2. Orchestrators and Docker healthchecks can now catch stuck syncs. (#239) -
Structured
failed_assets[]insync_report.json. Each entry hasid,version_size, anderror_message. Capped at 200 entries withfailed_assets_truncatedcarrying the tail count. (#239)
Fixed
-
CloudKit pagination EOF now requires two consecutive empty pages. A single empty
/records/querypage no longer cuts enumeration short. (#239) -
.partresume window tightened to 1h with server-byte reconciliation on 206 responses. Splicing bytes from pre- and post-rotation versions of the same asset is no longer possible..partcreation usesOpenOptions::create_newto reject concurrent writers. (#239) -
CDN allowlist on download URLs. CloudKit-returned URLs are validated against a narrow allowlist (
.icloud-content.com[.cn],.cdn-apple.com) before any byte hits disk. (#239) -
JSON error envelopes no longer written as images. Known CloudKit error envelope shapes are rejected during content validation, so an error page can't be saved out as
IMG_001.JPG. (#239) -
Disk-space forecast before enqueueing a batch. Batch-size estimate against free disk space warns at 90% and bails at 100%. (#239)
-
State DB unwritable at startup bails the sync. Previously accumulated progress that could not be saved. (#239)
-
PID file liveness check before overwrite. A second kei no longer clobbers a live process's PID file. (#239)
-
Atomic EXIF/XMP/sidecar writes. EXIF/XMP embeds write into a
.partcopy, patch in place, atomic rename. A RAII guard removes the.meta-tmpon any exit path including FFI panic from xmp_toolkit. Sidecar and sync-report writers share onefs_util::atomic_installhelper that prefers rename and falls back to copy-to-sibling-then-rename under EXDEV. (#239)
Dependencies
- Added:
xmp_toolkit(Adobe XMP Toolkit, vendored C++),mp4-atom(pure-Rust ISO-BMFF atom editing),plist(binary plist decoding for Apple'sEncfields),async-speed-limit(bandwidth throttle). - Removed:
kamadak-exif,little_exif. - Bumped:
dialoguer0.11 -> 0.12.
Known limitations
- The state DB tracks a single download path per asset. A photo copied to multiple album folders under
{album}/...has all copies on disk, but the DB records only the most recently written path. Re-running sync stays idempotent because kei's filesystem-exists check is path-aware - it won't re-download files it already put on disk, regardless of which path the DB currently holds.
Full changelog: CHANGELOG.md