v0.4.0: scroll log, worker slots, disk skip, color gradient, parallel export

This commit is contained in:
Ein Anderssono
2026-06-12 14:03:18 +02:00
parent e888f7cad1
commit 3d3c4a4742
15 changed files with 1609 additions and 181 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.2.5 VERSION := 0.4.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
@@ -31,7 +31,7 @@ build: $(LIB)
test: $(STUB_LIB) test: $(STUB_LIB)
go vet -tags=test ./... 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 @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
+11 -5
View File
@@ -37,7 +37,7 @@ The bridge uses PhotoKit to:
- Go 1.22+ - Go 1.22+
- Xcode command-line tools - 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 ## Build
@@ -106,9 +106,13 @@ Example output:
- Requests Photos access - Requests Photos access
- Walks the Photos folder and album hierarchy - 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` - Creates directories as `out/folder/album/files`
- Exports previews by default, originals when `--originals` is present - 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 - Uses `--size` only for preview export
Example layout: Example layout:
@@ -128,7 +132,7 @@ backup/
- Requests Photos access - Requests Photos access
- Resolves `--album-id` by local identifier first, then by album title if not found - Resolves `--album-id` by local identifier first, then by album title if not found
- Creates the output directory if needed - 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 - Shows file size and cloud/local status for each exported asset
- Exports resized JPEG previews by default - Exports resized JPEG previews by default
- Exports original files when `--originals` is present - 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. `--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` `tree`
- Requests Photos access - Requests Photos access
@@ -191,7 +197,7 @@ A second signal forces an immediate exit.
## Architecture ## 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 - `internal/photos`: Go bridge interface, JSON parsing, and error mapping
- `bridge/`: Objective-C PhotoKit implementation plus a C test stub - `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 - 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 - iCloud-backed assets may require network download during export
- A second interrupt signal forces an immediate exit without waiting for the current file - 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
+17 -2
View File
@@ -1,10 +1,19 @@
#ifndef PHOTOKIT_BRIDGE_H #ifndef PHOTOKIT_BRIDGE_H
#define PHOTOKIT_BRIDGE_H #define PHOTOKIT_BRIDGE_H
#include <stdint.h>
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
typedef struct {
int active;
double progress;
int64_t bytes_done;
int64_t bytes_total;
} export_progress_t;
int photos_request_access(void); int photos_request_access(void);
char *photos_list_albums_json(void); char *photos_list_albums_json(void);
@@ -15,13 +24,15 @@ char *photos_export_preview_json(
const char *asset_id, const char *asset_id,
const char *output_dir, const char *output_dir,
int target_size, int target_size,
int index int index,
int slot_index
); );
char *photos_export_original_json( char *photos_export_original_json(
const char *asset_id, const char *asset_id,
const char *output_dir, const char *output_dir,
int index int index,
int slot_index
); );
char *photos_list_tree_json(void); char *photos_list_tree_json(void);
@@ -32,6 +43,10 @@ int photos_request_is_cancelled(void);
void photos_free_string(char *value); 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 #ifdef __cplusplus
} }
#endif #endif
+289 -32
View File
@@ -1,10 +1,24 @@
#import <Foundation/Foundation.h> #import <Foundation/Foundation.h>
#import <AppKit/AppKit.h> #import <AppKit/AppKit.h>
#import <Photos/Photos.h> #import <Photos/Photos.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <objc/message.h>
#import "photokit_bridge.h" #import "photokit_bridge.h"
static volatile int photos_cancelled = 0; 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) { static NSDictionary *make_error_dict(NSString *message) {
return @{@"error": message}; return @{@"error": message};
} }
@@ -192,6 +206,60 @@ char *photos_list_albums_json(void) {
return json_from_object(@{@"albums": list}); 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 <objc/message.h>
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) { static NSString *asset_cloud_status_string(PHAsset *asset) {
@try { @try {
id cloudId = [asset performSelector:@selector(cloudIdentifier)]; id cloudId = [asset performSelector:@selector(cloudIdentifier)];
@@ -200,16 +268,17 @@ static NSString *asset_cloud_status_string(PHAsset *asset) {
} }
} @catch (NSException *exception) { } @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) { if (resource) {
@try { if (resource_is_locally_available(resource)) {
id locallyAvailable = [resource performSelector:@selector(isLocallyAvailable)]; return @"local";
if (locallyAvailable && [locallyAvailable boolValue]) {
return @"local";
}
return @"cloud";
} @catch (NSException *exception) {
} }
return @"cloud";
} }
return @"local"; return @"local";
} }
@@ -236,16 +305,53 @@ char *photos_list_assets_json(const char *album_id) {
PHAsset *asset = assets[i]; PHAsset *asset = assets[i];
NSString *lid = asset.localIdentifier; NSString *lid = asset.localIdentifier;
NSString *filename = nil; NSString *filename = nil;
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset]; NSArray<PHAssetResource *> *resources = nil;
@try {
resources = [PHAssetResource assetResourcesForAsset:asset];
} @catch (NSException *e) {
resources = @[];
}
if (resources.count > 0) { if (resources.count > 0) {
filename = resources.firstObject.originalFilename; filename = resources.firstObject.originalFilename;
} }
NSString *cloudStatus = asset_cloud_status_string(asset); 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 ?: @"", @"id": lid ?: @"",
@"filename": filename ?: @"", @"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)}); 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) { #define RETURN_PREVIEW(x) do { reset_slot(slot_index); return (x); } while(0)
if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required"));
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; if (target_size <= 0) target_size = 1024;
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")); if (!nsAssetId || !nsOutputDir) RETURN_PREVIEW(json_from_object(make_error_dict(@"invalid UTF-8 in arguments")));
if (!ensure_directory(nsOutputDir)) { 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<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_PREVIEW(json_from_object(make_error_dict(@"asset not found")));
PHAsset *asset = fetch.firstObject; 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]; PHImageManager *im = [PHImageManager defaultManager];
CGFloat scale = (CGFloat)target_size; CGFloat scale = (CGFloat)target_size;
CGSize targetCGSize = CGSizeMake(scale, scale); 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.resizeMode = PHImageRequestOptionsResizeModeExact;
imgOpts.networkAccessAllowed = YES; imgOpts.networkAccessAllowed = YES;
imgOpts.synchronous = 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); dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block NSData *imageData = nil; __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)) { 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) { 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 *safe = sanitized_asset_identifier(asset.localIdentifier);
NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe]; NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe];
NSString *filepath = [nsOutputDir stringByAppendingPathComponent:filename]; 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; NSError *writeErr = nil;
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error"; 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; NSNumber *fileSize = nil;
@@ -336,32 +470,47 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
fileSize = attrs[NSFileSize]; fileSize = attrs[NSFileSize];
} }
return json_from_object(@{ RETURN_PREVIEW(json_from_object(@{
@"filename": filename, @"filename": filename,
@"size": fileSize ?: @0, @"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset) @"cloud": asset_cloud_status_string(asset)
}); }));
} }
#undef RETURN_PREVIEW
char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) { #define RETURN_ORIGINAL(x) do { reset_slot(slot_index); return (x); } while(0)
if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required"));
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 *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")); if (!nsAssetId || !nsOutputDir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"invalid UTF-8 in arguments")));
if (!ensure_directory(nsOutputDir)) { 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<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_ORIGINAL(json_from_object(make_error_dict(@"asset not found")));
PHAsset *asset = fetch.firstObject; PHAsset *asset = fetch.firstObject;
NSArray<PHAssetResource *> *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<PHAssetResource *> *resources = nil;
@try {
resources = [PHAssetResource assetResourcesForAsset:asset];
} @catch (NSException *e) {
resources = @[];
}
if (resources.count == 0) { 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; 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); 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]; NSURL *fileURL = [NSURL fileURLWithPath:filepath];
if ([fm fileExistsAtPath:filepath]) {
[fm removeItemAtPath:filepath error:nil];
}
PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager];
PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init];
opts.networkAccessAllowed = YES; 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); dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block NSError *writeErr = nil; __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); dispatch_semaphore_signal(sem);
}]; }];
if (!semaphore_wait_with_timeout(sem, 120)) { 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) { 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]; NSString *writtenFilename = [filepath lastPathComponent];
@@ -401,12 +645,13 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
fileSize = attrs[NSFileSize]; fileSize = attrs[NSFileSize];
} }
return json_from_object(@{ RETURN_ORIGINAL(json_from_object(@{
@"filename": writtenFilename, @"filename": writtenFilename,
@"size": fileSize ?: @0, @"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset) @"cloud": asset_cloud_status_string(asset)
}); }));
} }
#undef RETURN_ORIGINAL
void photos_free_string(char *value) { void photos_free_string(char *value) {
if (value) free(value); if (value) free(value);
@@ -419,3 +664,15 @@ void photos_request_cancel(void) {
int photos_request_is_cancelled(void) { int photos_request_is_cancelled(void) {
return photos_cancelled; 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));
}
+19 -2
View File
@@ -1,5 +1,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "../bridge/photokit_bridge.h"
static export_progress_t stub_progress_slots[3];
static char *alloc_json(const char *s) { static char *alloc_json(const char *s) {
size_t len = strlen(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); 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)asset_id;
(void)output_dir; (void)output_dir;
(void)target_size; (void)target_size;
(void)index; (void)index;
(void)slot_index;
return maybe_alloc_json(stub_export_preview_json); 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)asset_id;
(void)output_dir; (void)output_dir;
(void)index; (void)index;
(void)slot_index;
return maybe_alloc_json(stub_export_original_json); 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) { void photos_test_set_export_original_json(const char *json) {
stub_export_original_json = 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));
} }
+276 -112
View File
@@ -3,7 +3,10 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"os"
"path/filepath"
"strings" "strings"
"sync"
"time" "time"
"gitea.k3s.k0.nu/tools/photocli/internal/photos" "gitea.k3s.k0.nu/tools/photocli/internal/photos"
@@ -47,8 +50,8 @@ Usage:
photoscli albums photoscli albums
photoscli photos --album-id <id> photoscli photos --album-id <id>
photoscli tree photoscli tree
photoscli backup-all --out <dir> [--size <px>] [--originals] photoscli backup-all --out <dir> [--size <px>] [--originals] [--include-videos]
photoscli export --album-id <id> --out <dir> [--size <px>] [--originals] photoscli export --album-id <id> --out <dir> [--size <px>] [--originals] [--include-videos]
photoscli version photoscli version
Commands: Commands:
@@ -60,10 +63,11 @@ Commands:
version Print version version Print version
Flags: Flags:
--album-id <id> Album local identifier or title (required for photos/export) --album-id <id> Album local identifier or title (required for photos/export)
--out <dir> Output directory (required for export/backup-all) --out <dir> Output directory (required for export/backup-all)
--size <px> Target longest-side in pixels (default: 1024, preview export only) --size <px> Target longest-side in pixels (default: 1024, preview export only)
--originals Export original files instead of JPEG previews`) --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 { 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 return 1
} }
for _, a := range assets { 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 return 0
} }
@@ -153,6 +167,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
albumID := flagVal(args, "--album-id") albumID := flagVal(args, "--album-id")
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
originals := hasFlag(args, "--originals") originals := hasFlag(args, "--originals")
skipVideos := !hasFlag(args, "--include-videos")
sizeStr := flagValWithDefault(args, "--size", "1024") sizeStr := flagValWithDefault(args, "--size", "1024")
if albumID == "" { if albumID == "" {
@@ -189,6 +204,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return 1 return 1
} }
if skipVideos {
assets, total = filterVideos(assets)
}
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir) fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "") exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
@@ -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 { func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
originals := hasFlag(args, "--originals") originals := hasFlag(args, "--originals")
skipVideos := !hasFlag(args, "--include-videos")
sizeStr := flagValWithDefault(args, "--size", "1024") sizeStr := flagValWithDefault(args, "--size", "1024")
if outDir == "" { if outDir == "" {
@@ -238,9 +258,9 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
return 1 return 1
} }
albumCount := countAlbums(nodes) 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 { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return 1 return 1
@@ -258,72 +278,279 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
return 0 return 0
} }
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, int, error) { type pendingAsset struct {
exported := 0 asset photos.Asset
total := 0 path string
failed := 0 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 { for _, node := range nodes {
if bridge.IsCancelled() { if bridge.IsCancelled() {
break return
} }
path := outDir + "/" + sanitizePathComponent(node.Name) path := outDir + "/" + sanitizePathComponent(node.Name)
if node.Kind == "folder" { if node.Kind == "folder" {
n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, stderr)
if err != nil {
return exported, total, failed, err
}
exported += n
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, _, err := bridge.ListAssets(node.ID)
if err != nil { 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 continue
} }
fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal) if skipVideos {
total += assetTotal assets, _ = filterVideos(assets)
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/") }
exported += n for _, a := range assets {
failed += f 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) { func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
exported := 0 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 failed := 0
var totalBytes int64 var totalBytes int64
var totalDur time.Duration var totalDur time.Duration
for i, a := range assets {
for i, pa := range pending {
if bridge.IsCancelled() { if bridge.IsCancelled() {
break 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() 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) dur := time.Since(start)
if exportErr == nil { isErr := exportErr != nil
isSkipped := result.Skipped
if !isErr && !isSkipped {
totalBytes += result.Size totalBytes += result.Size
totalDur += dur totalDur += dur
} }
if isErr {
failed++
} else {
done++
}
avgSpeed := float64(0) avgSpeed := float64(0)
if totalDur > 0 { if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds() avgSpeed = float64(totalBytes) / totalDur.Seconds()
} }
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil) bar.setTotal(done+failed, total, "")
if exportErr != nil { bar.setAlbum(pa.album, 0, 0)
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr) bar.setWorker(0, "", 0, "", "")
failed++ 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 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 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) { func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) {
if originals { if originals {
return bridge.ExportOriginal(a.ID, outDir, index) 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) 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 // 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)
@@ -385,79 +622,6 @@ func flagValWithDefault(args []string, name, def string) string {
return def 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 { func exportMode(originals bool) string {
if originals { if originals {
return "originals" return "originals"
+409 -7
View File
@@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"gitea.k3s.k0.nu/tools/photocli/internal/photos" "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 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) Cancel() { m.cancelled = true }
func (m *mockBridge) IsCancelled() bool { return m.cancelled } func (m *mockBridge) IsCancelled() bool { return m.cancelled }
@@ -161,13 +168,16 @@ 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", 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) out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 0 { if rc != 0 {
t.Errorf("rc = %d", rc) 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 { if out != expected {
t.Errorf("out = %q, want %q", out, expected) t.Errorf("out = %q, want %q", out, expected)
} }
@@ -317,7 +327,7 @@ 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)
} }
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) t.Errorf("stderr should contain failure detail, got: %q", stderr)
} }
if !strings.Contains(stderr, "(1 failed)") { if !strings.Contains(stderr, "(1 failed)") {
@@ -416,7 +426,7 @@ 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") { if !strings.Contains(stderr, "\u274c img.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr) 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") { 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") { if !strings.Contains(stderr, "\u274c img.jpg: copy failed") {
t.Errorf("stderr should contain failure detail, got: %q", stderr) t.Errorf("stderr should contain failure detail, got: %q", stderr)
} }
} }
@@ -646,7 +656,7 @@ func TestCmdExportPartialFailure(t *testing.T) {
if rc != 0 { if rc != 0 {
t.Errorf("rc = %d, stderr = %q", rc, stderr) 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) t.Errorf("stderr should contain failure detail, got: %q", stderr)
} }
if !strings.Contains(stderr, "(1 failed)") { if !strings.Contains(stderr, "(1 failed)") {
@@ -666,7 +676,7 @@ func TestCmdBackupAllSkippedAlbum(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)
} }
if !strings.Contains(stderr, "skipped album Broken") { if !strings.Contains(stderr, "\u26a0 album Broken") {
t.Errorf("stderr should contain skipped album, got: %q", stderr) 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()) 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)
}
}
+393
View File
@@ -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)
}
+23
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build test
package main
func termSize() (int, int) {
return 80, 24
}
+2
View File
@@ -12,6 +12,8 @@ type Bridge interface {
ListTree() ([]CollectionNode, error) ListTree() ([]CollectionNode, 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)
ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error)
ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error)
Cancel() Cancel()
IsCancelled() bool IsCancelled() bool
} }
+59 -13
View File
@@ -4,7 +4,7 @@ package photos
/* /*
#cgo CFLAGS: -I${SRCDIR}/../../bridge #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 "photokit_bridge.h"
#include <stdlib.h> #include <stdlib.h>
*/ */
@@ -63,29 +63,75 @@ func (*CgoBridge) IsCancelled() bool {
} }
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID) return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1)
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))
} }
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { 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) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir) cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir)) 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 { if cs == nil {
return ExportResult{}, errBridgeNil return ExportResult{}, errBridgeNil
} }
defer C.photos_free_string(cs) defer C.photos_free_string(cs)
return ParseExportResultJSON(C.GoString(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
}
+33 -3
View File
@@ -83,11 +83,27 @@ func (*CgoBridge) IsCancelled() bool {
} }
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { 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) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir) cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir)) 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 { if cs == nil {
return ExportResult{}, errBridgeNil return ExportResult{}, errBridgeNil
} }
@@ -95,15 +111,29 @@ func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int
return ParseExportResultJSON(C.GoString(cs)) 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) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir) cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir)) 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 { if cs == nil {
return ExportResult{}, errBridgeNil return ExportResult{}, errBridgeNil
} }
defer C.photos_free_string(cs) defer C.photos_free_string(cs)
return ParseExportResultJSON(C.GoString(cs)) return ParseExportResultJSON(C.GoString(cs))
} }
type ExportProgressSlot struct {
Active bool
Progress float64
BytesDone int64
BytesTotal int64
}
func GetProgressSlots() []ExportProgressSlot {
return nil
}
func ResetProgressSlots() {
}
+51 -1
View File
@@ -85,12 +85,41 @@ func TestParseAssetsJSON(t *testing.T) {
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}}, want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
wantTotal: 1, 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", name: "multiple assets",
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`, 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"}}, want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
wantTotal: 3, 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", name: "error response",
json: `{"error":"album not found"}`, json: `{"error":"album not found"}`,
@@ -131,7 +160,7 @@ func TestParseAssetsJSON(t *testing.T) {
return return
} }
for i := range got { 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]) 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 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
}
+18 -2
View File
@@ -6,15 +6,31 @@ type Album struct {
} }
type Asset 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"` Filename string `json:"filename"`
Cloud string `json:"cloud"` UTI string `json:"uti"`
Local bool `json:"local"`
} }
type ExportResult struct { type ExportResult struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Size int64 `json:"size"` Size int64 `json:"size"`
Cloud string `json:"cloud"` Cloud string `json:"cloud"`
Skipped bool `json:"skipped,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }