mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
API: Update Thumb/ThumbSrc for subjects and labels #4151
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -157,7 +157,7 @@ Security & Hot Spots (Where to Look)
|
||||
- Tests guard HW runs with `PHOTOPRISM_FFMPEG_ENCODER`; otherwise assert command strings and negative paths.
|
||||
- libvips thumbnails:
|
||||
- Pipeline: `internal/thumb/vips.go` (VipsInit, VipsRotate, export params).
|
||||
- Sizes & names: `internal/thumb/sizes.go`, `internal/thumb/names.go`, `internal/thumb/filter.go`.
|
||||
- Sizes & names: `internal/thumb/sizes.go`, `internal/thumb/names.go`, `internal/thumb/filter.go`; face/marker crop helpers live in `internal/thumb/crop` (e.g., `ParseThumb`, `IsCroppedThumb`).
|
||||
|
||||
- Safe HTTP downloader:
|
||||
- Shared utility: `pkg/service/http/safe` (`Download`, `Options`).
|
||||
|
||||
@@ -75,6 +75,21 @@ func RemoveFromAlbumCoverCache(uid string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveFromLabelCoverCache removes covers by label UID e.g. after updates.
|
||||
func RemoveFromLabelCoverCache(uid string) {
|
||||
if !rnd.IsAlnum(uid) {
|
||||
return
|
||||
}
|
||||
|
||||
cache := get.CoverCache()
|
||||
|
||||
for thumbName := range thumb.Sizes {
|
||||
cacheKey := CacheKey(labelCover, uid, string(thumbName))
|
||||
cache.Delete(cacheKey)
|
||||
log.Debugf("removed %s from cache", cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
// FlushCoverCache clears the complete cover cache.
|
||||
func FlushCoverCache() {
|
||||
get.CoverCache().Flush()
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
@@ -28,3 +36,102 @@ func TestAddVideoCacheHeader(t *testing.T) {
|
||||
assert.Equal(t, "private, max-age=21600, immutable", s)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveFromFolderCache(t *testing.T) {
|
||||
cache := get.FolderCache()
|
||||
cache.Flush()
|
||||
|
||||
root := "originals"
|
||||
key := fmt.Sprintf("folder:%s:%t:%t", root, true, false)
|
||||
|
||||
cache.SetDefault(key, FoldersResponse{Root: root})
|
||||
|
||||
RemoveFromFolderCache(root)
|
||||
|
||||
_, ok := cache.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRemoveFromAlbumCoverCache(t *testing.T) {
|
||||
cache := get.CoverCache()
|
||||
cache.Flush()
|
||||
|
||||
uid := rnd.GenerateUID(entity.AlbumUID)
|
||||
|
||||
for thumbName := range thumb.Sizes {
|
||||
key := CacheKey(albumCover, uid, string(thumbName))
|
||||
cache.SetDefault(key, ThumbCache{FileName: "cached-file", ShareName: "share"})
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
shareDir := path.Join(conf.ThumbCachePath(), "share")
|
||||
|
||||
if err := fs.MkdirAll(shareDir); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", shareDir, err)
|
||||
}
|
||||
|
||||
sharePreview := path.Join(shareDir, uid+fs.ExtJpeg)
|
||||
|
||||
if err := os.WriteFile(sharePreview, []byte("preview"), fs.ModeFile); err != nil {
|
||||
t.Fatalf("write %s: %v", sharePreview, err)
|
||||
}
|
||||
|
||||
RemoveFromAlbumCoverCache(uid)
|
||||
|
||||
for thumbName := range thumb.Sizes {
|
||||
key := CacheKey(albumCover, uid, string(thumbName))
|
||||
_, ok := cache.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
_, err := os.Stat(sharePreview)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRemoveFromAlbumCoverCacheInvalidUID(t *testing.T) {
|
||||
cache := get.CoverCache()
|
||||
cache.Flush()
|
||||
|
||||
uid := "" // empty string fails rnd.IsAlnum
|
||||
key := CacheKey(albumCover, uid, thumb.Tile500.String())
|
||||
cache.SetDefault(key, ThumbCache{FileName: "file", ShareName: "share"})
|
||||
|
||||
RemoveFromAlbumCoverCache(uid)
|
||||
|
||||
_, ok := cache.Get(key)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestRemoveFromLabelCoverCache(t *testing.T) {
|
||||
cache := get.CoverCache()
|
||||
cache.Flush()
|
||||
|
||||
uid := rnd.GenerateUID(entity.LabelUID)
|
||||
|
||||
for thumbName := range thumb.Sizes {
|
||||
key := CacheKey(labelCover, uid, string(thumbName))
|
||||
cache.SetDefault(key, ThumbCache{FileName: "cached-file", ShareName: "share"})
|
||||
}
|
||||
|
||||
RemoveFromLabelCoverCache(uid)
|
||||
|
||||
for thumbName := range thumb.Sizes {
|
||||
key := CacheKey(labelCover, uid, string(thumbName))
|
||||
_, ok := cache.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFromLabelCoverCacheInvalidUID(t *testing.T) {
|
||||
cache := get.CoverCache()
|
||||
cache.Flush()
|
||||
|
||||
uid := ""
|
||||
key := CacheKey(labelCover, uid, thumb.Tile500.String())
|
||||
cache.SetDefault(key, ThumbCache{FileName: "file", ShareName: "share"})
|
||||
|
||||
RemoveFromLabelCoverCache(uid)
|
||||
|
||||
_, ok := cache.Get(key)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ func UpdateLabel(router *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
RemoveFromLabelCoverCache(id)
|
||||
|
||||
event.SuccessMsg(i18n.MsgLabelSaved)
|
||||
|
||||
PublishLabelEvent(StatusUpdated, id, c)
|
||||
|
||||
@@ -338,6 +338,21 @@ func (m *Subject) SaveForm(frm *form.Subject) (changed bool, err error) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Update thumbnail (hash with crop area).
|
||||
thumbChanged := false
|
||||
if thumbCrop := clean.ThumbCrop(frm.Thumb); thumbCrop != "" && thumbCrop != m.Thumb {
|
||||
if SrcPriority[frm.ThumbSrc] > 0 {
|
||||
m.Thumb = thumbCrop
|
||||
m.ThumbSrc = frm.ThumbSrc
|
||||
thumbChanged = true
|
||||
changed = true
|
||||
} else {
|
||||
return false, fmt.Errorf("invalid thumb source")
|
||||
}
|
||||
} else if frm.Thumb != "" && frm.Thumb != m.Thumb && frm.Thumb != thumbCrop {
|
||||
return false, fmt.Errorf("invalid thumb")
|
||||
}
|
||||
|
||||
// Change favorite status?
|
||||
if m.SubjFavorite != frm.SubjFavorite {
|
||||
m.SubjFavorite = frm.SubjFavorite
|
||||
@@ -375,7 +390,12 @@ func (m *Subject) SaveForm(frm *form.Subject) (changed bool, err error) {
|
||||
"SubjExcluded": m.SubjExcluded,
|
||||
}
|
||||
|
||||
if err := m.Updates(values); err == nil {
|
||||
if thumbChanged {
|
||||
values["Thumb"] = m.Thumb
|
||||
values["ThumbSrc"] = m.ThumbSrc
|
||||
}
|
||||
|
||||
if updateErr := m.Updates(values); updateErr == nil {
|
||||
event.EntitiesUpdated("subjects", []*Subject{m})
|
||||
|
||||
if m.IsPerson() {
|
||||
@@ -384,7 +404,7 @@ func (m *Subject) SaveForm(frm *form.Subject) (changed bool, err error) {
|
||||
|
||||
return true, nil
|
||||
} else {
|
||||
return false, err
|
||||
return false, updateErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ var SubjectFixtures = SubjectMap{
|
||||
SubjNotes: "Short Note",
|
||||
FileCount: 1,
|
||||
PhotoCount: 1,
|
||||
Thumb: "",
|
||||
CreatedAt: Now(),
|
||||
UpdatedAt: Now(),
|
||||
DeletedAt: nil,
|
||||
@@ -49,6 +50,7 @@ var SubjectFixtures = SubjectMap{
|
||||
SubjNotes: "",
|
||||
FileCount: 1,
|
||||
PhotoCount: 1,
|
||||
Thumb: "",
|
||||
CreatedAt: Now(),
|
||||
UpdatedAt: Now(),
|
||||
DeletedAt: nil,
|
||||
@@ -65,7 +67,8 @@ var SubjectFixtures = SubjectMap{
|
||||
SubjExcluded: false,
|
||||
SubjBio: "",
|
||||
SubjNotes: "",
|
||||
Thumb: "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818",
|
||||
Thumb: "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818-0910162fd2fd",
|
||||
ThumbSrc: SrcManual,
|
||||
FileCount: 0,
|
||||
PhotoCount: 0,
|
||||
CreatedAt: Now(),
|
||||
@@ -85,6 +88,7 @@ var SubjectFixtures = SubjectMap{
|
||||
SubjNotes: "",
|
||||
FileCount: 3,
|
||||
PhotoCount: 2,
|
||||
Thumb: "",
|
||||
CreatedAt: Now().AddDate(0, 0, 1),
|
||||
UpdatedAt: Now(),
|
||||
DeletedAt: nil,
|
||||
@@ -98,6 +102,7 @@ var SubjectFixtures = SubjectMap{
|
||||
SubjFavorite: false,
|
||||
SubjPrivate: false,
|
||||
SubjNotes: "",
|
||||
Thumb: "",
|
||||
CreatedAt: Now(),
|
||||
UpdatedAt: Now(),
|
||||
DeletedAt: nil,
|
||||
@@ -111,6 +116,7 @@ var SubjectFixtures = SubjectMap{
|
||||
SubjFavorite: false,
|
||||
SubjPrivate: false,
|
||||
SubjNotes: "",
|
||||
Thumb: "",
|
||||
CreatedAt: Now(),
|
||||
UpdatedAt: Now(),
|
||||
DeletedAt: nil,
|
||||
|
||||
@@ -393,6 +393,82 @@ func TestSubject_SaveForm(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "no uid")
|
||||
assert.False(t, changed)
|
||||
})
|
||||
t.Run("ManualThumb", func(t *testing.T) {
|
||||
subj := NewSubject("Cover Person", SubjPerson, SrcAuto)
|
||||
subj.SubjFavorite = true
|
||||
if err := subj.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subjForm, err := form.NewSubject(subj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hash := "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd"
|
||||
subjForm.Thumb = hash
|
||||
subjForm.ThumbSrc = "manual"
|
||||
|
||||
changed, err := subj.SaveForm(subjForm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, hash, subj.Thumb)
|
||||
assert.Equal(t, SrcManual, subj.ThumbSrc)
|
||||
|
||||
if err := subj.Delete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("DefaultThumbSrc", func(t *testing.T) {
|
||||
subj := NewSubject("Auto Src", SubjPerson, SrcAuto)
|
||||
if err := subj.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subjForm, err := form.NewSubject(subj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subjForm.Thumb = "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c"
|
||||
subjForm.ThumbSrc = ""
|
||||
|
||||
changed, err := subj.SaveForm(subjForm)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid thumb")
|
||||
assert.False(t, changed)
|
||||
assert.Equal(t, "", subj.Thumb)
|
||||
assert.Equal(t, "", subj.ThumbSrc)
|
||||
|
||||
if err := subj.Delete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("InvalidThumbSrc", func(t *testing.T) {
|
||||
subj := NewSubject("Invalid Src", SubjPerson, SrcAuto)
|
||||
if err := subj.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subjForm, err := form.NewSubject(subj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subjForm.Thumb = "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd"
|
||||
subjForm.ThumbSrc = "invalid-src"
|
||||
|
||||
_, err = subj.SaveForm(subjForm)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid thumb source")
|
||||
|
||||
if err := subj.Delete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubject_UpdateName(t *testing.T) {
|
||||
|
||||
42
pkg/clean/thumb.go
Normal file
42
pkg/clean/thumb.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package clean
|
||||
|
||||
import "strings"
|
||||
|
||||
// Thumb returns a sanitized thumbnail hash (40 hex characters) or an empty string when invalid.
|
||||
func Thumb(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) != 40 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if h := Hex(s); len(h) == 40 {
|
||||
return h
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ThumbCrop returns a sanitized thumbnail crop hash (`hash-area`) or an empty string when invalid.
|
||||
// Cropped thumbnails combine the base hash with a 12-hex crop descriptor separated by a dash.
|
||||
func ThumbCrop(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 41 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if i := strings.IndexByte(s, '-'); i == -1 || i < 40 || i >= len(s)-1 {
|
||||
return ""
|
||||
} else {
|
||||
h := Thumb(s[:i])
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
crop := Hex(s[i+1:])
|
||||
if len(crop) != 12 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return h + "-" + crop
|
||||
}
|
||||
}
|
||||
45
pkg/clean/thumb_test.go
Normal file
45
pkg/clean/thumb_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package clean
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThumb(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{"valid", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c"},
|
||||
{"upper", "6F6CBAA6AE8EAD9DA7EE99AB66ACA1AE7EED8D5C", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c"},
|
||||
{"trimmed", " 6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c ", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c"},
|
||||
{"invalidLength", "123", ""},
|
||||
{"invalidChars", "zz6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := Thumb(tc.in); got != tc.out {
|
||||
t.Fatalf("%s: Thumb(%q) = %q, want %q", tc.name, tc.in, got, tc.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbCrop(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{"valid", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd"},
|
||||
{"upper", "6F6CBAA6AE8EAD9DA7EE99AB66ACA1AE7EED8D5C-0910162FD2FD", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd"},
|
||||
{"trimmed", " 6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd ", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd"},
|
||||
{"missingDash", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c0910162fd2fd", ""},
|
||||
{"invalidHash", "zz6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910162fd2fd", ""},
|
||||
{"invalidCrop", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-zzzz", ""},
|
||||
{"shortCrop", "6f6cbaa6ae8ead9da7ee99ab66aca1ae7eed8d5c-0910", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := ThumbCrop(tc.in); got != tc.out {
|
||||
t.Fatalf("%s: ThumbCrop(%q) = %q, want %q", tc.name, tc.in, got, tc.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user