package main import ( "fmt" "io" "strings" "sync" "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] photoscli export --album-id --out [--size ] [--originals] 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`) } func mustAuth(stderr io.Writer, bridge photos.Bridge) int { if err := bridge.RequestAccess(); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } return 0 } func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int { if rc := mustAuth(stderr, bridge); rc != 0 { return rc } 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\n", a.ID, a.Filename, a.Cloud) } 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") 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 } } assets, total, err := bridge.ListAssets(resolved) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } 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") 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 } } nodes, err := bridge.ListTree() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } albumCount := countAlbums(nodes) totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, 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 } func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, int, error) { exported := 0 total := 0 failed := 0 for _, node := range nodes { path := outDir + "/" + sanitizePathComponent(node.Name) if node.Kind == "folder" { n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) if err != nil { return exported, total, failed, err } exported += n total += t failed += f continue } if node.Kind == "album" && node.ID != "" { assets, assetTotal, err := bridge.ListAssets(node.ID) if err != nil { fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err) continue } total += assetTotal n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/") exported += n failed += f } } return exported, total, failed, nil } type exportJob struct { asset photos.Asset index int } type exportResult struct { index int result photos.ExportResult err error } func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { if len(assets) == 0 { return 0, 0 } if len(assets) < 4 { return exportAssetsSerial(assets, outDir, targetSize, originals, total, stderr, bridge, dirPrefix) } return exportAssetsParallel(assets, outDir, targetSize, originals, total, stderr, bridge, 3, dirPrefix) } func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { exported := 0 failed := 0 for i, a := range assets { result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i) progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud) if exportErr != nil { fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr) failed++ continue } exported++ } return exported, failed } func exportAssetsParallel(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, workers int, dirPrefix string) (int, int) { jobs := make(chan exportJob, len(assets)) results := make(chan exportResult, len(assets)) var wg sync.WaitGroup for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() for job := range jobs { result, exportErr := exportOne(bridge, job.asset, outDir, targetSize, originals, job.index) results <- exportResult{index: job.index, result: result, err: exportErr} } }() } go func() { for i, a := range assets { jobs <- exportJob{asset: a, index: i} } close(jobs) }() go func() { wg.Wait() close(results) }() ordered := make([]exportResult, len(assets)) for r := range results { ordered[r.index] = r } exported := 0 failed := 0 for i, a := range assets { r := ordered[i] progressBar(stderr, exported+failed+1, total, dirPrefix+r.result.Filename, r.result.Size, r.result.Cloud) if r.err != nil { fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, r.err) failed++ continue } exported++ } return exported, failed } 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) } // 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 progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) { pct := 0 if total > 0 { pct = current * 100 / total } barWidth := 30 filled := pct * barWidth / 100 bar := strings.Repeat("=", filled) + strings.Repeat(" ", barWidth-filled) fmt.Fprintf(w, "\r[%s] %3d%% %s %s %s", bar, pct, filename, formatSize(size), cloud) } func formatSize(bytes int64) string { if bytes <= 0 { return "" } const kb = 1024 const mb = kb * 1024 if bytes >= mb { return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) } return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) }