Backend: Improve labels, keywords and caching

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer
2020-05-28 21:20:42 +02:00
parent 4783183790
commit a77b2431d3
21 changed files with 129 additions and 31 deletions

View File

@@ -72,6 +72,9 @@ build-js:
build-go: build-go:
rm -f $(BINARY_NAME) rm -f $(BINARY_NAME)
scripts/build.sh debug $(BINARY_NAME) scripts/build.sh debug $(BINARY_NAME)
build-race:
rm -f $(BINARY_NAME)
scripts/build.sh race $(BINARY_NAME)
build-static: build-static:
rm -f $(BINARY_NAME) rm -f $(BINARY_NAME)
scripts/build.sh static $(BINARY_NAME) scripts/build.sh static $(BINARY_NAME)

View File

@@ -43,7 +43,7 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, urlPath, rootName,
if cached { if cached {
if cacheData, ok := gc.Get(cacheKey); ok { if cacheData, ok := gc.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start)) log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cacheData.(FoldersResponse)) c.JSON(http.StatusOK, cacheData.(*FoldersResponse))
return return
} }
} }
@@ -65,7 +65,7 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, urlPath, rootName,
} }
if cached { if cached {
gc.Set(cacheKey, resp, time.Minute*5) gc.Set(cacheKey, &resp, time.Minute*5)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start)) log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
} }

View File

@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
@@ -114,11 +115,11 @@ func RemovePhotoLabel(router *gin.RouterGroup, conf *config.Config) {
return return
} }
if label.LabelSrc == entity.SrcManual { if label.LabelSrc == classify.SrcManual || label.LabelSrc == classify.SrcKeyword{
entity.Db().Delete(&label) logError("label", entity.Db().Delete(&label).Error)
} else { } else {
label.Uncertainty = 100 label.Uncertainty = 100
entity.Db().Save(&label) logError("label", entity.Db().Save(&label).Error)
} }
p, err := query.PhotoPreloadByUID(c.Param("uid")) p, err := query.PhotoPreloadByUID(c.Param("uid"))
@@ -128,6 +129,8 @@ func RemovePhotoLabel(router *gin.RouterGroup, conf *config.Config) {
return return
} }
logError("label", p.RemoveKeyword(label.Label.LabelName))
if err := p.Save(); err != nil { if err := p.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return return

View File

@@ -51,7 +51,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
if cacheData, ok := gc.Get(cacheKey); ok { if cacheData, ok := gc.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start)) log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache) cached := cacheData.(*ThumbCache)
if !fs.FileExists(cached.FileName) { if !fs.FileExists(cached.FileName) {
log.Errorf("thumbnail: %s not found", fileHash) log.Errorf("thumbnail: %s not found", fileHash)
@@ -137,7 +137,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
} }
// Cache thumbnail filename. // Cache thumbnail filename.
gc.Set(cacheKey, ThumbCache{thumbnail, f.ShareFileName()}, time.Hour*24) gc.Set(cacheKey, &ThumbCache{thumbnail, f.ShareFileName()}, time.Hour*24)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start)) log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
if c.Query("download") != "" { if c.Query("download") != "" {

View File

@@ -6,4 +6,5 @@ const (
SrcManual = "manual" SrcManual = "manual"
SrcLocation = "location" SrcLocation = "location"
SrcImage = "image" SrcImage = "image"
SrcKeyword = "keyword"
) )

View File

@@ -13,9 +13,9 @@ type Labels []Label
func (l Labels) Len() int { return len(l) } func (l Labels) Len() int { return len(l) }
func (l Labels) Swap(i, j int) { l[i], l[j] = l[j], l[i] } func (l Labels) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l Labels) Less(i, j int) bool { func (l Labels) Less(i, j int) bool {
if l[i].Uncertainty >= 100 { if l[i].Uncertainty >= 100 || l[i].Source == SrcKeyword {
return false return false
} else if l[j].Uncertainty >= 100 { } else if l[j].Uncertainty >= 100 || l[j].Source == SrcKeyword {
return true return true
} else if l[i].Priority == l[j].Priority { } else if l[i].Priority == l[j].Priority {
return l[i].Uncertainty < l[j].Uncertainty return l[i].Uncertainty < l[j].Uncertainty
@@ -36,7 +36,7 @@ func (l Labels) AppendLabel(label Label) Labels {
// Keywords returns all keywords contains in Labels and their categories // Keywords returns all keywords contains in Labels and their categories
func (l Labels) Keywords() (result []string) { func (l Labels) Keywords() (result []string) {
for _, label := range l { for _, label := range l {
if label.Uncertainty >= 100 { if label.Uncertainty >= 100 || label.Source == SrcKeyword {
continue continue
} }

View File

@@ -94,6 +94,19 @@ func FirstOrCreateLabel(m *Label) *Label {
return m return m
} }
// FindLabel returns an existing row if exists.
func FindLabel(s string) *Label {
labelSlug := slug.Make(txt.Clip(s, txt.ClipSlug))
result := Label{}
if err := Db().Where("label_slug = ? OR custom_slug = ?", labelSlug, labelSlug).First(&result).Error; err == nil {
return &result
}
return nil
}
// AfterCreate sets the New column used for database callback // AfterCreate sets the New column used for database callback
func (m *Label) AfterCreate(scope *gorm.Scope) error { func (m *Label) AfterCreate(scope *gorm.Scope) error {
m.New = true m.New = true

View File

@@ -102,12 +102,16 @@ func SavePhotoForm(model Photo, form form.Photo, geoApi string) error {
model.Details.Keywords = strings.Join(txt.UniqueWords(w), ", ") model.Details.Keywords = strings.Join(txt.UniqueWords(w), ", ")
} }
if err := model.SyncKeywordLabels(); err != nil {
log.Errorf("photo: %s", err)
}
if err := model.UpdateTitle(model.ClassifyLabels()); err != nil { if err := model.UpdateTitle(model.ClassifyLabels()); err != nil {
log.Warn(err) log.Warn(err)
} }
if err := model.IndexKeywords(); err != nil { if err := model.IndexKeywords(); err != nil {
log.Error(err) log.Errorf("photo: %s", err.Error())
} }
edited := time.Now().UTC() edited := time.Now().UTC()
@@ -146,7 +150,7 @@ func (m *Photo) Save() error {
} }
if err := m.IndexKeywords(); err != nil { if err := m.IndexKeywords(); err != nil {
log.Error(err) log.Errorf("photo: %s", err.Error())
} }
m.PhotoQuality = m.QualityScore() m.PhotoQuality = m.QualityScore()
@@ -216,10 +220,39 @@ func (m *Photo) BeforeSave(scope *gorm.Scope) error {
return nil return nil
} }
// RemoveKeyword removes a word from photo keywords.
func (m *Photo) RemoveKeyword(w string) error {
if !m.DetailsLoaded() {
return fmt.Errorf("can't remove keyword, details not loaded")
}
words := txt.RemoveFromWords(txt.Words(m.Details.Keywords), w)
m.Details.Keywords = strings.Join(words, ", ")
return nil
}
// SyncKeywordLabels maintains the label / photo relationship for existing labels and keywords.
func (m *Photo) SyncKeywordLabels() error {
keywords := txt.UniqueKeywords(m.Details.Keywords)
var labelIds []uint
for _, w := range keywords {
if label := FindLabel(w); label != nil {
labelIds = append(labelIds, label.ID)
FirstOrCreatePhotoLabel(NewPhotoLabel(m.ID, label.ID, 5, classify.SrcKeyword))
}
}
return Db().Where("label_src = ? AND photo_id = ? AND label_id NOT IN (?)", classify.SrcKeyword, m.ID, labelIds).Delete(&PhotoLabel{}).Error
}
// IndexKeywords adds given keywords to the photo entry // IndexKeywords adds given keywords to the photo entry
func (m *Photo) IndexKeywords() error { func (m *Photo) IndexKeywords() error {
if !m.DetailsLoaded() { if !m.DetailsLoaded() {
return fmt.Errorf("photo: can't index keywords, details not loaded for %s", m.PhotoUID) return fmt.Errorf("can't index keywords, details not loaded")
} }
db := Db() db := Db()
@@ -240,7 +273,7 @@ func (m *Photo) IndexKeywords() error {
kw := FirstOrCreateKeyword(NewKeyword(w)) kw := FirstOrCreateKeyword(NewKeyword(w))
if kw == nil { if kw == nil {
log.Errorf("photo: index keyword should not be nil - bug?") log.Errorf("index keyword should not be nil - bug?")
continue continue
} }

View File

@@ -59,7 +59,7 @@ func (m *Photo) Maintain() error {
} }
if err := m.IndexKeywords(); err != nil { if err := m.IndexKeywords(); err != nil {
log.Error(err) log.Errorf("photo: %s", err.Error())
} }
m.PhotoQuality = m.QualityScore() m.PhotoQuality = m.QualityScore()

View File

@@ -54,9 +54,9 @@ func FindLocation(id string) (result Location, err error) {
if hit, ok := cache.Get(id); ok { if hit, ok := cache.Get(id); ok {
log.Debugf("api: cache hit for lat %f, lng %f (%s)", lat, lng, ApiName) log.Debugf("api: cache hit for lat %f, lng %f (%s)", lat, lng, ApiName)
result = hit.(Location) cached := hit.(*Location)
result.Cached = true cached.Cached = true
return result, nil return *cached, nil
} }
url := fmt.Sprintf(ReverseLookupURL, id) url := fmt.Sprintf(ReverseLookupURL, id)
@@ -101,7 +101,7 @@ func FindLocation(id string) (result Location, err error) {
return result, fmt.Errorf("api: no result for %s (%s)", id, ApiName) return result, fmt.Errorf("api: no result for %s (%s)", id, ApiName)
} }
cache.Set(id, result, gc.DefaultExpiration) cache.Set(id, &result, gc.DefaultExpiration)
result.Cached = false result.Cached = false

View File

@@ -8,7 +8,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"sync" "sync"
"github.com/karrick/godirwalk" "github.com/karrick/godirwalk"
@@ -249,10 +248,6 @@ func (c *Convert) ToJpeg(image *MediaFile, hidden bool) (*MediaFile, error) {
return nil, err return nil, err
} }
// Unclear if this is really necessary here, but safe is safe.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if useMutex { if useMutex {
// Make sure only one command is executed at a time. // Make sure only one command is executed at a time.
// See https://photo.stackexchange.com/questions/105969/darktable-cli-fails-because-of-locked-database-file // See https://photo.stackexchange.com/questions/105969/darktable-cli-fails-because-of-locked-database-file

View File

@@ -462,8 +462,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
return result return result
} }
if err := photo.SyncKeywordLabels(); err != nil {
log.Errorf("index: %s for %s", err, quotedName)
}
if err := photo.IndexKeywords(); err != nil { if err := photo.IndexKeywords(); err != nil {
log.Errorf("%s for %s", err, quotedName) log.Errorf("index: %s for %s", err, quotedName)
} }
} else { } else {
if photo.PhotoQuality >= 0 { if photo.PhotoQuality >= 0 {

View File

@@ -636,6 +636,10 @@ func (m *MediaFile) decodeDimensions() error {
if m.IsJpeg() { if m.IsJpeg() {
file, err := os.Open(m.FileName()) file, err := os.Open(m.FileName())
if err != nil || file == nil {
return err
}
defer file.Close() defer file.Close()
size, _, err := image.DecodeConfig(file) size, _, err := image.DecodeConfig(file)

View File

@@ -33,7 +33,8 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
Joins(`JOIN files ON files.photo_id = photos.id AND Joins(`JOIN files ON files.photo_id = photos.id AND
files.file_missing = 0 AND files.file_primary AND files.deleted_at IS NULL`). files.file_missing = 0 AND files.file_primary AND files.deleted_at IS NULL`).
Where("photos.deleted_at IS NULL"). Where("photos.deleted_at IS NULL").
Where("photos.photo_lat <> 0") Where("photos.photo_lat <> 0").
Group("photos.id, files.id")
f.Query = txt.Clip(f.Query, txt.ClipKeyword) f.Query = txt.Clip(f.Query, txt.ClipKeyword)

View File

@@ -38,7 +38,8 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
Joins("JOIN cameras ON photos.camera_id = cameras.id"). Joins("JOIN cameras ON photos.camera_id = cameras.id").
Joins("JOIN lenses ON photos.lens_id = lenses.id"). Joins("JOIN lenses ON photos.lens_id = lenses.id").
Joins("JOIN places ON photos.place_uid = places.place_uid"). Joins("JOIN places ON photos.place_uid = places.place_uid").
Where("files.file_type = 'jpg' OR files.file_video = 1") Where("files.file_type = 'jpg' OR files.file_video = 1").
Group("photos.id, files.id")
// Shortcut for known photo ids. // Shortcut for known photo ids.
if f.ID != "" { if f.ID != "" {

View File

@@ -180,7 +180,7 @@ func (c Client) CreateDir(dir string) error {
func (c Client) Upload(from, to string) error { func (c Client) Upload(from, to string) error {
file, err := os.Open(from) file, err := os.Open(from)
if err != nil { if err != nil || file == nil {
return err return err
} }

View File

@@ -10,8 +10,6 @@ func InitDatabase(port uint, password string) error {
db, err := sql.Open("mysql", fmt.Sprintf("root:@tcp(localhost:%d)/", port)) db, err := sql.Open("mysql", fmt.Sprintf("root:@tcp(localhost:%d)/", port))
defer db.Close()
if err != nil { if err != nil {
log.Debug(err.Error()) log.Debug(err.Error())
log.Debug("database login as root with password") log.Debug("database login as root with password")
@@ -23,6 +21,8 @@ func InitDatabase(port uint, password string) error {
return err return err
} }
defer db.Close()
if password != "" { if password != "" {
log.Debug("set database password") log.Debug("set database password")

View File

@@ -31,11 +31,12 @@ func Zip(filename string, files []string) error {
} }
func AddToZip(zipWriter *zip.Writer, filename string) error { func AddToZip(zipWriter *zip.Writer, filename string) error {
fileToZip, err := os.Open(filename) fileToZip, err := os.Open(filename)
if err != nil { if err != nil {
return err return err
} }
defer fileToZip.Close() defer fileToZip.Close()
// Get the file information // Get the file information
@@ -64,6 +65,7 @@ func AddToZip(zipWriter *zip.Writer, filename string) error {
// Extract Zip file in destination directory // Extract Zip file in destination directory
func Unzip(src, dest string) (fileNames []string, err error) { func Unzip(src, dest string) (fileNames []string, err error) {
r, err := zip.OpenReader(src) r, err := zip.OpenReader(src)
if err != nil { if err != nil {
return fileNames, err return fileNames, err
} }

View File

@@ -72,6 +72,28 @@ func UniqueWords(words []string) (results []string) {
return results return results
} }
// RemoveFromWords removes words from a string slice and returns the sorted result.
func RemoveFromWords(words []string, remove string) (results []string) {
remove = strings.ToLower(remove)
last := ""
SortCaseInsensitive(words)
for _, w := range words {
w = strings.ToLower(w)
if len(w) < 3 || w == last || strings.Contains(remove, w){
continue
}
last = w
results = append(results, w)
}
return results
}
// UniqueKeywords returns a slice of unique and sorted keywords without stopwords. // UniqueKeywords returns a slice of unique and sorted keywords without stopwords.
func UniqueKeywords(s string) (results []string) { func UniqueKeywords(s string) (results []string) {
last := "" last := ""

View File

@@ -126,3 +126,14 @@ func TestUniqueKeywords(t *testing.T) {
assert.Equal(t, []string(nil), result) assert.Equal(t, []string(nil), result)
}) })
} }
func TestRemoveFromWords(t *testing.T) {
t.Run("brown apple", func(t *testing.T) {
result := RemoveFromWords([]string{"lazy", "jpg", "Brown", "apple", "brown", "new-york", "JPG"}, "brown apple")
assert.Equal(t, []string{"jpg", "lazy", "new-york"}, result)
})
t.Run("empty", func(t *testing.T) {
result := RemoveFromWords([]string{"lazy", "jpg", "Brown", "apple"}, "")
assert.Equal(t, []string{"apple", "brown", "jpg", "lazy"}, result)
})
}

View File

@@ -30,6 +30,11 @@ if [[ $1 == "debug" ]]; then
go build -ldflags "-X main.version=${PHOTOPRISM_DATE}-${PHOTOPRISM_VERSION}-${PHOTOPRISM_OS}-${PHOTOPRISM_ARCH}-DEBUG" -o $2 cmd/photoprism/photoprism.go go build -ldflags "-X main.version=${PHOTOPRISM_DATE}-${PHOTOPRISM_VERSION}-${PHOTOPRISM_OS}-${PHOTOPRISM_ARCH}-DEBUG" -o $2 cmd/photoprism/photoprism.go
du -h $2 du -h $2
echo "Done." echo "Done."
elif [[ $1 == "race" ]]; then
echo "Building with data race detector..."
go build -race -ldflags "-X main.version=${PHOTOPRISM_DATE}-${PHOTOPRISM_VERSION}-${PHOTOPRISM_OS}-${PHOTOPRISM_ARCH}-DEBUG" -o $2 cmd/photoprism/photoprism.go
du -h $2
echo "Done."
elif [[ $1 == "static" ]]; then elif [[ $1 == "static" ]]; then
echo "Building static production binary..." echo "Building static production binary..."
go build -a -v -ldflags "-linkmode external -extldflags \"-static -L /usr/lib -ltensorflow\" -s -w -X main.version=${PHOTOPRISM_DATE}-${PHOTOPRISM_VERSION}-${PHOTOPRISM_OS}-${PHOTOPRISM_ARCH}" -o $2 cmd/photoprism/photoprism.go go build -a -v -ldflags "-linkmode external -extldflags \"-static -L /usr/lib -ltensorflow\" -s -w -X main.version=${PHOTOPRISM_DATE}-${PHOTOPRISM_VERSION}-${PHOTOPRISM_OS}-${PHOTOPRISM_ARCH}" -o $2 cmd/photoprism/photoprism.go