Add moments #154
Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
parent
e775c8f910
commit
dd442ab9e9
37 changed files with 755 additions and 247 deletions
|
@ -26,6 +26,7 @@ func main() {
|
|||
commands.StopCommand,
|
||||
commands.IndexCommand,
|
||||
commands.ImportCommand,
|
||||
commands.MomentsCommand,
|
||||
commands.PurgeCommand,
|
||||
commands.CopyCommand,
|
||||
commands.ConvertCommand,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 } }">
|
||||
|
|
|
@ -90,6 +90,7 @@
|
|||
q: q,
|
||||
count: 1000,
|
||||
offset: 0,
|
||||
type: "album"
|
||||
};
|
||||
|
||||
Album.search(params).then(response => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
1
go.mod
|
@ -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
4
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
f, err := query.AlbumThumbByUID(uid)
|
||||
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.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))
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "image/jpeg", thumbData)
|
||||
if c.Query("download") != "" {
|
||||
c.FileAttachment(thumbnail, f.ShareFileName())
|
||||
} else {
|
||||
c.File(thumbnail)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
@ -31,22 +32,28 @@ 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 {
|
||||
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, cacheData.(*FoldersResponse))
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if folders, err := query.FoldersByPath(rootName, rootPath, path, recursive); err != nil {
|
||||
log.Errorf("folders: %s", err)
|
||||
|
@ -64,10 +71,12 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, urlPath, rootName,
|
|||
}
|
||||
}
|
||||
|
||||
if cached {
|
||||
gc.Set(cacheKey, &resp, time.Minute*5)
|
||||
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)))
|
||||
c.Header("X-Offset", "0")
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
f, err := query.LabelThumbByUID(labelUID)
|
||||
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(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
|
||||
}
|
||||
|
||||
gc.Set(cacheKey, thumbData, time.Hour*4)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "image/jpeg", thumbData)
|
||||
if c.Query("download") != "" {
|
||||
c.FileAttachment(thumbnail, f.ShareFileName())
|
||||
} else {
|
||||
c.File(thumbnail)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
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())
|
||||
|
|
51
internal/commands/moments.go
Normal file
51
internal/commands/moments.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ const (
|
|||
TypeAlbum = "album"
|
||||
TypeFolder = "folder"
|
||||
TypeMoment = "moment"
|
||||
TypeMonth = "month"
|
||||
TypeImage = "image"
|
||||
TypeLive = "live"
|
||||
TypeVideo = "video"
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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("")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
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
|
||||
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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
18
internal/photoprism/moments_test.go
Normal file
18
internal/photoprism/moments_test.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
74
internal/query/counts.go
Normal 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)
|
||||
}
|
|
@ -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).
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue