1516 lines
45 KiB
Go
1516 lines
45 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
|
|
)
|
|
|
|
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 "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.
|
|
|
|
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 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.
|
|
|
|
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 appended here and can be retried with retry-failed.
|
|
|
|
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
|
|
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 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, 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.asset, pa.path, 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 {
|
|
if m != nil {
|
|
m.Add(pa.asset.ID, result.Filename, result.Size, result.Cloud)
|
|
}
|
|
} else {
|
|
done++
|
|
if m != nil {
|
|
m.Add(pa.asset.ID, result.Filename, result.Size, result.Cloud)
|
|
}
|
|
}
|
|
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].asset, pending[i].path, 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 {
|
|
if m != nil {
|
|
m.Add(entry.pa.asset.ID, entry.result.Filename, entry.result.Size, entry.result.Cloud)
|
|
}
|
|
} else {
|
|
done++
|
|
if m != nil {
|
|
m.Add(entry.pa.asset.ID, entry.result.Filename, entry.result.Size, entry.result.Cloud)
|
|
}
|
|
}
|
|
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, 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 exportOneWithRetry(bridge photos.Bridge, a photos.Asset, outDir string, 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 = exportOne(bridge, a, outDir, 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, a photos.Asset, outDir string, 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++ {
|
|
if originals {
|
|
result, err = bridge.ExportOriginalWithSlot(a.ID, outDir, index, slotIndex)
|
|
} else {
|
|
result, err = bridge.ExportPreviewWithSlot(a.ID, outDir, targetSize, quality, 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") }
|
|
|
|
func appendFailure(dir string, pa pendingAsset, err error) {
|
|
_ = os.MkdirAll(dir, 0755)
|
|
f, openErr := os.OpenFile(failuresPath(dir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
if openErr != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
data, _ := json.Marshal(struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
Album string `json:"album"`
|
|
Path string `json:"path"`
|
|
Error string `json:"error"`
|
|
}{pa.asset.ID, pa.asset.Filename, pa.album, pa.path, err.Error()})
|
|
f.Write(data)
|
|
f.Write([]byte("\n"))
|
|
}
|
|
|
|
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
|
|
}
|
|
missing := 0
|
|
for id, e := range entries {
|
|
if e.Filename == "" {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(filepath.Join(outDir, e.Filename)); err != nil {
|
|
missing++
|
|
fmt.Fprintf(stdout, "%s\t%s\n", id, e.Filename)
|
|
}
|
|
}
|
|
if missing > 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")
|
|
if outDir == "" {
|
|
fmt.Fprintln(stderr, "error: --out is required")
|
|
return exitErr
|
|
}
|
|
data, err := os.ReadFile(failuresPath(outDir))
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
|
return exitErr
|
|
}
|
|
var pending []pendingAsset
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var f struct{ ID, Filename, Album, Path string }
|
|
if json.Unmarshal([]byte(line), &f) == nil && f.ID != "" {
|
|
pending = append(pending, pendingAsset{asset: photos.Asset{ID: f.ID, Filename: f.Filename}, 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{})
|
|
writeJSONSummary(stdout, commandSummary{Exported: done, Failed: failed, Total: len(pending)})
|
|
if failed > 0 {
|
|
return exitPartial
|
|
}
|
|
return exitOK
|
|
}
|