package main import ( "fmt" "io" "os" "path/filepath" "strings" "sync" "time" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { if len(args) < 1 { usage(stderr) return 1 } cmd := args[0] switch cmd { case "albums": return cmdAlbums(stdout, stderr, bridge) case "photos": return cmdPhotos(args[1:], stdout, stderr, bridge) case "tree": return cmdTree(stdout, stderr, bridge) case "backup-all": return cmdBackupAll(args[1:], stdout, stderr, bridge) case "export": return cmdExport(args[1:], stdout, stderr, bridge) case "version", "--version", "-v": fmt.Fprintln(stdout, version) return 0 case "help", "--help", "-h": usage(stderr) return 0 default: fmt.Fprintf(stderr, "unknown command: %s\n", cmd) usage(stderr) return 1 } } func usage(w io.Writer) { fmt.Fprintln(w, `photoscli — export optimized images from Apple Photos Usage: photoscli albums photoscli photos --album-id photoscli tree photoscli backup-all --out [--size ] [--originals] [--include-videos] photoscli export --album-id --out [--size ] [--originals] [--include-videos] photoscli version Commands: albums List user-created albums photos List photo assets in an album tree Show folder and album hierarchy backup-all Export all albums into the Photos folder tree export Export optimized JPEG previews or original files version Print version Flags: --album-id Album local identifier or title (required for photos/export) --out Output directory (required for export/backup-all) --size Target longest-side in pixels (default: 1024, preview export only) --originals Export original files instead of JPEG previews --include-videos Include video assets (videos are skipped by default)`) } func mustAuth(stderr io.Writer, bridge photos.Bridge) int { fmt.Fprintln(stderr, "requesting photo library access...") if err := bridge.RequestAccess(); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } fmt.Fprintln(stderr, "access granted") return 0 } func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int { if rc := mustAuth(stderr, bridge); rc != 0 { return rc } fmt.Fprintln(stderr, "loading albums...") albums, err := bridge.ListAlbums() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } for _, a := range albums { fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Title) } return 0 } func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) { albums, listErr := bridge.ListAlbums() if listErr != nil { return idOrName, listErr } for _, a := range albums { if a.Title == idOrName { return a.ID, nil } } _, _, err := bridge.ListAssets(idOrName) if err == nil { return idOrName, nil } return idOrName, fmt.Errorf("album not found: %s", idOrName) } func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { albumID := flagVal(args, "--album-id") if albumID == "" { fmt.Fprintln(stderr, "error: --album-id is required") return 1 } if rc := mustAuth(stderr, bridge); rc != 0 { return rc } resolved, err := resolveAlbumID(bridge, albumID) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } assets, _, err := bridge.ListAssets(resolved) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } for _, a := range assets { fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%dx%d", a.ID, a.Filename, a.Cloud, a.MediaType, a.PixelWidth, a.PixelHeight) if a.CreationDate != nil { fmt.Fprintf(stdout, "\t%s", *a.CreationDate) } if a.Duration > 0 { fmt.Fprintf(stdout, "\t%.1fs", a.Duration) } if a.IsFavorite { fmt.Fprintf(stdout, "\t*") } fmt.Fprintln(stdout) } return 0 } func cmdTree(stdout, stderr io.Writer, bridge photos.Bridge) int { if rc := mustAuth(stderr, bridge); rc != 0 { return rc } nodes, err := bridge.ListTree() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } for _, node := range nodes { printNode(stdout, node, 0) } return 0 } func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { albumID := flagVal(args, "--album-id") outDir := flagVal(args, "--out") originals := hasFlag(args, "--originals") skipVideos := !hasFlag(args, "--include-videos") sizeStr := flagValWithDefault(args, "--size", "1024") if albumID == "" { fmt.Fprintln(stderr, "error: --album-id is required") return 1 } if outDir == "" { fmt.Fprintln(stderr, "error: --out is required") return 1 } if rc := mustAuth(stderr, bridge); rc != 0 { return rc } resolved, err := resolveAlbumID(bridge, albumID) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } var size int if !originals { if _, err2 := fmt.Sscanf(sizeStr, "%d", &size); err2 != nil || size <= 0 { fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr) return 1 } } fmt.Fprintf(stderr, "loading assets for album %s...\n", albumID) assets, total, err := bridge.ListAssets(resolved) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } if skipVideos { assets, total = filterVideos(assets) } fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir) exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "") if exported == 0 && failed > 0 { fmt.Fprintf(stderr, "\nerror: all exports failed\n") return 1 } if originals { fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir) } else { fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir) } if failed > 0 { fmt.Fprintf(stderr, " (%d failed)", failed) } fmt.Fprintln(stderr) return 0 } func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { outDir := flagVal(args, "--out") originals := hasFlag(args, "--originals") skipVideos := !hasFlag(args, "--include-videos") sizeStr := flagValWithDefault(args, "--size", "1024") if outDir == "" { fmt.Fprintln(stderr, "error: --out is required") return 1 } if rc := mustAuth(stderr, bridge); rc != 0 { return rc } var size int if !originals { if _, err := fmt.Sscanf(sizeStr, "%d", &size); err != nil || size <= 0 { fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr) return 1 } } fmt.Fprintln(stderr, "loading photo library tree...") nodes, err := bridge.ListTree() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } albumCount := countAlbums(nodes) fmt.Fprintf(stderr, "found %d albums, building index...\n", albumCount) totalAssets, failed, err := backupTree(nodes, outDir, size, originals, skipVideos, stderr, bridge) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } if originals { fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir) } else { fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir) } if failed > 0 { fmt.Fprintf(stderr, " (%d failed)", failed) } fmt.Fprintln(stderr) return 0 } type pendingAsset struct { asset photos.Asset path string album string } func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, stderr io.Writer) ([]pendingAsset, int) { var items []pendingAsset var skipped int collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, stderr) return items, skipped } func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, stderr io.Writer) { for _, node := range nodes { if bridge.IsCancelled() { return } path := outDir + "/" + sanitizePathComponent(node.Name) if node.Kind == "folder" { collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, stderr) continue } if node.Kind == "album" && node.ID != "" { assets, _, err := bridge.ListAssets(node.ID) if err != nil { fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", node.Name, err) continue } if skipVideos { assets, _ = filterVideos(assets) } for _, a := range assets { if fileExistsOnDisk(a, path, originals, len(*items)+*skipped) { *skipped++ continue } *items = append(*items, pendingAsset{asset: a, path: path, album: node.Name}) } fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", len(*items), *skipped) } } } func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) { pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, stderr) if bridge.IsCancelled() { return 0, 0, nil } total := len(pending) fmt.Fprintf(stderr, " indexed %d files (%d skipped), exporting to %s...\n", total, skipped, outDir) bar := newProgressBar(stderr, 3) exported, failed := exportPending(pending, targetSize, originals, total, bar, bridge) bar.clear() return exported, failed, nil } func exportPending(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) { if len(pending) < 4 { return exportPendingSerial(pending, targetSize, originals, total, bar, bridge) } return exportPendingParallel(pending, targetSize, originals, total, bar, bridge, 3) } func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) { done := 0 failed := 0 var totalBytes int64 var totalDur time.Duration for i, pa := range pending { if bridge.IsCancelled() { break } bar.setWorker(0, pa.asset.Filename, 0, pa.asset.Cloud, "exporting") bar.setTotal(done+failed, total, "") bar.setAlbum(pa.album, 0, 0) bar.draw() start := time.Now() result, exportErr := exportOne(bridge, pa.asset, pa.path, targetSize, originals, i) dur := time.Since(start) isErr := exportErr != nil isSkipped := result.Skipped if !isErr && !isSkipped { totalBytes += result.Size totalDur += dur } if isErr { failed++ } else { done++ } avgSpeed := float64(0) if totalDur > 0 { avgSpeed = float64(totalBytes) / totalDur.Seconds() } bar.setTotal(done+failed, total, "") bar.setAlbum(pa.album, 0, 0) bar.setWorker(0, "", 0, "", "") var logLine string if isErr { logLine = fmt.Sprintf("\u274c %s: %v", pa.asset.Filename, exportErr) } else if isSkipped { logLine = fmt.Sprintf("\u23ed %s", result.Filename) } else if result.Cloud == "cloud" { logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", result.Filename, formatSize(result.Size), formatSpeed(avgSpeed)) } else { logLine = fmt.Sprintf("\u2705 %s - %s - copied", result.Filename, formatSize(result.Size)) } bar.logCompleted(logLine) } return done, failed } func exportPendingParallel(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int) (int, int) { type resultEntry struct { result photos.ExportResult err error pa pendingAsset dur time.Duration } completed := make(chan resultEntry, len(pending)) jobs := make(chan int, len(pending)) var wg sync.WaitGroup for w := 0; w < workers; w++ { wg.Add(1) go func(workerID int) { defer wg.Done() for i := range jobs { if bridge.IsCancelled() { completed <- resultEntry{err: fmt.Errorf("cancelled"), pa: pending[i]} continue } bar.setWorker(workerID, pending[i].asset.Filename, 0, pending[i].asset.Cloud, "exporting") start := time.Now() var result photos.ExportResult var exportErr error if originals { result, exportErr = bridge.ExportOriginalWithSlot(pending[i].asset.ID, pending[i].path, i, workerID) } else { result, exportErr = bridge.ExportPreviewWithSlot(pending[i].asset.ID, pending[i].path, targetSize, i, workerID) } dur := time.Since(start) bar.setWorker(workerID, "", 0, "", "") completed <- resultEntry{result: result, err: exportErr, pa: pending[i], dur: dur} } }(w) } go func() { for i := range pending { if bridge.IsCancelled() { break } jobs <- i } close(jobs) }() slots := photos.GetProgressSlots() pollDone := make(chan struct{}) go func() { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: if bridge.IsCancelled() { return } slots = photos.GetProgressSlots() for i := 0; i < workers && i < len(slots); i++ { bar.updateWorkerProgress(i, slots[i].Progress, slots[i].BytesDone, slots[i].BytesTotal) } bar.draw() case <-pollDone: return } } }() done := 0 failed := 0 var totalBytes int64 var totalDur time.Duration for n := 0; n < len(pending); n++ { var entry resultEntry select { case entry = <-completed: case <-time.After(2 * time.Second): if bridge.IsCancelled() { close(pollDone) wg.Wait() return done, failed } continue } if bridge.IsCancelled() { close(pollDone) wg.Wait() return done, failed } isErr := entry.err != nil isSkipped := entry.result.Skipped if !isErr && !isSkipped { totalBytes += entry.result.Size totalDur += entry.dur } if isErr { failed++ } else { done++ } avgSpeed := float64(0) if totalDur > 0 { avgSpeed = float64(totalBytes) / totalDur.Seconds() } bar.setTotal(done+failed, total, "") bar.setAlbum(entry.pa.album, 0, 0) var logLine string if isErr { logLine = fmt.Sprintf("\u274c %s: %v", entry.pa.asset.Filename, entry.err) } else if isSkipped { logLine = fmt.Sprintf("\u23ed %s", entry.result.Filename) } else if entry.result.Cloud == "cloud" { logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", entry.result.Filename, formatSize(entry.result.Size), formatSpeed(avgSpeed)) } else { logLine = fmt.Sprintf("\u2705 %s - %s - copied", entry.result.Filename, formatSize(entry.result.Size)) } bar.logCompleted(logLine) } close(pollDone) wg.Wait() photos.ResetProgressSlots() return done, failed } func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { pending := make([]pendingAsset, len(assets)) for i, a := range assets { pending[i] = pendingAsset{asset: a, path: outDir, album: dirPrefix} } bar := newProgressBar(stderr, 1) exported, failed := exportPending(pending, targetSize, originals, len(pending), bar, bridge) bar.clear() return exported, failed } func fileExistsOnDisk(asset photos.Asset, outDir string, originals bool, index int) bool { var candidates []string if originals { if asset.Filename != "" { candidates = append(candidates, filepath.Join(outDir, asset.Filename)) base := strings.TrimSuffix(asset.Filename, filepath.Ext(asset.Filename)) ext := filepath.Ext(asset.Filename) candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s%s", index, base, ext))) } } else { safeID := sanitizePathComponent(asset.ID) candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s.jpg", index, safeID))) } for _, p := range candidates { info, err := os.Stat(p) if err == nil && info.Size() > 0 { return true } } return false } func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) { if originals { return bridge.ExportOriginal(a.ID, outDir, index) } return bridge.ExportPreview(a.ID, outDir, targetSize, index) } func filterVideos(assets []photos.Asset) ([]photos.Asset, int) { filtered := make([]photos.Asset, 0, len(assets)) for _, a := range assets { if a.MediaType != "video" && a.MediaType != "audio" { filtered = append(filtered, a) } } return filtered, len(filtered) } // Must stay in sync with sanitized_path_component in bridge/photokit_bridge.m func sanitizePathComponent(name string) string { s := strings.TrimSpace(name) if s == "" { s = "Untitled" } s = strings.ReplaceAll(s, "/", "_") s = strings.ReplaceAll(s, "\\", "_") return s } func flagVal(args []string, name string) string { return flagValWithDefault(args, name, "") } func hasFlag(args []string, name string) bool { for _, arg := range args { if arg == name { return true } } return false } func printNode(w io.Writer, node photos.CollectionNode, depth int) { for i := 0; i < depth; i++ { fmt.Fprint(w, " ") } fmt.Fprintln(w, node.Name) for _, child := range node.Children { printNode(w, child, depth+1) } } func countAlbums(nodes []photos.CollectionNode) int { total := 0 for _, node := range nodes { if node.Kind == "album" { total++ } total += countAlbums(node.Children) } return total } func flagValWithDefault(args []string, name, def string) string { for i, arg := range args { if arg == name && i+1 < len(args) { return args[i+1] } } return def } func exportMode(originals bool) string { if originals { return "originals" } return "previews" }