fix: enhance playlist path normalization for cross-library support

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-11-06 21:15:38 -05:00
parent f158190ea4
commit 544f912c10
2 changed files with 74 additions and 8 deletions

View File

@ -233,7 +233,9 @@ func normalizePathForComparison(path string) string {
return strings.ToLower(norm.NFC.String(path))
}
// TODO This won't work for multiple libraries
// normalizePaths converts playlist file paths to library-relative paths.
// For relative paths, it resolves them to absolute paths first, then determines which
// library they belong to. This allows playlists to reference files across library boundaries.
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 {
@ -246,15 +248,21 @@ func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, fol
var filePath string
if folder != nil && !filepath.IsAbs(line) {
// For relative paths, resolve them to absolute first
// Two-step resolution for relative paths:
// 1. Resolve relative path to absolute path based on playlist location
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
// resolves to /music/songs/abc.mp3
filePath = filepath.Join(folder.AbsolutePath(), line)
filePath = filepath.Clean(filePath)
// Try to find which library this resolved path belongs to
// 2. Determine which library this absolute path belongs to using regex matching
// This allows playlists to reference files in different libraries
if libPath = libRegex.FindString(filePath); libPath == "" {
// If not found in regex, fallback to the playlist's library
// If regex doesn't match any library, check if it's in the playlist's library
libPath = folder.LibraryPath
// Verify the file is actually in a library by checking if we can get a relative path
if _, err := filepath.Rel(libPath, filePath); err != nil {
// Verify the file is actually in this library (reject paths with ..)
rel, err := filepath.Rel(libPath, filePath)
if err != nil || strings.HasPrefix(rel, "..") {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
continue
}

View File

@ -117,7 +117,7 @@ var _ = Describe("Playlists", func() {
var tmpDir, plsDir, songsDir string
BeforeEach(func() {
// Create temp directory structure once
// Create temp directory structure
tmpDir = GinkgoT().TempDir()
plsDir = tmpDir + "/playlists"
songsDir = tmpDir + "/songs"
@ -160,8 +160,66 @@ var _ = Describe("Playlists", func() {
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"))
Expect(pls.Tracks[1].Path).To(Equal("def.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
})
})
})