679 lines
26 KiB
Objective-C
679 lines
26 KiB
Objective-C
#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};
|
|
}
|
|
|
|
static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) {
|
|
int64_t deadline = (int64_t)[NSDate timeIntervalSinceReferenceDate] + seconds;
|
|
while (1) {
|
|
if (photos_cancelled) return NO;
|
|
int64_t remaining = deadline - (int64_t)[NSDate timeIntervalSinceReferenceDate];
|
|
if (remaining <= 0) return NO;
|
|
int64_t waitSecs = remaining < 1 ? remaining : 1;
|
|
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, waitSecs * NSEC_PER_SEC);
|
|
if (dispatch_semaphore_wait(sem, timeout) == 0) return YES;
|
|
}
|
|
}
|
|
|
|
static NSDictionary *collection_to_dict(PHCollection *collection) {
|
|
NSString *name = collection.localizedTitle ?: @"Untitled";
|
|
NSString *identifier = collection.localIdentifier ?: @"";
|
|
|
|
if ([collection isKindOfClass:[PHCollectionList class]]) {
|
|
PHCollectionList *list = (PHCollectionList *)collection;
|
|
PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list
|
|
options:nil];
|
|
NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count];
|
|
for (NSUInteger i = 0; i < children.count; i++) {
|
|
NSDictionary *child = collection_to_dict(children[i]);
|
|
if (child) {
|
|
[childList addObject:child];
|
|
}
|
|
}
|
|
return @{
|
|
@"id": identifier,
|
|
@"name": name,
|
|
@"kind": @"folder",
|
|
@"children": childList,
|
|
};
|
|
}
|
|
|
|
if ([collection isKindOfClass:[PHAssetCollection class]]) {
|
|
return @{
|
|
@"id": identifier,
|
|
@"name": name,
|
|
@"kind": @"album",
|
|
@"children": @[],
|
|
};
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
static char *json_from_object(id obj) {
|
|
if (!obj) return NULL;
|
|
NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:0 error:nil];
|
|
if (!data) return NULL;
|
|
const char *utf8 = [data bytes];
|
|
size_t len = [data length];
|
|
char *copy = (char *)malloc(len + 1);
|
|
if (!copy) return NULL;
|
|
memcpy(copy, utf8, len);
|
|
copy[len] = '\0';
|
|
return copy;
|
|
}
|
|
|
|
static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) {
|
|
NSData *result = nil;
|
|
NSBitmapImageRep *rep = nil;
|
|
for (NSImageRep *r in image.representations) {
|
|
if ([r isKindOfClass:[NSBitmapImageRep class]]) {
|
|
rep = (NSBitmapImageRep *)r;
|
|
break;
|
|
}
|
|
}
|
|
if (!rep) {
|
|
rep = [[NSBitmapImageRep alloc] initWithData:[image TIFFRepresentation]];
|
|
}
|
|
if (rep) {
|
|
NSDictionary *props = @{NSImageCompressionMethod: @(NSTIFFCompressionJPEG),
|
|
NSImageCompressionFactor: @(compression)};
|
|
result = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static BOOL ensure_directory(NSString *outputDir) {
|
|
NSFileManager *fm = [NSFileManager defaultManager];
|
|
NSError *dirErr = nil;
|
|
BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
|
|
attributes:nil error:&dirErr];
|
|
if (!ok && dirErr) {
|
|
NSLog(@"ensure_directory failed: %@", dirErr);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *album) {
|
|
PHFetchOptions *opts = [[PHFetchOptions alloc] init];
|
|
opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate"
|
|
ascending:YES]];
|
|
return [PHAsset fetchAssetsInAssetCollection:album options:opts];
|
|
}
|
|
|
|
|
|
static NSString *sanitized_asset_identifier(NSString *assetID) {
|
|
NSMutableString *safe = [assetID mutableCopy];
|
|
NSRange fullRange = NSMakeRange(0, safe.length);
|
|
[safe replaceOccurrencesOfString:@"/"
|
|
withString:@"_"
|
|
options:0
|
|
range:fullRange];
|
|
[safe replaceOccurrencesOfString:@"\\"
|
|
withString:@"_"
|
|
options:0
|
|
range:NSMakeRange(0, safe.length)];
|
|
return safe;
|
|
}
|
|
|
|
static NSString *unique_path_for_filename(NSString *outputDir, NSString *filename, NSUInteger index) {
|
|
NSFileManager *fm = [NSFileManager defaultManager];
|
|
NSString *candidate = [outputDir stringByAppendingPathComponent:filename];
|
|
if (![fm fileExistsAtPath:candidate]) {
|
|
return candidate;
|
|
}
|
|
|
|
NSString *base = [filename stringByDeletingPathExtension];
|
|
NSString *ext = [filename pathExtension];
|
|
NSString *prefixed = ext.length > 0
|
|
? [NSString stringWithFormat:@"%04lu_%@.%@", (unsigned long)index, base, ext]
|
|
: [NSString stringWithFormat:@"%04lu_%@", (unsigned long)index, base];
|
|
return [outputDir stringByAppendingPathComponent:prefixed];
|
|
}
|
|
|
|
// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go
|
|
static NSString *sanitized_path_component(NSString *name) {
|
|
NSString *source = name ?: @"Untitled";
|
|
NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
|
|
if (safe.length == 0) {
|
|
return @"Untitled";
|
|
}
|
|
NSRange fullRange = NSMakeRange(0, safe.length);
|
|
[safe replaceOccurrencesOfString:@"/"
|
|
withString:@"_"
|
|
options:0
|
|
range:fullRange];
|
|
[safe replaceOccurrencesOfString:@"\\"
|
|
withString:@"_"
|
|
options:0
|
|
range:NSMakeRange(0, safe.length)];
|
|
return safe;
|
|
}
|
|
|
|
int photos_request_access(void) {
|
|
__block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
|
|
if (status == PHAuthorizationStatusNotDetermined) {
|
|
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
|
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus s) {
|
|
status = s;
|
|
dispatch_semaphore_signal(sem);
|
|
}];
|
|
if (!semaphore_wait_with_timeout(sem, 30)) {
|
|
return -1;
|
|
}
|
|
}
|
|
return (status == PHAuthorizationStatusAuthorized) ? 0 : -1;
|
|
}
|
|
|
|
char *photos_list_albums_json(void) {
|
|
PHFetchResult<PHAssetCollection *> *albums =
|
|
[PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum
|
|
subtype:PHAssetCollectionSubtypeAny
|
|
options:nil];
|
|
|
|
NSMutableArray *list = [NSMutableArray arrayWithCapacity:albums.count];
|
|
for (NSUInteger i = 0; i < albums.count; i++) {
|
|
PHAssetCollection *album = albums[i];
|
|
NSString *title = album.localizedTitle;
|
|
NSString *lid = album.localIdentifier;
|
|
|
|
[list addObject:@{
|
|
@"id": lid ?: @"",
|
|
@"title": title ?: @""
|
|
}];
|
|
}
|
|
|
|
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)];
|
|
if (cloudId && ![cloudId isEqual:[NSNull null]]) {
|
|
return @"cloud";
|
|
}
|
|
} @catch (NSException *exception) {
|
|
}
|
|
PHAssetResource *resource = nil;
|
|
@try {
|
|
resource = [PHAssetResource assetResourcesForAsset:asset].firstObject;
|
|
} @catch (NSException *e) {
|
|
resource = nil;
|
|
}
|
|
if (resource) {
|
|
if (resource_is_locally_available(resource)) {
|
|
return @"local";
|
|
}
|
|
return @"cloud";
|
|
}
|
|
return @"local";
|
|
}
|
|
|
|
char *photos_list_assets_json(const char *album_id) {
|
|
if (!album_id) return json_from_object(make_error_dict(@"album_id is required"));
|
|
|
|
NSString *nsAlbumId = [NSString stringWithUTF8String:album_id];
|
|
if (!nsAlbumId) return json_from_object(make_error_dict(@"invalid UTF-8 in album_id"));
|
|
PHFetchResult<PHAssetCollection *> *albums =
|
|
[PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId]
|
|
options:nil];
|
|
if (albums.count == 0) return json_from_object(make_error_dict(@"album not found"));
|
|
|
|
PHAssetCollection *album = albums.firstObject;
|
|
PHFetchOptions *opts = [[PHFetchOptions alloc] init];
|
|
opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate"
|
|
ascending:YES]];
|
|
PHFetchResult<PHAsset *> *assets =
|
|
[PHAsset fetchAssetsInAssetCollection:album options:opts];
|
|
|
|
NSMutableArray *list = [NSMutableArray arrayWithCapacity:assets.count];
|
|
for (NSUInteger i = 0; i < assets.count; i++) {
|
|
PHAsset *asset = assets[i];
|
|
NSString *lid = asset.localIdentifier;
|
|
NSString *filename = nil;
|
|
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);
|
|
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,
|
|
@"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)});
|
|
}
|
|
|
|
char *photos_list_tree_json(void) {
|
|
PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
|
|
|
|
NSMutableArray *list = [NSMutableArray arrayWithCapacity:collections.count];
|
|
for (NSUInteger i = 0; i < collections.count; i++) {
|
|
NSDictionary *node = collection_to_dict(collections[i]);
|
|
if (node) {
|
|
[list addObject:node];
|
|
}
|
|
}
|
|
|
|
return json_from_object(@{@"collections": list});
|
|
}
|
|
|
|
|
|
#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_PREVIEW(json_from_object(make_error_dict(@"invalid UTF-8 in arguments")));
|
|
|
|
if (!ensure_directory(nsOutputDir)) {
|
|
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_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);
|
|
PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init];
|
|
imgOpts.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat;
|
|
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;
|
|
__block NSDictionary *errorInfo = nil;
|
|
|
|
[im requestImageForAsset:asset
|
|
targetSize:targetCGSize
|
|
contentMode:PHImageContentModeAspectFit
|
|
options:imgOpts
|
|
resultHandler:^(NSImage *result, NSDictionary *info) {
|
|
if (info[PHImageErrorKey]) {
|
|
errorInfo = info;
|
|
dispatch_semaphore_signal(sem);
|
|
return;
|
|
}
|
|
if (result) {
|
|
imageData = nsimage_to_jpeg(result, 0.85);
|
|
}
|
|
dispatch_semaphore_signal(sem);
|
|
}];
|
|
|
|
if (!semaphore_wait_with_timeout(sem, 120)) {
|
|
RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)}));
|
|
}
|
|
|
|
if (!imageData) {
|
|
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_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)}));
|
|
}
|
|
|
|
NSNumber *fileSize = nil;
|
|
NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil];
|
|
if (attrs) {
|
|
fileSize = attrs[NSFileSize];
|
|
}
|
|
|
|
RETURN_PREVIEW(json_from_object(@{
|
|
@"filename": filename,
|
|
@"size": fileSize ?: @0,
|
|
@"cloud": asset_cloud_status_string(asset)
|
|
}));
|
|
}
|
|
#undef RETURN_PREVIEW
|
|
|
|
#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_ORIGINAL(json_from_object(make_error_dict(@"invalid UTF-8 in arguments")));
|
|
|
|
if (!ensure_directory(nsOutputDir)) {
|
|
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_ORIGINAL(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;
|
|
}
|
|
|
|
NSArray<PHAssetResource *> *resources = nil;
|
|
@try {
|
|
resources = [PHAssetResource assetResourcesForAsset:asset];
|
|
} @catch (NSException *e) {
|
|
resources = @[];
|
|
}
|
|
if (resources.count == 0) {
|
|
RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)}));
|
|
}
|
|
|
|
PHAssetResource *resource = resources.firstObject;
|
|
NSString *filename = resource.originalFilename;
|
|
if (!filename || filename.length == 0) {
|
|
filename = sanitized_asset_identifier(asset.localIdentifier);
|
|
}
|
|
|
|
NSString *filepath = unique_path_for_filename(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;
|
|
[rm writeDataForAssetResource:resource
|
|
toFile:fileURL
|
|
options:opts
|
|
completionHandler:^(NSError * _Nullable error) {
|
|
writeErr = error;
|
|
dispatch_semaphore_signal(sem);
|
|
}];
|
|
if (!semaphore_wait_with_timeout(sem, 120)) {
|
|
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)}));
|
|
}
|
|
|
|
if (writeErr) {
|
|
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];
|
|
NSNumber *fileSize = nil;
|
|
NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil];
|
|
if (attrs) {
|
|
fileSize = attrs[NSFileSize];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
void photos_request_cancel(void) {
|
|
photos_cancelled = 1;
|
|
}
|
|
|
|
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));
|
|
}
|