mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
459 lines
14 KiB
Go
459 lines
14 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("validatePathInLibrary", func() {
|
|
It("validates path within library", func() {
|
|
resolution := resolver.validatePathInLibrary("/music/artist/track.mp3", "/music", 1)
|
|
|
|
Expect(resolution.valid).To(BeTrue())
|
|
Expect(resolution.libraryID).To(Equal(1))
|
|
Expect(resolution.libraryPath).To(Equal("/music"))
|
|
})
|
|
|
|
It("rejects path that escapes library using ..", func() {
|
|
resolution := resolver.validatePathInLibrary("/music/../etc/passwd", "/music", 1)
|
|
|
|
Expect(resolution.valid).To(BeFalse())
|
|
})
|
|
|
|
It("rejects path outside library", func() {
|
|
resolution := resolver.validatePathInLibrary("/podcasts/track.mp3", "/music", 1)
|
|
|
|
Expect(resolution.valid).To(BeFalse())
|
|
})
|
|
|
|
It("accepts path exactly at library root", func() {
|
|
resolution := resolver.validatePathInLibrary("/music", "/music", 1)
|
|
|
|
Expect(resolution.valid).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
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"))
|
|
})
|
|
})
|