Compare commits

4 Commits

Author SHA1 Message Date
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
Ein Anderssono 27ff1b5c83 v0.2.1: add status messages, fix parallel export progress
- mustAuth: print 'requesting photo library access...' / 'access granted'
- cmdBackupAll: print 'loading photo library tree...' / 'found N albums'
- cmdExport: print 'loading assets...' / 'exporting N assets (mode)...'
- backupTree: print 'album: Name (N assets)' per album
- exportAssetsParallel: slot-based progress shows each asset as it completes
  instead of waiting for all to finish
- Fix data race: use jobs channel instead of shared range iteration
2026-06-11 21:18:34 +02:00
9 changed files with 92 additions and 40 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.0 VERSION := 0.2.4
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;
} }
+51 -35
View File
@@ -67,10 +67,12 @@ Flags:
} }
func mustAuth(stderr io.Writer, bridge photos.Bridge) int { func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
fmt.Fprintln(stderr, "requesting photo library access...")
if err := bridge.RequestAccess(); err != nil { if err := bridge.RequestAccess(); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return 1 return 1
} }
fmt.Fprintln(stderr, "access granted")
return 0 return 0
} }
@@ -78,6 +80,7 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
if rc := mustAuth(stderr, bridge); rc != 0 { if rc := mustAuth(stderr, bridge); rc != 0 {
return rc return rc
} }
fmt.Fprintln(stderr, "loading albums...")
albums, err := bridge.ListAlbums() albums, err := bridge.ListAlbums()
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
@@ -179,12 +182,14 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
} }
} }
fmt.Fprintf(stderr, "loading assets for album %s...\n", albumID)
assets, total, err := bridge.ListAssets(resolved) assets, total, err := bridge.ListAssets(resolved)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return 1 return 1
} }
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "") exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
if exported == 0 && failed > 0 { if exported == 0 && failed > 0 {
@@ -226,12 +231,14 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
} }
} }
fmt.Fprintln(stderr, "loading photo library tree...")
nodes, err := bridge.ListTree() nodes, err := bridge.ListTree()
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return 1 return 1
} }
albumCount := countAlbums(nodes) albumCount := countAlbums(nodes)
fmt.Fprintf(stderr, "found %d albums, exporting to %s...\n", albumCount, outDir)
totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge) totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
if err != nil { if err != nil {
@@ -256,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)
@@ -273,6 +283,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err) fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
continue continue
} }
fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal)
total += assetTotal total += assetTotal
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/") n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
exported += n exported += n
@@ -282,17 +293,6 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
return exported, total, failed, nil return exported, total, failed, nil
} }
type exportJob struct {
asset photos.Asset
index int
}
type exportResult struct {
index int
result photos.ExportResult
err error
}
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 { if len(assets) == 0 {
return 0, 0 return 0, 0
@@ -309,6 +309,9 @@ func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, or
exported := 0 exported := 0
failed := 0 failed := 0
for i, a := range assets { for i, a := range assets {
if bridge.IsCancelled() {
break
}
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) progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud)
if exportErr != nil { if exportErr != nil {
@@ -322,50 +325,56 @@ func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, or
} }
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) { 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) {
jobs := make(chan exportJob, len(assets)) type slot struct {
results := make(chan exportResult, len(assets)) 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 var wg sync.WaitGroup
for w := 0; w < workers; w++ { for w := 0; w < workers; w++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for job := range jobs { for i := range jobs {
result, exportErr := exportOne(bridge, job.asset, outDir, targetSize, originals, job.index) if bridge.IsCancelled() {
results <- exportResult{index: job.index, result: result, err: exportErr} 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)
} }
}() }()
} }
go func() { for i := range assets {
for i, a := range assets { jobs <- i
jobs <- exportJob{asset: a, index: i}
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
ordered := make([]exportResult, len(assets))
for r := range results {
ordered[r.index] = r
} }
close(jobs)
exported := 0 exported := 0
failed := 0 failed := 0
for i, a := range assets { for i, a := range assets {
r := ordered[i] <-slots[i].done
progressBar(stderr, exported+failed+1, total, dirPrefix+r.result.Filename, r.result.Size, r.result.Cloud) s := slots[i]
if r.err != nil { progressBar(stderr, exported+failed+1, total, dirPrefix+s.result.Filename, s.result.Size, s.result.Cloud)
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, r.err) if s.err != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, s.err)
failed++ failed++
continue continue
} }
exported++ exported++
} }
wg.Wait()
return exported, failed return exported, failed
} }
@@ -452,3 +461,10 @@ func formatSize(bytes int64) string {
} }
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
} }
func exportMode(originals bool) string {
if originals {
return "originals"
}
return "previews"
}
+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))