v0.8.0: enrich XMP metadata
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 01:21:49 +02:00
parent 4fe4c15adf
commit fffb30023b
16 changed files with 791 additions and 107 deletions
+256 -60
View File
@@ -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)
+192 -19
View File
@@ -31,6 +31,7 @@ type mockBridge struct {
treeErr error
exportPreviewFn func(string, string, int, int, int) (photos.ExportResult, error)
exportOrigFn func(string, string, int) (photos.ExportResult, error)
reverseGeocodeFn func(float64, float64) (photos.Placemark, error)
cancelled atomic.Bool
}
@@ -57,6 +58,12 @@ func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
return m.assets, len(m.assets), nil
}
func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr }
func (m *mockBridge) ReverseGeocode(lat, lon float64) (photos.Placemark, error) {
if m.reverseGeocodeFn != nil {
return m.reverseGeocodeFn(lat, lon)
}
return photos.Placemark{}, nil
}
func (m *mockBridge) ExportPreview(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if m.exportPreviewFn != nil {
return m.exportPreviewFn(assetID, out, targetSize, quality, index)
@@ -4160,24 +4167,38 @@ func TestXMPSidecarHelpers(t *testing.T) {
t.Fatalf("sidecar path = %q", got)
}
xmp := string(renderXMP(xmpSidecarData{
AssetID: `id&<>"`,
OriginalFilename: "IMG_0001.HEIC",
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
PixelWidth: 10,
PixelHeight: 20,
IsFavorite: true,
Cloud: "local",
ExportMode: "preview",
PhotoscliVersion: "test",
ExportedAt: "2026-01-01T00:00:00Z",
Size: 123,
CreateDate: "2024-01-01T00:00:00Z",
AssetID: `id&<>"`,
OriginalFilename: "IMG_0001.HEIC",
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
SourceType: "userLibrary",
PlaybackStyle: "livePhoto",
PixelWidth: 10,
PixelHeight: 20,
Duration: 1.25,
IsFavorite: true,
IsHidden: true,
HasAdjustments: true,
Cloud: "local",
ExportMode: "preview",
PhotoscliVersion: "test",
ExportedAt: "2026-01-01T00:00:00Z",
Size: 123,
CreateDate: "2024-01-01T00:00:00Z",
ModifyDate: "2024-02-01T00:00:00Z",
Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686, Altitude: 10, HorizontalAccuracy: 5},
Placemark: &photos.Placemark{Country: "Sweden", CountryCode: "SE", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden", AreasOfInterest: []string{"Gamla stan"}},
BurstIdentifier: "burst1",
RepresentsBurst: true,
BurstSelectionTypes: []string{"autoPick"},
AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"},
Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}},
}))
for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:CreateDate=\"2024-01-01T00:00:00Z\""} {
for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp)
}
@@ -4235,6 +4256,158 @@ func TestSidecarExportIntegration(t *testing.T) {
}
}
func TestSidecarReverseGeocodeCache(t *testing.T) {
dir := t.TempDir()
geoCalls := 0
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}}}
b.reverseGeocodeFn = func(lat, lon float64) (photos.Placemark, error) {
geoCalls++
return photos.Placemark{Country: "Sweden", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden"}, nil
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := fmt.Sprintf("photo%d.jpg", geoCalls)
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
}
for i := 0; i < 2; i++ {
exported, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "Album", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp", reverseGeocode: true})
if exported != 1 || failed != 0 {
t.Fatalf("run %d exported=%d failed=%d", i, exported, failed)
}
}
if geoCalls != 1 {
t.Fatalf("expected cached geocode after first call, got %d", geoCalls)
}
data, err := os.ReadFile(filepath.Join(dir, "photo1.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:addressCity=\"Stockholm\"") || !strings.Contains(string(data), "photoscli:reverseGeocoder=\"MapKit\"") {
t.Fatalf("missing geocode fields: %s", string(data))
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("missing geocode cache: %v", err)
}
}
func TestGeocodeCacheBranches(t *testing.T) {
dir := t.TempDir()
cache := newGeocodeCache(dir)
if got := cache.lookup(1, 2, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{}, fmt.Errorf("offline")
}}); got != nil {
t.Fatalf("expected nil on geocode error, got %+v", got)
}
if got := (*geocodeCache)(nil).lookup(1, 2, &mockBridge{}); got != nil {
t.Fatalf("expected nil cache lookup, got %+v", got)
}
oldOpen := openFileFunc
openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") }
got := cache.lookup(3, 4, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Nowhere"}, nil
}})
openFileFunc = oldOpen
if got == nil || got.Country != "Nowhere" {
t.Fatalf("expected placemark despite cache write error, got %+v", got)
}
}
func TestSidecarReverseGeocodeWithoutCache(t *testing.T) {
dir := t.TempDir()
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, root: dir, path: dir}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "geo.jpg", Size: 1}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "geo.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "photoscli:latitude=\"1.00000000\"") || strings.Contains(content, "photoscli:reverseGeocoder") {
t.Fatalf("unexpected reverse geocode content: %s", content)
}
}
func TestSidecarModificationDate(t *testing.T) {
dir := t.TempDir()
modified := "2024-03-04T05:06:07Z"
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "mod.jpg", ModificationDate: &modified}, path: dir}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "mod.jpg", Size: 1}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "mod.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "xmp:ModifyDate=\"2024-03-04T05:06:07Z\"") {
t.Fatalf("missing modify date: %s", string(data))
}
}
func TestExportPendingReverseGeocodeNoPending(t *testing.T) {
bar := newProgressBar(io.Discard, 1)
done, failed := exportPending(nil, 1024, 85, false, 0, bar, &mockBridge{}, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != 0 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
}
func TestExportPendingGeocodeCacheRootFallback(t *testing.T) {
dir := t.TempDir()
a := photos.Asset{ID: "g1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}
b := &mockBridge{assets: []photos.Asset{a}, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "geo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "geo.jpg", Size: 4}, nil
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPending([]pendingAsset{{asset: a, path: dir}}, 1024, 85, false, 1, bar, b, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != 1 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("expected fallback-root cache: %v", err)
}
}
func TestExportPendingCreatesGeocodeCacheForParallel(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{
{ID: "g1", Filename: "one.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}},
{ID: "g2", Filename: "two.jpg", Location: &photos.AssetLocation{Latitude: 3, Longitude: 4}},
{ID: "g3", Filename: "three.jpg", Location: &photos.AssetLocation{Latitude: 5, Longitude: 6}},
{ID: "g4", Filename: "four.jpg", Location: &photos.AssetLocation{Latitude: 7, Longitude: 8}},
}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, root: dir, path: dir, album: "Geo"}
}
b := &mockBridge{assets: assets, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := assetID + ".jpg"
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4}, nil
}
bar := newProgressBar(io.Discard, 4)
done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 4, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != len(pending) || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("expected geocode cache: %v", err)
}
}
func TestSidecarConfigAndErrors(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
@@ -4283,7 +4456,7 @@ func TestSidecarAdditionalBranches(t *testing.T) {
writeFileFunc = oldWriteFile
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}); err != nil {
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
@@ -4295,7 +4468,7 @@ func TestSidecarAdditionalBranches(t *testing.T) {
t.Fatalf("unexpected sidecar: %s", content)
}
otherRoot := filepath.Join(dir, "other")
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}); err != nil {
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))