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
# 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 varexport PICORA_API_KEY=sk_live_xxxnpx @picora/photo-importer --library "$HOME/Pictures/Photos Library.photoslibrary"What it does
- Opens your
Photos.sqlite(ZASSETtable) read-only — never modifies Apple’s database - Streams sha256 for each file (full library hash with < 50 MB heap)
- Batches
POST /v1/images/exists(200 hashes per call) to find what’s already on Picora - Uploads only missing files, 4 in parallel by default
- Persists progress to
~/.picora-importer-state.jsonso you can interrupt + resume
Key options
| Option | Default | Meaning |
|---|---|---|
--library <path> | — | Apple Photos library path (.photoslibrary) |
--api-key <key> | env PICORA_API_KEY | Picora API key (sk_live_…) |
--since YYYY-MM-DD | all | Only sync photos after this date |
--until YYYY-MM-DD | today | Only sync photos before this date |
--types <list> | all | Comma-separated: photo,video,live |
--concurrent <n> | 4 | Concurrent uploads (1-16) |
--dry-run | off | Scan + dedup only, no upload |
--reset-state | off | Wipe local state, full rescan |
--verbose | off | Per-file logging |
Run npx @picora/photo-importer --help for the full list.
Exit codes
| Code | Meaning |
|---|---|
| 0 | All success |
| 1 | Partial (some failed but not blocked) |
| 2 | Auth fail (401/403 from Picora) |
| 3 | Quota fully exhausted |
| 4 | User 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-baseyou specify) - State file
~/.picora-importer-state.jsonischmod 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).