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:
@@ -1,6 +1,6 @@
|
|||||||
BINARY := ./bin/photoscli
|
BINARY := ./bin/photoscli
|
||||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||||
VERSION := 0.1.0
|
VERSION := 0.2.0
|
||||||
BRIDGE_DIR := bridge
|
BRIDGE_DIR := bridge
|
||||||
LDFLAGS := -X main.version=$(VERSION)
|
LDFLAGS := -X main.version=$(VERSION)
|
||||||
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
||||||
@@ -30,7 +30,8 @@ build: $(LIB)
|
|||||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(MODULE)/cmd/photoscli
|
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(MODULE)/cmd/photoscli
|
||||||
|
|
||||||
test: $(STUB_LIB)
|
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
|
@grep -v 'main_main.go' coverage.out > coverage_filtered.out 2>/dev/null || true
|
||||||
@mv coverage_filtered.out coverage.out 2>/dev/null || true
|
@mv coverage_filtered.out coverage.out 2>/dev/null || true
|
||||||
@go tool cover -func=coverage.out | tail -1
|
@go tool cover -func=coverage.out | tail -1
|
||||||
|
|||||||
@@ -26,23 +26,6 @@ char *photos_export_original_json(
|
|||||||
|
|
||||||
char *photos_list_tree_json(void);
|
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_request_cancel(void);
|
||||||
|
|
||||||
void photos_free_string(char *value);
|
void photos_free_string(char *value);
|
||||||
|
|||||||
+23
-215
@@ -9,6 +9,11 @@ static NSDictionary *make_error_dict(NSString *message) {
|
|||||||
return @{@"error": 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) {
|
static NSDictionary *collection_to_dict(PHCollection *collection) {
|
||||||
NSString *name = collection.localizedTitle ?: @"Untitled";
|
NSString *name = collection.localizedTitle ?: @"Untitled";
|
||||||
NSString *identifier = collection.localIdentifier ?: @"";
|
NSString *identifier = collection.localIdentifier ?: @"";
|
||||||
@@ -80,8 +85,12 @@ static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) {
|
|||||||
static BOOL ensure_directory(NSString *outputDir) {
|
static BOOL ensure_directory(NSString *outputDir) {
|
||||||
NSFileManager *fm = [NSFileManager defaultManager];
|
NSFileManager *fm = [NSFileManager defaultManager];
|
||||||
NSError *dirErr = nil;
|
NSError *dirErr = nil;
|
||||||
return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
|
BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
|
||||||
attributes:nil error:&dirErr];
|
attributes:nil error:&dirErr];
|
||||||
|
if (!ok && dirErr) {
|
||||||
|
NSLog(@"ensure_directory failed: %@", dirErr);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *album) {
|
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];
|
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) {
|
static NSString *sanitized_asset_identifier(NSString *assetID) {
|
||||||
NSMutableString *safe = [assetID mutableCopy];
|
NSMutableString *safe = [assetID mutableCopy];
|
||||||
@@ -132,6 +130,7 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam
|
|||||||
return [outputDir stringByAppendingPathComponent:prefixed];
|
return [outputDir stringByAppendingPathComponent:prefixed];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go
|
||||||
static NSString *sanitized_path_component(NSString *name) {
|
static NSString *sanitized_path_component(NSString *name) {
|
||||||
NSString *source = name ?: @"Untitled";
|
NSString *source = name ?: @"Untitled";
|
||||||
NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
|
NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
|
||||||
@@ -150,146 +149,6 @@ static NSString *sanitized_path_component(NSString *name) {
|
|||||||
return safe;
|
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) {
|
int photos_request_access(void) {
|
||||||
__block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
|
__block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
|
||||||
if (status == PHAuthorizationStatusNotDetermined) {
|
if (status == PHAuthorizationStatusNotDetermined) {
|
||||||
@@ -298,7 +157,9 @@ int photos_request_access(void) {
|
|||||||
status = s;
|
status = s;
|
||||||
dispatch_semaphore_signal(sem);
|
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;
|
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"));
|
if (!album_id) return json_from_object(make_error_dict(@"album_id is required"));
|
||||||
|
|
||||||
NSString *nsAlbumId = [NSString stringWithUTF8String:album_id];
|
NSString *nsAlbumId = [NSString stringWithUTF8String:album_id];
|
||||||
|
if (!nsAlbumId) return json_from_object(make_error_dict(@"invalid UTF-8 in album_id"));
|
||||||
PHFetchResult<PHAssetCollection *> *albums =
|
PHFetchResult<PHAssetCollection *> *albums =
|
||||||
[PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId]
|
[PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId]
|
||||||
options:nil];
|
options:nil];
|
||||||
@@ -396,66 +258,6 @@ char *photos_list_tree_json(void) {
|
|||||||
return json_from_object(@{@"collections": list});
|
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) {
|
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"));
|
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 *nsAssetId = [NSString stringWithUTF8String:asset_id];
|
||||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||||
|
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
|
||||||
|
|
||||||
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
||||||
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
||||||
@@ -498,7 +301,9 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
|||||||
dispatch_semaphore_signal(sem);
|
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) {
|
if (!imageData) {
|
||||||
return json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)});
|
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 *nsAssetId = [NSString stringWithUTF8String:asset_id];
|
||||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||||
|
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
|
||||||
|
|
||||||
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
||||||
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
||||||
@@ -564,7 +370,9 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
|||||||
writeErr = error;
|
writeErr = error;
|
||||||
dispatch_semaphore_signal(sem);
|
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) {
|
if (writeErr) {
|
||||||
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
|
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ static int stub_access_rc = 0;
|
|||||||
static const char *stub_albums_json = "{\"albums\":[]}";
|
static const char *stub_albums_json = "{\"albums\":[]}";
|
||||||
static const char *stub_assets_json = "{\"assets\":[]}";
|
static const char *stub_assets_json = "{\"assets\":[]}";
|
||||||
static const char *stub_tree_json = "{\"collections\":[]}";
|
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_albums_null = 0;
|
||||||
static int stub_assets_null = 0;
|
static int stub_assets_null = 0;
|
||||||
static int stub_tree_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_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_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_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_albums_null(void) { stub_albums_null = 1; }
|
||||||
void photos_test_set_assets_null(void) { stub_assets_null = 1; }
|
void photos_test_set_assets_null(void) { stub_assets_null = 1; }
|
||||||
void photos_test_set_tree_null(void) { stub_tree_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);
|
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) {
|
void photos_free_string(char *value) {
|
||||||
if (value) free(value);
|
if (value) free(value);
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-54
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
"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) {
|
func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) {
|
||||||
_, _, err := bridge.ListAssets(idOrName)
|
|
||||||
if err == nil {
|
|
||||||
return idOrName, nil
|
|
||||||
}
|
|
||||||
albums, listErr := bridge.ListAlbums()
|
albums, listErr := bridge.ListAlbums()
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
return idOrName, err
|
return idOrName, listErr
|
||||||
}
|
}
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
if a.Title == idOrName {
|
if a.Title == idOrName {
|
||||||
return a.ID, nil
|
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 {
|
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
|
return 1
|
||||||
}
|
}
|
||||||
for _, a := range assets {
|
for _, a := range assets {
|
||||||
cloud := "local"
|
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, a.Cloud)
|
||||||
if a.Cloud {
|
|
||||||
cloud = "cloud"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, cloud)
|
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -188,25 +185,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
exported := 0
|
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
|
||||||
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++
|
|
||||||
}
|
|
||||||
|
|
||||||
if exported == 0 && failed > 0 {
|
if exported == 0 && failed > 0 {
|
||||||
fmt.Fprintf(stderr, "\nerror: all exports failed\n")
|
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 {
|
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 {
|
} 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,60 +233,150 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
}
|
}
|
||||||
albumCount := countAlbums(nodes)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if originals {
|
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 {
|
} 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
|
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
|
exported := 0
|
||||||
total := 0
|
total := 0
|
||||||
|
failed := 0
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
path := outDir + "/" + sanitizePathComponent(node.Name)
|
path := outDir + "/" + sanitizePathComponent(node.Name)
|
||||||
if node.Kind == "folder" {
|
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 {
|
if err != nil {
|
||||||
return exported, total, err
|
return exported, total, failed, err
|
||||||
}
|
}
|
||||||
exported += n
|
exported += n
|
||||||
total += t
|
total += t
|
||||||
|
failed += f
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if node.Kind == "album" && node.ID != "" {
|
if node.Kind == "album" && node.ID != "" {
|
||||||
assets, assetTotal, err := bridge.ListAssets(node.ID)
|
assets, assetTotal, err := bridge.ListAssets(node.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total += assetTotal
|
total += assetTotal
|
||||||
for i, a := range assets {
|
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
|
||||||
var result photos.ExportResult
|
exported += n
|
||||||
var exportErr error
|
failed += f
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
func sanitizePathComponent(name string) string {
|
||||||
s := strings.TrimSpace(name)
|
s := strings.TrimSpace(name)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||||
@@ -15,21 +16,20 @@ func main() {
|
|||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
var rc int
|
var rc atomic.Int32
|
||||||
|
|
||||||
go func() {
|
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)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
case sig := <-sigCh:
|
case <-sigCh:
|
||||||
photos.DefaultBridge.Cancel()
|
photos.DefaultBridge.Cancel()
|
||||||
os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n"))
|
os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n"))
|
||||||
<-done
|
<-done
|
||||||
_ = sig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Exit(rc)
|
os.Exit(int(rc.Load()))
|
||||||
}
|
}
|
||||||
+90
-42
@@ -20,18 +20,9 @@ type mockBridge struct {
|
|||||||
assetsByAlbum map[string][]photos.Asset
|
assetsByAlbum map[string][]photos.Asset
|
||||||
tree []photos.CollectionNode
|
tree []photos.CollectionNode
|
||||||
treeErr error
|
treeErr error
|
||||||
exportN int
|
exportPreviewFn func(string, string, int, int) (photos.ExportResult, error)
|
||||||
exportErr error
|
exportOrigFn func(string, string, int) (photos.ExportResult, 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)
|
|
||||||
cancelled bool
|
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 }
|
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
|
return m.assets, len(m.assets), m.assetsErr
|
||||||
}
|
}
|
||||||
func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr }
|
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) {
|
func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) {
|
||||||
if m.exportPreviewFn2 != nil {
|
if m.exportPreviewFn != nil {
|
||||||
return m.exportPreviewFn2(assetID, out, targetSize, index)
|
return m.exportPreviewFn(assetID, out, targetSize, index)
|
||||||
}
|
}
|
||||||
return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil
|
return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil
|
||||||
}
|
}
|
||||||
func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.ExportResult, error) {
|
func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.ExportResult, error) {
|
||||||
if m.exportOrigFn2 != nil {
|
if m.exportOrigFn != nil {
|
||||||
return m.exportOrigFn2(assetID, out, index)
|
return m.exportOrigFn(assetID, out, index)
|
||||||
}
|
}
|
||||||
return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil
|
return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil
|
||||||
}
|
}
|
||||||
func (m *mockBridge) 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 (m *mockBridge) Cancel() { m.cancelled = true }
|
||||||
|
|
||||||
func runWith(args []string, b photos.Bridge) (string, string, int) {
|
func runWith(args []string, b photos.Bridge) (string, string, int) {
|
||||||
@@ -187,7 +160,7 @@ func TestCmdPhotosMissingAlbumID(t *testing.T) {
|
|||||||
|
|
||||||
func TestCmdPhotosSuccess(t *testing.T) {
|
func TestCmdPhotosSuccess(t *testing.T) {
|
||||||
b := &mockBridge{
|
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)
|
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
|
||||||
if rc != 0 {
|
if rc != 0 {
|
||||||
@@ -211,7 +184,7 @@ func TestCmdPhotosAuthDenied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCmdPhotosBridgeError(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)
|
_, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b)
|
||||||
if rc != 1 {
|
if rc != 1 {
|
||||||
t.Errorf("rc = %d", rc)
|
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") {
|
if !strings.Contains(stderr, "exported 2 preview files across 2 albums to /backup") {
|
||||||
t.Errorf("stderr = %q", stderr)
|
t.Errorf("stderr = %q", stderr)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(stderr, "failed") {
|
||||||
|
t.Errorf("unexpected failed count in stderr = %q", stderr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCmdBackupAllOriginalsSuccess(t *testing.T) {
|
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") {
|
if !strings.Contains(stderr, "exported 3 original files across 1 albums to /backup") {
|
||||||
t.Errorf("stderr = %q", stderr)
|
t.Errorf("stderr = %q", stderr)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(stderr, "failed") {
|
||||||
|
t.Errorf("unexpected failed count in stderr = %q", stderr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCmdBackupAllMissingOutDir(t *testing.T) {
|
func TestCmdBackupAllMissingOutDir(t *testing.T) {
|
||||||
@@ -329,7 +308,7 @@ func TestCmdBackupAllExportError(t *testing.T) {
|
|||||||
assetsByAlbum: map[string][]photos.Asset{
|
assetsByAlbum: map[string][]photos.Asset{
|
||||||
"a1": {{ID: "as1", Filename: "img.jpg"}},
|
"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")
|
return photos.ExportResult{}, fmt.Errorf("disk full")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -337,7 +316,12 @@ func TestCmdBackupAllExportError(t *testing.T) {
|
|||||||
if rc != 0 {
|
if rc != 0 {
|
||||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
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) {
|
func TestCmdExportMissingAlbumID(t *testing.T) {
|
||||||
@@ -420,7 +404,7 @@ func TestCmdExportAuthDenied(t *testing.T) {
|
|||||||
func TestCmdExportBridgeError(t *testing.T) {
|
func TestCmdExportBridgeError(t *testing.T) {
|
||||||
b := &mockBridge{
|
b := &mockBridge{
|
||||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
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")
|
return photos.ExportResult{}, fmt.Errorf("disk full")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -431,6 +415,9 @@ func TestCmdExportBridgeError(t *testing.T) {
|
|||||||
if !strings.Contains(stderr, "all exports failed") {
|
if !strings.Contains(stderr, "all exports failed") {
|
||||||
t.Errorf("stderr = %q", stderr)
|
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) {
|
func TestCmdExportOriginalsSuccess(t *testing.T) {
|
||||||
@@ -462,7 +449,7 @@ func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) {
|
|||||||
func TestCmdExportOriginalsBridgeError(t *testing.T) {
|
func TestCmdExportOriginalsBridgeError(t *testing.T) {
|
||||||
b := &mockBridge{
|
b := &mockBridge{
|
||||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
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")
|
return photos.ExportResult{}, fmt.Errorf("copy failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -473,6 +460,9 @@ func TestCmdExportOriginalsBridgeError(t *testing.T) {
|
|||||||
if !strings.Contains(stderr, "all exports failed") {
|
if !strings.Contains(stderr, "all exports failed") {
|
||||||
t.Errorf("stderr = %q", stderr)
|
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) {
|
func TestFlagVal(t *testing.T) {
|
||||||
@@ -527,7 +517,7 @@ func TestFlagValEmptyArgs(t *testing.T) {
|
|||||||
func TestResolveAlbumIDDirectMatch(t *testing.T) {
|
func TestResolveAlbumIDDirectMatch(t *testing.T) {
|
||||||
b := &mockBridge{
|
b := &mockBridge{
|
||||||
assetsByAlbum: map[string][]photos.Asset{
|
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")
|
id, err := resolveAlbumID(b, "ABC/L0/001")
|
||||||
@@ -546,7 +536,7 @@ func TestResolveAlbumIDByName(t *testing.T) {
|
|||||||
{ID: "DEF/L0/001", Title: "Work"},
|
{ID: "DEF/L0/001", Title: "Work"},
|
||||||
},
|
},
|
||||||
assetsByAlbum: map[string][]photos.Asset{
|
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")
|
id, err := resolveAlbumID(b, "DnD")
|
||||||
@@ -588,7 +578,7 @@ func TestCmdPhotosResolvesAlbumName(t *testing.T) {
|
|||||||
{ID: "ABC/L0/001", Title: "DnD"},
|
{ID: "ABC/L0/001", Title: "DnD"},
|
||||||
},
|
},
|
||||||
assetsByAlbum: map[string][]photos.Asset{
|
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)
|
out, stderr, rc := runWith([]string{"photos", "--album-id", "DnD"}, b)
|
||||||
@@ -635,3 +625,61 @@ func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) {
|
|||||||
t.Errorf("stderr = %q", stderr)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -10,11 +10,8 @@ type Bridge interface {
|
|||||||
ListAlbums() ([]Album, error)
|
ListAlbums() ([]Album, error)
|
||||||
ListAssets(albumID string) ([]Asset, int, error)
|
ListAssets(albumID string) ([]Asset, int, error)
|
||||||
ListTree() ([]CollectionNode, 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)
|
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
|
||||||
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
||||||
BackupAll(outputDir string, targetSize int, originals bool) (int, error)
|
|
||||||
Cancel()
|
Cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,23 +59,3 @@ func ParseExportResultJSON(jsonStr string) (ExportResult, error) {
|
|||||||
}
|
}
|
||||||
return resp.ExportResult, nil
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,39 +54,6 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) {
|
|||||||
return ParseTreeJSON(C.GoString(cs))
|
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() {
|
func (*CgoBridge) Cancel() {
|
||||||
C.photos_request_cancel()
|
C.photos_request_cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ void photos_test_set_access(int rc);
|
|||||||
void photos_test_set_albums(const char *json);
|
void photos_test_set_albums(const char *json);
|
||||||
void photos_test_set_assets(const char *json);
|
void photos_test_set_assets(const char *json);
|
||||||
void photos_test_set_tree(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_albums_null(void);
|
||||||
void photos_test_set_assets_null(void);
|
void photos_test_set_assets_null(void);
|
||||||
void photos_test_set_tree_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 SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
||||||
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(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 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 SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
||||||
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
||||||
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
||||||
@@ -80,37 +74,6 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) {
|
|||||||
return ParseTreeJSON(C.GoString(cs))
|
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() {
|
func (*CgoBridge) Cancel() {
|
||||||
C.photos_request_cancel()
|
C.photos_request_cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package photos
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security")
|
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")
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
func TestAlbumsResponseUnmarshal(t *testing.T) {
|
||||||
raw := `{"albums":[{"id":"x","title":"Y"}]}`
|
raw := `{"albums":[{"id":"x","title":"Y"}]}`
|
||||||
var resp AlbumsResponse
|
var resp AlbumsResponse
|
||||||
@@ -299,68 +260,6 @@ func TestCgoBridgeImplementsBridge(t *testing.T) {
|
|||||||
var _ Bridge = &CgoBridge{}
|
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) {
|
func TestCgoBridgeListAlbumsViaStub(t *testing.T) {
|
||||||
SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`)
|
SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`)
|
||||||
bridge := &CgoBridge{}
|
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) {
|
func TestCgoBridgeRequestAccessGranted(t *testing.T) {
|
||||||
SetTestAccessRC(0)
|
SetTestAccessRC(0)
|
||||||
bridge := &CgoBridge{}
|
bridge := &CgoBridge{}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Album struct {
|
|||||||
type Asset struct {
|
type Asset struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Cloud bool `json:"cloud"`
|
Cloud string `json:"cloud"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportResult struct {
|
type ExportResult struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user