mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
9 Commits
5925e8dbd8
...
52d8cf4f76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d8cf4f76 | ||
|
|
15de641bea | ||
|
|
b4b618eb0c | ||
|
|
f8c430586d | ||
|
|
b0cc0a2f40 | ||
|
|
a875606c28 | ||
|
|
7cd4a482c2 | ||
|
|
5c4df0912e | ||
|
|
9a50079978 |
@ -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 {
|
||||||
|
|||||||
@ -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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user