e888f7cad1
- Unicode block progress bar (█▓░) - Cloud downloads show ☁ with average speed (MB/s, KB/s, B/s) - Truncated filenames with ellipsis for long names - Error indicator (✗) in progress bar - Simplified to serial export for clean cancel behavior - Added IsCancelled() to Bridge interface
467 lines
12 KiB
Go
467 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"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 <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) {
|
|
exported := 0
|
|
failed := 0
|
|
var totalBytes int64
|
|
var totalDur time.Duration
|
|
for i, a := range assets {
|
|
if bridge.IsCancelled() {
|
|
break
|
|
}
|
|
start := time.Now()
|
|
result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i)
|
|
dur := time.Since(start)
|
|
if exportErr == nil {
|
|
totalBytes += result.Size
|
|
totalDur += dur
|
|
}
|
|
avgSpeed := float64(0)
|
|
if totalDur > 0 {
|
|
avgSpeed = float64(totalBytes) / totalDur.Seconds()
|
|
}
|
|
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil)
|
|
if exportErr != nil {
|
|
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
|
|
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, avgSpeed float64, isErr bool) {
|
|
pct := 0
|
|
if total > 0 {
|
|
pct = current * 100 / total
|
|
}
|
|
barWidth := 25
|
|
filled := pct * barWidth / 100
|
|
partial := (pct * barWidth % 100) * len(blockPartial) / 100
|
|
bar := strings.Repeat(string(blockFull), filled)
|
|
if filled < barWidth {
|
|
if partial > 0 {
|
|
bar += string(blockPartial[partial])
|
|
}
|
|
bar += strings.Repeat(string(blockEmpty), barWidth-filled-1)
|
|
}
|
|
fileSize := formatSize(size)
|
|
cloudLabel := ""
|
|
if cloud == "cloud" {
|
|
cloudLabel = formatSpeed(avgSpeed)
|
|
if cloudLabel != "" {
|
|
cloudLabel = " " + cloudLabel
|
|
}
|
|
cloudLabel = " ☁" + cloudLabel
|
|
}
|
|
name := filename
|
|
maxName := 30
|
|
if len(name) > maxName {
|
|
name = "…" + name[len(name)-maxName+1:]
|
|
}
|
|
fmt.Fprintf(w, "\r%s [%s] %3d%% %s%s %s", name, bar, pct, fileSize, cloudLabel, statusLabel(isErr))
|
|
}
|
|
|
|
var blockFull = '█'
|
|
var blockEmpty = '░'
|
|
var blockPartial = []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'}
|
|
|
|
func statusLabel(isErr bool) string {
|
|
if isErr {
|
|
return "✗"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func formatSpeed(bytesPerSec float64) string {
|
|
if bytesPerSec <= 0 {
|
|
return ""
|
|
}
|
|
const kb = 1024
|
|
const mb = kb * 1024
|
|
if bytesPerSec >= mb {
|
|
return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb)
|
|
}
|
|
if bytesPerSec >= kb {
|
|
return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb)
|
|
}
|
|
return fmt.Sprintf("%.0f B/s", bytesPerSec)
|
|
}
|
|
|
|
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))
|
|
}
|
|
if bytes >= kb {
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
|
}
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
|
|
func exportMode(originals bool) string {
|
|
if originals {
|
|
return "originals"
|
|
}
|
|
return "previews"
|
|
}
|