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
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.2.1
VERSION := 0.2.5
BRIDGE_DIR := bridge
LDFLAGS := -X main.version=$(VERSION)
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
+2
View File
@@ -28,6 +28,8 @@ char *photos_list_tree_json(void);
void photos_request_cancel(void);
int photos_request_is_cancelled(void);
void photos_free_string(char *value);
#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) {
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, seconds * NSEC_PER_SEC);
return dispatch_semaphore_wait(sem, timeout) == 0;
int64_t deadline = (int64_t)[NSDate timeIntervalSinceReferenceDate] + seconds;
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) {
@@ -267,6 +274,10 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
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];
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;
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;
@@ -338,6 +350,10 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
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];
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) {
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];
@@ -399,3 +415,7 @@ void photos_free_string(char *value) {
void photos_request_cancel(void) {
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;
}
int photos_request_is_cancelled(void) {
return stub_cancelled;
}
void photos_test_set_export_preview_json(const char *json) {
stub_export_preview_json = json;
}
+75 -68
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"io"
"strings"
"sync"
"time"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
)
@@ -263,6 +263,9 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
total := 0
failed := 0
for _, node := range nodes {
if bridge.IsCancelled() {
break
}
path := outDir + "/" + sanitizePathComponent(node.Name)
if node.Kind == "folder" {
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) {
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++
@@ -318,55 +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 {
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)
@@ -428,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 {
@@ -448,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 {
+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
}
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) {
var out, err bytes.Buffer
+1
View File
@@ -13,6 +13,7 @@ type Bridge interface {
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
Cancel()
IsCancelled() bool
}
func ParseAlbumsJSON(jsonStr string) ([]Album, error) {
+4
View File
@@ -58,6 +58,10 @@ func (*CgoBridge) 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) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
+4
View File
@@ -78,6 +78,10 @@ func (*CgoBridge) 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) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))