Compare commits

2 Commits

Author SHA1 Message Date
Ein Anderssono e888f7cad1 v0.2.5: Unicode progress bar with cloud download speed
- Unicode block progress bar (█▓░)
- Cloud downloads show ☁ with average speed (MB/s, KB/s, B/s)
- Truncated filenames with ellipsis for long names
- Error indicator (✗) in progress bar
- Simplified to serial export for clean cancel behavior
- Added IsCancelled() to Bridge interface
2026-06-11 21:59:42 +02:00
Ein Anderssono 479c284dfc v0.2.4: stop export loop on Ctrl+C instead of flooding failures
- Add IsCancelled() to Bridge interface
- Check bridge.IsCancelled() before each export in serial/parallel/backupTree
- Parallel workers mark remaining slots as 'cancelled' instead of exporting
- Add photos_request_is_cancelled to ObjC and C stub
2026-06-11 21:44:55 +02:00
9 changed files with 96 additions and 69 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.2.3 VERSION := 0.2.5
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
LDFLAGS := -X main.version=$(VERSION) LDFLAGS := -X main.version=$(VERSION)
OBJ := $(BRIDGE_DIR)/photokit_bridge.o OBJ := $(BRIDGE_DIR)/photokit_bridge.o
+2
View File
@@ -28,6 +28,8 @@ char *photos_list_tree_json(void);
void photos_request_cancel(void); void photos_request_cancel(void);
int photos_request_is_cancelled(void);
void photos_free_string(char *value); void photos_free_string(char *value);
#ifdef __cplusplus #ifdef __cplusplus
+4
View File
@@ -415,3 +415,7 @@ void photos_free_string(char *value) {
void photos_request_cancel(void) { void photos_request_cancel(void) {
photos_cancelled = 1; photos_cancelled = 1;
} }
int photos_request_is_cancelled(void) {
return photos_cancelled;
}
+4
View File
@@ -74,6 +74,10 @@ void photos_request_cancel(void) {
stub_cancelled = 1; stub_cancelled = 1;
} }
int photos_request_is_cancelled(void) {
return stub_cancelled;
}
void photos_test_set_export_preview_json(const char *json) { void photos_test_set_export_preview_json(const char *json) {
stub_export_preview_json = json; stub_export_preview_json = json;
} }
+75 -68
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"strings" "strings"
"sync" "time"
"gitea.k3s.k0.nu/tools/photocli/internal/photos" "gitea.k3s.k0.nu/tools/photocli/internal/photos"
) )
@@ -263,6 +263,9 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
total := 0 total := 0
failed := 0 failed := 0
for _, node := range nodes { for _, node := range nodes {
if bridge.IsCancelled() {
break
}
path := outDir + "/" + sanitizePathComponent(node.Name) path := outDir + "/" + sanitizePathComponent(node.Name)
if node.Kind == "folder" { if node.Kind == "folder" {
n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
@@ -291,23 +294,26 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
} }
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
if len(assets) == 0 {
return 0, 0
}
if len(assets) < 4 {
return exportAssetsSerial(assets, outDir, targetSize, originals, total, stderr, bridge, dirPrefix)
}
return exportAssetsParallel(assets, outDir, targetSize, originals, total, stderr, bridge, 3, dirPrefix)
}
func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
exported := 0 exported := 0
failed := 0 failed := 0
var totalBytes int64
var totalDur time.Duration
for i, a := range assets { for i, a := range assets {
if bridge.IsCancelled() {
break
}
start := time.Now()
result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i) result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i)
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud) dur := time.Since(start)
if exportErr == nil {
totalBytes += result.Size
totalDur += dur
}
avgSpeed := float64(0)
if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds()
}
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil)
if exportErr != nil { if exportErr != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr) fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
failed++ failed++
@@ -318,55 +324,6 @@ func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, or
return exported, failed return exported, failed
} }
func exportAssetsParallel(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, workers int, dirPrefix string) (int, int) {
type slot struct {
result photos.ExportResult
err error
done chan struct{}
}
slots := make([]slot, len(assets))
for i := range slots {
slots[i].done = make(chan struct{})
}
jobs := make(chan int, len(assets))
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range jobs {
result, exportErr := exportOne(bridge, assets[i], outDir, targetSize, originals, i)
slots[i].result = result
slots[i].err = exportErr
close(slots[i].done)
}
}()
}
for i := range assets {
jobs <- i
}
close(jobs)
exported := 0
failed := 0
for i, a := range assets {
<-slots[i].done
s := slots[i]
progressBar(stderr, exported+failed+1, total, dirPrefix+s.result.Filename, s.result.Size, s.result.Cloud)
if s.err != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, s.err)
failed++
continue
}
exported++
}
wg.Wait()
return exported, failed
}
func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) { func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) {
if originals { if originals {
return bridge.ExportOriginal(a.ID, outDir, index) return bridge.ExportOriginal(a.ID, outDir, index)
@@ -428,15 +385,62 @@ func flagValWithDefault(args []string, name, def string) string {
return def return def
} }
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) { func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string, avgSpeed float64, isErr bool) {
pct := 0 pct := 0
if total > 0 { if total > 0 {
pct = current * 100 / total pct = current * 100 / total
} }
barWidth := 30 barWidth := 25
filled := pct * barWidth / 100 filled := pct * barWidth / 100
bar := strings.Repeat("=", filled) + strings.Repeat(" ", barWidth-filled) partial := (pct * barWidth % 100) * len(blockPartial) / 100
fmt.Fprintf(w, "\r[%s] %3d%% %s %s %s", bar, pct, filename, formatSize(size), cloud) 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 { func formatSize(bytes int64) string {
@@ -448,7 +452,10 @@ func formatSize(bytes int64) string {
if bytes >= mb { if bytes >= mb {
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
} }
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) if bytes >= kb {
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
}
return fmt.Sprintf("%d B", bytes)
} }
func exportMode(originals bool) string { func exportMode(originals bool) string {
+1
View File
@@ -50,6 +50,7 @@ func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.Expo
return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil
} }
func (m *mockBridge) Cancel() { m.cancelled = true } func (m *mockBridge) Cancel() { m.cancelled = true }
func (m *mockBridge) IsCancelled() bool { return m.cancelled }
func runWith(args []string, b photos.Bridge) (string, string, int) { func runWith(args []string, b photos.Bridge) (string, string, int) {
var out, err bytes.Buffer var out, err bytes.Buffer
+1
View File
@@ -13,6 +13,7 @@ type Bridge interface {
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
Cancel() Cancel()
IsCancelled() bool
} }
func ParseAlbumsJSON(jsonStr string) ([]Album, error) { func ParseAlbumsJSON(jsonStr string) ([]Album, error) {
+4
View File
@@ -58,6 +58,10 @@ func (*CgoBridge) Cancel() {
C.photos_request_cancel() C.photos_request_cancel()
} }
func (*CgoBridge) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))
+4
View File
@@ -78,6 +78,10 @@ func (*CgoBridge) Cancel() {
C.photos_request_cancel() C.photos_request_cancel()
} }
func (*CgoBridge) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))