Files
photocli/cmd/photoscli/main.go
T
Ein Anderssono 36832060d0
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
docs: clarify Apple Silicon release target
2026-06-15 00:41:49 +02:00

1764 lines
52 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
)
var (
progressPollInterval = 100 * time.Millisecond
exportTimeout = 2 * time.Second
configValues map[string]string
configLoaded bool
mkdirTempFunc = os.MkdirTemp
renameFunc = os.Rename
openFileFunc = os.OpenFile
removeFunc = os.Remove
)
type exportOptions struct {
dryRun bool
retry int
onlyFavorites bool
media string
jsonOut bool
verify bool
format string
minSize int64
maxSize int64
dateTemplate string
}
type commandSummary struct {
Exported int `json:"exported"`
Failed int `json:"failed"`
Total int `json:"total"`
}
const (
exitOK = 0
exitErr = 1
exitPartial = 2
exitAuth = 3
)
func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
if len(args) < 1 {
usage(stderr)
return exitErr
}
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 "report":
return cmdReport(args[1:], stdout, stderr)
case "diff":
return cmdDiff(args[1:], stdout, stderr, bridge)
case "verify":
return cmdVerify(args[1:], stdout, stderr)
case "retry-failed":
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
case "failures":
return cmdFailures(args[1:], stdout, stderr)
case "status":
return cmdStatus(args[1:], stdout, stderr)
case "version", "--version", "-v":
fmt.Fprintln(stdout, version)
return exitOK
case "help", "--help", "-h":
usage(stderr)
return exitOK
default:
fmt.Fprintf(stderr, "unknown command: %s\n", cmd)
usage(stderr)
return exitErr
}
}
func usage(w io.Writer) {
fmt.Fprintln(w, `photoscli - export and verify Apple Photos backups
DESCRIPTION
photoscli is a macOS-only Apple Photos exporter. It uses PhotoKit through a
small Objective-C bridge to list albums, inspect assets, export previews or
originals, keep resumable manifests, log structured export events, and verify
backup integrity.
Prebuilt releases target Apple Silicon Macs only (darwin/arm64: M1/M2/M3/M4
or newer). Intel Macs are not currently a supported release target.
The tool is intended for repeatable backups. By default it records exported
asset IDs in a manifest so later runs can skip work already completed.
USAGE
photoscli albums
photoscli photos --album-id <id-or-title>
photoscli tree
photoscli export --album-id <id-or-title> --out <dir> [flags]
photoscli backup-all --out <dir> [flags]
photoscli report --out <dir> [--manifest jsonl|sqlite]
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
photoscli verify --out <dir> [--manifest jsonl|sqlite]
photoscli retry-failed --out <dir>
photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir>
photoscli failures clear --out <dir>
photoscli status --out <dir> [--json]
photoscli version
photoscli help
COMMANDS
albums
Request Photos access and list user-created albums as:
<album-id><TAB><album-title>
photos --album-id <id-or-title>
List assets in one album. The album can be a PhotoKit local identifier or
an exact album title. Output includes asset ID, filename, cloud state,
media type, dimensions, optional creation date, optional duration, and a
trailing * for favorites.
tree
Print the Photos folder/album hierarchy as an indented tree.
export --album-id <id-or-title> --out <dir> [flags]
Export assets from one album. Preview JPEGs are exported by default.
Use --originals to export original files instead.
backup-all --out <dir> [flags]
Walk the Photos tree and export every album into a matching directory
structure. Duplicate sibling album names are disambiguated with album IDs.
report --out <dir> [--manifest jsonl|sqlite]
Print manifest entry count and failure count.
diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
Compare album assets against the manifest. Missing assets are printed as
<asset-id><TAB><filename>. Exits 2 when differences are found.
verify --out <dir> [--manifest jsonl|sqlite]
Verify that manifest entries point to files that exist on disk. Missing
files are printed as <asset-id><TAB><filename>. Exits 2 on missing files.
retry-failed --out <dir>
Retry assets previously written to failures.jsonl.
failures list|clear --out <dir>
List or clear deduplicated failure records.
status --out <dir> [--manifest jsonl|sqlite] [--json]
Show manifest type, entry count, and failure count for a backup.
COMMON EXPORT FLAGS
--out <dir>
Destination directory. Required for export, backup-all, report, diff,
verify, and retry-failed.
--album-id <id-or-title>
Album PhotoKit local identifier or exact album title. Required by export,
photos, and diff.
--size <px>
Target longest-side preview size in pixels. Default: 1024. Ignored when
--originals is used.
--quality <1-100>
JPEG preview compression quality. Default: 85. Ignored when --originals is
used.
--originals
Export original files instead of preview JPEGs.
--concurrency <N>
Number of parallel export workers. Default: 3. Values above the bridge's
progress slot count are capped automatically.
--retry <N>
Retry failed exports N times with a small backoff. Default: 0.
--dry-run
Print assets that would be exported without writing files, manifests, or
logs. Useful before large backup-all runs.
--json
Print a machine-readable summary to stdout:
{"exported":N,"failed":N,"total":N}
--verify
Run manifest/file verification after export or backup-all.
FILTERING AND SELECTION
--since <date>
Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339.
--sort oldest|newest
Sort assets by creation date. Default: oldest.
--only-favorites
Export only favorite assets.
--media photos|videos|all
Select media type. Default: photos.
--include-videos
Compatibility shortcut that includes videos/audio.
--exclude-album <pattern>
backup-all only. Repeatable. Excludes album names by exact match or glob
pattern, for example --exclude-album "Temp*".
--min-size <n>
Include only assets with estimated pixel count >= n.
--max-size <n>
Include only assets with estimated pixel count <= n.
OUTPUT LAYOUT AND FORMAT
--date-template <template>
Append date folders based on asset creation date. Supported tokens:
YYYY, MM, DD. Example: --date-template YYYY/MM/DD.
--format jpeg|heic|png
Preview output format hint. Currently validated by the CLI; the bridge's
preview export path still writes the existing preview output format.
MANIFESTS
--manifest jsonl|sqlite
Manifest backend. Default: jsonl.
jsonl -> downloads.jsonl
sqlite -> downloads.db
--no-manifest
Disable manifest reads and writes. This makes export stateless and removes
resumable skip behavior.
LOGGING AND FAILURES
--log
Enable structured export logs. With JSONL/no-manifest mode logs are
written to export.log. With SQLite manifests logs are written to a logs
table in downloads.db.
failures.jsonl
Failed exports are deduplicated by asset ID and can be retried with
retry-failed. Use retry-failed --clear-on-success to remove successful
retries from the failure list.
CONFIGURATION
Defaults can be read from ~/.photoscli.toml or from PHOTOSCLI_CONFIG.
Keys match long flag names without leading dashes:
size = 2048
quality = 90
concurrency = 8
manifest = "sqlite"
sort = "newest"
retry = 3
log = true
Command-line flags override config values.
EXAMPLES
photoscli albums
photoscli photos --album-id "Vacation"
photoscli export --album-id "Vacation" --out ./export --size 2048 --quality 92
photoscli export --album-id "Favorites" --out ./favorites --only-favorites
photoscli export --album-id "Archive" --out ./archive --originals --retry 3
photoscli backup-all --out ./backup --manifest sqlite --log --concurrency 8
photoscli backup-all --out ./backup --dry-run --json
photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*"
photoscli backup-all --out ./backup --since 2024-01-01 --date-template YYYY/MM/DD
photoscli report --out ./backup --manifest sqlite
photoscli diff --album-id "Vacation" --out ./backup
photoscli verify --out ./backup --manifest sqlite
photoscli retry-failed --out ./backup
EXIT CODES
0 Success.
1 Error, invalid arguments, or runtime failure.
2 Partial failure, missing diff entries, or verify failures.
3 Photos access denied.
PERMISSIONS
On first use, macOS may prompt for Photos access. If denied, grant access in:
System Settings > Privacy & Security > Photos`)
}
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 exitAuth
}
fmt.Fprintln(stderr, "access granted")
return exitOK
}
func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
if rc := mustAuth(stderr, bridge); rc != exitOK {
return rc
}
fmt.Fprintln(stderr, "loading albums...")
albums, err := bridge.ListAlbums()
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
for _, a := range albums {
fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Title)
}
return exitOK
}
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 exitErr
}
if rc := mustAuth(stderr, bridge); rc != exitOK {
return rc
}
resolved, err := resolveAlbumID(bridge, albumID)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
assets, _, err := bridge.ListAssets(resolved)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
for _, a := range assets {
fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%dx%d", a.ID, a.Filename, a.Cloud, a.MediaType, a.PixelWidth, a.PixelHeight)
if a.CreationDate != nil {
fmt.Fprintf(stdout, "\t%s", *a.CreationDate)
}
if a.Duration > 0 {
fmt.Fprintf(stdout, "\t%.1fs", a.Duration)
}
if a.IsFavorite {
fmt.Fprintf(stdout, "\t*")
}
fmt.Fprintln(stdout)
}
return exitOK
}
func cmdTree(stdout, stderr io.Writer, bridge photos.Bridge) int {
if rc := mustAuth(stderr, bridge); rc != exitOK {
return rc
}
nodes, err := bridge.ListTree()
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
for _, node := range nodes {
printNode(stdout, node, 0)
}
return exitOK
}
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")
skipVideos := !hasFlag(args, "--include-videos")
noManifest := hasFlag(args, "--no-manifest")
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
sortOrder := flagValWithDefault(args, "--sort", "oldest")
sizeStr := flagValWithDefault(args, "--size", "1024")
qualityStr := flagValWithDefault(args, "--quality", "85")
concurrencyStr := flagValWithDefault(args, "--concurrency", "3")
sinceStr := flagVal(args, "--since")
enableLog := hasFlag(args, "--log")
opts, ok := parseExportOptions(args, stderr)
if !ok {
return exitErr
}
if hasFlag(args, "--include-videos") {
opts.media = "all"
}
if albumID == "" {
fmt.Fprintln(stderr, "error: --album-id is required")
return exitErr
}
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
mf, mfErr := manifest.ParseFormat(manifestFmt)
if mfErr != nil {
fmt.Fprintf(stderr, "error: %v\n", mfErr)
return exitErr
}
sortNewest := sortOrder == "newest"
if sortOrder != "oldest" && sortOrder != "newest" {
fmt.Fprintf(stderr, "error: --sort must be newest or oldest, got %q\n", sortOrder)
return exitErr
}
if rc := mustAuth(stderr, bridge); rc != exitOK {
return rc
}
resolved, err := resolveAlbumID(bridge, albumID)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
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 exitErr
}
}
var quality int
if !originals {
if _, err2 := fmt.Sscanf(qualityStr, "%d", &quality); err2 != nil || quality < 1 || quality > 100 {
fmt.Fprintf(stderr, "error: --quality must be 1-100, got %q\n", qualityStr)
return exitErr
}
}
var concurrency int
if _, err2 := fmt.Sscanf(concurrencyStr, "%d", &concurrency); err2 != nil || concurrency < 1 {
fmt.Fprintf(stderr, "error: --concurrency must be a positive integer, got %q\n", concurrencyStr)
return exitErr
}
maxSlots := photos.GetProgressSlotCount()
if concurrency > maxSlots {
concurrency = maxSlots
}
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 exitErr
}
if hasFlag(args, "--include-videos") {
opts.media = "all"
}
if skipVideos && opts.media == "photos" {
assets, total = filterVideos(assets)
}
assets = applyAssetFilters(assets, opts)
total = len(assets)
if sinceStr != "" {
since, err := parseSinceDate(sinceStr)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
assets = filterBySince(assets, since)
total = len(assets)
}
if sortNewest {
sort.Slice(assets, func(i, j int) bool {
di := assets[i].CreationDate
dj := assets[j].CreationDate
if di == nil && dj == nil {
return assets[i].ID < assets[j].ID
}
if di == nil {
return false
}
if dj == nil {
return true
}
return *di > *dj
})
}
if opts.dryRun {
for _, a := range assets {
fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Filename)
}
if opts.jsonOut {
writeJSONSummary(stdout, commandSummary{Total: total})
}
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
return exitOK
}
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
if opts.jsonOut {
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
}
if opts.verify && !noManifest {
if rc := cmdVerify([]string{"--out", outDir, "--manifest", manifestFmt}, stdout, stderr); rc != exitOK && failed == 0 {
failed++
}
}
if exported == 0 && failed > 0 {
fmt.Fprintf(stderr, "\nerror: all exports failed\n")
return exitErr
}
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)
if failed > 0 && exported > 0 {
return exitPartial
}
return exitOK
}
func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out")
originals := hasFlag(args, "--originals")
skipVideos := !hasFlag(args, "--include-videos")
noManifest := hasFlag(args, "--no-manifest")
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
sortOrder := flagValWithDefault(args, "--sort", "oldest")
sizeStr := flagValWithDefault(args, "--size", "1024")
qualityStr := flagValWithDefault(args, "--quality", "85")
concurrencyStr := flagValWithDefault(args, "--concurrency", "3")
excludeAlbums := flagVals(args, "--exclude-album")
sinceStr := flagVal(args, "--since")
enableLog := hasFlag(args, "--log")
opts, ok := parseExportOptions(args, stderr)
if !ok {
return exitErr
}
if hasFlag(args, "--include-videos") {
opts.media = "all"
}
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
mf, mfErr := manifest.ParseFormat(manifestFmt)
if mfErr != nil {
fmt.Fprintf(stderr, "error: %v\n", mfErr)
return exitErr
}
sortNewest := sortOrder == "newest"
if sortOrder != "oldest" && sortOrder != "newest" {
fmt.Fprintf(stderr, "error: --sort must be newest or oldest, got %q\n", sortOrder)
return exitErr
}
if rc := mustAuth(stderr, bridge); rc != exitOK {
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 exitErr
}
}
var quality int
if !originals {
if _, err := fmt.Sscanf(qualityStr, "%d", &quality); err != nil || quality < 1 || quality > 100 {
fmt.Fprintf(stderr, "error: --quality must be 1-100, got %q\n", qualityStr)
return exitErr
}
}
var concurrency int
if _, err := fmt.Sscanf(concurrencyStr, "%d", &concurrency); err != nil || concurrency < 1 {
fmt.Fprintf(stderr, "error: --concurrency must be a positive integer, got %q\n", concurrencyStr)
return exitErr
}
maxSlots := photos.GetProgressSlotCount()
if concurrency > maxSlots {
concurrency = maxSlots
}
var sinceTime time.Time
if sinceStr != "" {
var err error
sinceTime, err = parseSinceDate(sinceStr)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
}
fmt.Fprintln(stderr, "loading photo library tree...")
nodes, err := bridge.ListTree()
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
albumCount := countAlbums(nodes)
fmt.Fprintf(stderr, "found %d albums, building index...\n", albumCount)
if opts.dryRun {
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
for _, pa := range pending {
fmt.Fprintf(stdout, "%s\t%s\t%s\n", pa.asset.ID, pa.album, pa.asset.Filename)
}
if opts.jsonOut {
writeJSONSummary(stdout, commandSummary{Total: len(pending)})
}
fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped)
return exitOK
}
totalAssets, failed, err := backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
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)
if opts.jsonOut {
writeJSONSummary(stdout, commandSummary{Exported: totalAssets, Failed: failed, Total: totalAssets + failed})
}
if opts.verify && !noManifest {
if rc := cmdVerify([]string{"--out", outDir, "--manifest", manifestFmt}, stdout, stderr); rc != exitOK && failed == 0 {
failed++
}
}
if failed > 0 && totalAssets > 0 {
return exitPartial
}
return exitOK
}
type pendingAsset struct {
asset photos.Asset
root string
path string
album string
}
type collectProgress struct {
pending int
skipped int
album string
err error
}
func logEntry(event, level, assetID, album, filename, cloud string, size int64, durationMs int64, message string) manifest.LogEntry {
return manifest.LogEntry{
Timestamp: time.Now().Unix(),
Level: level,
Event: event,
AssetID: assetID,
Album: album,
Filename: filename,
Size: size,
Cloud: cloud,
DurationMs: durationMs,
Message: message,
}
}
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult) {
if m == nil {
return
}
root := pa.root
if root == "" {
root = pa.path
}
fullPath := filepath.Join(pa.path, result.Filename)
relPath, err := filepath.Rel(root, fullPath)
if err != nil || strings.HasPrefix(relPath, "..") {
relPath = result.Filename
}
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
}
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
var items []pendingAsset
var skipped int
collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, onProgress, m, exclude, opts)
if sortNewest {
sort.Slice(items, func(i, j int) bool {
di := items[i].asset.CreationDate
dj := items[j].asset.CreationDate
if di == nil && dj == nil {
return items[i].asset.ID < items[j].asset.ID
}
if di == nil {
return false
}
if dj == nil {
return true
}
return *di > *dj
})
}
if !since.IsZero() {
filtered := make([]pendingAsset, 0, len(items))
for _, pa := range items {
if pa.asset.CreationDate != nil {
t, err := time.Parse(time.RFC3339, *pa.asset.CreationDate)
if err == nil && t.Before(since) {
skipped++
continue
}
}
filtered = append(filtered, pa)
}
items = filtered
}
return items, skipped
}
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, onProgress func(collectProgress), m manifest.Manifest, exclude []string, opts exportOptions) {
names := make(map[string]int)
for _, node := range nodes {
names[node.Name]++
}
for _, node := range nodes {
if bridge.IsCancelled() {
return
}
name := node.Name
if names[node.Name] > 1 && node.ID != "" {
name = fmt.Sprintf("%s (%s)", node.Name, sanitizePathComponent(node.ID))
}
path := outDir + "/" + sanitizePathComponent(name)
if node.Kind == "folder" {
collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, onProgress, m, exclude, opts)
continue
}
if node.Kind == "album" && node.ID != "" {
if isExcluded(node.Name, exclude) {
if onProgress != nil {
onProgress(collectProgress{pending: len(*items), skipped: *skipped, album: node.Name})
}
continue
}
assets, _, err := bridge.ListAssets(node.ID)
if err != nil {
if onProgress != nil {
onProgress(collectProgress{pending: len(*items), skipped: *skipped, album: node.Name, err: err})
}
continue
}
if skipVideos && opts.media == "photos" {
assets, _ = filterVideos(assets)
}
assets = applyAssetFilters(assets, opts)
for _, a := range assets {
if m != nil && m.Has(a.ID) {
*skipped++
continue
}
*items = append(*items, pendingAsset{asset: a, root: outDir, path: pathWithDateTemplate(path, a, opts.dateTemplate), album: node.Name})
}
if onProgress != nil {
onProgress(collectProgress{pending: len(*items), skipped: *skipped, album: node.Name})
}
}
}
}
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, quality, concurrency int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge, noManifest bool, mf manifest.Format, sortNewest bool, exclude []string, since time.Time, enableLog bool, opts exportOptions) (int, int, error) {
var m manifest.Manifest
if !noManifest {
var err error
m, err = manifest.Open(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
} else {
if err := m.OpenAppend(); err != nil {
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
}
defer m.Close()
}
}
var lw manifest.LogWriter = manifest.NoopLogWriter
if enableLog {
var err error
lw, err = manifest.OpenLogWriter(m, outDir)
if err != nil {
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
lw = manifest.NoopLogWriter
} else {
defer lw.Close()
}
}
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, func(p collectProgress) {
if p.err != nil {
fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", p.album, p.err)
} else {
fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", p.pending, p.skipped)
}
}, m, sortNewest, exclude, since, opts)
if bridge.IsCancelled() {
return 0, 0, fmt.Errorf("cancelled")
}
total := len(pending)
fmt.Fprintf(stderr, " indexed %d files (%d skipped), exporting to %s...\n", total, skipped, outDir)
bar := newProgressBar(stderr, concurrency)
lw.Log(logEntry("session_start", "info", "", "", "", "", 0, 0, fmt.Sprintf("version=%s size=%d quality=%d concurrency=%d", version, targetSize, quality, concurrency)))
exported, failed := exportPending(pending, targetSize, quality, originals, total, bar, bridge, m, concurrency, lw, opts)
bar.clear()
if m != nil {
if err := m.Save(); err != nil {
fmt.Fprintf(stderr, " warning: could not save manifest: %v\n", err)
}
}
lw.Log(logEntry("session_end", "info", "", "", "", "", 0, 0, fmt.Sprintf("exported=%d failed=%d skipped=%d", exported, failed, skipped)))
return exported, failed, nil
}
func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) {
if len(pending) < 4 {
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts)
}
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts)
}
func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) {
done := 0
failed := 0
var totalBytes int64
var totalDur time.Duration
for i, pa := range pending {
if bridge.IsCancelled() {
break
}
bar.setWorker(0, pa.asset.Filename, 0, pa.asset.Cloud, "exporting")
bar.setTotal(done+failed, total, "")
bar.setAlbum(pa.album, 0, 0)
bar.draw()
start := time.Now()
result, exportErr := exportOneWithRetry(bridge, pa, targetSize, quality, originals, i, opts.retry)
dur := time.Since(start)
isErr := exportErr != nil
isSkipped := result.Skipped
if !isErr && !isSkipped {
totalBytes += result.Size
totalDur += dur
}
if isErr {
failed++
appendFailure(pa.path, pa, exportErr)
} else if isSkipped {
addManifestEntry(m, pa, result)
} else {
done++
addManifestEntry(m, pa, result)
}
avgSpeed := float64(0)
if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds()
}
bar.setTotal(done+failed, total, "")
bar.setAlbum(pa.album, 0, 0)
bar.setWorker(0, "", 0, "", "")
var logLine string
if isErr {
logLine = fmt.Sprintf("\u274c %s: %v", pa.asset.Filename, exportErr)
lw.Log(logEntry("export_fail", "error", pa.asset.ID, pa.album, pa.asset.Filename, "", result.Size, dur.Milliseconds(), exportErr.Error()))
} else if isSkipped {
logLine = fmt.Sprintf("\u23ed %s", result.Filename)
lw.Log(logEntry("export_skip", "info", pa.asset.ID, pa.album, result.Filename, result.Cloud, result.Size, 0, ""))
} else if result.Cloud == "cloud" {
logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", result.Filename, formatSize(result.Size), formatSpeed(avgSpeed))
lw.Log(logEntry("export_done", "info", pa.asset.ID, pa.album, result.Filename, result.Cloud, result.Size, dur.Milliseconds(), ""))
} else {
logLine = fmt.Sprintf("\u2705 %s - %s - copied", result.Filename, formatSize(result.Size))
lw.Log(logEntry("export_done", "info", pa.asset.ID, pa.album, result.Filename, result.Cloud, result.Size, dur.Milliseconds(), ""))
}
bar.logCompleted(logLine)
}
return done, failed
}
func exportPendingParallel(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) {
type resultEntry struct {
result photos.ExportResult
err error
pa pendingAsset
dur time.Duration
}
completed := make(chan resultEntry, len(pending))
jobs := make(chan int, len(pending))
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for i := range jobs {
if bridge.IsCancelled() {
completed <- resultEntry{err: fmt.Errorf("cancelled"), pa: pending[i]}
continue
}
bar.setWorker(workerID, pending[i].asset.Filename, 0, pending[i].asset.Cloud, "exporting")
start := time.Now()
var result photos.ExportResult
var exportErr error
result, exportErr = exportOneWithSlotRetry(bridge, pending[i], targetSize, quality, originals, i, workerID, opts.retry)
dur := time.Since(start)
bar.setWorker(workerID, "", 0, "", "")
completed <- resultEntry{result: result, err: exportErr, pa: pending[i], dur: dur}
}
}(w)
}
go func() {
for i := range pending {
if bridge.IsCancelled() {
break
}
jobs <- i
}
close(jobs)
}()
slots := photos.GetProgressSlots()
pollDone := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(progressPollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if bridge.IsCancelled() {
return
}
slots = photos.GetProgressSlots()
for i := 0; i < workers && i < len(slots); i++ {
bar.updateWorkerProgress(i, slots[i].Progress, slots[i].BytesDone, slots[i].BytesTotal)
}
bar.draw()
case <-pollDone:
return
}
}
}()
done := 0
failed := 0
var totalBytes int64
var totalDur time.Duration
for n := 0; n < len(pending); n++ {
var entry resultEntry
select {
case entry = <-completed:
case <-time.After(exportTimeout):
if bridge.IsCancelled() {
close(pollDone)
wg.Wait()
return done, failed
}
continue
}
if bridge.IsCancelled() {
close(pollDone)
wg.Wait()
return done, failed
}
isErr := entry.err != nil
isSkipped := entry.result.Skipped
if !isErr && !isSkipped {
totalBytes += entry.result.Size
totalDur += entry.dur
}
if isErr {
failed++
appendFailure(entry.pa.path, entry.pa, entry.err)
} else if isSkipped {
addManifestEntry(m, entry.pa, entry.result)
} else {
done++
addManifestEntry(m, entry.pa, entry.result)
}
avgSpeed := float64(0)
if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds()
}
bar.setTotal(done+failed, total, "")
bar.setAlbum(entry.pa.album, 0, 0)
var logLine string
if isErr {
logLine = fmt.Sprintf("\u274c %s: %v", entry.pa.asset.Filename, entry.err)
lw.Log(logEntry("export_fail", "error", entry.pa.asset.ID, entry.pa.album, entry.pa.asset.Filename, "", entry.result.Size, entry.dur.Milliseconds(), entry.err.Error()))
} else if isSkipped {
logLine = fmt.Sprintf("\u23ed %s", entry.result.Filename)
lw.Log(logEntry("export_skip", "info", entry.pa.asset.ID, entry.pa.album, entry.result.Filename, entry.result.Cloud, entry.result.Size, 0, ""))
} else if entry.result.Cloud == "cloud" {
logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", entry.result.Filename, formatSize(entry.result.Size), formatSpeed(avgSpeed))
lw.Log(logEntry("export_done", "info", entry.pa.asset.ID, entry.pa.album, entry.result.Filename, entry.result.Cloud, entry.result.Size, entry.dur.Milliseconds(), ""))
} else {
logLine = fmt.Sprintf("\u2705 %s - %s - copied", entry.result.Filename, formatSize(entry.result.Size))
lw.Log(logEntry("export_done", "info", entry.pa.asset.ID, entry.pa.album, entry.result.Filename, entry.result.Cloud, entry.result.Size, entry.dur.Milliseconds(), ""))
}
bar.logCompleted(logLine)
}
close(pollDone)
wg.Wait()
photos.ResetProgressSlots()
return done, failed
}
func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, concurrency int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string, noManifest bool, mf manifest.Format, enableLog bool, opts exportOptions) (int, int) {
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, root: outDir, path: pathWithDateTemplate(outDir, a, opts.dateTemplate), album: dirPrefix}
}
var m manifest.Manifest
if !noManifest {
var err error
m, err = manifest.Open(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
} else {
if err := m.OpenAppend(); err != nil {
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
}
defer m.Close()
}
}
var lw manifest.LogWriter = manifest.NoopLogWriter
if enableLog {
var err error
lw, err = manifest.OpenLogWriter(m, outDir)
if err != nil {
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
lw = manifest.NoopLogWriter
} else {
defer lw.Close()
}
}
bar := newProgressBar(stderr, concurrency)
exported, failed := exportPending(pending, targetSize, quality, originals, len(pending), bar, bridge, m, concurrency, lw, opts)
bar.clear()
if m != nil {
if err := m.Save(); err != nil {
fmt.Fprintf(stderr, "warning: could not save manifest: %v\n", err)
}
}
return exported, failed
}
func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize, quality int, originals bool, index int) (photos.ExportResult, error) {
if originals {
return bridge.ExportOriginal(a.ID, outDir, index)
}
return bridge.ExportPreview(a.ID, outDir, targetSize, quality, index)
}
func exportOneAtomic(bridge photos.Bridge, pa pendingAsset, targetSize, quality int, originals bool, index int) (photos.ExportResult, error) {
root := pa.root
if root == "" {
root = pa.path
}
stagingRoot := filepath.Join(root, ".photoscli-tmp")
if err := os.MkdirAll(stagingRoot, 0755); err != nil {
return exportOne(bridge, pa.asset, pa.path, targetSize, quality, originals, index)
}
stagingDir, err := mkdirTempFunc(stagingRoot, sanitizePathComponent(pa.asset.ID)+"-*")
if err != nil {
return photos.ExportResult{}, err
}
defer os.RemoveAll(stagingDir)
result, err := exportOne(bridge, pa.asset, stagingDir, targetSize, quality, originals, index)
if err != nil || result.Skipped {
return result, err
}
src := filepath.Join(stagingDir, result.Filename)
info, statErr := os.Stat(src)
if statErr != nil {
return result, nil
}
if info.Size() == 0 {
return result, fmt.Errorf("exported zero-byte file: %s", result.Filename)
}
if err := os.MkdirAll(pa.path, 0755); err != nil {
return result, err
}
dst := filepath.Join(pa.path, result.Filename)
if err := renameFunc(src, dst); err != nil {
return result, err
}
return result, nil
}
func exportOneWithSlotAtomic(bridge photos.Bridge, pa pendingAsset, targetSize, quality int, originals bool, index, slotIndex int) (photos.ExportResult, error) {
root := pa.root
if root == "" {
root = pa.path
}
stagingRoot := filepath.Join(root, ".photoscli-tmp")
if err := os.MkdirAll(stagingRoot, 0755); err != nil {
if originals {
return bridge.ExportOriginalWithSlot(pa.asset.ID, pa.path, index, slotIndex)
}
return bridge.ExportPreviewWithSlot(pa.asset.ID, pa.path, targetSize, quality, index, slotIndex)
}
stagingDir, err := mkdirTempFunc(stagingRoot, sanitizePathComponent(pa.asset.ID)+"-*")
if err != nil {
return photos.ExportResult{}, err
}
defer os.RemoveAll(stagingDir)
var result photos.ExportResult
if originals {
result, err = bridge.ExportOriginalWithSlot(pa.asset.ID, stagingDir, index, slotIndex)
} else {
result, err = bridge.ExportPreviewWithSlot(pa.asset.ID, stagingDir, targetSize, quality, index, slotIndex)
}
if err != nil || result.Skipped {
return result, err
}
src := filepath.Join(stagingDir, result.Filename)
info, statErr := os.Stat(src)
if statErr != nil {
return result, nil
}
if info.Size() == 0 {
return result, fmt.Errorf("exported zero-byte file: %s", result.Filename)
}
if err := os.MkdirAll(pa.path, 0755); err != nil {
return result, err
}
dst := filepath.Join(pa.path, result.Filename)
if err := renameFunc(src, dst); err != nil {
return result, err
}
return result, nil
}
func exportOneWithRetry(bridge photos.Bridge, pa pendingAsset, targetSize, quality int, originals bool, index, retry int) (photos.ExportResult, error) {
var result photos.ExportResult
var err error
for attempt := 0; attempt <= retry; attempt++ {
result, err = exportOneAtomic(bridge, pa, targetSize, quality, originals, index)
if err == nil {
return result, nil
}
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
}
return result, err
}
func exportOneWithSlotRetry(bridge photos.Bridge, pa pendingAsset, targetSize, quality int, originals bool, index, slotIndex, retry int) (photos.ExportResult, error) {
var result photos.ExportResult
var err error
for attempt := 0; attempt <= retry; attempt++ {
result, err = exportOneWithSlotAtomic(bridge, pa, targetSize, quality, originals, index, slotIndex)
if err == nil {
return result, nil
}
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
}
return result, err
}
func parseSinceDate(s string) (time.Time, error) {
for _, layout := range []string{"2006-01-02", time.RFC3339} {
t, err := time.Parse(layout, s)
if err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("invalid date %q, use YYYY-MM-DD or RFC3339 format", s)
}
func filterBySince(assets []photos.Asset, since time.Time) []photos.Asset {
filtered := make([]photos.Asset, 0, len(assets))
for _, a := range assets {
if a.CreationDate != nil {
t, err := time.Parse(time.RFC3339, *a.CreationDate)
if err == nil && t.Before(since) {
continue
}
}
filtered = append(filtered, a)
}
return filtered
}
func filterVideos(assets []photos.Asset) ([]photos.Asset, int) {
filtered := make([]photos.Asset, 0, len(assets))
for _, a := range assets {
if a.MediaType != "video" && a.MediaType != "audio" {
filtered = append(filtered, a)
}
}
return filtered, len(filtered)
}
// 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 flagVals(args []string, name string) []string {
var vals []string
for i, arg := range args {
if arg == name && i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
return vals
}
func isExcluded(name string, exclude []string) bool {
for _, pat := range exclude {
if name == pat {
return true
}
if m, _ := filepath.Match(pat, name); m {
return true
}
}
return false
}
func hasFlag(args []string, name string) bool {
for _, arg := range args {
if arg == name {
return true
}
}
v := configValue(name)
return v == "true" || v == "1" || v == "yes"
}
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]
}
}
if v := configValue(name); v != "" {
return v
}
return def
}
func configValue(flag string) string {
if !configLoaded {
configValues = loadConfigFile()
configLoaded = true
}
return configValues[strings.TrimPrefix(flag, "--")]
}
func loadConfigFile() map[string]string {
path := os.Getenv("PHOTOSCLI_CONFIG")
if path == "" {
home, _ := os.UserHomeDir()
path = filepath.Join(home, ".photoscli.toml")
}
data, err := os.ReadFile(path)
if err != nil {
return map[string]string{}
}
out := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
out[key] = val
}
return out
}
func exportMode(originals bool) string {
if originals {
return "originals"
}
return "previews"
}
func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
opts := exportOptions{
dryRun: hasFlag(args, "--dry-run"),
onlyFavorites: hasFlag(args, "--only-favorites"),
media: flagValWithDefault(args, "--media", "photos"),
jsonOut: hasFlag(args, "--json"),
verify: hasFlag(args, "--verify"),
format: flagValWithDefault(args, "--format", "jpeg"),
dateTemplate: flagVal(args, "--date-template"),
}
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
fmt.Fprintf(stderr, "error: --media must be photos, videos, or all, got %q\n", opts.media)
return opts, false
}
if opts.format != "jpeg" && opts.format != "heic" && opts.format != "png" {
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
return opts, false
}
if v := flagVal(args, "--retry"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
fmt.Fprintf(stderr, "error: --retry must be a non-negative integer, got %q\n", v)
return opts, false
}
opts.retry = n
}
if v := flagVal(args, "--min-size"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
fmt.Fprintf(stderr, "error: --min-size must be a non-negative integer, got %q\n", v)
return opts, false
}
opts.minSize = n
}
if v := flagVal(args, "--max-size"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
fmt.Fprintf(stderr, "error: --max-size must be a non-negative integer, got %q\n", v)
return opts, false
}
opts.maxSize = n
}
return opts, true
}
func applyAssetFilters(assets []photos.Asset, opts exportOptions) []photos.Asset {
filtered := make([]photos.Asset, 0, len(assets))
for _, a := range assets {
if opts.onlyFavorites && !a.IsFavorite {
continue
}
if opts.media == "photos" && (a.MediaType == "video" || a.MediaType == "audio") {
continue
}
if opts.media == "videos" && a.MediaType != "video" {
continue
}
est := int64(a.PixelWidth) * int64(a.PixelHeight)
if opts.minSize > 0 && est < opts.minSize {
continue
}
if opts.maxSize > 0 && est > opts.maxSize {
continue
}
filtered = append(filtered, a)
}
return filtered
}
func pathWithDateTemplate(base string, a photos.Asset, tmpl string) string {
if tmpl == "" || a.CreationDate == nil {
return base
}
t, err := time.Parse(time.RFC3339, *a.CreationDate)
if err != nil {
return base
}
repl := strings.NewReplacer("YYYY", t.Format("2006"), "MM", t.Format("01"), "DD", t.Format("02"))
return filepath.Join(base, repl.Replace(tmpl))
}
func loadManifestEntries(outDir string, mf manifest.Format) (map[string]manifest.Entry, error) {
m, _ := manifest.Open(outDir, mf)
if err := m.OpenAppend(); err != nil {
return nil, err
}
defer m.Close()
reader := m.(manifest.EntryReader)
return reader.Entries(), nil
}
func failuresPath(dir string) string { return filepath.Join(dir, "failures.jsonl") }
type failureEntry struct {
ID string `json:"id"`
Filename string `json:"filename"`
Album string `json:"album"`
Path string `json:"path"`
Error string `json:"error"`
FailedAt int64 `json:"failed_at"`
Attempts int `json:"attempts"`
}
func loadFailures(dir string) map[string]failureEntry {
out := map[string]failureEntry{}
data, err := os.ReadFile(failuresPath(dir))
if err != nil {
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var f failureEntry
if json.Unmarshal([]byte(line), &f) == nil && f.ID != "" {
out[f.ID] = f
}
}
return out
}
func saveFailures(dir string, failures map[string]failureEntry) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
f, err := openFileFunc(failuresPath(dir), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
ids := make([]string, 0, len(failures))
for id := range failures {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
data, _ := json.Marshal(failures[id])
f.Write(data)
f.Write([]byte("\n"))
}
return nil
}
func appendFailure(dir string, pa pendingAsset, err error) {
root := pa.root
if root == "" {
root = dir
}
failures := loadFailures(root)
f := failures[pa.asset.ID]
f.ID = pa.asset.ID
f.Filename = pa.asset.Filename
f.Album = pa.album
f.Path = pa.path
f.Error = err.Error()
f.FailedAt = time.Now().Unix()
f.Attempts++
failures[pa.asset.ID] = f
_ = saveFailures(root, failures)
}
func writeJSONSummary(stdout io.Writer, s commandSummary) {
data, _ := json.Marshal(s)
fmt.Fprintln(stdout, string(data))
}
func cmdReport(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
failures := 0
if data, err := os.ReadFile(failuresPath(outDir)); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if strings.TrimSpace(line) != "" {
failures++
}
}
}
fmt.Fprintf(stdout, "entries\t%d\nfailures\t%d\n", len(entries), failures)
return exitOK
}
func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
albumID := flagVal(args, "--album-id")
outDir := flagVal(args, "--out")
if albumID == "" || outDir == "" {
fmt.Fprintln(stderr, "error: --album-id and --out are required")
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
if rc := mustAuth(stderr, bridge); rc != exitOK {
return rc
}
resolved, err := resolveAlbumID(bridge, albumID)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
assets, _, err := bridge.ListAssets(resolved)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
missing := 0
for _, a := range assets {
if _, ok := entries[a.ID]; !ok {
missing++
fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Filename)
}
}
if missing > 0 {
return exitPartial
}
return exitOK
}
func cmdVerify(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
bad := 0
for id, e := range entries {
checkPath := e.Path
if checkPath == "" {
checkPath = e.Filename
}
if checkPath == "" {
continue
}
info, err := os.Stat(filepath.Join(outDir, checkPath))
if err != nil {
bad++
fmt.Fprintf(stdout, "%s\t%s\tmissing\n", id, checkPath)
continue
}
if info.Size() == 0 {
bad++
fmt.Fprintf(stdout, "%s\t%s\tzero-byte\n", id, checkPath)
continue
}
if e.Size > 0 && info.Size() != e.Size {
bad++
fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
}
}
if bad > 0 {
return exitPartial
}
fmt.Fprintf(stdout, "verified\t%d\n", len(entries))
return exitOK
}
func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out")
clearOnSuccess := hasFlag(args, "--clear-on-success")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
failures := loadFailures(outDir)
if len(failures) == 0 {
fmt.Fprintf(stderr, "error: no failures found in %s\n", failuresPath(outDir))
return exitErr
}
var pending []pendingAsset
ids := make([]string, 0, len(failures))
for id := range failures {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
f := failures[id]
pending = append(pending, pendingAsset{asset: photos.Asset{ID: f.ID, Filename: f.Filename}, root: outDir, path: f.Path, album: f.Album})
}
bar := newProgressBar(stderr, 1)
done, failed := exportPendingSerial(pending, 1024, 85, false, len(pending), bar, bridge, nil, manifest.NoopLogWriter, exportOptions{})
if clearOnSuccess && done > 0 {
for i := 0; i < done && i < len(pending); i++ {
delete(failures, pending[i].asset.ID)
}
_ = saveFailures(outDir, failures)
}
writeJSONSummary(stdout, commandSummary{Exported: done, Failed: failed, Total: len(pending)})
if failed > 0 {
return exitPartial
}
return exitOK
}
func cmdFailures(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 {
fmt.Fprintln(stderr, "error: failures requires list or clear")
return exitErr
}
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
switch args[0] {
case "list":
failures := loadFailures(outDir)
ids := make([]string, 0, len(failures))
for id := range failures {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
f := failures[id]
fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%d\n", f.ID, f.Filename, f.Album, f.Error, f.Attempts)
}
return exitOK
case "clear":
if err := removeFunc(failuresPath(outDir)); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
return exitOK
default:
fmt.Fprintf(stderr, "error: unknown failures command %q\n", args[0])
return exitErr
}
}
func cmdStatus(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
mf, err := manifest.ParseFormat(manifestFmt)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
failures := loadFailures(outDir)
if hasFlag(args, "--json") {
data, _ := json.Marshal(struct {
Manifest string `json:"manifest"`
Entries int `json:"entries"`
Failures int `json:"failures"`
}{manifestFmt, len(entries), len(failures)})
fmt.Fprintln(stdout, string(data))
return exitOK
}
fmt.Fprintf(stdout, "manifest\t%s\nentries\t%d\nfailures\t%d\n", manifestFmt, len(entries), len(failures))
return exitOK
}