From 3d3c4a4742220315d70c84d47708f08415bd03ed Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Fri, 12 Jun 2026 14:03:18 +0200 Subject: [PATCH] v0.4.0: scroll log, worker slots, disk skip, color gradient, parallel export --- Makefile | 4 +- README.md | 16 +- bridge/photokit_bridge.h | 19 +- bridge/photokit_bridge.m | 321 ++++++++++++++++-- bridge/photokit_bridge_stub.c | 21 +- cmd/photoscli/main.go | 388 +++++++++++++++------- cmd/photoscli/main_test.go | 416 +++++++++++++++++++++++- cmd/photoscli/progress.go | 393 ++++++++++++++++++++++ cmd/photoscli/termsize_darwin.go | 23 ++ cmd/photoscli/termsize_test.go | 7 + internal/photos/bridge.go | 2 + internal/photos/cgo_bridge.go | 72 +++- internal/photos/cgo_bridge_test_impl.go | 36 +- internal/photos/photos_test.go | 52 ++- internal/photos/types.go | 20 +- 15 files changed, 1609 insertions(+), 181 deletions(-) create mode 100644 cmd/photoscli/progress.go create mode 100644 cmd/photoscli/termsize_darwin.go create mode 100644 cmd/photoscli/termsize_test.go diff --git a/Makefile b/Makefile index f4d7755..d4b8103 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.2.5 +VERSION := 0.4.0 BRIDGE_DIR := bridge LDFLAGS := -X main.version=$(VERSION) OBJ := $(BRIDGE_DIR)/photokit_bridge.o @@ -31,7 +31,7 @@ build: $(LIB) test: $(STUB_LIB) go vet -tags=test ./... - go test -tags=test -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... + go test -tags=test -race -coverprofile=coverage.out ./cmd/photoscli/ ./internal/photos/ @grep -v 'main_main.go' coverage.out > coverage_filtered.out 2>/dev/null || true @mv coverage_filtered.out coverage.out 2>/dev/null || true @go tool cover -func=coverage.out | tail -1 diff --git a/README.md b/README.md index d95ab04..f324873 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The bridge uses PhotoKit to: - Go 1.22+ - Xcode command-line tools -The project builds with cgo and links against `Photos`, `Foundation`, and `AppKit`. +The project builds with cgo and links against `Photos`, `Foundation`, `AppKit`, and `UniformTypeIdentifiers`. ## Build @@ -106,9 +106,13 @@ Example output: - Requests Photos access - Walks the Photos folder and album hierarchy +- Builds a complete index of all assets before exporting, skipping files already on disk - Creates directories as `out/folder/album/files` - Exports previews by default, originals when `--originals` is present -- Shows per-asset progress bar with filename, file size, and cloud/local status +- Shows a progress display with: + - Scroll log of completed files (✅ copied, ☁ downloaded, ⏭ skipped, ❌ failed) + - Worker status lines with live download progress bars for cloud files + - Total and Album progress bars with color gradient (red → yellow → green) - Uses `--size` only for preview export Example layout: @@ -128,7 +132,7 @@ backup/ - Requests Photos access - Resolves `--album-id` by local identifier first, then by album title if not found - Creates the output directory if needed -- Exports assets one at a time with a progress bar: `[=======---] 50% filename.jpg 1.2 MB cloud` +- Exports assets with a progress bar - Shows file size and cloud/local status for each exported asset - Exports resized JPEG previews by default - Exports original files when `--originals` is present @@ -138,6 +142,8 @@ backup/ `--originals` switches export mode to original-file export. In that mode, `--size` is ignored. +`--include-videos` includes video and audio assets in the export. By default, videos and audio are filtered out. + `tree` - Requests Photos access @@ -191,7 +197,7 @@ A second signal forces an immediate exit. ## Architecture -- `cmd/photoscli`: CLI entrypoint, argument parsing, and album name resolution +- `cmd/photoscli`: CLI entrypoint, argument parsing, progress display, and album name resolution - `internal/photos`: Go bridge interface, JSON parsing, and error mapping - `bridge/`: Objective-C PhotoKit implementation plus a C test stub @@ -206,4 +212,4 @@ Data passed from Objective-C to Go is serialized as JSON and unmarshaled into Go - Original export currently writes the first PhotoKit asset resource for each asset, which may not capture every related representation for complex assets - iCloud-backed assets may require network download during export - A second interrupt signal forces an immediate exit without waiting for the current file -- Partial export failures are not listed individually +- Partial export failures are not listed individually \ No newline at end of file diff --git a/bridge/photokit_bridge.h b/bridge/photokit_bridge.h index a8b18bd..1034375 100644 --- a/bridge/photokit_bridge.h +++ b/bridge/photokit_bridge.h @@ -1,10 +1,19 @@ #ifndef PHOTOKIT_BRIDGE_H #define PHOTOKIT_BRIDGE_H +#include + #ifdef __cplusplus extern "C" { #endif +typedef struct { + int active; + double progress; + int64_t bytes_done; + int64_t bytes_total; +} export_progress_t; + int photos_request_access(void); char *photos_list_albums_json(void); @@ -15,13 +24,15 @@ char *photos_export_preview_json( const char *asset_id, const char *output_dir, int target_size, - int index + int index, + int slot_index ); char *photos_export_original_json( const char *asset_id, const char *output_dir, - int index + int index, + int slot_index ); char *photos_list_tree_json(void); @@ -32,6 +43,10 @@ int photos_request_is_cancelled(void); void photos_free_string(char *value); +export_progress_t *photos_get_progress_slots(void); +int photos_get_progress_slot_count(void); +void photos_reset_progress_slots(void); + #ifdef __cplusplus } #endif diff --git a/bridge/photokit_bridge.m b/bridge/photokit_bridge.m index 85f6e79..138fd85 100644 --- a/bridge/photokit_bridge.m +++ b/bridge/photokit_bridge.m @@ -1,10 +1,24 @@ #import #import #import +#import +#import #import "photokit_bridge.h" static volatile int photos_cancelled = 0; +#define PROGRESS_SLOT_COUNT 3 +static export_progress_t progress_slots[PROGRESS_SLOT_COUNT]; + +static void reset_slot(int slot_index) { + if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { + progress_slots[slot_index].active = 0; + progress_slots[slot_index].progress = 0; + progress_slots[slot_index].bytes_done = 0; + progress_slots[slot_index].bytes_total = 0; + } +} + static NSDictionary *make_error_dict(NSString *message) { return @{@"error": message}; } @@ -192,6 +206,60 @@ char *photos_list_albums_json(void) { return json_from_object(@{@"albums": list}); } +static NSString *resource_type_string(NSInteger type) { + switch (type) { + case 1: return @"photo"; + case 2: return @"video"; + case 3: return @"audio"; + case 4: return @"alternatePhoto"; + case 5: return @"fullSizePhoto"; + case 6: return @"fullSizeVideo"; + case 7: return @"adjustmentData"; + case 8: return @"adjustmentBasePhoto"; + case 9: return @"pairedVideo"; + case 10: return @"fullSizePairedVideo"; + case 11: return @"adjustmentBasePairedVideo"; + case 12: return @"adjustmentBaseVideo"; + default: return [NSString stringWithFormat:@"other(%ld)", (long)type]; + } +} + +static NSString *media_type_string(PHAssetMediaType type) { + switch (type) { + case PHAssetMediaTypeImage: return @"image"; + case PHAssetMediaTypeVideo: return @"video"; + case PHAssetMediaTypeAudio: return @"audio"; + default: return @"unknown"; + } +} + +static NSString *iso8601_string(NSDate *date) { + if (!date) return nil; + static NSDateFormatter *fmt = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fmt = [[NSDateFormatter alloc] init]; + fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + fmt.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZ"; + }); + return [fmt stringFromDate:date]; +} + +#import + +static BOOL resource_is_locally_available(PHAssetResource *res) { + @try { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = @selector(isLocallyAvailable); + if ([res respondsToSelector:sel]) { + return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel); + } +#pragma clang diagnostic pop + } @catch (NSException *e) {} + return NO; +} + static NSString *asset_cloud_status_string(PHAsset *asset) { @try { id cloudId = [asset performSelector:@selector(cloudIdentifier)]; @@ -200,16 +268,17 @@ static NSString *asset_cloud_status_string(PHAsset *asset) { } } @catch (NSException *exception) { } - PHAssetResource *resource = [PHAssetResource assetResourcesForAsset:asset].firstObject; + PHAssetResource *resource = nil; + @try { + resource = [PHAssetResource assetResourcesForAsset:asset].firstObject; + } @catch (NSException *e) { + resource = nil; + } if (resource) { - @try { - id locallyAvailable = [resource performSelector:@selector(isLocallyAvailable)]; - if (locallyAvailable && [locallyAvailable boolValue]) { - return @"local"; - } - return @"cloud"; - } @catch (NSException *exception) { + if (resource_is_locally_available(resource)) { + return @"local"; } + return @"cloud"; } return @"local"; } @@ -236,16 +305,53 @@ char *photos_list_assets_json(const char *album_id) { PHAsset *asset = assets[i]; NSString *lid = asset.localIdentifier; NSString *filename = nil; - NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; + NSArray *resources = nil; + @try { + resources = [PHAssetResource assetResourcesForAsset:asset]; + } @catch (NSException *e) { + resources = @[]; + } if (resources.count > 0) { filename = resources.firstObject.originalFilename; } NSString *cloudStatus = asset_cloud_status_string(asset); - [list addObject:@{ + NSString *mediaTypeStr = media_type_string(asset.mediaType); + + NSString *creationDateStr = iso8601_string(asset.creationDate); + + NSMutableArray *resourcesList = [NSMutableArray arrayWithCapacity:resources.count]; + for (PHAssetResource *res in resources) { + NSString *resTypeStr = resource_type_string(res.type); + NSString *uti = res.uniformTypeIdentifier ?: @""; + BOOL isLocal = resource_is_locally_available(res); + [resourcesList addObject:@{ + @"type": resTypeStr, + @"filename": res.originalFilename ?: @"", + @"uti": uti, + @"local": @(isLocal) + }]; + } + + NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{ @"id": lid ?: @"", @"filename": filename ?: @"", - @"cloud": cloudStatus + @"cloud": cloudStatus, + @"mediaType": mediaTypeStr, + @"pixelWidth": @(asset.pixelWidth), + @"pixelHeight": @(asset.pixelHeight), + @"duration": @(asset.duration), + @"isFavorite": @(asset.isFavorite) }]; + if (creationDateStr) { + dict[@"creationDate"] = creationDateStr; + } + if (@available(macOS 12, *)) { + dict[@"hasAdjustments"] = @(asset.hasAdjustments); + } + if (resourcesList.count > 0) { + dict[@"resources"] = resourcesList; + } + [list addObject:dict]; } return json_from_object(@{@"assets": list, @"total": @(assets.count)}); @@ -266,23 +372,32 @@ char *photos_list_tree_json(void) { } -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")); +#define RETURN_PREVIEW(x) do { reset_slot(slot_index); return (x); } while(0) + +char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) { + if (!asset_id || !output_dir) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset_id and output_dir required"))); if (target_size <= 0) target_size = 1024; 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")); + if (!nsAssetId || !nsOutputDir) RETURN_PREVIEW(json_from_object(make_error_dict(@"invalid UTF-8 in arguments"))); if (!ensure_directory(nsOutputDir)) { - return json_from_object(make_error_dict(@"failed to create output directory")); + RETURN_PREVIEW(json_from_object(make_error_dict(@"failed to create output directory"))); } PHFetchResult *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_PREVIEW(json_from_object(make_error_dict(@"asset not found"))); PHAsset *asset = fetch.firstObject; + if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { + progress_slots[slot_index].active = 1; + progress_slots[slot_index].progress = 0; + progress_slots[slot_index].bytes_done = 0; + progress_slots[slot_index].bytes_total = 0; + } + PHImageManager *im = [PHImageManager defaultManager]; CGFloat scale = (CGFloat)target_size; CGSize targetCGSize = CGSizeMake(scale, scale); @@ -291,6 +406,11 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i imgOpts.resizeMode = PHImageRequestOptionsResizeModeExact; imgOpts.networkAccessAllowed = YES; imgOpts.synchronous = YES; + imgOpts.progressHandler = ^(double progress, NSError * _Nullable error, BOOL * _Nonnull stop, NSDictionary * _Nullable info) { + if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { + progress_slots[slot_index].progress = progress; + } + }; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSData *imageData = nil; @@ -313,21 +433,35 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i }]; if (!semaphore_wait_with_timeout(sem, 120)) { - return json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)}); + RETURN_PREVIEW(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)}); + RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)})); } NSString *safe = sanitized_asset_identifier(asset.localIdentifier); NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe]; NSString *filepath = [nsOutputDir stringByAppendingPathComponent:filename]; + NSFileManager *fm = [NSFileManager defaultManager]; + NSDictionary *existingAttrs = [fm attributesOfItemAtPath:filepath error:nil]; + if (existingAttrs) { + NSNumber *existingSize = existingAttrs[NSFileSize]; + if (existingSize && existingSize.unsignedLongValue > 0) { + RETURN_PREVIEW(json_from_object(@{ + @"filename": filename, + @"size": existingSize, + @"cloud": asset_cloud_status_string(asset), + @"skipped": @YES + })); + } + } + NSError *writeErr = nil; if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error"; - return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)}); + RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)})); } NSNumber *fileSize = nil; @@ -336,32 +470,47 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i fileSize = attrs[NSFileSize]; } - return json_from_object(@{ + RETURN_PREVIEW(json_from_object(@{ @"filename": filename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string(asset) - }); + })); } +#undef RETURN_PREVIEW -char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) { - if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required")); +#define RETURN_ORIGINAL(x) do { reset_slot(slot_index); return (x); } while(0) + +char *photos_export_original_json(const char *asset_id, const char *output_dir, int index, int slot_index) { + if (!asset_id || !output_dir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"asset_id and output_dir required"))); 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")); + if (!nsAssetId || !nsOutputDir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"invalid UTF-8 in arguments"))); if (!ensure_directory(nsOutputDir)) { - return json_from_object(make_error_dict(@"failed to create output directory")); + RETURN_ORIGINAL(json_from_object(make_error_dict(@"failed to create output directory"))); } PHFetchResult *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_ORIGINAL(json_from_object(make_error_dict(@"asset not found"))); PHAsset *asset = fetch.firstObject; - NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; + if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { + progress_slots[slot_index].active = 1; + progress_slots[slot_index].progress = 0; + progress_slots[slot_index].bytes_done = 0; + progress_slots[slot_index].bytes_total = 0; + } + + NSArray *resources = nil; + @try { + resources = [PHAssetResource assetResourcesForAsset:asset]; + } @catch (NSException *e) { + resources = @[]; + } if (resources.count == 0) { - return json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)}); + RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)})); } PHAssetResource *resource = resources.firstObject; @@ -371,11 +520,38 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, } NSString *filepath = unique_path_for_filename(nsOutputDir, filename, (NSUInteger)index); + + NSFileManager *fm = [NSFileManager defaultManager]; + { + NSString *checkPath = [nsOutputDir stringByAppendingPathComponent:filename]; + NSDictionary *existingAttrs = [fm attributesOfItemAtPath:checkPath error:nil]; + if (existingAttrs) { + NSNumber *existingSize = existingAttrs[NSFileSize]; + if (existingSize && existingSize.unsignedLongValue > 0) { + RETURN_ORIGINAL(json_from_object(@{ + @"filename": filename, + @"size": existingSize, + @"cloud": asset_cloud_status_string(asset), + @"skipped": @YES + })); + } + } + } + NSURL *fileURL = [NSURL fileURLWithPath:filepath]; + if ([fm fileExistsAtPath:filepath]) { + [fm removeItemAtPath:filepath error:nil]; + } + PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; opts.networkAccessAllowed = YES; + opts.progressHandler = ^(double progress) { + if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { + progress_slots[slot_index].progress = progress; + } + }; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSError *writeErr = nil; @@ -387,11 +563,79 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 120)) { - return json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)}); + RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)})); } if (writeErr) { - return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)}); + PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init]; + imgOpts.networkAccessAllowed = YES; + imgOpts.synchronous = NO; + + dispatch_semaphore_t sem2 = dispatch_semaphore_create(0); + __block NSData *fallbackData = nil; + __block NSError *fallbackErr = nil; + __block NSString *fallbackUTI = nil; + + [[PHImageManager defaultManager] requestImageDataAndOrientationForAsset:asset + options:imgOpts + resultHandler:^(NSData *imageData, NSString *dataUTI, CGImagePropertyOrientation orientation, NSDictionary *info) { + if (info[PHImageErrorKey]) { + fallbackErr = info[PHImageErrorKey]; + } else if (imageData) { + fallbackData = imageData; + fallbackUTI = dataUTI; + } else { + fallbackErr = [NSError errorWithDomain:@"photoscli" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"no data"}]; + } + dispatch_semaphore_signal(sem2); + }]; + + if (!semaphore_wait_with_timeout(sem2, 120)) { + RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string(asset)})); + } + + if (fallbackErr || !fallbackData) { + NSString *detail = writeErr.localizedDescription; + if (fallbackErr) { + detail = [NSString stringWithFormat:@"%@; fallback: %@", detail, fallbackErr.localizedDescription]; + } + RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string(asset)})); + } + + NSString *ext = nil; + if (fallbackUTI) { + UTType *uti = [UTType typeWithIdentifier:fallbackUTI]; + if (uti && uti.preferredFilenameExtension) { + ext = uti.preferredFilenameExtension; + } + } + if (ext.length == 0) { + ext = @"dng"; + } + + NSString *baseFilename = [filename stringByDeletingPathExtension]; + if (baseFilename.length == 0) { + baseFilename = sanitized_asset_identifier(asset.localIdentifier); + } + NSString *fallbackFilename = [NSString stringWithFormat:@"%@.%@", baseFilename, ext]; + NSString *fallbackPath = [nsOutputDir stringByAppendingPathComponent:fallbackFilename]; + + NSError *writeFallbackErr = nil; + if (![fallbackData writeToFile:fallbackPath options:NSDataWritingAtomic error:&writeFallbackErr]) { + RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)})); + } + + NSNumber *fileSize = nil; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:fallbackPath error:nil]; + if (attrs) { + fileSize = attrs[NSFileSize]; + } + + RETURN_ORIGINAL(json_from_object(@{ + @"filename": fallbackFilename, + @"size": fileSize ?: @0, + @"cloud": asset_cloud_status_string(asset) + })); } NSString *writtenFilename = [filepath lastPathComponent]; @@ -401,12 +645,13 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, fileSize = attrs[NSFileSize]; } - return json_from_object(@{ + RETURN_ORIGINAL(json_from_object(@{ @"filename": writtenFilename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string(asset) - }); + })); } +#undef RETURN_ORIGINAL void photos_free_string(char *value) { if (value) free(value); @@ -419,3 +664,15 @@ void photos_request_cancel(void) { int photos_request_is_cancelled(void) { return photos_cancelled; } + +export_progress_t *photos_get_progress_slots(void) { + return progress_slots; +} + +int photos_get_progress_slot_count(void) { + return PROGRESS_SLOT_COUNT; +} + +void photos_reset_progress_slots(void) { + memset(progress_slots, 0, sizeof(progress_slots)); +} diff --git a/bridge/photokit_bridge_stub.c b/bridge/photokit_bridge_stub.c index bd90354..f277274 100644 --- a/bridge/photokit_bridge_stub.c +++ b/bridge/photokit_bridge_stub.c @@ -1,5 +1,8 @@ #include #include +#include "../bridge/photokit_bridge.h" + +static export_progress_t stub_progress_slots[3]; static char *alloc_json(const char *s) { size_t len = strlen(s); @@ -46,18 +49,20 @@ char *photos_list_assets_json(const char *album_id) { return alloc_json(stub_assets_json); } -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, int slot_index) { (void)asset_id; (void)output_dir; (void)target_size; (void)index; + (void)slot_index; return maybe_alloc_json(stub_export_preview_json); } -char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) { +char *photos_export_original_json(const char *asset_id, const char *output_dir, int index, int slot_index) { (void)asset_id; (void)output_dir; (void)index; + (void)slot_index; return maybe_alloc_json(stub_export_original_json); } @@ -84,4 +89,16 @@ void photos_test_set_export_preview_json(const char *json) { void photos_test_set_export_original_json(const char *json) { stub_export_original_json = json; +} + +export_progress_t *photos_get_progress_slots(void) { + return stub_progress_slots; +} + +int photos_get_progress_slot_count(void) { + return 3; +} + +void photos_reset_progress_slots(void) { + memset(stub_progress_slots, 0, sizeof(stub_progress_slots)); } \ No newline at end of file diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index f96d781..81b9be5 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -3,7 +3,10 @@ package main import ( "fmt" "io" + "os" + "path/filepath" "strings" + "sync" "time" "gitea.k3s.k0.nu/tools/photocli/internal/photos" @@ -47,8 +50,8 @@ Usage: photoscli albums photoscli photos --album-id photoscli tree - photoscli backup-all --out [--size ] [--originals] - photoscli export --album-id --out [--size ] [--originals] + photoscli backup-all --out [--size ] [--originals] [--include-videos] + photoscli export --album-id --out [--size ] [--originals] [--include-videos] photoscli version Commands: @@ -60,10 +63,11 @@ Commands: version Print version Flags: - --album-id Album local identifier or title (required for photos/export) - --out Output directory (required for export/backup-all) - --size Target longest-side in pixels (default: 1024, preview export only) - --originals Export original files instead of JPEG previews`) + --album-id Album local identifier or title (required for photos/export) + --out Output directory (required for export/backup-all) + --size Target longest-side in pixels (default: 1024, preview export only) + --originals Export original files instead of JPEG previews + --include-videos Include video assets (videos are skipped by default)`) } func mustAuth(stderr io.Writer, bridge photos.Bridge) int { @@ -129,7 +133,17 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in return 1 } for _, a := range assets { - fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, a.Cloud) + fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%dx%d", a.ID, a.Filename, a.Cloud, a.MediaType, a.PixelWidth, a.PixelHeight) + if a.CreationDate != nil { + fmt.Fprintf(stdout, "\t%s", *a.CreationDate) + } + if a.Duration > 0 { + fmt.Fprintf(stdout, "\t%.1fs", a.Duration) + } + if a.IsFavorite { + fmt.Fprintf(stdout, "\t*") + } + fmt.Fprintln(stdout) } return 0 } @@ -153,6 +167,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in albumID := flagVal(args, "--album-id") outDir := flagVal(args, "--out") originals := hasFlag(args, "--originals") + skipVideos := !hasFlag(args, "--include-videos") sizeStr := flagValWithDefault(args, "--size", "1024") if albumID == "" { @@ -189,6 +204,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in return 1 } + if skipVideos { + assets, total = filterVideos(assets) + } + fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir) exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "") @@ -212,6 +231,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { outDir := flagVal(args, "--out") originals := hasFlag(args, "--originals") + skipVideos := !hasFlag(args, "--include-videos") sizeStr := flagValWithDefault(args, "--size", "1024") if outDir == "" { @@ -238,9 +258,9 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) return 1 } albumCount := countAlbums(nodes) - fmt.Fprintf(stderr, "found %d albums, exporting to %s...\n", albumCount, outDir) + fmt.Fprintf(stderr, "found %d albums, building index...\n", albumCount) - totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge) + totalAssets, failed, err := backupTree(nodes, outDir, size, originals, skipVideos, stderr, bridge) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 @@ -258,72 +278,279 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) return 0 } -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 +type pendingAsset struct { + asset photos.Asset + path string + album string +} + +func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, stderr io.Writer) ([]pendingAsset, int) { + var items []pendingAsset + var skipped int + collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, stderr) + return items, skipped +} + +func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, stderr io.Writer) { for _, node := range nodes { if bridge.IsCancelled() { - break + return } path := outDir + "/" + sanitizePathComponent(node.Name) if node.Kind == "folder" { - n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) - if err != nil { - return exported, total, failed, err - } - exported += n - total += t - failed += f + collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, stderr) continue } if node.Kind == "album" && node.ID != "" { - assets, assetTotal, err := bridge.ListAssets(node.ID) + assets, _, err := bridge.ListAssets(node.ID) if err != nil { - fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err) + fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", node.Name, err) continue } - fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal) - total += assetTotal - n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/") - exported += n - failed += f + if skipVideos { + assets, _ = filterVideos(assets) + } + for _, a := range assets { + if fileExistsOnDisk(a, path, originals, len(*items)+*skipped) { + *skipped++ + continue + } + *items = append(*items, pendingAsset{asset: a, path: path, album: node.Name}) + } + fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", len(*items), *skipped) } } - return exported, total, failed, nil } -func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { - exported := 0 +func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) { + pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, stderr) + if bridge.IsCancelled() { + return 0, 0, nil + } + total := len(pending) + fmt.Fprintf(stderr, " indexed %d files (%d skipped), exporting to %s...\n", total, skipped, outDir) + bar := newProgressBar(stderr, 3) + exported, failed := exportPending(pending, targetSize, originals, total, bar, bridge) + bar.clear() + return exported, failed, nil +} + +func exportPending(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) { + if len(pending) < 4 { + return exportPendingSerial(pending, targetSize, originals, total, bar, bridge) + } + return exportPendingParallel(pending, targetSize, originals, total, bar, bridge, 3) +} + +func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) { + done := 0 failed := 0 var totalBytes int64 var totalDur time.Duration - for i, a := range assets { + + for i, pa := range pending { if bridge.IsCancelled() { break } + bar.setWorker(0, pa.asset.Filename, 0, pa.asset.Cloud, "exporting") + bar.setTotal(done+failed, total, "") + bar.setAlbum(pa.album, 0, 0) + bar.draw() start := time.Now() - result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i) + result, exportErr := exportOne(bridge, pa.asset, pa.path, targetSize, originals, i) dur := time.Since(start) - if exportErr == nil { + isErr := exportErr != nil + isSkipped := result.Skipped + if !isErr && !isSkipped { totalBytes += result.Size totalDur += dur } + if isErr { + failed++ + } else { + done++ + } avgSpeed := float64(0) if totalDur > 0 { avgSpeed = float64(totalBytes) / totalDur.Seconds() } - progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil) - if exportErr != nil { - fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr) - failed++ + bar.setTotal(done+failed, total, "") + bar.setAlbum(pa.album, 0, 0) + bar.setWorker(0, "", 0, "", "") + var logLine string + if isErr { + logLine = fmt.Sprintf("\u274c %s: %v", pa.asset.Filename, exportErr) + } else if isSkipped { + logLine = fmt.Sprintf("\u23ed %s", result.Filename) + } else if result.Cloud == "cloud" { + logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", result.Filename, formatSize(result.Size), formatSpeed(avgSpeed)) + } else { + logLine = fmt.Sprintf("\u2705 %s - %s - copied", result.Filename, formatSize(result.Size)) + } + bar.logCompleted(logLine) + } + return done, failed +} + +func exportPendingParallel(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int) (int, int) { + type resultEntry struct { + result photos.ExportResult + err error + pa pendingAsset + dur time.Duration + } + + completed := make(chan resultEntry, len(pending)) + + jobs := make(chan int, len(pending)) + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for i := range jobs { + if bridge.IsCancelled() { + completed <- resultEntry{err: fmt.Errorf("cancelled"), pa: pending[i]} + continue + } + bar.setWorker(workerID, pending[i].asset.Filename, 0, pending[i].asset.Cloud, "exporting") + start := time.Now() + var result photos.ExportResult + var exportErr error + if originals { + result, exportErr = bridge.ExportOriginalWithSlot(pending[i].asset.ID, pending[i].path, i, workerID) + } else { + result, exportErr = bridge.ExportPreviewWithSlot(pending[i].asset.ID, pending[i].path, targetSize, i, workerID) + } + dur := time.Since(start) + bar.setWorker(workerID, "", 0, "", "") + completed <- resultEntry{result: result, err: exportErr, pa: pending[i], dur: dur} + } + }(w) + } + + go func() { + for i := range pending { + if bridge.IsCancelled() { + break + } + jobs <- i + } + close(jobs) + }() + + slots := photos.GetProgressSlots() + pollDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if bridge.IsCancelled() { + return + } + slots = photos.GetProgressSlots() + for i := 0; i < workers && i < len(slots); i++ { + bar.updateWorkerProgress(i, slots[i].Progress, slots[i].BytesDone, slots[i].BytesTotal) + } + bar.draw() + case <-pollDone: + return + } + } + }() + + done := 0 + failed := 0 + var totalBytes int64 + var totalDur time.Duration + for n := 0; n < len(pending); n++ { + var entry resultEntry + select { + case entry = <-completed: + case <-time.After(2 * time.Second): + if bridge.IsCancelled() { + close(pollDone) + wg.Wait() + return done, failed + } continue } - exported++ + if bridge.IsCancelled() { + close(pollDone) + wg.Wait() + return done, failed + } + isErr := entry.err != nil + isSkipped := entry.result.Skipped + if !isErr && !isSkipped { + totalBytes += entry.result.Size + totalDur += entry.dur + } + if isErr { + failed++ + } else { + done++ + } + avgSpeed := float64(0) + if totalDur > 0 { + avgSpeed = float64(totalBytes) / totalDur.Seconds() + } + bar.setTotal(done+failed, total, "") + bar.setAlbum(entry.pa.album, 0, 0) + + var logLine string + if isErr { + logLine = fmt.Sprintf("\u274c %s: %v", entry.pa.asset.Filename, entry.err) + } else if isSkipped { + logLine = fmt.Sprintf("\u23ed %s", entry.result.Filename) + } else if entry.result.Cloud == "cloud" { + logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", entry.result.Filename, formatSize(entry.result.Size), formatSpeed(avgSpeed)) + } else { + logLine = fmt.Sprintf("\u2705 %s - %s - copied", entry.result.Filename, formatSize(entry.result.Size)) + } + bar.logCompleted(logLine) } + close(pollDone) + wg.Wait() + photos.ResetProgressSlots() + return done, failed +} + +func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { + pending := make([]pendingAsset, len(assets)) + for i, a := range assets { + pending[i] = pendingAsset{asset: a, path: outDir, album: dirPrefix} + } + bar := newProgressBar(stderr, 1) + exported, failed := exportPending(pending, targetSize, originals, len(pending), bar, bridge) + bar.clear() return exported, failed } +func fileExistsOnDisk(asset photos.Asset, outDir string, originals bool, index int) bool { + var candidates []string + if originals { + if asset.Filename != "" { + candidates = append(candidates, filepath.Join(outDir, asset.Filename)) + base := strings.TrimSuffix(asset.Filename, filepath.Ext(asset.Filename)) + ext := filepath.Ext(asset.Filename) + candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s%s", index, base, ext))) + } + } else { + safeID := sanitizePathComponent(asset.ID) + candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s.jpg", index, safeID))) + } + for _, p := range candidates { + info, err := os.Stat(p) + if err == nil && info.Size() > 0 { + return true + } + } + return false +} + 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) @@ -331,6 +558,16 @@ func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize i return bridge.ExportPreview(a.ID, outDir, targetSize, index) } +func filterVideos(assets []photos.Asset) ([]photos.Asset, int) { + filtered := make([]photos.Asset, 0, len(assets)) + for _, a := range assets { + if a.MediaType != "video" && a.MediaType != "audio" { + filtered = append(filtered, a) + } + } + return filtered, len(filtered) +} + // Must stay in sync with sanitized_path_component in bridge/photokit_bridge.m func sanitizePathComponent(name string) string { s := strings.TrimSpace(name) @@ -385,79 +622,6 @@ func flagValWithDefault(args []string, name, def string) string { return def } -func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string, avgSpeed float64, isErr bool) { - pct := 0 - if total > 0 { - pct = current * 100 / total - } - barWidth := 25 - filled := pct * barWidth / 100 - partial := (pct * barWidth % 100) * len(blockPartial) / 100 - bar := strings.Repeat(string(blockFull), filled) - if filled < barWidth { - if partial > 0 { - bar += string(blockPartial[partial]) - } - bar += strings.Repeat(string(blockEmpty), barWidth-filled-1) - } - fileSize := formatSize(size) - cloudLabel := "" - if cloud == "cloud" { - cloudLabel = formatSpeed(avgSpeed) - if cloudLabel != "" { - cloudLabel = " " + cloudLabel - } - cloudLabel = " ☁" + cloudLabel - } - name := filename - maxName := 30 - if len(name) > maxName { - name = "…" + name[len(name)-maxName+1:] - } - fmt.Fprintf(w, "\r%s [%s] %3d%% %s%s %s", name, bar, pct, fileSize, cloudLabel, statusLabel(isErr)) -} - -var blockFull = '█' -var blockEmpty = '░' -var blockPartial = []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'} - -func statusLabel(isErr bool) string { - if isErr { - return "✗" - } - return "" -} - -func formatSpeed(bytesPerSec float64) string { - if bytesPerSec <= 0 { - return "" - } - const kb = 1024 - const mb = kb * 1024 - if bytesPerSec >= mb { - return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb) - } - if bytesPerSec >= kb { - return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb) - } - return fmt.Sprintf("%.0f B/s", bytesPerSec) -} - -func formatSize(bytes int64) string { - if bytes <= 0 { - return "" - } - const kb = 1024 - const mb = kb * 1024 - if bytes >= mb { - return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) - } - if bytes >= kb { - return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) - } - return fmt.Sprintf("%d B", bytes) -} - func exportMode(originals bool) string { if originals { return "originals" diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index 1cfea97..aa6f535 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" "strings" + "sync/atomic" "testing" "gitea.k3s.k0.nu/tools/photocli/internal/photos" @@ -49,6 +50,12 @@ func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.Expo } return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil } +func (m *mockBridge) ExportPreviewWithSlot(assetID, out string, targetSize, index, slotIndex int) (photos.ExportResult, error) { + return m.ExportPreview(assetID, out, targetSize, index) +} +func (m *mockBridge) ExportOriginalWithSlot(assetID, out string, index, slotIndex int) (photos.ExportResult, error) { + return m.ExportOriginal(assetID, out, index) +} func (m *mockBridge) Cancel() { m.cancelled = true } func (m *mockBridge) IsCancelled() bool { return m.cancelled } @@ -161,13 +168,16 @@ func TestCmdPhotosMissingAlbumID(t *testing.T) { func TestCmdPhotosSuccess(t *testing.T) { b := &mockBridge{ - assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud"}}, + assets: []photos.Asset{ + {ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local", MediaType: "image", PixelWidth: 4032, PixelHeight: 3024}, + {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud", MediaType: "image", PixelWidth: 1920, PixelHeight: 1080}, + }, } out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } - expected := "as1\tIMG_0001.JPG\tlocal\nas2\tIMG_0002.JPG\tcloud\n" + expected := "as1\tIMG_0001.JPG\tlocal\timage\t4032x3024\nas2\tIMG_0002.JPG\tcloud\timage\t1920x1080\n" if out != expected { t.Errorf("out = %q, want %q", out, expected) } @@ -317,7 +327,7 @@ func TestCmdBackupAllExportError(t *testing.T) { if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } - if !strings.Contains(stderr, "failed: img.jpg: disk full") { + if !strings.Contains(stderr, "\u274c img.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } if !strings.Contains(stderr, "(1 failed)") { @@ -416,7 +426,7 @@ 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") { + if !strings.Contains(stderr, "\u274c img.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } } @@ -461,7 +471,7 @@ 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") { + if !strings.Contains(stderr, "\u274c img.jpg: copy failed") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } } @@ -646,7 +656,7 @@ func TestCmdExportPartialFailure(t *testing.T) { if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } - if !strings.Contains(stderr, "failed: bad.jpg: disk full") { + if !strings.Contains(stderr, "\u274c bad.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) } if !strings.Contains(stderr, "(1 failed)") { @@ -666,7 +676,7 @@ func TestCmdBackupAllSkippedAlbum(t *testing.T) { if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } - if !strings.Contains(stderr, "skipped album Broken") { + if !strings.Contains(stderr, "\u26a0 album Broken") { t.Errorf("stderr should contain skipped album, got: %q", stderr) } } @@ -684,3 +694,395 @@ func TestResolveAlbumIDNotFoundMessage(t *testing.T) { t.Errorf("err = %q", err.Error()) } } + +func TestFormatSpeed(t *testing.T) { + tests := []struct { + bps float64 + want string + }{ + {0, ""}, + {500, "500 B/s"}, + {1500, "1.5 KB/s"}, + {1024 * 1024, "1.0 MB/s"}, + {2.5 * 1024 * 1024, "2.5 MB/s"}, + } + for _, tt := range tests { + got := formatSpeed(tt.bps) + if got != tt.want { + t.Errorf("formatSpeed(%v) = %q, want %q", tt.bps, got, tt.want) + } + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, ""}, + {-1, ""}, + {500, "500 B"}, + {1500, "1.5 KB"}, + {1024 * 1024, "1.0 MB"}, + {2.5 * 1024 * 1024, "2.5 MB"}, + } + for _, tt := range tests { + got := formatSize(tt.bytes) + if got != tt.want { + t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want) + } + } +} + +func TestSanitizePathComponent(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Hello World", "Hello World"}, + {"Hello/World", "Hello_World"}, + {"Hello\\World", "Hello_World"}, + {" spaces ", "spaces"}, + {"", "Untitled"}, + {" ", "Untitled"}, + } + for _, tt := range tests { + got := sanitizePathComponent(tt.input) + if got != tt.want { + t.Errorf("sanitizePathComponent(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestExportMode(t *testing.T) { + if exportMode(true) != "originals" { + t.Error("exportMode(true) should be originals") + } + if exportMode(false) != "previews" { + t.Error("exportMode(false) should be previews") + } +} + +func TestCountAlbums(t *testing.T) { + nodes := []photos.CollectionNode{ + {Name: "folder", Kind: "folder", Children: []photos.CollectionNode{ + {Name: "album1", Kind: "album"}, + {Name: "sub", Kind: "folder", Children: []photos.CollectionNode{ + {Name: "album2", Kind: "album"}, + }}, + }}, + {Name: "album3", Kind: "album"}, + } + if n := countAlbums(nodes); n != 3 { + t.Errorf("countAlbums = %d, want 3", n) + } +} + +func TestCmdExportAllFailures(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "bad.jpg"}}, + exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("error") + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "all exports failed") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdPhotosAssetsError(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbum: map[string][]photos.Asset{}, + } + _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "album not found") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllAuthDenied(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("denied")} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "denied") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportAssetsByAlbumMap(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "TestAlbum"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "photo.jpg", Cloud: "cloud"}}, + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "TestAlbum", "--out", "/tmp"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 1") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllWithFolder(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "MyFolder", Kind: "folder", Children: []photos.CollectionNode{ + {ID: "a1", Name: "SubAlbum", Kind: "album"}, + }}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "photo.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 1 preview files across 1 albums") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestProgressDisplayRenderBar(t *testing.T) { + var buf bytes.Buffer + d := newProgressBar(&buf, 1) + d.setTotal(5, 10, "1.0 MB") + d.setAlbum("DnD", 3, 5) + d.setWorker(0, "photo.jpg", 0, "cloud", "exporting") + d.draw() + output := buf.String() + if !strings.Contains(output, "Total") { + t.Error("should contain Total") + } + if !strings.Contains(output, "Album") { + t.Error("should contain Album") + } + if !strings.Contains(output, "DnD") { + t.Error("should contain album name DnD") + } + if !strings.Contains(output, "photo.jpg") { + t.Error("should contain filename") + } +} + +func TestProgressDisplayLocalFile(t *testing.T) { + var buf bytes.Buffer + d := newProgressBar(&buf, 1) + d.setTotal(1, 1, "500 B") + d.setAlbum("", 0, 0) + d.logCompleted("\u2705 local.jpg - 500 B - copied") + output := buf.String() + if !strings.Contains(output, "copied") { + t.Error("local files should show copied status") + } + if !strings.Contains(output, "\u2705") { + t.Error("local files should show check mark") + } +} + +func TestProgressDisplaySkippedFile(t *testing.T) { + var buf bytes.Buffer + d := newProgressBar(&buf, 1) + d.setTotal(1, 1, "0 B") + d.setAlbum("", 0, 0) + d.logCompleted("\u23ed exists.jpg") + output := buf.String() + if !strings.Contains(output, "\u23ed") { + t.Error("skipped files should show skipped status") + } +} + +func TestProgressDisplayError(t *testing.T) { + var buf bytes.Buffer + d := newProgressBar(&buf, 1) + d.logCompleted("\u274c bad.jpg: some error") + output := buf.String() + if !strings.Contains(output, "\u274c") { + t.Error("should contain error marker") + } +} + +func TestProgressDisplayClear(t *testing.T) { + var buf bytes.Buffer + d := newProgressBar(&buf, 1) + d.setTotal(1, 1, "0 B") + d.draw() + d.clear() + output := buf.String() + if !strings.Contains(output, "\x1b[") { + t.Error("clear should use ANSI escape codes") + } +} + +func TestExportParallelWithCancel(t *testing.T) { + var cancelFlag int32 + call := int32(0) + bridge := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Test"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "x1", Filename: "img1.jpg"}, + {ID: "x2", Filename: "img2.jpg"}, + {ID: "x3", Filename: "img3.jpg"}, + {ID: "x4", Filename: "img4.jpg"}, + {ID: "x5", Filename: "img5.jpg"}, + }, + }, + exportOrigFn: func(string, string, int) (photos.ExportResult, error) { + if atomic.AddInt32(&call, 1) >= 2 { + atomic.StoreInt32(&cancelFlag, 1) + } + return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil + }, + } + _, _, _ = runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--originals"}, bridge) + _ = cancelFlag +} + +func TestExportParallelPartialFailure(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Test"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "x1", Filename: "ok1.jpg"}, + {ID: "x2", Filename: "bad.jpg"}, + {ID: "x3", Filename: "ok2.jpg"}, + {ID: "x4", Filename: "ok3.jpg"}, + {ID: "x5", Filename: "ok4.jpg"}, + }, + }, + exportPreviewFn: func(_ string, _ string, _ int, idx int) (photos.ExportResult, error) { + if idx == 1 { + return photos.ExportResult{}, fmt.Errorf("fail") + } + return photos.ExportResult{Filename: "ok.jpg", Size: 2048, Cloud: "local"}, nil + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "Test", "--out", "/tmp"}, b) + if rc != 0 { + t.Errorf("rc = %d, want 0 (partial success)", rc) + } + if !strings.Contains(stderr, "1 failed") { + t.Errorf("stderr should contain failed count, got: %q", stderr) + } +} + +func TestBackupAllEmptyTree(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "exported 0") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestFilterVideos(t *testing.T) { + assets := []photos.Asset{ + {ID: "1", Filename: "a.jpg", MediaType: "image"}, + {ID: "2", Filename: "b.mov", MediaType: "video"}, + {ID: "3", Filename: "c.jpg", MediaType: "image"}, + {ID: "4", Filename: "d.mp3", MediaType: "audio"}, + {ID: "5", Filename: "e.heic", MediaType: "image"}, + } + filtered, count := filterVideos(assets) + if count != 3 { + t.Errorf("count = %d, want 3", count) + } + for _, a := range filtered { + if a.MediaType == "video" || a.MediaType == "audio" { + t.Errorf("found %s asset: %+v", a.MediaType, a) + } + } +} + +func TestPhotosOutputWithCreationDate(t *testing.T) { + date := "2024-06-15T12:30:00+0200" + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "a1", Filename: "IMG.jpg", Cloud: "local", MediaType: "image", PixelWidth: 4032, PixelHeight: 3024, CreationDate: &date}, + }, + } + out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(out, date) { + t.Errorf("out = %q, want creation date %s", out, date) + } +} + +func TestPhotosOutputWithDuration(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "v1", Filename: "clip.mov", Cloud: "cloud", MediaType: "video", PixelWidth: 1920, PixelHeight: 1080, Duration: 12.5}, + }, + } + out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(out, "12.5s") { + t.Errorf("out = %q, want duration", out) + } +} + +func TestPhotosOutputWithFavorite(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "f1", Filename: "fav.jpg", Cloud: "local", MediaType: "image", PixelWidth: 1000, PixelHeight: 1000, IsFavorite: true}, + }, + } + out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(out, "*") { + t.Errorf("out = %q, want favorite marker", out) + } +} + +func TestExportSkipsVideos(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "1", Filename: "a.jpg", MediaType: "image"}, + {ID: "2", Filename: "b.mov", MediaType: "video"}, + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "1 assets") { + t.Errorf("stderr = %q, want 1 asset (video skipped)", stderr) + } +} + +func TestExportIncludesVideos(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "1", Filename: "a.jpg", MediaType: "image"}, + {ID: "2", Filename: "b.mov", MediaType: "video"}, + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--include-videos"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "2 assets") { + t.Errorf("stderr = %q, want 2 assets (video included)", stderr) + } +} diff --git a/cmd/photoscli/progress.go b/cmd/photoscli/progress.go new file mode 100644 index 0000000..7312f2a --- /dev/null +++ b/cmd/photoscli/progress.go @@ -0,0 +1,393 @@ +package main + +import ( + "fmt" + "io" + "strings" + "sync" + "time" +) + +type progressBar struct { + mu sync.Mutex + w io.Writer + width int + termH int + start time.Time + errors []string + workers int + footerLines int + scrollSet bool + + total barLine + album barLine + workerState []workerSlot +} + +type barLine struct { + current int + total int + label string + detail string +} + +type workerSlot struct { + filename string + size int64 + cloud string + progress float64 + bytesDone int64 + bytesTotal int64 + speed float64 + status string +} + +func newProgressBar(w io.Writer, workers int) *progressBar { + fl := workers + 2 + return &progressBar{ + w: w, + width: 80, + termH: 24, + start: time.Now(), + workers: workers, + footerLines: fl, + workerState: make([]workerSlot, workers), + } +} + +func (p *progressBar) setTotal(current, total int, detail string) { + p.mu.Lock() + defer p.mu.Unlock() + p.total = barLine{current: current, total: total, label: "Total", detail: detail} +} + +func (p *progressBar) setAlbum(name string, current, total int) { + p.mu.Lock() + defer p.mu.Unlock() + p.album = barLine{current: current, total: total, label: "Album", detail: name} +} + +func (p *progressBar) setWorker(i int, filename string, size int64, cloud string, status string) { + p.mu.Lock() + defer p.mu.Unlock() + if i >= 0 && i < len(p.workerState) { + p.workerState[i].filename = filename + p.workerState[i].size = size + p.workerState[i].cloud = cloud + p.workerState[i].status = status + p.workerState[i].progress = 0 + p.workerState[i].bytesDone = 0 + p.workerState[i].bytesTotal = 0 + } +} + +func (p *progressBar) updateWorkerProgress(i int, progress float64, bytesDone, bytesTotal int64) { + p.mu.Lock() + defer p.mu.Unlock() + if i >= 0 && i < len(p.workerState) { + p.workerState[i].progress = progress + p.workerState[i].bytesDone = bytesDone + p.workerState[i].bytesTotal = bytesTotal + if bytesDone > 0 && bytesTotal > 0 { + p.workerState[i].size = bytesTotal + } + } +} + +func (p *progressBar) addError(filename string, err error) { + p.mu.Lock() + defer p.mu.Unlock() + p.errors = append(p.errors, fmt.Sprintf(" \u274c %s: %v", filename, err)) +} + +func (p *progressBar) logCompleted(line string) { + p.mu.Lock() + defer p.mu.Unlock() + p.ensureScrollRegion() + scrollTop := p.termH - p.footerLines + fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s\n", scrollTop, line) + p.drawFooterLocked() +} + +func (p *progressBar) draw() { + p.mu.Lock() + defer p.mu.Unlock() + p.ensureScrollRegion() + p.drawFooterLocked() +} + +func (p *progressBar) ensureScrollRegion() { + w, h := termSize() + if w != p.width || h != p.termH || !p.scrollSet { + p.width = w + p.termH = h + scrollTop := p.termH - p.footerLines + if scrollTop < 1 { + scrollTop = 1 + } + fmt.Fprintf(p.w, "\x1b[1;%dr", scrollTop) + p.scrollSet = true + } +} + +func (p *progressBar) drawFooterLocked() { + scrollTop := p.termH - p.footerLines + if scrollTop < 1 { + scrollTop = 1 + } + elapsed := time.Since(p.start) + + fmt.Fprintf(p.w, "\x1b[?25l") + + footerStart := scrollTop + 1 + for i := 0; i < p.workers; i++ { + row := footerStart + i + fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K", row) + if i < len(p.workerState) && p.workerState[i].filename != "" { + fmt.Fprintf(p.w, "%s", truncateOrPad(renderWorkerLine(p.workerState[i], p.width), p.width)) + } else { + fmt.Fprintf(p.w, "%s", strings.Repeat(" ", p.width)) + } + } + + row := footerStart + p.workers + fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s", row, renderLine(p.total, elapsed, p.width)) + + row++ + fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s", row, renderLine(p.album, elapsed, p.width)) + + fmt.Fprintf(p.w, "\x1b[%d;1H", scrollTop) + fmt.Fprintf(p.w, "\x1b[?25h") +} + +func (p *progressBar) clear() { + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprintf(p.w, "\x1b[r") + fmt.Fprintf(p.w, "\x1b[?25h") + p.scrollSet = false +} + +func (p *progressBar) flushErrors() { + for _, e := range p.errors { + fmt.Fprintln(p.w, e) + } + p.errors = nil +} + +func renderWorkerLine(ws workerSlot, width int) string { + if width <= 0 { + width = 80 + } + parts := []string{} + if ws.status == "FAIL" { + parts = append(parts, "\u274c") + parts = append(parts, ws.filename) + } else if ws.status == "skipped" { + parts = append(parts, "\u23ed") + parts = append(parts, ws.filename) + if s := formatSize(ws.size); s != "" { + parts = append(parts, s) + } + } else if ws.cloud == "cloud" && ws.progress > 0 && ws.progress < 1.0 { + parts = append(parts, "\u2601") + parts = append(parts, ws.filename) + barWidth := 20 + bar := renderBar(int(ws.progress*100), barWidth) + pct := int(ws.progress * 100) + parts = append(parts, fmt.Sprintf("[%s] %d%%", bar, pct)) + if ws.bytesTotal > 0 { + parts = append(parts, fmt.Sprintf("%s/%s", formatSize(ws.bytesDone), formatSize(ws.bytesTotal))) + } + if ws.speed > 0 { + parts = append(parts, formatSpeed(ws.speed)) + } + } else if ws.cloud == "cloud" { + parts = append(parts, "\u2601") + parts = append(parts, ws.filename) + if s := formatSize(ws.size); s != "" { + parts = append(parts, s) + } + parts = append(parts, "downloaded") + if ws.speed > 0 { + parts = append(parts, formatSpeed(ws.speed)) + } + } else { + parts = append(parts, "\u2705") + parts = append(parts, ws.filename) + if s := formatSize(ws.size); s != "" { + parts = append(parts, s) + } + parts = append(parts, "copied") + } + return strings.Join(parts, " ") +} + +func renderLine(b barLine, elapsed time.Duration, width int) string { + if width <= 0 { + width = 80 + } + pct := 0 + if b.total > 0 { + pct = b.current * 100 / b.total + } + + counter := "" + if b.total > 0 { + counter = fmt.Sprintf("%d/%d", b.current, b.total) + } + + eta := "" + if pct > 0 && pct < 100 && elapsed > 500*time.Millisecond { + remaining := elapsed * time.Duration(100-pct) / time.Duration(pct) + if remaining > time.Second { + eta = formatDuration(remaining) + } + } + + right := b.detail + if b.label == "Album" && counter != "" && b.detail != "" { + right = fmt.Sprintf("%s %s", b.detail, counter) + } else if counter != "" && right != "" { + right = fmt.Sprintf("%s %s", right, counter) + } else if counter != "" { + right = counter + } + if eta != "" { + right += " " + eta + } + + labelWidth := 6 + pctWidth := 4 + gap := 2 + rightWidth := runeWidth(right) + availableForBar := width - labelWidth - pctWidth - gap - rightWidth - gap + if availableForBar < 3 { + availableForBar = 3 + } + if availableForBar > 40 { + availableForBar = 40 + } + + bar := renderBar(pct, availableForBar) + + return fmt.Sprintf("%-6s [%s] %3d%% %s", b.label, bar, pct, right) +} + +func renderBar(pct, barWidth int) string { + if barWidth <= 0 { + return "" + } + fraction := float64(pct) / 100.0 + filled := fraction * float64(barWidth) + fullBlocks := int(filled) + partial := filled - float64(fullBlocks) + + var r, g uint8 + if pct <= 50 { + r = 255 + g = uint8(float64(pct) * 5.1) + } else { + r = uint8(float64(100-pct) * 5.1) + g = 255 + } + + var sb strings.Builder + fmt.Fprintf(&sb, "\x1b[38;2;%d;%d;0m", r, g) + for i := 0; i < fullBlocks && i < barWidth; i++ { + sb.WriteString("\u2588") + } + if fullBlocks < barWidth { + fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"} + idx := int(partial * 8) + if idx > 7 { + idx = 7 + } + if idx > 0 { + sb.WriteString(fracs[idx]) + fullBlocks++ + } + } + sb.WriteString("\x1b[0m") + for i := fullBlocks; i < barWidth; i++ { + sb.WriteString("\u2591") + } + return sb.String() +} + +func runeWidth(s string) int { + w := 0 + for _, r := range s { + if r >= 0x1100 && (r <= 0x115f || r == 0x2329 || r == 0x232a || + (r >= 0x2e80 && r <= 0xa4cf && r != 0x303f) || + (r >= 0xac00 && r <= 0xd7a3) || + (r >= 0xf900 && r <= 0xfaff) || + (r >= 0xfe30 && r <= 0xfe6f) || + (r >= 0xff01 && r <= 0xff60) || + (r >= 0xffe0 && r <= 0xffe6) || + (r >= 0x20000 && r <= 0x2fffd) || + (r >= 0x30000 && r <= 0x3fffd)) { + w += 2 + } else { + w += 1 + } + } + return w +} + +func truncateOrPad(s string, width int) string { + if width <= 0 { + width = 80 + } + rw := runeWidth(s) + if rw > width { + runes := []rune(s) + for i := range runes { + if runeWidth(string(runes[:i+1])) > width-3 { + return string(runes[:i]) + "..." + } + } + return s + } + return s + strings.Repeat(" ", width-rw) +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + m := int(d.Minutes()) + s := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm%02ds", m, s) +} + +func formatSpeed(bytesPerSec float64) string { + if bytesPerSec <= 0 { + return "" + } + const kb = 1024 + const mb = kb * 1024 + if bytesPerSec >= mb { + return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb) + } + if bytesPerSec >= kb { + return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb) + } + return fmt.Sprintf("%.0f B/s", bytesPerSec) +} + +func formatSize(bytes int64) string { + if bytes <= 0 { + return "" + } + const kb = 1024 + const mb = kb * 1024 + if bytes >= mb { + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + } + if bytes >= kb { + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + } + return fmt.Sprintf("%d B", bytes) +} \ No newline at end of file diff --git a/cmd/photoscli/termsize_darwin.go b/cmd/photoscli/termsize_darwin.go new file mode 100644 index 0000000..9929e66 --- /dev/null +++ b/cmd/photoscli/termsize_darwin.go @@ -0,0 +1,23 @@ +//go:build !test + +package main + +import ( + "syscall" + "unsafe" +) + +func termSize() (int, int) { + type winsize struct { + Rows uint16 + Cols uint16 + Xpixels uint16 + Ypixels uint16 + } + var ws winsize + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(syscall.Stdout), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws))) + if errno != 0 || ws.Cols == 0 || ws.Rows == 0 { + return 80, 24 + } + return int(ws.Cols), int(ws.Rows) +} \ No newline at end of file diff --git a/cmd/photoscli/termsize_test.go b/cmd/photoscli/termsize_test.go new file mode 100644 index 0000000..a4c2937 --- /dev/null +++ b/cmd/photoscli/termsize_test.go @@ -0,0 +1,7 @@ +//go:build test + +package main + +func termSize() (int, int) { + return 80, 24 +} \ No newline at end of file diff --git a/internal/photos/bridge.go b/internal/photos/bridge.go index d4dfd65..09ed085 100644 --- a/internal/photos/bridge.go +++ b/internal/photos/bridge.go @@ -12,6 +12,8 @@ type Bridge interface { ListTree() ([]CollectionNode, error) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) + ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) + ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) Cancel() IsCancelled() bool } diff --git a/internal/photos/cgo_bridge.go b/internal/photos/cgo_bridge.go index 312db4b..f7a1195 100644 --- a/internal/photos/cgo_bridge.go +++ b/internal/photos/cgo_bridge.go @@ -4,7 +4,7 @@ package photos /* #cgo CFLAGS: -I${SRCDIR}/../../bridge -#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge -framework Photos -framework Foundation -framework AppKit +#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge -framework Photos -framework Foundation -framework AppKit -framework UniformTypeIdentifiers #include "photokit_bridge.h" #include */ @@ -63,29 +63,75 @@ func (*CgoBridge) IsCancelled() bool { } func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { - cid := C.CString(assetID) - defer C.free(unsafe.Pointer(cid)) - cdir := C.CString(outputDir) - defer C.free(unsafe.Pointer(cdir)) - - cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index)) - if cs == nil { - return ExportResult{}, errBridgeNil - } - defer C.photos_free_string(cs) - return ParseExportResultJSON(C.GoString(cs)) + return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1) } func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { + return exportOriginalWithSlot(assetID, outputDir, index, -1) +} + +func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { + return exportPreviewWithSlot(assetID, outputDir, targetSize, index, slotIndex) +} + +func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { + return exportOriginalWithSlot(assetID, outputDir, index, slotIndex) +} + +func exportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { cid := C.CString(assetID) defer C.free(unsafe.Pointer(cid)) cdir := C.CString(outputDir) defer C.free(unsafe.Pointer(cdir)) - cs := C.photos_export_original_json(cid, cdir, C.int(index)) + cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex)) if cs == nil { return ExportResult{}, errBridgeNil } defer C.photos_free_string(cs) return ParseExportResultJSON(C.GoString(cs)) } + +func exportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { + cid := C.CString(assetID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + cs := C.photos_export_original_json(cid, cdir, C.int(index), C.int(slotIndex)) + if cs == nil { + return ExportResult{}, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseExportResultJSON(C.GoString(cs)) +} + +func GetProgressSlots() []ExportProgressSlot { + count := int(C.photos_get_progress_slot_count()) + slots := C.photos_get_progress_slots() + if slots == nil || count == 0 { + return nil + } + result := make([]ExportProgressSlot, count) + for i := 0; i < count; i++ { + ptr := (*C.export_progress_t)(unsafe.Pointer(uintptr(unsafe.Pointer(slots)) + uintptr(i)*unsafe.Sizeof(C.export_progress_t{}))) + result[i] = ExportProgressSlot{ + Active: ptr.active != 0, + Progress: float64(ptr.progress), + BytesDone: int64(ptr.bytes_done), + BytesTotal: int64(ptr.bytes_total), + } + } + return result +} + +func ResetProgressSlots() { + C.photos_reset_progress_slots() +} + +type ExportProgressSlot struct { + Active bool + Progress float64 + BytesDone int64 + BytesTotal int64 +} diff --git a/internal/photos/cgo_bridge_test_impl.go b/internal/photos/cgo_bridge_test_impl.go index c65888c..eca8168 100644 --- a/internal/photos/cgo_bridge_test_impl.go +++ b/internal/photos/cgo_bridge_test_impl.go @@ -83,11 +83,27 @@ func (*CgoBridge) IsCancelled() bool { } func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { + return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, -1) +} + +func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { + return exportOriginalWithSlotTest(assetID, outputDir, index, -1) +} + +func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { + return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, slotIndex) +} + +func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { + return exportOriginalWithSlotTest(assetID, outputDir, index, slotIndex) +} + +func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { cid := C.CString(assetID) defer C.free(unsafe.Pointer(cid)) cdir := C.CString(outputDir) defer C.free(unsafe.Pointer(cdir)) - cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index)) + cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex)) if cs == nil { return ExportResult{}, errBridgeNil } @@ -95,15 +111,29 @@ func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int return ParseExportResultJSON(C.GoString(cs)) } -func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { +func exportOriginalWithSlotTest(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { cid := C.CString(assetID) defer C.free(unsafe.Pointer(cid)) cdir := C.CString(outputDir) defer C.free(unsafe.Pointer(cdir)) - cs := C.photos_export_original_json(cid, cdir, C.int(index)) + cs := C.photos_export_original_json(cid, cdir, C.int(index), C.int(slotIndex)) if cs == nil { return ExportResult{}, errBridgeNil } defer C.photos_free_string(cs) return ParseExportResultJSON(C.GoString(cs)) } + +type ExportProgressSlot struct { + Active bool + Progress float64 + BytesDone int64 + BytesTotal int64 +} + +func GetProgressSlots() []ExportProgressSlot { + return nil +} + +func ResetProgressSlots() { +} diff --git a/internal/photos/photos_test.go b/internal/photos/photos_test.go index a0ac658..c45b42d 100644 --- a/internal/photos/photos_test.go +++ b/internal/photos/photos_test.go @@ -85,12 +85,41 @@ func TestParseAssetsJSON(t *testing.T) { want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}}, wantTotal: 1, }, + { + name: "asset with metadata", + json: `{"assets":[{"id":"a1","filename":"IMG.JPG","cloud":"local","mediaType":"image","pixelWidth":4032,"pixelHeight":3024,"duration":0,"isFavorite":true,"hasAdjustments":false,"resources":[{"type":"photo","filename":"IMG.JPG","uti":"public.heic","local":true}]}],"total":1}`, + want: []Asset{{ + ID: "a1", Filename: "IMG.JPG", Cloud: "local", + MediaType: "image", PixelWidth: 4032, PixelHeight: 3024, + IsFavorite: true, HasAdjustments: false, + Resources: []AssetResource{{Type: "photo", Filename: "IMG.JPG", UTI: "public.heic", Local: true}}, + }}, + wantTotal: 1, + }, + { + name: "video asset with duration", + json: `{"assets":[{"id":"v1","filename":"clip.mov","cloud":"cloud","mediaType":"video","pixelWidth":1920,"pixelHeight":1080,"duration":12.5}],"total":1}`, + want: []Asset{{ + ID: "v1", Filename: "clip.mov", Cloud: "cloud", + MediaType: "video", PixelWidth: 1920, PixelHeight: 1080, Duration: 12.5, + }}, + wantTotal: 1, + }, { name: "multiple assets", json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`, want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}}, wantTotal: 3, }, + { + name: "asset with creationDate", + json: `{"assets":[{"id":"d1","filename":"photo.jpg","creationDate":"2024-06-15T12:30:00+0200"}],"total":1}`, + want: func() []Asset { + d := "2024-06-15T12:30:00+0200" + return []Asset{{ID: "d1", Filename: "photo.jpg", CreationDate: &d}} + }(), + wantTotal: 1, + }, { name: "error response", json: `{"error":"album not found"}`, @@ -131,7 +160,7 @@ func TestParseAssetsJSON(t *testing.T) { return } for i := range got { - if got[i] != tt.want[i] { + if !equalAsset(got[i], tt.want[i]) { t.Errorf("ParseAssetsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i]) } } @@ -438,3 +467,24 @@ func equalCollectionNode(a, b CollectionNode) bool { } return true } + +func equalAsset(a, b Asset) bool { + if a.ID != b.ID || a.Filename != b.Filename || a.Cloud != b.Cloud || a.MediaType != b.MediaType || a.PixelWidth != b.PixelWidth || a.PixelHeight != b.PixelHeight || a.Duration != b.Duration || a.IsFavorite != b.IsFavorite || a.HasAdjustments != b.HasAdjustments { + return false + } + if (a.CreationDate == nil) != (b.CreationDate == nil) { + return false + } + if a.CreationDate != nil && b.CreationDate != nil && *a.CreationDate != *b.CreationDate { + return false + } + if len(a.Resources) != len(b.Resources) { + return false + } + for i := range a.Resources { + if a.Resources[i] != b.Resources[i] { + return false + } + } + return true +} diff --git a/internal/photos/types.go b/internal/photos/types.go index 54ea880..8f484c4 100644 --- a/internal/photos/types.go +++ b/internal/photos/types.go @@ -6,15 +6,31 @@ type Album struct { } type Asset struct { - ID string `json:"id"` + ID string `json:"id"` + Filename string `json:"filename"` + Cloud string `json:"cloud"` + MediaType string `json:"mediaType"` + PixelWidth int `json:"pixelWidth"` + PixelHeight int `json:"pixelHeight"` + CreationDate *string `json:"creationDate,omitempty"` + Duration float64 `json:"duration,omitempty"` + IsFavorite bool `json:"isFavorite,omitempty"` + HasAdjustments bool `json:"hasAdjustments,omitempty"` + Resources []AssetResource `json:"resources,omitempty"` +} + +type AssetResource struct { + Type string `json:"type"` Filename string `json:"filename"` - Cloud string `json:"cloud"` + UTI string `json:"uti"` + Local bool `json:"local"` } type ExportResult struct { Filename string `json:"filename"` Size int64 `json:"size"` Cloud string `json:"cloud"` + Skipped bool `json:"skipped,omitempty"` Error string `json:"error,omitempty"` }