From 348f700a3b409b3f623cc7ee90a2d8ee1b553e28 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 17 Nov 2025 20:15:52 -0500 Subject: [PATCH] fix: enhance Unicode normalization for path comparisons in playlists. Fixes 4663 Signed-off-by: Deluan --- core/playlists.go | 29 +++++++++++++---------------- core/playlists_internal_test.go | 26 -------------------------- core/playlists_test.go | 29 +++++++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 44 deletions(-) diff --git a/core/playlists.go b/core/playlists.go index 3cd8f3d18..ed90cc23b 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -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 diff --git a/core/playlists_internal_test.go b/core/playlists_internal_test.go index e5abb225d..88e36cc3a 100644 --- a/core/playlists_internal_test.go +++ b/core/playlists_internal_test.go @@ -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")) - }) -}) diff --git a/core/playlists_test.go b/core/playlists_test.go index 59b027690..6aa8aac9a 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -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