Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 009c71e6bb | |||
| b2d4c6188d | |||
| 27ff1b5c83 |
@@ -1,6 +1,6 @@
|
|||||||
BINARY := ./bin/photoscli
|
BINARY := ./bin/photoscli
|
||||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||||
VERSION := 0.2.0
|
VERSION := 0.2.3
|
||||||
BRIDGE_DIR := bridge
|
BRIDGE_DIR := bridge
|
||||||
LDFLAGS := -X main.version=$(VERSION)
|
LDFLAGS := -X main.version=$(VERSION)
|
||||||
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ static NSDictionary *make_error_dict(NSString *message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) {
|
static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) {
|
||||||
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, seconds * NSEC_PER_SEC);
|
int64_t deadline = (int64_t)[NSDate timeIntervalSinceReferenceDate] + seconds;
|
||||||
return dispatch_semaphore_wait(sem, timeout) == 0;
|
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) {
|
static NSDictionary *collection_to_dict(PHCollection *collection) {
|
||||||
@@ -267,6 +274,10 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
|||||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||||
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
|
if (!nsAssetId || !nsOutputDir) return 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"));
|
||||||
|
}
|
||||||
|
|
||||||
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
||||||
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
||||||
|
|
||||||
@@ -315,7 +326,8 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
|||||||
|
|
||||||
NSError *writeErr = nil;
|
NSError *writeErr = nil;
|
||||||
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
|
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
|
||||||
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
|
NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error";
|
||||||
|
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)});
|
||||||
}
|
}
|
||||||
|
|
||||||
NSNumber *fileSize = nil;
|
NSNumber *fileSize = nil;
|
||||||
@@ -338,6 +350,10 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
|||||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||||
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
|
if (!nsAssetId || !nsOutputDir) return 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"));
|
||||||
|
}
|
||||||
|
|
||||||
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
|
||||||
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
|
||||||
|
|
||||||
@@ -375,7 +391,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (writeErr) {
|
if (writeErr) {
|
||||||
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
|
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)});
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString *writtenFilename = [filepath lastPathComponent];
|
NSString *writtenFilename = [filepath lastPathComponent];
|
||||||
|
|||||||
+40
-35
@@ -67,10 +67,12 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
|
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
|
||||||
|
fmt.Fprintln(stderr, "requesting photo library access...")
|
||||||
if err := bridge.RequestAccess(); err != nil {
|
if err := bridge.RequestAccess(); err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(stderr, "access granted")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
|||||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||||
return rc
|
return rc
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(stderr, "loading albums...")
|
||||||
albums, err := bridge.ListAlbums()
|
albums, err := bridge.ListAlbums()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
@@ -179,12 +182,14 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stderr, "loading assets for album %s...\n", albumID)
|
||||||
assets, total, err := bridge.ListAssets(resolved)
|
assets, total, err := bridge.ListAssets(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
||||||
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
|
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
|
||||||
|
|
||||||
if exported == 0 && failed > 0 {
|
if exported == 0 && failed > 0 {
|
||||||
@@ -226,12 +231,14 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stderr, "loading photo library tree...")
|
||||||
nodes, err := bridge.ListTree()
|
nodes, err := bridge.ListTree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
albumCount := countAlbums(nodes)
|
albumCount := countAlbums(nodes)
|
||||||
|
fmt.Fprintf(stderr, "found %d albums, exporting to %s...\n", albumCount, outDir)
|
||||||
|
|
||||||
totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
|
totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -273,6 +280,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
|
|||||||
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
|
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal)
|
||||||
total += assetTotal
|
total += assetTotal
|
||||||
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
|
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
|
||||||
exported += n
|
exported += n
|
||||||
@@ -282,17 +290,6 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
|
|||||||
return exported, total, failed, nil
|
return exported, total, failed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportJob struct {
|
|
||||||
asset photos.Asset
|
|
||||||
index int
|
|
||||||
}
|
|
||||||
|
|
||||||
type exportResult struct {
|
|
||||||
index int
|
|
||||||
result photos.ExportResult
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
|
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 {
|
if len(assets) == 0 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
@@ -322,50 +319,51 @@ func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, or
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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) {
|
||||||
jobs := make(chan exportJob, len(assets))
|
type slot struct {
|
||||||
results := make(chan exportResult, len(assets))
|
result photos.ExportResult
|
||||||
|
err error
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
slots := make([]slot, len(assets))
|
||||||
|
for i := range slots {
|
||||||
|
slots[i].done = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := make(chan int, len(assets))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for w := 0; w < workers; w++ {
|
for w := 0; w < workers; w++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for job := range jobs {
|
for i := range jobs {
|
||||||
result, exportErr := exportOne(bridge, job.asset, outDir, targetSize, originals, job.index)
|
result, exportErr := exportOne(bridge, assets[i], outDir, targetSize, originals, i)
|
||||||
results <- exportResult{index: job.index, result: result, err: exportErr}
|
slots[i].result = result
|
||||||
|
slots[i].err = exportErr
|
||||||
|
close(slots[i].done)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
for i := range assets {
|
||||||
for i, a := range assets {
|
jobs <- i
|
||||||
jobs <- exportJob{asset: a, index: i}
|
|
||||||
}
|
|
||||||
close(jobs)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
wg.Wait()
|
|
||||||
close(results)
|
|
||||||
}()
|
|
||||||
|
|
||||||
ordered := make([]exportResult, len(assets))
|
|
||||||
for r := range results {
|
|
||||||
ordered[r.index] = r
|
|
||||||
}
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
exported := 0
|
exported := 0
|
||||||
failed := 0
|
failed := 0
|
||||||
for i, a := range assets {
|
for i, a := range assets {
|
||||||
r := ordered[i]
|
<-slots[i].done
|
||||||
progressBar(stderr, exported+failed+1, total, dirPrefix+r.result.Filename, r.result.Size, r.result.Cloud)
|
s := slots[i]
|
||||||
if r.err != nil {
|
progressBar(stderr, exported+failed+1, total, dirPrefix+s.result.Filename, s.result.Size, s.result.Cloud)
|
||||||
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, r.err)
|
if s.err != nil {
|
||||||
|
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, s.err)
|
||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
exported++
|
exported++
|
||||||
}
|
}
|
||||||
|
wg.Wait()
|
||||||
return exported, failed
|
return exported, failed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,3 +450,10 @@ func formatSize(bytes int64) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportMode(originals bool) string {
|
||||||
|
if originals {
|
||||||
|
return "originals"
|
||||||
|
}
|
||||||
|
return "previews"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user