Skip to content

macOS Photo Importer CLI

The @picora/photo-importer CLI runs directly on macOS and lifts the iOS Shortcut “500 photos per batch” ceiling. It scans your local Apple Photos library, deduplicates against Picora server-side by sha256, and uploads only what’s new — resumable, parallel, and safe to interrupt.

Requirements

  • macOS 12+ (Apple Photos.app is macOS-only)
  • Node.js 22+
  • Terminal Full Disk Access: System Settings → Privacy & Security → Full Disk Access → add your terminal app (Terminal.app / iTerm / etc.)

Quick start

Terminal window
# One-shot (no install)
npx @picora/photo-importer \
--library "/Users/$USER/Pictures/Photos Library.photoslibrary" \
--api-key sk_live_xxx \
--since 2020-01-01
# Or with env var
export PICORA_API_KEY=sk_live_xxx
npx @picora/photo-importer --library "$HOME/Pictures/Photos Library.photoslibrary"

What it does

  1. Opens your Photos.sqlite (ZASSET table) read-only — never modifies Apple’s database
  2. Streams sha256 for each file (full library hash with < 50 MB heap)
  3. Batches POST /v1/images/exists (200 hashes per call) to find what’s already on Picora
  4. Uploads only missing files, 4 in parallel by default
  5. Persists progress to ~/.picora-importer-state.json so you can interrupt + resume

Key options

OptionDefaultMeaning
--library <path>Apple Photos library path (.photoslibrary)
--api-key <key>env PICORA_API_KEYPicora API key (sk_live_…)
--since YYYY-MM-DDallOnly sync photos after this date
--until YYYY-MM-DDtodayOnly sync photos before this date
--types <list>allComma-separated: photo,video,live
--concurrent <n>4Concurrent uploads (1-16)
--dry-runoffScan + dedup only, no upload
--reset-stateoffWipe local state, full rescan
--verboseoffPer-file logging

Run npx @picora/photo-importer --help for the full list.

Exit codes

CodeMeaning
0All success
1Partial (some failed but not blocked)
2Auth fail (401/403 from Picora)
3Quota fully exhausted
4User aborted (Ctrl-C)

Interruption + resume

Ctrl-C once → flushes state and exits gracefully. Re-running the same command picks up exactly where you left off (already-uploaded files are skipped via local state; server-side dedup catches any state file loss).

Programmatic API

The CLI is a thin wrapper around PhotoImporter (EventEmitter). You can embed it in your own desktop app or scripts:

import { PhotoImporter } from '@picora/photo-importer'
const importer = new PhotoImporter({
library: '/Users/foo/Pictures/Photos Library.photoslibrary',
apiKey: 'sk_live_xxx',
concurrent: 8,
})
importer.on('progress', (stats) => console.log(`${stats.uploaded}/${stats.total}`))
importer.on('quotaExceeded', () => console.error('Upgrade Pro+'))
await importer.run()

The Moraya desktop app (v0.57.0) is built on top of this same library.

Privacy

  • Apple Photos library opened in readonly mode, never modified
  • API key sent only to api.picora.me (or --api-base you specify)
  • State file ~/.picora-importer-state.json is chmod 0600
  • No telemetry by default (opt in with --telemetry)

Troubleshooting

“Photos.sqlite not found”: The library path must end in .photoslibrary. Make sure Terminal has Full Disk Access.

Hangs on a single huge file: Probably a corrupted iCloud download. Add --continue-on-quota-fail or re-export from Photos.app.

X-Picora-Duplicate: true for everything: That’s by design — sha256 already on Picora. The local state file caches these so future runs skip the hash compute.

Need OAuth instead of API key?: Use @picora/oauth-device-flow (v0.57.0).