package playlists_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/text/unicode/norm"
)
var _ = Describe("Playlists - Import", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFromFolder", func() {
var folder *model.Folder
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: libPath,
Path: "tests/fixtures",
Name: "playlists",
}
})
Describe("M3U", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
Expect(mockPlsRepo.Last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists with UTF-8 BOM marker", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Test Playlist"))
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
})
It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
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() {
conf.Server.EnableM3UExternalAlbumArt = true
pls, err := ps.ImportFromFolder(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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "A+B.jpg")
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\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"}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
tests.SkipOnWindows("relies on Unix /etc filesystem")
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:/etc/passwd\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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() {
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:file:///etc/passwd\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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL with relative path escaping library", func() {
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:../../etc/passwd\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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:https://example.com/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, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() {
conf.Server.EnableM3UExternalAlbumArt = true
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: true,
UploadedImage: "existing-id.jpg",
ExternalImageURL: "https://example.com/old-cover.jpg",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
})
It("skips non-synced playlist on re-import (respects user's choice)", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: false,
OwnerID: "123",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).To(BeEmpty())
})
It("clears ExternalImageURL on re-scan when directive is removed", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
m3u := "test.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: true,
ExternalImageURL: "https://example.com/old-cover.jpg",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
})
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
It("returns an error if the playlist is not well-formed", func() {
tests.SkipOnWindows("line-ending differences affect JSON error offset")
_, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp")
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
It("parses NSP with public: true and creates public playlist", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Public Playlist"))
Expect(pls.Public).To(BeTrue())
})
It("parses NSP with public: false and creates private playlist", func() {
pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Private Playlist"))
Expect(pls.Public).To(BeFalse())
})
It("uses server default when public field is absent", func() {
conf.Server.DefaultPlaylistPublicVisibility = true
pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Public).To(BeTrue())
})
})
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
func(storedForm, filesystemForm string) {
tests.SkipOnWindows("/tmp hardcoded in test")
plsNameNFC := "Piosenki_Polskie_zΓ³ΕΔ"
plsNameNFD := norm.NFD.String(plsNameNFC)
Expect(plsNameNFD).ToNot(Equal(plsNameNFC))
nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD}
storedName := nameByForm[storedForm]
filesystemName := nameByForm[filesystemForm]
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := tmpDir + "/" + filesystemName + ".m3u"
Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed())
storedPath := tmpDir + "/" + storedName + ".m3u"
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: storedPath,
Sync: true,
}
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: tmpDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Name).To(Equal("Existing Playlist"))
},
Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"),
Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"),
)
Describe("Cross-library relative paths", func() {
var tmpDir, plsDir, songsDir string
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
plsDir = tmpDir + "/playlists"
songsDir = tmpDir + "/songs"
Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: songsDir},
{ID: 2, Path: plsDir},
})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"abc.mp3",
"def.mp3",
},
}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
})
It("handles relative paths that reference files in other libraries", func() {
plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
plsFile := plsDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("def.mp3"))
})
It("ignores paths that point outside all libraries", func() {
plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
plsFile := plsDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
})
It("handles relative paths with multiple '../' components", func() {
subFolder := plsDir + "/subfolder"
Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"abc.mp3",
"def.mp3",
},
}
plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
plsFile := subFolder + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "subfolder",
}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("def.mp3"))
})
It("correctly resolves libraries when one path is a prefix of another", func() {
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
musicClassicalDir := tmpDir + "/music-classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir},
{ID: 2, Path: musicClassicalDir},
})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"rock.mp3",
"bach.mp3",
},
}
plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3"))
})
It("correctly handles identical relative paths from different libraries", func() {
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
classicalDir := tmpDir + "/classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir},
{ID: 2, Path: classicalDir},
})
ds.MockedMediaFile = &mockedMediaFileRepo{
data: map[string]model.MediaFile{
"1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
"2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
},
}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
libIDs := make(map[int]bool)
for _, track := range pls.Tracks {
libIDs[track.LibraryID] = true
}
Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
})
})
})
Describe("ImportFile", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
})
It("resolves file inside a library and imports it", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: tmpDir,
Path: "",
Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n"
plsFile := filepath.Join(tmpDir, "my-playlist.m3u")
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Playlist"))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Path).To(Equal(plsFile))
Expect(pls.Sync).To(BeTrue())
})
It("records path for files outside all libraries", func() {
tmpDir := GinkgoT().TempDir()
libDir := filepath.Join(tmpDir, "music")
Expect(os.Mkdir(libDir, 0755)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}})
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n"
plsFile := filepath.Join(tmpDir, "external.m3u")
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("External Playlist"))
Expect(pls.Path).To(Equal(plsFile))
Expect(pls.Sync).To(BeFalse())
})
It("imports with Sync=false", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Sync).To(BeFalse())
})
It("imports with Sync=true", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Sync).To(BeTrue())
})
It("upgrades non-synced playlist to synced on re-import with sync=true", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id", Name: "Existing", Path: plsFile,
Sync: false, OwnerID: "123",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Sync).To(BeTrue())
})
})
Describe("ImportM3U", func() {
var repo *mockedMediaFileFromListRepo
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
repo.data = []string{
"tests/test.mp3",
"tests/test.ogg",
"tests/01 Invisible (RED) Edit Version.mp3",
"downloads/newfile.flac",
}
m3u := strings.Join([]string{
"#PLAYLIST:playlist 1",
"/music/tests/test.mp3",
"/music/tests/test.ogg",
"/new/downloads/newfile.flac",
"file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(pls.Sync).To(BeFalse())
Expect(pls.Tracks).To(HaveLen(4))
Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
Expect(mockPlsRepo.Last).To(Equal(pls))
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
repo.data = []string{
"tests/test.mp3",
"tests/test.ogg",
"/tests/01 Invisible (RED) Edit Version.mp3",
}
m3u := strings.Join([]string{
"/music/tests/test.mp3",
"/music/tests/test.ogg",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
repo.data = []string{
"album1/test1.mp3",
"album2/test2.mp3",
"album3/test3.mp3",
}
m3u := strings.Join([]string{
"/music/album3/test3.mp3",
"/music/album1/test1.mp3",
"/music/album4/test4.mp3",
"/music/album2/test2.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
})
It("is case-insensitive when comparing paths", func() {
repo.data = []string{
"abc/tEsT1.Mp3",
}
m3u := strings.Join([]string{
"/music/ABC/TeSt1.mP3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
conf.Server.EnableM3UExternalAlbumArt = true
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())
})
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
repo.data = []string{
"plex/02 - οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³.flac",
}
m3u := "/music/plex/02 - οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³.flac\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("plex/02 - οΌ‘οΌ£οΌ²οΌ―οΌ³οΌ³.flac"))
})
DescribeTable("matches paths across Unicode NFC/NFD normalization",
func(description, pathNFC string, dbForm, playlistForm norm.Form) {
pathNFD := norm.NFD.String(pathNFC)
Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters")
var dbPath string
if dbForm == norm.NFC {
dbPath = pathNFC
} else {
dbPath = pathNFD
}
repo.data = []string{dbPath}
var playlistPath string
if playlistForm == norm.NFC {
playlistPath = pathNFC
} else {
playlistPath = pathNFD
}
m3u := "/music/" + playlistPath + "\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal(dbPath))
},
Entry("French diacritics - DB:NFD, playlist:NFC",
"macOS DB with Apple Music playlist",
"artist/Michèle/song.mp3", norm.NFD, norm.NFC),
Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)",
"Linux/Windows DB with NFC playlist",
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC),
Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)",
"macOS DB with NFC playlist",
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC),
Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)",
"macOS DB with NFC playlist",
"ΠΡΠΊΠΈ/ΠΠ°ΡΠ°ΡΠ΅ΠΉΠΊΠ°/01 - Π Π°Π·Π»ΡΠ±ΠΈΠ»Π°.mp3", norm.NFD, norm.NFC),
Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)",
"macOS DB with NFC playlist",
"ZespΓ³Ε/CzΕowiek/Piosenka o miΕoΕci.mp3", norm.NFD, norm.NFC),
Entry("Polish diacritics - DB:NFC, playlist:NFD",
"Linux/Windows DB with macOS-exported playlist",
"ZespΓ³Ε/CzΕowiek/Piosenka o miΕoΕci.mp3", norm.NFC, norm.NFD),
)
})
Describe("InPath", func() {
var folder model.Folder
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
folder = model.Folder{
LibraryPath: "/music",
Path: "playlists/abc",
Name: "folder1",
}
})
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(playlists.InPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(playlists.InPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
Path: "",
Name: ".",
}
Expect(playlists.InPath(folder2)).To(BeTrue())
})
})
})
type mockedMediaFileRepo struct {
model.MediaFileRepository
data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
if r.data != nil {
for _, path := range paths {
if mf, ok := r.data[path]; ok {
mfs = append(mfs, mf)
}
}
return mfs, nil
}
for idx, path := range paths {
actualPath := path
libraryID := 1
if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: actualPath,
LibraryID: libraryID,
})
}
return mfs, nil
}
type mockedMediaFileFromListRepo struct {
model.MediaFileRepository
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, dataPath := range r.data {
for _, requestPath := range paths {
actualPath := requestPath
libraryID := 1
if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
if strings.EqualFold(actualPath, dataPath) {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: dataPath,
LibraryID: libraryID,
})
break
}
}
}
return mfs, nil
}
type mockFolderRepoForImport struct {
model.FolderRepository
folder *model.Folder
}
func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) {
if m.folder != nil {
return m.folder, nil
}
return nil, model.ErrNotFound
}