mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(artwork): add sidecar image discovery and cache invalidation for playlists
Add playlist sidecar image support to the artwork reader fallback chain. A sidecar image (e.g., MyPlaylist.jpg next to MyPlaylist.m3u) is discovered via case-insensitive base name matching using model.IsImageFile(). Cache invalidation uses max(playlist.UpdatedAt, imageFile.ModTime()) to bust stale artwork when sidecar or ExternalImageURL local files change.
This commit is contained in:
parent
60e6320af4
commit
c596aed591
@ -235,6 +235,91 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("playlistArtworkReader", func() {
|
||||
Describe("findPlaylistSidecarPath", func() {
|
||||
It("discovers sidecar image next to playlist file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("returns empty string when no sidecar image exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(plsPath)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty string when playlist has no path", func() {
|
||||
result := findPlaylistSidecarPath("")
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("finds sidecar with different case base name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromPlaylistSidecar", func() {
|
||||
It("returns the sidecar image data when it exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("sidecar image data"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{Path: plsPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistSidecar()()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(string(data)).To(Equal("sidecar image data"))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
It("returns nil when no sidecar exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{Path: plsPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistSidecar()()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns nil when playlist has no path (API-created)", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{Path: ""},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistSidecar()()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
|
||||
@ -9,6 +9,8 @@ import (
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
@ -36,6 +38,24 @@ func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = pl.UpdatedAt
|
||||
|
||||
// Check sidecar and ExternalImageURL local file ModTimes for cache invalidation.
|
||||
// If either is newer than the playlist's UpdatedAt, use that instead so the
|
||||
// cache is busted when a user replaces a sidecar image or local file reference.
|
||||
for _, path := range []string{
|
||||
findPlaylistSidecarPath(pl.Path),
|
||||
pl.ExternalImageURL,
|
||||
} {
|
||||
if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
if info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@ -46,6 +66,7 @@ func (a *playlistArtworkReader) LastUpdated() time.Time {
|
||||
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromPlaylistUploadedImage(),
|
||||
a.fromPlaylistSidecar(),
|
||||
a.fromGeneratedTiledCover(ctx),
|
||||
fromAlbumPlaceholder(),
|
||||
)
|
||||
@ -65,6 +86,51 @@ func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
|
||||
// image file with the same base name (case-insensitive). Returns empty string if
|
||||
// no matching image is found or if plsPath is empty.
|
||||
func findPlaylistSidecarPath(plsPath string) string {
|
||||
if plsPath == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(plsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
nameBase := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if !strings.EqualFold(nameBase, base) {
|
||||
continue
|
||||
}
|
||||
if !model.IsImageFile(name) {
|
||||
continue
|
||||
}
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistSidecar() sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imgPath := findPlaylistSidecarPath(a.pl.Path)
|
||||
if imgPath == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := os.Open(imgPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, imgPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
tiles, err := a.loadTiles(ctx)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user