mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
461 lines
15 KiB
Go
461 lines
15 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"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", func() {
|
|
var ds *tests.MockDataStore
|
|
var ps Playlists
|
|
var mockPlsRepo mockedPlaylistRepo
|
|
var mockLibRepo *tests.MockLibraryRepo
|
|
ctx := context.Background()
|
|
|
|
BeforeEach(func() {
|
|
mockPlsRepo = mockedPlaylistRepo{}
|
|
mockLibRepo = &tests.MockLibraryRepo{}
|
|
ds = &tests.MockDataStore{
|
|
MockedPlaylist: &mockPlsRepo,
|
|
MockedLibrary: mockLibRepo,
|
|
}
|
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
|
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
|
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
|
|
})
|
|
|
|
Describe("ImportFile", func() {
|
|
var folder *model.Folder
|
|
BeforeEach(func() {
|
|
ps = NewPlaylists(ds)
|
|
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
|
libPath, _ := os.Getwd()
|
|
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.ImportFile(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.ImportFile(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.ImportFile(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.ImportFile(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.ImportFile(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"))
|
|
})
|
|
})
|
|
|
|
Describe("NSP", func() {
|
|
It("parses well-formed playlists", func() {
|
|
pls, err := ps.ImportFile(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() {
|
|
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
|
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
|
})
|
|
})
|
|
|
|
Describe("Cross-library relative paths", func() {
|
|
var tmpDir, plsDir, songsDir string
|
|
|
|
BeforeEach(func() {
|
|
// Create temp directory structure
|
|
tmpDir = GinkgoT().TempDir()
|
|
plsDir = tmpDir + "/playlists"
|
|
songsDir = tmpDir + "/songs"
|
|
Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
|
|
Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
|
|
|
|
// Setup two different libraries with paths matching our temp structure
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: songsDir},
|
|
{ID: 2, Path: plsDir},
|
|
})
|
|
|
|
// Create a mock media file repository that returns files for both libraries
|
|
// Note: The paths are relative to their respective library roots
|
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
|
data: []string{
|
|
"abc.mp3", // This is songs/abc.mp3 relative to songsDir
|
|
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
|
},
|
|
}
|
|
ps = NewPlaylists(ds)
|
|
})
|
|
|
|
It("handles relative paths that reference files in other libraries", func() {
|
|
// Create a temporary playlist file with relative path
|
|
plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
|
|
plsFile := plsDir + "/test.m3u"
|
|
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
|
|
|
// Playlist is in the Playlists library folder
|
|
// Important: Path should be relative to LibraryPath, and Name is the folder name
|
|
plsFolder := &model.Folder{
|
|
ID: "2",
|
|
LibraryID: 2,
|
|
LibraryPath: plsDir,
|
|
Path: "",
|
|
Name: "",
|
|
}
|
|
|
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(pls.Tracks).To(HaveLen(2))
|
|
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
|
|
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
|
|
})
|
|
|
|
It("ignores paths that point outside all libraries", func() {
|
|
// Create a temporary playlist file with path outside libraries
|
|
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.ImportFile(ctx, plsFolder, "test.m3u")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Should only find abc.mp3, not outside.mp3
|
|
Expect(pls.Tracks).To(HaveLen(1))
|
|
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
|
|
})
|
|
|
|
It("handles relative paths with multiple '../' components", func() {
|
|
// Create a nested structure: tmpDir/playlists/subfolder/test.m3u
|
|
subFolder := plsDir + "/subfolder"
|
|
Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
|
|
|
|
// Create the media file in the subfolder directory
|
|
// The mock will return it as "def.mp3" relative to plsDir
|
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
|
data: []string{
|
|
"abc.mp3", // From songsDir library
|
|
"def.mp3", // From plsDir library root
|
|
},
|
|
}
|
|
|
|
// From subfolder, ../../songs/abc.mp3 should resolve to songs library
|
|
// ../def.mp3 should resolve to plsDir/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())
|
|
|
|
// The folder: AbsolutePath = LibraryPath + Path + Name
|
|
// So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
|
|
plsFolder := &model.Folder{
|
|
ID: "2",
|
|
LibraryID: 2,
|
|
LibraryPath: plsDir,
|
|
Path: "", // Empty because subfolder is directly under library root
|
|
Name: "subfolder", // The folder name
|
|
}
|
|
|
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(pls.Tracks).To(HaveLen(2))
|
|
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
|
|
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("ImportM3U", func() {
|
|
var repo *mockedMediaFileFromListRepo
|
|
BeforeEach(func() {
|
|
repo = &mockedMediaFileFromListRepo{}
|
|
ds.MockedMediaFile = repo
|
|
ps = NewPlaylists(ds)
|
|
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 other 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("handles Unicode normalization when comparing paths", func() {
|
|
// Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
|
|
// The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
|
|
|
|
const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
|
|
|
|
// Simulate a database entry with NFD encoding (as stored by macOS filesystem)
|
|
nfdPath := norm.NFD.String(pathWithAccents)
|
|
repo.data = []string{nfdPath}
|
|
|
|
// Simulate an Apple Music M3U playlist entry with NFC encoding
|
|
nfcPath := norm.NFC.String("/music/" + pathWithAccents)
|
|
m3u := strings.Join([]string{
|
|
nfcPath,
|
|
}, "\n")
|
|
f := strings.NewReader(m3u)
|
|
|
|
pls, err := ps.ImportM3U(ctx, f)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
|
|
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
|
|
})
|
|
})
|
|
|
|
Describe("normalizePathForComparison", func() {
|
|
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
|
|
// Test with NFD (decomposed) input - as would come from macOS filesystem
|
|
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
|
|
normalized := normalizePathForComparison(nfdPath)
|
|
Expect(normalized).To(Equal("michèle"))
|
|
|
|
// Test with NFC (composed) input - as would come from Apple Music M3U
|
|
nfcPath := "Michèle" // This might be in NFC form
|
|
normalizedNfc := normalizePathForComparison(nfcPath)
|
|
|
|
// Ensure the two paths are not equal in their original forms
|
|
Expect(nfdPath).ToNot(Equal(nfcPath))
|
|
|
|
// Both should normalize to the same result
|
|
Expect(normalized).To(Equal(normalizedNfc))
|
|
})
|
|
|
|
It("handles paths with mixed case and Unicode characters", func() {
|
|
path := "Artist/Noël Coward/Album/Song.mp3"
|
|
normalized := normalizePathForComparison(path)
|
|
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
|
|
})
|
|
})
|
|
|
|
Describe("InPlaylistsPath", 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(InPlaylistsPath(folder)).To(BeTrue())
|
|
})
|
|
|
|
It("returns true if PlaylistsPath is any (**/**)", func() {
|
|
conf.Server.PlaylistsPath = "**/**"
|
|
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
|
})
|
|
|
|
It("returns true if folder is in PlaylistsPath", func() {
|
|
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
|
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
|
})
|
|
|
|
It("returns false if folder is not in PlaylistsPath", func() {
|
|
conf.Server.PlaylistsPath = "other"
|
|
Expect(InPlaylistsPath(folder)).To(BeFalse())
|
|
})
|
|
|
|
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
|
conf.Server.PlaylistsPath = "."
|
|
Expect(InPlaylistsPath(folder)).To(BeFalse())
|
|
|
|
folder2 := model.Folder{
|
|
LibraryPath: "/music",
|
|
Path: "",
|
|
Name: ".",
|
|
}
|
|
|
|
Expect(InPlaylistsPath(folder2)).To(BeTrue())
|
|
})
|
|
})
|
|
})
|
|
|
|
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
|
|
type mockedMediaFileRepo struct {
|
|
model.MediaFileRepository
|
|
}
|
|
|
|
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
|
var mfs model.MediaFiles
|
|
for idx, path := range paths {
|
|
mfs = append(mfs, model.MediaFile{
|
|
ID: strconv.Itoa(idx),
|
|
Path: path,
|
|
})
|
|
}
|
|
return mfs, nil
|
|
}
|
|
|
|
// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
|
|
type mockedMediaFileFromListRepo struct {
|
|
model.MediaFileRepository
|
|
data []string
|
|
}
|
|
|
|
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
|
|
var mfs model.MediaFiles
|
|
for idx, path := range r.data {
|
|
mfs = append(mfs, model.MediaFile{
|
|
ID: strconv.Itoa(idx),
|
|
Path: path,
|
|
})
|
|
}
|
|
return mfs, nil
|
|
}
|
|
|
|
type mockedPlaylistRepo struct {
|
|
last *model.Playlist
|
|
model.PlaylistRepository
|
|
}
|
|
|
|
func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
|
r.last = pls
|
|
return nil
|
|
}
|