Merge branch 'master' into feat/add-squiddies-glass-theme

This commit is contained in:
Xavier Araque 2025-11-04 20:09:33 +01:00 committed by GitHub
commit ccc5bc5663
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 241 additions and 25 deletions

View File

@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/ioutils"
)
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
contents, err := os.ReadFile(externalLyric)
contents, err := ioutils.UTF8ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
return nil, nil

View File

@ -108,5 +108,39 @@ var _ = Describe("sources", func() {
},
}))
})
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
// The function looks for <basePath-without-ext><suffix>, so we need to pass
// a MediaFile with .mp3 path and look for .lrc suffix
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics).To(HaveLen(1))
// The critical assertion: even with BOM, synced should be true
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(1))
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
})
It("should handle UTF-16 LE encoded LRC files", func() {
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics).To(HaveLen(1))
// UTF-16 should be properly converted to UTF-8
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(2))
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
})
})
})

View File

@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@ -97,12 +98,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
err = s.parseNSP(ctx, pls, file)
err = s.parseNSP(ctx, pls, reader)
default:
err = s.parseM3U(ctx, pls, folder, file)
err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}

View File

@ -74,6 +74,24 @@ var _ = Describe("Playlists", func() {
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists with UTF-8 BOM marker", func() {
pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Test Playlist"))
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
})
It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
})
})
Describe("NSP", func() {

View File

@ -400,23 +400,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
// This now calculates per-library statistics and stores them in library_artist.stats
batchUpdateStatsSQL := `
WITH artist_role_counters AS (
SELECT jt.atom AS artist_id,
SELECT mfa.artist_id,
mf.library_id,
substr(
replace(jt.path, '$.', ''),
1,
CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
THEN instr(replace(jt.path, '$.', ''), '[') - 1
ELSE length(replace(jt.path, '$.', ''))
END
) AS role,
mfa.role,
count(DISTINCT mf.album_id) AS album_count,
count(mf.id) AS count,
count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
FROM media_file mf
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
GROUP BY jt.atom, mf.library_id, role
FROM media_file_artists mfa
JOIN media_file mf ON mfa.media_file_id = mf.id
WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
GROUP BY mfa.artist_id, mf.library_id, mfa.role
),
artist_total_counters AS (
SELECT mfa.artist_id,
@ -445,24 +438,24 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
),
combined_counters AS (
SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
UNION
UNION ALL
SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
UNION
UNION ALL
SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
),
library_artist_counters AS (
SELECT artist_id,
library_id,
json_group_object(
replace(role, '"', ''),
role,
json_object('a', album_count, 'm', count, 's', size)
) AS counters
FROM combined_counters
GROUP BY artist_id, library_id
)
UPDATE library_artist
SET stats = coalesce((SELECT counters FROM library_artist_counters lac
WHERE lac.artist_id = library_artist.artist_id
SET stats = coalesce((SELECT counters FROM library_artist_counters lac
WHERE lac.artist_id = library_artist.artist_id
AND lac.library_id = library_artist.library_id), '{}')
WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders

4
tests/fixtures/bom-test.lrc vendored Normal file
View File

@ -0,0 +1,4 @@
[00:00.00] 作曲 : 柏大輔
NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0.
This tests BOM handling in lyrics parsing (GitHub issue #4631).
The BOM bytes are: 0xEF 0xBB 0xBF

BIN
tests/fixtures/bom-utf16-test.lrc vendored Normal file

Binary file not shown.

Binary file not shown.

6
tests/fixtures/playlists/bom-test.m3u vendored Normal file
View File

@ -0,0 +1,6 @@
#EXTM3U
# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning
# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing.
#PLAYLIST:Test Playlist
#EXTINF:123,Test Artist - Test Song
test.mp3

View File

@ -127,6 +127,7 @@ const Player = () => {
/>
),
locale: locale(translate),
sortableOptions: { delay: 200, delayOnTouchOnly: true },
}),
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
)

View File

@ -450,13 +450,21 @@ export default {
},
RaPaginationActions: {
button: {
backgroundColor: 'inherit',
backgroundColor: '#fff',
color: '#000',
minWidth: 48,
margin: '0 4px',
border: '1px solid #282828',
border: '1px solid #cccccc',
'@global': {
'> .MuiButton-label': {
padding: 0,
color: '#656565',
'&:hover': {
color: '#fff !important',
},
},
'> .MuiButton-label > svg': {
color: '#656565',
},
},
},

33
utils/ioutils/ioutils.go Normal file
View File

@ -0,0 +1,33 @@
package ioutils
import (
"io"
"os"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly.
// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8.
// This is particularly useful for reading user-provided text files (like LRC lyrics,
// playlists) that may have been created on Windows, which often adds BOM markers.
//
// Reference: https://en.wikipedia.org/wiki/Byte_order_mark
func UTF8Reader(r io.Reader) io.Reader {
return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
}
// UTF8ReadFile reads the named file and returns its contents as a byte slice,
// automatically handling BOM markers. It's similar to os.ReadFile but strips
// UTF-8 BOM and converts UTF-16 encoded files to UTF-8.
func UTF8ReadFile(filename string) ([]byte, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
reader := UTF8Reader(file)
return io.ReadAll(reader)
}

View File

@ -0,0 +1,117 @@
package ioutils
import (
"bytes"
"io"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestIOUtils(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "IO Utils Suite")
}
var _ = Describe("UTF8Reader", func() {
Context("when reading text with UTF-8 BOM", func() {
It("strips the UTF-8 BOM marker", func() {
// UTF-8 BOM is EF BB BF
input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}
reader := UTF8Reader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(output)).To(Equal("hello"))
})
It("strips UTF-8 BOM from multi-line text", func() {
// Test with the actual LRC file format
input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'}
reader := UTF8Reader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(output)).To(Equal("[00:00.00] test"))
})
})
Context("when reading text without BOM", func() {
It("passes through unchanged", func() {
input := []byte("hello world")
reader := UTF8Reader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(output)).To(Equal("hello world"))
})
})
Context("when reading UTF-16 LE encoded text", func() {
It("converts to UTF-8 and strips BOM", func() {
// UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE
input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00}
reader := UTF8Reader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(output)).To(Equal("hi"))
})
})
Context("when reading UTF-16 BE encoded text", func() {
It("converts to UTF-8 and strips BOM", func() {
// UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE
input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'}
reader := UTF8Reader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(output)).To(Equal("hi"))
})
})
Context("when reading empty content", func() {
It("returns empty string", func() {
reader := UTF8Reader(bytes.NewReader([]byte{}))
output, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(output)).To(Equal(""))
})
})
})
var _ = Describe("UTF8ReadFile", func() {
Context("when reading a file with UTF-8 BOM", func() {
It("strips the BOM marker", func() {
// Use the actual fixture from issue #4631
contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc")
Expect(err).ToNot(HaveOccurred())
// Should NOT start with BOM
Expect(contents[0]).ToNot(Equal(byte(0xEF)))
// Should start with '['
Expect(contents[0]).To(Equal(byte('[')))
Expect(string(contents)).To(HavePrefix("[00:00.00]"))
})
})
Context("when reading a file without BOM", func() {
It("reads the file normally", func() {
contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc")
Expect(err).ToNot(HaveOccurred())
// Should contain the expected content
Expect(string(contents)).To(ContainSubstring("We're no strangers to love"))
})
})
Context("when reading a non-existent file", func() {
It("returns an error", func() {
_, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc")
Expect(err).To(HaveOccurred())
})
})
})