v0.8.2: add metadata-only sidecars
This commit is contained in:
+111
-6
@@ -41,6 +41,7 @@ type exportOptions struct {
|
||||
verify bool
|
||||
format string
|
||||
sidecar string
|
||||
metadataOnly bool
|
||||
reverseGeocode bool
|
||||
minSize int64
|
||||
maxSize int64
|
||||
@@ -220,6 +221,10 @@ COMMON EXPORT FLAGS
|
||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
||||
none. If XMP writing fails, the asset is counted as failed.
|
||||
|
||||
--metadata-only
|
||||
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
||||
the manifest without exporting media files. Requires a manifest.
|
||||
|
||||
--reverse-geocode
|
||||
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
|
||||
for assets with GPS coordinates. Results are cached under .photoscli.
|
||||
@@ -444,6 +449,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return exitErr
|
||||
}
|
||||
if opts.metadataOnly && noManifest {
|
||||
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
|
||||
return exitErr
|
||||
}
|
||||
|
||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
||||
if mfErr != nil {
|
||||
@@ -546,8 +555,17 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
||||
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
||||
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
||||
var exported, failed int
|
||||
if opts.metadataOnly {
|
||||
m, _ := manifest.Open(outDir, mf)
|
||||
defer m.Close()
|
||||
entries := manifestEntries(m)
|
||||
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
||||
exported, failed = metadataOnlyAssets(assets, outDir, originals, "", entries, opts, bridge)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
||||
exported, failed = exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
||||
}
|
||||
if opts.jsonOut {
|
||||
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
||||
}
|
||||
@@ -562,7 +580,9 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
||||
return exitErr
|
||||
}
|
||||
|
||||
if originals {
|
||||
if opts.metadataOnly {
|
||||
fmt.Fprintf(stderr, "\nwrote %d metadata sidecars to %s", exported, outDir)
|
||||
} else if originals {
|
||||
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
|
||||
@@ -602,6 +622,10 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return exitErr
|
||||
}
|
||||
if opts.metadataOnly && noManifest {
|
||||
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
|
||||
return exitErr
|
||||
}
|
||||
|
||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
||||
if mfErr != nil {
|
||||
@@ -675,13 +699,25 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
||||
fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped)
|
||||
return exitOK
|
||||
}
|
||||
totalAssets, failed, err := backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
|
||||
var totalAssets, failed int
|
||||
if opts.metadataOnly {
|
||||
m, _ := manifest.Open(outDir, mf)
|
||||
entries := manifestEntries(m)
|
||||
m.Close()
|
||||
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
|
||||
fmt.Fprintf(stderr, " indexed %d metadata entries (%d skipped), writing sidecars to %s...\n", len(pending), skipped, outDir)
|
||||
totalAssets, failed = metadataOnlyPending(pending, entries, originals, opts, bridge)
|
||||
} else {
|
||||
totalAssets, failed, err = backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return exitErr
|
||||
}
|
||||
|
||||
if originals {
|
||||
if opts.metadataOnly {
|
||||
fmt.Fprintf(stderr, "\nwrote %d metadata sidecars across %d albums to %s", totalAssets, albumCount, outDir)
|
||||
} else if originals {
|
||||
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
|
||||
@@ -1086,6 +1122,46 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
||||
})
|
||||
}
|
||||
|
||||
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||
checkPath := entry.Path
|
||||
if checkPath == "" {
|
||||
checkPath = entry.Filename
|
||||
}
|
||||
if checkPath == "" {
|
||||
return fmt.Errorf("manifest entry has no path")
|
||||
}
|
||||
root := pa.root
|
||||
if root == "" {
|
||||
root = pa.path
|
||||
}
|
||||
fullPath := filepath.Join(root, checkPath)
|
||||
info, err := statFunc(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadata target missing: %s", checkPath)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("metadata target zero-byte: %s", checkPath)
|
||||
}
|
||||
pa.path = filepath.Dir(fullPath)
|
||||
result := photos.ExportResult{Filename: filepath.Base(fullPath), Size: info.Size(), Cloud: entry.Cloud}
|
||||
return writeSidecarIfNeeded(pa, result, originals, opts, cache, bridge)
|
||||
}
|
||||
|
||||
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
|
||||
if r, ok := m.(manifest.EntryReader); ok {
|
||||
return r.Entries()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
|
||||
pending := make([]pendingAsset, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
pending = append(pending, pendingAsset{asset: a, root: outDir, path: outDir, album: dirPrefix})
|
||||
}
|
||||
return metadataOnlyPending(pending, entries, originals, opts, bridge)
|
||||
}
|
||||
|
||||
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
|
||||
var items []pendingAsset
|
||||
var skipped int
|
||||
@@ -1123,6 +1199,30 @@ func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge p
|
||||
return items, skipped
|
||||
}
|
||||
|
||||
func metadataOnlyPending(pending []pendingAsset, entries map[string]manifest.Entry, originals bool, opts exportOptions, bridge photos.Bridge) (int, int) {
|
||||
var cache *geocodeCache
|
||||
if opts.reverseGeocode && len(pending) > 0 {
|
||||
root := pending[0].root
|
||||
if root == "" {
|
||||
root = pending[0].path
|
||||
}
|
||||
cache = newGeocodeCache(root)
|
||||
}
|
||||
written, failed := 0, 0
|
||||
for _, pa := range pending {
|
||||
entry, ok := entries[pa.asset.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := writeMetadataOnlySidecar(pa, entry, originals, opts, cache, bridge); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
written++
|
||||
}
|
||||
return written, failed
|
||||
}
|
||||
|
||||
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, onProgress func(collectProgress), m manifest.Manifest, exclude []string, opts exportOptions) {
|
||||
names := make(map[string]int)
|
||||
for _, node := range nodes {
|
||||
@@ -1160,7 +1260,7 @@ func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Br
|
||||
}
|
||||
assets = applyAssetFilters(assets, opts)
|
||||
for _, a := range assets {
|
||||
if m != nil && m.Has(a.ID) {
|
||||
if m != nil && m.Has(a.ID) && !opts.metadataOnly {
|
||||
*skipped++
|
||||
continue
|
||||
}
|
||||
@@ -1767,6 +1867,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
verify: hasFlag(args, "--verify"),
|
||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||
metadataOnly: hasFlag(args, "--metadata-only"),
|
||||
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
||||
dateTemplate: flagVal(args, "--date-template"),
|
||||
}
|
||||
@@ -1782,6 +1883,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
||||
return opts, false
|
||||
}
|
||||
if opts.metadataOnly && opts.sidecar != "xmp" {
|
||||
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
||||
return opts, false
|
||||
}
|
||||
if v := flagVal(args, "--retry"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
|
||||
@@ -35,6 +35,15 @@ type mockBridge struct {
|
||||
cancelled atomic.Bool
|
||||
}
|
||||
|
||||
type noEntryManifest struct{}
|
||||
|
||||
func (noEntryManifest) Has(string) bool { return false }
|
||||
func (noEntryManifest) Add(string, string, int64, string) {}
|
||||
func (noEntryManifest) AddEntry(manifest.Entry) {}
|
||||
func (noEntryManifest) Save() error { return nil }
|
||||
func (noEntryManifest) Close() {}
|
||||
func (noEntryManifest) OpenAppend() error { return nil }
|
||||
|
||||
func (m *mockBridge) RequestAccess() error { return m.accessErr }
|
||||
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
||||
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
||||
@@ -4505,6 +4514,117 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataOnlyExport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := manifest.LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||
m.Close()
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}}
|
||||
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
|
||||
t.Fatal("metadata-only must not export media")
|
||||
return photos.ExportResult{}, nil
|
||||
}
|
||||
out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
|
||||
if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
|
||||
t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") {
|
||||
t.Fatalf("unexpected metadata sidecar: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataOnlyExportErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
|
||||
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar xmp") {
|
||||
t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr)
|
||||
}
|
||||
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)
|
||||
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
|
||||
t.Fatalf("expected manifest requirement rc=%d stderr=%q", rc, stderr)
|
||||
}
|
||||
_, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, &mockBridge{})
|
||||
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
|
||||
t.Fatalf("expected backup manifest requirement rc=%d stderr=%q", rc, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataOnlyHelperBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, path: dir}
|
||||
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "no path") {
|
||||
t.Fatalf("expected no path error, got %v", err)
|
||||
}
|
||||
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "missing.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("expected missing error, got %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "zero.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "zero-byte") {
|
||||
t.Fatalf("expected zero-byte error, got %v", err)
|
||||
}
|
||||
if entries := manifestEntries(noEntryManifest{}); entries != nil {
|
||||
t.Fatalf("expected nil entries, got %#v", entries)
|
||||
}
|
||||
written, failed := metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x1"}, path: dir}}, map[string]manifest.Entry{"x1": {ID: "x1", Path: "missing.jpg"}}, false, exportOptions{sidecar: "xmp"}, &mockBridge{})
|
||||
if written != 0 || failed != 1 {
|
||||
t.Fatalf("expected failed metadata pending, written=%d failed=%d", written, failed)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
written, failed = metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x2", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, path: dir}}, map[string]manifest.Entry{"x2": {ID: "x2", Path: "photo.jpg"}}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
|
||||
return photos.Placemark{Country: "Sweden"}, nil
|
||||
}})
|
||||
if written != 1 || failed != 0 {
|
||||
t.Fatalf("expected reverse geocode metadata success, written=%d failed=%d", written, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataOnlyBackupAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := manifest.LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "Album/photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||
m.Close()
|
||||
albumDir := filepath.Join(dir, "Album")
|
||||
if err := os.Mkdir(albumDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(albumDir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &mockBridge{
|
||||
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
|
||||
assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "orig.heic", MediaType: "image"}, {ID: "x2", Filename: "missing.heic", MediaType: "image"}}},
|
||||
}
|
||||
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
|
||||
t.Fatal("metadata-only backup-all must not export media")
|
||||
return photos.ExportResult{}, nil
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
|
||||
if rc != exitOK || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
|
||||
t.Fatalf("metadata-only backup rc=%d stderr=%q", rc, stderr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(albumDir, "photo.xmp")); err != nil {
|
||||
t.Fatalf("expected metadata-only sidecar: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidecarAdditionalBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldCreateTemp := createTempFunc
|
||||
|
||||
Reference in New Issue
Block a user