photoprism/internal/meta/resolver.go
Ömer Duran 86efc1c982
Index: Improve support for metadata in XMP sidecar files #2260 #2828 #5563
* Meta: Add XMP sidecar fixture corpus for reader rewrite #2260
* Meta: Replace XMP sidecar reader with antchfx/xmlquery #2260
* Meta: Add XMP sidecar reader test suite #2260
* Meta: Assert XMP GPS extraction on Apple/Adobe sidecars #2828
* Meta: Apply XMP sidecar metadata to photo and primary file #2260
* Meta: Resolve XMP sidecar time zone via shared helper #2260
* Meta: Derive panorama & caption keywords on XMP sidecar reads #2260
* Meta: Assert XMP sidecar GPS override and malformed-file handling #2260
* Meta: Tidy XMP sidecar reader comments and reuse logName local #2260
* Meta: Fix XMP sidecar capture-time priority OffsetTime handling #2260
* Meta: Address XMP sidecar review feedback #2260
2026-06-17 01:53:28 +02:00

130 lines
5.3 KiB
Go

/*
Package meta resolver.go normalizes the time-zone, capture-time, and local-time
fields on a Data receiver from any combination of CreatedAt, TakenGps, Lat/Lng,
TimeOffset, MimeType, and TakenNs already populated by an upstream reader.
Shared between the ExifTool JSON path (internal/meta/json_exiftool.go) and the
XMP sidecar path (internal/meta/xmp.go) so both flows produce identical entity
state for the same capture metadata.
*/
package meta
import (
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/time/tz"
)
// ResolveTimeZone normalizes TakenAt, TakenAtLocal, and TimeZone from any
// combination of CreatedAt, TakenGps, Lat/Lng, TimeOffset, MimeType, and
// TakenNs already present on the Data receiver. Safe to call from any
// metadata reader (EXIF, ExifTool JSON, XMP sidecar). Reads no external
// state beyond the receiver; writes only data.TakenAt, data.TakenAtLocal,
// and data.TimeZone. The logName argument is used for diagnostic log
// output only.
func (data *Data) ResolveTimeZone(logName string) {
hasTimeOffset := false
// Has Media Create Date?
if !data.CreatedAt.IsZero() {
data.TakenAt = data.CreatedAt
}
// Fallback to GPS UTC Time?
if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() {
data.TimeZone = tz.UTC
data.TakenAt = data.TakenGps.UTC()
data.TakenAtLocal = time.Time{}
}
// Check plausibility of the local <> UTC time difference.
if !data.TakenAt.IsZero() && !data.TakenAtLocal.IsZero() {
if d := data.TakenAt.Sub(data.TakenAtLocal).Abs(); d > time.Hour*27 {
log.Infof("metadata: %s has an invalid local time offset (%s)", logName, d.String())
log.Debugf("metadata: %s was taken at %s, local time %s, create time %s, time zone %s", logName, clean.Log(data.TakenAt.UTC().String()), clean.Log(data.TakenAtLocal.String()), clean.Log(data.CreatedAt.String()), clean.Log(data.TimeZone))
data.TakenAtLocal = data.TakenAt
data.TakenAt = data.TakenAt.UTC()
}
}
// Has time zone offset?
if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() {
hasTimeOffset = true
} else if mt := data.MimeType; mt != "" && data.TakenAtLocal.IsZero() && (mt == MimeVideoMp4 || mt == MimeQuicktime) {
// Assume default time zone for MP4 & Quicktime videos is UTC.
// see https://exiftool.org/TagNames/QuickTime.html
log.Tracef("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt))
data.TimeZone = tz.UTC
data.TakenAt = data.TakenAt.UTC()
data.TakenAtLocal = time.Time{}
}
// Set time zone and calculate UTC time.
if data.Lat != 0 && data.Lng != 0 {
if zone := tz.Position(data.Lat, data.Lng); zone != "" {
data.TimeZone = zone
}
if loc := tz.Find(data.TimeZone); !data.TakenAtLocal.IsZero() {
if tl, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); parseErr == nil {
data.TakenAtLocal = tz.Strip(data.TakenAtLocal)
data.TakenAt = tl.Truncate(time.Second).UTC()
} else {
log.Errorf("metadata: %s", clean.Error(parseErr)) // this should never happen
}
} else if !data.TakenAt.IsZero() {
if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
data.TakenAtLocal = localUtc
data.TakenAt = data.TakenAt.UTC()
} else {
log.Errorf("metadata: %s", clean.Error(parseErr)) // this should never happen
}
}
} else if hasTimeOffset {
if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
data.TakenAtLocal = localUtc.Truncate(time.Second).UTC()
}
data.TakenAt = data.TakenAt.Truncate(time.Second).UTC()
}
// Set UTC offset as time zone?
if data.TimeZone != "" && data.TimeZone != tz.Local && data.TimeZone != tz.UTC || data.TakenAt.IsZero() {
// Don't change existing time zone.
} else if utcOffset := tz.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" {
data.TimeZone = utcOffset
if data.TakenAtLocal.IsZero() {
data.TakenAtLocal = tz.Strip(data.TakenAt)
}
data.TakenAt = data.TakenAt.UTC()
log.Infof("metadata: %s has time offset %s", logName, clean.Log(utcOffset))
} else if data.TimeOffset != "" {
log.Infof("metadata: %s has invalid time offset %s", logName, clean.Log(data.TimeOffset))
}
// Normalize time zone name.
data.TimeZone = tz.Name(data.TimeZone)
// Set local time based on UTC time if empty.
if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() {
if loc := tz.Find(data.TimeZone); loc.String() == tz.Local {
data.TakenAtLocal = tz.Strip(data.TakenAt)
data.TakenAt = data.TakenAt.UTC()
} else if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
data.TakenAtLocal = localUtc
data.TakenAt = data.TakenAt.UTC()
} else {
log.Errorf("metadata: %s", clean.Error(parseErr)) // this should never happen
}
}
// Add nanoseconds to the calculated UTC and local time.
if data.TakenAt.Nanosecond() == 0 {
if ns := time.Duration(data.TakenNs); ns > 0 && ns <= time.Second {
data.TakenAt = data.TakenAt.Truncate(time.Second).UTC().Add(ns)
data.TakenAtLocal = data.TakenAtLocal.Truncate(time.Second).Add(ns)
}
}
}