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 { 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\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 } } 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 } 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") 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, exporting to %s...\n", albumCount, outDir) 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 { if bridge.IsCancelled() { break } 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 } fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal) total += assetTotal n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/") exported += n failed += f } } return exported, total, failed, nil } 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 { if bridge.IsCancelled() { break } 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) { type slot struct { result photos.ExportResult err error done chan struct{} } slots := make([]slot, len(assets)) for i := range slots { slots[i].done = make(chan struct{}) } jobs := make(chan int, len(assets)) var wg sync.WaitGroup for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() for i := range jobs { if bridge.IsCancelled() { slots[i].err = fmt.Errorf("cancelled") close(slots[i].done) continue } result, exportErr := exportOne(bridge, assets[i], outDir, targetSize, originals, i) slots[i].result = result slots[i].err = exportErr close(slots[i].done) } }() } for i := range assets { jobs <- i } close(jobs) exported := 0 failed := 0 for i, a := range assets { <-slots[i].done s := slots[i] progressBar(stderr, exported+failed+1, total, dirPrefix+s.result.Filename, s.result.Size, s.result.Cloud) if s.err != nil { fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, s.err) failed++ continue } exported++ } wg.Wait() 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)) } func exportMode(originals bool) string { if originals { return "originals" } return "previews" }