diff --git a/.dockerignore b/.dockerignore index 596aa2955..eb012c6e2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,4 +15,5 @@ dist binaries cache music +music.old !Dockerfile \ No newline at end of file diff --git a/.gitignore b/.gitignore index 27c02da32..db8c0abcf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ cache/* coverage.out dist music +music.old *.db* .gitinfo docker-compose.yml diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 6dc635b50..b3e89a9dc 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -31,6 +31,12 @@ var ignoredContent = []string{ `Read more on Last\.fm\.?`) + +func cleanContent(content string) string { + return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, "")) +} + type lastfmAgent struct { ds model.DataStore sessionKeys *agents.SessionKeys @@ -95,7 +101,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin resp.MBID = a.MBID resp.URL = a.URL if isValidContent(a.Description.Summary) { - resp.Description = strings.TrimSpace(a.Description.Summary) + resp.Description = cleanContent(a.Description.Summary) return &resp, nil } log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang) @@ -171,7 +177,7 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str return "", err } if isValidContent(a.Bio.Summary) { - return strings.TrimSpace(a.Bio.Summary), nil + return cleanContent(a.Bio.Summary), nil } log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang) } diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index bdc0a0e33..94788b8bd 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -80,7 +80,7 @@ var _ = Describe("lastfmAgent", func() { It("returns the biography", func() { f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. Read more on Last.fm")) + Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente.")) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) @@ -535,7 +535,7 @@ var _ = Describe("lastfmAgent", func() { Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{ Name: "Believe", MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", - Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Read more on Last.fm.", + Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob", URL: "https://www.last.fm/music/Cher/Believe", })) Expect(httpClient.RequestCount).To(Equal(1)) diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 1372d0034..00ddcb2a6 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -250,7 +250,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model id3Base := parseID3Pairs(name, lowered) if len(aliasValues) > 0 { - id3Base = append(id3Base, parseVorbisPairs(aliasValues)...) + // For lyrics, don't use parseVorbisPairs as parentheses in lyrics content + // should not be interpreted as language keys (e.g. "(intro)" is not a language) + if name == model.TagLyrics { + for _, v := range aliasValues { + id3Base = append(id3Base, NewPair("xxx", v)) + } + } else { + id3Base = append(id3Base, parseVorbisPairs(aliasValues)...) + } } return id3Base } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 82afd8657..663e306c4 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() { metadata.NewPair("eng", "Lyrics"), )) }) + + It("should preserve lyrics starting with parentheses from alias tags", func() { + props.Tags = model.RawTags{ + "LYRICS": {"(line one)\nline two\nline three"}, + } + md = metadata.New(filePath, props) + + Expect(md.All()).To(HaveKey(model.TagLyrics)) + Expect(md.Strings(model.TagLyrics)).To(ContainElements( + metadata.NewPair("xxx", "(line one)\nline two\nline three"), + )) + }) }) Describe("ReplayGain", func() { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 3fdd19af2..8bdcea8ed 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -285,13 +285,16 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } // Update when the playlist was last refreshed (for cache purposes) - updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID}) + now := time.Now() + updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) _, err = r.executeSQL(updSql) if err != nil { log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) return false } + pls.EvaluatedAt = &now + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) return true diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 05a36352f..232eb14b4 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -4,6 +4,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -160,14 +161,23 @@ var _ = Describe("PlaylistRepository", func() { }) }) - // TODO Validate these tests - XContext("child smart playlists", func() { - When("refresh day has expired", func() { + Context("child smart playlists", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + When("refresh delay has expired", func() { It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules} + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.All{ @@ -175,45 +185,69 @@ var _ = Describe("PlaylistRepository", func() { }, }} Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + // Nested playlist has not been evaluated yet nestedPlsRead, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) - _, err = repo.GetWithTracks(parentPls.ID, true, false) + // Getting parent with refresh should recursively refresh the nested playlist + pls, err := repo.GetWithTracks(parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) + Expect(pls.EvaluatedAt).ToNot(BeNil()) + Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - // Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get + // Parent should have tracks from the nested playlist + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + + // Nested playlist should now have been refreshed (EvaluatedAt set) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) - - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt)) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) }) }) - When("refresh day has not expired", func() { + When("refresh delay has not expired", func() { It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + childEvaluatedAt := time.Now().Add(-30 * time.Minute) - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules} + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + // Parent has no EvaluatedAt, so it WILL refresh, but the child should not parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.All{ criteria.InPlaylist{"id": nestedPls.ID}, }, }} Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) nestedPlsRead, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) - _, err = repo.GetWithTracks(parentPls.ID, true, false) + // Getting parent with refresh should NOT recursively refresh the nested playlist + parent, err := repo.GetWithTracks(parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) - // Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get + // Parent should have been refreshed (its EvaluatedAt was nil) + Expect(parent.EvaluatedAt).ToNot(BeNil()) + Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Nested playlist should NOT have been refreshed (still within delay window) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) - + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) }) }) diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index db500c1fc..13ebe14a4 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -282,6 +282,9 @@ type ServiceB interface { Entry("option pattern (value, exists bool)", "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), + + Entry("raw=true binary response", + "raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"), ) It("generates compilable client code for comprehensive service", func() { diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 4a189ddd2..ed15f174b 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -264,6 +264,96 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) + It("should generate binary framing for raw=true methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should include encoding/binary import for raw methods + Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) + + // Should NOT generate a response type for raw methods + Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct")) + + // Should generate request type (request is still JSON) + Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct")) + + // Should build binary frame [0x00][4-byte CT len][CT][data] + Expect(codeStr).To(ContainSubstring("frame[0] = 0x00")) + Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32")) + + // Should have writeRawError helper + Expect(codeStr).To(ContainSubstring("streamWriteRawError")) + + // Should use writeRawError instead of writeError for raw methods + Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack")) + }) + + It("should generate both writeError and writeRawError for mixed services", func() { + svc := Service{ + Name: "API", + Permission: "api", + Interface: "APIService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + { + Name: "CallRaw", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should have both helpers + Expect(codeStr).To(ContainSubstring("apiWriteResponse")) + Expect(codeStr).To(ContainSubstring("apiWriteError")) + Expect(codeStr).To(ContainSubstring("apiWriteRawError")) + + // Should generate response type for non-raw method only + Expect(codeStr).To(ContainSubstring("type APICallResponse struct")) + Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct")) + }) + It("should always include json import for JSON protocol", func() { // All services use JSON protocol, so json import is always needed svc := Service{ @@ -626,6 +716,72 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) }) + + It("should generate binary frame parsing for raw methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + Doc: "GetStream returns raw binary stream data.", + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should import Tuple and struct for raw methods + Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple")) + Expect(codeStr).To(ContainSubstring("import struct")) + + // Should return Tuple[str, bytes] + Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:")) + + // Should parse binary frame instead of JSON + Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()")) + Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) + Expect(codeStr).To(ContainSubstring("struct.unpack")) + Expect(codeStr).To(ContainSubstring("return content_type, data")) + + // Should NOT use json.loads for response + Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))")) + }) + + It("should not import Tuple or struct for non-raw services", func() { + svc := Service{ + Name: "Test", + Permission: "test", + Interface: "TestService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + Expect(codeStr).NotTo(ContainSubstring("Tuple")) + Expect(codeStr).NotTo(ContainSubstring("import struct")) + }) }) Describe("GenerateGoDoc", func() { @@ -782,6 +938,47 @@ var _ = Describe("Generator", func() { // Check for PDK import Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk")) }) + + It("should include encoding/binary import for raw methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + }, + }, + } + + code, err := GenerateClientGo(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should include encoding/binary for raw binary frame parsing + Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) + + // Should NOT generate response type struct for raw methods + Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct")) + + // Should still generate request type + Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct")) + + // Should parse binary frame + Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01")) + Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32")) + + // Should return (string, []byte, error) + Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)")) + }) }) Describe("GenerateClientGoStub", func() { @@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring("Result")) Expect(codeStr).NotTo(ContainSubstring("Option")) }) + + It("should generate raw extern C import and binary frame parsing for raw methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + Doc: "GetStream returns raw binary stream data.", + }, + }, + } + + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost" + Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`)) + Expect(codeStr).To(ContainSubstring(`extern "C"`)) + Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64")) + + // Should NOT generate response type for raw methods + Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse")) + + // Should generate request type (request is still JSON) + Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest")) + + // Should return Result<(String, Vec), Error> + Expect(codeStr).To(ContainSubstring("Result<(String, Vec), Error>")) + + // Should parse binary frame + Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) + Expect(codeStr).To(ContainSubstring("u32::from_be_bytes")) + Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy")) + }) }) }) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d4..c2d571779 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri m := Method{ Name: name, ExportName: annotation["name"], + Raw: annotation["raw"] == "true", Doc: doc, } @@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri } } + // Validate raw=true methods: must return exactly (string, []byte, error) + if m.Raw { + if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" { + return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name) + } + } + return m, nil } diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f43578397..f2bdbeded 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -122,6 +122,119 @@ type TestService interface { Expect(services[0].Methods[0].Name).To(Equal("Exported")) }) + It("should parse raw=true annotation", func() { + src := `package host + +import "context" + +//nd:hostservice name=Stream permission=stream +type StreamService interface { + //nd:hostfunc raw=true + GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + m := services[0].Methods[0] + Expect(m.Name).To(Equal("GetStream")) + Expect(m.Raw).To(BeTrue()) + Expect(m.HasError).To(BeTrue()) + Expect(m.Returns).To(HaveLen(2)) + Expect(m.Returns[0].Name).To(Equal("contentType")) + Expect(m.Returns[0].Type).To(Equal("string")) + Expect(m.Returns[1].Name).To(Equal("data")) + Expect(m.Returns[1].Type).To(Equal("[]byte")) + }) + + It("should set Raw=false when raw annotation is absent", func() { + src := `package host + +import "context" + +//nd:hostservice name=Test permission=test +type TestService interface { + //nd:hostfunc + Call(ctx context.Context, uri string) (response string, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services[0].Methods[0].Raw).To(BeFalse()) + }) + + It("should reject raw=true with invalid return signature", func() { + src := `package host + +import "context" + +//nd:hostservice name=Test permission=test +type TestService interface { + //nd:hostfunc raw=true + BadRaw(ctx context.Context, uri string) (result string, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + _, err = ParseDirectory(tmpDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("raw=true")) + Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)")) + }) + + It("should reject raw=true without error return", func() { + src := `package host + +import "context" + +//nd:hostservice name=Test permission=test +type TestService interface { + //nd:hostfunc raw=true + BadRaw(ctx context.Context, uri string) (contentType string, data []byte) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + _, err = ParseDirectory(tmpDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("raw=true")) + }) + + It("should parse mixed raw and non-raw methods", func() { + src := `package host + +import "context" + +//nd:hostservice name=API permission=api +type APIService interface { + //nd:hostfunc + Call(ctx context.Context, uri string) (responseJSON string, err error) + + //nd:hostfunc raw=true + CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + Expect(services[0].Methods).To(HaveLen(2)) + Expect(services[0].Methods[0].Raw).To(BeFalse()) + Expect(services[0].Methods[1].Raw).To(BeTrue()) + Expect(services[0].HasRawMethods()).To(BeTrue()) + }) + It("should handle custom export name", func() { src := `package host diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee04446..971ae394a 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -8,6 +8,9 @@ package {{.Package}} import ( +{{- if .Service.HasRawMethods}} + "encoding/binary" +{{- end}} "encoding/json" {{- if .Service.HasErrors}} "errors" @@ -49,7 +52,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if not .IsErrorOnly}} +{{- if and (not .IsErrorOnly) (not .Raw)}} type {{responseType .}} struct { {{- range .Returns}} @@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{ // Read the response from memory responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() -{{- if .IsErrorOnly}} +{{- if .Raw}} + + // Parse binary-framed response + if len(responseBytes) == 0 { + return "", nil, errors.New("empty response from host") + } + if responseBytes[0] == 0x01 { // error + return "", nil, errors.New(string(responseBytes[1:])) + } + if responseBytes[0] != 0x00 { + return "", nil, errors.New("unknown response status") + } + if len(responseBytes) < 5 { + return "", nil, errors.New("malformed raw response: incomplete header") + } + ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) + if uint32(len(responseBytes)) < 5+ctLen { + return "", nil, errors.New("malformed raw response: content-type overflow") + } + return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil +{{- else if .IsErrorOnly}} // Parse error-only response var response struct { diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl index 99c5be51b..84bff4abe 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl @@ -8,10 +8,13 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any +from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}} import extism import json +{{- if .Service.HasRawMethods}} +import struct +{{- end}} class HostFunctionError(Exception): @@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int: {{- end}} {{- /* Generate dataclasses for multi-value returns */ -}} {{range .Service.Methods}} -{{- if .NeedsResultClass}} +{{- if and .NeedsResultClass (not .Raw)}} @dataclass @@ -44,7 +47,7 @@ class {{pythonResultType .}}: {{range .Service.Methods}} -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: +def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{.PythonName}}: {{.PythonType}} parameter. {{- end}} {{- end}} -{{- if .HasReturns}} +{{- if .Raw}} + + Returns: + Tuple of (content_type, data) with the raw binary response. +{{- else if .HasReturns}} Returns: {{- if .NeedsResultClass}} @@ -79,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam request_mem = extism.memory.alloc(request_bytes) response_offset = _{{exportName .}}(request_mem.offset) response_mem = extism.memory.find(response_offset) +{{- if .Raw}} + response_bytes = response_mem.bytes() + + if len(response_bytes) == 0: + raise HostFunctionError("empty response from host") + if response_bytes[0] == 0x01: + raise HostFunctionError(response_bytes[1:].decode("utf-8")) + if response_bytes[0] != 0x00: + raise HostFunctionError("unknown response status") + if len(response_bytes) < 5: + raise HostFunctionError("malformed raw response: incomplete header") + ct_len = struct.unpack(">I", response_bytes[1:5])[0] + if len(response_bytes) < 5 + ct_len: + raise HostFunctionError("malformed raw response: content-type overflow") + content_type = response_bytes[5:5 + ct_len].decode("utf-8") + data = response_bytes[5 + ct_len:] + return content_type, data +{{- else}} response = json.loads(extism.memory.string(response_mem)) {{if .HasError}} if response.get("error"): @@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) {{- end}} {{- end}} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index 6dea8098e..2fb368bf1 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -33,6 +33,7 @@ struct {{requestType .}} { {{- end}} } {{- end}} +{{- if not .Raw}} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -47,16 +48,92 @@ struct {{responseType .}} { {{- end}} } {{- end}} +{{- end}} #[host_fn] extern "ExtismHost" { {{- range .Service.Methods}} +{{- if not .Raw}} fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>; {{- end}} +{{- end}} } +{{- /* Declare raw extern "C" imports for raw methods */ -}} +{{- range .Service.Methods}} +{{- if .Raw}} + +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + fn {{exportName .}}(offset: u64) -> u64; +} +{{- end}} +{{- end}} {{- /* Generate wrapper functions */ -}} {{range .Service.Methods}} +{{- if .Raw}} + +{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} +{{- if .HasParams}} +/// +/// # Arguments +{{- range .Params}} +/// * `{{.RustName}}` - {{rustType .}} parameter. +{{- end}} +{{- end}} +/// +/// # Returns +/// A tuple of (content_type, data) with the raw binary response. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec), Error> { +{{- if .HasParams}} + let req = {{requestType .}} { +{{- range .Params}} + {{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}}, +{{- end}} + }; + let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; +{{- else}} + let input_bytes = b"{}".to_vec(); +{{- end}} + let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; + + let response_offset = unsafe { {{exportName .}}(input_mem.offset()) }; + + let response_mem = Memory::find(response_offset) + .ok_or_else(|| Error::msg("empty response from host"))?; + let response_bytes = response_mem.to_vec(); + + if response_bytes.is_empty() { + return Err(Error::msg("empty response from host")); + } + if response_bytes[0] == 0x01 { + let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); + return Err(Error::msg(msg)); + } + if response_bytes[0] != 0x00 { + return Err(Error::msg("unknown response status")); + } + if response_bytes.len() < 5 { + return Err(Error::msg("malformed raw response: incomplete header")); + } + let ct_len = u32::from_be_bytes([ + response_bytes[1], + response_bytes[2], + response_bytes[3], + response_bytes[4], + ]) as usize; + if ct_len > response_bytes.len() - 5 { + return Err(Error::msg("malformed raw response: content-type overflow")); + } + let ct_end = 5 + ct_len; + let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); + let data = response_bytes[ct_end..].to_vec(); + Ok((content_type, data)) +} +{{- else}} {{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName } {{- end}} {{- end}} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index d10c01ee4..12dd20475 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -4,6 +4,9 @@ package {{.Package}} import ( "context" +{{- if .Service.HasRawMethods}} + "encoding/binary" +{{- end}} "encoding/json" extism "github.com/extism/go-sdk" @@ -20,6 +23,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} +{{- if not .Raw}} // {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}. type {{responseType .}} struct { @@ -30,6 +34,7 @@ type {{responseType .}} struct { Error string `json:"error,omitempty"` {{- end}} } +{{- end}} {{end}} // Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions. @@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) if err != nil { +{{- if .Raw}} + {{$.Service.Name | lower}}WriteRawError(p, stack, err) +{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) +{{- end}} return } var req {{requestType .}} if err := json.Unmarshal(reqBytes, &req); err != nil { +{{- if .Raw}} + {{$.Service.Name | lower}}WriteRawError(p, stack, err) +{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) +{{- end}} return } {{- end}} // Call the service method -{{- if .HasReturns}} +{{- if .Raw}} + {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) + if svcErr != nil { + {{$.Service.Name | lower}}WriteRawError(p, stack, svcErr) + return + } + + // Write binary-framed response to plugin memory: + // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] + ctBytes := []byte({{lower (index .Returns 0).Name}}) + frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}})) + frame[0] = 0x00 // success + binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) + copy(frame[5:5+len(ctBytes)], ctBytes) + copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}}) + + respPtr, err := p.WriteBytes(frame) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +{{- else if .HasReturns}} {{- if .HasError}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) if svcErr != nil { @@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) {{- else}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) {{- end}} -{{- else if .HasError}} - if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { - {{$.Service.Name | lower}}WriteError(p, stack, svcErr) - return - } -{{- else}} - service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) -{{- end}} // Write JSON response to plugin memory resp := {{responseType .}}{ @@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) {{- end}} } {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- else if .HasError}} + if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { + {{$.Service.Name | lower}}WriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := {{responseType .}}{} + {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- else}} + service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) + + // Write JSON response to plugin memory + resp := {{responseType .}}{} + {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- end}} }, []extism.ValueType{extism.ValueTypePTR}, []extism.ValueType{extism.ValueTypePTR}, @@ -119,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64 respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } +{{- if .Service.HasRawMethods}} + +// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory. +// Format: [0x01][UTF-8 error message] +func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { + errMsg := []byte(err.Error()) + frame := make([]byte, 1+len(errMsg)) + frame[0] = 0x01 // error + copy(frame[1:], errMsg) + respPtr, _ := p.WriteBytes(frame) + stack[0] = respPtr +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 7bd2bcc3f..29cf2316a 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -173,6 +173,16 @@ func (s Service) HasErrors() bool { return false } +// HasRawMethods returns true if any method in the service uses raw binary framing. +func (s Service) HasRawMethods() bool { + for _, m := range s.Methods { + if m.Raw { + return true + } + } + return false +} + // Method represents a host function method within a service. type Method struct { Name string // Go method name (e.g., "Call") @@ -181,6 +191,7 @@ type Method struct { Returns []Param // Return values (excluding error) HasError bool // Whether the method returns an error Doc string // Documentation comment for the method + Raw bool // If true, response uses binary framing instead of JSON } // FunctionName returns the Extism host function export name. diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt new file mode 100644 index 000000000..22d387041 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt @@ -0,0 +1,66 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Stream host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package ndpdk + +import ( + "encoding/binary" + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// stream_getstream is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user stream_getstream +func stream_getstream(uint64) uint64 + +type streamGetStreamRequest struct { + Uri string `json:"uri"` +} + +// StreamGetStream calls the stream_getstream host function. +// GetStream returns raw binary stream data with content type. +func StreamGetStream(uri string) (string, []byte, error) { + // Marshal request to JSON + req := streamGetStreamRequest{ + Uri: uri, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := stream_getstream(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse binary-framed response + if len(responseBytes) == 0 { + return "", nil, errors.New("empty response from host") + } + if responseBytes[0] == 0x01 { // error + return "", nil, errors.New(string(responseBytes[1:])) + } + if responseBytes[0] != 0x00 { + return "", nil, errors.New("unknown response status") + } + if len(responseBytes) < 5 { + return "", nil, errors.New("malformed raw response: incomplete header") + } + ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) + if uint32(len(responseBytes)) < 5+ctLen { + return "", nil, errors.New("malformed raw response: content-type overflow") + } + return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil +} diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.py b/plugins/cmd/ndpgen/testdata/raw_client_expected.py new file mode 100644 index 000000000..45af2b6c6 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.py @@ -0,0 +1,63 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the Stream host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any, Tuple + +import extism +import json +import struct + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "stream_getstream") +def _stream_getstream(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def stream_get_stream(uri: str) -> Tuple[str, bytes]: + """GetStream returns raw binary stream data with content type. + + Args: + uri: str parameter. + + Returns: + Tuple of (content_type, data) with the raw binary response. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "uri": uri, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _stream_getstream(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response_bytes = response_mem.bytes() + + if len(response_bytes) == 0: + raise HostFunctionError("empty response from host") + if response_bytes[0] == 0x01: + raise HostFunctionError(response_bytes[1:].decode("utf-8")) + if response_bytes[0] != 0x00: + raise HostFunctionError("unknown response status") + if len(response_bytes) < 5: + raise HostFunctionError("malformed raw response: incomplete header") + ct_len = struct.unpack(">I", response_bytes[1:5])[0] + if len(response_bytes) < 5 + ct_len: + raise HostFunctionError("malformed raw response: content-type overflow") + content_type = response_bytes[5:5 + ct_len].decode("utf-8") + data = response_bytes[5 + ct_len:] + return content_type, data diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.rs b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs new file mode 100644 index 000000000..6de18be8e --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs @@ -0,0 +1,73 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Stream host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct StreamGetStreamRequest { + uri: String, +} + +#[host_fn] +extern "ExtismHost" { +} + +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + fn stream_getstream(offset: u64) -> u64; +} + +/// GetStream returns raw binary stream data with content type. +/// +/// # Arguments +/// * `uri` - String parameter. +/// +/// # Returns +/// A tuple of (content_type, data) with the raw binary response. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_stream(uri: &str) -> Result<(String, Vec), Error> { + let req = StreamGetStreamRequest { + uri: uri.to_owned(), + }; + let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; + let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; + + let response_offset = unsafe { stream_getstream(input_mem.offset()) }; + + let response_mem = Memory::find(response_offset) + .ok_or_else(|| Error::msg("empty response from host"))?; + let response_bytes = response_mem.to_vec(); + + if response_bytes.is_empty() { + return Err(Error::msg("empty response from host")); + } + if response_bytes[0] == 0x01 { + let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); + return Err(Error::msg(msg)); + } + if response_bytes[0] != 0x00 { + return Err(Error::msg("unknown response status")); + } + if response_bytes.len() < 5 { + return Err(Error::msg("malformed raw response: incomplete header")); + } + let ct_len = u32::from_be_bytes([ + response_bytes[1], + response_bytes[2], + response_bytes[3], + response_bytes[4], + ]) as usize; + if ct_len > response_bytes.len() - 5 { + return Err(Error::msg("malformed raw response: content-type overflow")); + } + let ct_end = 5 + ct_len; + let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); + let data = response_bytes[ct_end..].to_vec(); + Ok((content_type, data)) +} diff --git a/plugins/cmd/ndpgen/testdata/raw_service.go.txt b/plugins/cmd/ndpgen/testdata/raw_service.go.txt new file mode 100644 index 000000000..c08332f5d --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_service.go.txt @@ -0,0 +1,10 @@ +package testpkg + +import "context" + +//nd:hostservice name=Stream permission=stream +type StreamService interface { + // GetStream returns raw binary stream data with content type. + //nd:hostfunc raw=true + GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) +} diff --git a/plugins/host/subsonicapi.go b/plugins/host/subsonicapi.go index d8fa900d3..117f8abff 100644 --- a/plugins/host/subsonicapi.go +++ b/plugins/host/subsonicapi.go @@ -15,4 +15,10 @@ type SubsonicAPIService interface { // e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. //nd:hostfunc Call(ctx context.Context, uri string) (responseJSON string, err error) + + // CallRaw executes a Subsonic API request and returns the raw binary response. + // Optimized for binary endpoints like getCoverArt and stream that return + // non-JSON data. The response is returned as raw bytes without JSON encoding overhead. + //nd:hostfunc raw=true + CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) } diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go index e3c2af7bd..438c51c95 100644 --- a/plugins/host/subsonicapi_gen.go +++ b/plugins/host/subsonicapi_gen.go @@ -4,6 +4,7 @@ package host import ( "context" + "encoding/binary" "encoding/json" extism "github.com/extism/go-sdk" @@ -20,11 +21,17 @@ type SubsonicAPICallResponse struct { Error string `json:"error,omitempty"` } +// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw. +type SubsonicAPICallRawRequest struct { + Uri string `json:"uri"` +} + // RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions. // The returned host functions should be added to the plugin's configuration. func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction { return []extism.HostFunction{ newSubsonicAPICallHostFunction(service), + newSubsonicAPICallRawHostFunction(service), } } @@ -62,6 +69,50 @@ func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunct ) } +func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "subsonicapi_callraw", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + subsonicapiWriteRawError(p, stack, err) + return + } + var req SubsonicAPICallRawRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + subsonicapiWriteRawError(p, stack, err) + return + } + + // Call the service method + contenttype, data, svcErr := service.CallRaw(ctx, req.Uri) + if svcErr != nil { + subsonicapiWriteRawError(p, stack, svcErr) + return + } + + // Write binary-framed response to plugin memory: + // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] + ctBytes := []byte(contenttype) + frame := make([]byte, 1+4+len(ctBytes)+len(data)) + frame[0] = 0x00 // success + binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) + copy(frame[5:5+len(ctBytes)], ctBytes) + copy(frame[5+len(ctBytes):], data) + + respPtr, err := p.WriteBytes(frame) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + // subsonicapiWriteResponse writes a JSON response to plugin memory. func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { respBytes, err := json.Marshal(resp) @@ -86,3 +137,14 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } + +// subsonicapiWriteRawError writes a binary-framed error response to plugin memory. +// Format: [0x01][UTF-8 error message] +func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { + errMsg := []byte(err.Error()) + frame := make([]byte, 1+len(errMsg)) + frame[0] = 0x01 // error + copy(frame[1:], errMsg) + respPtr, _ := p.WriteBytes(frame) + stack[0] = respPtr +} diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index b7a98fcea..01a33c039 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -24,7 +24,7 @@ const subsonicAPIVersion = "1.16.1" // // Authentication: The plugin must provide a valid 'u' (username) parameter in the URL. // URL Format: Only the path and query parameters are used - host/protocol are ignored. -// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format). +// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { pluginID string router SubsonicRouter @@ -50,15 +50,18 @@ func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.Data } } -func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { +// executeRequest handles URL parsing, validation, permission checks, HTTP request creation, +// and router invocation. Shared between Call and CallRaw. +// If setJSON is true, the 'f=json' query parameter is added. +func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) { if s.router == nil { - return "", fmt.Errorf("SubsonicAPI router not available") + return nil, fmt.Errorf("SubsonicAPI router not available") } // Parse the input URL parsedURL, err := url.Parse(uri) if err != nil { - return "", fmt.Errorf("invalid URL format: %w", err) + return nil, fmt.Errorf("invalid URL format: %w", err) } // Extract query parameters @@ -67,18 +70,20 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // Validate that 'u' (username) parameter is present username := query.Get("u") if username == "" { - return "", fmt.Errorf("missing required parameter 'u' (username)") + return nil, fmt.Errorf("missing required parameter 'u' (username)") } if err := s.checkPermissions(ctx, username); err != nil { log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) - return "", err + return nil, err } // Add required Subsonic API parameters query.Set("c", s.pluginID) // Client name (plugin ID) - query.Set("f", "json") // Response format query.Set("v", subsonicAPIVersion) // API version + if setJSON { + query.Set("f", "json") // Response format + } // Extract the endpoint from the path endpoint := path.Base(parsedURL.Path) @@ -96,7 +101,7 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // explicitly added in the next step via request.WithInternalAuth. httpReq, err := http.NewRequest("GET", finalURL.String(), nil) if err != nil { - return "", fmt.Errorf("failed to create HTTP request: %w", err) + return nil, fmt.Errorf("failed to create HTTP request: %w", err) } // Set internal authentication context using the username from the 'u' parameter @@ -109,10 +114,26 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // Call the subsonic router s.router.ServeHTTP(recorder, httpReq) - // Return the response body as JSON + return recorder, nil +} + +func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { + recorder, err := s.executeRequest(ctx, uri, true) + if err != nil { + return "", err + } return recorder.Body.String(), nil } +func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) { + recorder, err := s.executeRequest(ctx, uri, false) + if err != nil { + return "", nil, err + } + contentType := recorder.Header().Get("Content-Type") + return contentType, recorder.Body.Bytes(), nil +} + func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user if s.allUsers { diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 257332105..b0589fa12 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "os" + "path" "path/filepath" "github.com/navidrome/navidrome/conf" @@ -177,6 +178,61 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { Expect(err.Error()).To(ContainSubstring("missing required parameter")) }) }) + + Describe("SubsonicAPI CallRaw", func() { + var plugin *plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-subsonicapi-plugin"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + It("successfully calls getCoverArt and returns binary data", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + // Parse the metadata response from the test plugin + var result map[string]any + err = json.Unmarshal(output, &result) + Expect(err).ToNot(HaveOccurred()) + Expect(result["contentType"]).To(Equal("image/png")) + Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader))) + Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte + }) + + It("does NOT set f=json parameter for raw calls", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + _, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) + Expect(err).ToNot(HaveOccurred()) + + Expect(router.lastRequest).ToNot(BeNil()) + query := router.lastRequest.URL.Query() + Expect(query.Get("f")).To(BeEmpty()) + Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin")) + Expect(query.Get("v")).To(Equal("1.16.1")) + }) + + It("returns error when username is missing", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + }) }) var _ = Describe("SubsonicAPIService", func() { @@ -323,6 +379,66 @@ var _ = Describe("SubsonicAPIService", func() { }) }) + Describe("CallRaw", func() { + It("returns binary data and content-type", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(contentType).To(Equal("image/png")) + Expect(data).To(Equal(fakePNGHeader)) + }) + + It("does not set f=json parameter", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).ToNot(HaveOccurred()) + + Expect(router.lastRequest).ToNot(BeNil()) + query := router.lastRequest.URL.Query() + Expect(query.Get("f")).To(BeEmpty()) + }) + + It("enforces permission checks", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not authorized")) + }) + + It("returns error when username is missing", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + + It("returns error when router is nil", func() { + service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("router not available")) + }) + + It("returns error for invalid URL", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "://invalid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid URL")) + }) + }) + Describe("Router Availability", func() { It("returns error when router is nil", func() { service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) @@ -335,6 +451,9 @@ var _ = Describe("SubsonicAPIService", func() { }) }) +// fakePNGHeader is a minimal PNG file header used in tests. +var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + // fakeSubsonicRouter is a mock Subsonic router that returns predictable responses. type fakeSubsonicRouter struct { lastRequest *http.Request @@ -343,13 +462,20 @@ type fakeSubsonicRouter struct { func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.lastRequest = req - // Return a successful ping response - response := map[string]any{ - "subsonic-response": map[string]any{ - "status": "ok", - "version": "1.16.1", - }, + endpoint := path.Base(req.URL.Path) + switch endpoint { + case "getCoverArt": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNGHeader) + default: + // Return a successful ping response + response := map[string]any{ + "subsonic-response": map[string]any{ + "status": "ok", + "version": "1.16.1", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) } diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod index 4d5fcddfc..3916cd749 100644 --- a/plugins/pdk/go/go.mod +++ b/plugins/pdk/go/go.mod @@ -6,10 +6,3 @@ require ( github.com/extism/go-pdk v1.1.3 github.com/stretchr/testify v1.11.1 ) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/plugins/pdk/go/host/nd_host_subsonicapi.go b/plugins/pdk/go/host/nd_host_subsonicapi.go index 87469ce32..9bb4f4b15 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi.go @@ -8,6 +8,7 @@ package host import ( + "encoding/binary" "encoding/json" "errors" @@ -19,6 +20,11 @@ import ( //go:wasmimport extism:host/user subsonicapi_call func subsonicapi_call(uint64) uint64 +// subsonicapi_callraw is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user subsonicapi_callraw +func subsonicapi_callraw(uint64) uint64 + type subsonicAPICallRequest struct { Uri string `json:"uri"` } @@ -28,6 +34,10 @@ type subsonicAPICallResponse struct { Error string `json:"error,omitempty"` } +type subsonicAPICallRawRequest struct { + Uri string `json:"uri"` +} + // SubsonicAPICall calls the subsonicapi_call host function. // Call executes a Subsonic API request and returns the JSON response. // @@ -65,3 +75,46 @@ func SubsonicAPICall(uri string) (string, error) { return response.ResponseJSON, nil } + +// SubsonicAPICallRaw calls the subsonicapi_callraw host function. +// CallRaw executes a Subsonic API request and returns the raw binary response. +// Optimized for binary endpoints like getCoverArt and stream that return +// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +func SubsonicAPICallRaw(uri string) (string, []byte, error) { + // Marshal request to JSON + req := subsonicAPICallRawRequest{ + Uri: uri, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := subsonicapi_callraw(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse binary-framed response + if len(responseBytes) == 0 { + return "", nil, errors.New("empty response from host") + } + if responseBytes[0] == 0x01 { // error + return "", nil, errors.New(string(responseBytes[1:])) + } + if responseBytes[0] != 0x00 { + return "", nil, errors.New("unknown response status") + } + if len(responseBytes) < 5 { + return "", nil, errors.New("malformed raw response: incomplete header") + } + ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) + if uint32(len(responseBytes)) < 5+ctLen { + return "", nil, errors.New("malformed raw response: content-type overflow") + } + return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil +} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index f9d71a9c0..95dd41558 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -33,3 +33,17 @@ func (m *mockSubsonicAPIService) Call(uri string) (string, error) { func SubsonicAPICall(uri string) (string, error) { return SubsonicAPIMock.Call(uri) } + +// CallRaw is the mock method for SubsonicAPICallRaw. +func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { + args := m.Called(uri) + return args.String(0), args.Get(1).([]byte), args.Error(2) +} + +// SubsonicAPICallRaw delegates to the mock instance. +// CallRaw executes a Subsonic API request and returns the raw binary response. +// Optimized for binary endpoints like getCoverArt and stream that return +// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +func SubsonicAPICallRaw(uri string) (string, []byte, error) { + return SubsonicAPIMock.CallRaw(uri) +} diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py index ee6b543fa..4da8da77f 100644 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ b/plugins/pdk/python/host/nd_host_subsonicapi.py @@ -8,10 +8,11 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any +from typing import Any, Tuple import extism import json +import struct class HostFunctionError(Exception): @@ -25,6 +26,12 @@ def _subsonicapi_call(offset: int) -> int: ... +@extism.import_fn("extism:host/user", "subsonicapi_callraw") +def _subsonicapi_callraw(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + def subsonicapi_call(uri: str) -> str: """Call executes a Subsonic API request and returns the JSON response. @@ -53,3 +60,42 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. raise HostFunctionError(response["error"]) return response.get("responseJson", "") + + +def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]: + """CallRaw executes a Subsonic API request and returns the raw binary response. +Optimized for binary endpoints like getCoverArt and stream that return +non-JSON data. The response is returned as raw bytes without JSON encoding overhead. + + Args: + uri: str parameter. + + Returns: + Tuple of (content_type, data) with the raw binary response. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "uri": uri, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _subsonicapi_callraw(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response_bytes = response_mem.bytes() + + if len(response_bytes) == 0: + raise HostFunctionError("empty response from host") + if response_bytes[0] == 0x01: + raise HostFunctionError(response_bytes[1:].decode("utf-8")) + if response_bytes[0] != 0x00: + raise HostFunctionError("unknown response status") + if len(response_bytes) < 5: + raise HostFunctionError("malformed raw response: incomplete header") + ct_len = struct.unpack(">I", response_bytes[1:5])[0] + if len(response_bytes) < 5 + ct_len: + raise HostFunctionError("malformed raw response: content-type overflow") + content_type = response_bytes[5:5 + ct_len].decode("utf-8") + data = response_bytes[5 + ct_len:] + return content_type, data diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.lock b/plugins/pdk/rust/nd-pdk-host/Cargo.lock index b0a639ba3..b4d9042d0 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.lock +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.lock @@ -28,9 +28,9 @@ checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "either" @@ -171,7 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] -name = "nd-host" +name = "nd-pdk-host" version = "0.1.0" dependencies = [ "extism-pdk", diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs index e32b6d72f..2c9e6545f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs @@ -21,11 +21,22 @@ struct SubsonicAPICallResponse { error: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAPICallRawRequest { + uri: String, +} + #[host_fn] extern "ExtismHost" { fn subsonicapi_call(input: Json) -> Json; } +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + fn subsonicapi_callraw(offset: u64) -> u64; +} + /// Call executes a Subsonic API request and returns the JSON response. /// /// The uri parameter should be the Subsonic API path without the server prefix, @@ -52,3 +63,56 @@ pub fn call(uri: &str) -> Result { Ok(response.0.response_json) } + +/// CallRaw executes a Subsonic API request and returns the raw binary response. +/// Optimized for binary endpoints like getCoverArt and stream that return +/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +/// +/// # Arguments +/// * `uri` - String parameter. +/// +/// # Returns +/// A tuple of (content_type, data) with the raw binary response. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn call_raw(uri: &str) -> Result<(String, Vec), Error> { + let req = SubsonicAPICallRawRequest { + uri: uri.to_owned(), + }; + let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; + let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; + + let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) }; + + let response_mem = Memory::find(response_offset) + .ok_or_else(|| Error::msg("empty response from host"))?; + let response_bytes = response_mem.to_vec(); + + if response_bytes.is_empty() { + return Err(Error::msg("empty response from host")); + } + if response_bytes[0] == 0x01 { + let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); + return Err(Error::msg(msg)); + } + if response_bytes[0] != 0x00 { + return Err(Error::msg("unknown response status")); + } + if response_bytes.len() < 5 { + return Err(Error::msg("malformed raw response: incomplete header")); + } + let ct_len = u32::from_be_bytes([ + response_bytes[1], + response_bytes[2], + response_bytes[3], + response_bytes[4], + ]) as usize; + if ct_len > response_bytes.len() - 5 { + return Err(Error::msg("malformed raw response: content-type overflow")); + } + let ct_end = 5 + ct_len; + let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); + let data = response_bytes[ct_end..].to_vec(); + Ok((content_type, data)) +} diff --git a/plugins/testdata/test-subsonicapi-plugin/main.go b/plugins/testdata/test-subsonicapi-plugin/main.go index 03b912801..573036b95 100644 --- a/plugins/testdata/test-subsonicapi-plugin/main.go +++ b/plugins/testdata/test-subsonicapi-plugin/main.go @@ -3,6 +3,8 @@ package main import ( + "fmt" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -28,4 +30,28 @@ func callSubsonicAPIExport() int32 { return 0 } +// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function. +// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1") +// Output: JSON with contentType, size, and first bytes of the raw response +// +//go:wasmexport call_subsonic_api_raw +func callSubsonicAPIRawExport() int32 { + uri := pdk.InputString() + + contentType, data, err := host.SubsonicAPICallRaw(uri) + if err != nil { + pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error()) + return 1 + } + + // Return metadata about the raw response as JSON + firstByte := 0 + if len(data) > 0 { + firstByte = int(data[0]) + } + result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte) + pdk.OutputString(result) + return 0 +} + func main() {} diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index fbf9deb99..b8807563e 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -162,7 +163,11 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt if p.IsSmartPlaylist() { - pls.Changed = time.Now() + if p.EvaluatedAt != nil { + pls.Changed = *p.EvaluatedAt + } else { + pls.Changed = time.Now() + } } else { pls.Changed = p.UpdatedAt } @@ -176,6 +181,24 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Owner = p.OwnerName pls.Public = p.Public pls.CoverArt = p.CoverArtID().String() + pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls } + +func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist { + pls := responses.OpenSubsonicPlaylist{} + + if p.IsSmartPlaylist() { + pls.Readonly = true + + if p.EvaluatedAt != nil { + pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + } + } else { + user, ok := request.UserFrom(ctx) + pls.Readonly = !ok || p.OwnerID != user.ID + } + + return &pls +} diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 20da12dd7..05701fc1f 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -25,96 +26,194 @@ var _ = Describe("buildPlaylist", func() { ds = &tests.MockDataStore{} router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() - - createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) - updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) - - playlist = model.Playlist{ - ID: "pls-1", - Name: "My Playlist", - Comment: "Test comment", - OwnerName: "admin", - Public: true, - SongCount: 10, - Duration: 600, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } }) - Context("with minimal client", func() { + Describe("normal playlist", func() { BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "minimal-client"} - ctx = request.WithPlayer(ctx, player) + createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) + updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) + + playlist = model.Playlist{ + ID: "pls-1", + Name: "My Playlist", + Comment: "Test comment", + OwnerName: "admin", + OwnerID: "1234", + Public: true, + SongCount: 10, + Duration: 600, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } }) - It("returns only basic fields", func() { - result := router.buildPlaylist(ctx, playlist) + Context("with minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "minimal-client"} + ctx = request.WithPlayer(ctx, player) + }) - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + It("returns only basic fields", func() { + result := router.buildPlaylist(ctx, playlist) - // These should not be set - Expect(result.Comment).To(BeEmpty()) - Expect(result.Owner).To(BeEmpty()) - Expect(result.Public).To(BeFalse()) - Expect(result.CoverArt).To(BeEmpty()) + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + + // These should not be set + Expect(result.Comment).To(BeEmpty()) + Expect(result.Owner).To(BeEmpty()) + Expect(result.Public).To(BeFalse()) + Expect(result.CoverArt).To(BeEmpty()) + }) + }) + + Context("with non-minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.Readonly).To(BeTrue()) + }) + + It("returns all fields when as owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.Readonly).To(BeFalse()) + }) + }) + + Context("when minimal clients list is empty", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "" + player := model.Player{Client: "any-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + }) + }) + + Context("when no player in context", func() { + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + }) }) }) - Context("with non-minimal client", func() { + Describe("smart playlist", func() { + evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC) + validUntil := evaluatedAt.Add(5 * time.Second) + BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) + createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) + updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) + + playlist = model.Playlist{ + ID: "pls-1", + Name: "My Playlist", + Comment: "Test comment", + OwnerName: "admin", + OwnerID: "1234", + Public: true, + SongCount: 10, + Duration: 600, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + EvaluatedAt: &evaluatedAt, + Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "title"}}, + }, + } }) - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) + Context("with minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "minimal-client"} + ctx = request.WithPlayer(ctx, player) + }) - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) + It("returns only basic fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(evaluatedAt)) + + // These should not be set + Expect(result.Comment).To(BeEmpty()) + Expect(result.Owner).To(BeEmpty()) + Expect(result.Public).To(BeFalse()) + Expect(result.CoverArt).To(BeEmpty()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) + + Context("with non-minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(*playlist.EvaluatedAt)) + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.Readonly).To(BeTrue()) + Expect(result.ValidUntil).To(Equal(&validUntil)) + }) }) }) - - Context("when minimal clients list is empty", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "" - player := model.Player{Client: "any-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - }) - }) - - Context("when no player in context", func() { - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - }) - }) - }) var _ = Describe("UpdatePlaylist", func() { diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON index 5263fb07c..963a207d5 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON @@ -14,9 +14,20 @@ "duration": 120, "public": true, "owner": "admin", + "created": "2023-02-20T14:45:00Z", + "changed": "2023-02-20T14:45:00Z", + "coverArt": "pl-123123123123", + "readonly": true, + "validUntil": "2023-02-20T14:45:00Z" + }, + { + "id": "333", + "name": "ccc", + "songCount": 0, + "duration": 0, "created": "0001-01-01T00:00:00Z", "changed": "0001-01-01T00:00:00Z", - "coverArt": "pl-123123123123" + "readonly": false }, { "id": "222", diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML index 6bc26c593..759f7b52d 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML @@ -1,6 +1,7 @@ - + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c5e07395e..4ddcbe00f 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -298,16 +298,17 @@ type AlbumList2 struct { } type Playlist struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` - SongCount int32 `xml:"songCount,attr" json:"songCount"` - Duration int32 `xml:"duration,attr" json:"duration"` - Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` - Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` - Created time.Time `xml:"created,attr" json:"created"` - Changed time.Time `xml:"changed,attr" json:"changed"` - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` + Duration int32 `xml:"duration,attr" json:"duration"` + Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` + Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` + Created time.Time `xml:"created,attr" json:"created"` + Changed time.Time `xml:"changed,attr" json:"changed"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + *OpenSubsonicPlaylist `xml:",omitempty" json:",omitempty"` /* @@ -315,6 +316,11 @@ type Playlist struct { */ } +type OpenSubsonicPlaylist struct { + Readonly bool `xml:"readonly,attr,omitempty" json:"readonly"` + ValidUntil *time.Time `xml:"validUntil,attr,omitempty" json:"validUntil,omitempty"` +} + type Playlists struct { Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"` } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 2ee8e080d..ccf15afe3 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" @@ -531,9 +532,9 @@ var _ = Describe("Responses", func() { }) Context("with data", func() { - timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00") + timestamp := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) BeforeEach(func() { - pls := make([]Playlist, 2) + pls := make([]Playlist, 3) pls[0] = Playlist{ Id: "111", Name: "aaa", @@ -545,8 +546,13 @@ var _ = Describe("Responses", func() { CoverArt: "pl-123123123123", Created: timestamp, Changed: timestamp, + OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{ + Readonly: true, + ValidUntil: ×tamp, + }, } - pls[1] = Playlist{Id: "222", Name: "bbb"} + pls[1] = Playlist{Id: "333", Name: "ccc", OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{}} + pls[2] = Playlist{Id: "222", Name: "bbb"} response.Playlists.Playlist = pls }) diff --git a/ui/package-lock.json b/ui/package-lock.json index ed93a5ecb..e9ef2aaa4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2395,9 +2395,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index 1b6d74560..a83972f17 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -4,7 +4,7 @@ import { IconButton, Tooltip, Link } from '@material-ui/core' import { ImLastfm2 } from 'react-icons/im' import MusicBrainz from '../icons/MusicBrainz' -import { intersperse } from '../utils' +import { intersperse, isLastFmURL } from '../utils' import config from '../config' import { makeStyles } from '@material-ui/core/styles' @@ -38,13 +38,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - if (lastFMlink) { + if (lastFMlink && isLastFmURL(lastFMlink[2])) { addLink( lastFMlink[2], 'message.openIn.lastfm', , ) - } else if (artistInfo?.lastFmUrl) { + } else if (isLastFmURL(artistInfo?.lastFmUrl)) { addLink( artistInfo?.lastFmUrl, 'message.openIn.lastfm', diff --git a/ui/src/common/SafeHTML.jsx b/ui/src/common/SafeHTML.jsx index 643b538b8..980883568 100644 --- a/ui/src/common/SafeHTML.jsx +++ b/ui/src/common/SafeHTML.jsx @@ -1,5 +1,5 @@ import DOMPurify from 'dompurify' -import { Fragment, useMemo } from 'react' +import { useMemo } from 'react' export const SafeHTML = ({ children }) => { const purified = useMemo(() => { @@ -23,5 +23,5 @@ export const SafeHTML = ({ children }) => { return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] }) }, [children]) - return + return } diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js index 6a05bf381..65ded5fc5 100644 --- a/ui/src/themes/nautiline.js +++ b/ui/src/themes/nautiline.js @@ -8,6 +8,7 @@ // ============================================ const ACCENT_COLOR = '#009688' // Material teal +const UNBOUNDED_FONT_PATH = 'fonts/Unbounded-Variable.woff2' // ============================================ // DESIGN TOKENS @@ -69,7 +70,7 @@ const tokens = { font-style: normal; font-weight: 300 800; font-display: swap; - src: url('/fonts/Unbounded-Variable.woff2') format('woff2'); + src: url('${UNBOUNDED_FONT_PATH}') format('woff2'); } `, }, @@ -275,7 +276,7 @@ const NautilineTheme = { fontStyle: 'normal', fontWeight: '300 800', fontDisplay: 'swap', - src: "url('/fonts/Unbounded-Variable.woff2') format('woff2')", + src: `url('${UNBOUNDED_FONT_PATH}') format('woff2')`, }, body: { backgroundColor: colors.background.primary, @@ -794,7 +795,7 @@ const NautilineTheme = { font-style: normal; font-weight: 300 800; font-display: swap; - src: url('/fonts/Unbounded-Variable.woff2') format('woff2'); + src: url('${UNBOUNDED_FONT_PATH}') format('woff2'); } .react-jinke-music-player-main { diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index 5788096df..80207fe83 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -44,3 +44,16 @@ export const shareCoverUrl = (id, square) => { } export const docsUrl = (path) => `https://www.navidrome.org${path}` + +export const isLastFmURL = (url) => { + try { + const parsed = new URL(url) + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + (parsed.hostname === 'last.fm' || parsed.hostname.endsWith('.last.fm')) && + parsed.pathname.startsWith('/music/') + ) + } catch (e) { + return false + } +} diff --git a/ui/src/utils/urls.test.js b/ui/src/utils/urls.test.js new file mode 100644 index 000000000..26bdd1283 --- /dev/null +++ b/ui/src/utils/urls.test.js @@ -0,0 +1,25 @@ +import { isLastFmURL } from './urls' + +describe('isLastFmURL', () => { + it('returns true for valid Last.fm music URLs', () => { + expect(isLastFmURL('https://last.fm/music/The+Beatles')).toBe(true) + expect(isLastFmURL('http://last.fm/music/Radiohead')).toBe(true) + expect(isLastFmURL('https://www.last.fm/music/Daft+Punk')).toBe(true) + }) + + it('returns false for non-http(s) protocols (XSS prevention)', () => { + expect(isLastFmURL('javascript:alert(1)//last.fm/music/')).toBe(false) + expect(isLastFmURL('data:text/html,