mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-01 19:47:37 +03:00
fix(server): import absolute paths in m3u (#3756)
* fix(server): import playlists with absolute paths Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): optimize playlist import Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): add test with multiple libraries Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): refactor Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
3892f70c35
commit
0c4c223127
6 changed files with 129 additions and 62 deletions
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -188,20 +189,14 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||||
if !model.IsAudioFile(line) {
|
if !model.IsAudioFile(line) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
line = filepath.Clean(line)
|
|
||||||
if folder != nil && !filepath.IsAbs(line) {
|
|
||||||
line = filepath.Join(folder.AbsolutePath(), line)
|
|
||||||
var err error
|
|
||||||
line, err = filepath.Rel(folder.LibraryPath, line)
|
|
||||||
if err != nil {
|
|
||||||
log.Trace(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "folder", folder, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filteredLines = append(filteredLines, line)
|
filteredLines = append(filteredLines, line)
|
||||||
}
|
}
|
||||||
filteredLines = slice.Map(filteredLines, filepath.ToSlash)
|
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
|
||||||
found, err := mediaFileRepository.FindByPaths(filteredLines)
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found, err := mediaFileRepository.FindByPaths(paths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
|
@ -210,7 +205,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||||
for idx := range found {
|
for idx := range found {
|
||||||
existing[strings.ToLower(found[idx].Path)] = idx
|
existing[strings.ToLower(found[idx].Path)] = idx
|
||||||
}
|
}
|
||||||
for _, path := range filteredLines {
|
for _, path := range paths {
|
||||||
idx, ok := existing[strings.ToLower(path)]
|
idx, ok := existing[strings.ToLower(path)]
|
||||||
if ok {
|
if ok {
|
||||||
mfs = append(mfs, found[idx])
|
mfs = append(mfs, found[idx])
|
||||||
|
@ -228,6 +223,64 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO This won't work for multiple libraries
|
||||||
|
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
|
||||||
|
libRegex, err := s.compileLibraryPaths(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]string, 0, len(lines))
|
||||||
|
for idx, line := range lines {
|
||||||
|
var libPath string
|
||||||
|
var filePath string
|
||||||
|
|
||||||
|
if folder != nil && !filepath.IsAbs(line) {
|
||||||
|
libPath = folder.LibraryPath
|
||||||
|
filePath = filepath.Join(folder.AbsolutePath(), line)
|
||||||
|
} else {
|
||||||
|
cleanLine := filepath.Clean(line)
|
||||||
|
if libPath = libRegex.FindString(cleanLine); libPath != "" {
|
||||||
|
filePath = cleanLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if libPath != "" {
|
||||||
|
if rel, err := filepath.Rel(libPath, filePath); err == nil {
|
||||||
|
res = append(res, rel)
|
||||||
|
} else {
|
||||||
|
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
|
||||||
|
"filePath", filePath, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slice.Map(res, filepath.ToSlash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
|
||||||
|
libs, err := s.ds.Library(ctx).GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create regex patterns for each library path
|
||||||
|
patterns := make([]string, len(libs))
|
||||||
|
for i, lib := range libs {
|
||||||
|
cleanPath := filepath.Clean(lib.Path)
|
||||||
|
escapedPath := regexp.QuoteMeta(cleanPath)
|
||||||
|
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
|
||||||
|
}
|
||||||
|
// Combine all patterns into a single regex
|
||||||
|
combinedPattern := strings.Join(patterns, "|")
|
||||||
|
re, err := regexp.Compile(combinedPattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
|
||||||
|
}
|
||||||
|
return re, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||||
owner, _ := request.UserFrom(ctx)
|
owner, _ := request.UserFrom(ctx)
|
||||||
|
|
||||||
|
|
|
@ -20,15 +20,20 @@ import (
|
||||||
var _ = Describe("Playlists", func() {
|
var _ = Describe("Playlists", func() {
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var ps Playlists
|
var ps Playlists
|
||||||
var mp mockedPlaylist
|
var mockPlsRepo mockedPlaylistRepo
|
||||||
|
var mockLibRepo *tests.MockLibraryRepo
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
mp = mockedPlaylist{}
|
mockPlsRepo = mockedPlaylistRepo{}
|
||||||
|
mockLibRepo = &tests.MockLibraryRepo{}
|
||||||
ds = &tests.MockDataStore{
|
ds = &tests.MockDataStore{
|
||||||
MockedPlaylist: &mp,
|
MockedPlaylist: &mockPlsRepo,
|
||||||
|
MockedLibrary: mockLibRepo,
|
||||||
}
|
}
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
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() {
|
Describe("ImportFile", func() {
|
||||||
|
@ -48,15 +53,13 @@ var _ = Describe("Playlists", func() {
|
||||||
|
|
||||||
Describe("M3U", func() {
|
Describe("M3U", func() {
|
||||||
It("parses well-formed playlists", func() {
|
It("parses well-formed playlists", func() {
|
||||||
// get absolute path for "tests/fixtures" folder
|
|
||||||
pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
|
pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(pls.OwnerID).To(Equal("123"))
|
Expect(pls.OwnerID).To(Equal("123"))
|
||||||
Expect(pls.Tracks).To(HaveLen(3))
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
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(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
||||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||||
Expect(mp.last).To(Equal(pls))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("parses playlists using LF ending", func() {
|
It("parses playlists using LF ending", func() {
|
||||||
|
@ -76,7 +79,7 @@ var _ = Describe("Playlists", func() {
|
||||||
It("parses well-formed playlists", func() {
|
It("parses well-formed playlists", func() {
|
||||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mp.last).To(Equal(pls))
|
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||||
Expect(pls.OwnerID).To(Equal("123"))
|
Expect(pls.OwnerID).To(Equal("123"))
|
||||||
Expect(pls.Name).To(Equal("Recently Played"))
|
Expect(pls.Name).To(Equal("Recently Played"))
|
||||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||||
|
@ -98,79 +101,90 @@ var _ = Describe("Playlists", func() {
|
||||||
repo = &mockedMediaFileFromListRepo{}
|
repo = &mockedMediaFileFromListRepo{}
|
||||||
ds.MockedMediaFile = repo
|
ds.MockedMediaFile = repo
|
||||||
ps = NewPlaylists(ds)
|
ps = NewPlaylists(ds)
|
||||||
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||||
})
|
})
|
||||||
|
|
||||||
It("parses well-formed playlists", func() {
|
It("parses well-formed playlists", func() {
|
||||||
repo.data = []string{
|
repo.data = []string{
|
||||||
"tests/fixtures/test.mp3",
|
"tests/test.mp3",
|
||||||
"tests/fixtures/test.ogg",
|
"tests/test.ogg",
|
||||||
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
"tests/01 Invisible (RED) Edit Version.mp3",
|
||||||
|
"downloads/newfile.flac",
|
||||||
}
|
}
|
||||||
f, _ := os.Open("tests/fixtures/playlists/pls-with-name.m3u")
|
m3u := strings.Join([]string{
|
||||||
defer f.Close()
|
"#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)
|
pls, err := ps.ImportM3U(ctx, f)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(pls.OwnerID).To(Equal("123"))
|
Expect(pls.OwnerID).To(Equal("123"))
|
||||||
Expect(pls.Name).To(Equal("playlist 1"))
|
Expect(pls.Name).To(Equal("playlist 1"))
|
||||||
Expect(pls.Sync).To(BeFalse())
|
Expect(pls.Sync).To(BeFalse())
|
||||||
Expect(pls.Tracks).To(HaveLen(3))
|
Expect(pls.Tracks).To(HaveLen(4))
|
||||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
|
||||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
||||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
||||||
Expect(mp.last).To(Equal(pls))
|
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
||||||
f.Close()
|
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||||
repo.data = []string{
|
repo.data = []string{
|
||||||
"tests/fixtures/test.mp3",
|
"tests/test.mp3",
|
||||||
"tests/fixtures/test.ogg",
|
"tests/test.ogg",
|
||||||
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
"/tests/01 Invisible (RED) Edit Version.mp3",
|
||||||
}
|
}
|
||||||
f, _ := os.Open("tests/fixtures/playlists/pls-without-name.m3u")
|
m3u := strings.Join([]string{
|
||||||
defer f.Close()
|
"/music/tests/test.mp3",
|
||||||
|
"/music/tests/test.ogg",
|
||||||
|
}, "\n")
|
||||||
|
f := strings.NewReader(m3u)
|
||||||
pls, err := ps.ImportM3U(ctx, f)
|
pls, err := ps.ImportM3U(ctx, f)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
_, err = time.Parse(time.RFC3339, pls.Name)
|
_, err = time.Parse(time.RFC3339, pls.Name)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(pls.Tracks).To(HaveLen(3))
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||||
repo.data = []string{
|
repo.data = []string{
|
||||||
"test1.mp3",
|
"album1/test1.mp3",
|
||||||
"test2.mp3",
|
"album2/test2.mp3",
|
||||||
"test3.mp3",
|
"album3/test3.mp3",
|
||||||
}
|
}
|
||||||
m3u := strings.Join([]string{
|
m3u := strings.Join([]string{
|
||||||
"test3.mp3",
|
"/music/album3/test3.mp3",
|
||||||
"test1.mp3",
|
"/music/album1/test1.mp3",
|
||||||
"test4.mp3",
|
"/music/album4/test4.mp3",
|
||||||
"test2.mp3",
|
"/music/album2/test2.mp3",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
f := strings.NewReader(m3u)
|
f := strings.NewReader(m3u)
|
||||||
pls, err := ps.ImportM3U(ctx, f)
|
pls, err := ps.ImportM3U(ctx, f)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(pls.Tracks).To(HaveLen(3))
|
Expect(pls.Tracks).To(HaveLen(3))
|
||||||
Expect(pls.Tracks[0].Path).To(Equal("test3.mp3"))
|
Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
|
||||||
Expect(pls.Tracks[1].Path).To(Equal("test1.mp3"))
|
Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
|
||||||
Expect(pls.Tracks[2].Path).To(Equal("test2.mp3"))
|
Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is case-insensitive when comparing paths", func() {
|
It("is case-insensitive when comparing paths", func() {
|
||||||
repo.data = []string{
|
repo.data = []string{
|
||||||
"tEsT1.Mp3",
|
"abc/tEsT1.Mp3",
|
||||||
}
|
}
|
||||||
m3u := strings.Join([]string{
|
m3u := strings.Join([]string{
|
||||||
"TeSt1.mP3",
|
"/music/ABC/TeSt1.mP3",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
f := strings.NewReader(m3u)
|
f := strings.NewReader(m3u)
|
||||||
pls, err := ps.ImportM3U(ctx, f)
|
pls, err := ps.ImportM3U(ctx, f)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(pls.Tracks).To(HaveLen(1))
|
Expect(pls.Tracks).To(HaveLen(1))
|
||||||
Expect(pls.Tracks[0].Path).To(Equal("tEsT1.Mp3"))
|
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -254,16 +268,16 @@ func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, e
|
||||||
return mfs, nil
|
return mfs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockedPlaylist struct {
|
type mockedPlaylistRepo struct {
|
||||||
last *model.Playlist
|
last *model.Playlist
|
||||||
model.PlaylistRepository
|
model.PlaylistRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
|
func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
|
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
||||||
r.last = pls
|
r.last = pls
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
3
tests/fixtures/playlists/pls1.m3u
vendored
3
tests/fixtures/playlists/pls1.m3u
vendored
|
@ -1,3 +1,2 @@
|
||||||
test.mp3
|
test.mp3
|
||||||
test.ogg
|
test.ogg
|
||||||
file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3
|
|
6
tests/fixtures/playlists/subfolder2/pls2.m3u
vendored
6
tests/fixtures/playlists/subfolder2/pls2.m3u
vendored
|
@ -1,2 +1,4 @@
|
||||||
test.mp3
|
../test.mp3
|
||||||
test.ogg
|
../test.ogg
|
||||||
|
/tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3
|
||||||
|
/invalid/path/xyz.mp3
|
|
@ -2,7 +2,6 @@ package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io/fs"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ func TempFileName(t testingT, prefix, suffix string) string {
|
||||||
return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix)
|
return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TempFile(t testingT, prefix, suffix string) (fs.File, string, error) {
|
func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) {
|
||||||
name := TempFileName(t, prefix, suffix)
|
name := TempFileName(t, prefix, suffix)
|
||||||
f, err := os.Create(name)
|
f, err := os.Create(name)
|
||||||
return f, name, err
|
return f, name, err
|
||||||
|
|
|
@ -140,7 +140,7 @@ var _ = Describe("Slice Utils", func() {
|
||||||
Expect(count).To(Equal(expected))
|
Expect(count).To(Equal(expected))
|
||||||
},
|
},
|
||||||
Entry("returns empty slice for an empty input", "tests/fixtures/empty.txt", 0),
|
Entry("returns empty slice for an empty input", "tests/fixtures/empty.txt", 0),
|
||||||
Entry("returns the lines of a file", "tests/fixtures/playlists/pls1.m3u", 3),
|
Entry("returns the lines of a file", "tests/fixtures/playlists/pls1.m3u", 2),
|
||||||
Entry("returns empty if file does not exist", "tests/fixtures/NON-EXISTENT", 0),
|
Entry("returns empty if file does not exist", "tests/fixtures/NON-EXISTENT", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue