parent
4f8af03b55
commit
2952728098
|
@ -113,7 +113,7 @@ func GetAccountFolders(router *gin.RouterGroup) {
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
id := ParseUint(c.Param("id"))
|
id := ParseUint(c.Param("id"))
|
||||||
cache := service.Cache()
|
cache := service.BigCache()
|
||||||
cacheKey := fmt.Sprintf("account-folders:%d", id)
|
cacheKey := fmt.Sprintf("account-folders:%d", id)
|
||||||
|
|
||||||
if cacheData, err := cache.Get(cacheKey); err == nil {
|
if cacheData, err := cache.Get(cacheKey); err == nil {
|
||||||
|
|
|
@ -16,7 +16,6 @@ import (
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
"github.com/photoprism/photoprism/internal/photoprism"
|
||||||
"github.com/photoprism/photoprism/internal/query"
|
"github.com/photoprism/photoprism/internal/query"
|
||||||
"github.com/photoprism/photoprism/internal/service"
|
"github.com/photoprism/photoprism/internal/service"
|
||||||
"github.com/photoprism/photoprism/internal/thumb"
|
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"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
|
// GET /api/v1/albums
|
||||||
func GetAlbums(router *gin.RouterGroup) {
|
func GetAlbums(router *gin.RouterGroup) {
|
||||||
router.GET("/albums", func(c *gin.Context) {
|
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()))
|
event.SuccessMsg(i18n.MsgEntriesAddedTo, len(added), txt.Quote(a.Title()))
|
||||||
}
|
}
|
||||||
|
|
||||||
ClearAlbumThumbCache(a.AlbumUID)
|
RemoveFromAlbumCoverCache(a.AlbumUID)
|
||||||
|
|
||||||
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
|
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())))
|
event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), txt.Quote(txt.Quote(a.Title())))
|
||||||
}
|
}
|
||||||
|
|
||||||
ClearAlbumThumbCache(a.AlbumUID)
|
RemoveFromAlbumCoverCache(a.AlbumUID)
|
||||||
|
|
||||||
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
|
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
|
||||||
|
|
||||||
|
|
|
@ -231,6 +231,8 @@ func BatchPhotosPrivate(router *gin.RouterGroup) {
|
||||||
|
|
||||||
UpdateClientConfig()
|
UpdateClientConfig()
|
||||||
|
|
||||||
|
FlushCoverCache()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
|
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
57
internal/api/cache.go
Normal file
57
internal/api/cache.go
Normal 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
243
internal/api/covers.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -7,68 +7,45 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetThumb(t *testing.T) {
|
func TestAlbumCover(t *testing.T) {
|
||||||
t.Run("invalid type", func(t *testing.T) {
|
t.Run("invalid type", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
GetThumb(router)
|
AlbumCover(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)
|
|
||||||
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba7/t/"+conf.PreviewToken()+"/xxx")
|
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba7/t/"+conf.PreviewToken()+"/xxx")
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, r.Code)
|
assert.Equal(t, http.StatusOK, r.Code)
|
||||||
})
|
})
|
||||||
t.Run("album has no photo (because is not existing)", func(t *testing.T) {
|
t.Run("album has no photo (because is not existing)", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
AlbumThumb(router)
|
AlbumCover(router)
|
||||||
r := PerformRequest(app, "GET", "/api/v1/albums/987-986435/t/"+conf.PreviewToken()+"/tile_500")
|
r := PerformRequest(app, "GET", "/api/v1/albums/987-986435/t/"+conf.PreviewToken()+"/tile_500")
|
||||||
assert.Equal(t, http.StatusOK, r.Code)
|
assert.Equal(t, http.StatusOK, r.Code)
|
||||||
})
|
})
|
||||||
t.Run("album: could not find original", func(t *testing.T) {
|
t.Run("album: could not find original", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
AlbumThumb(router)
|
AlbumCover(router)
|
||||||
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba8/t/"+conf.PreviewToken()+"/tile_500")
|
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba8/t/"+conf.PreviewToken()+"/tile_500")
|
||||||
assert.Equal(t, http.StatusOK, r.Code)
|
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) {
|
t.Run("invalid type", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
LabelThumb(router)
|
LabelCover(router)
|
||||||
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c2/t/"+conf.PreviewToken()+"/xxx")
|
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c2/t/"+conf.PreviewToken()+"/xxx")
|
||||||
assert.Equal(t, http.StatusOK, r.Code)
|
assert.Equal(t, http.StatusOK, r.Code)
|
||||||
})
|
})
|
||||||
t.Run("invalid label", func(t *testing.T) {
|
t.Run("invalid label", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
LabelThumb(router)
|
LabelCover(router)
|
||||||
r := PerformRequest(app, "GET", "/api/v1/labels/xxx/t/"+conf.PreviewToken()+"/tile_500")
|
r := PerformRequest(app, "GET", "/api/v1/labels/xxx/t/"+conf.PreviewToken()+"/tile_500")
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, r.Code)
|
assert.Equal(t, http.StatusOK, r.Code)
|
||||||
})
|
})
|
||||||
t.Run("could not find original", func(t *testing.T) {
|
t.Run("could not find original", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
LabelThumb(router)
|
LabelCover(router)
|
||||||
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c3/t/"+conf.PreviewToken()+"/tile_500")
|
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c3/t/"+conf.PreviewToken()+"/tile_500")
|
||||||
assert.Equal(t, http.StatusOK, r.Code)
|
assert.Equal(t, http.StatusOK, r.Code)
|
||||||
})
|
})
|
|
@ -26,7 +26,7 @@ type FoldersResponse struct {
|
||||||
|
|
||||||
// ClearFoldersCache removes folder lists from cache e.g. after indexing.
|
// ClearFoldersCache removes folder lists from cache e.g. after indexing.
|
||||||
func ClearFoldersCache(rootName string) {
|
func ClearFoldersCache(rootName string) {
|
||||||
cache := service.Cache()
|
cache := service.BigCache()
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("folders:%s:%t:%t", rootName, true, false)
|
cacheKey := fmt.Sprintf("folders:%s:%t:%t", rootName, true, false)
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ func GetFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := service.Cache()
|
cache := service.BigCache()
|
||||||
recursive := f.Recursive
|
recursive := f.Recursive
|
||||||
listFiles := f.Files
|
listFiles := f.Files
|
||||||
uncached := listFiles || f.Uncached
|
uncached := listFiles || f.Uncached
|
||||||
|
|
|
@ -9,20 +9,13 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MaxAge string
|
|
||||||
|
|
||||||
var (
|
|
||||||
CoverCacheTTL MaxAge = "3600" // 1 hour
|
|
||||||
ThumbCacheTTL MaxAge = "7776000" // ~ 3 months
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ContentTypeAvc = `video/mp4; codecs="avc1`
|
ContentTypeAvc = `video/mp4; codecs="avc1`
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddCacheHeader adds a cache control header to the response.
|
// AddCacheHeader adds a cache control header to the response.
|
||||||
func AddCacheHeader(c *gin.Context, maxAge MaxAge) {
|
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.
|
// 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.
|
// AddCacheHeader adds thumbnail cache control headers to the response.
|
||||||
func AddThumbCacheHeader(c *gin.Context) {
|
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.
|
// AddCountHeader adds the actual result count to the response.
|
||||||
|
|
|
@ -90,6 +90,8 @@ func UpdatePhoto(router *gin.RouterGroup) {
|
||||||
if err := c.BindJSON(&f); err != nil {
|
if err := c.BindJSON(&f); err != nil {
|
||||||
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
|
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
|
||||||
return
|
return
|
||||||
|
} else if f.PhotoPrivate {
|
||||||
|
FlushCoverCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Save model with values from form
|
// 3) Save model with values from form
|
||||||
|
|
169
internal/api/photo_thumb.go
Normal file
169
internal/api/photo_thumb.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
31
internal/api/photo_thumb_test.go
Normal file
31
internal/api/photo_thumb_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -77,7 +77,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
||||||
api.DeleteLabelLink(v1)
|
api.DeleteLabelLink(v1)
|
||||||
api.LikeLabel(v1)
|
api.LikeLabel(v1)
|
||||||
api.DislikeLabel(v1)
|
api.DislikeLabel(v1)
|
||||||
api.LabelThumb(v1)
|
api.LabelCover(v1)
|
||||||
|
|
||||||
api.GetFoldersOriginals(v1)
|
api.GetFoldersOriginals(v1)
|
||||||
api.GetFoldersImport(v1)
|
api.GetFoldersImport(v1)
|
||||||
|
@ -107,7 +107,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
||||||
api.DeleteAlbumLink(v1)
|
api.DeleteAlbumLink(v1)
|
||||||
api.LikeAlbum(v1)
|
api.LikeAlbum(v1)
|
||||||
api.DislikeAlbum(v1)
|
api.DislikeAlbum(v1)
|
||||||
api.AlbumThumb(v1)
|
api.AlbumCover(v1)
|
||||||
api.CloneAlbums(v1)
|
api.CloneAlbums(v1)
|
||||||
api.AddPhotosToAlbum(v1)
|
api.AddPhotosToAlbum(v1)
|
||||||
api.RemovePhotosFromAlbum(v1)
|
api.RemovePhotosFromAlbum(v1)
|
||||||
|
|
26
internal/service/big-cache.go
Normal file
26
internal/service/big-cache.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
20
internal/service/cover-cache.go
Normal file
20
internal/service/cover-cache.go
Normal 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
|
||||||
|
}
|
|
@ -9,25 +9,28 @@ import (
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
"github.com/photoprism/photoprism/internal/photoprism"
|
||||||
"github.com/photoprism/photoprism/internal/query"
|
"github.com/photoprism/photoprism/internal/query"
|
||||||
"github.com/photoprism/photoprism/internal/session"
|
"github.com/photoprism/photoprism/internal/session"
|
||||||
|
|
||||||
|
gc "github.com/patrickmn/go-cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
var log = event.Log
|
var log = event.Log
|
||||||
var conf *config.Config
|
var conf *config.Config
|
||||||
|
|
||||||
var services struct {
|
var services struct {
|
||||||
Cache *bigcache.BigCache
|
BigCache *bigcache.BigCache
|
||||||
Classify *classify.TensorFlow
|
CoverCache *gc.Cache
|
||||||
Convert *photoprism.Convert
|
Classify *classify.TensorFlow
|
||||||
Files *photoprism.Files
|
Convert *photoprism.Convert
|
||||||
Photos *photoprism.Photos
|
Files *photoprism.Files
|
||||||
Import *photoprism.Import
|
Photos *photoprism.Photos
|
||||||
Index *photoprism.Index
|
Import *photoprism.Import
|
||||||
Moments *photoprism.Moments
|
Index *photoprism.Index
|
||||||
Purge *photoprism.Purge
|
Moments *photoprism.Moments
|
||||||
Nsfw *nsfw.Detector
|
Purge *photoprism.Purge
|
||||||
Query *query.Query
|
Nsfw *nsfw.Detector
|
||||||
Resample *photoprism.Resample
|
Query *query.Query
|
||||||
Session *session.Session
|
Resample *photoprism.Resample
|
||||||
|
Session *session.Session
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetConfig(c *config.Config) {
|
func SetConfig(c *config.Config) {
|
||||||
|
|
|
@ -4,6 +4,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/allegro/bigcache"
|
||||||
|
gc "github.com/patrickmn/go-cache"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/classify"
|
"github.com/photoprism/photoprism/internal/classify"
|
||||||
"github.com/photoprism/photoprism/internal/nsfw"
|
"github.com/photoprism/photoprism/internal/nsfw"
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
"github.com/photoprism/photoprism/internal/photoprism"
|
||||||
|
@ -30,6 +33,14 @@ func TestConfig(t *testing.T) {
|
||||||
assert.Equal(t, conf, Config())
|
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) {
|
func TestClassify(t *testing.T) {
|
||||||
assert.IsType(t, &classify.TensorFlow{}, Classify())
|
assert.IsType(t, &classify.TensorFlow{}, Classify())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue