diff --git a/consts/consts.go b/consts/consts.go
index f453ac125..b788be9e9 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -124,6 +124,7 @@ const (
// Entity types
const (
EntityArtist = "artist"
+ EntityAlbum = "album"
EntityPlaylist = "playlist"
EntityRadio = "radio"
)
diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go
index ac0f63794..6306aa098 100644
--- a/core/artwork/image_cache.go
+++ b/core/artwork/image_cache.go
@@ -27,6 +27,14 @@ func (k *cacheKey) Key() string {
)
}
+// coverStamp renders the album's manual-cover timestamp for cache keys (0 when unset).
+func coverStamp(t *time.Time) int64 {
+ if t == nil {
+ return 0
+ }
+ return t.UnixMilli()
+}
+
type imageCache struct {
cache.FileCache
}
diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go
index 8ad07773b..6048d2ca5 100644
--- a/core/artwork/reader_album.go
+++ b/core/artwork/reader_album.go
@@ -58,6 +58,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
if imagesUpdateAt != nil {
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
}
+ if al.CoverArtUpdatedAt != nil {
+ a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *al.CoverArtUpdatedAt)
+ }
return a, nil
}
@@ -67,11 +70,14 @@ func (a *albumArtworkReader) Key() string {
hashInput = conf.Server.Agents + hashInput
}
hash := md5.Sum([]byte(hashInput))
+ // coverStamp is a separate component: folded into lastUpdate it could be masked
+ // by a newer updated_at (files with future mtimes).
return fmt.Sprintf(
- "%s.%x.%t",
+ "%s.%x.%t.%d",
a.cacheKey.Key(),
hash,
conf.Server.EnableExternalServices,
+ coverStamp(a.album.CoverArtUpdatedAt),
)
}
func (a *albumArtworkReader) LastUpdated() time.Time {
@@ -79,10 +85,15 @@ func (a *albumArtworkReader) LastUpdated() time.Time {
}
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
- var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
+ ff := []sourceFunc{a.fromAlbumUploadedImage()}
+ ff = append(ff, a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)...)
return selectImageReader(ctx, a.artID, ff...)
}
+func (a *albumArtworkReader) fromAlbumUploadedImage() sourceFunc {
+ return fromLocalFile(a.album.UploadedImagePath())
+}
+
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
index fe4a1a545..71d833360 100644
--- a/core/artwork/reader_album_test.go
+++ b/core/artwork/reader_album_test.go
@@ -3,14 +3,56 @@ package artwork
import (
"context"
"errors"
+ "io"
+ "os"
+ "path/filepath"
"time"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Album Artwork Reader", func() {
+ Describe("fromAlbumUploadedImage", func() {
+ var (
+ tempDir string
+ reader *albumArtworkReader
+ )
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tempDir = GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tempDir)
+ Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "album"), 0755)).To(Succeed())
+ reader = &albumArtworkReader{}
+ })
+ When("the album has an uploaded image", func() {
+ It("returns the uploaded image", func() {
+ imgPath := filepath.Join(tempDir, "artwork", "album", "al-1_test.jpg")
+ Expect(os.WriteFile(imgPath, []byte("uploaded album image"), 0600)).To(Succeed())
+ reader.album = model.Album{ID: "al-1", UploadedImage: "al-1_test.jpg"}
+ r, path, err := reader.fromAlbumUploadedImage()()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).ToNot(BeNil())
+ Expect(path).To(Equal(imgPath))
+ data, err := io.ReadAll(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("uploaded album image"))
+ r.Close()
+ })
+ })
+ When("the album has no uploaded image", func() {
+ It("returns a nil reader so the next source is tried", func() {
+ reader.album = model.Album{ID: "al-1"}
+ r, _, err := reader.fromAlbumUploadedImage()()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(r).To(BeNil())
+ })
+ })
+ })
+
Describe("loadAlbumFoldersPaths", func() {
var (
ctx context.Context
diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go
index 0f648c987..1c9d7ff78 100644
--- a/core/artwork/reader_disc.go
+++ b/core/artwork/reader_disc.go
@@ -110,15 +110,19 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
if imagesUpdatedAt != nil {
r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
}
+ if al.CoverArtUpdatedAt != nil {
+ r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *al.CoverArtUpdatedAt)
+ }
return r, nil
}
func (d *discArtworkReader) Key() string {
hash := md5.Sum([]byte(conf.Server.DiscArtPriority))
return fmt.Sprintf(
- "%s.%x",
+ "%s.%x.%d",
d.cacheKey.Key(),
hash,
+ coverStamp(d.album.CoverArtUpdatedAt),
)
}
diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go
index 8264ee27b..08f8c3ee3 100644
--- a/core/artwork/reader_disc_test.go
+++ b/core/artwork/reader_disc_test.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
@@ -11,6 +12,17 @@ import (
)
var _ = Describe("Disc Artwork Reader", func() {
+ Describe("Key", func() {
+ It("changes when the album's cover stamp changes", func() {
+ r := &discArtworkReader{}
+ r.album = model.Album{ID: "al-1"}
+ before := r.Key()
+ stamp := time.Now()
+ r.album.CoverArtUpdatedAt = &stamp
+ Expect(r.Key()).ToNot(Equal(before))
+ })
+ })
+
Describe("extractDiscNumber", func() {
DescribeTable("extracts disc number from filename based on glob pattern",
func(pattern, filename string, expectedNum int, expectedOk bool) {
diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go
index eac3c5e70..191266671 100644
--- a/core/artwork/reader_mediafile.go
+++ b/core/artwork/reader_mediafile.go
@@ -49,14 +49,18 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = *imagesUpdatedAt
}
+ if al.CoverArtUpdatedAt != nil && al.CoverArtUpdatedAt.After(a.cacheKey.lastUpdate) {
+ a.cacheKey.lastUpdate = *al.CoverArtUpdatedAt
+ }
return a, nil
}
func (a *mediafileArtworkReader) Key() string {
return fmt.Sprintf(
- "%s.%t",
+ "%s.%t.%d",
a.cacheKey.Key(),
conf.Server.EnableMediaFileCoverArt,
+ coverStamp(a.album.CoverArtUpdatedAt),
)
}
func (a *mediafileArtworkReader) LastUpdated() time.Time {
diff --git a/core/artwork/reader_mediafile_test.go b/core/artwork/reader_mediafile_test.go
new file mode 100644
index 000000000..9976c7a13
--- /dev/null
+++ b/core/artwork/reader_mediafile_test.go
@@ -0,0 +1,22 @@
+package artwork
+
+import (
+ "time"
+
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("MediaFile Artwork Reader", func() {
+ Describe("Key", func() {
+ It("changes when the album's cover stamp changes", func() {
+ r := &mediafileArtworkReader{}
+ r.album = model.Album{ID: "al-1"}
+ before := r.Key()
+ stamp := time.Now()
+ r.album.CoverArtUpdatedAt = &stamp
+ Expect(r.Key()).ToNot(Equal(before))
+ })
+ })
+})
diff --git a/db/migrations/20260717224137_add_album_uploaded_image.sql b/db/migrations/20260717224137_add_album_uploaded_image.sql
new file mode 100644
index 000000000..760ee3e0e
--- /dev/null
+++ b/db/migrations/20260717224137_add_album_uploaded_image.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+ALTER TABLE album ADD COLUMN uploaded_image varchar NOT NULL DEFAULT '';
+-- cover_art_updated_at tracks manual cover edits separately from updated_at, so
+-- busting the artwork cache never disturbs updated_at-based Recently Added ordering.
+ALTER TABLE album ADD COLUMN cover_art_updated_at datetime;
+
+-- +goose Down
+ALTER TABLE album DROP COLUMN uploaded_image;
+ALTER TABLE album DROP COLUMN cover_art_updated_at;
diff --git a/model/album.go b/model/album.go
index ade7f6ee0..f0454752f 100644
--- a/model/album.go
+++ b/model/album.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/gohugoio/hashstructure"
)
@@ -23,32 +24,34 @@ type Album struct {
EmbedArtPath string `structs:"embed_art_path" json:"-"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants
// AlbumArtist is the display name used for the album artist.
- AlbumArtist string `structs:"album_artist" json:"albumArtist"`
- MaxYear int `structs:"max_year" json:"maxYear"`
- MinYear int `structs:"min_year" json:"minYear"`
- Date string `structs:"date" json:"date,omitempty"`
- MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"`
- MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"`
- OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
- ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
- Compilation bool `structs:"compilation" json:"compilation"`
- Comment string `structs:"comment" json:"comment,omitempty"`
- SongCount int `structs:"song_count" json:"songCount"`
- Duration float32 `structs:"duration" json:"duration"`
- Size int64 `structs:"size" json:"size"`
- Discs Discs `structs:"discs" json:"discs,omitempty"`
- SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
- SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
- OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
- OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
- CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
- MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
- MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"`
- MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
- MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
- MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
- FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
- ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
+ AlbumArtist string `structs:"album_artist" json:"albumArtist"`
+ MaxYear int `structs:"max_year" json:"maxYear"`
+ MinYear int `structs:"min_year" json:"minYear"`
+ Date string `structs:"date" json:"date,omitempty"`
+ MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"`
+ MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"`
+ OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
+ ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
+ Compilation bool `structs:"compilation" json:"compilation"`
+ Comment string `structs:"comment" json:"comment,omitempty"`
+ SongCount int `structs:"song_count" json:"songCount"`
+ Duration float32 `structs:"duration" json:"duration"`
+ Size int64 `structs:"size" json:"size"`
+ Discs Discs `structs:"discs" json:"discs,omitempty"`
+ SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
+ SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
+ OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
+ OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
+ CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
+ MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
+ MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"`
+ MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
+ MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
+ MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
+ FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
+ ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
+ UploadedImage string `structs:"-" json:"uploadedImage,omitempty" hash:"ignore"`
+ CoverArtUpdatedAt *time.Time `structs:"-" json:"coverArtUpdatedAt,omitempty" hash:"ignore"`
// External metadata fields
Description string `structs:"description" json:"description,omitempty" hash:"ignore"`
@@ -73,6 +76,10 @@ func (a Album) CoverArtID() ArtworkID {
return artworkIDFromAlbum(a)
}
+func (a Album) UploadedImagePath() string {
+ return UploadedImagePath(consts.EntityAlbum, a.UploadedImage)
+}
+
func (a Album) FullName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
@@ -139,6 +146,8 @@ type AlbumRepository interface {
Exists(id string) (bool, error)
Put(*Album) error
UpdateExternalInfo(*Album) error
+ UpdateImage(id, filename string) error
+ CountByImage(filename string) (int64, error)
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetCursor(...QueryOptions) (AlbumCursor, error)
diff --git a/model/album_test.go b/model/album_test.go
index 0f4c912cd..f8df981ba 100644
--- a/model/album_test.go
+++ b/model/album_test.go
@@ -2,6 +2,8 @@ package model_test
import (
"encoding/json"
+ "path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -25,6 +27,41 @@ var _ = Describe("Album", func() {
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
)
+
+ Describe("UploadedImagePath", func() {
+ BeforeEach(func() {
+ conf.Server.DataFolder = conf.NewDir("/data")
+ })
+ It("returns empty when no image was uploaded", func() {
+ Expect(Album{ID: "al-1"}.UploadedImagePath()).To(BeEmpty())
+ })
+ It("returns the path under the data folder when set", func() {
+ a := Album{ID: "al-1", UploadedImage: "al-1_cover.jpg"}
+ Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "album", "al-1_cover.jpg")))
+ })
+ })
+
+ Describe("CoverArtID", func() {
+ It("changes when a cover is uploaded, even if updated_at is in the future", func() {
+ future := time.Now().Add(365 * 24 * time.Hour)
+ stamp := time.Now()
+ a := Album{ID: "al-1", UpdatedAt: future}
+ before := a.CoverArtID().String()
+ a.CoverArtUpdatedAt = &stamp
+ Expect(a.CoverArtID().String()).ToNot(Equal(before))
+ })
+ It("does not collide when updated_at decreases by the cover-stamp delta", func() {
+ // With an additive combination, updated_at dropping by N while the cover
+ // stamp advances by N re-emits the same id — the mix must not.
+ u1 := time.Unix(1_000_000_000, 0)
+ c1 := time.Unix(2_000_000_000, 0)
+ u2 := u1.Add(-100 * time.Second)
+ c2 := c1.Add(100 * time.Second)
+ id1 := Album{ID: "al-1", UpdatedAt: u1, CoverArtUpdatedAt: &c1}.CoverArtID().String()
+ id2 := Album{ID: "al-1", UpdatedAt: u2, CoverArtUpdatedAt: &c2}.CoverArtID().String()
+ Expect(id1).ToNot(Equal(id2))
+ })
+ })
})
var _ = Describe("Albums", func() {
diff --git a/model/artwork_id.go b/model/artwork_id.go
index 1bd146c1f..6d595f8bf 100644
--- a/model/artwork_id.go
+++ b/model/artwork_id.go
@@ -112,10 +112,17 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
}
func artworkIDFromAlbum(al Album) ArtworkID {
+ // The suffix is a cache discriminator, not a date: mix (not max/add) the timestamps so
+ // the id changes whenever either does — updated_at can decrease and cancel a plain sum.
+ lastUpdate := al.UpdatedAt
+ if al.CoverArtUpdatedAt != nil {
+ mixed := (uint64(al.UpdatedAt.Unix()) ^ (uint64(al.CoverArtUpdatedAt.Unix()) * 0x9E3779B97F4A7C15)) & 0x3FFFFFFFFFFFFFFF
+ lastUpdate = time.Unix(int64(max(mixed, 1)), 0)
+ }
return ArtworkID{
Kind: KindAlbumArtwork,
ID: al.ID,
- LastUpdate: al.UpdatedAt,
+ LastUpdate: lastUpdate,
}
}
diff --git a/model/mediafile.go b/model/mediafile.go
index 90db80f71..8432379a0 100644
--- a/model/mediafile.go
+++ b/model/mediafile.go
@@ -30,11 +30,14 @@ type MediaFile struct {
LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"`
LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
- FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"`
- Path string `structs:"path" json:"path" hash:"ignore"`
- Title string `structs:"title" json:"title"`
- Album string `structs:"album" json:"album"`
- ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead
+ // CoverArtUpdatedAt mirrors the album's manual-cover timestamp so clients can
+ // bust song artwork URLs that resolve to the album cover.
+ CoverArtUpdatedAt *time.Time `structs:"-" json:"coverArtUpdatedAt,omitempty" hash:"ignore"`
+ FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"`
+ Path string `structs:"path" json:"path" hash:"ignore"`
+ Title string `structs:"title" json:"title"`
+ Album string `structs:"album" json:"album"`
+ ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead
// Artist is the display name used for the artist.
Artist string `structs:"artist" json:"artist"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
@@ -129,13 +132,13 @@ func (mf MediaFile) CoverArtID() ArtworkID {
// otherwise it returns the album artwork ID.
func (mf MediaFile) DiscCoverArtID() ArtworkID {
if mf.DiscNumber > 0 {
- return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil)
+ return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), mf.CoverArtUpdatedAt)
}
return mf.AlbumCoverArtID()
}
func (mf MediaFile) AlbumCoverArtID() ArtworkID {
- return artworkIDFromAlbum(Album{ID: mf.AlbumID})
+ return artworkIDFromAlbum(Album{ID: mf.AlbumID, CoverArtUpdatedAt: mf.CoverArtUpdatedAt})
}
func (mf MediaFile) StructuredLyrics() (LyricList, error) {
diff --git a/model/mediafile_test.go b/model/mediafile_test.go
index c94653530..98de68ec2 100644
--- a/model/mediafile_test.go
+++ b/model/mediafile_test.go
@@ -549,6 +549,17 @@ var _ = Describe("MediaFile", func() {
Expect(id.Kind).To(Equal(KindAlbumArtwork))
Expect(id.ID).To(Equal(mf.AlbumID))
})
+ It("folds CoverArtUpdatedAt into disc and album fallback ids", func() {
+ stamp := time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC)
+ disc := MediaFile{ID: "111", AlbumID: "1", DiscNumber: 2}
+ album := MediaFile{ID: "111", AlbumID: "1"}
+ Expect(disc.CoverArtID().String()).To(HaveSuffix("_0"))
+ Expect(album.CoverArtID().String()).To(HaveSuffix("_0"))
+ disc.CoverArtUpdatedAt = &stamp
+ album.CoverArtUpdatedAt = &stamp
+ Expect(disc.CoverArtID().String()).ToNot(HaveSuffix("_0"))
+ Expect(album.CoverArtID().String()).ToNot(HaveSuffix("_0"))
+ })
})
Describe("AudioCodec", func() {
diff --git a/model/share.go b/model/share.go
index ce0846d60..3723f001c 100644
--- a/model/share.go
+++ b/model/share.go
@@ -2,6 +2,7 @@ package model
import (
"cmp"
+ "slices"
"strings"
"time"
@@ -37,6 +38,10 @@ func (s Share) CoverArtID() ArtworkID {
}
switch s.ResourceType {
case "album":
+ // Use the loaded album when available, so the public URL busts on cover edits
+ if i := slices.IndexFunc(s.Albums, func(al Album) bool { return al.ID == ids[0] }); i >= 0 {
+ return s.Albums[i].CoverArtID()
+ }
return Album{ID: ids[0]}.CoverArtID()
case "playlist":
return Playlist{ID: ids[0]}.CoverArtID()
diff --git a/model/share_test.go b/model/share_test.go
new file mode 100644
index 000000000..cf09043bf
--- /dev/null
+++ b/model/share_test.go
@@ -0,0 +1,32 @@
+package model_test
+
+import (
+ "time"
+
+ . "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Share", func() {
+ Describe("CoverArtID", func() {
+ It("uses the loaded album, so the public URL busts on cover edits", func() {
+ s := Share{ResourceType: "album", ResourceIDs: "al-1"}
+ plain := s.CoverArtID().String()
+ Expect(s.CoverArtID().ID).To(Equal("al-1"))
+
+ stamp := time.Now()
+ s.Albums = Albums{{ID: "al-1", CoverArtUpdatedAt: &stamp}}
+ Expect(s.CoverArtID().ID).To(Equal("al-1"))
+ Expect(s.CoverArtID().String()).ToNot(Equal(plain))
+ })
+ It("finds the shared album even when it is not first in the loaded list", func() {
+ stamp := time.Now()
+ s := Share{ResourceType: "album", ResourceIDs: "al-2,al-9"}
+ s.Albums = Albums{{ID: "al-9"}, {ID: "al-2", CoverArtUpdatedAt: &stamp}}
+ id := s.CoverArtID()
+ Expect(id.ID).To(Equal("al-2"))
+ Expect(id.String()).ToNot(HaveSuffix("_0"))
+ })
+ })
+})
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index 6ebbd9202..1eb1b4422 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -3,9 +3,11 @@ package persistence
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"iter"
"maps"
+ "os"
"slices"
"strings"
"sync"
@@ -14,6 +16,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
@@ -219,6 +222,31 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
return err
}
+// UpdateImage is the sole writer of uploaded_image (raw SQL: Put's structs.Map drops the
+// structs:"-" field). Bumps cover_art_updated_at, not updated_at, to leave Recently Added put.
+func (r *albumRepository) UpdateImage(id, filename string) error {
+ c, err := r.executeSQL(Update(r.tableName).
+ Set("uploaded_image", filename).
+ Set("cover_art_updated_at", time.Now()).
+ Where(Eq{"id": id}))
+ if err != nil {
+ return err
+ }
+ if c == 0 {
+ return model.ErrNotFound
+ }
+ return nil
+}
+
+// CountByImage counts album rows referencing an uploaded image filename, unfiltered by
+// library — CopyAttributes can leave two rows sharing one file across an album-ID change.
+func (r *albumRepository) CountByImage(filename string) (int64, error) {
+ if filename == "" {
+ return 0, nil
+ }
+ return r.count(Select(), model.QueryOptions{Filters: Eq{"uploaded_image": filename}})
+}
+
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name").
LeftJoin("library on album.library_id = library.id")
@@ -257,8 +285,14 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
+ // The cover-stamp guard below needs the source's uploaded_image even when the
+ // caller didn't request it
+ selectCols := columns
+ if slices.Contains(columns, "cover_art_updated_at") && !slices.Contains(columns, "uploaded_image") {
+ selectCols = append(slices.Clone(columns), "uploaded_image")
+ }
var from dbx.NullStringMap
- err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
+ err := r.queryOne(Select(selectCols...).From(r.tableName).Where(Eq{"id": fromID}), &from)
if err != nil {
return fmt.Errorf("getting album to copy fields from: %w", err)
}
@@ -271,6 +305,14 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) {
continue
}
+ // A source without an uploaded cover must not wipe one the destination has,
+ // nor contribute a stale cover stamp left behind by a cover removal.
+ if (col == "uploaded_image" || col == "cover_art_updated_at") && (!v.Valid || v.String == "") {
+ continue
+ }
+ if col == "cover_art_updated_at" && (!from["uploaded_image"].Valid || from["uploaded_image"].String == "") {
+ continue
+ }
to[col] = v
}
if len(to) == 0 {
@@ -353,11 +395,23 @@ on conflict (user_id, item_id, item_type) do update
}
func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
- del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
+ orphanFilter := "id not in (select distinct(album_id) from media_file)"
+
+ // Collect uploaded image filenames before deleting
+ sel := Select("uploaded_image").From(r.tableName).
+ Where(orphanFilter).
+ Where("uploaded_image <> ''")
+ del := Delete(r.tableName).Where(orphanFilter)
// If libraryIDs are specified, only purge albums from those libraries
if len(libraryIDs) > 0 {
+ sel = sel.Where(Eq{"library_id": libraryIDs})
del = del.Where(Eq{"library_id": libraryIDs})
}
+ var imageFiles []string
+ if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) {
+ return fmt.Errorf("collecting album images for cleanup: %w", err)
+ }
+
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("purging empty albums: %w", err)
@@ -365,6 +419,32 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
if c > 0 {
log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c)
}
+
+ if len(imageFiles) == 0 {
+ return nil
+ }
+ // CopyAttributes carries the filename (not the file) across album-ID changes, so a
+ // surviving album may still reference a purged album's image — keep those.
+ var stillUsed []string
+ if err := r.queryAllSlice(Select("uploaded_image").From(r.tableName).Where(Eq{"uploaded_image": imageFiles}), &stillUsed); err != nil && !errors.Is(err, model.ErrNotFound) {
+ return fmt.Errorf("checking album images still in use: %w", err)
+ }
+ used := make(map[string]struct{}, len(stillUsed))
+ for _, f := range stillUsed {
+ used[f] = struct{}{}
+ }
+
+ // Best-effort cleanup of uploaded image files
+ log.Debug(r.ctx, "Cleaning up album images", "totalImages", len(imageFiles))
+ for _, filename := range imageFiles {
+ if _, ok := used[filename]; ok {
+ continue
+ }
+ path := model.UploadedImagePath(consts.EntityAlbum, filename)
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ log.Warn(r.ctx, "Failed to remove album image during GC", "path", path, err)
+ }
+ }
return nil
}
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 64ff0095e..f30ebea31 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -3,11 +3,14 @@ package persistence
import (
"errors"
"fmt"
+ "os"
+ "path/filepath"
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@@ -41,6 +44,97 @@ var _ = Describe("AlbumRepository", func() {
})
})
+ Describe("UpdateImage", func() {
+ BeforeEach(func() {
+ Expect(albumRepo.Put(&model.Album{ID: "img-1", Name: "img", LibraryID: 1})).To(Succeed())
+ DeferCleanup(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "img-1"}))
+ })
+ })
+ It("sets and clears the uploaded image filename", func() {
+ Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed())
+ got, err := albumRepo.Get("img-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.UploadedImage).To(Equal("img-1_cover.jpg"))
+
+ Expect(albumRepo.UpdateImage("img-1", "")).To(Succeed())
+ got, err = albumRepo.Get("img-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.UploadedImage).To(BeEmpty())
+ })
+ It("is preserved across a full-row Put (structs:\"-\" contract)", func() {
+ Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed())
+ // A scan-style refresh re-Puts the album with a zero-valued UploadedImage.
+ Expect(albumRepo.Put(&model.Album{ID: "img-1", Name: "img changed", LibraryID: 1})).To(Succeed())
+ got, err := albumRepo.Get("img-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.UploadedImage).To(Equal("img-1_cover.jpg"))
+ })
+ It("returns ErrNotFound for a missing album", func() {
+ Expect(albumRepo.UpdateImage("does-not-exist", "x.jpg")).To(MatchError(model.ErrNotFound))
+ })
+ It("counts rows sharing an image filename, ignoring library filters", func() {
+ Expect(albumRepo.CountByImage("img-1_cover.jpg")).To(Equal(int64(0)))
+ Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed())
+ Expect(albumRepo.CountByImage("img-1_cover.jpg")).To(Equal(int64(1)))
+ Expect(albumRepo.CountByImage("")).To(Equal(int64(0)))
+ })
+ It("bumps cover_art_updated_at without touching updated_at", func() {
+ before, err := albumRepo.Get("img-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(before.CoverArtUpdatedAt).To(BeNil())
+
+ Expect(albumRepo.UpdateImage("img-1", "img-1_cover.jpg")).To(Succeed())
+
+ after, err := albumRepo.Get("img-1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(after.CoverArtUpdatedAt).ToNot(BeNil())
+ Expect(*after.CoverArtUpdatedAt).To(BeTemporally("~", time.Now(), time.Minute))
+ Expect(after.UpdatedAt).To(Equal(before.UpdatedAt))
+ })
+ })
+
+ Describe("purgeEmpty image cleanup", func() {
+ var artDir string
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ tempDir := GinkgoT().TempDir()
+ conf.Server.DataFolder = conf.NewDir(tempDir)
+ artDir = filepath.Join(tempDir, "artwork", "album")
+ Expect(os.MkdirAll(artDir, 0755)).To(Succeed())
+
+ _, err := albumRepo.executeSQL(squirrel.Insert("library").Columns("id", "name", "path").Values(99, "purge-lib", "/tmp/purge-lib"))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(albumRepo.Put(&model.Album{ID: "purge-a", Name: "a", LibraryID: 99})).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{ID: "purge-b", Name: "b", LibraryID: 99})).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{ID: "purge-keeper", Name: "k", LibraryID: 1})).To(Succeed())
+ Expect(albumRepo.UpdateImage("purge-a", "orphan.jpg")).To(Succeed())
+ Expect(albumRepo.UpdateImage("purge-b", "shared.jpg")).To(Succeed())
+ Expect(albumRepo.UpdateImage("purge-keeper", "shared.jpg")).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artDir, "orphan.jpg"), []byte("x"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artDir, "shared.jpg"), []byte("x"), 0600)).To(Succeed())
+
+ DeferCleanup(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"purge-a", "purge-b", "purge-keeper"}}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("library").Where(squirrel.Eq{"id": 99}))
+ })
+ })
+
+ It("removes orphaned image files but keeps ones still referenced elsewhere", func() {
+ Expect(albumRepo.purgeEmpty(99)).To(Succeed())
+
+ var ids []string
+ Expect(albumRepo.queryAllSlice(squirrel.Select("id").From("album").Where(squirrel.Eq{"id": []string{"purge-a", "purge-b"}}), &ids)).To(Succeed())
+ Expect(ids).To(BeEmpty(), "purged album rows should be gone")
+ Expect(albumRepo.queryAllSlice(squirrel.Select("id").From("album").Where(squirrel.Eq{"id": "purge-keeper"}), &ids)).To(Succeed())
+ Expect(ids).To(HaveLen(1), "album in another library must survive")
+
+ _, err := os.Stat(filepath.Join(artDir, "orphan.jpg"))
+ Expect(os.IsNotExist(err)).To(BeTrue(), "orphaned image file should be removed")
+ Expect(filepath.Join(artDir, "shared.jpg")).To(BeAnExistingFile(), "file still referenced by a surviving album must be kept")
+ })
+ })
+
Describe("CopyAttributes", func() {
var srcTime, dstTime time.Time
BeforeEach(func() {
@@ -65,6 +159,52 @@ var _ = Describe("AlbumRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
+ It("copies uploaded_image from source to destination", func() {
+ Expect(albumRepo.UpdateImage("copy-src", "copy-src_cover.jpg")).To(Succeed())
+ Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "uploaded_image")).To(Succeed())
+ got, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.UploadedImage).To(Equal("copy-src_cover.jpg"))
+ })
+ It("does not wipe the destination's cover when the source has none", func() {
+ Expect(albumRepo.UpdateImage("copy-dst", "copy-dst_cover.jpg")).To(Succeed())
+ Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "uploaded_image", "cover_art_updated_at")).To(Succeed())
+ got, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.UploadedImage).To(Equal("copy-dst_cover.jpg"))
+ Expect(got.CoverArtUpdatedAt).ToNot(BeNil())
+ })
+ It("applies the cover-stamp guard even when uploaded_image is not requested", func() {
+ Expect(albumRepo.UpdateImage("copy-src", "copy-src_cover.jpg")).To(Succeed())
+ Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "cover_art_updated_at")).To(Succeed())
+ got, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.CoverArtUpdatedAt).ToNot(BeNil(), "stamp must copy when the source has a cover")
+
+ Expect(albumRepo.UpdateImage("copy-src", "")).To(Succeed())
+ Expect(albumRepo.UpdateImage("copy-zero", "z.jpg")).To(Succeed())
+ Expect(albumRepo.UpdateImage("copy-zero", "")).To(Succeed())
+ before, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "cover_art_updated_at")).To(Succeed())
+ got, err = albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.CoverArtUpdatedAt.Equal(*before.CoverArtUpdatedAt)).To(BeTrue(), "coverless source must not contribute a stamp")
+ })
+ It("does not copy a stale cover stamp left behind by a cover removal", func() {
+ // A removal clears uploaded_image but keeps cover_art_updated_at set
+ Expect(albumRepo.UpdateImage("copy-src", "copy-src_cover.jpg")).To(Succeed())
+ Expect(albumRepo.UpdateImage("copy-src", "")).To(Succeed())
+ Expect(albumRepo.UpdateImage("copy-dst", "copy-dst_cover.jpg")).To(Succeed())
+ before, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "uploaded_image", "cover_art_updated_at")).To(Succeed())
+ got, err := albumRepo.Get("copy-dst")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.UploadedImage).To(Equal("copy-dst_cover.jpg"))
+ Expect(got.CoverArtUpdatedAt.Equal(*before.CoverArtUpdatedAt)).To(BeTrue())
+ })
})
Describe("GetCursor", func() {
diff --git a/persistence/helpers.go b/persistence/helpers.go
index fd6a9a4cd..efc676445 100644
--- a/persistence/helpers.go
+++ b/persistence/helpers.go
@@ -90,3 +90,9 @@ func mapSortOrder(tableName, order string) string {
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
return sortOrderRegex.ReplaceAllString(order, repl)
}
+
+// coverArtUpdatedAtCol projects the album's manual-cover timestamp onto rows of the given
+// table/alias as a scalar subquery — a join would clash with media_file's column names.
+func coverArtUpdatedAtCol(alias string) string {
+ return "(select cover_art_updated_at from album where album.id = " + alias + ".album_id) as cover_art_updated_at"
+}
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index ace61610c..9d11b96b9 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -184,7 +184,8 @@ func (r *mediaFileRepository) UpdateProbeData(id string, data string) error {
}
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
- sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name").
+ sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name",
+ coverArtUpdatedAtCol("media_file")).
LeftJoin("library on media_file.library_id = library.id")
sql = r.withAnnotation(sql, "media_file.id")
sql = r.withBookmark(sql, "media_file.id")
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index f6a744d8d..8d2f45f1b 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -29,6 +29,27 @@ var _ = Describe("MediaRepository", func() {
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
})
+ Describe("CoverArtUpdatedAt", func() {
+ It("exposes the album's cover timestamp on its songs", func() {
+ albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder())
+ Expect(albumRepo.Put(&model.Album{ID: "cover-al", Name: "cover", LibraryID: 1})).To(Succeed())
+ Expect(mr.Put(&model.MediaFile{ID: "cover-mf", LibraryID: 1, AlbumID: "cover-al", Path: "test/cover-mf.mp3"})).To(Succeed())
+ DeferCleanup(func() {
+ _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": "cover-mf"}))
+ _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "cover-al"}))
+ })
+
+ got, err := mr.Get("cover-mf")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.CoverArtUpdatedAt).To(BeNil())
+
+ Expect(albumRepo.UpdateImage("cover-al", "cover-al_x.jpg")).To(Succeed())
+ got, err = mr.Get("cover-mf")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(got.CoverArtUpdatedAt).ToNot(BeNil())
+ })
+ })
+
Describe("GetCursor", func() {
It("yields the same media files as GetAll", func() {
opts := model.QueryOptions{Sort: "title"}
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index e39f0bbd3..435823561 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -314,6 +314,7 @@ func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectB
"playlist_tracks.*",
"library.path as library_path",
"library.name as library_name",
+ coverArtUpdatedAtCol("f"),
).
LeftJoin("annotation on (" +
"annotation.item_id = media_file_id" +
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index e51ff8ea6..6580643cd 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -108,6 +108,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) {
"rated_at",
"f.*",
"playlist_tracks.*",
+ coverArtUpdatedAtCol("f"),
).
Join("media_file f on f.id = media_file_id").
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go
index 36f9ae4a9..8e0615521 100644
--- a/persistence/playlist_track_repository_test.go
+++ b/persistence/playlist_track_repository_test.go
@@ -1,6 +1,7 @@
package persistence
import (
+ "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -37,6 +38,35 @@ var _ = Describe("PlaylistTrackRepository", func() {
})
})
+ Describe("Read", func() {
+ It("exposes the album's cover timestamp on a single track", func() {
+ tracks, err := repo.GetAll(model.QueryOptions{Sort: "id"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(tracks).ToNot(BeEmpty())
+ albumID := tracks[0].AlbumID
+
+ albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()).(*albumRepository)
+ Expect(albumRepo.UpdateImage(albumID, "cover-read.jpg")).To(Succeed())
+ DeferCleanup(func() {
+ // Restore both columns so fixture-equality tests stay untouched
+ _, _ = albumRepo.executeSQL(squirrel.Update("album").
+ Set("uploaded_image", "").Set("cover_art_updated_at", nil).
+ Where(squirrel.Eq{"id": albumID}))
+ })
+
+ got, err := repo.Read(tracks[0].ID)
+ Expect(err).ToNot(HaveOccurred())
+ trk, ok := got.(*model.PlaylistTrack)
+ Expect(ok).To(BeTrue())
+ Expect(trk.CoverArtUpdatedAt).ToNot(BeNil())
+
+ // The list path (tracksQuery/loadTracks) must expose it too
+ all, err := repo.GetAll(model.QueryOptions{Sort: "id"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(all[0].CoverArtUpdatedAt).ToNot(BeNil())
+ })
+ })
+
Describe("CountAll", func() {
It("returns the number of tracks in the playlist", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go
index 19f16b231..16d97e6d9 100644
--- a/persistence/sql_bookmarks.go
+++ b/persistence/sql_bookmarks.go
@@ -100,7 +100,9 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
user, _ := request.UserFrom(r.ctx)
idField := r.tableName + ".id"
- sq := r.newSelect().Columns(r.tableName + ".*")
+ // Only media files are bookmarkable (see dbMediaFiles scan below), so the cover
+ // stamp projection is safe to add here
+ sq := r.newSelect().Columns(r.tableName+".*", coverArtUpdatedAtCol(r.tableName))
sq = r.withAnnotation(sq, idField)
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
var mfs dbMediaFiles // TODO Decouple from media_file
diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go
index 712a928db..6757a04e5 100644
--- a/persistence/sql_bookmarks_test.go
+++ b/persistence/sql_bookmarks_test.go
@@ -3,6 +3,7 @@ package persistence
import (
"context"
+ "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -70,5 +71,24 @@ var _ = Describe("sqlBookmarks", func() {
Expect(mr.DeleteBookmark(songComeTogether.ID)).To(Succeed())
Expect(mr.GetBookmarks()).To(BeEmpty())
})
+
+ It("exposes the album's cover timestamp on bookmarked songs", func() {
+ albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()).(*albumRepository)
+ Expect(mr.AddBookmark(songAntenna.ID, "cover test", 1)).To(Succeed())
+ DeferCleanup(func() {
+ _ = mr.DeleteBookmark(songAntenna.ID)
+ // Restore both columns so fixture-equality tests stay untouched
+ _, _ = albumRepo.executeSQL(squirrel.Update("album").
+ Set("uploaded_image", "").Set("cover_art_updated_at", nil).
+ Where(squirrel.Eq{"id": songAntenna.AlbumID}))
+ })
+
+ Expect(albumRepo.UpdateImage(songAntenna.AlbumID, "cover-bm.jpg")).To(Succeed())
+
+ bms, err := mr.GetBookmarks()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(bms).To(HaveLen(1))
+ Expect(bms[0].Item.CoverArtUpdatedAt).ToNot(BeNil())
+ })
})
})
diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go
index 5e898590b..7ac45d3fc 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -449,8 +449,8 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album,
p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err))
}
- // Keep created_at field from previous instance of the album
- if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil {
+ // Keep created_at and any uploaded cover from the previous instance of the album
+ if err := repo.CopyAttributes(prevID, a.ID, "created_at", "uploaded_image", "cover_art_updated_at"); err != nil {
// Silently ignore when the previous album is not found
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy fields", "from", prevID, "to", a.ID, "album", a.Name, err)
diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go
index 8c258b833..fc049b054 100644
--- a/scanner/phase_2_missing_tracks.go
+++ b/scanner/phase_2_missing_tracks.go
@@ -38,7 +38,8 @@ type phaseMissingTracks struct {
totalMatched atomic.Uint32
state *scanState
processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments
- annotationMutex sync.RWMutex // Protects processedAlbumAnnotations
+ processedAlbumCopies map[string]bool // Track attribute copies, per old→new album pair
+ annotationMutex sync.RWMutex // Protects the two maps above
}
func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks {
@@ -47,6 +48,7 @@ func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.Da
ds: ds,
state: state,
processedAlbumAnnotations: make(map[string]bool),
+ processedAlbumCopies: make(map[string]bool),
}
}
@@ -313,8 +315,7 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
- // Keep created_at field from previous instance of the album, so moved albums
- // don't appear in "Recently Added"
+ // Keep created_at once per target, so moved albums don't resurface in "Recently Added"
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
@@ -328,6 +329,25 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
} else {
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
}
+
+ // Copy the cover per old→new pair (not per target): with several old albums
+ // merging into one, any of them may hold it; empty sources never copy.
+ pairKey := oldAlbumID + "\x00" + newAlbumID
+ p.annotationMutex.RLock()
+ copyDone := p.processedAlbumCopies[pairKey]
+ p.annotationMutex.RUnlock()
+ if !copyDone {
+ p.annotationMutex.Lock()
+ if !p.processedAlbumCopies[pairKey] {
+ if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "uploaded_image", "cover_art_updated_at"); err != nil {
+ if !errors.Is(err, model.ErrNotFound) {
+ log.Warn(p.ctx, "Scanner: Could not copy album cover", "from", oldAlbumID, "to", newAlbumID, err)
+ }
+ }
+ p.processedAlbumCopies[pairKey] = true
+ }
+ p.annotationMutex.Unlock()
+ }
}
p.state.changesDetected.Store(true)
diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go
index d54ceee40..638aa05a8 100644
--- a/scanner/phase_2_missing_tracks_test.go
+++ b/scanner/phase_2_missing_tracks_test.go
@@ -841,6 +841,60 @@ var _ = Describe("phaseMissingTracks", func() {
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
})
+ It("should preserve an uploaded cover during moves with album change", func() {
+ missingTrack := model.MediaFile{
+ ID: "missing-img", PID: "C", Path: "lib1/song.mp3",
+ AlbumID: "old-album", LibraryID: 1,
+ }
+ matchedTrack := model.MediaFile{
+ ID: "matched-img", PID: "C", Path: "lib2/song.mp3",
+ AlbumID: "new-album", LibraryID: 2,
+ }
+
+ albumRepo.SetData(model.Albums{
+ {ID: "old-album", LibraryID: 1, UploadedImage: "old-album_cover.jpg"},
+ {ID: "new-album", LibraryID: 2},
+ })
+
+ _ = ds.MediaFile(ctx).Put(&missingTrack)
+ _ = ds.MediaFile(ctx).Put(&matchedTrack)
+
+ Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed())
+
+ newAlbum, err := albumRepo.Get("new-album")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(newAlbum.UploadedImage).To(Equal("old-album_cover.jpg"))
+ })
+
+ It("should preserve a cover held by any of several old albums merged into one", func() {
+ // old-1 (no cover) merges first and marks the target as processed for annotations;
+ // old-2 carries the cover and must still contribute it.
+ missing1 := model.MediaFile{ID: "mg-1", PID: "MG1", Path: "lib1/a.mp3", AlbumID: "old-1", LibraryID: 1}
+ matched1 := model.MediaFile{ID: "mt-1", PID: "MG1", Path: "lib2/a.mp3", AlbumID: "new-album", LibraryID: 1}
+ missing2 := model.MediaFile{ID: "mg-2", PID: "MG2", Path: "lib1/b.mp3", AlbumID: "old-2", LibraryID: 1}
+ matched2 := model.MediaFile{ID: "mt-2", PID: "MG2", Path: "lib2/b.mp3", AlbumID: "new-album", LibraryID: 1}
+
+ firstTime := time.Date(2018, 3, 1, 0, 0, 0, 0, time.UTC)
+ albumRepo.SetData(model.Albums{
+ {ID: "old-1", LibraryID: 1, CreatedAt: firstTime},
+ {ID: "old-2", LibraryID: 1, UploadedImage: "old-2_cover.jpg", CreatedAt: time.Date(2022, 9, 9, 0, 0, 0, 0, time.UTC)},
+ {ID: "new-album", LibraryID: 1},
+ })
+
+ for _, mf := range []*model.MediaFile{&missing1, &matched1, &missing2, &matched2} {
+ _ = ds.MediaFile(ctx).Put(mf)
+ }
+
+ Expect(phase.moveMatched(matched1, missing1)).To(Succeed())
+ Expect(phase.moveMatched(matched2, missing2)).To(Succeed())
+
+ newAlbum, err := albumRepo.Get("new-album")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(newAlbum.UploadedImage).To(Equal("old-2_cover.jpg"))
+ // created_at copies once per target: the second merged album must not overwrite it
+ Expect(newAlbum.CreatedAt).To(Equal(firstTime))
+ })
+
It("should not copy album created_at when album ID does not change", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
diff --git a/server/nativeapi/albums.go b/server/nativeapi/albums.go
new file mode 100644
index 000000000..469963fcc
--- /dev/null
+++ b/server/nativeapi/albums.go
@@ -0,0 +1,101 @@
+package nativeapi
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/deluan/rest"
+ "github.com/go-chi/chi/v5"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/server"
+)
+
+func (api *Router) addAlbumRoute(r chi.Router) {
+ constructor := func(ctx context.Context) rest.Repository {
+ return api.ds.Resource(ctx, model.Album{})
+ }
+ r.Route("/album", func(r chi.Router) {
+ r.Get("/", rest.GetAll(constructor))
+ r.Route("/{id}", func(r chi.Router) {
+ r.Use(server.URLParamsMiddleware)
+ r.Get("/", rest.Get(constructor))
+ r.Post("/image", api.uploadAlbumImage())
+ r.Delete("/image", api.deleteAlbumImage())
+ })
+ })
+}
+
+func (api *Router) uploadAlbumImage() http.HandlerFunc {
+ return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
+ api.albumImgOps.Lock()
+ defer api.albumImgOps.Unlock()
+ albumID := chi.URLParamFromCtx(ctx, "id")
+ al, err := api.ds.Album(ctx).Get(albumID)
+ if err != nil {
+ if errors.Is(err, model.ErrNotFound) {
+ return model.ErrNotFound
+ }
+ return err
+ }
+ oldPath, err := api.albumImagePathToRemove(ctx, al)
+ if err != nil {
+ return err
+ }
+ name := al.Name
+ if oldPath == "" && al.UploadedImage != "" {
+ // Current file is shared (post album-ID copy): write under a unique name so
+ // SetImage can't truncate the path the other album still references.
+ name = fmt.Sprintf("%s-%d", al.Name, time.Now().UnixMilli())
+ }
+ filename, err := api.imgUpload.SetImage(ctx, consts.EntityAlbum, al.ID, name, oldPath, reader, ext)
+ if err != nil {
+ return err
+ }
+ return api.ds.Album(ctx).UpdateImage(al.ID, filename)
+ })
+}
+
+// albumImagePathToRemove returns the album's current image path, or "" when the file is
+// shared with another album row (post album-ID copy) and must be left on disk.
+func (api *Router) albumImagePathToRemove(ctx context.Context, al *model.Album) (string, error) {
+ path := al.UploadedImagePath()
+ if path == "" {
+ return "", nil
+ }
+ refs, err := api.ds.Album(ctx).CountByImage(al.UploadedImage)
+ if err != nil {
+ return "", err
+ }
+ if refs > 1 {
+ return "", nil
+ }
+ return path, nil
+}
+
+func (api *Router) deleteAlbumImage() http.HandlerFunc {
+ return handleImageDelete(func(ctx context.Context) error {
+ api.albumImgOps.Lock()
+ defer api.albumImgOps.Unlock()
+ albumID := chi.URLParamFromCtx(ctx, "id")
+ al, err := api.ds.Album(ctx).Get(albumID)
+ if err != nil {
+ if errors.Is(err, model.ErrNotFound) {
+ return model.ErrNotFound
+ }
+ return err
+ }
+ oldPath, err := api.albumImagePathToRemove(ctx, al)
+ if err != nil {
+ return err
+ }
+ if err := api.imgUpload.RemoveImage(ctx, oldPath); err != nil {
+ return err
+ }
+ return api.ds.Album(ctx).UpdateImage(al.ID, "")
+ })
+}
diff --git a/server/nativeapi/albums_test.go b/server/nativeapi/albums_test.go
new file mode 100644
index 000000000..79ffd3a04
--- /dev/null
+++ b/server/nativeapi/albums_test.go
@@ -0,0 +1,131 @@
+package nativeapi
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Album Image Endpoints", func() {
+ var api *Router
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ api = &Router{ds: &tests.MockDataStore{}, imgUpload: core.NewImageUploadService()}
+ })
+
+ DescribeTable("uploadAlbumImage guard",
+ func(enableArtworkUpload, isAdmin bool, expectedStatus int) {
+ conf.Server.EnableArtworkUpload = enableArtworkUpload
+ req := httptest.NewRequest("POST", "/album/al-1/image", nil)
+ ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
+ w := httptest.NewRecorder()
+ api.uploadAlbumImage().ServeHTTP(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(expectedStatus))
+ },
+ Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest),
+ Entry("enabled, admin passes guard", true, true, http.StatusBadRequest),
+ Entry("disabled, admin passes guard", false, true, http.StatusBadRequest),
+ Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden),
+ )
+
+ DescribeTable("deleteAlbumImage guard",
+ func(enableArtworkUpload, isAdmin bool, expectedStatus int) {
+ conf.Server.EnableArtworkUpload = enableArtworkUpload
+ req := httptest.NewRequest("DELETE", "/album/al-1/image", nil)
+ ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
+ w := httptest.NewRecorder()
+ api.deleteAlbumImage().ServeHTTP(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(expectedStatus))
+ },
+ Entry("enabled, regular user passes guard", true, false, http.StatusNotFound),
+ Entry("enabled, admin passes guard", true, true, http.StatusNotFound),
+ Entry("disabled, admin passes guard", false, true, http.StatusNotFound),
+ Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden),
+ )
+})
+
+// tinyPNG is a valid 1x1 PNG, enough to pass handleImageUpload's image validation.
+var tinyPNG = []byte{
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
+ 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x64, 0x62, 0x06,
+ 0x00, 0x00, 0x0e, 0x00, 0x07, 0xd7, 0x6f, 0xe4, 0x78, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
+ 0x44, 0xae, 0x42, 0x60, 0x82,
+}
+
+type fakeImgUpload struct {
+ entityID, name, oldPath string
+}
+
+func (f *fakeImgUpload) SetImage(_ context.Context, _ string, entityID string, name string, oldPath string, _ io.Reader, _ string) (string, error) {
+ f.entityID, f.name, f.oldPath = entityID, name, oldPath
+ return "stored.png", nil
+}
+
+func (f *fakeImgUpload) RemoveImage(_ context.Context, path string) error {
+ f.oldPath = path
+ return nil
+}
+
+var _ = Describe("uploadAlbumImage shared-file handling", func() {
+ var api *Router
+ var fake *fakeImgUpload
+
+ upload := func(albumID string) {
+ body := &bytes.Buffer{}
+ w := multipart.NewWriter(body)
+ fw, err := w.CreateFormFile("image", "c.png")
+ Expect(err).ToNot(HaveOccurred())
+ _, _ = fw.Write(tinyPNG)
+ Expect(w.Close()).To(Succeed())
+
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", albumID)
+ ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "u", IsAdmin: true})
+ ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
+ req := httptest.NewRequest("POST", "/album/"+albumID+"/image", body).WithContext(ctx)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+
+ rec := httptest.NewRecorder()
+ api.uploadAlbumImage().ServeHTTP(rec, req)
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ }
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ds := &tests.MockDataStore{}
+ ds.Album(GinkgoT().Context()).(*tests.MockAlbumRepo).SetData(model.Albums{
+ {ID: "al-1", Name: "Album One", LibraryID: 1, UploadedImage: "shared.jpg"},
+ {ID: "al-2", Name: "Album Two", LibraryID: 1, UploadedImage: "shared.jpg"},
+ {ID: "al-solo", Name: "Album Solo", LibraryID: 1, UploadedImage: "solo.jpg"},
+ })
+ fake = &fakeImgUpload{}
+ api = &Router{ds: ds, imgUpload: fake}
+ })
+
+ It("replaces in place when the file is not shared", func() {
+ upload("al-solo")
+ Expect(fake.oldPath).ToNot(BeEmpty(), "sole reference: old file should be removed")
+ Expect(fake.name).To(Equal("Album Solo"))
+ })
+
+ It("keeps the shared file and writes under a unique name", func() {
+ upload("al-1")
+ Expect(fake.oldPath).To(BeEmpty(), "shared file must not be removed")
+ Expect(fake.name).ToNot(Equal("Album One"), "name must be de-duplicated so the derived filename is unique")
+ Expect(fake.name).To(HavePrefix("Album One-"))
+ })
+})
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index 5a7023eb6..671910a9b 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -6,6 +6,7 @@ import (
"html"
"net/http"
"strconv"
+ "sync"
"time"
"github.com/deluan/rest"
@@ -45,6 +46,9 @@ type Router struct {
maintenance core.Maintenance
pluginManager PluginManager
imgUpload core.ImageUploadService
+ // Serializes album image check-and-act sequences: shared-file ref-counting is
+ // check-then-act, and concurrent requests could orphan or clobber a shared file.
+ albumImgOps sync.Mutex
}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router {
@@ -66,7 +70,7 @@ func (api *Router) routes() http.Handler {
r.Use(server.UpdateLastAccessMiddleware(api.ds))
api.RX(r, "/user", api.users.NewRepository, true)
api.R(r, "/song", model.MediaFile{}, false)
- api.R(r, "/album", model.Album{}, false)
+ api.addAlbumRoute(r)
api.addArtistRoute(r)
api.R(r, "/genre", model.Genre{}, false)
api.R(r, "/player", model.Player{}, true)
diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go
index 03dfed879..2bb1ab3f0 100644
--- a/tests/mock_album_repo.go
+++ b/tests/mock_album_repo.go
@@ -134,6 +134,32 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
return nil
}
+func (m *MockAlbumRepo) UpdateImage(id, filename string) error {
+ if m.Err {
+ return errors.New("unexpected error")
+ }
+ if al, ok := m.Data[id]; ok {
+ al.UploadedImage = filename
+ now := time.Now()
+ al.CoverArtUpdatedAt = &now
+ return nil
+ }
+ return model.ErrNotFound
+}
+
+func (m *MockAlbumRepo) CountByImage(filename string) (int64, error) {
+ if m.Err {
+ return 0, errors.New("unexpected error")
+ }
+ var n int64
+ for _, al := range m.Data {
+ if filename != "" && al.UploadedImage == filename {
+ n++
+ }
+ }
+ return n, nil
+}
+
func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
m.SearchQuery = q
if len(options) > 0 {
@@ -176,6 +202,16 @@ func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) e
switch col {
case "created_at":
to.CreatedAt = from.CreatedAt
+ case "uploaded_image":
+ // Mirrors the real repo: an empty source never wipes the destination's cover
+ if from.UploadedImage != "" {
+ to.UploadedImage = from.UploadedImage
+ }
+ case "cover_art_updated_at":
+ // Mirrors the real repo: a coverless source never contributes a stale stamp
+ if from.CoverArtUpdatedAt != nil && from.UploadedImage != "" {
+ to.CoverArtUpdatedAt = from.CoverArtUpdatedAt
+ }
}
}
if m.CopyAttributesCalls == nil {
diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx
index cec66eb8b..b0de608f9 100644
--- a/ui/src/album/AlbumDetails.jsx
+++ b/ui/src/album/AlbumDetails.jsx
@@ -26,6 +26,7 @@ import {
CollapsibleComment,
DurationField,
formatRange,
+ ImageUploadOverlay,
LoveButton,
RatingField,
SizeField,
@@ -74,6 +75,7 @@ const useStyles = makeStyles(
width: '15em',
minWidth: '15em',
},
+ position: 'relative',
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
@@ -276,6 +278,11 @@ const AlbumDetails = (props) => {
cursor: imageError ? 'default' : 'pointer',
}}
/>
+