v0.4.0: scroll log, worker slots, disk skip, color gradient, parallel export

This commit is contained in:
Ein Anderssono
2026-06-12 14:03:18 +02:00
parent e888f7cad1
commit 3d3c4a4742
15 changed files with 1609 additions and 181 deletions
+276 -112
View File
@@ -3,7 +3,10 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
@@ -47,8 +50,8 @@ Usage:
photoscli albums
photoscli photos --album-id <id>
photoscli tree
photoscli backup-all --out <dir> [--size <px>] [--originals]
photoscli export --album-id <id> --out <dir> [--size <px>] [--originals]
photoscli backup-all --out <dir> [--size <px>] [--originals] [--include-videos]
photoscli export --album-id <id> --out <dir> [--size <px>] [--originals] [--include-videos]
photoscli version
Commands:
@@ -60,10 +63,11 @@ Commands:
version Print version
Flags:
--album-id <id> Album local identifier or title (required for photos/export)
--out <dir> Output directory (required for export/backup-all)
--size <px> Target longest-side in pixels (default: 1024, preview export only)
--originals Export original files instead of JPEG previews`)
--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
--include-videos Include video assets (videos are skipped by default)`)
}
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
@@ -129,7 +133,17 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return 1
}
for _, a := range assets {
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, a.Cloud)
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 0
}
@@ -153,6 +167,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
albumID := flagVal(args, "--album-id")
outDir := flagVal(args, "--out")
originals := hasFlag(args, "--originals")
skipVideos := !hasFlag(args, "--include-videos")
sizeStr := flagValWithDefault(args, "--size", "1024")
if albumID == "" {
@@ -189,6 +204,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return 1
}
if skipVideos {
assets, total = filterVideos(assets)
}
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
@@ -212,6 +231,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
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")
sizeStr := flagValWithDefault(args, "--size", "1024")
if outDir == "" {
@@ -238,9 +258,9 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
return 1
}
albumCount := countAlbums(nodes)
fmt.Fprintf(stderr, "found %d albums, exporting to %s...\n", albumCount, outDir)
fmt.Fprintf(stderr, "found %d albums, building index...\n", albumCount)
totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
totalAssets, failed, err := backupTree(nodes, outDir, size, originals, skipVideos, stderr, bridge)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return 1
@@ -258,72 +278,279 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
return 0
}
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, int, error) {
exported := 0
total := 0
failed := 0
type pendingAsset struct {
asset photos.Asset
path string
album string
}
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, stderr io.Writer) ([]pendingAsset, int) {
var items []pendingAsset
var skipped int
collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, stderr)
return items, skipped
}
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, stderr io.Writer) {
for _, node := range nodes {
if bridge.IsCancelled() {
break
return
}
path := outDir + "/" + sanitizePathComponent(node.Name)
if node.Kind == "folder" {
n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
if err != nil {
return exported, total, failed, err
}
exported += n
total += t
failed += f
collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, stderr)
continue
}
if node.Kind == "album" && node.ID != "" {
assets, assetTotal, err := bridge.ListAssets(node.ID)
assets, _, err := bridge.ListAssets(node.ID)
if err != nil {
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", node.Name, err)
continue
}
fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal)
total += assetTotal
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
exported += n
failed += f
if skipVideos {
assets, _ = filterVideos(assets)
}
for _, a := range assets {
if fileExistsOnDisk(a, path, originals, len(*items)+*skipped) {
*skipped++
continue
}
*items = append(*items, pendingAsset{asset: a, path: path, album: node.Name})
}
fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", len(*items), *skipped)
}
}
return exported, total, failed, nil
}
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
exported := 0
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, stderr)
if bridge.IsCancelled() {
return 0, 0, nil
}
total := len(pending)
fmt.Fprintf(stderr, " indexed %d files (%d skipped), exporting to %s...\n", total, skipped, outDir)
bar := newProgressBar(stderr, 3)
exported, failed := exportPending(pending, targetSize, originals, total, bar, bridge)
bar.clear()
return exported, failed, nil
}
func exportPending(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) {
if len(pending) < 4 {
return exportPendingSerial(pending, targetSize, originals, total, bar, bridge)
}
return exportPendingParallel(pending, targetSize, originals, total, bar, bridge, 3)
}
func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) {
done := 0
failed := 0
var totalBytes int64
var totalDur time.Duration
for i, a := range assets {
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 := exportOne(bridge, a, outDir, targetSize, originals, i)
result, exportErr := exportOne(bridge, pa.asset, pa.path, targetSize, originals, i)
dur := time.Since(start)
if exportErr == nil {
isErr := exportErr != nil
isSkipped := result.Skipped
if !isErr && !isSkipped {
totalBytes += result.Size
totalDur += dur
}
if isErr {
failed++
} else {
done++
}
avgSpeed := float64(0)
if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds()
}
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil)
if exportErr != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
failed++
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)
} else if isSkipped {
logLine = fmt.Sprintf("\u23ed %s", result.Filename)
} else if result.Cloud == "cloud" {
logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", result.Filename, formatSize(result.Size), formatSpeed(avgSpeed))
} else {
logLine = fmt.Sprintf("\u2705 %s - %s - copied", result.Filename, formatSize(result.Size))
}
bar.logCompleted(logLine)
}
return done, failed
}
func exportPendingParallel(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int) (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
if originals {
result, exportErr = bridge.ExportOriginalWithSlot(pending[i].asset.ID, pending[i].path, i, workerID)
} else {
result, exportErr = bridge.ExportPreviewWithSlot(pending[i].asset.ID, pending[i].path, targetSize, i, workerID)
}
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{})
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
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(2 * time.Second):
if bridge.IsCancelled() {
close(pollDone)
wg.Wait()
return done, failed
}
continue
}
exported++
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++
} else {
done++
}
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)
} else if isSkipped {
logLine = fmt.Sprintf("\u23ed %s", entry.result.Filename)
} else if entry.result.Cloud == "cloud" {
logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", entry.result.Filename, formatSize(entry.result.Size), formatSpeed(avgSpeed))
} else {
logLine = fmt.Sprintf("\u2705 %s - %s - copied", entry.result.Filename, formatSize(entry.result.Size))
}
bar.logCompleted(logLine)
}
close(pollDone)
wg.Wait()
photos.ResetProgressSlots()
return done, failed
}
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, path: outDir, album: dirPrefix}
}
bar := newProgressBar(stderr, 1)
exported, failed := exportPending(pending, targetSize, originals, len(pending), bar, bridge)
bar.clear()
return exported, failed
}
func fileExistsOnDisk(asset photos.Asset, outDir string, originals bool, index int) bool {
var candidates []string
if originals {
if asset.Filename != "" {
candidates = append(candidates, filepath.Join(outDir, asset.Filename))
base := strings.TrimSuffix(asset.Filename, filepath.Ext(asset.Filename))
ext := filepath.Ext(asset.Filename)
candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s%s", index, base, ext)))
}
} else {
safeID := sanitizePathComponent(asset.ID)
candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s.jpg", index, safeID)))
}
for _, p := range candidates {
info, err := os.Stat(p)
if err == nil && info.Size() > 0 {
return true
}
}
return false
}
func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) {
if originals {
return bridge.ExportOriginal(a.ID, outDir, index)
@@ -331,6 +558,16 @@ func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize i
return bridge.ExportPreview(a.ID, outDir, targetSize, index)
}
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)
@@ -385,79 +622,6 @@ func flagValWithDefault(args []string, name, def string) string {
return def
}
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string, avgSpeed float64, isErr bool) {
pct := 0
if total > 0 {
pct = current * 100 / total
}
barWidth := 25
filled := pct * barWidth / 100
partial := (pct * barWidth % 100) * len(blockPartial) / 100
bar := strings.Repeat(string(blockFull), filled)
if filled < barWidth {
if partial > 0 {
bar += string(blockPartial[partial])
}
bar += strings.Repeat(string(blockEmpty), barWidth-filled-1)
}
fileSize := formatSize(size)
cloudLabel := ""
if cloud == "cloud" {
cloudLabel = formatSpeed(avgSpeed)
if cloudLabel != "" {
cloudLabel = " " + cloudLabel
}
cloudLabel = " ☁" + cloudLabel
}
name := filename
maxName := 30
if len(name) > maxName {
name = "…" + name[len(name)-maxName+1:]
}
fmt.Fprintf(w, "\r%s [%s] %3d%% %s%s %s", name, bar, pct, fileSize, cloudLabel, statusLabel(isErr))
}
var blockFull = '█'
var blockEmpty = '░'
var blockPartial = []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'}
func statusLabel(isErr bool) string {
if isErr {
return "✗"
}
return ""
}
func formatSpeed(bytesPerSec float64) string {
if bytesPerSec <= 0 {
return ""
}
const kb = 1024
const mb = kb * 1024
if bytesPerSec >= mb {
return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb)
}
if bytesPerSec >= kb {
return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb)
}
return fmt.Sprintf("%.0f B/s", bytesPerSec)
}
func formatSize(bytes int64) string {
if bytes <= 0 {
return ""
}
const kb = 1024
const mb = kb * 1024
if bytes >= mb {
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
}
if bytes >= kb {
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
}
return fmt.Sprintf("%d B", bytes)
}
func exportMode(originals bool) string {
if originals {
return "originals"
+409 -7
View File
@@ -6,6 +6,7 @@ import (
"bytes"
"fmt"
"strings"
"sync/atomic"
"testing"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
@@ -49,6 +50,12 @@ func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.Expo
}
return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil
}
func (m *mockBridge) ExportPreviewWithSlot(assetID, out string, targetSize, index, slotIndex int) (photos.ExportResult, error) {
return m.ExportPreview(assetID, out, targetSize, index)
}
func (m *mockBridge) ExportOriginalWithSlot(assetID, out string, index, slotIndex int) (photos.ExportResult, error) {
return m.ExportOriginal(assetID, out, index)
}
func (m *mockBridge) Cancel() { m.cancelled = true }
func (m *mockBridge) IsCancelled() bool { return m.cancelled }
@@ -161,13 +168,16 @@ func TestCmdPhotosMissingAlbumID(t *testing.T) {
func TestCmdPhotosSuccess(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud"}},
assets: []photos.Asset{
{ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local", MediaType: "image", PixelWidth: 4032, PixelHeight: 3024},
{ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud", MediaType: "image", PixelWidth: 1920, PixelHeight: 1080},
},
}
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
expected := "as1\tIMG_0001.JPG\tlocal\nas2\tIMG_0002.JPG\tcloud\n"
expected := "as1\tIMG_0001.JPG\tlocal\timage\t4032x3024\nas2\tIMG_0002.JPG\tcloud\timage\t1920x1080\n"
if out != expected {
t.Errorf("out = %q, want %q", out, expected)
}
@@ -317,7 +327,7 @@ func TestCmdBackupAllExportError(t *testing.T) {
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "failed: img.jpg: disk full") {
if !strings.Contains(stderr, "\u274c img.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
if !strings.Contains(stderr, "(1 failed)") {
@@ -416,7 +426,7 @@ func TestCmdExportBridgeError(t *testing.T) {
if !strings.Contains(stderr, "all exports failed") {
t.Errorf("stderr = %q", stderr)
}
if !strings.Contains(stderr, "failed: img.jpg: disk full") {
if !strings.Contains(stderr, "\u274c img.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
}
@@ -461,7 +471,7 @@ func TestCmdExportOriginalsBridgeError(t *testing.T) {
if !strings.Contains(stderr, "all exports failed") {
t.Errorf("stderr = %q", stderr)
}
if !strings.Contains(stderr, "failed: img.jpg: copy failed") {
if !strings.Contains(stderr, "\u274c img.jpg: copy failed") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
}
@@ -646,7 +656,7 @@ func TestCmdExportPartialFailure(t *testing.T) {
if rc != 0 {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "failed: bad.jpg: disk full") {
if !strings.Contains(stderr, "\u274c bad.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
if !strings.Contains(stderr, "(1 failed)") {
@@ -666,7 +676,7 @@ func TestCmdBackupAllSkippedAlbum(t *testing.T) {
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "skipped album Broken") {
if !strings.Contains(stderr, "\u26a0 album Broken") {
t.Errorf("stderr should contain skipped album, got: %q", stderr)
}
}
@@ -684,3 +694,395 @@ func TestResolveAlbumIDNotFoundMessage(t *testing.T) {
t.Errorf("err = %q", err.Error())
}
}
func TestFormatSpeed(t *testing.T) {
tests := []struct {
bps float64
want string
}{
{0, ""},
{500, "500 B/s"},
{1500, "1.5 KB/s"},
{1024 * 1024, "1.0 MB/s"},
{2.5 * 1024 * 1024, "2.5 MB/s"},
}
for _, tt := range tests {
got := formatSpeed(tt.bps)
if got != tt.want {
t.Errorf("formatSpeed(%v) = %q, want %q", tt.bps, got, tt.want)
}
}
}
func TestFormatSize(t *testing.T) {
tests := []struct {
bytes int64
want string
}{
{0, ""},
{-1, ""},
{500, "500 B"},
{1500, "1.5 KB"},
{1024 * 1024, "1.0 MB"},
{2.5 * 1024 * 1024, "2.5 MB"},
}
for _, tt := range tests {
got := formatSize(tt.bytes)
if got != tt.want {
t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want)
}
}
}
func TestSanitizePathComponent(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Hello World", "Hello World"},
{"Hello/World", "Hello_World"},
{"Hello\\World", "Hello_World"},
{" spaces ", "spaces"},
{"", "Untitled"},
{" ", "Untitled"},
}
for _, tt := range tests {
got := sanitizePathComponent(tt.input)
if got != tt.want {
t.Errorf("sanitizePathComponent(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestExportMode(t *testing.T) {
if exportMode(true) != "originals" {
t.Error("exportMode(true) should be originals")
}
if exportMode(false) != "previews" {
t.Error("exportMode(false) should be previews")
}
}
func TestCountAlbums(t *testing.T) {
nodes := []photos.CollectionNode{
{Name: "folder", Kind: "folder", Children: []photos.CollectionNode{
{Name: "album1", Kind: "album"},
{Name: "sub", Kind: "folder", Children: []photos.CollectionNode{
{Name: "album2", Kind: "album"},
}},
}},
{Name: "album3", Kind: "album"},
}
if n := countAlbums(nodes); n != 3 {
t.Errorf("countAlbums = %d, want 3", n)
}
}
func TestCmdExportAllFailures(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "a1", Filename: "bad.jpg"}},
exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("error")
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b)
if rc != 1 {
t.Errorf("rc = %d, want 1", rc)
}
if !strings.Contains(stderr, "all exports failed") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdPhotosAssetsError(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Album"}},
assetsByAlbum: map[string][]photos.Asset{},
}
_, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 1 {
t.Errorf("rc = %d, want 1", rc)
}
if !strings.Contains(stderr, "album not found") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdBackupAllAuthDenied(t *testing.T) {
b := &mockBridge{accessErr: fmt.Errorf("denied")}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp"}, b)
if rc != 1 {
t.Errorf("rc = %d, want 1", rc)
}
if !strings.Contains(stderr, "denied") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdExportAssetsByAlbumMap(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "TestAlbum"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "photo.jpg", Cloud: "cloud"}},
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "TestAlbum", "--out", "/tmp"}, b)
if rc != 0 {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "exported 1") {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdBackupAllWithFolder(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
{Name: "MyFolder", Kind: "folder", Children: []photos.CollectionNode{
{ID: "a1", Name: "SubAlbum", Kind: "album"},
}},
},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "photo.jpg"}},
},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "exported 1 preview files across 1 albums") {
t.Errorf("stderr = %q", stderr)
}
}
func TestProgressDisplayRenderBar(t *testing.T) {
var buf bytes.Buffer
d := newProgressBar(&buf, 1)
d.setTotal(5, 10, "1.0 MB")
d.setAlbum("DnD", 3, 5)
d.setWorker(0, "photo.jpg", 0, "cloud", "exporting")
d.draw()
output := buf.String()
if !strings.Contains(output, "Total") {
t.Error("should contain Total")
}
if !strings.Contains(output, "Album") {
t.Error("should contain Album")
}
if !strings.Contains(output, "DnD") {
t.Error("should contain album name DnD")
}
if !strings.Contains(output, "photo.jpg") {
t.Error("should contain filename")
}
}
func TestProgressDisplayLocalFile(t *testing.T) {
var buf bytes.Buffer
d := newProgressBar(&buf, 1)
d.setTotal(1, 1, "500 B")
d.setAlbum("", 0, 0)
d.logCompleted("\u2705 local.jpg - 500 B - copied")
output := buf.String()
if !strings.Contains(output, "copied") {
t.Error("local files should show copied status")
}
if !strings.Contains(output, "\u2705") {
t.Error("local files should show check mark")
}
}
func TestProgressDisplaySkippedFile(t *testing.T) {
var buf bytes.Buffer
d := newProgressBar(&buf, 1)
d.setTotal(1, 1, "0 B")
d.setAlbum("", 0, 0)
d.logCompleted("\u23ed exists.jpg")
output := buf.String()
if !strings.Contains(output, "\u23ed") {
t.Error("skipped files should show skipped status")
}
}
func TestProgressDisplayError(t *testing.T) {
var buf bytes.Buffer
d := newProgressBar(&buf, 1)
d.logCompleted("\u274c bad.jpg: some error")
output := buf.String()
if !strings.Contains(output, "\u274c") {
t.Error("should contain error marker")
}
}
func TestProgressDisplayClear(t *testing.T) {
var buf bytes.Buffer
d := newProgressBar(&buf, 1)
d.setTotal(1, 1, "0 B")
d.draw()
d.clear()
output := buf.String()
if !strings.Contains(output, "\x1b[") {
t.Error("clear should use ANSI escape codes")
}
}
func TestExportParallelWithCancel(t *testing.T) {
var cancelFlag int32
call := int32(0)
bridge := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "Test"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "x1", Filename: "img1.jpg"},
{ID: "x2", Filename: "img2.jpg"},
{ID: "x3", Filename: "img3.jpg"},
{ID: "x4", Filename: "img4.jpg"},
{ID: "x5", Filename: "img5.jpg"},
},
},
exportOrigFn: func(string, string, int) (photos.ExportResult, error) {
if atomic.AddInt32(&call, 1) >= 2 {
atomic.StoreInt32(&cancelFlag, 1)
}
return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil
},
}
_, _, _ = runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--originals"}, bridge)
_ = cancelFlag
}
func TestExportParallelPartialFailure(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "Test"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {
{ID: "x1", Filename: "ok1.jpg"},
{ID: "x2", Filename: "bad.jpg"},
{ID: "x3", Filename: "ok2.jpg"},
{ID: "x4", Filename: "ok3.jpg"},
{ID: "x5", Filename: "ok4.jpg"},
},
},
exportPreviewFn: func(_ string, _ string, _ int, idx int) (photos.ExportResult, error) {
if idx == 1 {
return photos.ExportResult{}, fmt.Errorf("fail")
}
return photos.ExportResult{Filename: "ok.jpg", Size: 2048, Cloud: "local"}, nil
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "Test", "--out", "/tmp"}, b)
if rc != 0 {
t.Errorf("rc = %d, want 0 (partial success)", rc)
}
if !strings.Contains(stderr, "1 failed") {
t.Errorf("stderr should contain failed count, got: %q", stderr)
}
}
func TestBackupAllEmptyTree(t *testing.T) {
b := &mockBridge{tree: []photos.CollectionNode{}}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(stderr, "exported 0") {
t.Errorf("stderr = %q", stderr)
}
}
func TestFilterVideos(t *testing.T) {
assets := []photos.Asset{
{ID: "1", Filename: "a.jpg", MediaType: "image"},
{ID: "2", Filename: "b.mov", MediaType: "video"},
{ID: "3", Filename: "c.jpg", MediaType: "image"},
{ID: "4", Filename: "d.mp3", MediaType: "audio"},
{ID: "5", Filename: "e.heic", MediaType: "image"},
}
filtered, count := filterVideos(assets)
if count != 3 {
t.Errorf("count = %d, want 3", count)
}
for _, a := range filtered {
if a.MediaType == "video" || a.MediaType == "audio" {
t.Errorf("found %s asset: %+v", a.MediaType, a)
}
}
}
func TestPhotosOutputWithCreationDate(t *testing.T) {
date := "2024-06-15T12:30:00+0200"
b := &mockBridge{
assets: []photos.Asset{
{ID: "a1", Filename: "IMG.jpg", Cloud: "local", MediaType: "image", PixelWidth: 4032, PixelHeight: 3024, CreationDate: &date},
},
}
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(out, date) {
t.Errorf("out = %q, want creation date %s", out, date)
}
}
func TestPhotosOutputWithDuration(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "v1", Filename: "clip.mov", Cloud: "cloud", MediaType: "video", PixelWidth: 1920, PixelHeight: 1080, Duration: 12.5},
},
}
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(out, "12.5s") {
t.Errorf("out = %q, want duration", out)
}
}
func TestPhotosOutputWithFavorite(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "f1", Filename: "fav.jpg", Cloud: "local", MediaType: "image", PixelWidth: 1000, PixelHeight: 1000, IsFavorite: true},
},
}
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(out, "*") {
t.Errorf("out = %q, want favorite marker", out)
}
}
func TestExportSkipsVideos(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "1", Filename: "a.jpg", MediaType: "image"},
{ID: "2", Filename: "b.mov", MediaType: "video"},
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(stderr, "1 assets") {
t.Errorf("stderr = %q, want 1 asset (video skipped)", stderr)
}
}
func TestExportIncludesVideos(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{
{ID: "1", Filename: "a.jpg", MediaType: "image"},
{ID: "2", Filename: "b.mov", MediaType: "video"},
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--include-videos"}, b)
if rc != 0 {
t.Errorf("rc = %d", rc)
}
if !strings.Contains(stderr, "2 assets") {
t.Errorf("stderr = %q, want 2 assets (video included)", stderr)
}
}
+393
View File
@@ -0,0 +1,393 @@
package main
import (
"fmt"
"io"
"strings"
"sync"
"time"
)
type progressBar struct {
mu sync.Mutex
w io.Writer
width int
termH int
start time.Time
errors []string
workers int
footerLines int
scrollSet bool
total barLine
album barLine
workerState []workerSlot
}
type barLine struct {
current int
total int
label string
detail string
}
type workerSlot struct {
filename string
size int64
cloud string
progress float64
bytesDone int64
bytesTotal int64
speed float64
status string
}
func newProgressBar(w io.Writer, workers int) *progressBar {
fl := workers + 2
return &progressBar{
w: w,
width: 80,
termH: 24,
start: time.Now(),
workers: workers,
footerLines: fl,
workerState: make([]workerSlot, workers),
}
}
func (p *progressBar) setTotal(current, total int, detail string) {
p.mu.Lock()
defer p.mu.Unlock()
p.total = barLine{current: current, total: total, label: "Total", detail: detail}
}
func (p *progressBar) setAlbum(name string, current, total int) {
p.mu.Lock()
defer p.mu.Unlock()
p.album = barLine{current: current, total: total, label: "Album", detail: name}
}
func (p *progressBar) setWorker(i int, filename string, size int64, cloud string, status string) {
p.mu.Lock()
defer p.mu.Unlock()
if i >= 0 && i < len(p.workerState) {
p.workerState[i].filename = filename
p.workerState[i].size = size
p.workerState[i].cloud = cloud
p.workerState[i].status = status
p.workerState[i].progress = 0
p.workerState[i].bytesDone = 0
p.workerState[i].bytesTotal = 0
}
}
func (p *progressBar) updateWorkerProgress(i int, progress float64, bytesDone, bytesTotal int64) {
p.mu.Lock()
defer p.mu.Unlock()
if i >= 0 && i < len(p.workerState) {
p.workerState[i].progress = progress
p.workerState[i].bytesDone = bytesDone
p.workerState[i].bytesTotal = bytesTotal
if bytesDone > 0 && bytesTotal > 0 {
p.workerState[i].size = bytesTotal
}
}
}
func (p *progressBar) addError(filename string, err error) {
p.mu.Lock()
defer p.mu.Unlock()
p.errors = append(p.errors, fmt.Sprintf(" \u274c %s: %v", filename, err))
}
func (p *progressBar) logCompleted(line string) {
p.mu.Lock()
defer p.mu.Unlock()
p.ensureScrollRegion()
scrollTop := p.termH - p.footerLines
fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s\n", scrollTop, line)
p.drawFooterLocked()
}
func (p *progressBar) draw() {
p.mu.Lock()
defer p.mu.Unlock()
p.ensureScrollRegion()
p.drawFooterLocked()
}
func (p *progressBar) ensureScrollRegion() {
w, h := termSize()
if w != p.width || h != p.termH || !p.scrollSet {
p.width = w
p.termH = h
scrollTop := p.termH - p.footerLines
if scrollTop < 1 {
scrollTop = 1
}
fmt.Fprintf(p.w, "\x1b[1;%dr", scrollTop)
p.scrollSet = true
}
}
func (p *progressBar) drawFooterLocked() {
scrollTop := p.termH - p.footerLines
if scrollTop < 1 {
scrollTop = 1
}
elapsed := time.Since(p.start)
fmt.Fprintf(p.w, "\x1b[?25l")
footerStart := scrollTop + 1
for i := 0; i < p.workers; i++ {
row := footerStart + i
fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K", row)
if i < len(p.workerState) && p.workerState[i].filename != "" {
fmt.Fprintf(p.w, "%s", truncateOrPad(renderWorkerLine(p.workerState[i], p.width), p.width))
} else {
fmt.Fprintf(p.w, "%s", strings.Repeat(" ", p.width))
}
}
row := footerStart + p.workers
fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s", row, renderLine(p.total, elapsed, p.width))
row++
fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s", row, renderLine(p.album, elapsed, p.width))
fmt.Fprintf(p.w, "\x1b[%d;1H", scrollTop)
fmt.Fprintf(p.w, "\x1b[?25h")
}
func (p *progressBar) clear() {
p.mu.Lock()
defer p.mu.Unlock()
fmt.Fprintf(p.w, "\x1b[r")
fmt.Fprintf(p.w, "\x1b[?25h")
p.scrollSet = false
}
func (p *progressBar) flushErrors() {
for _, e := range p.errors {
fmt.Fprintln(p.w, e)
}
p.errors = nil
}
func renderWorkerLine(ws workerSlot, width int) string {
if width <= 0 {
width = 80
}
parts := []string{}
if ws.status == "FAIL" {
parts = append(parts, "\u274c")
parts = append(parts, ws.filename)
} else if ws.status == "skipped" {
parts = append(parts, "\u23ed")
parts = append(parts, ws.filename)
if s := formatSize(ws.size); s != "" {
parts = append(parts, s)
}
} else if ws.cloud == "cloud" && ws.progress > 0 && ws.progress < 1.0 {
parts = append(parts, "\u2601")
parts = append(parts, ws.filename)
barWidth := 20
bar := renderBar(int(ws.progress*100), barWidth)
pct := int(ws.progress * 100)
parts = append(parts, fmt.Sprintf("[%s] %d%%", bar, pct))
if ws.bytesTotal > 0 {
parts = append(parts, fmt.Sprintf("%s/%s", formatSize(ws.bytesDone), formatSize(ws.bytesTotal)))
}
if ws.speed > 0 {
parts = append(parts, formatSpeed(ws.speed))
}
} else if ws.cloud == "cloud" {
parts = append(parts, "\u2601")
parts = append(parts, ws.filename)
if s := formatSize(ws.size); s != "" {
parts = append(parts, s)
}
parts = append(parts, "downloaded")
if ws.speed > 0 {
parts = append(parts, formatSpeed(ws.speed))
}
} else {
parts = append(parts, "\u2705")
parts = append(parts, ws.filename)
if s := formatSize(ws.size); s != "" {
parts = append(parts, s)
}
parts = append(parts, "copied")
}
return strings.Join(parts, " ")
}
func renderLine(b barLine, elapsed time.Duration, width int) string {
if width <= 0 {
width = 80
}
pct := 0
if b.total > 0 {
pct = b.current * 100 / b.total
}
counter := ""
if b.total > 0 {
counter = fmt.Sprintf("%d/%d", b.current, b.total)
}
eta := ""
if pct > 0 && pct < 100 && elapsed > 500*time.Millisecond {
remaining := elapsed * time.Duration(100-pct) / time.Duration(pct)
if remaining > time.Second {
eta = formatDuration(remaining)
}
}
right := b.detail
if b.label == "Album" && counter != "" && b.detail != "" {
right = fmt.Sprintf("%s %s", b.detail, counter)
} else if counter != "" && right != "" {
right = fmt.Sprintf("%s %s", right, counter)
} else if counter != "" {
right = counter
}
if eta != "" {
right += " " + eta
}
labelWidth := 6
pctWidth := 4
gap := 2
rightWidth := runeWidth(right)
availableForBar := width - labelWidth - pctWidth - gap - rightWidth - gap
if availableForBar < 3 {
availableForBar = 3
}
if availableForBar > 40 {
availableForBar = 40
}
bar := renderBar(pct, availableForBar)
return fmt.Sprintf("%-6s [%s] %3d%% %s", b.label, bar, pct, right)
}
func renderBar(pct, barWidth int) string {
if barWidth <= 0 {
return ""
}
fraction := float64(pct) / 100.0
filled := fraction * float64(barWidth)
fullBlocks := int(filled)
partial := filled - float64(fullBlocks)
var r, g uint8
if pct <= 50 {
r = 255
g = uint8(float64(pct) * 5.1)
} else {
r = uint8(float64(100-pct) * 5.1)
g = 255
}
var sb strings.Builder
fmt.Fprintf(&sb, "\x1b[38;2;%d;%d;0m", r, g)
for i := 0; i < fullBlocks && i < barWidth; i++ {
sb.WriteString("\u2588")
}
if fullBlocks < barWidth {
fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"}
idx := int(partial * 8)
if idx > 7 {
idx = 7
}
if idx > 0 {
sb.WriteString(fracs[idx])
fullBlocks++
}
}
sb.WriteString("\x1b[0m")
for i := fullBlocks; i < barWidth; i++ {
sb.WriteString("\u2591")
}
return sb.String()
}
func runeWidth(s string) int {
w := 0
for _, r := range s {
if r >= 0x1100 && (r <= 0x115f || r == 0x2329 || r == 0x232a ||
(r >= 0x2e80 && r <= 0xa4cf && r != 0x303f) ||
(r >= 0xac00 && r <= 0xd7a3) ||
(r >= 0xf900 && r <= 0xfaff) ||
(r >= 0xfe30 && r <= 0xfe6f) ||
(r >= 0xff01 && r <= 0xff60) ||
(r >= 0xffe0 && r <= 0xffe6) ||
(r >= 0x20000 && r <= 0x2fffd) ||
(r >= 0x30000 && r <= 0x3fffd)) {
w += 2
} else {
w += 1
}
}
return w
}
func truncateOrPad(s string, width int) string {
if width <= 0 {
width = 80
}
rw := runeWidth(s)
if rw > width {
runes := []rune(s)
for i := range runes {
if runeWidth(string(runes[:i+1])) > width-3 {
return string(runes[:i]) + "..."
}
}
return s
}
return s + strings.Repeat(" ", width-rw)
}
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
m := int(d.Minutes())
s := int(d.Seconds()) % 60
return fmt.Sprintf("%dm%02ds", m, s)
}
func formatSpeed(bytesPerSec float64) string {
if bytesPerSec <= 0 {
return ""
}
const kb = 1024
const mb = kb * 1024
if bytesPerSec >= mb {
return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb)
}
if bytesPerSec >= kb {
return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb)
}
return fmt.Sprintf("%.0f B/s", bytesPerSec)
}
func formatSize(bytes int64) string {
if bytes <= 0 {
return ""
}
const kb = 1024
const mb = kb * 1024
if bytes >= mb {
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
}
if bytes >= kb {
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
}
return fmt.Sprintf("%d B", bytes)
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !test
package main
import (
"syscall"
"unsafe"
)
func termSize() (int, int) {
type winsize struct {
Rows uint16
Cols uint16
Xpixels uint16
Ypixels uint16
}
var ws winsize
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(syscall.Stdout), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws)))
if errno != 0 || ws.Cols == 0 || ws.Rows == 0 {
return 80, 24
}
return int(ws.Cols), int(ws.Rows)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build test
package main
func termSize() (int, int) {
return 80, 24
}