Compare commits

4 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
Ein Anderssono 009c71e6bb v0.2.3: fix export write failures and Ctrl+C cancellation
- Add ensure_directory calls in both export functions (preview + original)
- Include NSError.localizedDescription in write failed messages
- Make semaphore_wait_with_timeout poll photos_cancelled every ~1s
- Add status messages: auth, loading tree, per-album progress
- Fix parallel export: slot-based progress shows results in order
2026-06-11 21:37:11 +02:00
Ein Anderssono b2d4c6188d fix: make Ctrl+C cancel ObjC semaphore waits within ~1s
semaphore_wait_with_timeout now polls photos_cancelled every second
instead of blocking for the full timeout duration
2026-06-11 21:24:03 +02:00
9 changed files with 116 additions and 73 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.1 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
+24 -4
View File
@@ -10,8 +10,15 @@ static NSDictionary *make_error_dict(NSString *message) {
} }
static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) { static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) {
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, seconds * NSEC_PER_SEC); int64_t deadline = (int64_t)[NSDate timeIntervalSinceReferenceDate] + seconds;
return dispatch_semaphore_wait(sem, timeout) == 0; while (1) {
if (photos_cancelled) return NO;
int64_t remaining = deadline - (int64_t)[NSDate timeIntervalSinceReferenceDate];
if (remaining <= 0) return NO;
int64_t waitSecs = remaining < 1 ? remaining : 1;
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, waitSecs * NSEC_PER_SEC);
if (dispatch_semaphore_wait(sem, timeout) == 0) return YES;
}
} }
static NSDictionary *collection_to_dict(PHCollection *collection) { static NSDictionary *collection_to_dict(PHCollection *collection) {
@@ -267,6 +274,10 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments")); if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
if (!ensure_directory(nsOutputDir)) {
return json_from_object(make_error_dict(@"failed to create output directory"));
}
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
@@ -315,7 +326,8 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
NSError *writeErr = nil; NSError *writeErr = nil;
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error";
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)});
} }
NSNumber *fileSize = nil; NSNumber *fileSize = nil;
@@ -338,6 +350,10 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments")); if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
if (!ensure_directory(nsOutputDir)) {
return json_from_object(make_error_dict(@"failed to create output directory"));
}
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
@@ -375,7 +391,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
} }
if (writeErr) { if (writeErr) {
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)});
} }
NSString *writtenFilename = [filepath lastPathComponent]; NSString *writtenFilename = [filepath lastPathComponent];
@@ -399,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))