fix(playlists): better M3U paths matching across different UTF representations (#4890)

* fix: improve playlist path normalization for cross-platform compatibility

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log normalized path when playlist path is not found

Signed-off-by: Deluan <deluan@navidrome.org>

* test: enhance Unicode normalization tests for playlist paths

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: enhance playlist path normalization for cross-platform compatibility

See https://github.com/navidrome/navidrome/pull/4789#issuecomment-3645724780

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: improve playlist path normalization to handle fullwidth characters and enhance cross-platform compatibility

Signed-off-by: Deluan <deluan@navidrome.org>

* formatting

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: adjust chunk size for M3U parsing to optimize SQLite expression tree depth

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-01-24 12:47:43 -05:00
committed by GitHub
parent c6c1c16923
commit b455546fdf
3 changed files with 275 additions and 34 deletions
+88
View File
@@ -561,4 +561,92 @@ var _ = Describe("MediaRepository", func() {
})
})
})
Describe("FindByPaths", func() {
// Test fixtures for Unicode and case-sensitivity tests
var testFiles []model.MediaFile
BeforeEach(func() {
testFiles = []model.MediaFile{
{ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
{ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
// Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
{ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"},
// French diacritic: è (U+00E8, can decompose to e + combining grave)
{ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"},
}
for _, mf := range testFiles {
Expect(mr.Put(&mf)).To(Succeed())
}
})
AfterEach(func() {
for _, mf := range testFiles {
_ = mr.Delete(mf.ID)
}
})
It("finds files by exact path", func() {
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("findpath-1"))
})
It("finds files case-insensitively for ASCII characters (NOCASE)", func() {
// SQLite's COLLATE NOCASE handles ASCII case-insensitivity
results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("findpath-1"))
})
It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() {
// SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence
// The DB has fullwidth uppercase ACROSS, searching with exact match should work
results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("findpath-3"))
// Searching with fullwidth lowercase across should NOT match
// (this is the SQLite limitation that requires exact matching for non-ASCII)
results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("returns multiple files when querying multiple paths", func() {
results, err := mr.FindByPaths([]string{
"1:artist/Album/track.mp3",
"1:artist/Album/UPPER.mp3",
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
})
It("returns empty slice for non-existent paths", func() {
results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("returns empty slice for empty input", func() {
results, err := mr.FindByPaths([]string{})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("handles library-qualified paths correctly", func() {
// Library 1 should find the file
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
// Library 2 should NOT find it (file is in library 1)
results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
})
})