diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index c7f7dd94..56c80a95 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -9,10 +9,10 @@ import ( "os" "path/filepath" - "github.com/RaveNoX/go-jsoncommentstrip" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/utils/jsoncommentstrip" ) func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { diff --git a/utils/jsoncommentstrip/jsoncommentstrip.go b/utils/jsoncommentstrip/jsoncommentstrip.go new file mode 100644 index 00000000..54ddd88f --- /dev/null +++ b/utils/jsoncommentstrip/jsoncommentstrip.go @@ -0,0 +1,128 @@ +// Package jsoncommentstrip provides an io.Reader that strips JavaScript-style +// comments (// line and /* block */) from JSON input while preserving +// comment-like sequences inside JSON string values. +package jsoncommentstrip + +import ( + "bufio" + "io" +) + +type state int + +const ( + stateNormal state = iota + stateInString + stateInStringEscape + stateMaybeComment // saw '/' + stateLineComment // inside // ... + stateBlockComment // inside /* ... */ + stateMaybeBlockEnd // saw '*' inside block comment +) + +type reader struct { + r *bufio.Reader + state state +} + +// NewReader returns an io.Reader that strips JSON comments from the +// underlying reader. It removes single-line comments (// to end of line) +// and block comments (/* ... */), while preserving comment-like sequences +// that appear inside JSON string values. +func NewReader(r io.Reader) io.Reader { + return &reader{ + r: bufio.NewReader(r), + state: stateNormal, + } +} + +func (cr *reader) Read(p []byte) (int, error) { + n := 0 + for n < len(p) { + b, err := cr.r.ReadByte() + if err != nil { + if cr.state == stateMaybeComment { + // Emit the pending '/' before returning EOF + p[n] = '/' + n++ + cr.state = stateNormal + } + return n, err + } + + switch cr.state { + case stateNormal: + switch b { + case '"': + p[n] = b + n++ + cr.state = stateInString + case '/': + cr.state = stateMaybeComment + default: + p[n] = b + n++ + } + + case stateInString: + p[n] = b + n++ + switch b { + case '\\': + cr.state = stateInStringEscape + case '"': + cr.state = stateNormal + } + + case stateInStringEscape: + p[n] = b + n++ + cr.state = stateInString + + case stateMaybeComment: + switch b { + case '/': + cr.state = stateLineComment + case '*': + cr.state = stateBlockComment + default: + // The '/' was not a comment start; emit it and the current byte + p[n] = '/' + n++ + if n < len(p) { + p[n] = b + n++ + } else { + // We need to "unread" the current byte since buffer is full + _ = cr.r.UnreadByte() + } + cr.state = stateNormal + } + + case stateLineComment: + if b == '\n' || b == '\r' { + p[n] = b + n++ + cr.state = stateNormal + } + // Otherwise, consume and discard + + case stateBlockComment: + if b == '*' { + cr.state = stateMaybeBlockEnd + } + // Otherwise, consume and discard + + case stateMaybeBlockEnd: + if b == '/' { + cr.state = stateNormal + } else if b == '*' { + // Stay in stateMaybeBlockEnd (consecutive *'s) + cr.state = stateMaybeBlockEnd + } else { + cr.state = stateBlockComment + } + } + } + return n, nil +} diff --git a/utils/jsoncommentstrip/jsoncommentstrip_test.go b/utils/jsoncommentstrip/jsoncommentstrip_test.go new file mode 100644 index 00000000..21e4bd0b --- /dev/null +++ b/utils/jsoncommentstrip/jsoncommentstrip_test.go @@ -0,0 +1,164 @@ +package jsoncommentstrip_test + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/navidrome/navidrome/utils/jsoncommentstrip" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJsonCommentStrip(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "JsonCommentStrip Suite") +} + +var _ = Describe("NewReader", func() { + read := func(input string) string { + r := jsoncommentstrip.NewReader(strings.NewReader(input)) + out, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return string(out) + } + + // compact returns the compacted JSON form of s, for readable comparisons. + compact := func(s string) string { + var buf bytes.Buffer + ExpectWithOffset(1, json.Compact(&buf, []byte(s))).To(Succeed()) + return buf.String() + } + + It("passes through JSON without comments unchanged", func() { + input := `{"key": "value", "num": 42}` + Expect(read(input)).To(Equal(input)) + }) + + It("strips single-line comments", func() { + input := `{ + // this is a comment + "key": "value" + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "key": "value" + }`))) + }) + + It("strips single-line comments at end of line", func() { + input := `{ + "key": "value" // inline comment + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "key": "value" + }`))) + }) + + It("strips block comments", func() { + input := `{/* comment */"key": "value"}` + Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) + }) + + It("strips multi-line block comments", func() { + input := `{ + /* this is + a multi-line + comment */ + "key": "value" + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "key": "value" + }`))) + }) + + It("preserves // inside JSON strings", func() { + input := `{"key": "value // not a comment"}` + Expect(read(input)).To(Equal(input)) + }) + + It("preserves /* inside JSON strings", func() { + input := `{"key": "value /* not a comment */"}` + Expect(read(input)).To(Equal(input)) + }) + + It("handles escaped quotes in strings", func() { + input := `{"key": "val\"ue // not a comment"}` + Expect(read(input)).To(Equal(input)) + }) + + It("handles / at end of input as literal", func() { + input := `{"key": "value"}/` + Expect(read(input)).To(Equal(input)) + }) + + It("handles * inside block comment not followed by /", func() { + input := `{/* a * b */"key": "value"}` + Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) + }) + + It("handles empty input", func() { + Expect(read("")).To(Equal("")) + }) + + It("handles mixed comments with real content", func() { + input := `{ + // line comment + "name": "test", /* inline block */ + /* multi + line */ + "value": "hello // world", + "other": 123 // trailing + }` + Expect(compact(read(input))).To(Equal(compact(`{ + "name": "test", + "value": "hello // world", + "other": 123 + }`))) + }) + + It("handles consecutive slashes that are not comments", func() { + input := `{"path": "/a/b"}` + Expect(read(input)).To(Equal(input)) + }) + + It("handles block comment at end of input", func() { + input := `{"key": "value"}/* comment */` + Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) + }) + + It("strips comment with windows-style line endings", func() { + input := "{\r\n// comment\r\n\"key\": \"value\"\r\n}" + Expect(compact(read(input))).To(Equal(compact(`{"key": "value"}`))) + }) + + It("strips line comments with mixed line endings", func() { + // From original library: // comments with both \n and \r\n, including multiple on same line + input := "{\n\"one\": 1, // test //\n\"two\": 2, //test //\r\n\"string\": \"value\"\n//test\n}" + expected := "{\n\"one\": 1, \n\"two\": 2, \r\n\"string\": \"value\"\n\n}" + Expect(read(input)).To(Equal(expected)) + }) + + It("strips line comment at start of JSON", func() { + // From original library: // comment as first thing in JSON + input := "{// woot\n\"one\": 1, // test //\n\"two\": 2, //test //\r\n\"string\": \"value\"\n//test\n}" + expected := "{\n\"one\": 1, \n\"two\": 2, \r\n\"string\": \"value\"\n\n}" + Expect(read(input)).To(Equal(expected)) + }) + + It("strips block comments with mixed line endings inside", func() { + // From original library: block comment containing \r\n + input := "{/* multi\nline\r\ncomment */\"one\":1}" + expected := "{\"one\":1}" + Expect(read(input)).To(Equal(expected)) + }) + + It("handles complex mix of escaped quotes, comments, and strings", func() { + // From original library TestQuotationEscape: escaped quote inside string followed by + // comment-like chars, then real comments of both types + input := "{/* multi\nline\r\ncomment */\"one\": \"a value \\\" // /*woot\"/* m\nl *///woot\r\n}" + expected := "{\"one\": \"a value \\\" // /*woot\"\r\n}" + Expect(read(input)).To(Equal(expected)) + }) +})