Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d3c4a4742 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,19 @@
|
||||
#ifndef PHOTOKIT_BRIDGE_H
|
||||
#define PHOTOKIT_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
|
||||
+289
-32
@@ -1,10 +1,24 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Photos/Photos.h>
|
||||
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||
#import <objc/message.h>
|
||||
#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 <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) {
|
||||
@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<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
|
||||
NSArray<PHAssetResource *> *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<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;
|
||||
|
||||
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<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;
|
||||
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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));
|
||||
}
|
||||
+276
-112
@@ -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 <id>
|
||||
photoscli tree
|
||||
photoscli backup-all --out <dir> [--size <px>] [--originals]
|
||||
photoscli export --album-id <id> --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] [--include-videos]
|
||||
photoscli version
|
||||
|
||||
Commands:
|
||||
@@ -60,10 +63,11 @@ Commands:
|
||||
version Print version
|
||||
|
||||
Flags:
|
||||
--album-id <id> Album local identifier or title (required for photos/export)
|
||||
--out <dir> Output directory (required for export/backup-all)
|
||||
--size <px> Target longest-side in pixels (default: 1024, preview export only)
|
||||
--originals Export original files instead of JPEG previews`)
|
||||
--album-id <id> Album local identifier or title (required for photos/export)
|
||||
--out <dir> Output directory (required for export/backup-all)
|
||||
--size <px> 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"
|
||||
|
||||
+409
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build test
|
||||
|
||||
package main
|
||||
|
||||
func termSize() (int, int) {
|
||||
return 80, 24
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <stdlib.h>
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user