Add moments #154

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-30 01:41:47 +02:00
parent e775c8f910
commit dd442ab9e9
37 changed files with 755 additions and 247 deletions

View file

@ -26,6 +26,7 @@ func main() {
commands.StopCommand,
commands.IndexCommand,
commands.ImportCommand,
commands.MomentsCommand,
commands.PurgeCommand,
commands.CopyCommand,
commands.ConvertCommand,

View file

@ -86,14 +86,20 @@ class Config {
case "videos":
this.values.count.videos += data.count;
break;
case "files":
this.values.count.files += data.count;
case "albums":
this.values.count.albums += data.count;
break;
case "moments":
this.values.count.moments += data.count;
break;
case "months":
this.values.count.months += data.count;
break;
case "folders":
this.values.count.folders += data.count;
break;
case "moments":
this.values.count.moments += data.count;
case "files":
this.values.count.files += data.count;
break;
case "favorites":
this.values.count.favorites += data.count;
@ -104,9 +110,6 @@ class Config {
case "private":
this.values.count.private += data.count;
break;
case "albums":
this.values.count.albums += data.count;
break;
case "photos":
this.values.count.photos += data.count;
break;

View file

@ -142,7 +142,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-group v-if="!mini" prepend-icon="folder" no-action :append-icon="albumExpandIcon">
<v-list-group v-if="!mini" prepend-icon="folder" no-action>
<v-list-tile slot="activator" to="/albums" @click.stop="">
<v-list-tile-content>
<v-list-tile-title>
@ -152,6 +152,22 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile>
<v-list-tile-content>
<v-list-tile-title><translate key="Folders">Folders</translate>
<span v-show="config.count.folders > 0"
class="p-navigation-count">{{ config.count.folders }}</span></v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile>
<v-list-tile-content>
<v-list-tile-title><translate key="Months">Months</translate>
<span v-show="config.count.months > 0"
class="p-navigation-count">{{ config.count.months }}</span></v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-for="(album, index) in config.albums"
:key="index"
:to="{ name: 'album', params: { uid: album.UID, slug: album.Slug } }">

View file

@ -90,6 +90,7 @@
q: q,
count: 1000,
offset: 0,
type: "album"
};
Album.search(params).then(response => {

View file

@ -171,6 +171,7 @@
count: count,
offset: offset,
album: this.uid,
filter: this.model.Filter ? this.model.Filter : "",
merged: true,
};
@ -227,6 +228,7 @@
count: this.pageSize,
offset: this.offset,
album: this.uid,
filter: this.model.Filter ? this.model.Filter : "",
merged: true,
};

View file

@ -76,7 +76,7 @@
@contextmenu="onContextMenu($event, index)"
:dark="selection.includes(album.UID)"
:class="selection.includes(album.UID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:to="{name: 'album', params: {uid: album.UID, slug: album.Slug}}"
:to="{name: albumRoute(), params: {uid: album.UID, slug: album.Slug}}"
>
<v-img
:src="album.thumbnailUrl('tile_500')"
@ -203,6 +203,13 @@
};
},
methods: {
albumRoute() {
if(this.routeName === "moments") {
return "moment";
}
return "album";
},
selectRange(rangeEnd, models) {
if (!models || !models[rangeEnd] || !(models[rangeEnd] instanceof RestModel)) {
console.warn("selectRange() - invalid arguments:", rangeEnd, models);

View file

@ -40,6 +40,12 @@ export default [
meta: {title: "Moments", auth: true},
props: {staticFilter: {type: "moment"}},
},
{
name: "moment",
path: "/moment/:uid",
component: AlbumPhotos,
meta: {title: "Moment", auth: true},
},
{
name: "albums",
path: "/albums",

1
go.mod
View file

@ -1,6 +1,7 @@
module github.com/photoprism/photoprism
require (
github.com/allegro/bigcache v1.2.1
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd // indirect
github.com/coreos/etcd v3.3.10+incompatible // indirect

4
go.sum
View file

@ -5,6 +5,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5Vpd
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc=
github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/apache/thrift v0.0.0-20161221203622-b2a4d4ae21c7 h1:Fv9bK1Q+ly/ROk4aJsVMeuIwPel4bEnD8EPiI91nZMg=
github.com/apache/thrift v0.0.0-20161221203622-b2a4d4ae21c7/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 h1:TEBmxO80TM04L8IuMWk77SGL1HomBmKTdzdJLLWznxI=
@ -84,6 +86,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.2 h1:88crIK23zO6TqlQBt+f9FrPJNKm9ZEr7qjp9vl/d5TM=
github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=

View file

@ -2,11 +2,12 @@ package api
import (
"archive/zip"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
@ -271,13 +272,15 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
var added []*entity.PhotoAlbum
var added []entity.PhotoAlbum
for _, p := range photos {
val := entity.FirstOrCreatePhotoAlbum(entity.NewPhotoAlbum(p.PhotoUID, a.AlbumUID))
pa := entity.PhotoAlbum{AlbumUID: a.AlbumUID, PhotoUID: p.PhotoUID, Hidden: false}
if val != nil {
added = append(added, val)
if err := pa.Save(); err != nil {
log.Errorf("album: %s", err.Error())
} else {
added = append(added, pa)
}
}
@ -321,13 +324,18 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
entity.Db().Where("album_uid = ? AND photo_uid IN (?)", a.AlbumUID, f.Photos).Delete(&entity.PhotoAlbum{})
for _, photoUID := range f.Photos {
pa := entity.PhotoAlbum{AlbumUID: a.AlbumUID, PhotoUID: photoUID, Hidden: true}
logError("album", pa.Save())
}
event.Success(fmt.Sprintf("photos removed from %s", a.AlbumTitle))
// affected := entity.Db().Model(entity.PhotoAlbum{}).Where("album_uid = ? AND photo_uid IN (?)", a.AlbumUID, f.Photos).UpdateColumn("Hidden", true).RowsAffected
event.Success(fmt.Sprintf("entries removed from %s", a.AlbumTitle))
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
c.JSON(http.StatusOK, gin.H{"message": "photos removed from album", "album": a, "photos": f.Photos})
c.JSON(http.StatusOK, gin.H{"message": "entries removed from album", "album": a, "photos": f.Photos})
})
}
@ -427,7 +435,7 @@ func DownloadAlbum(router *gin.RouterGroup, conf *config.Config) {
func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
router.GET("/albums/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidToken(c, conf) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
c.Data(http.StatusForbidden, "image/svg+xml", albumIconSvg)
return
}
@ -438,24 +446,44 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("album: invalid thumb type %s", typeName)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
log.Errorf("album-thumbnail: invalid type %s", typeName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
gc := service.Cache()
cache := service.Cache()
cacheKey := fmt.Sprintf("album-thumbnail:%s:%s", uid, typeName)
if cacheData, ok := gc.Get(cacheKey); ok {
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", cacheData.([]byte))
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("album-thumbnail: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("album-thumbnail: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.AlbumThumbByUID(uid)
f, err := query.AlbumCoverByUID(uid)
if err != nil {
log.Debugf("album: no photos yet, using generic image for %s", uid)
log.Debugf("album-thumbnail: no photos yet, using generic image for %s", uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
@ -463,18 +491,18 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
fileName := path.Join(conf.OriginalsPath(), f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("album: could not find original for %s", fileName)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
log.Errorf("album-thumbnail: could not find original for %s", fileName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
log.Warnf("album: %s is missing", txt.Quote(f.FileName))
logError("album", f.Update("FileMissing", true))
log.Warnf("album-thumbnail: %s is missing", txt.Quote(f.FileName))
logError("album-thumbnail", f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsLimit() && c.Query("download") == "" {
log.Debugf("album: using original, thumbnail size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
log.Debugf("album-thumbnail: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
c.File(fileName)
return
}
@ -491,24 +519,21 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if c.Query("download") != "" {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
}
thumbData, err := ioutil.ReadFile(thumbnail)
if err != nil {
log.Errorf("album: %s", err)
} else if thumbnail == "" {
log.Errorf("album-thumbnail: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
gc.Set(cacheKey, thumbData, time.Hour)
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareFileName()}); err == nil {
logError("album-thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", thumbData)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareFileName())
} else {
c.File(thumbnail)
}
})
}

View file

@ -222,7 +222,7 @@ func TestRemovePhotosFromAlbum(t *testing.T) {
RemovePhotosFromAlbum(router, conf)
r := PerformRequestWithBody(app, "DELETE", "/api/v1/albums/"+uid+"/photos", `{"photos": ["pt9jtdre2lvl0y12", "pt9jtdre2lvl0y11"]}`)
val := gjson.Get(r.Body.String(), "message")
assert.Equal(t, "photos removed from album", val.String())
assert.Equal(t, "entries removed from album", val.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("no photos selected", func(t *testing.T) {

View file

@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
@ -31,20 +32,26 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, urlPath, rootName,
}
start := time.Now()
gc := service.Cache()
cache := service.Cache()
recursive := c.Query("recursive") != ""
listFiles := c.Query("files") != ""
cached := !listFiles && c.Query("uncached") == ""
resp := FoldersResponse{Root: rootName, Recursive: recursive, Cached: cached}
uncached := listFiles || c.Query("uncached") != ""
resp := FoldersResponse{Root: rootName, Recursive: recursive, Cached: !uncached}
path := c.Param("path")
cacheKey := fmt.Sprintf("folders:%s:%t:%t", filepath.Join(rootPath, path), recursive, listFiles)
if cached {
if cacheData, ok := gc.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cacheData.(*FoldersResponse))
return
if !uncached {
if cacheData, err := cache.Get(cacheKey); err == nil {
var cached FoldersResponse
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("folders: %s", err)
} else {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return
}
}
}
@ -64,9 +71,11 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, urlPath, rootName,
}
}
if cached {
gc.Set(cacheKey, &resp, time.Minute*5)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
if !uncached {
if c, err := json.Marshal(resp); err == nil {
logError("folders", cache.Set(cacheKey, c))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
}
c.Header("X-Count", strconv.Itoa(len(resp.Files)+len(resp.Folders)))

View file

@ -64,6 +64,12 @@ func StartIndexing(router *gin.RouterGroup, conf *config.Config) {
event.Info(fmt.Sprintf("removed %d files and %d photos", len(files), len(photos)))
}
moments := service.Moments()
if err := moments.Start(); err != nil {
log.Error(err)
}
elapsed := int(time.Since(start).Seconds())
event.Success(fmt.Sprintf("indexing completed in %d s", elapsed))

View file

@ -1,10 +1,11 @@
package api
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"path"
"path/filepath"
"strconv"
"time"
@ -163,32 +164,52 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
router.GET("/labels/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidToken(c, conf) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
c.Data(http.StatusForbidden, "image/svg+xml", labelIconSvg)
return
}
start := time.Now()
typeName := c.Param("type")
labelUID := c.Param("uid")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("label: invalid thumb type %s", txt.Quote(typeName))
log.Errorf("label-thumbnail: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
gc := service.Cache()
cacheKey := fmt.Sprintf("label-thumbnail:%s:%s", labelUID, typeName)
cache := service.Cache()
cacheKey := fmt.Sprintf("label-thumbnail:%s:%s", uid, typeName)
if cacheData, ok := gc.Get(cacheKey); ok {
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", cacheData.([]byte))
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("label-thumbnail: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("label-thumbnail: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.LabelThumbByUID(labelUID)
f, err := query.LabelThumbByUID(uid)
if err != nil {
log.Errorf(err.Error())
@ -199,18 +220,18 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
fileName := path.Join(conf.OriginalsPath(), f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("label: file %s is missing", txt.Quote(f.FileName))
log.Errorf("label-thumbnail: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("label", f.Update("FileMissing", true))
logError("label-thumbnail", f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsLimit() {
log.Debugf("label: using original, thumbnail size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
log.Debugf("label-thumbnail: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
c.File(fileName)
@ -226,23 +247,24 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
}
if err != nil {
log.Errorf("label: %s", err)
log.Errorf("label-thumbnail: %s", err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
} else if thumbnail == "" {
log.Errorf("label-thumbnail: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
thumbData, err := ioutil.ReadFile(thumbnail)
if err != nil {
log.Errorf("label: %s", err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareFileName()}); err == nil {
logError("label-thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
gc.Set(cacheKey, thumbData, time.Hour*4)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
c.Data(http.StatusOK, "image/jpeg", thumbData)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareFileName())
} else {
c.File(thumbnail)
}
})
}

View file

@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path"
@ -21,6 +22,10 @@ type ThumbCache struct {
ShareName string
}
type ByteCache struct {
Data []byte
}
// GET /api/v1/t/:hash/:token/:type
//
// Parameters:
@ -45,13 +50,19 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
return
}
gc := service.Cache()
cache := service.Cache()
cacheKey := fmt.Sprintf("thumbnail:%s:%s", fileHash, typeName)
if cacheData, ok := gc.Get(cacheKey); ok {
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(*ThumbCache)
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("thumbnail: %s not found", fileHash)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("thumbnail: %s not found", fileHash)
@ -137,8 +148,10 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
}
// Cache thumbnail filename.
gc.Set(cacheKey, &ThumbCache{thumbnail, f.ShareFileName()}, time.Hour*24)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareFileName()}); err == nil {
logError("thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareFileName())

View file

@ -0,0 +1,51 @@
package commands
import (
"context"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
)
// MomentsCommand is used to register the index cli command
var MomentsCommand = cli.Command{
Name: "moments",
Usage: "Creates albums based on popular locations, dates and labels",
Action: momentsAction,
}
// momentsAction creates albums based on popular locations, dates and labels.
func momentsAction(ctx *cli.Context) error {
start := time.Now()
conf := config.NewConfig(ctx)
service.SetConfig(conf)
cctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := conf.Init(cctx); err != nil {
return err
}
conf.InitDb()
if conf.ReadOnly() {
log.Infof("read-only mode enabled")
}
moments := service.Moments()
if err := moments.Start(); err != nil {
return err
} else {
elapsed := time.Since(start)
log.Infof("completed in %s", elapsed)
}
conf.Shutdown()
return nil
}

View file

@ -48,23 +48,24 @@ type ClientConfig struct {
}
type ClientCounts struct {
Cameras uint `json:"cameras"`
Lenses uint `json:"lenses"`
Countries uint `json:"countries"`
Photos uint `json:"photos"`
Videos uint `json:"videos"`
Hidden uint `json:"hidden"`
Favorites uint `json:"favorites"`
Private uint `json:"private"`
Review uint `json:"review"`
Stories uint `json:"stories"`
Albums uint `json:"albums"`
Folders uint `json:"folders"`
Files uint `json:"files"`
Moments uint `json:"moments"`
Places uint `json:"places"`
Labels uint `json:"labels"`
LabelMaxPhotos uint `json:"labelMaxPhotos"`
Cameras int `json:"cameras"`
Lenses int `json:"lenses"`
Countries int `json:"countries"`
Photos int `json:"photos"`
Videos int `json:"videos"`
Hidden int `json:"hidden"`
Favorites int `json:"favorites"`
Private int `json:"private"`
Review int `json:"review"`
Stories int `json:"stories"`
Albums int `json:"albums"`
Moments int `json:"moments"`
Months int `json:"months"`
Folders int `json:"folders"`
Files int `json:"files"`
Places int `json:"places"`
Labels int `json:"labels"`
LabelMaxPhotos int `json:"labelMaxPhotos"`
}
type CategoryLabel struct {
@ -205,7 +206,7 @@ func (c *Config) ClientConfig() ClientConfig {
Take(&result.Count)
db.Table("albums").
Select("SUM(album_type = ?) AS albums, SUM(album_type = ?) AS moments, SUM(album_type = ?) AS folders", entity.TypeAlbum, entity.TypeMoment, entity.TypeFolder).
Select("SUM(album_type = ?) AS albums, SUM(album_type = ?) AS moments, SUM(album_type = ?) AS months, SUM(album_type = ?) AS folders", entity.TypeAlbum, entity.TypeMoment, entity.TypeMonth, entity.TypeFolder).
Where("deleted_at IS NULL").
Take(&result.Count)

View file

@ -9,7 +9,6 @@ import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
gc "github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/thumb"
@ -25,7 +24,6 @@ var once sync.Once
type Config struct {
once sync.Once
db *gorm.DB
cache *gc.Cache
params *Params
settings *Settings
token string
@ -193,15 +191,6 @@ func (c *Config) LogLevel() logrus.Level {
}
}
// Cache returns the in-memory cache.
func (c *Config) Cache() *gc.Cache {
if c.cache == nil {
c.cache = gc.New(336*time.Hour, 30*time.Minute)
}
return c.cache
}
// Shutdown services and workers.
func (c *Config) Shutdown() {
mutex.MainWorker.Cancel()

View file

@ -6,6 +6,7 @@ import (
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
@ -69,6 +70,62 @@ func NewAlbum(albumTitle, albumType string) *Album {
return result
}
// NewMoment creates a new moment.
func NewMoment(albumTitle, albumSlug, albumFilter string) *Album {
if albumTitle == "" || albumSlug == "" || albumFilter == "" {
return nil
}
now := time.Now().UTC()
result := &Album{
AlbumUID: rnd.PPID('a'),
AlbumOrder: SortOrderOldest,
AlbumType: TypeMoment,
AlbumTitle: albumTitle,
AlbumSlug: albumSlug,
AlbumFilter: albumFilter,
CreatedAt: now,
UpdatedAt: now,
}
return result
}
// NewMonth creates a new month album.
func NewMonth(albumTitle, albumSlug string, year, month int) *Album {
if albumTitle == "" || albumSlug == "" || year == 0 || month == 0 {
return nil
}
f := form.PhotoSearch{
Year: year,
Month: month,
}
now := time.Now().UTC()
result := &Album{
AlbumUID: rnd.PPID('a'),
AlbumOrder: SortOrderOldest,
AlbumType: TypeMonth,
AlbumTitle: albumTitle,
AlbumSlug: albumSlug,
AlbumFilter: f.Serialize(),
AlbumYear: year,
AlbumMonth: month,
CreatedAt: now,
UpdatedAt: now,
}
return result
}
// Checks if the album is of type moment.
func (m *Album) IsMoment() bool {
return m.AlbumType == TypeMoment
}
// SetTitle changes the album name.
func (m *Album) SetTitle(title string) {
title = strings.TrimSpace(title)
@ -111,5 +168,30 @@ func (m *Album) Save() error {
// Create inserts a new row to the database.
func (m *Album) Create() error {
return Db().Create(m).Error
if err := Db().Create(m).Error; err != nil {
return err
}
switch m.AlbumType {
case TypeAlbum:
event.Publish("count.albums", event.Data{"count": 1})
case TypeMoment:
event.Publish("count.moments", event.Data{"count": 1})
case TypeMonth:
event.Publish("count.months", event.Data{"count": 1})
case TypeFolder:
event.Publish("count.folders", event.Data{"count": 1})
}
return nil
}
// FindAlbum finds a matching album or returns nil.
func FindAlbum(slug string) *Album {
result := Album{}
if err := Db().Where("album_slug = ?", slug).First(&result).Error; err != nil {
return nil
}
return &result
}

View file

@ -31,6 +31,7 @@ const (
TypeAlbum = "album"
TypeFolder = "folder"
TypeMoment = "moment"
TypeMonth = "month"
TypeImage = "image"
TypeLive = "live"
TypeVideo = "video"

View file

@ -36,6 +36,11 @@ func (m *PhotoAlbum) Create() error {
return Db().Create(m).Error
}
// Save updates or inserts a row.
func (m *PhotoAlbum) Save() error {
return Db().Save(m).Error
}
// FirstOrCreatePhotoAlbum returns the existing row, inserts a new row or nil in case of errors.
func FirstOrCreatePhotoAlbum(m *PhotoAlbum) *PhotoAlbum {
result := PhotoAlbum{}

View file

@ -14,7 +14,7 @@ func (m *Photo) EstimatePosition() {
var recentPhoto Photo
if result := UnscopedDb().
Where("place_id <> '' AND place_id <> 'zz'").
Where("place_id <> '' AND place_id <> 'zz' AND loc_src <> '' AND loc_src <> ?", SrcEstimate).
Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", m.TakenAt)).
Preload("Place").First(&recentPhoto); result.Error == nil {
if recentPhoto.HasPlace() {

View file

@ -7,6 +7,7 @@ import (
// PhotoSearch represents search form fields for "/api/v1/photos".
type PhotoSearch struct {
Query string `form:"q"`
Filter string `form:"filter"`
ID string `form:"id"`
Type string `form:"type"`
Path string `form:"path"`
@ -62,13 +63,21 @@ func (f *PhotoSearch) SetQuery(q string) {
}
func (f *PhotoSearch) ParseQueryString() error {
err := ParseQueryString(f)
if err := ParseQueryString(f); err != nil {
return err
}
if f.Path == "" && f.Folder != "" {
f.Path = f.Folder
}
return err
if f.Filter != "" {
if err := Unserialize(f, f.Filter); err != nil {
return err
}
}
return nil
}
// Serialize returns a string containing non-empty fields and values of a struct.

View file

@ -1,106 +1,12 @@
package form
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
"unicode"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/araddon/dateparse"
)
type SearchForm interface {
GetQuery() string
SetQuery(q string)
}
func ParseQueryString(f SearchForm) (result error) {
var key, value []rune
var escaped, isKeyValue bool
q := f.GetQuery()
f.SetQuery("")
formValues := reflect.ValueOf(f).Elem()
q = strings.TrimSpace(q) + "\n"
var queryStrings []string
for _, char := range q {
if unicode.IsSpace(char) && !escaped {
if isKeyValue {
fieldName := strings.Title(string(key))
field := formValues.FieldByName(fieldName)
stringValue := string(value)
if field.CanSet() {
switch field.Interface().(type) {
case time.Time:
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
result = err
} else {
field.Set(reflect.ValueOf(timeValue))
}
case float32, float64:
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
result = err
} else {
field.SetFloat(floatValue)
}
case int, int8, int16, int32, int64:
if intValue, err := strconv.Atoi(stringValue); err != nil {
result = err
} else {
field.SetInt(int64(intValue))
}
case uint, uint8, uint16, uint32, uint64:
if intValue, err := strconv.Atoi(stringValue); err != nil {
result = err
} else {
field.SetUint(uint64(intValue))
}
case string:
field.SetString(stringValue)
case bool:
field.SetBool(txt.Bool(stringValue))
default:
result = fmt.Errorf("unsupported type: %s", fieldName)
}
} else {
result = fmt.Errorf("unknown filter: %s", fieldName)
}
} else if len(strings.TrimSpace(string(key))) > 0 {
queryStrings = append(queryStrings, strings.TrimSpace(string(key)))
}
escaped = false
isKeyValue = false
key = key[:0]
value = value[:0]
} else if char == ':' {
isKeyValue = true
} else if char == '"' {
escaped = !escaped
} else if isKeyValue {
value = append(value, char)
} else {
key = append(key, unicode.ToLower(char))
}
}
if len(queryStrings) > 0 {
f.SetQuery(strings.Join(queryStrings, " "))
}
if result != nil {
log.Errorf("error while parsing search form: %s", result)
}
return result
return Unserialize(f, q)
}

View file

@ -3,8 +3,13 @@ package form
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
"unicode"
"github.com/araddon/dateparse"
"github.com/photoprism/photoprism/pkg/txt"
)
// Serialize returns a string containing all non-empty fields and values of a struct.
@ -70,3 +75,88 @@ func Serialize(f interface{}, all bool) string {
return strings.Join(q, " ")
}
func Unserialize(f SearchForm, q string) (result error){
var key, value []rune
var escaped, isKeyValue bool
f.SetQuery("")
formValues := reflect.ValueOf(f).Elem()
q = strings.TrimSpace(q) + "\n"
var queryStrings []string
for _, char := range q {
if unicode.IsSpace(char) && !escaped {
if isKeyValue {
fieldName := strings.Title(string(key))
field := formValues.FieldByName(fieldName)
stringValue := string(value)
if field.CanSet() {
switch field.Interface().(type) {
case time.Time:
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
result = err
} else {
field.Set(reflect.ValueOf(timeValue))
}
case float32, float64:
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
result = err
} else {
field.SetFloat(floatValue)
}
case int, int8, int16, int32, int64:
if intValue, err := strconv.Atoi(stringValue); err != nil {
result = err
} else {
field.SetInt(int64(intValue))
}
case uint, uint8, uint16, uint32, uint64:
if intValue, err := strconv.Atoi(stringValue); err != nil {
result = err
} else {
field.SetUint(uint64(intValue))
}
case string:
field.SetString(stringValue)
case bool:
field.SetBool(txt.Bool(stringValue))
default:
result = fmt.Errorf("unsupported type: %s", fieldName)
}
} else {
result = fmt.Errorf("unknown filter: %s", fieldName)
}
} else if len(strings.TrimSpace(string(key))) > 0 {
queryStrings = append(queryStrings, strings.TrimSpace(string(key)))
}
escaped = false
isKeyValue = false
key = key[:0]
value = value[:0]
} else if char == ':' {
isKeyValue = true
} else if char == '"' {
escaped = !escaped
} else if isKeyValue {
value = append(value, char)
} else {
key = append(key, unicode.ToLower(char))
}
}
if len(queryStrings) > 0 {
f.SetQuery(strings.Join(queryStrings, " "))
}
if result != nil {
log.Errorf("error while parsing form values: %s", result)
}
return result
}

View file

@ -3,7 +3,17 @@ package places
import (
"time"
gc "github.com/patrickmn/go-cache"
"github.com/allegro/bigcache"
)
var cache = gc.New(15*time.Minute, 5*time.Minute)
var cache *bigcache.BigCache
func init() {
var err error
cache, err = bigcache.NewBigCache(bigcache.DefaultConfig(time.Hour))
if err != nil {
log.Errorf("")
}
}

View file

@ -7,7 +7,6 @@ import (
"strings"
"time"
gc "github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/pkg/s2"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -46,17 +45,22 @@ func FindLocation(id string) (result Location, err error) {
return result, fmt.Errorf("api: invalid location id %s (%s)", id, ApiName)
}
start := time.Now()
lat, lng := s2.LatLng(id)
if lat == 0.0 || lng == 0.0 {
return result, fmt.Errorf("api: skipping lat %f, lng %f (%s)", lat, lng, ApiName)
}
if hit, ok := cache.Get(id); ok {
if hit, err := cache.Get(id); err == nil {
log.Debugf("api: cache hit for lat %f, lng %f (%s)", lat, lng, ApiName)
cached := hit.(*Location)
cached.Cached = true
return *cached, nil
var cached Location
if err := json.Unmarshal(hit, &cached); err != nil {
log.Errorf("api: %s (%s)", err.Error(), ApiName)
} else {
cached.Cached = true
return cached, nil
}
}
url := fmt.Sprintf(ReverseLookupURL, id)
@ -101,7 +105,13 @@ func FindLocation(id string) (result Location, err error) {
return result, fmt.Errorf("api: no result for %s (%s)", id, ApiName)
}
cache.Set(id, &result, gc.DefaultExpiration)
if cached, err := json.Marshal(result); err == nil {
if err := cache.Set(id, cached); err != nil {
log.Errorf("api: %s (%s)", id, ApiName)
} else {
log.Debugf("cached %s [%s]", id, time.Since(start))
}
}
result.Cached = false

View file

@ -2,14 +2,20 @@ package photoprism
import (
"fmt"
"math"
"runtime"
"strings"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
)
// Moments represents a worker that creates albums based on popular locations, dates and categories.
// Moments represents a worker that creates albums based on popular locations, dates and labels.
type Moments struct {
conf *config.Config
}
@ -47,6 +53,116 @@ func (m *Moments) Start() (err error) {
}
}()
counts := query.Counts{}
counts.Refresh()
threshold := int(math.Log2(float64(counts.Photos))) + 1
log.Infof("moments: threshold %d / %d", threshold, counts.Photos)
// Important years and months.
if results, err := query.MomentsTime(threshold); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
if a := entity.FindAlbum(mom.Slug()); a != nil {
log.Infof("moments: %s already exists (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
} else if a := entity.NewMonth(mom.Title(), mom.Slug(), mom.Year, mom.Month); a != nil {
if err := a.Create(); err != nil {
log.Errorf("moments: %s", err)
} else {
log.Infof("moments: added %s (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
}
}
}
}
// Countries by year.
if results, err := query.MomentsCountries(threshold); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
f := form.PhotoSearch{
Country: mom.Country,
Year: mom.Year,
}
if a := entity.FindAlbum(mom.Slug()); a != nil {
log.Infof("moments: %s already exists (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
} else if a := entity.NewMoment(mom.Title(), mom.Slug(), f.Serialize()); a != nil {
a.AlbumYear = mom.Year
a.AlbumCountry = mom.Country
if err := a.Create(); err != nil {
log.Errorf("moments: %s", err)
} else {
log.Infof("moments: added %s (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
}
}
}
}
// States and countries.
if results, err := query.MomentsStates(threshold); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
f := form.PhotoSearch{
Country: mom.Country,
State: mom.State,
}
if a := entity.FindAlbum(mom.Slug()); a != nil {
log.Infof("moments: %s already exists (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
} else if a := entity.NewMoment(mom.Title(), mom.Slug(), f.Serialize()); a != nil {
a.AlbumCountry = mom.Country
if err := a.Create(); err != nil {
log.Errorf("moments: %s", err)
} else {
log.Infof("moments: added %s (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
}
}
}
}
// Popular labels.
if results, err := query.MomentsLabels(threshold); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
f := form.PhotoSearch{
Label: mom.Label,
}
if a := entity.FindAlbum(mom.Slug()); a != nil {
log.Infof("moments: %s already exists (%s)", txt.Quote(mom.Title()), f.Serialize())
if err := form.ParseQueryString(&f); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
w := txt.Words(f.Label)
w = append(w, mom.Label)
f.Label = strings.Join(txt.UniqueWords(w), ",")
}
if err := a.Update("AlbumFilter", f.Serialize()); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
log.Infof("moments: updated %s (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
}
} else if a := entity.NewMoment(mom.Title(), mom.Slug(), f.Serialize()); a != nil {
if err := a.Create(); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
log.Infof("moments: added %s (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
}
} else {
log.Errorf("moments: failed to create new moment %s (%s)", mom.Title(), f.Serialize())
}
}
}
return nil
}

View file

@ -0,0 +1,18 @@
package photoprism
import (
"testing"
"github.com/photoprism/photoprism/internal/config"
)
func TestMoments_Start(t *testing.T) {
conf := config.TestConfig()
m := NewMoments(conf)
err := m.Start()
if err != nil {
t.Fatal(err)
}
}

View file

@ -47,8 +47,26 @@ func AlbumByUID(albumUID string) (album entity.Album, err error) {
return album, nil
}
// AlbumThumbByUID returns a album preview file based on the uid.
func AlbumThumbByUID(albumUID string) (file entity.File, err error) {
// AlbumCoverByUID returns a album preview file based on the uid.
func AlbumCoverByUID(albumUID string) (file entity.File, err error) {
a := entity.Album{}
if err := Db().Where("album_uid = ?", albumUID).First(&a).Error; err != nil {
return file, err
} else if a.IsMoment() { // TODO: Optimize
f := form.PhotoSearch{Album: a.AlbumUID, Filter: a.AlbumFilter, Order: entity.SortOrderRelevance, Count: 1, Offset: 0, Merged: true}
if photos, _, err := PhotoSearch(f); err != nil {
return file, err
} else if len(photos) > 0 {
for _, photo := range photos {
return FileByPhotoUID(photo.PhotoUID)
}
}
return file, fmt.Errorf("found no cover for moment")
}
if err := Db().
Where("files.file_primary = 1 AND files.file_missing = 0 AND files.file_type = 'jpg' AND files.deleted_at IS NULL").
Joins("JOIN albums ON albums.album_uid = ?", albumUID).
@ -112,7 +130,7 @@ func AlbumSearch(f form.AlbumSearch) (results []AlbumResult, err error) {
case "slug":
s = s.Order("albums.album_favorite DESC, album_slug ASC")
default:
s = s.Order("albums.album_favorite DESC, photo_count DESC, albums.created_at DESC")
s = s.Order("albums.album_favorite DESC, albums.album_year DESC, albums.album_month DESC, albums.album_title, albums.created_at DESC")
}
if f.Count > 0 && f.Count <= 1000 {

View file

@ -27,7 +27,7 @@ func TestAlbumByUID(t *testing.T) {
func TestAlbumThumbByUID(t *testing.T) {
t.Run("existing uid", func(t *testing.T) {
file, err := AlbumThumbByUID("at9lxuqxpogaaba8")
file, err := AlbumCoverByUID("at9lxuqxpogaaba8")
if err != nil {
t.Fatal(err)
@ -37,7 +37,7 @@ func TestAlbumThumbByUID(t *testing.T) {
})
t.Run("not existing uid", func(t *testing.T) {
file, err := AlbumThumbByUID("3765")
file, err := AlbumCoverByUID("3765")
assert.Error(t, err, "record not found")
t.Log(file)
})

74
internal/query/counts.go Normal file
View file

@ -0,0 +1,74 @@
package query
import "github.com/photoprism/photoprism/internal/entity"
type Counts struct {
Cameras int `json:"cameras"`
Lenses int `json:"lenses"`
Countries int `json:"countries"`
Photos int `json:"photos"`
Videos int `json:"videos"`
Hidden int `json:"hidden"`
Favorites int `json:"favorites"`
Private int `json:"private"`
Review int `json:"review"`
Stories int `json:"stories"`
Albums int `json:"albums"`
Folders int `json:"folders"`
Files int `json:"files"`
Moments int `json:"moments"`
Places int `json:"places"`
Labels int `json:"labels"`
LabelMaxPhotos int `json:"labelMaxPhotos"`
}
func (c *Counts) Refresh() {
Db().Table("cameras").
Where("camera_slug <> 'zz' AND camera_slug <> ''").
Select("COUNT(*) AS cameras").
Take(c)
Db().Table("lenses").
Where("lens_slug <> 'zz' AND lens_slug <> ''").
Select("COUNT(*) AS lenses").
Take(c)
Db().Table("photos").
Select("SUM(photo_type = 'video' AND photo_quality >= 0 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live') AND photo_quality < 3 AND photo_quality >= 0 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live') AND photo_private = 0 AND photo_quality >= 0) AS photos, SUM(photo_favorite = 1 AND photo_quality >= 0) AS favorites, SUM(photo_private = 1 AND photo_quality >= 0) AS private").
Where("photos.id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND (file_missing = 1 OR file_error <> ''))").
Where("deleted_at IS NULL").
Take(c)
Db().Table("labels").
Select("MAX(photo_count) as label_max_photos, COUNT(*) AS labels").
Where("photo_count > 0").
Where("deleted_at IS NULL").
Where("(label_priority >= 0 || label_favorite = 1)").
Take(c)
Db().Table("albums").
Select("SUM(album_type = ?) AS albums, SUM(album_type = ?) AS moments, SUM(album_type = ?) AS folders", entity.TypeAlbum, entity.TypeMoment, entity.TypeFolder).
Where("deleted_at IS NULL").
Take(c)
Db().Table("files").
Select("COUNT(*) AS files").
Where("file_missing = 0").
Where("deleted_at IS NULL").
Take(c)
Db().Table("countries").
Select("(COUNT(*) - 1) AS countries").
Take(c)
Db().Table("places").
Select("SUM(photo_count > 0) AS places").
Where("id != 'zz'").
Take(c)
Db().Table("photos").
Select("SUM(photo_type = 'video' AND photo_quality >= 0 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live') AND photo_quality < 3 AND photo_quality >= 0 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live') AND photo_private = 0 AND photo_quality >= 0) AS photos, SUM(photo_favorite = 1 AND photo_quality >= 0) AS favorites, SUM(photo_private = 1 AND photo_quality >= 0) AS private").
Where("photos.id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND (file_missing = 1 OR file_error <> ''))").
Where("deleted_at IS NULL").
Take(c)
}

View file

@ -11,7 +11,7 @@ import (
// Moment contains photo counts per month and year
type Moment struct {
Category string `json:"Category"`
Label string `json:"Label"`
Country string `json:"Country"`
State string `json:"State"`
Year int `json:"Year"`
@ -19,7 +19,7 @@ type Moment struct {
PhotoCount int `json:"PhotoCount"`
}
var MomentCategory = map[string]string{
var MomentLabels = map[string]string{
"botanical-garden": "Botanical Gardens",
"nature-reserve": "Nature & Landscape",
"landscape": "Nature & Landscape",
@ -37,8 +37,8 @@ func (m Moment) Slug() string {
// Title returns an english title for the moment.
func (m Moment) Title() string {
if m.Year == 0 && m.Month == 0 {
if m.Category != "" {
return MomentCategory[m.Category]
if m.Label != "" {
return MomentLabels[m.Label]
}
country := maps.CountryName(m.Country)
@ -129,16 +129,16 @@ func MomentsStates(threshold int) (results Moments, err error) {
return results, nil
}
// MomentsCategories returns the most popular photo categories.
func MomentsCategories(threshold int) (results Moments, err error) {
// MomentsLabels returns the most popular photo labels.
func MomentsLabels(threshold int) (results Moments, err error) {
var cats []string
for cat, _ := range MomentCategory {
for cat, _ := range MomentLabels {
cats = append(cats, cat)
}
db := UnscopedDb().Table("photos").
Select("l.label_slug AS category, COUNT(*) AS photo_count").
Select("l.label_slug AS label, COUNT(*) AS photo_count").
Joins("JOIN photos_labels pl ON pl.photo_id = photos.id AND pl.uncertainty < 100").
Joins("JOIN labels l ON l.id = pl.label_id").
Where("photos.photo_quality >= 3 AND photos.deleted_at IS NULL AND l.label_slug IN (?)", cats).

View file

@ -86,20 +86,20 @@ func TestMomentsStates(t *testing.T) {
func TestMomentsCategories(t *testing.T) {
t.Run("result found", func(t *testing.T) {
results, err := MomentsCategories(1)
results, err := MomentsLabels(1)
if err != nil {
t.Fatal(err)
}
t.Logf("MomentsCategories %+v", results)
t.Logf("MomentsLabels %+v", results)
if len(results) < 1 {
t.Error("at least one result expected")
}
for _, moment := range results {
assert.NotEmpty(t, moment.Category)
assert.NotEmpty(t, moment.Label)
assert.Empty(t, moment.Country)
assert.Empty(t, moment.State)
assert.Equal(t, moment.Year, 0)

View file

@ -151,10 +151,6 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
s = s.Where("files.file_error = ''")
}
if f.Album != "" {
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = photos.photo_uid").Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album)
}
if f.Camera > 0 {
s = s.Where("photos.camera_id = ?", f.Camera)
}
@ -286,6 +282,14 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02"))
}
if f.Album != "" {
if f.Filter != "" {
s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", f.Album)
} else {
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = photos.photo_uid").Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album)
}
}
// Set sort order for results.
switch f.Order {
case entity.SortOrderRelevance:

View file

@ -4,16 +4,22 @@ import (
"sync"
"time"
gc "github.com/patrickmn/go-cache"
"github.com/allegro/bigcache"
)
var onceCache sync.Once
func initCache() {
services.Cache = gc.New(336*time.Hour, 30*time.Minute)
var err error
services.Cache, err = bigcache.NewBigCache(bigcache.DefaultConfig(time.Hour))
if err != nil {
log.Errorf("")
}
}
func Cache() *gc.Cache {
func Cache() *bigcache.BigCache {
onceCache.Do(initCache)
return services.Cache

View file

@ -1,19 +1,21 @@
package service
import (
gc "github.com/patrickmn/go-cache"
"github.com/allegro/bigcache"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/session"
)
var log = event.Log
var conf *config.Config
var services struct {
Cache *gc.Cache
Cache *bigcache.BigCache
Classify *classify.TensorFlow
Convert *photoprism.Convert
Import *photoprism.Import