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