diff --git a/core/playlists.go b/core/playlists.go index ca8a4add7..e26a1259a 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -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 } diff --git a/core/playlists_test.go b/core/playlists_test.go index 376166531..9d16d9ccd 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -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 }) }) })