Compare commits

2 Commits

Author SHA1 Message Date
Ein Anderssono 3d3c4a4742 v0.4.0: scroll log, worker slots, disk skip, color gradient, parallel export 2026-06-12 14:03:18 +02:00
Ein Anderssono e888f7cad1 v0.2.5: Unicode progress bar with cloud download speed
- Unicode block progress bar (█▓░)
- Cloud downloads show ☁ with average speed (MB/s, KB/s, B/s)
- Truncated filenames with ellipsis for long names
- Error indicator (✗) in progress bar
- Simplified to serial export for clean cancel behavior
- Added IsCancelled() to Bridge interface
2026-06-11 21:59:42 +02:00
15 changed files with 1594 additions and 170 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.2.4
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
+11 -5
View File
@@ -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
+17 -2
View File
@@ -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
View File
@@ -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));
}
+19 -2
View File
@@ -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));
}
+261 -101
View File
@@ -3,8 +3,11 @@ 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,126 +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) {
if len(assets) == 0 {
return 0, 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
}
if len(assets) < 4 {
return exportAssetsSerial(assets, outDir, targetSize, originals, total, stderr, bridge, dirPrefix)
}
return exportAssetsParallel(assets, outDir, targetSize, originals, total, stderr, bridge, 3, dirPrefix)
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 exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
exported := 0
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
for i, a := range assets {
var totalBytes int64
var totalDur time.Duration
for i, pa := range pending {
if bridge.IsCancelled() {
break
}
result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i)
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud)
if exportErr != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
failed++
continue
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, pa.asset, pa.path, targetSize, originals, i)
dur := time.Since(start)
isErr := exportErr != nil
isSkipped := result.Skipped
if !isErr && !isSkipped {
totalBytes += result.Size
totalDur += dur
}
exported++
if isErr {
failed++
} else {
done++
}
avgSpeed := float64(0)
if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds()
}
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 exported, failed
return done, failed
}
func exportAssetsParallel(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, workers int, dirPrefix string) (int, int) {
type slot struct {
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
done chan struct{}
pa pendingAsset
dur time.Duration
}
slots := make([]slot, len(assets))
for i := range slots {
slots[i].done = make(chan struct{})
}
completed := make(chan resultEntry, len(pending))
jobs := make(chan int, len(assets))
jobs := make(chan int, len(pending))
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
go func(workerID int) {
defer wg.Done()
for i := range jobs {
if bridge.IsCancelled() {
slots[i].err = fmt.Errorf("cancelled")
close(slots[i].done)
completed <- resultEntry{err: fmt.Errorf("cancelled"), pa: pending[i]}
continue
}
result, exportErr := exportOne(bridge, assets[i], outDir, targetSize, originals, i)
slots[i].result = result
slots[i].err = exportErr
close(slots[i].done)
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)
}
for i := range assets {
jobs <- i
}
close(jobs)
go func() {
for i := range pending {
if bridge.IsCancelled() {
break
}
jobs <- i
}
close(jobs)
}()
exported := 0
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
for i, a := range assets {
<-slots[i].done
s := slots[i]
progressBar(stderr, exported+failed+1, total, dirPrefix+s.result.Filename, s.result.Size, s.result.Cloud)
if s.err != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, s.err)
failed++
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)
@@ -385,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)
@@ -439,29 +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) {
pct := 0
if total > 0 {
pct = current * 100 / total
}
barWidth := 30
filled := pct * barWidth / 100
bar := strings.Repeat("=", filled) + strings.Repeat(" ", barWidth-filled)
fmt.Fprintf(w, "\r[%s] %3d%% %s %s %s", bar, pct, filename, formatSize(size), cloud)
}
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))
}
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
}
func exportMode(originals bool) string {
if originals {
return "originals"
+409 -7
View File
@@ -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)
}
}
+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)
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
}
+59 -13
View File
@@ -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
}
+33 -3
View File
@@ -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() {
}
+51 -1
View File
@@ -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
}
+18 -2
View File
@@ -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"`
}