initial commit: applephotos CLI with progress, cloud status, per-asset export
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Photos/Photos.h>
|
||||
#import "photokit_bridge.h"
|
||||
|
||||
static volatile int photos_cancelled = 0;
|
||||
|
||||
static NSDictionary *make_error_dict(NSString *message) {
|
||||
return @{@"error": message};
|
||||
}
|
||||
|
||||
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;
|
||||
return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
|
||||
attributes:nil error:&dirErr];
|
||||
}
|
||||
|
||||
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 PHFetchResult<PHAsset *> *fetch_assets_for_album(const char *album_id) {
|
||||
if (!album_id) return nil;
|
||||
|
||||
NSString *nsAlbumId = [NSString stringWithUTF8String:album_id];
|
||||
PHFetchResult<PHAssetCollection *> *albums =
|
||||
[PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId]
|
||||
options:nil];
|
||||
if (albums.count == 0) return nil;
|
||||
|
||||
return fetch_assets_for_collection(albums.firstObject);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static int export_preview_assets(PHFetchResult<PHAsset *> *assets, NSString *outputDir, int target_size) {
|
||||
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;
|
||||
|
||||
__block int exported = 0;
|
||||
__block int failed = 0;
|
||||
|
||||
for (NSUInteger i = 0; i < assets.count; i++) {
|
||||
if (photos_cancelled) break;
|
||||
PHAsset *asset = assets[i];
|
||||
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||
__block NSData *imageData = nil;
|
||||
|
||||
[im requestImageForAsset:asset
|
||||
targetSize:targetCGSize
|
||||
contentMode:PHImageContentModeAspectFit
|
||||
options:imgOpts
|
||||
resultHandler:^(NSImage *result, NSDictionary *info) {
|
||||
if (info[PHImageErrorKey]) {
|
||||
dispatch_semaphore_signal(sem);
|
||||
return;
|
||||
}
|
||||
if (result) {
|
||||
imageData = nsimage_to_jpeg(result, 0.85);
|
||||
}
|
||||
dispatch_semaphore_signal(sem);
|
||||
}];
|
||||
|
||||
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
||||
|
||||
if (!imageData) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *safe = sanitized_asset_identifier(asset.localIdentifier);
|
||||
NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)i, safe];
|
||||
NSString *filepath = [outputDir stringByAppendingPathComponent:filename];
|
||||
|
||||
NSError *writeErr = nil;
|
||||
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
exported++;
|
||||
}
|
||||
|
||||
return (failed > 0 && exported == 0) ? -4 : exported;
|
||||
}
|
||||
|
||||
static int export_original_assets(PHFetchResult<PHAsset *> *assets, NSString *outputDir) {
|
||||
PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager];
|
||||
PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init];
|
||||
opts.networkAccessAllowed = YES;
|
||||
|
||||
__block int exported = 0;
|
||||
__block int failed = 0;
|
||||
|
||||
for (NSUInteger i = 0; i < assets.count; i++) {
|
||||
if (photos_cancelled) break;
|
||||
PHAsset *asset = assets[i];
|
||||
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
|
||||
if (resources.count == 0) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
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(outputDir, filename, i);
|
||||
NSURL *fileURL = [NSURL fileURLWithPath:filepath];
|
||||
|
||||
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);
|
||||
}];
|
||||
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
||||
|
||||
if (writeErr) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
exported++;
|
||||
}
|
||||
|
||||
return (failed > 0 && exported == 0) ? -4 : exported;
|
||||
}
|
||||
|
||||
static int backup_collection(PHCollection *collection, NSString *outputDir, int target_size, BOOL originals) {
|
||||
NSString *path = [outputDir stringByAppendingPathComponent:sanitized_path_component(collection.localizedTitle)];
|
||||
|
||||
if ([collection isKindOfClass:[PHCollectionList class]]) {
|
||||
PHCollectionList *list = (PHCollectionList *)collection;
|
||||
PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list options:nil];
|
||||
__block int total = 0;
|
||||
__block BOOL failedAny = NO;
|
||||
for (NSUInteger i = 0; i < children.count; i++) {
|
||||
if (photos_cancelled) break;
|
||||
int rc = backup_collection(children[i], path, target_size, originals);
|
||||
if (rc >= 0) {
|
||||
total += rc;
|
||||
} else if (rc == -4) {
|
||||
failedAny = YES;
|
||||
} else {
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
if (failedAny && total == 0) {
|
||||
return -4;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
if ([collection isKindOfClass:[PHAssetCollection class]]) {
|
||||
if (!ensure_directory(path)) {
|
||||
return -2;
|
||||
}
|
||||
PHFetchResult<PHAsset *> *assets = fetch_assets_for_collection((PHAssetCollection *)collection);
|
||||
return originals ? export_original_assets(assets, path) : export_preview_assets(assets, path, target_size);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
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);
|
||||
}];
|
||||
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
||||
}
|
||||
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 *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 = [PHAssetResource assetResourcesForAsset:asset].firstObject;
|
||||
if (resource) {
|
||||
@try {
|
||||
id locallyAvailable = [resource performSelector:@selector(isLocallyAvailable)];
|
||||
if (locallyAvailable && [locallyAvailable boolValue]) {
|
||||
return @"local";
|
||||
}
|
||||
return @"cloud";
|
||||
} @catch (NSException *exception) {
|
||||
}
|
||||
}
|
||||
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];
|
||||
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 = [PHAssetResource assetResourcesForAsset:asset];
|
||||
if (resources.count > 0) {
|
||||
filename = resources.firstObject.originalFilename;
|
||||
}
|
||||
NSString *cloudStatus = asset_cloud_status_string(asset);
|
||||
[list addObject:@{
|
||||
@"id": lid ?: @"",
|
||||
@"filename": filename ?: @"",
|
||||
@"cloud": cloudStatus
|
||||
}];
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
int photos_export_album_previews(const char *album_id, const char *output_dir, int target_size) {
|
||||
if (!album_id || !output_dir) return -1;
|
||||
if (target_size <= 0) target_size = 1024;
|
||||
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
|
||||
if (!ensure_directory(nsOutputDir)) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
PHFetchResult<PHAsset *> *assets = fetch_assets_for_album(album_id);
|
||||
if (!assets) return -3;
|
||||
|
||||
return export_preview_assets(assets, nsOutputDir, target_size);
|
||||
}
|
||||
|
||||
int photos_export_album_originals(const char *album_id, const char *output_dir) {
|
||||
if (!album_id || !output_dir) return -1;
|
||||
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
if (!ensure_directory(nsOutputDir)) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
PHFetchResult<PHAsset *> *assets = fetch_assets_for_album(album_id);
|
||||
if (!assets) return -3;
|
||||
|
||||
return export_original_assets(assets, nsOutputDir);
|
||||
}
|
||||
|
||||
int photos_backup_all(const char *output_dir, int target_size, int originals) {
|
||||
if (!output_dir) return -1;
|
||||
if (!originals && target_size <= 0) target_size = 1024;
|
||||
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
if (!ensure_directory(nsOutputDir)) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
|
||||
__block int total = 0;
|
||||
__block BOOL failedAny = NO;
|
||||
|
||||
for (NSUInteger i = 0; i < collections.count; i++) {
|
||||
if (photos_cancelled) break;
|
||||
int rc = backup_collection(collections[i], nsOutputDir, target_size, originals != 0);
|
||||
if (rc >= 0) {
|
||||
total += rc;
|
||||
} else if (rc == -4) {
|
||||
failedAny = YES;
|
||||
} else {
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedAny && total == 0) {
|
||||
return -4;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
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"));
|
||||
if (target_size <= 0) target_size = 1024;
|
||||
|
||||
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
|
||||
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
||||
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
||||
|
||||
PHAsset *asset = fetch.firstObject;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}];
|
||||
|
||||
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
||||
|
||||
if (!imageData) {
|
||||
return 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];
|
||||
|
||||
NSError *writeErr = nil;
|
||||
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
|
||||
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
|
||||
}
|
||||
|
||||
NSNumber *fileSize = nil;
|
||||
NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil];
|
||||
if (attrs) {
|
||||
fileSize = attrs[NSFileSize];
|
||||
}
|
||||
|
||||
return json_from_object(@{
|
||||
@"filename": filename,
|
||||
@"size": fileSize ?: @0,
|
||||
@"cloud": asset_cloud_status_string(asset)
|
||||
});
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
|
||||
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
||||
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
||||
|
||||
PHAsset *asset = fetch.firstObject;
|
||||
|
||||
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
|
||||
if (resources.count == 0) {
|
||||
return 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);
|
||||
NSURL *fileURL = [NSURL fileURLWithPath:filepath];
|
||||
|
||||
PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager];
|
||||
PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init];
|
||||
opts.networkAccessAllowed = YES;
|
||||
|
||||
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);
|
||||
}];
|
||||
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
||||
|
||||
if (writeErr) {
|
||||
return json_from_object(@{@"error": @"write failed", @"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 json_from_object(@{
|
||||
@"filename": writtenFilename,
|
||||
@"size": fileSize ?: @0,
|
||||
@"cloud": asset_cloud_status_string(asset)
|
||||
});
|
||||
}
|
||||
|
||||
void photos_free_string(char *value) {
|
||||
if (value) free(value);
|
||||
}
|
||||
|
||||
void photos_request_cancel(void) {
|
||||
photos_cancelled = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user