API: Update Thumb/ThumbSrc for subjects and labels #4151

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-03 23:17:07 +02:00
parent 241f1df4c4
commit d7ee54ec58
9 changed files with 317 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,8 @@ func UpdateLabel(router *gin.RouterGroup) {
return
}
RemoveFromLabelCoverCache(id)
event.SuccessMsg(i18n.MsgLabelSaved)
PublishLabelEvent(StatusUpdated, id, c)

View File

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

View File

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

View File

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