rename applephotos to photoscli, update module path to gitea.k3s.k0.nu/tools/photocli
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||
)
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if len(args) < 1 {
|
||||
usage(stderr)
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd := args[0]
|
||||
switch cmd {
|
||||
case "albums":
|
||||
return cmdAlbums(stdout, stderr, bridge)
|
||||
case "photos":
|
||||
return cmdPhotos(args[1:], stdout, stderr, bridge)
|
||||
case "tree":
|
||||
return cmdTree(stdout, stderr, bridge)
|
||||
case "backup-all":
|
||||
return cmdBackupAll(args[1:], stdout, stderr, bridge)
|
||||
case "export":
|
||||
return cmdExport(args[1:], stdout, stderr, bridge)
|
||||
case "help", "--help", "-h":
|
||||
usage(stderr)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command: %s\n", cmd)
|
||||
usage(stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintln(w, `photoscli — export optimized images from Apple Photos
|
||||
|
||||
Usage:
|
||||
photoscli albums
|
||||
photoscli photos --album-id <id>
|
||||
photoscli tree
|
||||
photoscli backup-all --out <dir> [--size <px>] [--originals]
|
||||
photoscli export --album-id <id> --out <dir> [--size <px>] [--originals]
|
||||
|
||||
Commands:
|
||||
albums List user-created albums
|
||||
photos List photo assets in an album
|
||||
tree Show folder and album hierarchy
|
||||
backup-all Export all albums into the Photos folder tree
|
||||
export Export optimized JPEG previews or original files
|
||||
|
||||
Flags:
|
||||
--album-id <id> Album local identifier or title (required for photos/export)
|
||||
--out <dir> Output directory (required for export/backup-all)
|
||||
--size <px> Target longest-side in pixels (default: 1024, preview export only)
|
||||
--originals Export original files instead of JPEG previews`)
|
||||
}
|
||||
|
||||
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
|
||||
if err := bridge.RequestAccess(); err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
albums, err := bridge.ListAlbums()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, a := range albums {
|
||||
fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Title)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) {
|
||||
_, _, err := bridge.ListAssets(idOrName)
|
||||
if err == nil {
|
||||
return idOrName, nil
|
||||
}
|
||||
albums, listErr := bridge.ListAlbums()
|
||||
if listErr != nil {
|
||||
return idOrName, err
|
||||
}
|
||||
for _, a := range albums {
|
||||
if a.Title == idOrName {
|
||||
return a.ID, nil
|
||||
}
|
||||
}
|
||||
return idOrName, err
|
||||
}
|
||||
|
||||
func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
albumID := flagVal(args, "--album-id")
|
||||
if albumID == "" {
|
||||
fmt.Fprintln(stderr, "error: --album-id is required")
|
||||
return 1
|
||||
}
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
resolved, err := resolveAlbumID(bridge, albumID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
assets, _, err := bridge.ListAssets(resolved)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, a := range assets {
|
||||
cloud := "local"
|
||||
if a.Cloud {
|
||||
cloud = "cloud"
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, cloud)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTree(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
nodes, err := bridge.ListTree()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, node := range nodes {
|
||||
printNode(stdout, node, 0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
albumID := flagVal(args, "--album-id")
|
||||
outDir := flagVal(args, "--out")
|
||||
originals := hasFlag(args, "--originals")
|
||||
sizeStr := flagValWithDefault(args, "--size", "1024")
|
||||
|
||||
if albumID == "" {
|
||||
fmt.Fprintln(stderr, "error: --album-id is required")
|
||||
return 1
|
||||
}
|
||||
if outDir == "" {
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return 1
|
||||
}
|
||||
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
resolved, err := resolveAlbumID(bridge, albumID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
var size int
|
||||
if !originals {
|
||||
if _, err2 := fmt.Sscanf(sizeStr, "%d", &size); err2 != nil || size <= 0 {
|
||||
fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
assets, total, err := bridge.ListAssets(resolved)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
exported := 0
|
||||
failed := 0
|
||||
for i, a := range assets {
|
||||
var result photos.ExportResult
|
||||
var exportErr error
|
||||
if originals {
|
||||
result, exportErr = bridge.ExportOriginal(a.ID, outDir, i)
|
||||
} else {
|
||||
result, exportErr = bridge.ExportPreview(a.ID, outDir, size, i)
|
||||
}
|
||||
|
||||
progressBar(stderr, exported+failed+1, total, result.Filename, result.Size, result.Cloud)
|
||||
|
||||
if exportErr != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
exported++
|
||||
}
|
||||
|
||||
if exported == 0 && failed > 0 {
|
||||
fmt.Fprintf(stderr, "\nerror: all exports failed\n")
|
||||
return 1
|
||||
}
|
||||
|
||||
if originals {
|
||||
fmt.Fprintf(stderr, "\nexported %d original files to %s\n", exported, outDir)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
outDir := flagVal(args, "--out")
|
||||
originals := hasFlag(args, "--originals")
|
||||
sizeStr := flagValWithDefault(args, "--size", "1024")
|
||||
|
||||
if outDir == "" {
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return 1
|
||||
}
|
||||
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
var size int
|
||||
if !originals {
|
||||
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err != nil || size <= 0 {
|
||||
fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
nodes, err := bridge.ListTree()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
albumCount := countAlbums(nodes)
|
||||
|
||||
totalAssets, grandTotal, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if originals {
|
||||
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s\n", totalAssets, albumCount, outDir)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir)
|
||||
}
|
||||
_ = grandTotal
|
||||
return 0
|
||||
}
|
||||
|
||||
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
|
||||
exported := 0
|
||||
total := 0
|
||||
for _, node := range nodes {
|
||||
path := outDir + "/" + sanitizePathComponent(node.Name)
|
||||
if node.Kind == "folder" {
|
||||
n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
|
||||
if err != nil {
|
||||
return exported, total, err
|
||||
}
|
||||
exported += n
|
||||
total += t
|
||||
continue
|
||||
}
|
||||
if node.Kind == "album" && node.ID != "" {
|
||||
assets, assetTotal, err := bridge.ListAssets(node.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total += assetTotal
|
||||
for i, a := range assets {
|
||||
var result photos.ExportResult
|
||||
var exportErr error
|
||||
if originals {
|
||||
result, exportErr = bridge.ExportOriginal(a.ID, path, i)
|
||||
} else {
|
||||
result, exportErr = bridge.ExportPreview(a.ID, path, targetSize, i)
|
||||
}
|
||||
progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud)
|
||||
if exportErr != nil {
|
||||
continue
|
||||
}
|
||||
exported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return exported, total, nil
|
||||
}
|
||||
|
||||
func sanitizePathComponent(name string) string {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" {
|
||||
s = "Untitled"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "/", "_")
|
||||
s = strings.ReplaceAll(s, "\\", "_")
|
||||
return s
|
||||
}
|
||||
|
||||
func flagVal(args []string, name string) string {
|
||||
return flagValWithDefault(args, name, "")
|
||||
}
|
||||
|
||||
func hasFlag(args []string, name string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printNode(w io.Writer, node photos.CollectionNode, depth int) {
|
||||
for i := 0; i < depth; i++ {
|
||||
fmt.Fprint(w, " ")
|
||||
}
|
||||
fmt.Fprintln(w, node.Name)
|
||||
for _, child := range node.Children {
|
||||
printNode(w, child, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
func countAlbums(nodes []photos.CollectionNode) int {
|
||||
total := 0
|
||||
for _, node := range nodes {
|
||||
if node.Kind == "album" {
|
||||
total++
|
||||
}
|
||||
total += countAlbums(node.Children)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func flagValWithDefault(args []string, name, def string) string {
|
||||
for i, arg := range args {
|
||||
if arg == name && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) {
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = current * 100 / total
|
||||
}
|
||||
barWidth := 30
|
||||
filled := pct * barWidth / 100
|
||||
bar := strings.Repeat("=", filled) + strings.Repeat(" ", barWidth-filled)
|
||||
fmt.Fprintf(w, "\r[%s] %3d%% %s %s %s", bar, pct, filename, formatSize(size), cloud)
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
if bytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
const kb = 1024
|
||||
const mb = kb * 1024
|
||||
if bytes >= mb {
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||
}
|
||||
Reference in New Issue
Block a user