mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
feat(playlist): parse #EXTALBUMARTURL directive in M3U imports
This commit is contained in:
parent
beb5e85266
commit
2648bdd123
@ -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., ABCD) 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() {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
5
tests/fixtures/playlists/pls-with-art-url.m3u
vendored
Normal file
5
tests/fixtures/playlists/pls-with-art-url.m3u
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
#EXTM3U
|
||||
#PLAYLIST:Playlist With Art
|
||||
#EXTALBUMARTURL:https://example.com/cover.jpg
|
||||
test.mp3
|
||||
test.ogg
|
||||
Loading…
x
Reference in New Issue
Block a user