Batch Edit: Add keyword cache to speed up changes/indexing #271 #5324

This commit also normalizes the map key names for the GORM Updates and
UpdateColumns calls to use the database column names.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-11-18 23:06:57 +01:00
parent 34bf6e4e26
commit 7e6dabc9ad
24 changed files with 903 additions and 90 deletions

View file

@ -839,7 +839,7 @@ func (m *User) NoScope() bool {
// UpdateScope updates optional user account scope.
func (m *User) UpdateScope(scope string) error {
m.UserScope = clean.Scope(scope)
return m.Updates(Values{"UserScope": m.UserScope})
return m.Updates(Values{"user_scope": m.UserScope})
}
// Attr returns optional user account attributes as sanitized string.
@ -851,7 +851,7 @@ func (m *User) Attr() string {
// UpdateAttr updates optional user account attributes.
func (m *User) UpdateAttr(attr string) error {
m.UserAttr = clean.Attr(attr)
return m.Updates(Values{"UserAttr": m.UserAttr})
return m.Updates(Values{"user_attr": m.UserAttr})
}
// IsRegistered checks if this user has a registered account with a valid ID, username, and role.
@ -1218,7 +1218,7 @@ func (m *User) RegenerateTokens() error {
m.GenerateTokens(true)
return m.Updates(Values{"PreviewToken": m.PreviewToken, "DownloadToken": m.DownloadToken})
return m.Updates(Values{"preview_token": m.PreviewToken, "download_token": m.DownloadToken})
}
// RefreshShares updates the list of shares.
@ -1483,5 +1483,5 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error {
m.Thumb = thumb
m.ThumbSrc = thumbSrc
return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc})
return m.Updates(Values{"thumb": m.Thumb, "thumb_src": m.ThumbSrc})
}

View file

@ -117,7 +117,7 @@ func (m *Face) SetEmbeddings(embeddings face.Embeddings) (err error) {
// Matched updates the match timestamp.
func (m *Face) Matched() error {
m.MatchedAt = TimeStamp()
return UnscopedDb().Model(m).UpdateColumns(Values{"MatchedAt": m.MatchedAt}).Error
return UnscopedDb().Model(m).UpdateColumns(Values{"matched_at": m.MatchedAt}).Error
}
// Embedding returns parsed face embedding.
@ -199,7 +199,8 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
m.Collisions++
m.CollisionRadius = dist
UpdateFaces.Store(true)
return true, m.Updates(Values{"Collisions": m.Collisions, "CollisionRadius": m.CollisionRadius, "FaceKind": m.FaceKind, "UpdatedAt": m.UpdatedAt, "MatchedAt": m.MatchedAt})
return true, m.Updates(Values{"collisions": m.Collisions, "collision_radius": m.CollisionRadius,
"face_kind": m.FaceKind, "updated_at": m.UpdatedAt, "matched_at": m.MatchedAt})
} else {
m.MatchedAt = nil
m.Collisions++
@ -207,7 +208,7 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
UpdateFaces.Store(true)
}
err = m.Updates(Values{"Collisions": m.Collisions, "CollisionRadius": m.CollisionRadius, "MatchedAt": m.MatchedAt})
err = m.Updates(Values{"collisions": m.Collisions, "collision_radius": m.CollisionRadius, "matched_at": m.MatchedAt})
if err != nil {
return true, err
@ -302,7 +303,7 @@ func (m *Face) UpdateMatchStats(samples int, maxDistance float64) error {
m.SampleRadius = radius
UpdateFaces.Store(true)
return m.Updates(Values{"Samples": m.Samples, "SampleRadius": m.SampleRadius})
return m.Updates(Values{"samples": m.Samples, "sample_radius": m.SampleRadius})
}
// SetSubjectUID updates the face's subject uid and related markers.

View file

@ -1,9 +1,12 @@
package entity
import (
"errors"
"strings"
"sync"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -32,14 +35,32 @@ func NewKeyword(keyword string) *Keyword {
return result
}
// Updates multiple columns in the database.
func (m *Keyword) Updates(values interface{}) error {
return UnscopedDb().Model(m).UpdateColumns(values).Error
// Update modifies a single column on an already persisted keyword and relies on
// the standard GORM callback to evict the cached instance afterwards.
func (m *Keyword) Update(attr string, value interface{}) error {
if m == nil {
return errors.New("keyword must not be nil - you may have found a bug")
} else if !m.HasID() {
return errors.New("keyword ID must not be empty - you may have found a bug")
}
// Omit FlushCachedKeyword() because this should automatically trigger the AfterUpdate() hook.
return UnscopedDb().Model(m).Update(attr, value).Error
}
// Update a column in the database.
func (m *Keyword) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
// Updates applies a set of column changes to an existing keyword while keeping
// the cache consistent via the AfterUpdate hook.
func (m *Keyword) Updates(values interface{}) error {
if values == nil {
return nil
} else if m == nil {
return errors.New("keyword must not be nil - you may have found a bug")
} else if !m.HasID() {
return errors.New("keyword ID must not be empty - you may have found a bug")
}
// Omit FlushCachedKeyword() because this should automatically trigger the AfterUpdate() hook.
return UnscopedDb().Model(m).Updates(values).Error
}
// Save updates the record in the database or inserts a new record if it does not already exist.
@ -55,16 +76,41 @@ func (m *Keyword) Create() error {
return Db().Create(m).Error
}
// AfterUpdate flushes the cache when the entity is updated.
func (m *Keyword) AfterUpdate(tx *gorm.DB) (err error) {
FlushCachedKeyword(m)
return
}
// AfterDelete flushes the cache when the entity is deleted.
func (m *Keyword) AfterDelete(tx *gorm.DB) (err error) {
FlushCachedKeyword(m)
return
}
// AfterCreate flushes the cache when the entity is created.
func (m *Keyword) AfterCreate(scope *gorm.Scope) error {
FlushCachedKeyword(m)
return nil
}
// HasID reports whether the keyword has already been persisted and assigned
// a primary key so callers can skip duplicate writes or lookups.
func (m *Keyword) HasID() bool {
if m == nil {
return false
}
return m.ID > 0
}
// FirstOrCreateKeyword returns the existing row, inserts a new row or nil in case of errors.
func FirstOrCreateKeyword(m *Keyword) *Keyword {
result := Keyword{}
if err := Db().Where("keyword = ?", m.Keyword).First(&result).Error; err == nil {
return &result
if result, err := FindKeyword(m.Keyword, true); err == nil {
return result
} else if createErr := m.Create(); createErr == nil {
return m
} else if err := Db().Where("keyword = ?", m.Keyword).First(&result).Error; err == nil {
return &result
} else if result, err = FindKeyword(m.Keyword, false); err == nil {
return result
} else {
log.Errorf("keyword: %s (find or create %s)", createErr, m.Keyword)
}

View file

@ -0,0 +1,179 @@
package entity
import (
"errors"
"fmt"
"sync"
"time"
"github.com/dustin/go-humanize/english"
gc "github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/pkg/clean"
)
// Keyword and PhotoKeyword cache expiration times and cleanup interval.
const (
keywordCacheDefaultExpiration = 15 * time.Minute
keywordCacheErrorExpiration = 5 * time.Minute
keywordCacheCleanupInterval = 10 * time.Minute
photoKeywordCacheExpiration = 24 * time.Hour
)
// Cache Keyword and PhotoKeyword entities for faster indexing.
var (
keywordCache = gc.New(keywordCacheDefaultExpiration, keywordCacheCleanupInterval)
photoKeywordCache = gc.New(photoKeywordCacheExpiration, keywordCacheCleanupInterval)
photoKeywordCacheMutex = sync.Mutex{}
)
// photoKeywordCacheKey returns a string key for the photoKeywordCache.
func photoKeywordCacheKey(photoId, keywordId uint) string {
return fmt.Sprintf("%d-%d", photoId, keywordId)
}
// FlushKeywordCache removes all cached Keyword entities from the cache.
func FlushKeywordCache() {
keywordCache.Flush()
}
// FlushCachedKeyword deletes a cached Keyword entity from the cache.
func FlushCachedKeyword(m *Keyword) {
if m == nil {
return
} else if m.HasID() {
keywordCache.Delete(m.Keyword)
}
}
// FlushPhotoKeywordCache removes all cached PhotoKeyword entities from the cache.
func FlushPhotoKeywordCache() {
photoKeywordCacheMutex.Lock()
defer photoKeywordCacheMutex.Unlock()
start := time.Now()
photoKeywordCache.Flush()
log.Debugf("index: flushed photo keywords cache [%s]", time.Since(start))
}
// FlushCachedPhotoKeyword deletes a cached PhotoKeyword entity from the cache.
func FlushCachedPhotoKeyword(m *PhotoKeyword) {
if m == nil {
return
} else if m.HasID() {
photoKeywordCache.Delete(photoKeywordCacheKey(m.PhotoID, m.KeywordID))
}
}
// CachePhotoKeywords preloads the photo-keyword cache from the database to speed up lookups.
func CachePhotoKeywords() (err error) {
photoKeywordCacheMutex.Lock()
defer photoKeywordCacheMutex.Unlock()
start := time.Now()
var photoKeywords []PhotoKeyword
// Find photo keyword assignments.
if err = UnscopedDb().
Raw("SELECT * FROM photos_keywords").
Scan(&photoKeywords).Error; err != nil {
return err
}
// Cache existing keyword assignments.
for _, m := range photoKeywords {
photoKeywordCache.SetDefault(m.CacheKey(), m)
}
log.Debugf("index: cached %s [%s]", english.Plural(len(photoKeywords), "photo keyword", "photo keywords"), time.Since(start))
return nil
}
// FindKeyword resolves a keyword by its normalized name, optionally consulting
// the in-memory cache before hitting the database.
func FindKeyword(keyword string, cached bool) (*Keyword, error) {
if keyword == "" {
return &Keyword{}, errors.New("missing keyword name")
}
// Return cached keyword, if found.
if cached {
if cacheData, ok := keywordCache.Get(keyword); ok {
log.Tracef("keyword: cache hit for %s", keyword)
// Get cached data.
if result := cacheData.(*Keyword); result.HasID() {
// Return cached entity.
return result, nil
} else {
// Return cached "not found" error.
return &Keyword{}, fmt.Errorf("keyword not found")
}
}
}
// Fetch and cache keyword.
result := &Keyword{}
if find := Db().First(result, "keyword = ?", keyword); find.RecordNotFound() {
keywordCache.Set(keyword, result, keywordCacheErrorExpiration)
return result, fmt.Errorf("keyword not found")
} else if find.Error != nil {
keywordCache.Set(keyword, result, keywordCacheErrorExpiration)
return result, find.Error
} else {
keywordCache.SetDefault(result.Keyword, result)
}
return result, nil
}
// FindPhotoKeyword loads the photo-keyword join row for the given IDs, using the cache when enabled.
func FindPhotoKeyword(photoId, keywordId uint, cached bool) (*PhotoKeyword, error) {
if photoId == 0 {
return &PhotoKeyword{}, errors.New("invalid photo id")
} else if keywordId == 0 {
return &PhotoKeyword{}, errors.New("invalid keyword id")
}
cacheKey := photoKeywordCacheKey(photoId, keywordId)
if cacheKey == "" {
return &PhotoKeyword{}, fmt.Errorf("invalid cache key %s", clean.LogQuote(cacheKey))
}
// Return cached keyword, if found.
if cached {
if cacheData, ok := photoKeywordCache.Get(cacheKey); ok {
log.Tracef("photo-keyword: cache hit for %s", cacheKey)
// Get cached data.
if result := cacheData.(PhotoKeyword); result.HasID() {
// Return cached entity.
return &result, nil
} else {
// Return cached "not found" error.
return &PhotoKeyword{}, fmt.Errorf("photo-keyword not found")
}
}
}
// Fetch and cache photo-keyword.
result := &PhotoKeyword{}
if find := Db().First(result, "photo_id = ? AND keyword_id = ?", photoId, keywordId); find.RecordNotFound() {
photoKeywordCache.Set(cacheKey, *result, keywordCacheErrorExpiration)
return result, fmt.Errorf("photo-keyword not found")
} else if find.Error != nil {
photoKeywordCache.Set(cacheKey, *result, keywordCacheErrorExpiration)
return result, find.Error
} else {
photoKeywordCache.SetDefault(cacheKey, *result)
}
return result, nil
}

View file

@ -0,0 +1,116 @@
package entity
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindKeyword(t *testing.T) {
t.Run("Success", func(t *testing.T) {
FlushKeywordCache()
keywordName := fmt.Sprintf("keyword-cache-%d", time.Now().UnixNano())
keyword := NewKeyword(keywordName)
require.NoError(t, keyword.Save())
uncached, err := FindKeyword(keyword.Keyword, false)
assert.NoError(t, err)
assert.Equal(t, keyword.Keyword, uncached.Keyword)
assert.Equal(t, keyword.ID, uncached.ID)
cached, err := FindKeyword(keyword.Keyword, true)
assert.NoError(t, err)
assert.Equal(t, keyword.Keyword, cached.Keyword)
assert.Equal(t, keyword.ID, cached.ID)
})
t.Run("NotFound", func(t *testing.T) {
FlushKeywordCache()
missingName := fmt.Sprintf("missing-keyword-%d", time.Now().UnixNano())
result, err := FindKeyword(missingName, true)
assert.Error(t, err)
assert.NotNil(t, result)
result, err = FindKeyword(missingName, false)
assert.Error(t, err)
assert.NotNil(t, result)
})
t.Run("EmptyName", func(t *testing.T) {
FlushKeywordCache()
result, err := FindKeyword("", true)
assert.Error(t, err)
assert.NotNil(t, result)
})
}
func TestFindPhotoKeyword(t *testing.T) {
t.Run("Success", func(t *testing.T) {
FlushPhotoKeywordCache()
require.NoError(t, CachePhotoKeywords())
fixture := PhotoKeywordFixtures["3"]
cached, err := FindPhotoKeyword(fixture.PhotoID, fixture.KeywordID, true)
assert.NoError(t, err)
assert.Equal(t, fixture.KeywordID, cached.KeywordID)
assert.Equal(t, fixture.PhotoID, cached.PhotoID)
FlushPhotoKeywordCache()
cached, err = FindPhotoKeyword(fixture.PhotoID, fixture.KeywordID, true)
assert.NoError(t, err)
assert.Equal(t, fixture.KeywordID, cached.KeywordID)
assert.Equal(t, fixture.PhotoID, cached.PhotoID)
})
t.Run("NotFound", func(t *testing.T) {
FlushPhotoKeywordCache()
missingPhotoID := uint(5000000)
missingKeywordID := uint(6000000)
result, err := FindPhotoKeyword(missingPhotoID, missingKeywordID, true)
assert.Error(t, err)
assert.NotNil(t, result)
result, err = FindPhotoKeyword(missingPhotoID, missingKeywordID, false)
assert.Error(t, err)
assert.NotNil(t, result)
})
t.Run("InvalidID", func(t *testing.T) {
FlushPhotoKeywordCache()
result, err := FindPhotoKeyword(0, 0, true)
assert.Error(t, err)
assert.NotNil(t, result)
result, err = FindPhotoKeyword(0, 0, false)
assert.Error(t, err)
assert.NotNil(t, result)
})
}
func TestCachePhotoKeywords(t *testing.T) {
FlushPhotoKeywordCache()
require.NoError(t, CachePhotoKeywords())
fixture := PhotoKeywordFixtures["3"]
_, found := photoKeywordCache.Get(photoKeywordCacheKey(fixture.PhotoID, fixture.KeywordID))
assert.True(t, found)
}
func TestFlushCachedPhotoKeyword(t *testing.T) {
FlushPhotoKeywordCache()
fixture := PhotoKeywordFixtures["3"]
cacheKey := photoKeywordCacheKey(fixture.PhotoID, fixture.KeywordID)
photoKeywordCache.SetDefault(cacheKey, fixture)
FlushCachedPhotoKeyword(&fixture)
_, found := photoKeywordCache.Get(cacheKey)
assert.False(t, found)
// Ensure nil inputs are safe.
FlushCachedPhotoKeyword(nil)
}

View file

@ -1,9 +1,12 @@
package entity
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewKeyword(t *testing.T) {
@ -19,6 +22,11 @@ func TestNewKeyword(t *testing.T) {
})
}
func TestKeyword_TableName(t *testing.T) {
keyword := &Keyword{}
assert.Equal(t, "keywords", keyword.TableName())
}
func TestFirstOrCreateKeyword(t *testing.T) {
keyword := NewKeyword("food")
result := FirstOrCreateKeyword(keyword)
@ -36,6 +44,7 @@ func TestKeyword_Updates(t *testing.T) {
t.Run("Success", func(t *testing.T) {
keyword := NewKeyword("KeywordBeforeUpdate")
assert.NoError(t, keyword.Save())
assert.Equal(t, "keywordbeforeupdate", keyword.Keyword)
err := keyword.Updates(Keyword{Keyword: "KeywordAfterUpdate", ID: 999})
@ -43,14 +52,31 @@ func TestKeyword_Updates(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "KeywordAfterUpdate", keyword.Keyword)
assert.Equal(t, uint(0x3e7), keyword.ID)
})
t.Run("NilValues", func(t *testing.T) {
keyword := NewKeyword("noop")
assert.NoError(t, keyword.Updates(nil))
})
t.Run("NilKeyword", func(t *testing.T) {
var keyword *Keyword
err := keyword.Updates(Keyword{Keyword: "value"})
assert.EqualError(t, err, "keyword must not be nil - you may have found a bug")
})
t.Run("MissingID", func(t *testing.T) {
keyword := NewKeyword("missing-id")
err := keyword.Updates(Keyword{Keyword: "value"})
assert.EqualError(t, err, "keyword ID must not be empty - you may have found a bug")
})
}
func TestKeyword_Update(t *testing.T) {
t.Run("Success", func(t *testing.T) {
keyword := NewKeyword("KeywordBeforeUpdate2")
require.NoError(t, keyword.Save())
assert.Equal(t, "keywordbeforeupdate2", keyword.Keyword)
err := keyword.Update("Keyword", "new-name")
@ -58,8 +84,30 @@ func TestKeyword_Update(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "new-name", keyword.Keyword)
assert.Equal(t, "new-name", keyword.Keyword)
})
t.Run("NilKeyword", func(t *testing.T) {
var keyword *Keyword
err := keyword.Update("Keyword", "value")
assert.EqualError(t, err, "keyword must not be nil - you may have found a bug")
})
t.Run("MissingID", func(t *testing.T) {
keyword := NewKeyword("missing-id")
err := keyword.Update("Keyword", "value")
assert.EqualError(t, err, "keyword ID must not be empty - you may have found a bug")
})
t.Run("FlushesCache", func(t *testing.T) {
FlushKeywordCache()
keyword := NewKeyword(fmt.Sprintf("cache-update-%d", time.Now().UnixNano()))
require.NoError(t, keyword.Save())
keywordCache.SetDefault(keyword.Keyword, keyword)
require.NoError(t, keyword.Update("Skip", true))
_, found := keywordCache.Get(keyword.Keyword)
assert.False(t, found)
})
}
@ -74,3 +122,60 @@ func TestKeyword_Save(t *testing.T) {
}
})
}
func TestFlushCachedKeyword(t *testing.T) {
t.Run("DeletesCachedEntry", func(t *testing.T) {
FlushKeywordCache()
keyword := NewKeyword(fmt.Sprintf("flush-%d", time.Now().UnixNano()))
require.NoError(t, keyword.Save())
keywordCache.SetDefault(keyword.Keyword, keyword)
FlushCachedKeyword(keyword)
_, found := keywordCache.Get(keyword.Keyword)
assert.False(t, found)
})
t.Run("NilKeyword", func(t *testing.T) {
FlushCachedKeyword(nil)
})
}
func TestKeyword_Create(t *testing.T) {
FlushKeywordCache()
keyword := NewKeyword(fmt.Sprintf("keyword-create-%d", time.Now().UnixNano()))
require.NoError(t, keyword.Create())
assert.True(t, keyword.HasID())
var fetched Keyword
require.NoError(t, Db().First(&fetched, keyword.ID).Error)
assert.Equal(t, keyword.Keyword, fetched.Keyword)
}
func TestKeyword_HasID(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var keyword *Keyword
assert.False(t, keyword.HasID())
})
t.Run("Unsaved", func(t *testing.T) {
keyword := NewKeyword("unsaved")
assert.False(t, keyword.HasID())
})
t.Run("Saved", func(t *testing.T) {
keyword := NewKeyword(fmt.Sprintf("keyword-hasid-%d", time.Now().UnixNano()))
require.NoError(t, keyword.Save())
assert.True(t, keyword.HasID())
})
}
func TestFirstOrCreateKeyword_Cached(t *testing.T) {
name := fmt.Sprintf("keyword-firstorcreate-%d", time.Now().UnixNano())
keyword := NewKeyword(name)
result := FirstOrCreateKeyword(keyword)
require.NotNil(t, result)
assert.Equal(t, keyword.Keyword, result.Keyword)
// Second call should return cached/existing entity with same ID
cached := FirstOrCreateKeyword(NewKeyword(name))
require.NotNil(t, cached)
assert.Equal(t, result.ID, cached.ID)
}

View file

@ -1,6 +1,7 @@
package entity
import (
"errors"
"fmt"
"sync"
"time"
@ -201,7 +202,26 @@ func (m *Label) Skip() bool {
// Update a label property in the database.
func (m *Label) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
if m == nil {
return errors.New("label must not be nil - you may have found a bug")
} else if !m.HasID() {
return errors.New("label ID must not be empty - you may have found a bug")
}
return UnscopedDb().Model(m).Update(attr, value).Error
}
// Updates multiple columns in the database.
func (m *Label) Updates(values interface{}) error {
if values == nil {
return nil
} else if m == nil {
return errors.New("label must not be nil - you may have found a bug")
} else if !m.HasID() {
return errors.New("label ID must not be empty - you may have found a bug")
}
return UnscopedDb().Model(m).Updates(values).Error
}
// FirstOrCreateLabel reuses an existing label matched by slug/custom slug or creates and returns a new one; nil signals lookup/create failure.

View file

@ -1,12 +1,16 @@
package entity
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestNewLabel(t *testing.T) {
@ -24,6 +28,40 @@ func TestNewLabel(t *testing.T) {
})
}
func TestLabel_TableName(t *testing.T) {
label := &Label{}
assert.Equal(t, "labels", label.TableName())
}
func TestLabel_SaveForm(t *testing.T) {
t.Run("Success", func(t *testing.T) {
label := createTestLabel(t, "save-form")
frm := &form.Label{
LabelName: "Sunrise Field",
LabelPriority: 7,
LabelFavorite: true,
LabelDescription: "desc",
LabelNotes: "notes",
Thumb: "thumb.jpg",
ThumbSrc: "manual",
}
require.NoError(t, label.SaveForm(frm))
assert.Equal(t, "Sunrise Field", label.LabelName)
assert.Equal(t, 7, label.LabelPriority)
assert.True(t, label.LabelFavorite)
assert.Equal(t, "desc", label.LabelDescription)
assert.Equal(t, "notes", label.LabelNotes)
assert.Equal(t, "thumb.jpg", label.Thumb)
assert.Equal(t, "manual", label.ThumbSrc)
})
t.Run("InvalidForm", func(t *testing.T) {
label := createTestLabel(t, "save-form-invalid")
err := label.SaveForm(&form.Label{})
assert.Error(t, err)
})
}
func TestFlushLabelCache(t *testing.T) {
t.Run("Success", func(t *testing.T) {
FlushLabelCache()
@ -59,6 +97,80 @@ func TestLabel_SetName(t *testing.T) {
})
}
func TestLabel_HasID(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var label *Label
assert.False(t, label.HasID())
})
t.Run("Missing", func(t *testing.T) {
label := &Label{ID: 1}
assert.False(t, label.HasID())
})
t.Run("Persisted", func(t *testing.T) {
label := createTestLabel(t, "has-id")
assert.True(t, label.HasID())
})
}
func TestLabel_HasUID(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var label *Label
assert.False(t, label.HasUID())
})
t.Run("Invalid", func(t *testing.T) {
label := &Label{LabelUID: "invalid"}
assert.False(t, label.HasUID())
})
t.Run("Valid", func(t *testing.T) {
uid := rnd.GenerateUID(LabelUID)
label := &Label{LabelUID: uid}
assert.True(t, label.HasUID())
})
}
func TestLabel_Skip(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var label *Label
assert.True(t, label.Skip())
})
t.Run("MissingID", func(t *testing.T) {
label := &Label{}
assert.True(t, label.Skip())
})
t.Run("Deleted", func(t *testing.T) {
label := createTestLabel(t, "skip-deleted")
now := time.Now()
label.DeletedAt = &now
assert.True(t, label.Skip())
})
t.Run("Active", func(t *testing.T) {
label := createTestLabel(t, "skip-active")
assert.False(t, label.Skip())
})
}
func TestLabel_InvalidName(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
label := &Label{LabelName: ""}
assert.True(t, label.InvalidName())
})
t.Run("Valid", func(t *testing.T) {
label := &Label{LabelName: "Valid Name"}
assert.False(t, label.InvalidName())
})
}
func TestLabel_GetSlug(t *testing.T) {
label := &Label{CustomSlug: "custom", LabelSlug: "orig", LabelName: "Name"}
assert.Equal(t, "custom", label.GetSlug())
label.CustomSlug = ""
assert.Equal(t, "orig", label.GetSlug())
label.LabelSlug = ""
assert.Equal(t, "name", label.GetSlug())
}
func TestFirstOrCreateLabel(t *testing.T) {
label := LabelFixtures.Get("flower")
result := FirstOrCreateLabel(&label)
@ -139,6 +251,51 @@ func TestLabel_UpdateClassify(t *testing.T) {
})
}
func TestLabel_Update(t *testing.T) {
t.Run("Success", func(t *testing.T) {
label := createTestLabel(t, "update")
oldPriority := label.LabelPriority
require.NoError(t, label.Update("LabelPriority", oldPriority+5))
require.NoError(t, Db().First(label, label.ID).Error)
assert.Equal(t, oldPriority+5, label.LabelPriority)
})
t.Run("NilLabel", func(t *testing.T) {
var label *Label
err := label.Update("LabelPriority", 1)
assert.EqualError(t, err, "label must not be nil - you may have found a bug")
})
t.Run("MissingID", func(t *testing.T) {
label := NewLabel("missing", 0)
err := label.Update("LabelPriority", 1)
assert.EqualError(t, err, "label ID must not be empty - you may have found a bug")
})
}
func TestLabel_Updates(t *testing.T) {
t.Run("Success", func(t *testing.T) {
label := createTestLabel(t, "updates")
err := label.Updates(&Label{LabelDescription: "updated", LabelNotes: "notes"})
require.NoError(t, err)
require.NoError(t, Db().First(label, label.ID).Error)
assert.Equal(t, "updated", label.LabelDescription)
assert.Equal(t, "notes", label.LabelNotes)
})
t.Run("NilValues", func(t *testing.T) {
label := createTestLabel(t, "updates-nil")
assert.NoError(t, label.Updates(nil))
})
t.Run("NilLabel", func(t *testing.T) {
var label *Label
err := label.Updates(&Label{LabelDescription: "x"})
assert.EqualError(t, err, "label must not be nil - you may have found a bug")
})
t.Run("MissingID", func(t *testing.T) {
label := NewLabel("missing", 0)
err := label.Updates(&Label{LabelDescription: "x"})
assert.EqualError(t, err, "label ID must not be empty - you may have found a bug")
})
}
func TestLabel_Save(t *testing.T) {
t.Run("Success", func(t *testing.T) {
label := NewLabel("Unicorn2000", 5)
@ -219,22 +376,15 @@ func TestLabel_Links(t *testing.T) {
})
}
func TestLabel_Update(t *testing.T) {
t.Run("Success", func(t *testing.T) {
label := &Label{LabelSlug: "to-be-updated", LabelName: "Update Me Please"}
func createTestLabel(t *testing.T, prefix string) *Label {
t.Helper()
name := fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
label := NewLabel(name, 0)
require.NoError(t, label.Save())
err := label.Save()
if err != nil {
t.Fatal(err)
}
err = label.Update("LabelSlug", "my-unique-slug")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "my-unique-slug", label.LabelSlug)
t.Cleanup(func() {
_ = Db().Unscoped().Delete(label).Error
})
return label
}

View file

@ -256,7 +256,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
if m.SubjUID == f.SubjUID && m.FaceID == f.ID {
// Update matching timestamp.
m.MatchedAt = TimeStamp()
return false, m.Updates(Values{"MatchedAt": m.MatchedAt})
return false, m.Updates(Values{"matched_at": m.MatchedAt})
}
// Remember current values for comparison.
@ -302,7 +302,8 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
// Update matching timestamp.
m.MatchedAt = TimeStamp()
if err := m.Updates(Values{"FaceID": m.FaceID, "FaceDist": m.FaceDist, "SubjUID": m.SubjUID, "SubjSrc": m.SubjSrc, "MarkerReview": false, "MatchedAt": m.MatchedAt}); err != nil {
if err = m.Updates(Values{"face_id": m.FaceID, "face_dist": m.FaceDist, "subj_uid": m.SubjUID,
"subj_src": m.SubjSrc, "marker_review": false, "matched_at": m.MatchedAt}); err != nil {
return false, err
} else if !updated {
return false, nil
@ -472,20 +473,25 @@ func (m *Marker) ClearSubject(src string) error {
}()
// Update index & resolve collisions.
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjUID": "", "SubjSrc": src}); err != nil {
if err := m.Updates(Values{"marker_name": "", "face_id": "", "face_dist": -1.0, "subj_uid": "", "subj_src": src}); err != nil {
return err
} else if m.face == nil {
m.subject = nil
return nil
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
return err
} else if resolved, colErr := m.face.ResolveCollision(m.Embeddings()); colErr != nil {
return colErr
} else if resolved {
log.Debugf("faces: marker %s resolved ambiguous subjects for face %s", clean.Log(m.MarkerUID), clean.Log(m.face.ID))
}
// Clear references.
m.MarkerName = ""
m.face = nil
m.FaceID = ""
m.FaceDist = -1.0
m.subject = nil
m.SubjUID = ""
m.SubjSrc = src
return nil
}
@ -547,14 +553,15 @@ func (m *Marker) ClearFace() (updated bool, err error) {
// Remove face references.
m.face = nil
m.FaceID = ""
m.FaceDist = -1.0
m.MatchedAt = TimeStamp()
// Remove subject if set automatically.
if m.SubjSrc == SrcAuto {
m.SubjUID = ""
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "SubjUID": "", "MatchedAt": m.MatchedAt})
err = m.Updates(Values{"face_id": m.FaceID, "face_dist": m.FaceDist, "subj_uid": m.SubjUID, "matched_at": m.MatchedAt})
} else {
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "MatchedAt": m.MatchedAt})
err = m.Updates(Values{"face_id": m.FaceID, "face_dist": -1.0, "matched_at": m.MatchedAt})
}
return updated, m.RefreshPhotos()
@ -585,7 +592,7 @@ func (m *Marker) RefreshPhotos() error {
// Matched updates the match timestamp.
func (m *Marker) Matched() error {
m.MatchedAt = TimeStamp()
return UnscopedDb().Model(m).UpdateColumns(Values{"MatchedAt": m.MatchedAt}).Error
return UnscopedDb().Model(m).UpdateColumns(Values{"matched_at": m.MatchedAt}).Error
}
// Top returns the top Y coordinate as float64.

View file

@ -181,7 +181,7 @@ func TestMarker_Updates(t *testing.T) {
assert.Equal(t, SrcImage, m.MarkerSrc)
assert.Equal(t, MarkerLabel, m.MarkerType)
if err := m.Updates(Marker{MarkerSrc: SrcMeta}); err != nil {
if err = m.Updates(Marker{MarkerSrc: SrcMeta}); err != nil {
t.Fatal(err)
}

View file

@ -99,7 +99,7 @@ func (m *Migration) Fail(err error, db *gorm.DB) {
m.Error = err.Error()
}
db.Model(m).Updates(Map{"FinishedAt": m.FinishedAt, "Error": m.Error})
db.Model(m).Updates(Map{"finished_at": m.FinishedAt, "error": m.Error})
}
// Finish updates the FinishedAt timestamp and removes the error message when the migration was successful.
@ -111,7 +111,7 @@ func (m *Migration) Finish(db *gorm.DB) error {
finished := time.Now().UTC().Truncate(time.Second)
m.FinishedAt = &finished
m.Error = ""
return db.Model(m).Updates(Map{"FinishedAt": m.FinishedAt, "Error": m.Error}).Error
return db.Model(m).Updates(Map{"finished_at": m.FinishedAt, "error": m.Error}).Error
}
// Execute runs the migration.

View file

@ -60,7 +60,7 @@ func (m *Version) Migrated(db *gorm.DB) error {
m.MigratedAt = &timeStamp
m.Error = ""
return db.Model(m).Updates(Map{"MigratedAt": m.MigratedAt, "Error": m.Error}).Error
return db.Model(m).Updates(Map{"migrated_at": m.MigratedAt, "error": m.Error}).Error
}
// NewVersion creates a Version entity from a model name and a make name.

View file

@ -285,7 +285,7 @@ func (m *Passcode) Valid(code string) (valid bool, recovery bool, err error) {
// Set verified timestamp if nil.
if valid && m.VerifiedAt == nil {
m.VerifiedAt = TimeStamp()
err = m.Updates(Values{"VerifiedAt": m.VerifiedAt})
err = m.Updates(Values{"verified_at": m.VerifiedAt})
}
// Return result.
@ -304,7 +304,7 @@ func (m *Passcode) Activate() (err error) {
return authn.ErrPasscodeAlreadyActivated
} else {
m.ActivatedAt = TimeStamp()
err = m.Updates(Values{"ActivatedAt": m.ActivatedAt})
err = m.Updates(Values{"activated_at": m.ActivatedAt})
}
return err

View file

@ -380,6 +380,10 @@ func (m *Photo) HasUID() bool {
// GetUID returns the unique entity id.
func (m *Photo) GetUID() string {
if m == nil {
return "<nil>"
}
return m.PhotoUID
}
@ -481,13 +485,19 @@ func (m *Photo) SaveLabels() error {
return nil
}
// LabelKeywords converts the loaded photo labels (and their categories)
// into the keyword tokens that should be indexable for fulltext search.
// LabelKeywords converts the photo labels (and their categories) into
// keyword tokens that should be indexable for fulltext search. When the
// relation has not been preloaded yet, it fetches the labels transparently
// so callers always receive the same output.
func (m *Photo) LabelKeywords() (result []string) {
if m == nil {
return nil
}
if m.Labels == nil {
m.PreloadLabels()
}
for _, l := range m.Labels {
if l.Label == nil {
continue
@ -770,12 +780,18 @@ func (m *Photo) PreloadAlbums() {
Log("photo", "preload albums", q.Scan(&m.Albums).Error)
}
// PreloadLabels loads labels related to the photo from the database.
// PreloadLabels loads labels related to the photo from the database. It is a
// no-op when the Photo pointer is nil or the record has not been persisted yet
// so call sites can invoke it defensively before reading `m.Labels`.
func (m *Photo) PreloadLabels() {
if err := Db().Model(PhotoLabel{}).Preload("Label").Where("photo_id = ?", m.ID).
Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC").Find(&m.Labels).Error; err != nil {
log.Warnf("photo: failed to fetch labels (%s)", err)
if m == nil {
return
} else if !m.HasID() {
return
}
Log("photo", "preload labels", Db().Model(PhotoLabel{}).Preload("Label").Where("photo_id = ?", m.ID).
Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC").Find(&m.Labels).Error)
}
// PreloadMany loads the primary supporting associations (files, keywords, albums).
@ -1125,7 +1141,10 @@ func (m *Photo) Delete(permanently bool) (files Files, err error) {
}
}
return files, m.Updates(Values{"DeletedAt": Now(), "PhotoQuality": -1})
m.DeletedAt = TimeStamp()
m.PhotoQuality = -1
return files, m.Updates(Values{"deleted_at": *m.DeletedAt, "photo_quality": m.PhotoQuality})
}
// DeletePermanently permanently removes a photo from the index.
@ -1193,7 +1212,7 @@ func (m *Photo) SetFavorite(favorite bool) error {
m.PhotoFavorite = favorite
m.PhotoQuality = m.QualityScore()
if err := m.Updates(Values{"PhotoFavorite": m.PhotoFavorite, "PhotoQuality": m.PhotoQuality}); err != nil {
if err := m.Updates(Values{"photo_favorite": m.PhotoFavorite, "photo_quality": m.PhotoQuality}); err != nil {
return err
}
@ -1217,7 +1236,7 @@ func (m *Photo) SetFavorite(favorite bool) error {
func (m *Photo) SetStack(stack int8) {
if m.PhotoStack != stack {
m.PhotoStack = stack
Log("photo", "update stack flag", m.Update("PhotoStack", m.PhotoStack))
Log("photo", "update stack flag", m.Update("photo_stack", m.PhotoStack))
}
}

View file

@ -1,5 +1,9 @@
package entity
import (
"github.com/jinzhu/gorm"
)
// PhotoKeyword represents the many-to-many relation between Photo and Keyword.
type PhotoKeyword struct {
PhotoID uint `gorm:"primary_key;auto_increment:false"`
@ -26,16 +30,55 @@ func (m *PhotoKeyword) Create() error {
return Db().Create(m).Error
}
// AfterCreate flushes the keyword cache once a relation has been persisted.
func (m *PhotoKeyword) AfterCreate(scope *gorm.Scope) error {
FlushCachedPhotoKeyword(m)
return nil
}
// AfterUpdate flushes the keyword cache after a relation change.
func (m *PhotoKeyword) AfterUpdate(tx *gorm.DB) (err error) {
FlushCachedPhotoKeyword(m)
return
}
// Delete removes the keyword reference and clears the cache.
func (m *PhotoKeyword) Delete() error {
FlushCachedPhotoKeyword(m)
return Db().Delete(m).Error
}
// AfterDelete flushes the keyword cache when the photo-keyword relation is removed.
func (m *PhotoKeyword) AfterDelete(tx *gorm.DB) (err error) {
FlushCachedPhotoKeyword(m)
return
}
// HasID reports whether both sides of the relation have identifiers assigned,
// meaning the join row exists (or is ready to be cached) in the database.
func (m *PhotoKeyword) HasID() bool {
if m == nil {
return false
}
return m.PhotoID > 0 && m.KeywordID > 0
}
// CacheKey returns a string key for caching the entity.
func (m *PhotoKeyword) CacheKey() string {
if m == nil {
return ""
}
return photoKeywordCacheKey(m.PhotoID, m.KeywordID)
}
// FirstOrCreatePhotoKeyword returns the existing row, inserts a new row or nil in case of errors.
func FirstOrCreatePhotoKeyword(m *PhotoKeyword) *PhotoKeyword {
result := PhotoKeyword{}
if err := Db().Where("photo_id = ? AND keyword_id = ?", m.PhotoID, m.KeywordID).First(&result).Error; err == nil {
return &result
if result, err := FindPhotoKeyword(m.PhotoID, m.KeywordID, true); err == nil {
return result
} else if createErr := m.Create(); createErr == nil {
return m
} else if err = Db().Where("photo_id = ? AND keyword_id = ?", m.PhotoID, m.KeywordID).First(&result).Error; err == nil {
return &result
} else if result, err = FindPhotoKeyword(m.PhotoID, m.KeywordID, false); err == nil {
return result
} else {
log.Errorf("photo-keyword: %s (find or create)", createErr)
}

View file

@ -1,9 +1,12 @@
package entity
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewPhotoKeyword(t *testing.T) {
@ -37,3 +40,21 @@ func TestFirstOrCreatePhotoKeyword(t *testing.T) {
t.Errorf("KeywordID should be the same: %d %d", result.KeywordID, model.KeywordID)
}
}
func TestPhotoKeyword_Delete(t *testing.T) {
FlushPhotoKeywordCache()
photo := &Photo{}
require.NoError(t, Db().First(photo).Error)
keyword := NewKeyword(fmt.Sprintf("photo-keyword-delete-%d", time.Now().UnixNano()))
require.NoError(t, keyword.Save())
relation := NewPhotoKeyword(photo.ID, keyword.ID)
require.NoError(t, relation.Create())
photoKeywordCache.SetDefault(relation.CacheKey(), *relation)
require.NoError(t, relation.Delete())
_, found := photoKeywordCache.Get(relation.CacheKey())
assert.False(t, found)
}

View file

@ -73,12 +73,6 @@ func (m *PhotoLabel) Update(attr string, value interface{}) error {
return nil
}
// AfterUpdate flushes the label cache after a relation change.
func (m *PhotoLabel) AfterUpdate(tx *gorm.DB) (err error) {
FlushCachedPhotoLabel(m)
return
}
// Save updates the record in the database or inserts a new record if it does not already exist.
func (m *PhotoLabel) Save() error {
if m.Photo != nil {
@ -106,6 +100,12 @@ func (m *PhotoLabel) AfterCreate(scope *gorm.Scope) error {
return nil
}
// AfterUpdate flushes the label cache after a relation change.
func (m *PhotoLabel) AfterUpdate(tx *gorm.DB) (err error) {
FlushCachedPhotoLabel(m)
return
}
// Delete removes the label reference and clears the cache.
func (m *PhotoLabel) Delete() error {
FlushCachedPhotoLabel(m)

View file

@ -1,9 +1,12 @@
package entity
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/ai/classify"
)
@ -85,15 +88,104 @@ func TestPhotoLabel_Save(t *testing.T) {
}
func TestPhotoLabel_Update(t *testing.T) {
t.Run("Success", func(t *testing.T) {
photoLabel := PhotoLabel{LabelID: 555, PhotoID: 888}
assert.Equal(t, uint(0x22b), photoLabel.LabelID)
t.Run("FlushesCache", func(t *testing.T) {
FlushPhotoLabelCache()
relation := createTestPhotoLabel(t)
err := photoLabel.Update("LabelID", 8)
photoLabelCache.SetDefault(relation.CacheKey(), *relation)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, uint(0x8), photoLabel.LabelID)
require.NoError(t, relation.Update("uncertainty", relation.Uncertainty+1))
_, found := photoLabelCache.Get(relation.CacheKey())
assert.False(t, found)
})
t.Run("NilPhotoLabel", func(t *testing.T) {
var label *PhotoLabel
err := label.Update("uncertainty", 0)
assert.EqualError(t, err, "photo label must not be nil - you may have found a bug")
})
t.Run("MissingID", func(t *testing.T) {
label := &PhotoLabel{PhotoID: 0, LabelID: 1}
err := label.Update("uncertainty", 0)
assert.EqualError(t, err, "photo label ID must not be empty - you may have found a bug")
})
}
func TestPhotoLabel_Updates(t *testing.T) {
t.Run("FlushesCache", func(t *testing.T) {
FlushPhotoLabelCache()
relation := createTestPhotoLabel(t)
photoLabelCache.SetDefault(relation.CacheKey(), *relation)
require.NoError(t, relation.Updates(&PhotoLabel{Uncertainty: relation.Uncertainty + 1}))
_, found := photoLabelCache.Get(relation.CacheKey())
assert.False(t, found)
})
t.Run("NilPhotoLabel", func(t *testing.T) {
var label *PhotoLabel
err := label.Updates(&PhotoLabel{Uncertainty: 0})
assert.EqualError(t, err, "photo label must not be nil - you may have found a bug")
})
t.Run("MissingID", func(t *testing.T) {
label := &PhotoLabel{PhotoID: 0, LabelID: 1}
err := label.Updates(&PhotoLabel{Uncertainty: 0})
assert.EqualError(t, err, "photo label ID must not be empty - you may have found a bug")
})
}
func TestPhotoLabel_Delete(t *testing.T) {
FlushPhotoLabelCache()
relation := createTestPhotoLabel(t)
photoLabelCache.SetDefault(relation.CacheKey(), *relation)
require.NoError(t, relation.Delete())
_, found := photoLabelCache.Get(relation.CacheKey())
assert.False(t, found)
}
func TestPhotoLabel_HasID(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var label *PhotoLabel
assert.False(t, label.HasID())
})
t.Run("Missing", func(t *testing.T) {
label := &PhotoLabel{PhotoID: 1}
assert.False(t, label.HasID())
})
t.Run("Complete", func(t *testing.T) {
label := &PhotoLabel{PhotoID: 1, LabelID: 2}
assert.True(t, label.HasID())
})
}
func TestPhotoLabel_CacheKey(t *testing.T) {
label := &PhotoLabel{PhotoID: 1, LabelID: 2}
assert.Equal(t, "1-2", label.CacheKey())
}
func createTestPhotoLabel(t *testing.T) *PhotoLabel {
t.Helper()
photo := &Photo{}
require.NoError(t, Db().First(photo).Error)
label := NewLabel(fmt.Sprintf("photo-label-test-%d", time.Now().UnixNano()), 0)
require.NoError(t, label.Save())
relation := NewPhotoLabel(photo.ID, label.ID, 0, SrcManual)
require.NoError(t, relation.Create())
t.Cleanup(func() {
_ = Db().Where("photo_id = ? AND label_id = ?", relation.PhotoID, relation.LabelID).Delete(&PhotoLabel{}).Error
_ = Db().Delete(label).Error
})
return relation
}

View file

@ -164,6 +164,18 @@ func TestPhoto_LabelKeywords(t *testing.T) {
})
}
func TestPhoto_GetUID(t *testing.T) {
t.Run("ReturnsPhotoUID", func(t *testing.T) {
uid := rnd.GenerateUID(PhotoUID)
photo := &Photo{PhotoUID: uid}
assert.Equal(t, uid, photo.GetUID())
})
t.Run("NilPhoto", func(t *testing.T) {
var photo *Photo
assert.Equal(t, "<nil>", photo.GetUID())
})
}
func photoKeywordWords(t *testing.T, photoID uint) []string {
t.Helper()
@ -588,7 +600,7 @@ func TestPhoto_AddLabels(t *testing.T) {
label := LabelFixtures.Get(labelName)
assert.NoError(t, UnscopedDb().Model(&PhotoLabel{}).
Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).
UpdateColumns(Values{"Uncertainty": uncertainty, "LabelSrc": src}).Error)
UpdateColumns(Values{"uncertainty": uncertainty, "label_src": src}).Error)
}
t.Run("Add", func(t *testing.T) {

View file

@ -104,7 +104,7 @@ func (m *Photo) SaveSidecarYaml(originalsPath, sidecarPath string) error {
log.Warnf("photo: %s (%s %s)", err, action, clean.Log(relName))
return err
} else {
log.Infof("photo: %sd sidecar file %s", action, clean.Log(relName))
log.Debugf("photo: %sd sidecar file %s", action, clean.Log(relName))
}
return nil

View file

@ -281,7 +281,7 @@ func ResetFaceMergeRetry(subjUID string) (int, error) {
stmt = stmt.Where("subj_uid = ?", subjUID)
}
res := stmt.UpdateColumns(entity.Values{"MergeRetry": 0, "MergeNotes": ""})
res := stmt.UpdateColumns(entity.Values{"merge_retry": 0, "merge_notes": ""})
if res.Error != nil {
return 0, res.Error

View file

@ -76,7 +76,7 @@ func RemoveOrphanSubjects() (removed int64, err error) {
func CreateMarkerSubjects() (affected int64, err error) {
var markers entity.Markers
if err := Db().
if err = Db().
Where("subj_uid = '' AND marker_name <> '' AND subj_src <> ?", entity.SrcAuto).
Where("marker_invalid = 0 AND marker_type = ?", entity.MarkerFace).
Order("marker_name").
@ -103,14 +103,16 @@ func CreateMarkerSubjects() (affected int64, err error) {
}
name = m.MarkerName
m.SubjUID = subj.SubjUID
m.MarkerReview = false
if err := m.Updates(entity.Values{"SubjUID": subj.SubjUID, "MarkerReview": false}); err != nil {
if err = m.Updates(entity.Values{"subj_uid": m.SubjUID, "marker_review": m.MarkerReview}); err != nil {
return affected, err
}
if m.FaceID == "" {
continue
} else if err := Db().Model(&entity.Face{}).Where("id = ? AND subj_uid = ''", m.FaceID).Update("SubjUID", subj.SubjUID).Error; err != nil {
} else if err = Db().Model(&entity.Face{}).Where("id = ? AND subj_uid = ''", m.FaceID).Update("subj_uid", m.SubjUID).Error; err != nil {
return affected, err
}
}

View file

@ -444,7 +444,7 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
// Update subject record.
if err := m.SetName(name); err != nil {
return m, err
} else if err = m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err != nil {
} else if err = m.Updates(Values{"subj_name": m.SubjName, "subj_slug": m.SubjSlug}); err != nil {
return m, err
} else {
SubjNames.Set(m.SubjUID, m.SubjName)

View file

@ -75,10 +75,10 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
log.Errorf("faces: face must not be nil - you may have found a bug")
} else if f.SkipMatching() {
log.Infof("faces: skipped cluster %s, embedding not distinct enough", f.ID)
} else if err := f.Create(); err == nil {
} else if err = f.Create(); err == nil {
added = append(added, *f)
log.Debugf("faces: added cluster %s based on %s, radius %f", f.ID, english.Plural(f.Samples, "sample", "samples"), f.SampleRadius)
} else if err := f.Updates(entity.Values{"UpdatedAt": entity.Now()}); err != nil {
} else if err = f.Updates(entity.Values{"updated_at": entity.Now()}); err != nil {
log.Errorf("faces: %s", err)
} else {
log.Debugf("faces: updated cluster %s", f.ID)