diff --git a/frontend/src/model/folder.js b/frontend/src/model/folder.js index bd9643294..02fba6d15 100644 --- a/frontend/src/model/folder.js +++ b/frontend/src/model/folder.js @@ -33,6 +33,7 @@ import Api from "common/api"; import { DateTime } from "luxon"; import File from "model/file"; import Util from "common/util"; +import { config } from "../session"; import { $gettext } from "common/vm"; export const RootImport = "import"; @@ -96,8 +97,8 @@ export class Folder extends RestModel { return this.Root + "/" + this.Path; } - thumbnailUrl() { - return "/api/v1/svg/folder"; + thumbnailUrl(size) { + return `/api/v1/folders/t/${this.UID}/${config.previewToken()}/${size}`; } getDateString() { diff --git a/frontend/src/pages/library/files.vue b/frontend/src/pages/library/files.vue index 5be0118b6..9940badc4 100644 --- a/frontend/src/pages/library/files.vue +++ b/frontend/src/pages/library/files.vue @@ -61,6 +61,7 @@ :src="model.thumbnailUrl('tile_500')" :alt="model.Name" :transition="false" + loading="lazy" aspect-ratio="1" class="accent lighten-2 clickable" @mousedown="onMouseDown($event, index)" diff --git a/frontend/tests/unit/model/file_test.js b/frontend/tests/unit/model/file_test.js index bd0c210fa..a01c1577b 100644 --- a/frontend/tests/unit/model/file_test.js +++ b/frontend/tests/unit/model/file_test.js @@ -59,21 +59,21 @@ describe("model/file", () => { Type: "jpg", Name: "1/2/IMG123.jpg"}; const file = new File(values); - assert.equal(file.thumbnailUrl("abc"), "/api/v1/t/54ghtfd/public/abc"); + assert.equal(file.thumbnailUrl("tile_224"), "/api/v1/t/54ghtfd/public/tile_224"); const values2 = { InstanceID: 5, UID: "ABC123", Name: "1/2/IMG123.jpg", Error: true}; const file2 = new File(values2); - assert.equal(file2.thumbnailUrl("abc"), "/api/v1/svg/broken"); + assert.equal(file2.thumbnailUrl("tile_224"), "/api/v1/svg/broken"); const values3 = { InstanceID: 5, UID: "ABC123", Name: "1/2/IMG123.jpg", Type: "raw"}; const file3 = new File(values3); - assert.equal(file3.thumbnailUrl("abc"), "/api/v1/svg/raw"); + assert.equal(file3.thumbnailUrl("tile_224"), "/api/v1/svg/raw"); }); it("should return download url", () => { diff --git a/frontend/tests/unit/model/folder_test.js b/frontend/tests/unit/model/folder_test.js index 485aaa5a5..7bdefe250 100644 --- a/frontend/tests/unit/model/folder_test.js +++ b/frontend/tests/unit/model/folder_test.js @@ -108,7 +108,7 @@ describe("model/folder", () => { Title: "Halloween Party", }; const folder = new Folder(values); - assert.equal(folder.thumbnailUrl(), "/api/v1/svg/folder"); + assert.equal(folder.thumbnailUrl("tile_224"), "/api/v1/folders/t/dqbevau2zlhxrxww/public/tile_224"); }); it("should get date string", () => { diff --git a/internal/api/covers.go b/internal/api/covers.go index 5de784d3e..459295200 100644 --- a/internal/api/covers.go +++ b/internal/api/covers.go @@ -108,8 +108,8 @@ func AlbumCover(router *gin.RouterGroup) { } if err != nil { - log.Errorf("album: %s", err) - c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) + log.Errorf("%s: %s", albumCover, err) + c.Data(http.StatusOK, "image/svg+xml", albumIconSvg) return } else if thumbnail == "" { log.Errorf("%s: %s has empty thumb name - bug?", albumCover, filepath.Base(fileName)) diff --git a/internal/api/folder_cover.go b/internal/api/folder_cover.go new file mode 100644 index 000000000..bc1ce45ad --- /dev/null +++ b/internal/api/folder_cover.go @@ -0,0 +1,140 @@ +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" +) + +const ( + folderCover = "folder-cover" +) + +// GET /api/v1/folders/t/:hash/:token/:type +// +// Parameters: +// uid: string folder uid +// token: string url security token, see config +// type: string thumb type, see thumb.Types +func GetFolderCover(router *gin.RouterGroup) { + router.GET("/folders/t/:uid/:token/:type", func(c *gin.Context) { + if InvalidPreviewToken(c) { + c.Data(http.StatusForbidden, "image/svg+xml", folderIconSvg) + return + } + + start := time.Now() + conf := service.Config() + uid := c.Param("uid") + typeName := c.Param("type") + download := c.Query("download") != "" + + thumbType, ok := thumb.Types[typeName] + + if !ok { + log.Errorf("folder: invalid thumb type %s", txt.Quote(typeName)) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + return + } + + if thumbType.ExceedsSize() && !conf.ThumbUncached() { + typeName, thumbType = thumb.Find(conf.ThumbSize()) + + if typeName == "" { + log.Errorf("folder: invalid thumb size %d", conf.ThumbSize()) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + return + } + } + + cache := service.CoverCache() + cacheKey := CacheKey(folderCover, uid, typeName) + + if cacheData, ok := cache.Get(cacheKey); ok { + log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start)) + + cached := cacheData.(ThumbCache) + + if !fs.FileExists(cached.FileName) { + log.Errorf("%s: %s not found", folderCover, uid) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + return + } + + AddCoverCacheHeader(c) + + if download { + c.FileAttachment(cached.FileName, cached.ShareName) + } else { + c.File(cached.FileName) + } + + return + } + + f, err := query.FolderCoverByUID(uid) + + if err != nil { + log.Debugf("%s: no photos yet, using generic image for %s", folderCover, uid) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + return + } + + fileName := photoprism.FileName(f.FileRoot, f.FileName) + + if !fs.FileExists(fileName) { + log.Errorf("%s: could not find original for %s", folderCover, fileName) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + + // Set missing flag so that the file doesn't show up in search results anymore. + log.Warnf("%s: %s is missing", folderCover, txt.Quote(f.FileName)) + logError(folderCover, f.Update("FileMissing", true)) + return + } + + // Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157 + if thumbType.ExceedsSizeUncached() && !download { + log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", folderCover, 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", folderCover, err) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + return + } else if thumbnail == "" { + log.Errorf("%s: %s has empty thumb name - bug?", folderCover, filepath.Base(fileName)) + c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) + return + } + + cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)}) + log.Debugf("cached %s [%s]", cacheKey, time.Since(start)) + + AddCoverCacheHeader(c) + + if download { + c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0)) + } else { + c.File(thumbnail) + } + }) +} diff --git a/internal/entity/folder_fixtures.go b/internal/entity/folder_fixtures.go index 9014706d8..a5dbda002 100644 --- a/internal/entity/folder_fixtures.go +++ b/internal/entity/folder_fixtures.go @@ -6,6 +6,7 @@ import ( var FolderFixtures = map[string]Folder{ "1990": { + FolderUID: "dqo63pn35k2d495z", Path: "1990", FolderYear: 1990, FolderMonth: 0, @@ -15,6 +16,7 @@ var FolderFixtures = map[string]Folder{ DeletedAt: nil, }, "1990/04": { + FolderUID: "dqo63pn2f87f02xj", Path: "1990/04", FolderYear: 1990, FolderMonth: 4, diff --git a/internal/query/albums.go b/internal/query/albums.go index fb1acbd66..111127815 100644 --- a/internal/query/albums.go +++ b/internal/query/albums.go @@ -54,10 +54,10 @@ func AlbumByUID(albumUID string) (album entity.Album, err error) { } // AlbumCoverByUID returns a album preview file based on the uid. -func AlbumCoverByUID(albumUID string) (file entity.File, err error) { +func AlbumCoverByUID(uid string) (file entity.File, err error) { a := entity.Album{} - if err := Db().Where("album_uid = ?", albumUID).First(&a).Error; err != nil { + if err := Db().Where("album_uid = ?", uid).First(&a).Error; err != nil { return file, err } else if a.AlbumType != entity.AlbumDefault { // TODO: Optimize f := form.PhotoSearch{Album: a.AlbumUID, Filter: a.AlbumFilter, Order: entity.SortOrderRelevance, Count: 1, Offset: 0, Merged: false} @@ -78,7 +78,7 @@ func AlbumCoverByUID(albumUID string) (file entity.File, err error) { } 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). + Joins("JOIN albums ON albums.album_uid = ?", uid). Joins("JOIN photos_albums pa ON pa.album_uid = albums.album_uid AND pa.photo_uid = files.photo_uid AND pa.hidden = 0"). Joins("JOIN photos ON photos.id = files.photo_id AND photos.photo_private = 0 AND photos.deleted_at IS NULL"). Order("photos.photo_quality DESC, photos.taken_at DESC"). diff --git a/internal/query/folders.go b/internal/query/folders.go index 64a3344ba..6778a1004 100644 --- a/internal/query/folders.go +++ b/internal/query/folders.go @@ -32,6 +32,21 @@ func FoldersByPath(rootName, rootPath, path string, recursive bool) (folders ent return folders, nil } +// FolderCoverByUID returns a folder preview file based on the uid. +func FolderCoverByUID(uid string) (file entity.File, err error) { + 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 photos ON photos.id = files.photo_id AND photos.deleted_at IS NULL AND photos.photo_quality > -1"). + Joins("JOIN folders ON photos.photo_path = folders.path AND folders.folder_uid = ?", uid). + Order("photos.photo_quality DESC"). + Limit(1). + First(&file).Error; err != nil { + log.Error(err) + return file, err + } + + return file, nil +} + // AlbumFolders returns folders that should be added as album. func AlbumFolders(threshold int) (folders entity.Folders, err error) { db := UnscopedDb().Table("folders"). diff --git a/internal/query/folders_test.go b/internal/query/folders_test.go index 904b428ca..23794cae1 100644 --- a/internal/query/folders_test.go +++ b/internal/query/folders_test.go @@ -7,6 +7,18 @@ import ( "github.com/stretchr/testify/assert" ) +func TestFolderCoverByUID(t *testing.T) { + t.Run("1990/04", func(t *testing.T) { + if result, err := FolderCoverByUID("dqo63pn2f87f02xj"); err != nil { + t.Fatal(err) + } else if result.FileUID == "" { + t.Fatal("result must not be empty") + } else if result.FileUID != "ft2es49w15bnlqdw" { + t.Errorf("wrong result: %#v", result) + } + }) +} + func TestFoldersByPath(t *testing.T) { t.Run("root", func(t *testing.T) { folders, err := FoldersByPath(entity.RootOriginals, "testdata", "", false) diff --git a/internal/query/photo_search_test.go b/internal/query/photo_search_test.go index 30b99ce19..bfe8a206c 100644 --- a/internal/query/photo_search_test.go +++ b/internal/query/photo_search_test.go @@ -12,20 +12,20 @@ import ( func TestPhotoSearch(t *testing.T) { t.Run("search all", func(t *testing.T) { - //Db().LogMode(true) - var f form.PhotoSearch - f.Query = "" - f.Count = 10 - f.Offset = 0 + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "" + frm.Count = 10 + frm.Offset = 0 + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) } - // t.Logf("results: %+v", photos) assert.LessOrEqual(t, 3, len(photos)) + for _, r := range photos { assert.IsType(t, PhotoResult{}, r) assert.NotEmpty(t, r.ID) @@ -38,14 +38,15 @@ func TestPhotoSearch(t *testing.T) { } }) t.Run("search for ID and merged", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "" - f.Count = 5000 - f.Offset = 0 - f.ID = "pt9jtdre2lvl0yh7" - f.Merged = true + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "" + frm.Count = 5000 + frm.Offset = 0 + frm.ID = "pt9jtdre2lvl0yh7" + frm.Merged = true + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) @@ -53,14 +54,15 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) t.Run("search for ID with merged false", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "" - f.Count = 5000 - f.Offset = 0 - f.ID = "pt9jtdre2lvl0yh7" - f.Merged = false + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "" + frm.Count = 5000 + frm.Offset = 0 + frm.ID = "pt9jtdre2lvl0yh7" + frm.Merged = false + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) @@ -68,23 +70,25 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) t.Run("label query dog", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "label:dog" - f.Count = 10 - f.Offset = 0 + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "label:dog" + frm.Count = 10 + frm.Offset = 0 + + photos, _, err := PhotoSearch(frm) assert.Equal(t, "dog not found", err.Error()) assert.Empty(t, photos) }) t.Run("label query landscape", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "label:landscape Order:relevance" - f.Count = 10 - f.Offset = 0 + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "label:landscape Order:relevance" + frm.Count = 10 + frm.Offset = 0 + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) } @@ -92,12 +96,13 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 2, len(photos)) }) t.Run("invalid label query", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "label:xxx" - f.Count = 10 - f.Offset = 0 + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "label:xxx" + frm.Count = 10 + frm.Offset = 0 + + photos, _, err := PhotoSearch(frm) assert.Error(t, err) assert.Empty(t, photos) @@ -107,13 +112,14 @@ func TestPhotoSearch(t *testing.T) { } }) t.Run("form.location true", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "" - f.Count = 10 - f.Offset = 0 - f.Geo = true + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "" + frm.Count = 10 + frm.Offset = 0 + frm.Geo = true + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) @@ -122,14 +128,15 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 2, len(photos)) }) t.Run("form.location true and keyword", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "bridge" - f.Count = 10 - f.Offset = 0 - f.Geo = true - f.Error = false + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "bridge" + frm.Count = 10 + frm.Offset = 0 + frm.Geo = true + frm.Error = false + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) @@ -138,12 +145,13 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) t.Run("search for keyword", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "bridge" - f.Count = 5000 - f.Offset = 0 + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "bridge" + frm.Count = 5000 + frm.Offset = 0 + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) @@ -152,12 +160,13 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 2, len(photos)) }) t.Run("search for label in query", func(t *testing.T) { - var f form.PhotoSearch - f.Query = "flower" - f.Count = 5000 - f.Offset = 0 + var frm form.PhotoSearch - photos, _, err := PhotoSearch(f) + frm.Query = "flower" + frm.Count = 5000 + frm.Offset = 0 + + photos, _, err := PhotoSearch(frm) if err != nil { t.Fatal(err) @@ -166,8 +175,8 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 1, len(photos)) }) t.Run("search for archived", func(t *testing.T) { - var f form.PhotoSearch + f.Query = "" f.Count = 5000 f.Offset = 0 @@ -182,6 +191,7 @@ func TestPhotoSearch(t *testing.T) { }) t.Run("search for private", func(t *testing.T) { var f form.PhotoSearch + f.Query = "" f.Count = 5000 f.Offset = 0 @@ -197,6 +207,7 @@ func TestPhotoSearch(t *testing.T) { }) t.Run("search for public", func(t *testing.T) { var f form.PhotoSearch + f.Query = "" f.Count = 5000 f.Offset = 0 @@ -211,6 +222,7 @@ func TestPhotoSearch(t *testing.T) { }) t.Run("search for review", func(t *testing.T) { var f form.PhotoSearch + f.Query = "" f.Count = 5000 f.Offset = 0 @@ -225,6 +237,7 @@ func TestPhotoSearch(t *testing.T) { }) t.Run("search for quality", func(t *testing.T) { var f form.PhotoSearch + f.Query = "" f.Count = 5000 f.Offset = 0 @@ -240,6 +253,7 @@ func TestPhotoSearch(t *testing.T) { }) t.Run("search for file error", func(t *testing.T) { var f form.PhotoSearch + f.Query = "" f.Count = 5000 f.Offset = 0 @@ -254,6 +268,7 @@ func TestPhotoSearch(t *testing.T) { }) t.Run("form.camera", func(t *testing.T) { var f form.PhotoSearch + f.Query = "" f.Count = 10 f.Offset = 0 diff --git a/internal/server/routes.go b/internal/server/routes.go index dc56ee684..e513cbe81 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -87,6 +87,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.GetFoldersOriginals(v1) api.GetFoldersImport(v1) + api.GetFolderCover(v1) api.Upload(v1) api.StartImport(v1) diff --git a/internal/server/server.go b/internal/server/server.go index 525d01be8..7be44eb44 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,7 +38,13 @@ func Start(ctx context.Context, conf *config.Config) { log.Infof("http: enabling gzip compression") router.Use(gzip.Gzip( gzip.DefaultCompression, - gzip.WithExcludedPaths([]string{"/api/v1/t", "/api/v1/zip", "/api/v1/albums", "/api/v1/labels"}))) + gzip.WithExcludedPaths([]string{ + "/api/v1/t", + "/api/v1/folders/t", + "/api/v1/zip", + "/api/v1/albums", + "/api/v1/labels", + }))) } // Set template directory