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 {
|
||||
|
||||
Reference in New Issue
Block a user