diff --git a/db/migrations/20260907225736_add_user_uploaded_image.sql b/db/migrations/20260907225736_add_user_uploaded_image.sql new file mode 100644 index 000000000..aee8efa7c --- /dev/null +++ b/db/migrations/20260907225736_add_user_uploaded_image.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE user ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''; + +-- +goose Down +ALTER TABLE user DROP COLUMN uploaded_image; diff --git a/model/image.go b/model/image.go index 30307fcea..5986d1b7e 100644 --- a/model/image.go +++ b/model/image.go @@ -13,5 +13,8 @@ func UploadedImagePath(entityType, filename string) string { if filename == "" { return "" } + if entityType == consts.EntityUser { + return filepath.Join(conf.Server.DataFolder.String(), consts.AvatarFolder, filename) + } return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename) } diff --git a/model/image_test.go b/model/image_test.go new file mode 100644 index 000000000..9d5001051 --- /dev/null +++ b/model/image_test.go @@ -0,0 +1,29 @@ +package model_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir("/data") + }) + + It("puts user avatars in the avatar folder, not under artwork", func() { + Expect(model.UploadedImagePath(consts.EntityUser, "abc_deluan.png")). + To(Equal(filepath.Join("/data", "avatar", "abc_deluan.png"))) + }) + + It("keeps other entities under the artwork folder", func() { + Expect(model.UploadedImagePath(consts.EntityArtist, "abc_bowie.png")). + To(Equal(filepath.Join("/data", "artwork", "artist", "abc_bowie.png"))) + }) +}) diff --git a/model/user.go b/model/user.go index 37bdca33d..3ea3985e6 100644 --- a/model/user.go +++ b/model/user.go @@ -1,7 +1,12 @@ package model import ( + "crypto/md5" + "encoding/hex" + "fmt" "time" + + "github.com/navidrome/navidrome/consts" ) type User struct { @@ -17,6 +22,10 @@ type User struct { // Smart-playlist criteria JSON; matching songs are not sent to external scrobblers ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"` + // structs:"-" because userRepository.Put writes every mapped column with no column list, so a + // profile save from the UI would blank this. UpdateImage is the only writer. + UploadedImage string `structs:"-" json:"uploadedImage,omitempty"` + // Library associations (many-to-many relationship) Libraries Libraries `structs:"-" json:"libraries,omitempty"` @@ -43,6 +52,18 @@ func (u User) HasLibraryAccess(libraryID int) bool { return false } +func (u User) UploadedImagePath() string { + return UploadedImagePath(consts.EntityUser, u.UploadedImage) +} + +func (u User) AvatarTag() string { + if u.UploadedImage == "" { + return "" + } + sum := md5.Sum(fmt.Appendf(nil, "%s|%d", u.UploadedImage, u.UpdatedAt.UnixNano())) + return hex.EncodeToString(sum[:])[:16] +} + type Users []User type UserRepository interface { diff --git a/model/user_test.go b/model/user_test.go index ab66a29a9..0abd679b6 100644 --- a/model/user_test.go +++ b/model/user_test.go @@ -1,6 +1,8 @@ package model_test import ( + "time" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -80,4 +82,25 @@ var _ = Describe("User", func() { }) }) }) + + Describe("AvatarTag", func() { + It("returns an empty tag when there is no avatar", func() { + u := model.User{ID: "1", UpdatedAt: time.Now()} + Expect(u.AvatarTag()).To(BeEmpty()) + }) + + It("changes the tag when the image changes", func() { + at := time.Unix(1000, 0) + a := model.User{ID: "1", UploadedImage: "1_deluan.png", UpdatedAt: at} + b := model.User{ID: "1", UploadedImage: "1_deluan.jpg", UpdatedAt: at} + Expect(a.AvatarTag()).NotTo(BeEmpty()) + Expect(a.AvatarTag()).NotTo(Equal(b.AvatarTag())) + }) + + It("changes the tag when the same file is replaced", func() { + a := model.User{ID: "1", UploadedImage: "1_deluan.png", UpdatedAt: time.Unix(1000, 0)} + b := model.User{ID: "1", UploadedImage: "1_deluan.png", UpdatedAt: time.Unix(2000, 0)} + Expect(a.AvatarTag()).NotTo(Equal(b.AvatarTag())) + }) + }) })