feat(playlist): parse #EXTALBUMARTURL directive in M3U imports

This commit is contained in:
Deluan 2026-03-01 21:33:09 -05:00
parent beb5e85266
commit 2648bdd123
3 changed files with 127 additions and 0 deletions

View File

@ -2,7 +2,9 @@ package playlists_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -93,6 +95,69 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
})
It("parses #EXTALBUMARTURL with HTTP URL", func() {
pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses #EXTALBUMARTURL with absolute local path", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath)
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("parses #EXTALBUMARTURL with relative local path", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed())
m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
})
It("parses #EXTALBUMARTURL with file:// URL", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "my cover.jpg")
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20"))
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
})
Describe("NSP", func() {
@ -495,6 +560,22 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
})
It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() {
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
// Fullwidth characters (e.g., ) are not handled by SQLite's NOCASE collation,
// so we need exact matching for non-ASCII characters.
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {

View File

@ -34,6 +34,10 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
pls.Name = line[len("#PLAYLIST:"):]
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
pls.ExternalImageURL = resolveImageURL(after, folder)
continue
}
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
@ -267,3 +271,40 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
return results, nil
}
// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
// HTTP(S) URLs are stored as-is. file:// URLs are decoded to absolute paths.
// Absolute paths are stored as-is. Relative paths are resolved against the
// playlist's folder. If folder is nil (API import), relative paths cannot be
// resolved and an empty string is returned.
func resolveImageURL(value string, folder *model.Folder) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
// HTTP(S) URLs — store as-is
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value
}
// file:// URL — decode to local path
if after, ok := strings.CutPrefix(value, "file://"); ok {
decoded, err := url.QueryUnescape(after)
if err != nil {
return ""
}
return filepath.Clean(decoded)
}
// Absolute path — store as-is
if filepath.IsAbs(value) {
return filepath.Clean(value)
}
// Relative path — resolve against folder
if folder == nil {
return ""
}
return filepath.Clean(filepath.Join(folder.AbsolutePath(), value))
}

View File

@ -0,0 +1,5 @@
#EXTM3U
#PLAYLIST:Playlist With Art
#EXTALBUMARTURL:https://example.com/cover.jpg
test.mp3
test.ogg