diff --git a/frontend/src/component/p-album-toolbar.vue b/frontend/src/component/p-album-toolbar.vue index ecbc76462..293a90707 100644 --- a/frontend/src/component/p-album-toolbar.vue +++ b/frontend/src/component/p-album-toolbar.vue @@ -155,6 +155,7 @@ {value: 'newest', text: this.$gettext('Newest first')}, {value: 'oldest', text: this.$gettext('Oldest first')}, {value: 'similar', text: this.$gettext('Similar')}, + {value: 'relevance', text: this.$gettext('Relevance')}, ], }, labels: { diff --git a/frontend/src/component/p-navigation.vue b/frontend/src/component/p-navigation.vue index 5cf75a9ee..a6e6549fb 100644 --- a/frontend/src/component/p-navigation.vue +++ b/frontend/src/component/p-navigation.vue @@ -71,7 +71,7 @@ - + Monochrome @@ -79,10 +79,10 @@ - + - Vibrant + Review diff --git a/frontend/src/component/p-photo-search.vue b/frontend/src/component/p-photo-search.vue index ef108778a..281946462 100644 --- a/frontend/src/component/p-photo-search.vue +++ b/frontend/src/component/p-photo-search.vue @@ -170,6 +170,7 @@ {value: 'newest', text: this.$gettext('Newest first')}, {value: 'oldest', text: this.$gettext('Oldest first')}, {value: 'similar', text: this.$gettext('Similar')}, + {value: 'relevance', text: this.$gettext('Relevance')}, ], }, labels: { diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index 8c9d77d8f..4b3a31a34 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -25,10 +25,11 @@ class Photo extends RestModel { PhotoTitle: "", TitleSrc: "", PhotoFavorite: false, + PhotoStory: false, PhotoPrivate: false, PhotoNSFW: false, - PhotoStory: false, - PhotoReview: false, + PhotoResolution: 0, + PhotoQuality: 0, PhotoLat: 0.0, PhotoLng: 0.0, PhotoAltitude: 0, @@ -85,13 +86,13 @@ class Photo extends RestModel { getColor() { switch (this.PhotoColor) { - case "brown": - case "black": - case "white": - case "grey": - return "grey lighten-2"; - default: - return this.PhotoColor + " lighten-4"; + case "brown": + case "black": + case "white": + case "grey": + return "grey lighten-2"; + default: + return this.PhotoColor + " lighten-4"; } } diff --git a/frontend/src/pages/photos.vue b/frontend/src/pages/photos.vue index dba729b76..b652dac83 100644 --- a/frontend/src/pages/photos.vue +++ b/frontend/src/pages/photos.vue @@ -55,6 +55,7 @@ this.filter.year = query['year'] ? parseInt(query['year']) : 0; this.filter.color = query['color'] ? query['color'] : ''; this.filter.label = query['label'] ? query['label'] : ''; + this.filter.order = this.sortOrder(); this.settings.view = this.viewType(); this.lastFilter = {}; this.routeName = this.$route.name; @@ -64,7 +65,7 @@ data() { const query = this.$route.query; const routeName = this.$route.name; - const order = query['order'] ? query['order'] : 'newest'; + const order = this.sortOrder(); const camera = query['camera'] ? parseInt(query['camera']) : 0; const q = query['q'] ? query['q'] : ''; const country = query['country'] ? query['country'] : ''; @@ -120,10 +121,10 @@ methods: { viewType() { let queryParam = this.$route.query['view']; - let storedType = window.localStorage.getItem("photo_view_type"); + let storedType = window.localStorage.getItem("photo_view"); if (queryParam) { - window.localStorage.setItem("photo_view_type", queryParam); + window.localStorage.setItem("photo_view", queryParam); return queryParam; } else if (storedType) { return storedType; @@ -133,6 +134,19 @@ return 'cards'; }, + sortOrder() { + let queryParam = this.$route.query['order']; + let storedType = window.localStorage.getItem("photo_order"); + + if (queryParam) { + window.localStorage.setItem("photo_order", queryParam); + return queryParam; + } else if (storedType) { + return storedType; + } + + return 'newest'; + }, openLocation(index) { const photo = this.results[index]; diff --git a/internal/api/photo.go b/internal/api/photo.go index dc7c8e966..9cd7f9155 100644 --- a/internal/api/photo.go +++ b/internal/api/photo.go @@ -150,6 +150,7 @@ func LikePhoto(router *gin.RouterGroup, conf *config.Config) { } m.PhotoFavorite = true + m.PhotoQuality = m.QualityScore() conf.Db().Save(&m) event.Publish("count.favorites", event.Data{ @@ -183,6 +184,7 @@ func DislikePhoto(router *gin.RouterGroup, conf *config.Config) { } m.PhotoFavorite = false + m.PhotoQuality = m.QualityScore() conf.Db().Save(&m) event.Publish("count.favorites", event.Data{ diff --git a/internal/entity/location.go b/internal/entity/location.go index 5182c424b..a0fa88c85 100644 --- a/internal/entity/location.go +++ b/internal/entity/location.go @@ -2,18 +2,14 @@ package entity import ( "strings" - "sync" "time" "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/maps" - "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/pkg/s2" "github.com/photoprism/photoprism/pkg/txt" ) -var locationMutex = sync.Mutex{} - // Location used to associate photos to location type Location struct { ID string `gorm:"type:varbinary(16);primary_key;auto_increment:false;"` @@ -26,16 +22,6 @@ type Location struct { UpdatedAt time.Time } -// Lock location for updates -func (Location) Lock() { - locationMutex.Lock() -} - -// Unlock location for updates -func (Location) Unlock() { - locationMutex.Unlock() -} - // NewLocation creates a location using a token extracted from coordinate func NewLocation(lat, lng float64) *Location { result := &Location{} @@ -47,9 +33,6 @@ func NewLocation(lat, lng float64) *Location { // Find gets the location using either the db or the api if not in the db func (m *Location) Find(db *gorm.DB, api string) error { - mutex.Db.Lock() - defer mutex.Db.Unlock() - if err := db.First(m, "id = ?", m.ID).Error; err == nil { m.Place = FindPlace(m.PlaceID, db) return nil @@ -77,8 +60,12 @@ func (m *Location) Find(db *gorm.DB, api string) error { m.LocCategory = l.LocCategory m.LocSource = l.LocSource - if err := db.Create(m).Error; err != nil { - log.Errorf("location: %s", err) + if err := db.Create(m).Error; err == nil { + return nil + } else if err := db.First(m, "id = ?", m.ID).Error; err == nil { + // avoid mutex by trying again to find location + m.Place = FindPlace(m.PlaceID, db) + } else { return err } diff --git a/internal/entity/photo.go b/internal/entity/photo.go index efdbf2ba5..08f00c697 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -25,11 +25,12 @@ type Photo struct { PhotoName string `gorm:"type:varbinary(256);"` PhotoTitle string `json:"PhotoTitle"` TitleSrc string `gorm:"type:varbinary(8);" json:"TitleSrc"` + PhotoQuality int `gorm:"type:SMALLINT" json:"PhotoQuality"` + PhotoResolution int `gorm:"type:SMALLINT" json:"PhotoResolution"` PhotoFavorite bool `json:"PhotoFavorite"` PhotoPrivate bool `json:"PhotoPrivate"` - PhotoNSFW bool `json:"PhotoNSFW"` PhotoStory bool `json:"PhotoStory"` - PhotoReview bool `json:"PhotoReview"` + PhotoNSFW bool `json:"PhotoNSFW"` PhotoLat float64 `gorm:"index;" json:"PhotoLat"` PhotoLng float64 `gorm:"index;" json:"PhotoLng"` PhotoAltitude int `json:"PhotoAltitude"` @@ -100,6 +101,8 @@ func SavePhotoForm(model Photo, form form.Photo, db *gorm.DB, geoApi string) err log.Warnf("%s (%s)", err.Error(), model.PhotoUUID) } + model.PhotoQuality = model.QualityScore() + return db.Unscoped().Save(&model).Error } @@ -121,6 +124,8 @@ func (m *Photo) Save(db *gorm.DB) error { log.Error(err) } + m.PhotoQuality = m.QualityScore() + return db.Unscoped().Save(m).Error } diff --git a/internal/entity/photo_location.go b/internal/entity/photo_location.go index bdb0b0d99..e58eb65a0 100644 --- a/internal/entity/photo_location.go +++ b/internal/entity/photo_location.go @@ -46,9 +46,6 @@ func (m *Photo) GetTakenAt() time.Time { func (m *Photo) UpdateLocation(db *gorm.DB, geoApi string) (keywords []string, labels classify.Labels) { var location = NewLocation(m.PhotoLat, m.PhotoLng) - location.Lock() - defer location.Unlock() - err := location.Find(db, geoApi) if err == nil { diff --git a/internal/entity/photo_quality.go b/internal/entity/photo_quality.go new file mode 100644 index 000000000..72e30d4cf --- /dev/null +++ b/internal/entity/photo_quality.go @@ -0,0 +1,53 @@ +package entity + +import ( + "strings" + + "github.com/photoprism/photoprism/pkg/txt" +) + +var QualityBlacklist = map[string]bool{ + "screenshot": true, + "screenshots": true, + "info": true, +} + +// QualityScore returns a score based on photo properties like size and metadata. +func (m *Photo) QualityScore() (score int) { + if m.PhotoFavorite { + score += 3 + } + + if m.TakenSrc != SrcAuto { + score++ + } + + if m.HasLatLng() { + score++ + } + + if m.PhotoResolution >= 2 { + score++ + } + + blacklisted := false + + if m.Description.PhotoKeywords != "" { + keywords := txt.Words(m.Description.PhotoKeywords) + + for _, w := range keywords { + w = strings.ToLower(w) + + if _, ok := QualityBlacklist[w]; ok { + blacklisted = true + break + } + } + } + + if !blacklisted { + score++ + } + + return score +} diff --git a/internal/form/photo_search.go b/internal/form/photo_search.go index 8d92889b8..0b43601e6 100644 --- a/internal/form/photo_search.go +++ b/internal/form/photo_search.go @@ -29,6 +29,8 @@ type PhotoSearch struct { Year uint `form:"year"` Month uint `form:"month"` Color string `form:"color"` + Quality int `form:"quality"` + Review bool `form:"review"` Camera int `form:"camera"` Lens int `form:"lens"` Before time.Time `form:"before" time_format:"2006-01-02"` diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index d5e581ab8..78d3a5aef 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -2,6 +2,7 @@ package photoprism import ( "errors" + "math" "path/filepath" "sort" "strings" @@ -258,7 +259,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( } } - if photo.Place != nil && (photo.PhotoCountry == "" || photo.PhotoCountry == "zz") { + if photo.Place == nil { + photo.Place = entity.UnknownPlace + photo.PlaceID = entity.UnknownPlace.ID + } + + if photo.PhotoCountry == "" || photo.PhotoCountry == "zz" { photo.PhotoCountry = photo.Place.LocCountry } @@ -301,6 +307,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( file.FileHeight = m.Height() file.FileAspectRatio = m.AspectRatio() file.FilePortrait = m.Width() < m.Height() + + megapixels := int(math.Round(float64(file.FileWidth*file.FileHeight) / 1000000)) + + if megapixels > photo.PhotoResolution { + photo.PhotoResolution = megapixels + } } } @@ -368,6 +380,8 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( log.Debug("index: no photo keywords") } + photo.PhotoQuality = photo.QualityScore() + if err := ind.db.Unscoped().Save(&photo).Error; err != nil { log.Errorf("index: %s", err) result.Status = IndexFailed @@ -378,6 +392,15 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( if err := photo.IndexKeywords(ind.db); err != nil { log.Warnf("%s (%s)", err.Error(), photo.PhotoUUID) } + } else { + photo.PhotoQuality = photo.QualityScore() + + if err := ind.db.Unscoped().Save(&photo).Error; err != nil { + log.Errorf("index: %s", err) + result.Status = IndexFailed + result.Error = err + return result + } } result.Status = IndexUpdated diff --git a/internal/query/geo.go b/internal/query/geo.go index 25c47b5c6..76ed38e09 100644 --- a/internal/query/geo.go +++ b/internal/query/geo.go @@ -42,6 +42,7 @@ func (q *Query) Geo(f form.GeoSearch) (results []GeoResult, err error) { AND files.file_missing = 0 AND files.file_primary AND files.deleted_at IS NULL`). Where("photos.deleted_at IS NULL"). Where("photos.photo_lat <> 0"). + Where("photos.photo_quality > 2"). Group("photos.id, files.id") if f.Query != "" { diff --git a/internal/query/photo.go b/internal/query/photo.go index 646f80c7e..64a6256e1 100644 --- a/internal/query/photo.go +++ b/internal/query/photo.go @@ -33,8 +33,6 @@ type PhotoResult struct { PhotoCountry string PhotoFavorite bool PhotoPrivate bool - PhotoSensitive bool - PhotoStory bool PhotoLat float64 PhotoLng float64 PhotoAltitude int @@ -42,6 +40,8 @@ type PhotoResult struct { PhotoFocalLength int PhotoFNumber float64 PhotoExposure string + PhotoQuality int + PhotoResolution int Merged bool // Camera @@ -322,6 +322,12 @@ func (q *Query) Photos(f form.PhotoSearch) (results PhotoResults, count int, err s = s.Where("files.file_chroma > 0 AND files.file_chroma <= ?", f.Chroma) } + if f.Review { + s = s.Where("photos.photo_quality < 3") + } else if f.Quality != 0 { + s = s.Where("photos.photo_quality >= ?", f.Quality) + } + if f.Diff != 0 { s = s.Where("files.file_diff = ?", f.Diff) } @@ -363,7 +369,7 @@ func (q *Query) Photos(f form.PhotoSearch) (results PhotoResults, count int, err switch f.Order { case entity.SortOrderRelevance: - s = s.Order("photo_story DESC, photo_favorite DESC, taken_at DESC, files.file_primary DESC") + s = s.Order("photo_quality DESC, taken_at DESC, files.file_primary DESC") case entity.SortOrderNewest: s = s.Order("taken_at DESC, photos.photo_uuid, files.file_primary DESC") case entity.SortOrderOldest: