A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: update README and CLAUDE.md for 0.5.x APIs

Covers adaptive concurrency, local-availability lookup, error
classification, -1728 unavailable mapping, and updated test count (63).

+83 -13
+9 -5
CLAUDE.md
··· 6 6 7 7 ```bash 8 8 swift build -c release | xcsift # Build release binary 9 - swift test | xcsift # Run tests (44 tests, 8 suites) 9 + swift test | xcsift # Run tests (63 tests) 10 10 swiftlint --fix # Auto-fix lint issues 11 11 ``` 12 12 13 13 ## Architecture 14 14 15 15 - **LadderKit** (library product): consumed by other Swift packages via SPM 16 - - `PhotoLibrary` protocol + `PhotoKitLibrary` — asset discovery and export via PhotoKit 17 - - `PhotosDatabase` — reads Photos.sqlite for metadata enrichment (keywords, people, descriptions, albums, filenames, edits) 16 + - `PhotoLibrary` protocol + `PhotoKitLibrary` — asset discovery and export via PhotoKit. `fetchAssets` preserves caller-provided identifier keys (bare UUID or full `"UUID/L0/001"`). `loadEnrichedAssets(libraryURL:)` is a one-call convenience that enumerates + enriches. 17 + - `PhotosDatabase` — reads Photos.sqlite for metadata enrichment (keywords, people, descriptions, albums, filenames, edits) and local-availability flags. 18 18 - `PhotosLibraryPath` — validates `.photoslibrary` bundles, derives database paths 19 - - `PhotoExporter` — concurrent file export with inline SHA-256 hashing 19 + - `PhotoExporter` — concurrent file export with inline SHA-256 hashing. Partitions a batch into local vs. iCloud lanes; the iCloud lane can be throttled by an `AdaptiveConcurrencyControlling` controller. 20 + - `LocalAvailabilityProviding` + `PhotosDatabaseLocalAvailability` — tells the exporter which assets are cached locally (`ZLOCALAVAILABILITY = 1`) so iCloud-only work can be isolated. Use `.fromLibrary(at:)` for one-call setup. 21 + - `AdaptiveConcurrencyControlling` + `ExportOutcome` — observation-only protocol for tuning the iCloud lane. LadderKit ships no concrete controller; callers own the policy. Outcomes are `.success`, `.transientFailure`, `.permanentFailure`. 22 + - `AppleScriptRunner` / `ScriptExporter` — iCloud-only fallback via Photos.app. Detects `-1728 "Can't get media item"` and raises `AppleScriptError.assetUnavailable`, which the exporter maps to `ExportClassification.permanentlyUnavailable`. 23 + - `ExportError.classification` — `.other`, `.transientCloud`, or `.permanentlyUnavailable`. Flows end-to-end so callers can route retry/skip decisions without string-matching. 20 24 - `StreamingHasher` / `FileHasher` — incremental and one-shot SHA-256 21 25 - `PathSafety` — filename sanitization and path traversal prevention 22 26 - `AssetInfo`, `AlbumInfo`, `PersonInfo`, `AssetKind` — data models (all `Codable` + `Sendable`) 23 - - **CLI** (executable): thin JSON-in/JSON-out wrapper around `PhotoExporter` for subprocess use by the attic Deno CLI 27 + - **CLI** (executable): thin JSON-in/JSON-out wrapper around `PhotoExporter`. Kept for ad-hoc use; the Swift `attic` CLI consumes LadderKit directly as a library. 24 28 25 29 ## Key Design Decisions 26 30
+74 -8
README.md
··· 12 12 13 13 ```swift 14 14 dependencies: [ 15 - .package(url: "https://github.com/tijs/ladder", from: "0.2.0"), 15 + .package(url: "https://github.com/tijs/ladder", from: "0.5.1"), 16 16 ], 17 17 targets: [ 18 18 .target( ··· 94 94 95 95 The fallback runs sequentially (one asset at a time) after all PhotoKit exports complete. SHA-256 is computed after export using `FileHasher`. Pass `scriptExporter: nil` to disable the fallback. 96 96 97 + When Photos.app reports `-1728 "Can't get media item"` (typical for shared-album assets whose derivative has gone missing server-side), the runner raises `AppleScriptError.assetUnavailable` and the exporter classifies the error as `.permanentlyUnavailable` so callers can skip-forever instead of retrying. 98 + 97 99 This approach is inspired by [osxphotos](https://github.com/RhetTbull/osxphotos) (MIT license) by Rhet Turnbull. 98 100 99 101 **Additional permission required:** The AppleScript fallback needs Automation permission (System Settings > Privacy & Security > Automation > ladder > Photos). 100 102 103 + ### Adaptive iCloud-lane throttling 104 + 105 + When "Optimize Mac Storage" is enabled, a batch typically mixes locally-cached assets (fast, parallel-safe) with iCloud-only assets (slow, easily throttled by iCloud). `PhotoExporter` can partition the batch and apply separate concurrency limits per lane. 106 + 107 + ```swift 108 + let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL) 109 + let exporter = PhotoExporter( 110 + stagingDir: stagingDir, 111 + library: library, 112 + scriptExporter: AppleScriptRunner(), 113 + localAvailability: availability, 114 + adaptiveController: MyAIMDController() // your policy 115 + ) 116 + ``` 117 + 118 + `AdaptiveConcurrencyControlling` is observation-only: 119 + 120 + ```swift 121 + public protocol AdaptiveConcurrencyControlling: Sendable { 122 + func currentLimit() async -> Int 123 + func record(_ outcome: ExportOutcome) async 124 + } 125 + 126 + public enum ExportOutcome: Sendable { 127 + case success 128 + case transientFailure // iCloud throttling / network blip — tune down 129 + case permanentFailure // asset gone — ignore for tuning 130 + } 131 + ``` 132 + 133 + LadderKit ships no concrete controller. Implement your own (AIMD, EWMA, whatever fits) or pass `nil` to run the iCloud lane at the exporter's static `maxConcurrency`. 134 + 135 + ### Local-availability lookup 136 + 137 + `PhotosDatabaseLocalAvailability` reads `ZINTERNALRESOURCE.ZLOCALAVAILABILITY` to determine which asset originals are cached locally: 138 + 139 + ```swift 140 + guard let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL) 141 + else { return } 142 + 143 + if availability.isLocallyAvailable(uuid: "B84E8479-475C-4727-A7F4-B3D5E5D71923") { 144 + // export will be fast, no iCloud round-trip 145 + } 146 + ``` 147 + 148 + ### Error classification 149 + 150 + `ExportError.classification` distinguishes genuinely-dead assets from transient failures, so callers can route retry/skip decisions without parsing messages: 151 + 152 + ```swift 153 + public enum ExportClassification: Sendable { 154 + case other // unclassified 155 + case transientCloud // retry later 156 + case permanentlyUnavailable // skip forever (e.g., -1728) 157 + } 158 + ``` 159 + 101 160 ### Standalone Hashing 102 161 103 162 ```swift ··· 117 176 118 177 | Protocol / Type | Purpose | 119 178 |---|---| 120 - | `PhotoLibrary` | Asset discovery and fetch by identifier | 121 - | `AssetHandle` | Single asset's exportable resource | 122 - | `PhotoExporter` | Concurrent export with inline hashing | 123 - | `ScriptExporter` | AppleScript fallback for iCloud-only assets | 124 - | `PhotosDatabase` | Photos.sqlite enrichment reader | 179 + | `PhotoLibrary` | Asset discovery and fetch by identifier (keys preserved) | 180 + | `PhotoKitLibrary.loadEnrichedAssets(libraryURL:)` | Enumerate + enrich in one call | 181 + | `AssetHandle` | Single asset's exportable resource (`isShared` flag for shared-album detection) | 182 + | `PhotoExporter` | Concurrent export with inline hashing, local/iCloud lane partition | 183 + | `AdaptiveConcurrencyControlling` / `ExportOutcome` | Throttle the iCloud lane with your own policy | 184 + | `LocalAvailabilityProviding` / `PhotosDatabaseLocalAvailability` | Which assets are cached locally | 185 + | `ScriptExporter` / `AppleScriptRunner` | AppleScript fallback for iCloud-only assets | 186 + | `AppleScriptError` | Includes `.assetUnavailable` for permanent `-1728` failures | 187 + | `ExportError.classification` | `.other` / `.transientCloud` / `.permanentlyUnavailable` | 188 + | `PhotosDatabase` | Photos.sqlite enrichment reader (+ `localAvailableUUIDs`) | 125 189 | `PhotosLibraryPath` | Library bundle validation and path derivation | 126 190 | `StreamingHasher` | Incremental SHA-256 | 127 191 | `FileHasher` | One-shot file SHA-256 | ··· 238 302 LadderKit/ 239 303 AssetInfo.swift AssetInfo, AssetKind, AlbumInfo, PersonInfo 240 304 PhotoLibrary.swift PhotoLibrary protocol + PhotoKit implementation 241 - PhotoExporter.swift concurrent export with inline hashing 305 + PhotoExporter.swift concurrent export, local/iCloud lane partition, inline hashing 306 + AdaptiveConcurrency.swift AdaptiveConcurrencyControlling + ExportOutcome 307 + LocalAvailability.swift LocalAvailabilityProviding + PhotosDatabaseLocalAvailability 242 308 AppleScriptExporter.swift iCloud-only fallback via Photos.app 243 309 PhotosDatabase.swift Photos.sqlite enrichment reader 244 310 PhotosLibraryPath.swift library bundle validation ··· 262 328 swift test 263 329 ``` 264 330 265 - 44 tests across 8 suites. All tests use mock implementations — no Photos library, credentials, or network required. 331 + 63 tests. All tests use mock implementations — no Photos library, credentials, or network required.