diff --git a/Makefile b/Makefile index 041138b..f4d7755 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.2.4 +VERSION := 0.2.5 BRIDGE_DIR := bridge LDFLAGS := -X main.version=$(VERSION) OBJ := $(BRIDGE_DIR)/photokit_bridge.o diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index a4ed8c9..f96d781 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -4,7 +4,7 @@ import ( "fmt" "io" "strings" - "sync" + "time" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) @@ -294,26 +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) { - 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 failed := 0 + var totalBytes int64 + var totalDur time.Duration for i, a := range assets { if bridge.IsCancelled() { break } + start := time.Now() 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 { fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr) failed++ @@ -324,60 +324,6 @@ func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, or 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 { - if bridge.IsCancelled() { - slots[i].err = fmt.Errorf("cancelled") - close(slots[i].done) - continue - } - 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) { if originals { return bridge.ExportOriginal(a.ID, outDir, index) @@ -439,15 +385,62 @@ func flagValWithDefault(args []string, name, def string) string { 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 if total > 0 { pct = current * 100 / total } - barWidth := 30 + barWidth := 25 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) + 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 { @@ -459,7 +452,10 @@ func formatSize(bytes int64) string { if bytes >= 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 {