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:
Deluan 2026-03-01 21:44:21 -05:00
parent 60e6320af4
commit c596aed591
2 changed files with 151 additions and 0 deletions

View File

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

View File

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