Files
photocli/cmd/photoscli/main.go
T
Ein Anderssono 479c284dfc v0.2.4: stop export loop on Ctrl+C instead of flooding failures
- Add IsCancelled() to Bridge interface
- Check bridge.IsCancelled() before each export in serial/parallel/backupTree
- Parallel workers mark remaining slots as 'cancelled' instead of exporting
- Add photos_request_is_cancelled to ObjC and C stub
2026-06-11 21:44:55 +02:00

471 lines
12 KiB
Go

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 <id>
photoscli tree
photoscli backup-all --out <dir> [--size <px>] [--originals]
photoscli export --album-id <id> --out <dir> [--size <px>] [--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 <id> Album local identifier or title (required for photos/export)
--out <dir> Output directory (required for export/backup-all)
--size <px> 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"
}