Compare commits

..

No commits in common. "52d8cf4f760fd0f87e5b239a99ea1ea72fec9e90" and "5925e8dbd8b1f423a3e3e6114f489459d4e6c67a" have entirely different histories.

4 changed files with 234 additions and 597 deletions

View File

@ -10,6 +10,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
@ -195,31 +196,22 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
} }
filteredLines = append(filteredLines, line) filteredLines = append(filteredLines, line)
} }
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines) paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
if err != nil { if err != nil {
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err) log.Warn(ctx, "Error normalizing 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 {
// Key format: "libraryID:path" (normalized) existing[normalizePathForComparison(found[idx].Path)] = idx
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, normalizePathForComparison(found[idx].Path))
existing[key] = idx
} }
for _, path := range resolvedPaths { for _, path := range paths {
// Parse the library-qualified path idx, ok := existing[normalizePathForComparison(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 {
@ -247,146 +239,131 @@ 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
} }
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes. // toRelativePath converts the absolute path to a library-relative path.
// Format: "libraryID:relativePath" with forward slashes for path separators. func (r pathResolution) toRelativePath() (string, error) {
func (r pathResolution) ToQualifiedString() (string, error) {
if !r.valid { if !r.valid {
return "", fmt.Errorf("invalid path resolution") return "", fmt.Errorf("invalid path resolution")
} }
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath) return 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
} }
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching. // newValidResolution creates a valid path resolution.
type libraryMatcher struct { func newValidResolution(absolutePath, libraryPath string) pathResolution {
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: libPath, libraryPath: libraryPath,
libraryID: libID,
valid: true, valid: true,
} }
} }
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath"). // normalizePaths converts playlist file paths to library-relative paths.
// 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) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) { func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
resolver, err := newPathResolver(ctx, s.ds) libRegex, err := s.compileLibraryPaths(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
results := make([]string, 0, len(lines)) res := make([]string, 0, len(lines))
for idx, line := range lines { for idx, line := range lines {
resolution := resolver.resolvePath(line, folder) resolution := s.resolvePath(line, folder, libRegex)
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
} }
qualifiedPath, err := resolution.ToQualifiedString() relativePath, err := resolution.toRelativePath()
if err != nil { if err != nil {
log.Debug(ctx, "Error getting library-qualified path", "path", line, log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line,
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err) "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
continue continue
} }
results = append(results, qualifiedPath) res = append(res, relativePath)
}
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)
} }
return results, nil // Fallback: Check if it's in the playlist's own library
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("libraryMatcher", func() { var _ = Describe("compileLibraryPaths", 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,15 +20,9 @@ var _ = Describe("libraryMatcher", 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
@ -38,24 +32,25 @@ var _ = Describe("libraryMatcher", func() {
{ID: 3, Path: "/music-classical/opera"}, {ID: 3, Path: "/music-classical/opera"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
// Test that longest path matches first and returns correct library ID // Test that longest path matches first
// Note: The regex pattern ^path(?:/|$) will match the path plus the trailing /
testCases := []struct { testCases := []struct {
path string path string
expectedLibID int expected string
expectedLibPath string
}{ }{
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"}, {"/music-classical/opera/track.mp3", "/music-classical/opera/"},
{"/music-classical/track.mp3", 2, "/music-classical"}, {"/music-classical/track.mp3", "/music-classical/"},
{"/music/track.mp3", 1, "/music"}, {"/music/track.mp3", "/music/"},
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"}, {"/music-classical/opera/", "/music-classical/opera/"}, // Trailing slash
{"/music-classical/opera", "/music-classical/opera"}, // Exact match (no trailing /)
} }
for _, tc := range testCases { for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path) matched := libRegex.FindString(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID) Expect(matched).To(Equal(tc.expected), "Path %s should match %s, but got %s", tc.path, tc.expected, matched)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
} }
}) })
@ -65,17 +60,16 @@ var _ = Describe("libraryMatcher", func() {
{ID: 2, Path: "/home/user/music-backup"}, {ID: 2, Path: "/home/user/music-backup"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
// Test that music-backup library is matched correctly // Test that music-backup library is matched correctly
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3") matched := libRegex.FindString("/home/user/music-backup/track.mp3")
Expect(libID).To(Equal(2)) Expect(matched).To(Equal("/home/user/music-backup/"))
Expect(libPath).To(Equal("/home/user/music-backup"))
// Test that music library is still matched correctly // Test that music library is still matched correctly
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3") matched = libRegex.FindString("/home/user/music/track.mp3")
Expect(libID).To(Equal(1)) Expect(matched).To(Equal("/home/user/music/"))
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() {
@ -84,12 +78,12 @@ var _ = Describe("libraryMatcher", func() {
{ID: 2, Path: "/music-classical"}, {ID: 2, Path: "/music-classical"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
// Exact library path should match // Exact library path should match (no trailing /)
libID, libPath := matcher.findLibraryForPath("/music-classical") matched := libRegex.FindString("/music-classical")
Expect(libID).To(Equal(2)) Expect(matched).To(Equal("/music-classical"))
Expect(libPath).To(Equal("/music-classical"))
}) })
It("handles complex nested library structures", func() { It("handles complex nested library structures", func() {
@ -100,23 +94,22 @@ var _ = Describe("libraryMatcher", func() {
{ID: 4, Path: "/media/audio/classical/baroque"}, {ID: 4, Path: "/media/audio/classical/baroque"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
testCases := []struct { testCases := []struct {
path string path string
expectedLibID int expected string
expectedLibPath string
}{ }{
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"}, {"/media/audio/classical/baroque/bach/track.mp3", "/media/audio/classical/baroque/"},
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"}, {"/media/audio/classical/mozart/track.mp3", "/media/audio/classical/"},
{"/media/audio/rock/track.mp3", 2, "/media/audio"}, {"/media/audio/rock/track.mp3", "/media/audio/"},
{"/media/video/movie.mp4", 1, "/media"}, {"/media/video/movie.mp4", "/media/"},
} }
for _, tc := range testCases { for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path) matched := libRegex.FindString(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) Expect(matched).To(Equal(tc.expected), "Path %s should match %s, but got %s", tc.path, tc.expected, matched)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
} }
}) })
}) })
@ -125,13 +118,13 @@ var _ = Describe("libraryMatcher", func() {
It("handles empty library list", func() { It("handles empty library list", func() {
mockLibRepo.SetData([]model.Library{}) mockLibRepo.SetData([]model.Library{})
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(matcher).ToNot(BeNil()) Expect(err).ToNot(HaveOccurred())
Expect(libRegex).ToNot(BeNil())
// Should not match anything // Should not match anything
libID, libPath := matcher.findLibraryForPath("/music/track.mp3") matched := libRegex.FindString("/music/track.mp3")
Expect(libID).To(Equal(0)) Expect(matched).To(BeEmpty())
Expect(libPath).To(BeEmpty())
}) })
It("handles single library", func() { It("handles single library", func() {
@ -139,294 +132,55 @@ var _ = Describe("libraryMatcher", func() {
{ID: 1, Path: "/music"}, {ID: 1, Path: "/music"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
libID, libPath := matcher.findLibraryForPath("/music/track.mp3") matched := libRegex.FindString("/music/track.mp3")
Expect(libID).To(Equal(1)) Expect(matched).To(Equal("/music/"))
Expect(libPath).To(Equal("/music"))
}) })
It("handles libraries with special characters in paths", func() { It("handles libraries with special regex characters", 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)"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
Expect(matcher).ToNot(BeNil()) Expect(err).ToNot(HaveOccurred())
Expect(libRegex).ToNot(BeNil())
// Special characters should match literally // Special characters should be escaped and match literally
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3") matched := libRegex.FindString("/music[test]/track.mp3")
Expect(libID).To(Equal(1)) Expect(matched).To(Equal("/music[test]/"))
Expect(libPath).To(Equal("/music[test]"))
}) })
}) })
Describe("Path matching order", func() { Describe("Regex pattern validation", func() {
It("ensures longest paths match first", func() { It("ensures regex alternation respects order by testing actual matching behavior", 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"},
}) })
matcher := createMatcher(ds) libRegex, err := ps.compileLibraryPaths(ctx)
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
expectedLibID int expected string
}{ }{
{"/abc/file.mp3", 3}, {"/abc/file.mp3", "/abc/"},
{"/ab/file.mp3", 2}, {"/ab/file.mp3", "/ab/"},
{"/a/file.mp3", 1}, {"/a/file.mp3", "/a/"},
} }
for _, tc := range testCases { for _, tc := range testCases {
libID, _ := matcher.findLibraryForPath(tc.path) matched := libRegex.FindString(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) Expect(matched).To(Equal(tc.expected), "Path %s should match %s", tc.path, tc.expected)
} }
}) })
}) })
}) })
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_test package core
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 core.Playlists var ps 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 = core.NewPlaylists(ds) ps = 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 = core.NewPlaylists(ds) ps = 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,71 +264,6 @@ 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"))
})
}) })
}) })
@ -337,7 +272,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() { BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{} repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo ds.MockedMediaFile = repo
ps = core.NewPlaylists(ds) ps = 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"})
}) })
@ -424,6 +359,53 @@ 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() {
@ -440,27 +422,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(core.InPlaylistsPath(folder)).To(BeTrue()) Expect(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(core.InPlaylistsPath(folder)).To(BeTrue()) Expect(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(core.InPlaylistsPath(folder)).To(BeTrue()) Expect(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(core.InPlaylistsPath(folder)).To(BeFalse()) Expect(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(core.InPlaylistsPath(folder)).To(BeFalse()) Expect(InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{ folder2 := model.Folder{
LibraryPath: "/music", LibraryPath: "/music",
@ -468,47 +450,22 @@ var _ = Describe("Playlists", func() {
Name: ".", Name: ".",
} }
Expect(core.InPlaylistsPath(folder2)).To(BeTrue()) Expect(InPlaylistsPath(folder2)).To(BeTrue())
}) })
}) })
}) })
// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths. // mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
// 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: actualPath, Path: path,
LibraryID: libraryID,
}) })
} }
return mfs, nil return mfs, nil
@ -520,31 +477,13 @@ type mockedMediaFileFromListRepo struct {
data []string data []string
} }
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) { func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
var mfs model.MediaFiles var mfs model.MediaFiles
for idx, path := range r.data {
for idx, dataPath := range r.data { mfs = append(mfs, model.MediaFile{
for _, requestPath := range paths { ID: strconv.Itoa(idx),
// Strip library qualifier if present (format: "libraryID:path") Path: 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,8 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"slices" "slices"
"strconv"
"strings"
"sync" "sync"
"time" "time"
@ -194,43 +192,12 @@ 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) {
query := Or{} sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
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
} }