Metadata: Improve handling of Local and UTC timezones #4622 #4946

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-04-23 15:08:15 +02:00
parent 947e7a0fb1
commit 4c7ca93dcc
11 changed files with 72 additions and 38 deletions

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -19,6 +18,7 @@ import (
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/media/http/header"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/tz"
"github.com/photoprism/photoprism/pkg/time/unix"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -184,8 +184,8 @@ func OIDCRedirect(router *gin.RouterGroup) {
user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), user.Settings().UILanguage)
// Update UI timezone.
if tz := userInfo.Zoneinfo; tz != "" && tz != time.UTC.String() {
user.Settings().UITimeZone = tz
if zone := userInfo.Zoneinfo; zone != "" && zone != tz.UTC {
user.Settings().UITimeZone = zone
}
// Update user location, if available.

View File

@@ -6,13 +6,15 @@ import (
"github.com/jinzhu/gorm"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/time/tz"
)
func TestUTC(t *testing.T) {
t.Run("Zone", func(t *testing.T) {
utc := UTC()
if zone, offset := utc.Zone(); zone != time.UTC.String() {
if zone, offset := utc.Zone(); zone != tz.UTC {
t.Error("should be UTC")
} else if offset != 0 {
t.Error("offset should be 0")
@@ -26,7 +28,7 @@ func TestUTC(t *testing.T) {
assert.True(t, utcGorm.After(utc))
if zone, offset := utcGorm.Zone(); zone != time.UTC.String() {
if zone, offset := utcGorm.Zone(); zone != tz.UTC {
t.Error("gorm time should be UTC")
} else if offset != 0 {
t.Error("gorm time offset should be 0")

View File

@@ -126,6 +126,7 @@ func NewUserPhoto(stackable bool, userUid string) Photo {
LensID: UnknownLens.ID,
CellID: UnknownLocation.ID,
PlaceID: UnknownPlace.ID,
TimeZone: tz.Local,
Camera: &UnknownCamera,
Lens: &UnknownLens,
Cell: &UnknownLocation,

View File

@@ -77,22 +77,24 @@ func (m *Photo) SetTakenAt(utc, local time.Time, zone, source string) {
m.TakenAt = utc
m.TakenAtLocal = local
m.TakenSrc = source
m.TimeZone = tz.Name(m.TimeZone)
if zone == time.UTC.String() && m.TimeZone != tz.Local {
// Location exists, set local time from UTC.
if zone == tz.UTC && m.TimeZone != tz.Local {
// Set local time from UTC and keep existing time zone.
m.TakenAtLocal = m.GetTakenAtLocal()
} else if zone != tz.Local {
// Apply new time zone.
m.TimeZone = zone
m.TakenAt = m.GetTakenAt()
} else if m.TimeZoneUTC() {
m.TimeZone = zone
// Keep UTC?
if m.TimeZoneUTC() {
m.TakenAtLocal = utc
} else {
m.TakenAt = m.GetTakenAt()
}
} else if m.TimeZone != tz.Local {
// Apply existing time zone.
} else if m.TimeZoneUTC() {
m.TakenAtLocal = utc
} else if !m.TimeZoneLocal() {
// Keep existing time zone.
m.TakenAt = m.GetTakenAt()
}
@@ -104,6 +106,11 @@ func (m *Photo) TimeZoneUTC() bool {
return tz.IsUTC(m.TimeZone)
}
// TimeZoneLocal tests if the current time zone is Local.
func (m *Photo) TimeZoneLocal() bool {
return tz.IsLocal(m.TimeZone)
}
// UpdateTimeZone updates the time zone.
func (m *Photo) UpdateTimeZone(zone string) {
if zone == "" {

View File

@@ -145,7 +145,7 @@ func TestPhoto_SetTakenAt(t *testing.T) {
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAtLocal)
})
t.Run("SuccessLocalEmpty", func(t *testing.T) {
t.Run("LocalIsZero", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAtLocal)
@@ -154,32 +154,53 @@ func TestPhoto_SetTakenAt(t *testing.T) {
assert.Equal(t, time.Date(2019, 12, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2019, 12, 11, 9, 7, 18, 0, time.UTC), m.TakenAtLocal)
})
t.Run("SkipUpdate", func(t *testing.T) {
t.Run("Ignore", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC)}
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), "", SrcAuto)
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
})
t.Run("LocalFromUTC", func(t *testing.T) {
t.Run("SetFromUTC", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC), TimeZone: "Europe/Berlin"}
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), time.UTC.String(), SrcManual)
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), tz.UTC, SrcManual)
assert.Equal(t, "Europe/Berlin", photo.TimeZone)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 10, 07, 18, 0, time.UTC), photo.TakenAtLocal)
})
t.Run("KeepUTC", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC), TimeZone: time.UTC.String()}
t.Run("SetUtc", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC), TimeZone: tz.UTC}
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), time.UTC.String(), SrcManual)
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), tz.UTC, SrcManual)
assert.Equal(t, tz.UTC, photo.TimeZone)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 9, 07, 18, 0, time.UTC), photo.TakenAtLocal)
})
t.Run("UTCToLocal", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC), TimeZone: time.UTC.String()}
t.Run("KeepUtc", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC), TimeZone: tz.UTC}
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), "", SrcManual)
assert.Equal(t, tz.UTC, photo.TimeZone)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAtLocal)
})
t.Run("NoTimeZone", func(t *testing.T) {
photo := &Photo{TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC), TimeZone: ""}
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), "", SrcMeta)
assert.Equal(t, tz.Local, photo.TimeZone)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), photo.TakenAtLocal)
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), tz.Local, SrcMeta)
assert.Equal(t, tz.Local, photo.TimeZone)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), photo.TakenAtLocal)
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), tz.UTC, SrcMeta)
assert.Equal(t, tz.UTC, photo.TimeZone)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAtLocal)
})
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/time/tz"
)
func TestSavePhotoForm(t *testing.T) {
@@ -849,10 +850,12 @@ func TestNewPhoto(t *testing.T) {
t.Run("Stackable", func(t *testing.T) {
m := NewPhoto(true)
assert.Equal(t, IsStackable, m.PhotoStack)
assert.Equal(t, tz.Local, m.TimeZone)
})
t.Run("NotStackable", func(t *testing.T) {
m := NewPhoto(false)
assert.Equal(t, IsUnstacked, m.PhotoStack)
assert.Equal(t, tz.Local, m.TimeZone)
})
}

View File

@@ -214,7 +214,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
// Fallback to GPS UTC Time?
if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() {
data.TimeZone = time.UTC.String()
data.TimeZone = tz.UTC
data.TakenAt = data.TakenGps.UTC()
data.TakenAtLocal = time.Time{}
}
@@ -236,7 +236,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
// Assume default time zone for MP4 & Quicktime videos is UTC.
// see https://exiftool.org/TagNames/QuickTime.html
log.Debugf("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt))
data.TimeZone = time.UTC.String()
data.TimeZone = tz.UTC
data.TakenAt = data.TakenAt.UTC()
data.TakenAtLocal = time.Time{}
}

View File

@@ -2,13 +2,13 @@ package meta
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/media/projection"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/time/tz"
)
func TestJSON(t *testing.T) {
@@ -166,7 +166,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, time.UTC.String(), data.TimeZone)
assert.Equal(t, tz.UTC, data.TimeZone)
assert.Equal(t, "", data.TimeOffset)
assert.Equal(t, 270, data.Width)
assert.Equal(t, 480, data.Height)
@@ -1072,7 +1072,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2021-07-12T22:56:37Z", data.TakenAt.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, "2021-07-12T22:56:37Z", data.TakenAtLocal.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, time.UTC.String(), data.TimeZone)
assert.Equal(t, tz.UTC, data.TimeZone)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 0.0, data.Lat)
@@ -1090,7 +1090,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2021-07-06T13:51:36Z", data.TakenAt.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, "2021-07-06T13:51:36Z", data.TakenAtLocal.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, time.UTC.String(), data.TimeZone)
assert.Equal(t, tz.UTC, data.TimeZone)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 0.0, data.Lat)

View File

@@ -4,11 +4,10 @@ import (
"fmt"
"path/filepath"
"runtime/debug"
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/time/tz"
)
// XMP parses an XMP file and returns a Data struct.
@@ -68,7 +67,7 @@ func (data *Data) XMP(fileName string) (err error) {
if takenAt := doc.TakenAt(data.TimeZone); !takenAt.IsZero() {
data.TakenAt = takenAt.UTC()
if data.TimeZone == "" {
data.TimeZone = time.UTC.String()
data.TimeZone = tz.UTC
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/tz"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/txt/clip"
)
@@ -792,11 +793,11 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
if m.IsMedia() && entity.SrcPriority[photo.TakenSrc] <= entity.SrcPriority[entity.SrcName] {
// Try to extract time from original file name first.
if taken := txt.DateFromFilePath(photo.OriginalName); !taken.IsZero() {
photo.SetTakenAt(taken, taken, "", entity.SrcName)
} else if taken, takenSrc := m.TakenAt(); takenSrc == entity.SrcName {
photo.SetTakenAt(taken, taken, "", entity.SrcName)
} else if !taken.IsZero() {
photo.SetTakenAt(taken, taken, time.UTC.String(), takenSrc)
photo.SetTakenAt(taken, taken, tz.Local, entity.SrcName)
} else if takenAt, takenSrc := m.TakenAt(); takenSrc == entity.SrcName {
photo.SetTakenAt(takenAt, takenAt, tz.Local, entity.SrcName)
} else if !takenAt.IsZero() {
photo.SetTakenAt(takenAt, takenAt, tz.UTC, takenSrc)
}
}

View File

@@ -189,7 +189,7 @@ func (m *MediaFile) TakenAt() (time.Time, string) {
return m.takenAt, m.takenAtSrc
}
// Otherwiese, try to determine creation time from file name and path.
// Otherwise, try to determine creation time from file name and path.
if nameTime := txt.DateFromFilePath(m.fileName); !nameTime.IsZero() {
m.takenAt = nameTime
m.takenAtSrc = entity.SrcName