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
+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"))