API: Clear album cover cache when updating private flag(s) #807 #822

This commit is contained in:
Michael Mayer 2021-01-08 12:20:41 +01:00
parent 4f8af03b55
commit 2952728098
18 changed files with 594 additions and 516 deletions

View file

@ -113,7 +113,7 @@ func GetAccountFolders(router *gin.RouterGroup) {
start := time.Now()
id := ParseUint(c.Param("id"))
cache := service.Cache()
cache := service.BigCache()
cacheKey := fmt.Sprintf("account-folders:%d", id)
if cacheData, err := cache.Get(cacheKey); err == nil {

View file

@ -16,7 +16,6 @@ import (
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
@ -43,19 +42,6 @@ func SaveAlbumAsYaml(a entity.Album) {
}
}
// ClearAlbumThumbCache removes all cached album covers e.g. after adding or removed photos.
func ClearAlbumThumbCache(uid string) {
cache := service.Cache()
for typeName := range thumb.Types {
cacheKey := fmt.Sprintf("album-thumbs:%s:%s", uid, typeName)
if err := cache.Delete(cacheKey); err == nil {
log.Debugf("removed %s from cache", cacheKey)
}
}
}
// GET /api/v1/albums
func GetAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) {
@ -411,7 +397,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup) {
event.SuccessMsg(i18n.MsgEntriesAddedTo, len(added), txt.Quote(a.Title()))
}
ClearAlbumThumbCache(a.AlbumUID)
RemoveFromAlbumCoverCache(a.AlbumUID)
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
@ -460,7 +446,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) {
event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), txt.Quote(txt.Quote(a.Title())))
}
ClearAlbumThumbCache(a.AlbumUID)
RemoveFromAlbumCoverCache(a.AlbumUID)
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)

View file

@ -231,6 +231,8 @@ func BatchPhotosPrivate(router *gin.RouterGroup) {
UpdateClientConfig()
FlushCoverCache()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
})
}

57
internal/api/cache.go Normal file
View file

@ -0,0 +1,57 @@
package api
import (
"fmt"
"strconv"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
)
// MaxAge represents a cache TTL in seconds.
type MaxAge int
// String returns the cache TTL in seconds as string.
func (a MaxAge) String() string {
return strconv.Itoa(int(a))
}
// Default cache TTL times in seconds.
var (
CoverCacheTTL MaxAge = 3600 // 1 hour
ThumbCacheTTL MaxAge = 3600 * 24 * 90 // ~ 3 months
)
type ThumbCache struct {
FileName string
ShareName string
}
type ByteCache struct {
Data []byte
}
// CacheKey returns a cache key string based on namespace, uid and name.
func CacheKey(ns, uid, name string) string {
return fmt.Sprintf("%s:%s:%s", ns, uid, name)
}
// RemoveFromAlbumCoverCache removes covers by album UID e.g. after adding or removing photos.
func RemoveFromAlbumCoverCache(uid string) {
cache := service.CoverCache()
for typeName := range thumb.Types {
cacheKey := CacheKey(albumCover, uid, typeName)
cache.Delete(cacheKey)
log.Debugf("removed %s from cache", cacheKey)
}
}
// FlushCoverCache clears the complete cover cache.
func FlushCoverCache() {
service.CoverCache().Flush()
log.Debugf("albums: flushed cover cache")
}

243
internal/api/covers.go Normal file
View file

@ -0,0 +1,243 @@
package api
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// Namespaces for caching and logs.
const (
albumCover = "album-cover"
labelCover = "label-cover"
)
// GET /api/v1/albums/:uid/t/:token/:type
//
// Parameters:
// uid: string album uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func AlbumCover(router *gin.RouterGroup) {
router.GET("/albums/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", albumIconSvg)
return
}
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("%s: invalid type %s", albumCover, typeName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
cache := service.CoverCache()
cacheKey := CacheKey(albumCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
if !fs.FileExists(cached.FileName) {
log.Errorf("%s: %s not found", albumCover, uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
AddCoverCacheHeader(c)
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("%s: no photos yet, using generic image for %s", albumCover, uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: could not find original for %s", albumCover, 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("%s: %s is missing", albumCover, txt.Quote(f.FileName))
logError(albumCover, f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() && c.Query("download") == "" {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", albumCover, thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
} else if thumbnail == "" {
log.Errorf("%s: %s has empty thumb name - bug?", albumCover, filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
cache.Set(cacheKey, ThumbCache{thumbnail, f.ShareBase()}, time.Hour)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
} else {
c.File(thumbnail)
}
})
}
// GET /api/v1/labels/:uid/t/:token/:type
//
// Parameters:
// uid: string label uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func LabelCover(router *gin.RouterGroup) {
router.GET("/labels/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", labelIconSvg)
return
}
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("%s: invalid type %s", labelCover, txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
cache := service.CoverCache()
cacheKey := CacheKey(labelCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
if !fs.FileExists(cached.FileName) {
log.Errorf("%s: %s not found", labelCover, uid)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
AddCoverCacheHeader(c)
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())
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: file %s is missing", labelCover, 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(labelCover, f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", labelCover, thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("%s: %s", labelCover, err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
} else if thumbnail == "" {
log.Errorf("%s: %s has empty thumb name - bug?", labelCover, filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
cache.Set(cacheKey, ThumbCache{thumbnail, f.ShareBase()}, time.Hour)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
} else {
c.File(thumbnail)
}
})
}

View file

@ -7,68 +7,45 @@ import (
"github.com/stretchr/testify/assert"
)
func TestGetThumb(t *testing.T) {
func TestAlbumCover(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid hash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/2cad9168fa6acc5c5c2965ddf6ec465ca42fd818/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
}
func TestAlbumThumb(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
app, router, conf := NewApiTest()
AlbumThumb(router)
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba7/t/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("album has no photo (because is not existing)", func(t *testing.T) {
app, router, conf := NewApiTest()
AlbumThumb(router)
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/987-986435/t/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("album: could not find original", func(t *testing.T) {
app, router, conf := NewApiTest()
AlbumThumb(router)
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba8/t/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
}
func TestLabelThumb(t *testing.T) {
func TestLabelCover(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
app, router, conf := NewApiTest()
LabelThumb(router)
LabelCover(router)
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c2/t/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid label", func(t *testing.T) {
app, router, conf := NewApiTest()
LabelThumb(router)
LabelCover(router)
r := PerformRequest(app, "GET", "/api/v1/labels/xxx/t/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
app, router, conf := NewApiTest()
LabelThumb(router)
LabelCover(router)
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c3/t/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})

View file

@ -26,7 +26,7 @@ type FoldersResponse struct {
// ClearFoldersCache removes folder lists from cache e.g. after indexing.
func ClearFoldersCache(rootName string) {
cache := service.Cache()
cache := service.BigCache()
cacheKey := fmt.Sprintf("folders:%s:%t:%t", rootName, true, false)
@ -55,7 +55,7 @@ func GetFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string) {
return
}
cache := service.Cache()
cache := service.BigCache()
recursive := f.Recursive
listFiles := f.Files
uncached := listFiles || f.Uncached

View file

@ -9,20 +9,13 @@ import (
"github.com/gin-gonic/gin"
)
type MaxAge string
var (
CoverCacheTTL MaxAge = "3600" // 1 hour
ThumbCacheTTL MaxAge = "7776000" // ~ 3 months
)
const (
ContentTypeAvc = `video/mp4; codecs="avc1`
)
// AddCacheHeader adds a cache control header to the response.
func AddCacheHeader(c *gin.Context, maxAge MaxAge) {
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform", maxAge))
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform", maxAge.String()))
}
// AddCoverCacheHeader adds cover image cache control headers to the response.
@ -32,7 +25,7 @@ func AddCoverCacheHeader(c *gin.Context) {
// AddCacheHeader adds thumbnail cache control headers to the response.
func AddThumbCacheHeader(c *gin.Context) {
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform, immutable", ThumbCacheTTL))
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform, immutable", ThumbCacheTTL.String()))
}
// AddCountHeader adds the actual result count to the response.

View file

@ -90,6 +90,8 @@ func UpdatePhoto(router *gin.RouterGroup) {
if err := c.BindJSON(&f); err != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
} else if f.PhotoPrivate {
FlushCoverCache()
}
// 3) Save model with values from form

169
internal/api/photo_thumb.go Normal file
View file

@ -0,0 +1,169 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /api/v1/t/:hash/:token/:type
//
// Parameters:
// hash: string file hash as returned by the search API
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func GetThumb(router *gin.RouterGroup) {
router.GET("/t/:hash/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
start := time.Now()
conf := service.Config()
fileHash := c.Param("hash")
typeName := c.Param("type")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("thumbs: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if thumbType.ExceedsSize() && !conf.ThumbUncached() {
typeName, thumbType = thumb.Find(conf.ThumbSize())
if typeName == "" {
log.Errorf("thumbs: invalid size %d", conf.ThumbSize())
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
}
cache := service.BigCache()
cacheKey := fmt.Sprintf("thumbs:%s:%s", fileHash, typeName)
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("thumbs: %s not found", fileHash)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("thumbs: %s not found", fileHash)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
AddThumbCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.FileByHash(fileHash)
if err != nil {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
// Find fallback if file is not a JPEG image.
if f.NoJPEG() {
f, err = query.FileByPhotoUID(f.PhotoUID)
if err != nil {
c.Data(http.StatusOK, "image/svg+xml", fileIconSvg)
return
}
}
// Return SVG icon as placeholder if file has errors.
if f.FileError != "" {
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("thumbs: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("thumbnail", f.Update("FileMissing", true))
if f.AllFilesMissing() {
log.Infof("thumbs: deleting photo, all files missing for %s", txt.Quote(f.FileName))
logError("thumbnail", f.RelatedPhoto().Delete(false))
}
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() && c.Query("download") == "" {
log.Debugf("thumbs: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
AddThumbCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("thumbs: %s", err)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
} else if thumbnail == "" {
log.Errorf("thumbs: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
// BigCache thumbnail filename.
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareBase()}); err == nil {
logError("thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
AddThumbCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
} else {
c.File(thumbnail)
}
})
}

View file

@ -0,0 +1,31 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetThumb(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid hash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/2cad9168fa6acc5c5c2965ddf6ec465ca42fd818/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
}

View file

@ -1,416 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
type ThumbCache struct {
FileName string
ShareName string
}
type ByteCache struct {
Data []byte
}
// GET /api/v1/t/:hash/:token/:type
//
// Parameters:
// hash: string file hash as returned by the search API
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func GetThumb(router *gin.RouterGroup) {
router.GET("/t/:hash/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
start := time.Now()
conf := service.Config()
fileHash := c.Param("hash")
typeName := c.Param("type")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("thumbs: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if thumbType.ExceedsSize() && !conf.ThumbUncached() {
typeName, thumbType = thumb.Find(conf.ThumbSize())
if typeName == "" {
log.Errorf("thumbs: invalid size %d", conf.ThumbSize())
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
}
cache := service.Cache()
cacheKey := fmt.Sprintf("thumbs:%s:%s", fileHash, typeName)
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("thumbs: %s not found", fileHash)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("thumbs: %s not found", fileHash)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
AddThumbCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.FileByHash(fileHash)
if err != nil {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
// Find fallback if file is not a JPEG image.
if f.NoJPEG() {
f, err = query.FileByPhotoUID(f.PhotoUID)
if err != nil {
c.Data(http.StatusOK, "image/svg+xml", fileIconSvg)
return
}
}
// Return SVG icon as placeholder if file has errors.
if f.FileError != "" {
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("thumbs: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("thumbnail", f.Update("FileMissing", true))
if f.AllFilesMissing() {
log.Infof("thumbs: deleting photo, all files missing for %s", txt.Quote(f.FileName))
logError("thumbnail", f.RelatedPhoto().Delete(false))
}
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() && c.Query("download") == "" {
log.Debugf("thumbs: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
AddThumbCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("thumbs: %s", err)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
} else if thumbnail == "" {
log.Errorf("thumbs: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
// Cache thumbnail filename.
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareBase()}); err == nil {
logError("thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
AddThumbCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
} else {
c.File(thumbnail)
}
})
}
// GET /api/v1/albums/:uid/t/:token/:type
//
// Parameters:
// uid: string album uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func AlbumThumb(router *gin.RouterGroup) {
router.GET("/albums/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", albumIconSvg)
return
}
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("album-thumbs: invalid type %s", typeName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
cache := service.Cache()
cacheKey := fmt.Sprintf("album-thumbs:%s:%s", uid, typeName)
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("album-thumbs: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("album-thumbs: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
AddCacheHeader(c, CoverCacheTTL)
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-thumbs: no photos yet, using generic image for %s", uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("album-thumbs: 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-thumbs: %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.ExceedsSizeUncached() && c.Query("download") == "" {
log.Debugf("album-thumbs: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
} else if thumbnail == "" {
log.Errorf("album-thumbs: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareBase()}); err == nil {
logError("album-thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
} else {
c.File(thumbnail)
}
})
}
// GET /api/v1/labels/:uid/t/:token/:type
//
// Parameters:
// uid: string label uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func LabelThumb(router *gin.RouterGroup) {
router.GET("/labels/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", labelIconSvg)
return
}
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("label-thumbs: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
cache := service.Cache()
cacheKey := fmt.Sprintf("label-thumbs:%s:%s", uid, typeName)
if cacheData, err := cache.Get(cacheKey); err == nil {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
var cached ThumbCache
if err := json.Unmarshal(cacheData, &cached); err != nil {
log.Errorf("label-thumbs: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
if !fs.FileExists(cached.FileName) {
log.Errorf("label-thumbs: %s not found", uid)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
AddCoverCacheHeader(c)
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())
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("label-thumbs: 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-thumbnail", f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() {
log.Debugf("label-thumbs: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("label-thumbs: %s", err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
} else if thumbnail == "" {
log.Errorf("label-thumbs: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
if cached, err := json.Marshal(ThumbCache{thumbnail, f.ShareBase()}); err == nil {
logError("label-thumbnail", cache.Set(cacheKey, cached))
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
} else {
c.File(thumbnail)
}
})
}

View file

@ -77,7 +77,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteLabelLink(v1)
api.LikeLabel(v1)
api.DislikeLabel(v1)
api.LabelThumb(v1)
api.LabelCover(v1)
api.GetFoldersOriginals(v1)
api.GetFoldersImport(v1)
@ -107,7 +107,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteAlbumLink(v1)
api.LikeAlbum(v1)
api.DislikeAlbum(v1)
api.AlbumThumb(v1)
api.AlbumCover(v1)
api.CloneAlbums(v1)
api.AddPhotosToAlbum(v1)
api.RemovePhotosFromAlbum(v1)

View file

@ -0,0 +1,26 @@
package service
import (
"sync"
"time"
"github.com/allegro/bigcache"
)
var onceBigCache sync.Once
func initBigCache() {
var err error
services.BigCache, err = bigcache.NewBigCache(bigcache.DefaultConfig(time.Hour))
if err != nil {
log.Error(err)
}
}
func BigCache() *bigcache.BigCache {
onceBigCache.Do(initBigCache)
return services.BigCache
}

View file

@ -1,26 +0,0 @@
package service
import (
"sync"
"time"
"github.com/allegro/bigcache"
)
var onceCache sync.Once
func initCache() {
var err error
services.Cache, err = bigcache.NewBigCache(bigcache.DefaultConfig(time.Hour))
if err != nil {
log.Errorf("")
}
}
func Cache() *bigcache.BigCache {
onceCache.Do(initCache)
return services.Cache
}

View file

@ -0,0 +1,20 @@
package service
import (
"sync"
"time"
gc "github.com/patrickmn/go-cache"
)
var onceCoverCache sync.Once
func initCoverCache() {
services.CoverCache = gc.New(time.Hour, 10*time.Minute)
}
func CoverCache() *gc.Cache {
onceCoverCache.Do(initCoverCache)
return services.CoverCache
}

View file

@ -9,25 +9,28 @@ import (
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/session"
gc "github.com/patrickmn/go-cache"
)
var log = event.Log
var conf *config.Config
var services struct {
Cache *bigcache.BigCache
Classify *classify.TensorFlow
Convert *photoprism.Convert
Files *photoprism.Files
Photos *photoprism.Photos
Import *photoprism.Import
Index *photoprism.Index
Moments *photoprism.Moments
Purge *photoprism.Purge
Nsfw *nsfw.Detector
Query *query.Query
Resample *photoprism.Resample
Session *session.Session
BigCache *bigcache.BigCache
CoverCache *gc.Cache
Classify *classify.TensorFlow
Convert *photoprism.Convert
Files *photoprism.Files
Photos *photoprism.Photos
Import *photoprism.Import
Index *photoprism.Index
Moments *photoprism.Moments
Purge *photoprism.Purge
Nsfw *nsfw.Detector
Query *query.Query
Resample *photoprism.Resample
Session *session.Session
}
func SetConfig(c *config.Config) {

View file

@ -4,6 +4,9 @@ import (
"os"
"testing"
"github.com/allegro/bigcache"
gc "github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/photoprism"
@ -30,6 +33,14 @@ func TestConfig(t *testing.T) {
assert.Equal(t, conf, Config())
}
func TestBigCache(t *testing.T) {
assert.IsType(t, &bigcache.BigCache{}, BigCache())
}
func TestCoverCache(t *testing.T) {
assert.IsType(t, &gc.Cache{}, CoverCache())
}
func TestClassify(t *testing.T) {
assert.IsType(t, &classify.TensorFlow{}, Classify())
}