mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
fix: enhance Unicode normalization for path comparisons in playlists. Fixes 4663
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
d327f81fe0
commit
348f700a3b
@ -201,25 +201,29 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
|
||||
// See https://github.com/navidrome/navidrome/issues/4663
|
||||
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
|
||||
return strings.ToLower(norm.NFD.String(path))
|
||||
})
|
||||
|
||||
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
// Build lookup map with library-qualified keys
|
||||
// Build lookup map with library-qualified keys, normalized for comparison
|
||||
existing := make(map[string]int, len(found))
|
||||
for idx := range found {
|
||||
// Key format: "libraryID:path" (normalized)
|
||||
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, normalizePathForComparison(found[idx].Path))
|
||||
// Normalize to lowercase for case-insensitive comparison
|
||||
// Key format: "libraryID:path"
|
||||
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
|
||||
existing[key] = idx
|
||||
}
|
||||
for _, path := range resolvedPaths {
|
||||
// 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]
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
for _, path := range resolvedPaths {
|
||||
idx, ok := existing[path]
|
||||
if ok {
|
||||
mfs = append(mfs, found[idx])
|
||||
} else {
|
||||
@ -236,13 +240,6 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
|
||||
// for consistent comparison. This fixes Unicode normalization issues on macOS where
|
||||
// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
|
||||
func normalizePathForComparison(path string) string {
|
||||
return strings.ToLower(norm.NFC.String(path))
|
||||
}
|
||||
|
||||
// pathResolution holds the result of resolving a playlist path to a library-relative path.
|
||||
type pathResolution struct {
|
||||
absolutePath string
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("libraryMatcher", func() {
|
||||
@ -405,28 +404,3 @@ var _ = Describe("pathResolution", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
@ -424,6 +425,23 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||
})
|
||||
|
||||
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
|
||||
// Simulate macOS filesystem: stores paths in NFD (decomposed) form
|
||||
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
|
||||
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
|
||||
repo.data = []string{nfdPath}
|
||||
|
||||
// Simulate Apple Music M3U: uses NFC (composed) form
|
||||
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
|
||||
m3u := nfcPath + "\n"
|
||||
f := strings.NewReader(m3u)
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
// Should match despite different Unicode normalization forms
|
||||
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("InPlaylistsPath", func() {
|
||||
@ -524,6 +542,9 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
var mfs model.MediaFiles
|
||||
|
||||
for idx, dataPath := range r.data {
|
||||
// Normalize the data path to NFD (simulates macOS filesystem storage)
|
||||
normalizedDataPath := norm.NFD.String(dataPath)
|
||||
|
||||
for _, requestPath := range paths {
|
||||
// Strip library qualifier if present (format: "libraryID:path")
|
||||
actualPath := requestPath
|
||||
@ -535,11 +556,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
}
|
||||
}
|
||||
|
||||
// The request path should already be normalized to NFD by production code
|
||||
// before calling FindByPaths (to match DB storage)
|
||||
normalizedRequestPath := norm.NFD.String(actualPath)
|
||||
|
||||
// Case-insensitive comparison (like SQL's "collate nocase")
|
||||
if strings.EqualFold(actualPath, dataPath) {
|
||||
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: dataPath,
|
||||
Path: dataPath, // Return original path from DB
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
break
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user