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.

Swift 98.9%
Shell 0.8%
Makefile 0.3%
29 1 10

Clone this repository

https://tangled.org/tijs.org/ladder https://tangled.org/did:plc:aq7owa5y7ndc2hzjz37wy7ma/ladder
git@tangled.org:tijs.org/ladder git@tangled.org:did:plc:aq7owa5y7ndc2hzjz37wy7ma/ladder

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz
README.md

ladder logo

ladder#

A Swift library and CLI for accessing the macOS Photos library. LadderKit provides asset discovery, metadata enrichment, and file export via PhotoKit and Photos.sqlite.

LadderKit Library#

Add LadderKit as a dependency in your Package.swift:

dependencies: [
    .package(url: "https://github.com/tijs/ladder", from: "0.5.1"),
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [.product(name: "LadderKit", package: "ladder")]
    ),
]

Requires macOS 13+.

API#

Asset Discovery#

Enumerate all non-trashed assets in the Photos library via PhotoKit:

import LadderKit

let library = PhotoKitLibrary()
let count = library.totalAssetCount()
var assets = library.enumerateAssets()
// assets: [AssetInfo] sorted by creation date, newest first

Each AssetInfo contains core PhotoKit fields: identifier, creation date, media type, dimensions, GPS location, and favorite status.

Metadata Enrichment#

PhotoKit doesn't expose keywords, people, descriptions, albums, filenames, or edit details. These come from Photos.sqlite:

// User selects their .photoslibrary bundle (e.g., via NSOpenPanel)
let libraryURL: URL = ...

// Validate and derive database path
guard PhotosLibraryPath.validate(libraryURL).isValid,
      let dbPath = PhotosLibraryPath.databasePath(for: libraryURL)
else { return }

// Read enrichment data and apply it
let enrichment = PhotosDatabase.readEnrichment(dbPath: dbPath)
PhotosDatabase.enrich(&assets, with: enrichment)

// assets now have: originalFilename, uniformTypeIdentifier, albums,
// keywords, people, description, hasEdit, editedAt, editor

File Export#

Export original photo/video files to a staging directory with inline SHA-256 hashing:

let stagingDir = try PathSafety.validateStagingDir("/tmp/photo-export")
let exporter = PhotoExporter(stagingDir: stagingDir, library: library)
let response = await exporter.export(uuids: ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"])

for result in response.results {
    print(result.path)   // exported file path
    print(result.sha256) // hash computed during streaming write
    print(result.size)   // file size in bytes
}

Files are streamed from PhotoKit to disk. SHA-256 is computed inline during the write — no second pass.

iCloud-only assets#

When "Optimize Mac Storage" is enabled, some assets exist only in iCloud and are invisible to PhotoKit's fetchAssets(). For these assets, PhotoExporter falls back to AppleScript via Photos.app, which handles the iCloud download transparently:

let exporter = PhotoExporter(
    stagingDir: stagingDir,
    library: library,
    scriptExporter: AppleScriptRunner() // enables iCloud fallback
)

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.

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.

This approach is inspired by osxphotos (MIT license) by Rhet Turnbull.

Additional permission required: The AppleScript fallback needs Automation permission (System Settings > Privacy & Security > Automation > ladder > Photos).

Adaptive iCloud-lane throttling#

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.

let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL)
let exporter = PhotoExporter(
    stagingDir: stagingDir,
    library: library,
    scriptExporter: AppleScriptRunner(),
    localAvailability: availability,
    adaptiveController: MyAIMDController() // your policy
)

AdaptiveConcurrencyControlling is observation-only:

public protocol AdaptiveConcurrencyControlling: Sendable {
    func currentLimit() async -> Int
    func record(_ outcome: ExportOutcome) async
}

public enum ExportOutcome: Sendable {
    case success
    case transientFailure       // iCloud throttling / network blip — tune down
    case permanentFailure       // asset gone — ignore for tuning
}

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.

Local-availability lookup#

PhotosDatabaseLocalAvailability reads ZINTERNALRESOURCE.ZLOCALAVAILABILITY to determine which asset originals are cached locally:

guard let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL)
else { return }

if availability.isLocallyAvailable(uuid: "B84E8479-475C-4727-A7F4-B3D5E5D71923") {
    // export will be fast, no iCloud round-trip
}

Error classification#

ExportError.classification distinguishes genuinely-dead assets from transient failures, so callers can route retry/skip decisions without parsing messages:

public enum ExportClassification: Sendable {
    case other                  // unclassified
    case transientCloud         // retry later
    case permanentlyUnavailable // skip forever (e.g., -1728)
}

Standalone Hashing#

// Streaming hasher for incremental use
let hasher = StreamingHasher()
hasher.update(chunk1)
hasher.update(chunk2)
let hash = hasher.finalize() // hex-encoded SHA-256

// One-shot file hashing (8 MB chunks, memory-efficient)
let fileHash = try FileHasher.sha256(fileAt: fileURL)

API Contract#

Provided (what LadderKit gives you)#

Protocol / Type Purpose
PhotoLibrary Asset discovery and fetch by identifier (keys preserved)
PhotoKitLibrary.loadEnrichedAssets(libraryURL:) Enumerate + enrich in one call
AssetHandle Single asset's exportable resource (isShared flag for shared-album detection)
PhotoExporter Concurrent export with inline hashing, local/iCloud lane partition
AdaptiveConcurrencyControlling / ExportOutcome Throttle the iCloud lane with your own policy
LocalAvailabilityProviding / PhotosDatabaseLocalAvailability Which assets are cached locally
ScriptExporter / AppleScriptRunner AppleScript fallback for iCloud-only assets
AppleScriptError Includes .assetUnavailable for permanent -1728 failures
ExportError.classification .other / .transientCloud / .permanentlyUnavailable
PhotosDatabase Photos.sqlite enrichment reader (+ localAvailableUUIDs)
PhotosLibraryPath Library bundle validation and path derivation
StreamingHasher Incremental SHA-256
FileHasher One-shot file SHA-256
PathSafety Filename sanitization and path traversal prevention

Required (what your app must provide)#

Requirement How
Photos permission Call PHPhotoLibrary.requestAuthorization(for: .readWrite) before using PhotoKitLibrary
Photos library path User selects their .photoslibrary bundle. Use PhotosLibraryPath.validate() to verify, then databasePath(for:) to get the sqlite path. A security-scoped bookmark from NSOpenPanel grants file access without Full Disk Access.
Staging directory Provide an absolute path for exported files. Validate with PathSafety.validateStagingDir().

Data Types#

AssetInfo — metadata for a single photo or video:

identifier          String       PhotoKit local identifier (e.g., "UUID/L0/001")
uuid                String       UUID portion extracted from identifier
creationDate        Date?        when the photo was taken
kind                AssetKind    .photo (0) or .video (1)
pixelWidth          Int
pixelHeight         Int
latitude            Double?      GPS coordinates
longitude           Double?
isFavorite          Bool
originalFilename    String?      from Photos.sqlite enrichment
uniformTypeIdentifier String?    e.g., "public.heic"
hasEdit             Bool         true when both adjustment + rendered resource exist
albums              [AlbumInfo]  album membership
keywords            [String]     user-assigned keywords
people              [PersonInfo] recognized faces with names
assetDescription    String?      user-written caption (JSON key: "description")
editedAt            Date?        when the edit was made
editor              String?      editor identifier (e.g., "com.apple.photos")

AssetInfo conforms to Codable. The assetDescription field serializes as "description" in JSON.

AlbumInfo{ identifier: String, title: String }

PersonInfo{ uuid: String, displayName: String }

ExportResult{ uuid: String, path: String, size: Int64, sha256: String }

Testability#

All external dependencies are behind protocols:

  • PhotoLibrary — inject a mock that returns pre-configured assets
  • AssetHandle — inject a mock that writes known data
  • ScriptExporter — inject a mock for AppleScript fallback (or nil to disable)

Tests run without Photos library access, Photos permission, or network. See Tests/PhotoExporterTests.swift for examples.

CLI#

The CLI wraps LadderKit for use as a subprocess (used by attic).

Installing#

make install

Installs to /usr/local/bin/ladder. Use make install PREFIX=~/.local for a different location.

Usage#

echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | ladder
# or
ladder request.json

Input (ExportRequest):

{
  "uuids": ["B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"],
  "stagingDir": "/tmp/photo-export"
}

Output (ExportResponse):

{
  "results": [
    {
      "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001",
      "path": "/tmp/photo-export/B84E8479-475C-4727_IMG_0001.HEIC",
      "size": 3158112,
      "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
    }
  ],
  "errors": [
    { "uuid": "missing-uuid", "message": "Asset not found in Photos library" }
  ]
}

Required permissions#

  • Photos access — grant in System Settings > Privacy & Security > Photos
  • Full Disk Access — may be needed depending on library location
  • Automation (for iCloud-only assets) — grant in System Settings > Privacy & Security > Automation > ladder > Photos

Project structure#

Sources/
  CLI/
    Main.swift              entry point, stdin/stdout JSON protocol
  LadderKit/
    AssetInfo.swift          AssetInfo, AssetKind, AlbumInfo, PersonInfo
    PhotoLibrary.swift       PhotoLibrary protocol + PhotoKit implementation
    PhotoExporter.swift      concurrent export, local/iCloud lane partition, inline hashing
    AdaptiveConcurrency.swift AdaptiveConcurrencyControlling + ExportOutcome
    LocalAvailability.swift  LocalAvailabilityProviding + PhotosDatabaseLocalAvailability
    AppleScriptExporter.swift  iCloud-only fallback via Photos.app
    PhotosDatabase.swift     Photos.sqlite enrichment reader
    PhotosLibraryPath.swift  library bundle validation
    Hasher.swift             StreamingHasher + FileHasher
    Models.swift             ExportRequest, ExportResponse (CLI types)
    PathSafety.swift         filename sanitization, path traversal prevention
Tests/
    AssetInfoTests.swift
    PhotoExporterTests.swift
    AppleScriptExporterTests.swift
    PhotosDatabaseTests.swift
    PhotosLibraryPathTests.swift
    HasherTests.swift
    ModelsTests.swift
    PathSafetyTests.swift

Testing#

swift test

63 tests. All tests use mock implementations — no Photos library, credentials, or network required.