Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e888f7cad1 | |||
| 479c284dfc | |||
| 009c71e6bb | |||
| b2d4c6188d | |||
| 27ff1b5c83 |
@@ -1,6 +1,6 @@
|
||||
BINARY := ./bin/photoscli
|
||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||
VERSION := 0.2.0
|
||||
VERSION := 0.2.5
|
||||
BRIDGE_DIR := bridge
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
||||
|
||||
@@ -28,6 +28,8 @@ char *photos_list_tree_json(void);
|
||||
|
||||
void photos_request_cancel(void);
|
||||
|
||||
int photos_request_is_cancelled(void);
|
||||
|
||||
void photos_free_string(char *value);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -10,8 +10,15 @@ static NSDictionary *make_error_dict(NSString *message) {
|
||||
}
|
||||
|
||||
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);
|
||||
return dispatch_semaphore_wait(sem, timeout) == 0;
|
||||
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) {
|
||||
@@ -267,6 +274,10 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
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];
|
||||
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;
|
||||
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;
|
||||
@@ -338,6 +350,10 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
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];
|
||||
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) {
|
||||
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];
|
||||
@@ -399,3 +415,7 @@ void photos_free_string(char *value) {
|
||||
void photos_request_cancel(void) {
|
||||
photos_cancelled = 1;
|
||||
}
|
||||
|
||||
int photos_request_is_cancelled(void) {
|
||||
return photos_cancelled;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ void photos_request_cancel(void) {
|
||||
stub_cancelled = 1;
|
||||
}
|
||||
|
||||
int photos_request_is_cancelled(void) {
|
||||
return stub_cancelled;
|
||||
}
|
||||
|
||||
void photos_test_set_export_preview_json(const char *json) {
|
||||
stub_export_preview_json = json;
|
||||
}
|
||||
|
||||
+90
-78
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||
)
|
||||
@@ -67,10 +67,12 @@ Flags:
|
||||
}
|
||||
|
||||
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
|
||||
fmt.Fprintln(stderr, "requesting photo library access...")
|
||||
if err := bridge.RequestAccess(); err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stderr, "access granted")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
fmt.Fprintln(stderr, "loading albums...")
|
||||
albums, err := bridge.ListAlbums()
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
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, "")
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -256,6 +263,9 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
|
||||
total := 0
|
||||
failed := 0
|
||||
for _, node := range nodes {
|
||||
if bridge.IsCancelled() {
|
||||
break
|
||||
}
|
||||
path := outDir + "/" + sanitizePathComponent(node.Name)
|
||||
if node.Kind == "folder" {
|
||||
n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
|
||||
@@ -273,6 +283,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
|
||||
fmt.Fprintf(stderr, "\n skipped 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
|
||||
@@ -282,35 +293,27 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, or
|
||||
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) {
|
||||
if len(assets) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
failed := 0
|
||||
var totalBytes int64
|
||||
var totalDur time.Duration
|
||||
for i, a := range assets {
|
||||
if bridge.IsCancelled() {
|
||||
break
|
||||
}
|
||||
start := time.Now()
|
||||
result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i)
|
||||
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud)
|
||||
dur := time.Since(start)
|
||||
if exportErr == nil {
|
||||
totalBytes += result.Size
|
||||
totalDur += dur
|
||||
}
|
||||
avgSpeed := float64(0)
|
||||
if totalDur > 0 {
|
||||
avgSpeed = float64(totalBytes) / totalDur.Seconds()
|
||||
}
|
||||
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil)
|
||||
if exportErr != nil {
|
||||
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
|
||||
failed++
|
||||
@@ -321,54 +324,6 @@ func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, or
|
||||
return exported, 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) {
|
||||
jobs := make(chan exportJob, len(assets))
|
||||
results := make(chan exportResult, len(assets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for job := range jobs {
|
||||
result, exportErr := exportOne(bridge, job.asset, outDir, targetSize, originals, job.index)
|
||||
results <- exportResult{index: job.index, result: result, err: exportErr}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for i, a := range assets {
|
||||
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
|
||||
}
|
||||
|
||||
exported := 0
|
||||
failed := 0
|
||||
for i, a := range assets {
|
||||
r := ordered[i]
|
||||
progressBar(stderr, exported+failed+1, total, dirPrefix+r.result.Filename, r.result.Size, r.result.Cloud)
|
||||
if r.err != nil {
|
||||
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, r.err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
exported++
|
||||
}
|
||||
return exported, failed
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -430,15 +385,62 @@ func flagValWithDefault(args []string, name, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) {
|
||||
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string, avgSpeed float64, isErr bool) {
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = current * 100 / total
|
||||
}
|
||||
barWidth := 30
|
||||
barWidth := 25
|
||||
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)
|
||||
partial := (pct * barWidth % 100) * len(blockPartial) / 100
|
||||
bar := strings.Repeat(string(blockFull), filled)
|
||||
if filled < barWidth {
|
||||
if partial > 0 {
|
||||
bar += string(blockPartial[partial])
|
||||
}
|
||||
bar += strings.Repeat(string(blockEmpty), barWidth-filled-1)
|
||||
}
|
||||
fileSize := formatSize(size)
|
||||
cloudLabel := ""
|
||||
if cloud == "cloud" {
|
||||
cloudLabel = formatSpeed(avgSpeed)
|
||||
if cloudLabel != "" {
|
||||
cloudLabel = " " + cloudLabel
|
||||
}
|
||||
cloudLabel = " ☁" + cloudLabel
|
||||
}
|
||||
name := filename
|
||||
maxName := 30
|
||||
if len(name) > maxName {
|
||||
name = "…" + name[len(name)-maxName+1:]
|
||||
}
|
||||
fmt.Fprintf(w, "\r%s [%s] %3d%% %s%s %s", name, bar, pct, fileSize, cloudLabel, statusLabel(isErr))
|
||||
}
|
||||
|
||||
var blockFull = '█'
|
||||
var blockEmpty = '░'
|
||||
var blockPartial = []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'}
|
||||
|
||||
func statusLabel(isErr bool) string {
|
||||
if isErr {
|
||||
return "✗"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatSpeed(bytesPerSec float64) string {
|
||||
if bytesPerSec <= 0 {
|
||||
return ""
|
||||
}
|
||||
const kb = 1024
|
||||
const mb = kb * 1024
|
||||
if bytesPerSec >= mb {
|
||||
return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb)
|
||||
}
|
||||
if bytesPerSec >= kb {
|
||||
return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb)
|
||||
}
|
||||
return fmt.Sprintf("%.0f B/s", bytesPerSec)
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
@@ -450,5 +452,15 @@ func formatSize(bytes int64) string {
|
||||
if bytes >= mb {
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||
if bytes >= kb {
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||
}
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
|
||||
func exportMode(originals bool) string {
|
||||
if originals {
|
||||
return "originals"
|
||||
}
|
||||
return "previews"
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ 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) Cancel() { m.cancelled = true }
|
||||
func (m *mockBridge) IsCancelled() bool { return m.cancelled }
|
||||
|
||||
func runWith(args []string, b photos.Bridge) (string, string, int) {
|
||||
var out, err bytes.Buffer
|
||||
|
||||
@@ -13,6 +13,7 @@ type Bridge interface {
|
||||
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
|
||||
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
||||
Cancel()
|
||||
IsCancelled() bool
|
||||
}
|
||||
|
||||
func ParseAlbumsJSON(jsonStr string) ([]Album, error) {
|
||||
|
||||
@@ -58,6 +58,10 @@ func (*CgoBridge) Cancel() {
|
||||
C.photos_request_cancel()
|
||||
}
|
||||
|
||||
func (*CgoBridge) IsCancelled() bool {
|
||||
return C.photos_request_is_cancelled() != 0
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
|
||||
cid := C.CString(assetID)
|
||||
defer C.free(unsafe.Pointer(cid))
|
||||
|
||||
@@ -78,6 +78,10 @@ func (*CgoBridge) Cancel() {
|
||||
C.photos_request_cancel()
|
||||
}
|
||||
|
||||
func (*CgoBridge) IsCancelled() bool {
|
||||
return C.photos_request_is_cancelled() != 0
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
|
||||
cid := C.CString(assetID)
|
||||
defer C.free(unsafe.Pointer(cid))
|
||||
|
||||
Reference in New Issue
Block a user