package main import ( "fmt" "io" "strings" "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 "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] 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 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) { _, _, err := bridge.ListAssets(idOrName) if err == nil { return idOrName, nil } albums, listErr := bridge.ListAlbums() if listErr != nil { return idOrName, err } for _, a := range albums { if a.Title == idOrName { return a.ID, nil } } return idOrName, err } 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 { cloud := "local" if a.Cloud { cloud = "cloud" } fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, 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 := 0 failed := 0 for i, a := range assets { var result photos.ExportResult var exportErr error if originals { result, exportErr = bridge.ExportOriginal(a.ID, outDir, i) } else { result, exportErr = bridge.ExportPreview(a.ID, outDir, size, i) } progressBar(stderr, exported+failed+1, total, result.Filename, result.Size, result.Cloud) if exportErr != nil { failed++ continue } exported++ } 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\n", exported, outDir) } else { fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir) } 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, grandTotal, 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\n", totalAssets, albumCount, outDir) } else { fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir) } _ = grandTotal return 0 } func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) { exported := 0 total := 0 for _, node := range nodes { path := outDir + "/" + sanitizePathComponent(node.Name) if node.Kind == "folder" { n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) if err != nil { return exported, total, err } exported += n total += t continue } if node.Kind == "album" && node.ID != "" { assets, assetTotal, err := bridge.ListAssets(node.ID) if err != nil { continue } total += assetTotal for i, a := range assets { var result photos.ExportResult var exportErr error if originals { result, exportErr = bridge.ExportOriginal(a.ID, path, i) } else { result, exportErr = bridge.ExportPreview(a.ID, path, targetSize, i) } progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud) if exportErr != nil { continue } exported++ } } } return exported, total, nil } 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)) }