v0.8.2: add metadata-only sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 01:48:32 +02:00
parent 9cd702628d
commit a51db37fdb
7 changed files with 266 additions and 16 deletions
+111 -6
View File
@@ -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 {
+120
View File
@@ -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