diff --git a/Makefile b/Makefile index 49289a6..10a42c9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.1.0 +VERSION := 0.2.0 BRIDGE_DIR := bridge LDFLAGS := -X main.version=$(VERSION) OBJ := $(BRIDGE_DIR)/photokit_bridge.o @@ -30,7 +30,8 @@ build: $(LIB) go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(MODULE)/cmd/photoscli test: $(STUB_LIB) - go test -tags=test -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... + go vet -tags=test ./... + go test -tags=test -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... @grep -v 'main_main.go' coverage.out > coverage_filtered.out 2>/dev/null || true @mv coverage_filtered.out coverage.out 2>/dev/null || true @go tool cover -func=coverage.out | tail -1 diff --git a/bridge/photokit_bridge.h b/bridge/photokit_bridge.h index 0109464..618bff3 100644 --- a/bridge/photokit_bridge.h +++ b/bridge/photokit_bridge.h @@ -26,23 +26,6 @@ char *photos_export_original_json( char *photos_list_tree_json(void); -int photos_export_album_previews( - const char *album_id, - const char *output_dir, - int target_size -); - -int photos_export_album_originals( - const char *album_id, - const char *output_dir -); - -int photos_backup_all( - const char *output_dir, - int target_size, - int originals -); - void photos_request_cancel(void); void photos_free_string(char *value); diff --git a/bridge/photokit_bridge.m b/bridge/photokit_bridge.m index cdb3a92..d2a1efb 100644 --- a/bridge/photokit_bridge.m +++ b/bridge/photokit_bridge.m @@ -9,6 +9,11 @@ static NSDictionary *make_error_dict(NSString *message) { return @{@"error": 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; +} + static NSDictionary *collection_to_dict(PHCollection *collection) { NSString *name = collection.localizedTitle ?: @"Untitled"; NSString *identifier = collection.localIdentifier ?: @""; @@ -80,8 +85,12 @@ static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) { static BOOL ensure_directory(NSString *outputDir) { NSFileManager *fm = [NSFileManager defaultManager]; NSError *dirErr = nil; - return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES + BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES attributes:nil error:&dirErr]; + if (!ok && dirErr) { + NSLog(@"ensure_directory failed: %@", dirErr); + } + return ok; } static PHFetchResult *fetch_assets_for_collection(PHAssetCollection *album) { @@ -91,17 +100,6 @@ static PHFetchResult *fetch_assets_for_collection(PHAssetCollection * return [PHAsset fetchAssetsInAssetCollection:album options:opts]; } -static PHFetchResult *fetch_assets_for_album(const char *album_id) { - if (!album_id) return nil; - - NSString *nsAlbumId = [NSString stringWithUTF8String:album_id]; - PHFetchResult *albums = - [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId] - options:nil]; - if (albums.count == 0) return nil; - - return fetch_assets_for_collection(albums.firstObject); -} static NSString *sanitized_asset_identifier(NSString *assetID) { NSMutableString *safe = [assetID mutableCopy]; @@ -132,6 +130,7 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam return [outputDir stringByAppendingPathComponent:prefixed]; } +// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go static NSString *sanitized_path_component(NSString *name) { NSString *source = name ?: @"Untitled"; NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy]; @@ -150,146 +149,6 @@ static NSString *sanitized_path_component(NSString *name) { return safe; } -static int export_preview_assets(PHFetchResult *assets, NSString *outputDir, int target_size) { - PHImageManager *im = [PHImageManager defaultManager]; - CGFloat scale = (CGFloat)target_size; - CGSize targetCGSize = CGSizeMake(scale, scale); - PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init]; - imgOpts.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; - imgOpts.resizeMode = PHImageRequestOptionsResizeModeExact; - imgOpts.networkAccessAllowed = YES; - imgOpts.synchronous = YES; - - __block int exported = 0; - __block int failed = 0; - - for (NSUInteger i = 0; i < assets.count; i++) { - if (photos_cancelled) break; - PHAsset *asset = assets[i]; - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - __block NSData *imageData = nil; - - [im requestImageForAsset:asset - targetSize:targetCGSize - contentMode:PHImageContentModeAspectFit - options:imgOpts - resultHandler:^(NSImage *result, NSDictionary *info) { - if (info[PHImageErrorKey]) { - dispatch_semaphore_signal(sem); - return; - } - if (result) { - imageData = nsimage_to_jpeg(result, 0.85); - } - dispatch_semaphore_signal(sem); - }]; - - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); - - if (!imageData) { - failed++; - continue; - } - - NSString *safe = sanitized_asset_identifier(asset.localIdentifier); - NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)i, safe]; - NSString *filepath = [outputDir stringByAppendingPathComponent:filename]; - - NSError *writeErr = nil; - if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { - failed++; - continue; - } - - exported++; - } - - return (failed > 0 && exported == 0) ? -4 : exported; -} - -static int export_original_assets(PHFetchResult *assets, NSString *outputDir) { - PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; - PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; - opts.networkAccessAllowed = YES; - - __block int exported = 0; - __block int failed = 0; - - for (NSUInteger i = 0; i < assets.count; i++) { - if (photos_cancelled) break; - PHAsset *asset = assets[i]; - NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; - if (resources.count == 0) { - failed++; - continue; - } - - PHAssetResource *resource = resources.firstObject; - NSString *filename = resource.originalFilename; - if (!filename || filename.length == 0) { - filename = sanitized_asset_identifier(asset.localIdentifier); - } - - NSString *filepath = unique_path_for_filename(outputDir, filename, i); - NSURL *fileURL = [NSURL fileURLWithPath:filepath]; - - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - __block NSError *writeErr = nil; - [rm writeDataForAssetResource:resource - toFile:fileURL - options:opts - completionHandler:^(NSError * _Nullable error) { - writeErr = error; - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); - - if (writeErr) { - failed++; - continue; - } - exported++; - } - - return (failed > 0 && exported == 0) ? -4 : exported; -} - -static int backup_collection(PHCollection *collection, NSString *outputDir, int target_size, BOOL originals) { - NSString *path = [outputDir stringByAppendingPathComponent:sanitized_path_component(collection.localizedTitle)]; - - if ([collection isKindOfClass:[PHCollectionList class]]) { - PHCollectionList *list = (PHCollectionList *)collection; - PHFetchResult *children = [PHCollectionList fetchCollectionsInCollectionList:list options:nil]; - __block int total = 0; - __block BOOL failedAny = NO; - for (NSUInteger i = 0; i < children.count; i++) { - if (photos_cancelled) break; - int rc = backup_collection(children[i], path, target_size, originals); - if (rc >= 0) { - total += rc; - } else if (rc == -4) { - failedAny = YES; - } else { - return rc; - } - } - if (failedAny && total == 0) { - return -4; - } - return total; - } - - if ([collection isKindOfClass:[PHAssetCollection class]]) { - if (!ensure_directory(path)) { - return -2; - } - PHFetchResult *assets = fetch_assets_for_collection((PHAssetCollection *)collection); - return originals ? export_original_assets(assets, path) : export_preview_assets(assets, path, target_size); - } - - return 0; -} - int photos_request_access(void) { __block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; if (status == PHAuthorizationStatusNotDetermined) { @@ -298,7 +157,9 @@ int photos_request_access(void) { status = s; dispatch_semaphore_signal(sem); }]; - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + if (!semaphore_wait_with_timeout(sem, 30)) { + return -1; + } } return (status == PHAuthorizationStatusAuthorized) ? 0 : -1; } @@ -350,6 +211,7 @@ char *photos_list_assets_json(const char *album_id) { if (!album_id) return json_from_object(make_error_dict(@"album_id is required")); NSString *nsAlbumId = [NSString stringWithUTF8String:album_id]; + if (!nsAlbumId) return json_from_object(make_error_dict(@"invalid UTF-8 in album_id")); PHFetchResult *albums = [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId] options:nil]; @@ -396,66 +258,6 @@ char *photos_list_tree_json(void) { return json_from_object(@{@"collections": list}); } -int photos_export_album_previews(const char *album_id, const char *output_dir, int target_size) { - if (!album_id || !output_dir) return -1; - if (target_size <= 0) target_size = 1024; - - NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; - - if (!ensure_directory(nsOutputDir)) { - return -2; - } - - PHFetchResult *assets = fetch_assets_for_album(album_id); - if (!assets) return -3; - - return export_preview_assets(assets, nsOutputDir, target_size); -} - -int photos_export_album_originals(const char *album_id, const char *output_dir) { - if (!album_id || !output_dir) return -1; - - NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; - if (!ensure_directory(nsOutputDir)) { - return -2; - } - - PHFetchResult *assets = fetch_assets_for_album(album_id); - if (!assets) return -3; - - return export_original_assets(assets, nsOutputDir); -} - -int photos_backup_all(const char *output_dir, int target_size, int originals) { - if (!output_dir) return -1; - if (!originals && target_size <= 0) target_size = 1024; - - NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; - if (!ensure_directory(nsOutputDir)) { - return -2; - } - - PHFetchResult *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil]; - __block int total = 0; - __block BOOL failedAny = NO; - - for (NSUInteger i = 0; i < collections.count; i++) { - if (photos_cancelled) break; - int rc = backup_collection(collections[i], nsOutputDir, target_size, originals != 0); - if (rc >= 0) { - total += rc; - } else if (rc == -4) { - failedAny = YES; - } else { - return rc; - } - } - - if (failedAny && total == 0) { - return -4; - } - return total; -} char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index) { if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required")); @@ -463,6 +265,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments")); PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); @@ -498,7 +301,9 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i dispatch_semaphore_signal(sem); }]; - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + if (!semaphore_wait_with_timeout(sem, 120)) { + return json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)}); + } if (!imageData) { return json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)}); @@ -531,6 +336,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments")); PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); @@ -564,7 +370,9 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, writeErr = error; dispatch_semaphore_signal(sem); }]; - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + if (!semaphore_wait_with_timeout(sem, 120)) { + return json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)}); + } if (writeErr) { return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); diff --git a/bridge/photokit_bridge_stub.c b/bridge/photokit_bridge_stub.c index e1fffcd..3620f5e 100644 --- a/bridge/photokit_bridge_stub.c +++ b/bridge/photokit_bridge_stub.c @@ -13,9 +13,6 @@ static int stub_access_rc = 0; static const char *stub_albums_json = "{\"albums\":[]}"; static const char *stub_assets_json = "{\"assets\":[]}"; static const char *stub_tree_json = "{\"collections\":[]}"; -static int stub_export_rc = 0; -static int stub_export_originals_rc = 0; -static int stub_backup_all_rc = 0; static int stub_albums_null = 0; static int stub_assets_null = 0; static int stub_tree_null = 0; @@ -32,9 +29,6 @@ void photos_test_set_access(int rc) { stub_access_rc = rc; } void photos_test_set_albums(const char *json) { stub_albums_json = json; stub_albums_null = 0; } void photos_test_set_assets(const char *json) { stub_assets_json = json; stub_assets_null = 0; } void photos_test_set_tree(const char *json) { stub_tree_json = json; stub_tree_null = 0; } -void photos_test_set_export_rc(int rc) { stub_export_rc = rc; } -void photos_test_set_export_originals_rc(int rc) { stub_export_originals_rc = rc; } -void photos_test_set_backup_all_rc(int rc) { stub_backup_all_rc = rc; } void photos_test_set_albums_null(void) { stub_albums_null = 1; } void photos_test_set_assets_null(void) { stub_assets_null = 1; } void photos_test_set_tree_null(void) { stub_tree_null = 1; } @@ -72,26 +66,6 @@ char *photos_list_tree_json(void) { return alloc_json(stub_tree_json); } -int photos_export_album_previews(const char *album_id, const char *output_dir, int target_size) { - (void)album_id; - (void)output_dir; - (void)target_size; - return stub_export_rc; -} - -int photos_export_album_originals(const char *album_id, const char *output_dir) { - (void)album_id; - (void)output_dir; - return stub_export_originals_rc; -} - -int photos_backup_all(const char *output_dir, int target_size, int originals) { - (void)output_dir; - (void)target_size; - (void)originals; - return stub_backup_all_rc; -} - void photos_free_string(char *value) { if (value) free(value); } @@ -106,4 +80,4 @@ void photos_test_set_export_preview_json(const char *json) { void photos_test_set_export_original_json(const char *json) { stub_export_original_json = json; -} +} \ No newline at end of file diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index 07ef624..e3bcb5b 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "sync" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) @@ -89,20 +90,20 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int { } func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) { - _, _, err := bridge.ListAssets(idOrName) - if err == nil { - return idOrName, nil - } albums, listErr := bridge.ListAlbums() if listErr != nil { - return idOrName, err + return idOrName, listErr } for _, a := range albums { if a.Title == idOrName { return a.ID, nil } } - return idOrName, err + _, _, err := bridge.ListAssets(idOrName) + if err == nil { + return idOrName, nil + } + return idOrName, fmt.Errorf("album not found: %s", idOrName) } func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { @@ -125,11 +126,7 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in return 1 } for _, a := range assets { - cloud := "local" - if a.Cloud { - cloud = "cloud" - } - fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, cloud) + fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, a.Cloud) } return 0 } @@ -188,25 +185,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in return 1 } - exported := 0 - failed := 0 - for i, a := range assets { - var result photos.ExportResult - var exportErr error - if originals { - result, exportErr = bridge.ExportOriginal(a.ID, outDir, i) - } else { - result, exportErr = bridge.ExportPreview(a.ID, outDir, size, i) - } - - progressBar(stderr, exported+failed+1, total, result.Filename, result.Size, result.Cloud) - - if exportErr != nil { - failed++ - continue - } - exported++ - } + exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "") if exported == 0 && failed > 0 { fmt.Fprintf(stderr, "\nerror: all exports failed\n") @@ -214,10 +193,14 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in } if originals { - fmt.Fprintf(stderr, "\nexported %d original files to %s\n", exported, outDir) + fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir) } else { - fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir) + fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir) } + if failed > 0 { + fmt.Fprintf(stderr, " (%d failed)", failed) + } + fmt.Fprintln(stderr) return 0 } @@ -250,60 +233,150 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) } albumCount := countAlbums(nodes) - totalAssets, grandTotal, err := backupTree(nodes, outDir, size, originals, stderr, bridge) + totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } if originals { - fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s\n", totalAssets, albumCount, outDir) + fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir) } else { - fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir) + fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir) } - _ = grandTotal + if failed > 0 { + fmt.Fprintf(stderr, " (%d failed)", failed) + } + fmt.Fprintln(stderr) return 0 } -func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) { +func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, int, error) { exported := 0 total := 0 + failed := 0 for _, node := range nodes { path := outDir + "/" + sanitizePathComponent(node.Name) if node.Kind == "folder" { - n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) + n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) if err != nil { - return exported, total, err + return exported, total, failed, err } exported += n total += t + failed += f continue } if node.Kind == "album" && node.ID != "" { assets, assetTotal, err := bridge.ListAssets(node.ID) if err != nil { + fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err) continue } total += assetTotal - for i, a := range assets { - var result photos.ExportResult - var exportErr error - if originals { - result, exportErr = bridge.ExportOriginal(a.ID, path, i) - } else { - result, exportErr = bridge.ExportPreview(a.ID, path, targetSize, i) - } - progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud) - if exportErr != nil { - continue - } - exported++ - } + n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/") + exported += n + failed += f } } - return exported, total, 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) { + 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 + for i, a := range assets { + result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i) + progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud) + if exportErr != nil { + fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr) + failed++ + continue + } + exported++ + } + 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) { + jobs := make(chan exportJob, len(assets)) + results := make(chan exportResult, len(assets)) + + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + result, exportErr := exportOne(bridge, job.asset, outDir, targetSize, originals, job.index) + results <- exportResult{index: job.index, result: result, err: exportErr} + } + }() + } + + go func() { + for i, a := range assets { + 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 + } + + exported := 0 + failed := 0 + for i, a := range assets { + r := ordered[i] + progressBar(stderr, exported+failed+1, total, dirPrefix+r.result.Filename, r.result.Size, r.result.Cloud) + if r.err != nil { + fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, r.err) + failed++ + continue + } + exported++ + } + 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) + } + return bridge.ExportPreview(a.ID, outDir, targetSize, index) +} + +// Must stay in sync with sanitized_path_component in bridge/photokit_bridge.m func sanitizePathComponent(name string) string { s := strings.TrimSpace(name) if s == "" { diff --git a/cmd/photoscli/main_main.go b/cmd/photoscli/main_main.go index 283f162..4085d41 100644 --- a/cmd/photoscli/main_main.go +++ b/cmd/photoscli/main_main.go @@ -3,6 +3,7 @@ package main import ( "os" "os/signal" + "sync/atomic" "syscall" "gitea.k3s.k0.nu/tools/photocli/internal/photos" @@ -15,21 +16,20 @@ func main() { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) done := make(chan struct{}) - var rc int + var rc atomic.Int32 go func() { - rc = run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge) + rc.Store(int32(run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge))) close(done) }() select { case <-done: - case sig := <-sigCh: + case <-sigCh: photos.DefaultBridge.Cancel() os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n")) <-done - _ = sig } - os.Exit(rc) + os.Exit(int(rc.Load())) } \ No newline at end of file diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index 7c1d809..36937e2 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -20,18 +20,9 @@ type mockBridge struct { assetsByAlbum map[string][]photos.Asset tree []photos.CollectionNode treeErr error - exportN int - exportErr error - exportOrigN int - exportOrigErr error - exportPreviewFn func(string, string, int) (int, error) - exportOrigFn func(string, string) (int, error) - backupAllN int - backupAllErr error - backupAllFn func(string, int, bool) (int, error) + exportPreviewFn func(string, string, int, int) (photos.ExportResult, error) + exportOrigFn func(string, string, int) (photos.ExportResult, error) cancelled bool - exportPreviewFn2 func(string, string, int, int) (photos.ExportResult, error) - exportOrigFn2 func(string, string, int) (photos.ExportResult, error) } func (m *mockBridge) RequestAccess() error { return m.accessErr } @@ -46,36 +37,18 @@ func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) { return m.assets, len(m.assets), m.assetsErr } func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr } -func (m *mockBridge) ExportAlbumPreviews(albumID, out string, size int) (int, error) { - if m.exportPreviewFn != nil { - return m.exportPreviewFn(albumID, out, size) - } - return m.exportN, m.exportErr -} -func (m *mockBridge) ExportAlbumOriginals(albumID, out string) (int, error) { - if m.exportOrigFn != nil { - return m.exportOrigFn(albumID, out) - } - return m.exportOrigN, m.exportOrigErr -} func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) { - if m.exportPreviewFn2 != nil { - return m.exportPreviewFn2(assetID, out, targetSize, index) + if m.exportPreviewFn != nil { + return m.exportPreviewFn(assetID, out, targetSize, index) } return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil } func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.ExportResult, error) { - if m.exportOrigFn2 != nil { - return m.exportOrigFn2(assetID, out, index) + if m.exportOrigFn != nil { + return m.exportOrigFn(assetID, out, index) } return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil } -func (m *mockBridge) BackupAll(out string, size int, originals bool) (int, error) { - if m.backupAllFn != nil { - return m.backupAllFn(out, size, originals) - } - return m.backupAllN, m.backupAllErr -} func (m *mockBridge) Cancel() { m.cancelled = true } func runWith(args []string, b photos.Bridge) (string, string, int) { @@ -187,7 +160,7 @@ func TestCmdPhotosMissingAlbumID(t *testing.T) { func TestCmdPhotosSuccess(t *testing.T) { b := &mockBridge{ - assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: true}}, + assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud"}}, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { @@ -211,7 +184,7 @@ func TestCmdPhotosAuthDenied(t *testing.T) { } func TestCmdPhotosBridgeError(t *testing.T) { - b := &mockBridge{assetsErr: fmt.Errorf("fail")} + b := &mockBridge{albumsErr: fmt.Errorf("fail")} _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 1 { t.Errorf("rc = %d", rc) @@ -273,6 +246,9 @@ func TestCmdBackupAllPreviewSuccess(t *testing.T) { if !strings.Contains(stderr, "exported 2 preview files across 2 albums to /backup") { t.Errorf("stderr = %q", stderr) } + if strings.Contains(stderr, "failed") { + t.Errorf("unexpected failed count in stderr = %q", stderr) + } } func TestCmdBackupAllOriginalsSuccess(t *testing.T) { @@ -289,6 +265,9 @@ func TestCmdBackupAllOriginalsSuccess(t *testing.T) { if !strings.Contains(stderr, "exported 3 original files across 1 albums to /backup") { t.Errorf("stderr = %q", stderr) } + if strings.Contains(stderr, "failed") { + t.Errorf("unexpected failed count in stderr = %q", stderr) + } } func TestCmdBackupAllMissingOutDir(t *testing.T) { @@ -329,7 +308,7 @@ func TestCmdBackupAllExportError(t *testing.T) { assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "as1", Filename: "img.jpg"}}, }, - exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) { + exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } @@ -337,7 +316,12 @@ func TestCmdBackupAllExportError(t *testing.T) { if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } - _ = stderr + if !strings.Contains(stderr, "failed: img.jpg: disk full") { + t.Errorf("stderr should contain failure detail, got: %q", stderr) + } + if !strings.Contains(stderr, "(1 failed)") { + t.Errorf("stderr should contain failed count, got: %q", stderr) + } } func TestCmdExportMissingAlbumID(t *testing.T) { @@ -420,7 +404,7 @@ func TestCmdExportAuthDenied(t *testing.T) { func TestCmdExportBridgeError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, - exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) { + exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } @@ -431,6 +415,9 @@ func TestCmdExportBridgeError(t *testing.T) { if !strings.Contains(stderr, "all exports failed") { t.Errorf("stderr = %q", stderr) } + if !strings.Contains(stderr, "failed: img.jpg: disk full") { + t.Errorf("stderr should contain failure detail, got: %q", stderr) + } } func TestCmdExportOriginalsSuccess(t *testing.T) { @@ -462,7 +449,7 @@ func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) { func TestCmdExportOriginalsBridgeError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, - exportOrigFn2: func(string, string, int) (photos.ExportResult, error) { + exportOrigFn: func(string, string, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("copy failed") }, } @@ -473,6 +460,9 @@ func TestCmdExportOriginalsBridgeError(t *testing.T) { if !strings.Contains(stderr, "all exports failed") { t.Errorf("stderr = %q", stderr) } + if !strings.Contains(stderr, "failed: img.jpg: copy failed") { + t.Errorf("stderr should contain failure detail, got: %q", stderr) + } } func TestFlagVal(t *testing.T) { @@ -527,7 +517,7 @@ func TestFlagValEmptyArgs(t *testing.T) { func TestResolveAlbumIDDirectMatch(t *testing.T) { b := &mockBridge{ assetsByAlbum: map[string][]photos.Asset{ - "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}}, + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}}, }, } id, err := resolveAlbumID(b, "ABC/L0/001") @@ -546,7 +536,7 @@ func TestResolveAlbumIDByName(t *testing.T) { {ID: "DEF/L0/001", Title: "Work"}, }, assetsByAlbum: map[string][]photos.Asset{ - "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}}, + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}}, }, } id, err := resolveAlbumID(b, "DnD") @@ -588,7 +578,7 @@ func TestCmdPhotosResolvesAlbumName(t *testing.T) { {ID: "ABC/L0/001", Title: "DnD"}, }, assetsByAlbum: map[string][]photos.Asset{ - "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}}, + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}}, }, } out, stderr, rc := runWith([]string{"photos", "--album-id", "DnD"}, b) @@ -635,3 +625,61 @@ func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) { t.Errorf("stderr = %q", stderr) } } + +func TestCmdExportPartialFailure(t *testing.T) { + call := 0 + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "ok.jpg", Cloud: "local"}, {ID: "x2", Filename: "bad.jpg", Cloud: "local"}, {ID: "x3", Filename: "ok2.jpg", Cloud: "local"}}, + }, + exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { + call++ + if call == 2 { + return photos.ExportResult{}, fmt.Errorf("disk full") + } + return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", "/tmp"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "failed: bad.jpg: disk full") { + t.Errorf("stderr should contain failure detail, got: %q", stderr) + } + if !strings.Contains(stderr, "(1 failed)") { + t.Errorf("stderr should contain failed count, got: %q", stderr) + } + if !strings.Contains(stderr, "exported 2 photos") { + t.Errorf("stderr should contain export count, got: %q", stderr) + } +} + +func TestCmdBackupAllSkippedAlbum(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "bad-album", Name: "Broken", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{}, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "skipped album Broken") { + t.Errorf("stderr should contain skipped album, got: %q", stderr) + } +} + +func TestResolveAlbumIDNotFoundMessage(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Other"}}, + assetsByAlbum: map[string][]photos.Asset{}, + } + _, err := resolveAlbumID(b, "Nonexistent") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "album not found: Nonexistent") { + t.Errorf("err = %q", err.Error()) + } +} diff --git a/docs/adr/001-cloud-identifier.md b/docs/adr/001-cloud-identifier.md new file mode 100644 index 0000000..d9ceadd --- /dev/null +++ b/docs/adr/001-cloud-identifier.md @@ -0,0 +1,21 @@ +# ADR 001: Cloud Status Detection via performSelector + +## Status + +Accepted + +## Context + +We need to detect whether a photo asset is stored locally or in iCloud. Apple's PhotoKit does not expose `PHAsset.cloudIdentifier` as a public property on macOS. The `PHAsset` class has this property on iOS but it is undocumented on macOS. + +## Decision + +Use `performSelector:@selector(cloudIdentifier)` with `@try/@catch` to detect cloud status. If the selector returns a non-null value, the asset is in iCloud. If it throws an exception, fall back to checking `PHAssetResource.isLocallyAvailable` (also via `performSelector`). + +## Consequences + +- This accesses an undocumented Apple API. It may break in any macOS update without warning. +- The `@try/@catch` pattern prevents crashes if the selector is removed. +- If both checks fail or throw, we default to "local" — this may incorrectly report cloud-only assets as local. +- This approach could cause notarization issues if Apple enforces stricter private API checks in the future. +- No alternative public API exists on macOS for this purpose as of macOS 14. \ No newline at end of file diff --git a/internal/photos/bridge.go b/internal/photos/bridge.go index d26a500..b0bd809 100644 --- a/internal/photos/bridge.go +++ b/internal/photos/bridge.go @@ -10,11 +10,8 @@ type Bridge interface { ListAlbums() ([]Album, error) ListAssets(albumID string) ([]Asset, int, error) ListTree() ([]CollectionNode, error) - ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) - ExportAlbumOriginals(albumID, outputDir string) (int, error) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) - BackupAll(outputDir string, targetSize int, originals bool) (int, error) Cancel() } @@ -62,23 +59,3 @@ func ParseExportResultJSON(jsonStr string) (ExportResult, error) { } return resp.ExportResult, nil } - -func InterpretExportResult(rc int) (int, error) { - switch rc { - case -1: - return 0, fmt.Errorf("invalid arguments or directory creation failed") - case -2: - return 0, fmt.Errorf("could not create output directory") - case -3: - return 0, fmt.Errorf("album not found") - case -4: - return 0, fmt.Errorf("all exports failed") - case -5: - return 0, fmt.Errorf("cancelled") - default: - if rc < 0 { - return 0, fmt.Errorf("unknown error (code %d)", rc) - } - return rc, nil - } -} diff --git a/internal/photos/cgo_bridge.go b/internal/photos/cgo_bridge.go index 866269c..0cb486a 100644 --- a/internal/photos/cgo_bridge.go +++ b/internal/photos/cgo_bridge.go @@ -54,39 +54,6 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) { return ParseTreeJSON(C.GoString(cs)) } -func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) { - cid := C.CString(albumID) - defer C.free(unsafe.Pointer(cid)) - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - - rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize)) - return InterpretExportResult(int(rc)) -} - -func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) { - cid := C.CString(albumID) - defer C.free(unsafe.Pointer(cid)) - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - - rc := C.photos_export_album_originals(cid, cdir) - return InterpretExportResult(int(rc)) -} - -func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) { - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - - coriginals := 0 - if originals { - coriginals = 1 - } - - rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals)) - return InterpretExportResult(int(rc)) -} - func (*CgoBridge) Cancel() { C.photos_request_cancel() } diff --git a/internal/photos/cgo_bridge_test_impl.go b/internal/photos/cgo_bridge_test_impl.go index 3576507..72ff4d3 100644 --- a/internal/photos/cgo_bridge_test_impl.go +++ b/internal/photos/cgo_bridge_test_impl.go @@ -12,9 +12,6 @@ void photos_test_set_access(int rc); void photos_test_set_albums(const char *json); void photos_test_set_assets(const char *json); void photos_test_set_tree(const char *json); -void photos_test_set_export_rc(int rc); -void photos_test_set_export_originals_rc(int rc); -void photos_test_set_backup_all_rc(int rc); void photos_test_set_albums_null(void); void photos_test_set_assets_null(void); void photos_test_set_tree_null(void); @@ -34,9 +31,6 @@ func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) } func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) } func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) } func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) } -func SetTestExportRC(rc int) { C.photos_test_set_export_rc(C.int(rc)) } -func SetTestExportOriginalsRC(rc int) { C.photos_test_set_export_originals_rc(C.int(rc)) } -func SetTestBackupAllRC(rc int) { C.photos_test_set_backup_all_rc(C.int(rc)) } func SetTestAlbumsNull() { C.photos_test_set_albums_null() } func SetTestAssetsNull() { C.photos_test_set_assets_null() } func SetTestTreeNull() { C.photos_test_set_tree_null() } @@ -80,37 +74,6 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) { return ParseTreeJSON(C.GoString(cs)) } -func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) { - cid := C.CString(albumID) - defer C.free(unsafe.Pointer(cid)) - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize)) - return InterpretExportResult(int(rc)) -} - -func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) { - cid := C.CString(albumID) - defer C.free(unsafe.Pointer(cid)) - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - rc := C.photos_export_album_originals(cid, cdir) - return InterpretExportResult(int(rc)) -} - -func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) { - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - - coriginals := 0 - if originals { - coriginals = 1 - } - - rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals)) - return InterpretExportResult(int(rc)) -} - func (*CgoBridge) Cancel() { C.photos_request_cancel() } diff --git a/internal/photos/photos.go b/internal/photos/photos.go index 93f5887..eb2b313 100644 --- a/internal/photos/photos.go +++ b/internal/photos/photos.go @@ -1,36 +1,8 @@ +// Package photos provides a Go bridge to Apple's PhotoKit framework via CGo, +// enabling programmatic access to photos, albums, and export operations. package photos import "fmt" var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security") -var errBridgeNil = fmt.Errorf("bridge returned nil") - -func RequestAccess() error { return DefaultBridge.RequestAccess() } - -func ListAlbums() ([]Album, error) { return DefaultBridge.ListAlbums() } - -func ListAssets(albumID string) ([]Asset, int, error) { return DefaultBridge.ListAssets(albumID) } - -func ListTree() ([]CollectionNode, error) { return DefaultBridge.ListTree() } - -func ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) { - return DefaultBridge.ExportAlbumPreviews(albumID, outputDir, targetSize) -} - -func ExportAlbumOriginals(albumID, outputDir string) (int, error) { - return DefaultBridge.ExportAlbumOriginals(albumID, outputDir) -} - -func BackupAll(outputDir string, targetSize int, originals bool) (int, error) { - return DefaultBridge.BackupAll(outputDir, targetSize, originals) -} - -func Cancel() { DefaultBridge.Cancel() } - -func ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { - return DefaultBridge.ExportPreview(assetID, outputDir, targetSize, index) -} - -func ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { - return DefaultBridge.ExportOriginal(assetID, outputDir, index) -} +var errBridgeNil = fmt.Errorf("bridge returned nil") \ No newline at end of file diff --git a/internal/photos/photos_test.go b/internal/photos/photos_test.go index 6b08296..a0ac658 100644 --- a/internal/photos/photos_test.go +++ b/internal/photos/photos_test.go @@ -209,45 +209,6 @@ func TestParseTreeJSON(t *testing.T) { } } -func TestInterpretExportResult(t *testing.T) { - tests := []struct { - name string - rc int - wantN int - wantErr bool - errMsg string - }{ - {name: "success", rc: 5, wantN: 5}, - {name: "zero exports", rc: 0, wantN: 0}, - {name: "single export", rc: 1, wantN: 1}, - {name: "invalid args", rc: -1, wantErr: true, errMsg: "invalid arguments or directory creation failed"}, - {name: "mkdir failed", rc: -2, wantErr: true, errMsg: "could not create output directory"}, - {name: "album not found", rc: -3, wantErr: true, errMsg: "album not found"}, - {name: "all exports failed", rc: -4, wantErr: true, errMsg: "all exports failed"}, - {name: "cancelled", rc: -5, wantErr: true, errMsg: "cancelled"}, - {name: "unknown error", rc: -99, wantErr: true, errMsg: "unknown error (code -99)"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - n, err := InterpretExportResult(tt.rc) - if (err != nil) != tt.wantErr { - t.Errorf("InterpretExportResult() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err.Error() != tt.errMsg { - t.Errorf("InterpretExportResult() error = %q, want %q", err.Error(), tt.errMsg) - } - return - } - if n != tt.wantN { - t.Errorf("InterpretExportResult() = %d, want %d", n, tt.wantN) - } - }) - } -} - func TestAlbumsResponseUnmarshal(t *testing.T) { raw := `{"albums":[{"id":"x","title":"Y"}]}` var resp AlbumsResponse @@ -299,68 +260,6 @@ func TestCgoBridgeImplementsBridge(t *testing.T) { var _ Bridge = &CgoBridge{} } -func TestPackageLevelDelegatesToDefaultBridge(t *testing.T) { - SetTestAccessRC(0) - SetTestAlbumsJSON(`{"albums":[{"id":"p1","title":"PAlbum"}]}`) - SetTestAssetsJSON(`{"assets":[{"id":"pa1","filename":"IMG_1001.JPG"}],"total":1}`) - SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}`) - SetTestExportRC(3) - SetTestExportOriginalsRC(2) - SetTestBackupAllRC(5) - - if err := RequestAccess(); err != nil { - t.Fatal(err) - } - - albums, err := ListAlbums() - if err != nil { - t.Fatal(err) - } - if len(albums) != 1 || albums[0].ID != "p1" { - t.Errorf("got %+v", albums) - } - - assets, _, err := ListAssets("p1") - if err != nil { - t.Fatal(err) - } - if len(assets) != 1 || assets[0].ID != "pa1" || assets[0].Filename != "IMG_1001.JPG" { - t.Errorf("got %+v", assets) - } - - tree, err := ListTree() - if err != nil { - t.Fatal(err) - } - if len(tree) != 1 || tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "a1" || tree[0].Children[0].Name != "Venice" { - t.Errorf("got %+v", tree) - } - - n, err := ExportAlbumPreviews("p1", "/tmp/x", 1024) - if err != nil { - t.Fatal(err) - } - if n != 3 { - t.Errorf("got %d", n) - } - - n, err = ExportAlbumOriginals("p1", "/tmp/x") - if err != nil { - t.Fatal(err) - } - if n != 2 { - t.Errorf("got %d", n) - } - - n, err = BackupAll("/tmp/x", 1024, false) - if err != nil { - t.Fatal(err) - } - if n != 5 { - t.Errorf("got %d", n) - } -} - func TestCgoBridgeListAlbumsViaStub(t *testing.T) { SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`) bridge := &CgoBridge{} @@ -425,69 +324,6 @@ func TestCgoBridgeListTreeNil(t *testing.T) { } } -func TestCgoBridgeExportViaStub(t *testing.T) { - SetTestExportRC(7) - bridge := &CgoBridge{} - n, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512) - if err != nil { - t.Fatal(err) - } - if n != 7 { - t.Errorf("got %d", n) - } -} - -func TestCgoBridgeExportErrorViaStub(t *testing.T) { - SetTestExportRC(-3) - bridge := &CgoBridge{} - _, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512) - if err == nil || err.Error() != "album not found" { - t.Errorf("got %v", err) - } -} - -func TestCgoBridgeExportOriginalsViaStub(t *testing.T) { - SetTestExportOriginalsRC(6) - bridge := &CgoBridge{} - n, err := bridge.ExportAlbumOriginals("id", "/tmp/out") - if err != nil { - t.Fatal(err) - } - if n != 6 { - t.Errorf("got %d", n) - } -} - -func TestCgoBridgeExportOriginalsErrorViaStub(t *testing.T) { - SetTestExportOriginalsRC(-4) - bridge := &CgoBridge{} - _, err := bridge.ExportAlbumOriginals("id", "/tmp/out") - if err == nil || err.Error() != "all exports failed" { - t.Errorf("got %v", err) - } -} - -func TestCgoBridgeBackupAllViaStub(t *testing.T) { - SetTestBackupAllRC(8) - bridge := &CgoBridge{} - n, err := bridge.BackupAll("/tmp/out", 1024, false) - if err != nil { - t.Fatal(err) - } - if n != 8 { - t.Errorf("got %d", n) - } -} - -func TestCgoBridgeBackupAllErrorViaStub(t *testing.T) { - SetTestBackupAllRC(-2) - bridge := &CgoBridge{} - _, err := bridge.BackupAll("/tmp/out", 1024, true) - if err == nil || err.Error() != "could not create output directory" { - t.Errorf("got %v", err) - } -} - func TestCgoBridgeRequestAccessGranted(t *testing.T) { SetTestAccessRC(0) bridge := &CgoBridge{} diff --git a/internal/photos/types.go b/internal/photos/types.go index cac5912..54ea880 100644 --- a/internal/photos/types.go +++ b/internal/photos/types.go @@ -8,7 +8,7 @@ type Album struct { type Asset struct { ID string `json:"id"` Filename string `json:"filename"` - Cloud bool `json:"cloud"` + Cloud string `json:"cloud"` } type ExportResult struct {