v0.8.0: enrich XMP metadata
This commit is contained in:
+192
-19
@@ -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&<>"\"", "photoscli:isFavorite=\"true\"", "xmp:CreateDate=\"2024-01-01T00:00:00Z\""} {
|
||||
for _, want := range []string{"photoscli:assetID=\"id&<>"\"", "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&.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"))
|
||||
|
||||
Reference in New Issue
Block a user