Compare commits

...

23 commits

Author SHA1 Message Date
Deluan
d509d8b2f8 fix(subsonic): expose cover cache-buster on bookmarked songs
GetBookmarks selected media_file.* without the album cover-stamp projection,
so bookmarked songs kept emitting the pre-edit coverArt id after a cover
upload/delete. With this fourth copy of the projection appearing, extract it
into a shared coverArtUpdatedAtCol helper used by all media_file read paths
(list, playlist tracks, single playlist track, bookmarks).
2026-07-18 02:18:52 -04:00
Deluan
a5d3d615d8 fix(album): self-contained CopyAttributes stamp guard; close coverage gaps
CopyAttributes' cover-stamp guard read the source's uploaded_image only when
the caller also requested that column — a hidden cross-column contract a
future caller could silently violate. Fetch it internally instead, and align
the mock with the compound guard (it copied stamps from coverless sources).

Also cover the untested new code: Key() cover-stamp components in the disc
and mediafile readers, and the playlist tracks list path exposing
cover_art_updated_at.
2026-07-18 02:07:07 -04:00
Deluan
5a90e17693 fix(album): make the cover art id collision-proof against updated_at drops
The additive id (updated_at + cover stamp) could re-emit a previously used
value when updated_at decreases (the newest-mtime track leaves the album) by
the same delta the cover stamp advanced — re-serving a stale client cover.
Mix the timestamps instead, so the id changes whenever either does.
2026-07-18 02:07:07 -04:00
Deluan
a79e670740 fix(album): serialize cover upload/delete to close shared-file races
The shared-file handling is check-then-act (CountByImage, then remove/write):
two concurrent deletes on albums sharing one file could both see refs>1, both
skip removal, and both clear their rows — leaving the file orphaned with no
remaining reference for the purge GC to find. Serialize album image
operations behind a mutex; they are rare, admin-gated actions.
2026-07-18 02:07:07 -04:00
Deluan
b0aff3c9d6 fix(share): find the shared album in the loaded list for cover busting
The loaded albums of a multi-album share are IN-fetched without preserving
ResourceIDs order, so checking only index 0 could miss the shared album and
fall back to the never-busting bare id. Search the list instead.
2026-07-18 00:24:46 -04:00
Deluan
5418e88f8e fix(album): bust public share images on cover edits; drop stale stamp copies
Share.CoverArtID built a bare Album{ID}, so public album-share image URLs
were a constant al-<id>_0 — combined with the 10-year cache header, viewers
kept the pre-edit cover forever. Use the loaded album when available so the
URL carries the real timestamps.

Also skip copying cover_art_updated_at in CopyAttributes when the source has
no uploaded_image (a cover removal leaves the stamp set), so an album-ID
merge can't overwrite the destination's valid cover stamp with a stale one.
2026-07-18 00:12:21 -04:00
Deluan
7c27e9d5a3 fix(scanner): copy created_at once per target when albums merge
The per-pair attribute copy let a later merged old album overwrite the
target's created_at with its own, shifting the album's position in Recently
Added depending on processing order. Split the copy: created_at stays under
the per-target guard (copied once), while the cover fields copy per old→new
pair so any merged old album can still contribute an uploaded cover.
2026-07-17 23:57:56 -04:00
Deluan
f4c1431bdf fix(playlist): expose cover cache-buster on single-track reads
The playlist single-track Read select was missing the cover_art_updated_at
scalar that the list query carries, so a getOne (e.g. after rating a track)
replaced the cached UI record with one lacking the cover cache-buster,
reverting song art to the stale URL until the list reloaded.
2026-07-17 23:47:28 -04:00
Deluan
ee7c4d3b88 fix(album): never write over a cover file shared by another album
The shared-file guard stopped deletion, but re-uploading a cover on the album
whose id matches the shared filename derived the same deterministic path, and
SetImage's os.Create truncated the file the other album still references —
silently changing its cover too.

When the current file is shared, upload under a de-duplicated name so the
derived filename is unique; the shared file stays intact for the other row
and the purge GC reaps it when the last reference goes.
2026-07-17 23:36:08 -04:00
Deluan
cbc12034a4 fix(scanner): carry covers from every old album merged during a scan
The phase-2 attribute copy ran under the per-target annotations guard, so
when several old albums merged into one new album ID in a single scan, only
the first old album's attributes were copied — a cover held by a later one
was lost (and its file later reaped by the purge GC).

Dedupe the created_at/cover copy per old→new pair instead; the empty-source
guard in CopyAttributes keeps coverless old albums from overwriting anything.
2026-07-17 23:26:58 -04:00
Deluan
83ae195c10 fix(album): don't delete cover files still shared by another album row
After an album-ID copy (e.g. a partial track move), two album rows can
reference the same uploaded image file. Replacing or deleting the cover on
one of them removed the shared file out from under the other.

Add AlbumRepository.CountByImage (unfiltered by library) and skip the file
removal in the upload/delete handlers when the filename is referenced by more
than one row; the purge GC reaps the file when the last reference goes.
2026-07-17 23:16:10 -04:00
Deluan
f498dfde7d fix(album): make cover cache keys immune to future file mtimes
All cover cache keys used max(updated_at, cover_art_updated_at), so an album
whose updated_at is in the future (bad file mtimes) would mask a cover edit
entirely — id unchanged, reader cache key unchanged, UI URL unchanged — and
keep serving the old image.

Treat the keys as discriminators instead of dates: the album artwork id adds
the two timestamps, the reader cache keys (album/disc/mediafile) carry the
cover stamp as a separate component, and the UI cache-buster joins both
timestamps instead of picking the newest.
2026-07-17 23:03:20 -04:00
Deluan
0ffcb38eae feat(album): clean up uploaded cover files when albums are purged
Mirror the artist purgeEmpty file cleanup: collect uploaded_image filenames of
albums about to be purged and best-effort remove them from the data folder.
Filenames still referenced by a surviving album are kept — CopyAttributes
carries the filename (not the file) across album-ID changes, so two rows can
legitimately share one image file.
2026-07-17 23:03:20 -04:00
Deluan
223efdf020 fix: bust song/disc artwork ids and disc thumbnails on cover changes
Song coverArt ids emitted to Subsonic clients embedded no timestamp for the
disc/album fallback paths (always `_0`), so clients that cache by coverArt id
kept stale art after an album cover upload or delete. Fold the song's
CoverArtUpdatedAt into DiscCoverArtID and AlbumCoverArtID; ids are unchanged
when no cover was ever uploaded.

The web UI disc-header thumbnails had the same gap: getDiscCoverArtUrl only
used the song's updatedAt. It now takes the record and picks the newest of
updatedAt/coverArtUpdatedAt, sharing the cache-key helper with getCoverArtUrl.
2026-07-17 22:47:13 -04:00
Deluan
e8946b8810 fix(ui): bust song artwork URLs when the album cover changes
Song art URLs (mf- ids: player bar, queue) were cache-busted only by the
song's own updatedAt, and cover responses carry a 10-year max-age — so after
an album cover upload/delete the browser kept serving the stale cached image
(including the placeholder, for previously artless albums) until the file
itself was rescanned.

Expose the album's cover_art_updated_at on song payloads via a scalar
subquery (a join would make unqualified references ambiguous — media_file
and album share column names), in both the media_file select and the
playlist tracks query, and pass it through the player queue builder so
getCoverArtUrl picks it up.
2026-07-17 22:03:31 -04:00
Deluan
35d08a25a3 fix(album): don't wipe covers on album merges; bust disc/song art caches
Adversarial review findings:

- CopyAttributes copied uploaded_image/cover_art_updated_at unconditionally,
  so merging an album without a cover into one with a cover (retag onto an
  existing album ID, or phase-2 moved-track matching) blanked the
  destination's uploaded cover. Skip empty/NULL source values, mirroring the
  created_at guard, and make the mock faithful to the same semantics.

- The disc and mediafile artwork readers did not fold cover_art_updated_at
  into their cache keys, so song/disc art reached through the fromAlbum
  fallback kept serving stale bytes after a cover upload or delete.

- The UI cover cache-buster compared timestamps lexicographically; mixed
  offset/fractional formats could pick the older one. Compare as dates.
2026-07-17 21:34:15 -04:00
Deluan
319662174e fix(album): keep cover uploads out of Recently Added ordering
Cover upload/delete bumped album.updated_at to bust the artwork cache, but
recentlyAddedSort() and Subsonic albumCreatedAt() sort on updated_at when
RecentlyAddedByModTime is enabled, so a cover edit reordered Recently Added
and shifted the reported creation date.

Add a dedicated cover_art_updated_at column that UpdateImage bumps instead of
updated_at; the album artwork id, reader cache key, and UI cover URL fold it
into their cache-busting, so covers refresh everywhere while updated_at (and
Recently Added) stays untouched.

Also preserve uploaded_image across the phase-2 moved-track album-id change,
which previously copied only created_at.
2026-07-17 21:08:16 -04:00
Deluan
27a0841065 feat(album): show cover upload overlay on album details 2026-07-17 19:07:27 -04:00
Deluan
940dbe4c0a feat(album): add cover upload/delete API endpoints 2026-07-17 19:02:14 -04:00
Deluan
37c837425c feat(album): serve uploaded cover with highest priority 2026-07-17 18:58:09 -04:00
Deluan
75c11cab81 feat(album): preserve uploaded cover across album-id changes 2026-07-17 18:54:33 -04:00
Deluan
720993fe7c feat(album): add UpdateImage repository method for uploaded cover 2026-07-17 18:49:01 -04:00
Deluan
0d8ba90b13 feat(album): add uploaded_image column and path helper 2026-07-17 18:43:47 -04:00
38 changed files with 983 additions and 72 deletions

View file

@ -124,6 +124,7 @@ const (
// Entity types
const (
EntityArtist = "artist"
EntityAlbum = "album"
EntityPlaylist = "playlist"
EntityRadio = "radio"
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

32
model/share_test.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

101
server/nativeapi/albums.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -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',
}}
/>
<ImageUploadOverlay
entityType="album"
entityId={record.id}
hasUploadedImage={!!record.uploadedImage}
/>
</div>
<div className={classes.details}>
<CardContent className={classes.content}>

View file

@ -96,18 +96,9 @@ const DiscSubtitleRow = forwardRef(
onClick(discNumber)
}
const coverArtUrl = subsonic.getDiscCoverArtUrl(
record.albumId,
record.discNumber,
record.updatedAt,
96,
)
const coverArtUrl = subsonic.getDiscCoverArtUrl(record, 96)
const fullImageUrl = subsonic.getDiscCoverArtUrl(
record.albumId,
record.discNumber,
record.updatedAt,
)
const fullImageUrl = subsonic.getDiscCoverArtUrl(record)
const handleOpenLightbox = useCallback(
(e) => {

View file

@ -91,6 +91,7 @@ const mapToAudioLists = (item) => {
{
id: trackId,
updatedAt: item.updatedAt,
coverArtUpdatedAt: item.coverArtUpdatedAt,
album: item.album,
},
300,

View file

@ -80,9 +80,16 @@ const getAvatarUrl = (username, size) =>
}),
)
// Cache-buster from both timestamps (cover uploads bump coverArtUpdatedAt); joined,
// not max'd, so the URL changes even when updatedAt is newer (future file mtimes).
const artCacheKey = (record) =>
[record.updatedAt, record.coverArtUpdatedAt].filter(Boolean).join('|') ||
undefined
const getCoverArtUrl = (record, size, square) => {
const cacheKey = artCacheKey(record)
const options = {
...(record.updatedAt && { _: record.updatedAt }),
...(cacheKey && { _: cacheKey }),
...(size && { size }),
...(square && { square }),
}
@ -103,13 +110,18 @@ const getCoverArtUrl = (record, size, square) => {
}
}
const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => {
const getDiscCoverArtUrl = (record, size) => {
const cacheKey = artCacheKey(record)
const options = {
...(updatedAt && { _: updatedAt }),
...(cacheKey && { _: cacheKey }),
...(size && { size }),
}
return baseUrl(
url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options),
url(
'getCoverArt',
'dc-' + record.albumId + ':' + record.discNumber,
options,
),
)
}

View file

@ -79,6 +79,22 @@ describe('getCoverArtUrl', () => {
expect(url).toContain('square=true')
})
it('should include coverArtUpdatedAt in the cache key', () => {
const albumRecord = {
id: 'album-123',
albumArtist: 'Test Artist',
updatedAt: '2023-01-01T00:00:00Z',
coverArtUpdatedAt: '2024-06-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(albumRecord)
expect(url).toContain('al-album-123')
expect(url).toContain(
'_=2023-01-01T00%3A00%3A00Z%7C2024-06-01T00%3A00%3A00Z',
)
})
it('should return media file cover art URL for records with album', () => {
const songRecord = {
id: 'song-123',
@ -139,9 +155,11 @@ describe('getDiscCoverArtUrl', () => {
it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => {
const url = subsonic.getDiscCoverArtUrl(
'album-123',
2,
'2023-01-01T00:00:00Z',
{
albumId: 'album-123',
discNumber: 2,
updatedAt: '2023-01-01T00:00:00Z',
},
48,
)
@ -152,7 +170,10 @@ describe('getDiscCoverArtUrl', () => {
})
it('should handle missing updatedAt', () => {
const url = subsonic.getDiscCoverArtUrl('album-123', 1, undefined, 48)
const url = subsonic.getDiscCoverArtUrl(
{ albumId: 'album-123', discNumber: 1 },
48,
)
expect(url).toContain('id=dc-album-123%3A1')
expect(url).toContain('size=48')
@ -160,16 +181,30 @@ describe('getDiscCoverArtUrl', () => {
})
it('should handle missing size', () => {
const url = subsonic.getDiscCoverArtUrl(
'album-123',
1,
'2023-01-01T00:00:00Z',
)
const url = subsonic.getDiscCoverArtUrl({
albumId: 'album-123',
discNumber: 1,
updatedAt: '2023-01-01T00:00:00Z',
})
expect(url).toContain('id=dc-album-123%3A1')
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
expect(url).not.toContain('size=')
})
it('should include coverArtUpdatedAt in the cache key', () => {
const url = subsonic.getDiscCoverArtUrl({
albumId: 'album-123',
discNumber: 1,
updatedAt: '2023-01-01T00:00:00Z',
coverArtUpdatedAt: '2024-06-01T00:00:00Z',
})
expect(url).toContain('id=dc-album-123%3A1')
expect(url).toContain(
'_=2023-01-01T00%3A00%3A00Z%7C2024-06-01T00%3A00%3A00Z',
)
})
})
describe('getAvatarUrl', () => {