v0.2.0: semaphore timeouts, error logging, dead code removal, parallel exports

Critical:
- Replace DISPATCH_TIME_FOREVER with 120s/30s timeouts in ObjC
- Log failed asset IDs and error messages in cmdExport/backupTree
- Show failed count in export summaries

Cleanup:
- Remove legacy Bridge methods (ExportAlbumPreviews, ExportAlbumOriginals, BackupAll)
- Remove legacy ObjC functions and C stub equivalents
- Remove photos.go delegates (package-level pass-throughs)
- Remove InterpretExportResult (only used by legacy methods)
- Clean up mockBridge fields (rename Fn2 -> Fn)
- Fix rc race condition in main_main.go (atomic.Int32)
- Remove unused variables (_ = grandTotal, _ = sig)

Design:
- Fix resolveAlbumID: ListAlbums first (cheap), then direct ID
- Unify Cloud type: Asset.Cloud string (was bool)
- Extract shared export logic into exportAssets/exportOne
- Add worker pool for parallel exports (3 workers when assets >= 4)
- Fix backupTree progress bar counter and directory prefix

Robustness:
- Add nil checks for stringWithUTF8String: in ObjC
- Log directory creation errors in ensure_directory (ObjC)

Quality:
- Add go vet and -race flag to Makefile test target
- Add ADR for performSelector cloudIdentifier decision
- Add sync comments between Go/ObjC sanitizePathComponent
- Add package-level doc comment
- Add tests: partial failure, skipped album, album-not-found message
This commit is contained in:
Ein Anderssono
2026-06-11 21:12:47 +02:00
parent b460c68641
commit 85eaa3ea37
14 changed files with 274 additions and 651 deletions
+3 -2
View File
@@ -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
-17
View File
@@ -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);
+23 -215
View File
@@ -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<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *album) {
@@ -91,17 +100,6 @@ static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *
return [PHAsset fetchAssetsInAssetCollection:album options:opts];
}
static PHFetchResult<PHAsset *> *fetch_assets_for_album(const char *album_id) {
if (!album_id) return nil;
NSString *nsAlbumId = [NSString stringWithUTF8String:album_id];
PHFetchResult<PHAssetCollection *> *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<PHAsset *> *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<PHAsset *> *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<PHAssetResource *> *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<PHCollection *> *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<PHAsset *> *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<PHAssetCollection *> *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<PHAsset *> *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<PHAsset *> *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<PHCollection *> *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<PHAsset *> *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<PHAsset *> *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)});
-26
View File
@@ -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);
}
+123 -50
View File
@@ -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)
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
exported += n
failed += f
}
progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud)
}
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, total, nil
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 == "" {
+5 -5
View File
@@ -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()))
}
+90 -42
View File
@@ -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())
}
}
+21
View File
@@ -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.
-23
View File
@@ -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
}
}
-33
View File
@@ -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()
}
-37
View File
@@ -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()
}
+2 -30
View File
@@ -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)
}
-164
View File
@@ -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{}
+1 -1
View File
@@ -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 {