mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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.
431 lines
13 KiB
Go
431 lines
13 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
var _ = Describe("libraryMatcher", func() {
|
|
var ds *tests.MockDataStore
|
|
var mockLibRepo *tests.MockLibraryRepo
|
|
ctx := context.Background()
|
|
|
|
BeforeEach(func() {
|
|
mockLibRepo = &tests.MockLibraryRepo{}
|
|
ds = &tests.MockDataStore{
|
|
MockedLibrary: mockLibRepo,
|
|
}
|
|
})
|
|
|
|
// 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() {
|
|
It("matches the longest library path when multiple libraries share a prefix", func() {
|
|
// Setup libraries with prefix conflicts
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/music"},
|
|
{ID: 2, Path: "/music-classical"},
|
|
{ID: 3, Path: "/music-classical/opera"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
|
|
// Test that longest path matches first and returns correct library ID
|
|
testCases := []struct {
|
|
path string
|
|
expectedLibID int
|
|
expectedLibPath string
|
|
}{
|
|
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
|
|
{"/music-classical/track.mp3", 2, "/music-classical"},
|
|
{"/music/track.mp3", 1, "/music"},
|
|
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
libID, libPath := matcher.findLibraryForPath(tc.path)
|
|
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)
|
|
}
|
|
})
|
|
|
|
It("handles libraries with similar prefixes but different structures", func() {
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/home/user/music"},
|
|
{ID: 2, Path: "/home/user/music-backup"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
|
|
// Test that music-backup library is matched correctly
|
|
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
|
|
Expect(libID).To(Equal(2))
|
|
Expect(libPath).To(Equal("/home/user/music-backup"))
|
|
|
|
// Test that music library is still matched correctly
|
|
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
|
|
Expect(libID).To(Equal(1))
|
|
Expect(libPath).To(Equal("/home/user/music"))
|
|
})
|
|
|
|
It("matches path that is exactly the library root", func() {
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/music"},
|
|
{ID: 2, Path: "/music-classical"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
|
|
// Exact library path should match
|
|
libID, libPath := matcher.findLibraryForPath("/music-classical")
|
|
Expect(libID).To(Equal(2))
|
|
Expect(libPath).To(Equal("/music-classical"))
|
|
})
|
|
|
|
It("handles complex nested library structures", func() {
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/media"},
|
|
{ID: 2, Path: "/media/audio"},
|
|
{ID: 3, Path: "/media/audio/classical"},
|
|
{ID: 4, Path: "/media/audio/classical/baroque"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
|
|
testCases := []struct {
|
|
path string
|
|
expectedLibID int
|
|
expectedLibPath string
|
|
}{
|
|
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
|
|
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
|
|
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
|
|
{"/media/video/movie.mp4", 1, "/media"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
libID, libPath := matcher.findLibraryForPath(tc.path)
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("Edge cases", func() {
|
|
It("handles empty library list", func() {
|
|
mockLibRepo.SetData([]model.Library{})
|
|
|
|
matcher := createMatcher(ds)
|
|
Expect(matcher).ToNot(BeNil())
|
|
|
|
// Should not match anything
|
|
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
|
Expect(libID).To(Equal(0))
|
|
Expect(libPath).To(BeEmpty())
|
|
})
|
|
|
|
It("handles single library", func() {
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/music"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
|
|
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
|
Expect(libID).To(Equal(1))
|
|
Expect(libPath).To(Equal("/music"))
|
|
})
|
|
|
|
It("handles libraries with special characters in paths", func() {
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/music[test]"},
|
|
{ID: 2, Path: "/music(backup)"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
Expect(matcher).ToNot(BeNil())
|
|
|
|
// Special characters should match literally
|
|
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
|
|
Expect(libID).To(Equal(1))
|
|
Expect(libPath).To(Equal("/music[test]"))
|
|
})
|
|
})
|
|
|
|
Describe("Path matching order", func() {
|
|
It("ensures longest paths match first", func() {
|
|
mockLibRepo.SetData([]model.Library{
|
|
{ID: 1, Path: "/a"},
|
|
{ID: 2, Path: "/ab"},
|
|
{ID: 3, Path: "/abc"},
|
|
})
|
|
|
|
matcher := createMatcher(ds)
|
|
|
|
// Verify that longer paths match correctly (not cut off by shorter prefix)
|
|
testCases := []struct {
|
|
path string
|
|
expectedLibID int
|
|
}{
|
|
{"/abc/file.mp3", 3},
|
|
{"/ab/file.mp3", 2},
|
|
{"/a/file.mp3", 1},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
libID, _ := matcher.findLibraryForPath(tc.path)
|
|
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("resolveAbsolutePath", func() {
|
|
It("resolves path within a library", func() {
|
|
resolution := resolver.resolveAbsolutePath("/music/track.mp3")
|
|
|
|
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.resolveAbsolutePath("/music-classical/track.mp3")
|
|
|
|
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.resolveAbsolutePath("/videos/movie.mp4")
|
|
|
|
Expect(resolution.valid).To(BeFalse())
|
|
})
|
|
|
|
It("cleans the path before matching", func() {
|
|
resolution := resolver.resolveAbsolutePath("/music//artist/../artist/track.mp3")
|
|
|
|
Expect(resolution.valid).To(BeTrue())
|
|
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
|
|
})
|
|
})
|
|
|
|
Describe("resolveRelativePath", func() {
|
|
It("resolves relative path within same library", func() {
|
|
folder := &model.Folder{
|
|
Path: "playlists",
|
|
LibraryPath: "/music",
|
|
LibraryID: 1,
|
|
}
|
|
|
|
resolution := resolver.resolveRelativePath("../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.resolveRelativePath("../../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.resolveRelativePath("../../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.resolveRelativePath("../../../../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"))
|
|
})
|
|
})
|