Library: Show folder covers in Originals #1011

This commit is contained in:
Michael Mayer 2021-02-07 19:04:17 +01:00
parent 9039774cf7
commit c7753b87ff
13 changed files with 265 additions and 72 deletions

View file

@ -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() {

View file

@ -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)"

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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))

View file

@ -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)
}
})
}

View file

@ -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,

View file

@ -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").

View file

@ -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").

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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