Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e888f7cad1 | |||
| 479c284dfc |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user