Compare commits

...

9 Commits

Author SHA1 Message Date
Deluan
52d8cf4f76 docs: add FindByPaths comment
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 17:34:03 -05:00
Deluan
15de641bea refactor: consolidate path resolution logic
Collapse resolveRelativePath and resolveAbsolutePath into a unified
resolvePath function, extracting common library matching logic into a
new findInLibraries helper method.

This eliminates duplicate code (~20 lines) while maintaining clear
separation of concerns: resolvePath handles path normalization
(relative vs absolute), and findInLibraries handles library matching.

Update tests to call resolvePath directly with appropriate parameters,
maintaining full test coverage for both absolute and relative path
scenarios.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 17:28:26 -05:00
Deluan
b4b618eb0c refactor: remove unnecessary path validation fallback
Remove validatePathInLibrary function and its fallback logic in
resolveRelativePath. The library matcher should always find the correct
library, including the playlist's own library. If this fails, we now
return an invalid resolution instead of attempting a fallback validation.

This simplifies the code by removing redundant validation logic that
was masking test setup issues. Also fixes test mock configuration to
properly set up library paths that match folder LibraryPath values.
2025-11-07 17:28:24 -05:00
Deluan
f8c430586d refactor
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 16:54:07 -05:00
Deluan
b0cc0a2f40 fix: improve path resolution for cross-library playlists and enhance error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 16:41:26 -05:00
Deluan
a875606c28 fix: lint
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 14:51:02 -05:00
Deluan
7cd4a482c2 refactor: simplify mocks
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 14:41:10 -05:00
Deluan
5c4df0912e fix: enhance handling of library-qualified paths and improve cross-library playlist support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 13:11:19 -05:00
Deluan
9a50079978 test: refactor tests isolation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 08:46:59 -05:00
4 changed files with 597 additions and 234 deletions

View File

@ -10,7 +10,6 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
@ -196,22 +195,31 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
} }
filteredLines = append(filteredLines, line) filteredLines = append(filteredLines, line)
} }
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines) resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil { if err != nil {
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err) log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue continue
} }
found, err := mediaFileRepository.FindByPaths(paths)
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
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
} }
// Build lookup map with library-qualified keys
existing := make(map[string]int, len(found)) existing := make(map[string]int, len(found))
for idx := range found { for idx := range found {
existing[normalizePathForComparison(found[idx].Path)] = idx // Key format: "libraryID:path" (normalized)
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, normalizePathForComparison(found[idx].Path))
existing[key] = idx
} }
for _, path := range paths { for _, path := range resolvedPaths {
idx, ok := existing[normalizePathForComparison(path)] // Parse the library-qualified path
parts := strings.SplitN(path, ":", 2)
// Path is already qualified: "libraryID:path"
normalizedPath := parts[0] + ":" + normalizePathForComparison(parts[1])
idx, ok := existing[normalizedPath]
if ok { if ok {
mfs = append(mfs, found[idx]) mfs = append(mfs, found[idx])
} else { } else {
@ -239,131 +247,146 @@ func normalizePathForComparison(path string) string {
type pathResolution struct { type pathResolution struct {
absolutePath string absolutePath string
libraryPath string libraryPath string
libraryID int
valid bool valid bool
} }
// toRelativePath converts the absolute path to a library-relative path. // ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
func (r pathResolution) toRelativePath() (string, error) { // Format: "libraryID:relativePath" with forward slashes for path separators.
func (r pathResolution) ToQualifiedString() (string, error) {
if !r.valid { if !r.valid {
return "", fmt.Errorf("invalid path resolution") return "", fmt.Errorf("invalid path resolution")
} }
return filepath.Rel(r.libraryPath, r.absolutePath) relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
if err != nil {
return "", err
}
// Convert path separators to forward slashes
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
} }
// newValidResolution creates a valid path resolution. // libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
func newValidResolution(absolutePath, libraryPath string) pathResolution { type libraryMatcher struct {
libraries model.Libraries
cleanedPaths []string
}
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i].ID, cleanLibPath
}
}
}
return 0, ""
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
// This ensures correct matching when library paths are prefixes of each other.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
// Sort libraries by path length (descending) to ensure longest paths match first.
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// Pre-clean all library paths once for efficient matching
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanedPaths[i] = filepath.Clean(lib.Path)
}
return &libraryMatcher{
libraries: libs,
cleanedPaths: cleanedPaths,
}
}
// pathResolver handles path resolution logic for playlist imports.
type pathResolver struct {
matcher *libraryMatcher
}
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
libs, err := ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil
}
// resolvePath determines the absolute path and library path for a playlist entry.
// For absolute paths, it uses them directly.
// For relative paths, it resolves them relative to the playlist's folder location.
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
//
// resolves to /music/songs/abc.mp3
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
var absolutePath string
if folder != nil && !filepath.IsAbs(line) {
// Resolve relative path to absolute path based on playlist location
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
} else {
// Use absolute path directly after cleaning
absolutePath = filepath.Clean(line)
}
return r.findInLibraries(absolutePath)
}
// findInLibraries matches an absolute path against all known libraries and returns
// a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
if libID == 0 {
return pathResolution{valid: false}
}
return pathResolution{ return pathResolution{
absolutePath: absolutePath, absolutePath: absolutePath,
libraryPath: libraryPath, libraryPath: libPath,
libraryID: libID,
valid: true, valid: true,
} }
} }
// normalizePaths converts playlist file paths to library-relative paths. // resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
// For relative paths, it resolves them to absolute paths first, then determines which // 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. // 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) { func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
libRegex, err := s.compileLibraryPaths(ctx) resolver, err := newPathResolver(ctx, s.ds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
res := make([]string, 0, len(lines)) results := make([]string, 0, len(lines))
for idx, line := range lines { for idx, line := range lines {
resolution := s.resolvePath(line, folder, libRegex) resolution := resolver.resolvePath(line, folder)
if !resolution.valid { if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx) log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
continue continue
} }
relativePath, err := resolution.toRelativePath() qualifiedPath, err := resolution.ToQualifiedString()
if err != nil { if err != nil {
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, log.Debug(ctx, "Error getting library-qualified path", "path", line,
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err) "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
continue continue
} }
res = append(res, relativePath) results = append(results, qualifiedPath)
}
return slice.Map(res, filepath.ToSlash), nil
}
// resolvePath determines the absolute path and library path for a playlist entry.
func (s *playlists) resolvePath(line string, folder *model.Folder, libRegex *regexp.Regexp) pathResolution {
if folder != nil && !filepath.IsAbs(line) {
return s.resolveRelativePath(line, folder, libRegex)
}
return s.resolveAbsolutePath(line, libRegex)
}
// resolveRelativePath handles relative paths by converting them to absolute paths
// and finding their library location. This enables cross-library playlist references.
func (s *playlists) resolveRelativePath(line string, folder *model.Folder, libRegex *regexp.Regexp) pathResolution {
// Step 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
absolutePath := filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
// Step 2: Determine which library this absolute path belongs to using regex matching
if libPath := libRegex.FindString(absolutePath); libPath != "" {
return newValidResolution(absolutePath, libPath)
} }
// Fallback: Check if it's in the playlist's own library return results, nil
return s.validatePathInLibrary(absolutePath, folder.LibraryPath)
}
// resolveAbsolutePath handles absolute paths by matching them against library paths.
func (s *playlists) resolveAbsolutePath(line string, libRegex *regexp.Regexp) pathResolution {
cleanPath := filepath.Clean(line)
libPath := libRegex.FindString(cleanPath)
if libPath == "" {
return pathResolution{valid: false}
}
return newValidResolution(cleanPath, libPath)
}
// validatePathInLibrary verifies that an absolute path belongs to the specified library.
// It rejects paths that escape the library using ".." segments.
func (s *playlists) validatePathInLibrary(absolutePath, libraryPath string) pathResolution {
rel, err := filepath.Rel(libraryPath, absolutePath)
if err != nil || strings.HasPrefix(rel, "..") {
return pathResolution{valid: false}
}
return newValidResolution(absolutePath, libraryPath)
}
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
// Sort libraries by path length (descending) to ensure longest paths match first.
// This prevents shorter paths that are prefixes from incorrectly matching.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// 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 - order matters due to alternation
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 {

View File

@ -7,12 +7,12 @@ import (
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"golang.org/x/text/unicode/norm"
) )
var _ = Describe("compileLibraryPaths", func() { var _ = Describe("libraryMatcher", func() {
var ds *tests.MockDataStore var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo var mockLibRepo *tests.MockLibraryRepo
var ps *playlists
ctx := context.Background() ctx := context.Background()
BeforeEach(func() { BeforeEach(func() {
@ -20,9 +20,15 @@ var _ = Describe("compileLibraryPaths", func() {
ds = &tests.MockDataStore{ ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo, MockedLibrary: mockLibRepo,
} }
ps = &playlists{ds: ds}
}) })
// Helper function to create a libraryMatcher from the mock datastore
createMatcher := func(ds model.DataStore) *libraryMatcher {
libs, err := ds.Library(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
return newLibraryMatcher(libs)
}
Describe("Longest library path matching", func() { Describe("Longest library path matching", func() {
It("matches the longest library path when multiple libraries share a prefix", func() { It("matches the longest library path when multiple libraries share a prefix", func() {
// Setup libraries with prefix conflicts // Setup libraries with prefix conflicts
@ -32,25 +38,24 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 3, Path: "/music-classical/opera"}, {ID: 3, Path: "/music-classical/opera"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred())
// Test that longest path matches first // Test that longest path matches first and returns correct library ID
// Note: The regex pattern ^path(?:/|$) will match the path plus the trailing /
testCases := []struct { testCases := []struct {
path string path string
expected string expectedLibID int
expectedLibPath string
}{ }{
{"/music-classical/opera/track.mp3", "/music-classical/opera/"}, {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
{"/music-classical/track.mp3", "/music-classical/"}, {"/music-classical/track.mp3", 2, "/music-classical"},
{"/music/track.mp3", "/music/"}, {"/music/track.mp3", 1, "/music"},
{"/music-classical/opera/", "/music-classical/opera/"}, // Trailing slash {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
{"/music-classical/opera", "/music-classical/opera"}, // Exact match (no trailing /)
} }
for _, tc := range testCases { for _, tc := range testCases {
matched := libRegex.FindString(tc.path) libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(matched).To(Equal(tc.expected), "Path %s should match %s, but got %s", tc.path, tc.expected, matched) Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
} }
}) })
@ -60,16 +65,17 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 2, Path: "/home/user/music-backup"}, {ID: 2, Path: "/home/user/music-backup"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred())
// Test that music-backup library is matched correctly // Test that music-backup library is matched correctly
matched := libRegex.FindString("/home/user/music-backup/track.mp3") libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
Expect(matched).To(Equal("/home/user/music-backup/")) Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/home/user/music-backup"))
// Test that music library is still matched correctly // Test that music library is still matched correctly
matched = libRegex.FindString("/home/user/music/track.mp3") libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
Expect(matched).To(Equal("/home/user/music/")) Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/home/user/music"))
}) })
It("matches path that is exactly the library root", func() { It("matches path that is exactly the library root", func() {
@ -78,12 +84,12 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 2, Path: "/music-classical"}, {ID: 2, Path: "/music-classical"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred())
// Exact library path should match (no trailing /) // Exact library path should match
matched := libRegex.FindString("/music-classical") libID, libPath := matcher.findLibraryForPath("/music-classical")
Expect(matched).To(Equal("/music-classical")) Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/music-classical"))
}) })
It("handles complex nested library structures", func() { It("handles complex nested library structures", func() {
@ -94,22 +100,23 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 4, Path: "/media/audio/classical/baroque"}, {ID: 4, Path: "/media/audio/classical/baroque"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred())
testCases := []struct { testCases := []struct {
path string path string
expected string expectedLibID int
expectedLibPath string
}{ }{
{"/media/audio/classical/baroque/bach/track.mp3", "/media/audio/classical/baroque/"}, {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
{"/media/audio/classical/mozart/track.mp3", "/media/audio/classical/"}, {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
{"/media/audio/rock/track.mp3", "/media/audio/"}, {"/media/audio/rock/track.mp3", 2, "/media/audio"},
{"/media/video/movie.mp4", "/media/"}, {"/media/video/movie.mp4", 1, "/media"},
} }
for _, tc := range testCases { for _, tc := range testCases {
matched := libRegex.FindString(tc.path) libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(matched).To(Equal(tc.expected), "Path %s should match %s, but got %s", tc.path, tc.expected, matched) Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
} }
}) })
}) })
@ -118,13 +125,13 @@ var _ = Describe("compileLibraryPaths", func() {
It("handles empty library list", func() { It("handles empty library list", func() {
mockLibRepo.SetData([]model.Library{}) mockLibRepo.SetData([]model.Library{})
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred()) Expect(matcher).ToNot(BeNil())
Expect(libRegex).ToNot(BeNil())
// Should not match anything // Should not match anything
matched := libRegex.FindString("/music/track.mp3") libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(matched).To(BeEmpty()) Expect(libID).To(Equal(0))
Expect(libPath).To(BeEmpty())
}) })
It("handles single library", func() { It("handles single library", func() {
@ -132,55 +139,294 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 1, Path: "/music"}, {ID: 1, Path: "/music"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred())
matched := libRegex.FindString("/music/track.mp3") libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(matched).To(Equal("/music/")) Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music"))
}) })
It("handles libraries with special regex characters", func() { It("handles libraries with special characters in paths", func() {
mockLibRepo.SetData([]model.Library{ mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music[test]"}, {ID: 1, Path: "/music[test]"},
{ID: 2, Path: "/music(backup)"}, {ID: 2, Path: "/music(backup)"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred()) Expect(matcher).ToNot(BeNil())
Expect(libRegex).ToNot(BeNil())
// Special characters should be escaped and match literally // Special characters should match literally
matched := libRegex.FindString("/music[test]/track.mp3") libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
Expect(matched).To(Equal("/music[test]/")) Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music[test]"))
}) })
}) })
Describe("Regex pattern validation", func() { Describe("Path matching order", func() {
It("ensures regex alternation respects order by testing actual matching behavior", func() { It("ensures longest paths match first", func() {
mockLibRepo.SetData([]model.Library{ mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/a"}, {ID: 1, Path: "/a"},
{ID: 2, Path: "/ab"}, {ID: 2, Path: "/ab"},
{ID: 3, Path: "/abc"}, {ID: 3, Path: "/abc"},
}) })
libRegex, err := ps.compileLibraryPaths(ctx) matcher := createMatcher(ds)
Expect(err).ToNot(HaveOccurred())
// Verify that longer paths match correctly (not cut off by shorter prefix) // Verify that longer paths match correctly (not cut off by shorter prefix)
// If ordering is wrong, /ab would match before /abc for path "/abc/file"
testCases := []struct { testCases := []struct {
path string path string
expected string expectedLibID int
}{ }{
{"/abc/file.mp3", "/abc/"}, {"/abc/file.mp3", 3},
{"/ab/file.mp3", "/ab/"}, {"/ab/file.mp3", 2},
{"/a/file.mp3", "/a/"}, {"/a/file.mp3", 1},
} }
for _, tc := range testCases { for _, tc := range testCases {
matched := libRegex.FindString(tc.path) libID, _ := matcher.findLibraryForPath(tc.path)
Expect(matched).To(Equal(tc.expected), "Path %s should match %s", tc.path, tc.expected) Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
} }
}) })
}) })
}) })
var _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
var resolver *pathResolver
ctx := context.Background()
BeforeEach(func() {
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
// Setup test libraries
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/podcasts"},
})
var err error
resolver, err = newPathResolver(ctx, ds)
Expect(err).ToNot(HaveOccurred())
})
Describe("resolvePath", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
})
It("resolves path to the longest matching library", func() {
resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid resolution for path outside libraries", func() {
resolution := resolver.resolvePath("/videos/movie.mp4", nil)
Expect(resolution.valid).To(BeFalse())
})
It("cleans the path before matching", func() {
resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
})
})
Context("With relative paths", func() {
It("resolves relative path within same library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../songs/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
})
It("resolves relative path to different library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Path goes up and into a different library
resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3))
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("uses matcher to find correct library for resolved path", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// This relative path resolves to music-classical library
resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid for relative paths escaping all libraries", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../../../../etc/passwd", folder)
Expect(resolution.valid).To(BeFalse())
})
})
})
Describe("Cross-library resolution scenarios", func() {
It("handles playlist in library A referencing file in library B", func() {
// Playlist is in /music/playlists
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Relative path that goes to /podcasts library
resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("prefers longer library paths when resolving", func() {
// Ensure /music-classical is matched instead of /music
resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
})
})
})
var _ = Describe("pathResolution", func() {
Describe("ToQualifiedString", func() {
It("converts valid resolution to qualified string with forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 1,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
})
It("handles Windows-style paths by converting to forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 2,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
// Should always use forward slashes regardless of OS
Expect(qualifiedStr).To(ContainSubstring("2:"))
Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
})
It("returns error for invalid resolution", func() {
resolution := pathResolution{valid: false}
_, err := resolution.ToQualifiedString()
Expect(err).To(HaveOccurred())
})
})
})
var _ = 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"))
})
})

View File

@ -1,4 +1,4 @@
package core package core_test
import ( import (
"context" "context"
@ -9,18 +9,18 @@ import (
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"golang.org/x/text/unicode/norm"
) )
var _ = Describe("Playlists", func() { var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore var ds *tests.MockDataStore
var ps Playlists var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background() ctx := context.Background()
@ -33,16 +33,16 @@ var _ = Describe("Playlists", func() {
MockedLibrary: mockLibRepo, 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() {
var folder *model.Folder var folder *model.Folder
BeforeEach(func() { BeforeEach(func() {
ps = NewPlaylists(ds) ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{} ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd() libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{ folder = &model.Folder{
ID: "1", ID: "1",
LibraryID: 1, LibraryID: 1,
@ -138,7 +138,7 @@ var _ = Describe("Playlists", func() {
"def.mp3", // This is playlists/def.mp3 relative to plsDir "def.mp3", // This is playlists/def.mp3 relative to plsDir
}, },
} }
ps = NewPlaylists(ds) ps = core.NewPlaylists(ds)
}) })
It("handles relative paths that reference files in other libraries", func() { It("handles relative paths that reference files in other libraries", func() {
@ -264,6 +264,71 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!) Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
}) })
It("correctly handles identical relative paths from different libraries", func() {
// This tests the bug where two libraries have files at the same relative path
// and only one appears in the playlist
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())
// Create placeholder files so paths resolve correctly
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
// Both libraries have a file at "album/track.mp3"
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir},
{ID: 2, Path: classicalDir},
})
// Mock returns files with same relative path but different IDs and library IDs
// Keys use the library-qualified format: "libraryID:path"
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"},
},
}
// Recreate playlists service to pick up new mock
ps = core.NewPlaylists(ds)
// Create playlist in music library that references both tracks
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.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should have BOTH tracks, not just one
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
// Verify we got tracks from DIFFERENT libraries (the key fix!)
// Collect the library IDs
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")
// Both tracks should have the same relative path
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
})
}) })
}) })
@ -272,7 +337,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() { BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{} repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo ds.MockedMediaFile = repo
ps = NewPlaylists(ds) ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) 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"})
}) })
@ -359,53 +424,6 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3")) 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() { Describe("InPlaylistsPath", func() {
@ -422,27 +440,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() { It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = "" conf.Server.PlaylistsPath = ""
Expect(InPlaylistsPath(folder)).To(BeTrue()) Expect(core.InPlaylistsPath(folder)).To(BeTrue())
}) })
It("returns true if PlaylistsPath is any (**/**)", func() { It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**" conf.Server.PlaylistsPath = "**/**"
Expect(InPlaylistsPath(folder)).To(BeTrue()) Expect(core.InPlaylistsPath(folder)).To(BeTrue())
}) })
It("returns true if folder is in PlaylistsPath", func() { It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**" conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(InPlaylistsPath(folder)).To(BeTrue()) Expect(core.InPlaylistsPath(folder)).To(BeTrue())
}) })
It("returns false if folder is not in PlaylistsPath", func() { It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other" conf.Server.PlaylistsPath = "other"
Expect(InPlaylistsPath(folder)).To(BeFalse()) Expect(core.InPlaylistsPath(folder)).To(BeFalse())
}) })
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() { It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "." conf.Server.PlaylistsPath = "."
Expect(InPlaylistsPath(folder)).To(BeFalse()) Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{ folder2 := model.Folder{
LibraryPath: "/music", LibraryPath: "/music",
@ -450,22 +468,47 @@ var _ = Describe("Playlists", func() {
Name: ".", Name: ".",
} }
Expect(InPlaylistsPath(folder2)).To(BeTrue()) Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
}) })
}) })
}) })
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input // mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
// If data map is provided, looks up files by key; otherwise creates them from paths.
type mockedMediaFileRepo struct { type mockedMediaFileRepo struct {
model.MediaFileRepository model.MediaFileRepository
data map[string]model.MediaFile
} }
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) { func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles var mfs model.MediaFiles
// If data map provided, look up files
if r.data != nil {
for _, path := range paths {
if mf, ok := r.data[path]; ok {
mfs = append(mfs, mf)
}
}
return mfs, nil
}
// Otherwise, create MediaFiles from paths
for idx, path := range paths { for idx, path := range paths {
// Strip library qualifier if present (format: "libraryID:path")
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{ mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx), ID: strconv.Itoa(idx),
Path: path, Path: actualPath,
LibraryID: libraryID,
}) })
} }
return mfs, nil return mfs, nil
@ -477,13 +520,31 @@ type mockedMediaFileFromListRepo struct {
data []string data []string
} }
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) { func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles var mfs model.MediaFiles
for idx, path := range r.data {
mfs = append(mfs, model.MediaFile{ for idx, dataPath := range r.data {
ID: strconv.Itoa(idx), for _, requestPath := range paths {
Path: path, // Strip library qualifier if present (format: "libraryID:path")
}) 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]
}
}
// Case-insensitive comparison (like SQL's "collate nocase")
if strings.EqualFold(actualPath, dataPath) {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: dataPath,
LibraryID: libraryID,
})
break
}
}
} }
return mfs, nil return mfs, nil
} }

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"slices" "slices"
"strconv"
"strings"
"sync" "sync"
"time" "time"
@ -192,12 +194,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
}, nil }, nil
} }
// FindByPaths finds media files by their paths.
// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
// Library-qualified paths search within the specified library, while unqualified paths
// search across all libraries for backward compatibility.
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths}) query := Or{}
for _, path := range paths {
parts := strings.SplitN(path, ":", 2)
if len(parts) == 2 {
// Library-qualified path: "libraryID:path"
libraryID, err := strconv.Atoi(parts[0])
if err != nil {
// Invalid format, skip
continue
}
relativePath := parts[1]
query = append(query, And{
Eq{"path collate nocase": relativePath},
Eq{"library_id": libraryID},
})
} else {
// Unqualified path: search across all libraries
query = append(query, Eq{"path collate nocase": path})
}
}
if len(query) == 0 {
return model.MediaFiles{}, nil
}
sel := r.newSelect().Columns("*").Where(query)
var res dbMediaFiles var res dbMediaFiles
if err := r.queryAll(sel, &res); err != nil { if err := r.queryAll(sel, &res); err != nil {
return nil, err return nil, err
} }
return res.toModels(), nil return res.toModels(), nil
} }