v0.8.0: enrich XMP metadata
This commit is contained in:
+256
-60
@@ -31,17 +31,18 @@ var (
|
||||
)
|
||||
|
||||
type exportOptions struct {
|
||||
dryRun bool
|
||||
retry int
|
||||
onlyFavorites bool
|
||||
media string
|
||||
jsonOut bool
|
||||
verify bool
|
||||
format string
|
||||
sidecar string
|
||||
minSize int64
|
||||
maxSize int64
|
||||
dateTemplate string
|
||||
dryRun bool
|
||||
retry int
|
||||
onlyFavorites bool
|
||||
media string
|
||||
jsonOut bool
|
||||
verify bool
|
||||
format string
|
||||
sidecar string
|
||||
reverseGeocode bool
|
||||
minSize int64
|
||||
maxSize int64
|
||||
dateTemplate string
|
||||
}
|
||||
|
||||
type commandSummary struct {
|
||||
@@ -216,6 +217,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.
|
||||
|
||||
--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.
|
||||
|
||||
FILTERING AND SELECTION
|
||||
--since <date>
|
||||
Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339.
|
||||
@@ -284,6 +289,8 @@ CONFIGURATION
|
||||
sort = "newest"
|
||||
retry = 3
|
||||
log = true
|
||||
sidecar = "xmp"
|
||||
reverse-geocode = true
|
||||
|
||||
Command-line flags override config values.
|
||||
|
||||
@@ -740,22 +747,93 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
|
||||
}
|
||||
|
||||
type xmpSidecarData struct {
|
||||
AssetID string
|
||||
OriginalFilename string
|
||||
ExportedFilename string
|
||||
Album string
|
||||
AlbumPath string
|
||||
ManifestPath string
|
||||
MediaType string
|
||||
PixelWidth int
|
||||
PixelHeight int
|
||||
IsFavorite bool
|
||||
Cloud string
|
||||
ExportMode string
|
||||
PhotoscliVersion string
|
||||
ExportedAt string
|
||||
Size int64
|
||||
CreateDate string
|
||||
AssetID string
|
||||
OriginalFilename string
|
||||
ExportedFilename string
|
||||
Album string
|
||||
AlbumPath string
|
||||
ManifestPath string
|
||||
MediaType string
|
||||
MediaSubtypes []string
|
||||
SourceType string
|
||||
PlaybackStyle string
|
||||
PixelWidth int
|
||||
PixelHeight int
|
||||
Duration float64
|
||||
IsFavorite bool
|
||||
IsHidden bool
|
||||
HasAdjustments bool
|
||||
Cloud string
|
||||
ExportMode string
|
||||
PhotoscliVersion string
|
||||
ExportedAt string
|
||||
Size int64
|
||||
CreateDate string
|
||||
ModifyDate string
|
||||
Location *photos.AssetLocation
|
||||
Placemark *photos.Placemark
|
||||
BurstIdentifier string
|
||||
RepresentsBurst bool
|
||||
BurstSelectionTypes []string
|
||||
AdjustmentInfo *photos.AdjustmentInfo
|
||||
Resources []photos.AssetResource
|
||||
}
|
||||
|
||||
type geocodeCache struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
items map[string]photos.Placemark
|
||||
}
|
||||
|
||||
type geocodeCacheEntry struct {
|
||||
Key string `json:"key"`
|
||||
Placemark photos.Placemark `json:"placemark"`
|
||||
}
|
||||
|
||||
func newGeocodeCache(root string) *geocodeCache {
|
||||
c := &geocodeCache{path: filepath.Join(root, ".photoscli", "geocode-cache.jsonl"), items: map[string]photos.Placemark{}}
|
||||
data, err := os.ReadFile(c.path)
|
||||
if err != nil {
|
||||
return c
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var e geocodeCacheEntry
|
||||
if json.Unmarshal([]byte(line), &e) == nil && e.Key != "" {
|
||||
c.items[e.Key] = e.Placemark
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func geocodeKey(lat, lon float64) string { return fmt.Sprintf("%.5f,%.5f", lat, lon) }
|
||||
|
||||
func (c *geocodeCache) lookup(lat, lon float64, bridge photos.Bridge) *photos.Placemark {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
key := geocodeKey(lat, lon)
|
||||
c.mu.Lock()
|
||||
if p, ok := c.items[key]; ok {
|
||||
c.mu.Unlock()
|
||||
return &p
|
||||
}
|
||||
c.mu.Unlock()
|
||||
p, err := bridge.ReverseGeocode(lat, lon)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.items[key] = p
|
||||
_ = os.MkdirAll(filepath.Dir(c.path), 0755)
|
||||
if f, err := openFileFunc(c.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
_ = json.NewEncoder(f).Encode(geocodeCacheEntry{Key: key, Placemark: p})
|
||||
_ = f.Close()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return &p
|
||||
}
|
||||
|
||||
func sidecarPath(exportedPath string) string {
|
||||
@@ -772,10 +850,17 @@ func renderXMP(d xmpSidecarData) []byte {
|
||||
{"photoscli:albumPath", d.AlbumPath},
|
||||
{"photoscli:manifestPath", d.ManifestPath},
|
||||
{"photoscli:mediaType", d.MediaType},
|
||||
{"photoscli:sourceType", d.SourceType},
|
||||
{"photoscli:playbackStyle", d.PlaybackStyle},
|
||||
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
|
||||
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
|
||||
{"photoscli:duration", fmt.Sprintf("%.3f", d.Duration)},
|
||||
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
|
||||
{"photoscli:isHidden", fmt.Sprintf("%t", d.IsHidden)},
|
||||
{"photoscli:hasAdjustments", fmt.Sprintf("%t", d.HasAdjustments)},
|
||||
{"photoscli:cloud", d.Cloud},
|
||||
{"photoscli:burstIdentifier", d.BurstIdentifier},
|
||||
{"photoscli:representsBurst", fmt.Sprintf("%t", d.RepresentsBurst)},
|
||||
{"photoscli:exportMode", d.ExportMode},
|
||||
{"photoscli:photoscliVersion", d.PhotoscliVersion},
|
||||
{"photoscli:exportedAt", d.ExportedAt},
|
||||
@@ -785,6 +870,38 @@ func renderXMP(d xmpSidecarData) []byte {
|
||||
if d.CreateDate != "" {
|
||||
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
|
||||
}
|
||||
if d.ModifyDate != "" {
|
||||
attrs = append(attrs, struct{ key, val string }{"xmp:ModifyDate", d.ModifyDate})
|
||||
}
|
||||
if d.Location != nil {
|
||||
attrs = append(attrs,
|
||||
struct{ key, val string }{"photoscli:latitude", fmt.Sprintf("%.8f", d.Location.Latitude)},
|
||||
struct{ key, val string }{"photoscli:longitude", fmt.Sprintf("%.8f", d.Location.Longitude)},
|
||||
struct{ key, val string }{"photoscli:altitude", fmt.Sprintf("%.3f", d.Location.Altitude)},
|
||||
struct{ key, val string }{"photoscli:horizontalAccuracy", fmt.Sprintf("%.3f", d.Location.HorizontalAccuracy)},
|
||||
)
|
||||
}
|
||||
if d.Placemark != nil {
|
||||
attrs = append(attrs,
|
||||
struct{ key, val string }{"photoscli:addressName", d.Placemark.Name},
|
||||
struct{ key, val string }{"photoscli:addressCountry", d.Placemark.Country},
|
||||
struct{ key, val string }{"photoscli:addressCountryCode", d.Placemark.CountryCode},
|
||||
struct{ key, val string }{"photoscli:addressRegion", d.Placemark.AdministrativeArea},
|
||||
struct{ key, val string }{"photoscli:addressCity", d.Placemark.Locality},
|
||||
struct{ key, val string }{"photoscli:addressSubLocality", d.Placemark.SubLocality},
|
||||
struct{ key, val string }{"photoscli:addressStreet", strings.TrimSpace(d.Placemark.Thoroughfare + " " + d.Placemark.SubThoroughfare)},
|
||||
struct{ key, val string }{"photoscli:addressPostalCode", d.Placemark.PostalCode},
|
||||
struct{ key, val string }{"photoscli:addressFormatted", d.Placemark.FormattedAddress},
|
||||
struct{ key, val string }{"photoscli:reverseGeocoder", "MapKit"},
|
||||
)
|
||||
}
|
||||
if d.AdjustmentInfo != nil {
|
||||
attrs = append(attrs,
|
||||
struct{ key, val string }{"photoscli:adjustmentFormatIdentifier", d.AdjustmentInfo.FormatIdentifier},
|
||||
struct{ key, val string }{"photoscli:adjustmentFormatVersion", d.AdjustmentInfo.FormatVersion},
|
||||
struct{ key, val string }{"photoscli:adjustmentBaseFilename", d.AdjustmentInfo.BaseFilename},
|
||||
)
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
|
||||
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
|
||||
@@ -797,13 +914,53 @@ func renderXMP(d xmpSidecarData) []byte {
|
||||
xml.EscapeText(&sb, []byte(a.val))
|
||||
sb.WriteString("\"")
|
||||
}
|
||||
sb.WriteString(" />\n")
|
||||
sb.WriteString(" >\n")
|
||||
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
|
||||
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
|
||||
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
|
||||
if d.Placemark == nil {
|
||||
return nil
|
||||
}
|
||||
return d.Placemark.AreasOfInterest
|
||||
}())
|
||||
writeResourceSeq(&sb, d.Resources)
|
||||
sb.WriteString(" </rdf:Description>\n")
|
||||
sb.WriteString(" </rdf:RDF>\n")
|
||||
sb.WriteString("</x:xmpmeta>\n")
|
||||
sb.WriteString("<?xpacket end=\"w\"?>\n")
|
||||
return []byte(sb.String())
|
||||
}
|
||||
|
||||
func writeStringSeq(sb *strings.Builder, name string, vals []string) {
|
||||
if len(vals) == 0 {
|
||||
return
|
||||
}
|
||||
sb.WriteString("\n <" + name + "><rdf:Seq>")
|
||||
for _, v := range vals {
|
||||
sb.WriteString("<rdf:li>")
|
||||
xml.EscapeText(sb, []byte(v))
|
||||
sb.WriteString("</rdf:li>")
|
||||
}
|
||||
sb.WriteString("</rdf:Seq></" + name + ">")
|
||||
}
|
||||
|
||||
func writeResourceSeq(sb *strings.Builder, resources []photos.AssetResource) {
|
||||
if len(resources) == 0 {
|
||||
return
|
||||
}
|
||||
sb.WriteString("\n <photoscli:resources><rdf:Seq>")
|
||||
for _, r := range resources {
|
||||
sb.WriteString("<rdf:li><rdf:Description")
|
||||
for _, a := range []struct{ key, val string }{{"photoscli:resourceType", r.Type}, {"photoscli:resourceFilename", r.Filename}, {"photoscli:resourceUTI", r.UTI}, {"photoscli:resourceLocal", fmt.Sprintf("%t", r.Local)}, {"photoscli:resourceSize", fmt.Sprintf("%d", r.Size)}} {
|
||||
sb.WriteString(" " + a.key + "=\"")
|
||||
xml.EscapeText(sb, []byte(a.val))
|
||||
sb.WriteString("\"")
|
||||
}
|
||||
sb.WriteString(" /></rdf:li>")
|
||||
}
|
||||
sb.WriteString("</rdf:Seq></photoscli:resources>")
|
||||
}
|
||||
|
||||
func writeXMPSidecar(path string, data xmpSidecarData) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
@@ -825,7 +982,7 @@ func writeXMPSidecar(path string, data xmpSidecarData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions) error {
|
||||
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||
if opts.sidecar != "xmp" {
|
||||
return nil
|
||||
}
|
||||
@@ -846,23 +1003,45 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
||||
if pa.asset.CreationDate != nil {
|
||||
createDate = *pa.asset.CreationDate
|
||||
}
|
||||
modifyDate := ""
|
||||
if pa.asset.ModificationDate != nil {
|
||||
modifyDate = *pa.asset.ModificationDate
|
||||
}
|
||||
var placemark *photos.Placemark
|
||||
if opts.reverseGeocode && pa.asset.Location != nil && cache != nil {
|
||||
placemark = cache.lookup(pa.asset.Location.Latitude, pa.asset.Location.Longitude, bridge)
|
||||
}
|
||||
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
||||
AssetID: pa.asset.ID,
|
||||
OriginalFilename: pa.asset.Filename,
|
||||
ExportedFilename: result.Filename,
|
||||
Album: pa.album,
|
||||
AlbumPath: pa.path,
|
||||
ManifestPath: relPath,
|
||||
MediaType: pa.asset.MediaType,
|
||||
PixelWidth: pa.asset.PixelWidth,
|
||||
PixelHeight: pa.asset.PixelHeight,
|
||||
IsFavorite: pa.asset.IsFavorite,
|
||||
Cloud: result.Cloud,
|
||||
ExportMode: mode,
|
||||
PhotoscliVersion: version,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Size: result.Size,
|
||||
CreateDate: createDate,
|
||||
AssetID: pa.asset.ID,
|
||||
OriginalFilename: pa.asset.Filename,
|
||||
ExportedFilename: result.Filename,
|
||||
Album: pa.album,
|
||||
AlbumPath: pa.path,
|
||||
ManifestPath: relPath,
|
||||
MediaType: pa.asset.MediaType,
|
||||
MediaSubtypes: pa.asset.MediaSubtypes,
|
||||
SourceType: pa.asset.SourceType,
|
||||
PlaybackStyle: pa.asset.PlaybackStyle,
|
||||
PixelWidth: pa.asset.PixelWidth,
|
||||
PixelHeight: pa.asset.PixelHeight,
|
||||
Duration: pa.asset.Duration,
|
||||
IsFavorite: pa.asset.IsFavorite,
|
||||
IsHidden: pa.asset.IsHidden,
|
||||
HasAdjustments: pa.asset.HasAdjustments,
|
||||
Cloud: result.Cloud,
|
||||
ExportMode: mode,
|
||||
PhotoscliVersion: version,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Size: result.Size,
|
||||
CreateDate: createDate,
|
||||
ModifyDate: modifyDate,
|
||||
Location: pa.asset.Location,
|
||||
Placemark: placemark,
|
||||
BurstIdentifier: pa.asset.BurstIdentifier,
|
||||
RepresentsBurst: pa.asset.RepresentsBurst,
|
||||
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
|
||||
AdjustmentInfo: pa.asset.AdjustmentInfo,
|
||||
Resources: pa.asset.Resources,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1004,13 +1183,25 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
|
||||
}
|
||||
|
||||
func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
||||
if len(pending) < 4 {
|
||||
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts)
|
||||
var cache *geocodeCache
|
||||
if opts.reverseGeocode && len(pending) > 0 {
|
||||
root := pending[0].root
|
||||
if root == "" {
|
||||
root = pending[0].path
|
||||
}
|
||||
cache = newGeocodeCache(root)
|
||||
}
|
||||
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts)
|
||||
if len(pending) < 4 {
|
||||
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts, cache)
|
||||
}
|
||||
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts, cache)
|
||||
}
|
||||
|
||||
func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
||||
func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions, cache ...*geocodeCache) (int, int) {
|
||||
var geo *geocodeCache
|
||||
if len(cache) > 0 {
|
||||
geo = cache[0]
|
||||
}
|
||||
done := 0
|
||||
failed := 0
|
||||
var totalBytes int64
|
||||
@@ -1039,7 +1230,7 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
||||
} else if isSkipped {
|
||||
addManifestEntry(m, pa, result)
|
||||
} else {
|
||||
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts); sidecarErr != nil {
|
||||
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
|
||||
failed++
|
||||
exportErr = sidecarErr
|
||||
isErr = true
|
||||
@@ -1075,7 +1266,11 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
||||
return done, failed
|
||||
}
|
||||
|
||||
func exportPendingParallel(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
||||
func exportPendingParallel(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions, cache ...*geocodeCache) (int, int) {
|
||||
var geo *geocodeCache
|
||||
if len(cache) > 0 {
|
||||
geo = cache[0]
|
||||
}
|
||||
type resultEntry struct {
|
||||
result photos.ExportResult
|
||||
err error
|
||||
@@ -1175,7 +1370,7 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
|
||||
} else if isSkipped {
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
} else {
|
||||
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts); sidecarErr != nil {
|
||||
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
|
||||
failed++
|
||||
entry.err = sidecarErr
|
||||
isErr = true
|
||||
@@ -1524,14 +1719,15 @@ func exportMode(originals bool) string {
|
||||
|
||||
func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
opts := exportOptions{
|
||||
dryRun: hasFlag(args, "--dry-run"),
|
||||
onlyFavorites: hasFlag(args, "--only-favorites"),
|
||||
media: flagValWithDefault(args, "--media", "photos"),
|
||||
jsonOut: hasFlag(args, "--json"),
|
||||
verify: hasFlag(args, "--verify"),
|
||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||
dateTemplate: flagVal(args, "--date-template"),
|
||||
dryRun: hasFlag(args, "--dry-run"),
|
||||
onlyFavorites: hasFlag(args, "--only-favorites"),
|
||||
media: flagValWithDefault(args, "--media", "photos"),
|
||||
jsonOut: hasFlag(args, "--json"),
|
||||
verify: hasFlag(args, "--verify"),
|
||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
||||
dateTemplate: flagVal(args, "--date-template"),
|
||||
}
|
||||
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
|
||||
fmt.Fprintf(stderr, "error: --media must be photos, videos, or all, got %q\n", opts.media)
|
||||
|
||||
Reference in New Issue
Block a user