diff --git a/Makefile b/Makefile index e303017c7..90a405de8 100644 --- a/Makefile +++ b/Makefile @@ -113,10 +113,11 @@ wire: check_go_env ##@Development Update Dependency Injection gen: check_go_env ##@Development Run go generate for code generation go generate ./... - cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host - cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust - cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust - cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities + cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust + cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types + cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types go mod tidy -C plugins/pdk/go .PHONY: gen diff --git a/plugins/README.md b/plugins/README.md index 048cf549d..b9118d36f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1030,8 +1030,6 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` -**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). - ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md index fca3cbd31..2ad4a82da 100644 --- a/plugins/capabilities/README.md +++ b/plugins/capabilities/README.md @@ -76,7 +76,7 @@ The YAML schemas in this package are automatically generated from the capability To regenerate the schemas after modifying the interfaces, run: ```bash -cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities +cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types ``` ## Resources diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 04dd283dd..a64897509 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -9,20 +9,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name GetLyricsRequest: description: GetLyricsRequest contains the track information for lyrics lookup. properties: @@ -124,3 +110,21 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 935594abd..f856562c6 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // MetadataAgent provides artist and album metadata retrieval. // This capability allows plugins to provide external metadata for artists and albums, // such as biographies, images, similar artists, and top songs. @@ -102,10 +104,13 @@ type SimilarArtistsRequest struct { Limit int32 `json:"limit"` } +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // ImageInfo represents an image with URL and size. @@ -134,34 +139,13 @@ type TopSongsRequest struct { Count int32 `json:"count"` } -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` -} +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // AlbumRequest is the common request for album-related functions. @@ -235,5 +219,5 @@ type SimilarSongsByArtistRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index c90ac1ce0..cae23eadc 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -173,20 +173,6 @@ components: description: MBID is the MusicBrainz ID for the artist. required: - mbid - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name ArtistRequest: description: ArtistRequest is the common request for artist-related functions. properties: @@ -331,8 +317,61 @@ components: $ref: '#/components/schemas/SongRef' required: - songs + TopSongsRequest: + description: TopSongsRequest is the request for GetArtistTopSongs. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID. + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist (if known). + count: + type: integer + format: int32 + description: Count is the maximum number of top songs to return. + required: + - id + - name + - count + TopSongsResponse: + description: TopSongsResponse is the response for GetArtistTopSongs. + properties: + songs: + type: array + description: Songs is the list of top songs. + items: + $ref: '#/components/schemas/SongRef' + required: + - songs + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name SongRef: - description: SongRef is a reference to a song with metadata for matching. + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. properties: id: type: string @@ -369,33 +408,3 @@ components: description: Duration is the song duration in seconds. required: - name - TopSongsRequest: - description: TopSongsRequest is the request for GetArtistTopSongs. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist (if known). - count: - type: integer - format: int32 - description: Count is the maximum number of top songs to return. - required: - - id - - name - - count - TopSongsResponse: - description: TopSongsResponse is the response for GetArtistTopSongs. - properties: - songs: - type: array - description: Songs is the list of top songs. - items: - $ref: '#/components/schemas/SongRef' - required: - - songs diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 4918d5e8f..c1c05839a 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // Scrobbler provides scrobbling functionality to external services. // This capability allows plugins to submit listening history to services like Last.fm, // ListenBrainz, or custom scrobbling backends. @@ -32,16 +34,6 @@ type IsAuthorizedRequest struct { Username string `json:"username"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. @@ -55,9 +47,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 9d5cfed30..07af8ec7a 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -25,20 +25,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name IsAuthorizedRequest: description: IsAuthorizedRequest is the request for authorization check. properties: @@ -194,3 +180,21 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go index aadb9396e..a35d9d923 100644 --- a/plugins/capabilities/sonic_similarity.go +++ b/plugins/capabilities/sonic_similarity.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // SonicSimilarity provides audio-similarity based track discovery. // //nd:capability name=sonicsimilarity required=true @@ -12,14 +14,14 @@ type SonicSimilarity interface { } type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } type SonicSimilarityResponse struct { @@ -27,6 +29,6 @@ type SonicSimilarityResponse struct { } type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index 1d35e1e92..f0951fcec 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -16,20 +16,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name FindSonicPathRequest: properties: startSong: @@ -53,8 +39,49 @@ components: required: - song - count + SonicMatch: + properties: + song: + $ref: '#/components/schemas/SongRef' + similarity: + type: number + format: float + required: + - song + - similarity + SonicSimilarityResponse: + properties: + matches: + type: array + items: + $ref: '#/components/schemas/SonicMatch' + required: + - matches + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs), never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + required: + - name SongRef: - description: SongRef is a reference to a song with metadata for matching. + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. properties: id: type: string @@ -91,21 +118,3 @@ components: description: Duration is the song duration in seconds. required: - name - SonicMatch: - properties: - song: - $ref: '#/components/schemas/SongRef' - similarity: - type: number - format: float - required: - - song - - similarity - SonicSimilarityResponse: - properties: - matches: - type: array - items: - $ref: '#/components/schemas/SonicMatch' - required: - - matches diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md index d2f67a60c..7487db892 100644 --- a/plugins/cmd/ndpgen/README.md +++ b/plugins/cmd/ndpgen/README.md @@ -7,7 +7,7 @@ This tool is the unified code generator that handle both host function wrappers ## Usage ```bash -ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust] +ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-rust] ``` ### Flags @@ -21,10 +21,9 @@ ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] | `-dry-run` | Parse and validate without writing files | `false` | | `-host-only` | Generate only host function wrappers (capability support TBD) | `true` | | `-go` | Generate Go client wrappers | `true`* | -| `-python` | Generate Python client wrappers | `false` | | `-rust` | Generate Rust client wrappers | `false` | -\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages. +\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages. ### Example @@ -150,10 +149,6 @@ func TestMyPluginFunction(t *testing.T) { If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations. -### Python Client Library - -When using `-python`, Python client files are generated in a `python/` subdirectory. - ### Rust Client Library When using `-rust`, Rust client files are generated in a `rust/` subdirectory. diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index db500c1fc..d8bc5859d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -176,16 +176,15 @@ type ServiceB interface { Describe("code generation", func() { DescribeTable("generates correct client output", - func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) { + func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { serviceCode := readTestdata(serviceFile) goClientExpected := readTestdata(goClientExpectedFile) - pyClientExpected := readTestdata(pyClientExpectedFile) rsClientExpected := readTestdata(rsClientExpectedFile) Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - // Generate all client code (Go, Python, Rust) - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust") + // Generate all client code (Go, Rust) + cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) @@ -217,17 +216,6 @@ type ServiceB interface { Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch") - // Verify Python client code (now in $output/python/host/) - pythonHostDir := filepath.Join(outputDir, "python", "host") - pyClientEntries, err := os.ReadDir(pythonHostDir) - Expect(err).ToNot(HaveOccurred()) - Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file") - - pyClientActual, err := os.ReadFile(filepath.Join(pythonHostDir, pyClientEntries[0].Name())) - Expect(err).ToNot(HaveOccurred()) - - Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch") - // Verify Rust client code (now in $output/rust/nd-pdk-host/src/) rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src") rsClientEntries, err := os.ReadDir(rustSrcDir) @@ -251,39 +239,59 @@ type ServiceB interface { }, Entry("simple string params", - "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"), + "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), Entry("multiple simple params (int32)", - "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"), + "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), Entry("struct param with request type", - "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"), + "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), Entry("mixed simple and complex params", - "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"), + "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), Entry("method without error", - "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"), + "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), Entry("no params, error only", - "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"), + "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), Entry("map and interface types", - "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"), + "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), Entry("pointer types", - "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"), + "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), Entry("multiple returns", - "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"), + "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), Entry("bytes", - "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"), + "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), Entry("option pattern (value, exists bool)", - "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), + "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), ) + It("generates the shared Go types package with -shared-types", func() { + typesSrc := `package types + +// ArtistRef references an artist. +type ArtistRef struct { + ID string ` + "`json:\"id,omitempty\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed()) + cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go") + out, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out) + + content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("package types")) + Expect(string(content)).To(ContainSubstring("type ArtistRef struct {")) + }) + It("generates compilable client code for comprehensive service", func() { serviceCode := readTestdata("comprehensive_service.go.txt") @@ -386,119 +394,6 @@ var _ = ndpdk.ComprehensiveNoParams Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile()) }) - It("generates Python client code with -python flag", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - - pythonFile := filepath.Join(pythonHostDir, "nd_host_test.py") - Expect(pythonFile).To(BeAnExistingFile()) - - content, err := os.ReadFile(pythonFile) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):")) - Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`)) - Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:")) - }) - - It("generates both Go and Python client code with -go -python flags", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Go client code exists in $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile()) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - Expect(filepath.Join(pythonHostDir, "nd_host_test.py")).To(BeAnExistingFile()) - }) - - It("generates Python code with dataclass for multi-value returns", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Cache permission=cache -type CacheService interface { - //nd:hostfunc - GetString(ctx context.Context, key string) (value string, exists bool, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_cache.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("@dataclass")) - Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(contentStr).To(ContainSubstring("value: str")) - Expect(contentStr).To(ContainSubstring("exists: bool")) - Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - }) - - It("generates Python code for methods with no parameters", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Ping(ctx context.Context) (status string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_test.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("def test_ping() -> str:")) - Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) }) }) diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 514da578b..50f53cd37 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -4,6 +4,7 @@ import ( "bytes" "embed" "fmt" + "slices" "strings" "text/template" ) @@ -79,17 +80,6 @@ func mockAccessor(typ string, idx int) string { } } -// pythonFuncMap returns the template functions for Python client code generation. -func pythonFuncMap(svc Service) template.FuncMap { - return template.FuncMap{ - "lower": strings.ToLower, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) }, - "pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) }, - "pythonDefault": pythonDefaultValue, - } -} - // GenerateHost generates the host function wrapper code for a service. func GenerateHost(svc Service, pkgName string) ([]byte, error) { tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl") @@ -186,51 +176,13 @@ func formatDoc(doc string) string { return strings.Join(result, "\n") } -// GenerateClientPython generates Python client wrapper code for plugins. -func GenerateClientPython(svc Service) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Python client template: %w", err) - } - - tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// pythonDefaultValue returns a Python default value for response.get() calls. -func pythonDefaultValue(p Param) string { - switch p.Type { - case "string": - return `, ""` - case "int", "int32", "int64": - return ", 0" - case "float32", "float64": - return ", 0.0" - case "bool": - return ", False" - case "[]byte": - return ", b\"\"" - default: - return ", None" - } -} - // rustFuncMap returns the template functions for Rust client code generation. func rustFuncMap(svc Service) template.FuncMap { knownStructs := svc.KnownStructs() + shared := make(map[string]string) + for _, a := range svc.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "lower": strings.ToLower, "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, @@ -238,9 +190,9 @@ func rustFuncMap(svc Service) template.FuncMap { "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, "rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) }, "rustDocComment": RustDocComment, - "rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) }, - "rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, + "rustType": func(p Param) string { return p.RustTypeWithShared(knownStructs, shared) }, + "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) }, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, } } @@ -388,6 +340,18 @@ func indentText(n int, s string) string { return strings.Join(lines, "\n") } +// indentSpaces adds n spaces to each non-empty line of text. +func indentSpaces(spaces int, s string) string { + ind := strings.Repeat(" ", spaces) + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = ind + line + } + } + return strings.Join(lines, "\n") +} + // capabilityAgentName returns the interface name for a capability. // Uses the Go interface name stripped of common suffixes. func capabilityAgentName(cap Capability) string { @@ -459,6 +423,10 @@ func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) { // rustCapabilityFuncMap returns template functions for Rust capability code generation. func rustCapabilityFuncMap(cap Capability) template.FuncMap { knownStructs := cap.KnownStructs() + shared := make(map[string]string) + for _, a := range cap.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "rustDocComment": RustDocComment, "rustTypeAlias": rustTypeAlias, @@ -466,25 +434,25 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap { "rustConstName": rustConstName, "rustFieldName": func(name string) string { return ToSnakeCase(name) }, "rustMethodName": func(name string) string { return ToSnakeCase(name) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, - "rustOutputType": rustOutputType, - "isPrimitiveRust": isPrimitiveRustType, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, + "rustOutputType": func(goType string) string { return rustTraitType(goType, shared) }, + "rustMethodType": func(goType string) string { return rustMethodType(goType, cap.Name, shared) }, "skipSerializingFunc": skipSerializingFunc, "hasHashMap": hasHashMap, "agentName": capabilityAgentName, "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, - "snakeCase": ToSnakeCase, - "indent": func(spaces int, s string) string { - indent := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") + "rustSharedTarget": func(target string) string { + return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix) }, + // rustSharedNote is the human-facing path for deprecation notes: plugin + // authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types + // as `types`, so they reference these via nd_pdk::types::X. + "rustSharedNote": func(target string) string { + return "nd_pdk::types::" + strings.TrimPrefix(target, sharedTypesPrefix) + }, + "snakeCase": ToSnakeCase, + "indent": indentSpaces, } } @@ -526,6 +494,46 @@ func rustConstType(goType string) string { // TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly. // Currently "*string" returns "string" instead of "String". This would generate invalid // Rust code. No current capability uses this pattern, but it should be fixed if needed. +// rustMethodType returns the fully-qualified Rust type for a capability method +// input/output as referenced inside the generated export macro. The macro expands +// in the downstream plugin crate, which depends on the umbrella nd-pdk crate and +// not on nd-pdk-types directly, so shared types must be reached through $crate +// (the defining nd-pdk-capabilities crate, which re-exports nd_pdk_types as +// `types`) rather than by naming the transitive crate. Primitives map to their +// Rust name; any other named type is a capability-local struct, qualified as +// $crate::::X. This is used instead of hand-assembling +// "$crate::::" + rustOutputType, which produced invalid paths like +// "$crate::demo::types.SongRef" for shared types used directly in a signature. +func rustMethodType(goType, pkg string, shared map[string]string) string { + goType = strings.TrimPrefix(goType, "*") + if isPrimitiveRustType(goType) { + return rustOutputType(goType) + } + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "$crate::types::" + rest + } + if t, ok := shared[goType]; ok { + return "$crate::types::" + strings.TrimPrefix(t, "nd_pdk_types::") + } + return "$crate::" + ToSnakeCase(pkg) + "::" + goType +} + +// rustTraitType returns the Rust type for a capability trait method signature. +// The trait lives in the capability module alongside its local structs, so those +// stay bare; shared types must still resolve to their nd_pdk_types::X crate path +// (a shared type used directly in a signature would otherwise pass through as the +// invalid Go selector "types.SongRef"). +func rustTraitType(goType string, shared map[string]string) string { + stripped := strings.TrimPrefix(goType, "*") + if rest, ok := strings.CutPrefix(stripped, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + if t, ok := shared[stripped]; ok { + return t + } + return rustOutputType(goType) +} + func rustOutputType(goType string) string { // Strip pointer prefix - capability outputs use Result for optionality if strings.HasPrefix(goType, "*") { @@ -601,9 +609,9 @@ func skipSerializingFunc(goType string) string { } } -// hasHashMap returns true if any struct in the capability uses HashMap. -func hasHashMap(cap Capability) bool { - for _, st := range cap.Structs { +// anyFieldUsesHashMap returns true if any field in the given structs uses a map type. +func anyFieldUsesHashMap(structs []StructDef) bool { + for _, st := range structs { for _, f := range st.Fields { if strings.HasPrefix(f.Type, "map[") { return true @@ -613,6 +621,32 @@ func hasHashMap(cap Capability) bool { return false } +// anyFieldIsByteSlice reports whether any field across the given structs is a +// []byte, which Go's JSON encoder serializes as a base64 string. The Rust +// shared-types crate must match that with a base64_bytes serde override. +func anyFieldIsByteSlice(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + +// hasHashMap returns true if any struct in the capability uses HashMap. +func hasHashMap(cap Capability) bool { + return anyFieldUsesHashMap(cap.Structs) +} + +// sortedStructs returns a sorted copy of structs, ordered by name. +func sortedStructs(structs []StructDef) []StructDef { + sorted := append([]StructDef(nil), structs...) + slices.SortFunc(sorted, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return sorted +} + // registerMacroName returns the macro name for registering an optional method. // For package "websocket" and method "OnClose", returns "register_websocket_close". func registerMacroName(pkg, name string) string { @@ -669,6 +703,11 @@ func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) { buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n") buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n") + // Re-export the shared types so generated registration macros can reference them + // via $crate::types::X. The macro expands in the downstream plugin crate, which + // depends on the umbrella nd-pdk crate and not on nd-pdk-types directly. + buf.WriteString("pub use nd_pdk_types as types;\n\n") + // Module declarations for _, cap := range capabilities { moduleName := ToSnakeCase(cap.Name) @@ -894,3 +933,70 @@ func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) { return buf.Bytes(), nil } + +// GenerateSharedTypesRust generates the nd-pdk-types crate root (lib.rs). +func GenerateSharedTypesRust(structs []StructDef) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types rust template: %w", err) + } + sorted := sortedStructs(structs) + known := map[string]bool{} + for _, s := range sorted { + known[s.Name] = true + } + tmpl, err := template.New("types_rs").Funcs(template.FuncMap{ + "rustDocComment": RustDocComment, + "rustFieldName": func(n string) string { return ToSnakeCase(n) }, + "fieldRustType": func(f FieldDef) string { return f.RustType(known) }, + "skipSerializingFunc": skipSerializingFunc, + "indent": indentSpaces, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + + data := struct { + Structs []StructDef + HasHashMap bool + HasByteFields bool + }{Structs: sorted, HasHashMap: anyFieldUsesHashMap(sorted), HasByteFields: anyFieldIsByteSlice(sorted)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} + +// GenerateSharedTypesGo generates the shared `types` package (plain data structs). +func GenerateSharedTypesGo(structs []StructDef, pkgName string) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.go.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types template: %w", err) + } + tmpl, err := template.New("types").Funcs(template.FuncMap{ + "formatDoc": formatDoc, + "indent": indentText, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + data := struct { + Package string + Structs []StructDef + }{Package: pkgName, Structs: sortedStructs(structs)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 052aa146c..d52d6dcbf 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -418,266 +418,19 @@ var _ = Describe("Generator", func() { }) }) - Describe("Python type and name helpers", func() { - Describe("ToPythonType", func() { - It("should map Go types to Python types", func() { - Expect(ToPythonType("string")).To(Equal("str")) - Expect(ToPythonType("int")).To(Equal("int")) - Expect(ToPythonType("int32")).To(Equal("int")) - Expect(ToPythonType("int64")).To(Equal("int")) - Expect(ToPythonType("float32")).To(Equal("float")) - Expect(ToPythonType("float64")).To(Equal("float")) - Expect(ToPythonType("bool")).To(Equal("bool")) - Expect(ToPythonType("[]byte")).To(Equal("bytes")) - Expect(ToPythonType("unknown")).To(Equal("Any")) - }) + Describe("ToSnakeCase", func() { + It("should convert PascalCase to snake_case", func() { + Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) + Expect(ToSnakeCase("GetString")).To(Equal("get_string")) + Expect(ToSnakeCase("simple")).To(Equal("simple")) }) - Describe("ToSnakeCase", func() { - It("should convert PascalCase to snake_case", func() { - Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) - Expect(ToSnakeCase("GetString")).To(Equal("get_string")) - Expect(ToSnakeCase("simple")).To(Equal("simple")) - }) - - It("should handle acronyms correctly", func() { - Expect(ToSnakeCase("ID")).To(Equal("id")) - Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) - Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) - Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) - Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) - }) - }) - - Describe("Method.PythonFunctionName", func() { - It("should generate snake_case function name with service prefix", func() { - m := Method{Name: "GetString"} - Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string")) - }) - }) - - Describe("Param.PythonType", func() { - It("should return Python type for parameter", func() { - p := NewParam("value", "string") - Expect(p.PythonType()).To(Equal("str")) - }) - }) - - Describe("Param.PythonName", func() { - It("should return snake_case name for parameter", func() { - p := NewParam("ttlSeconds", "int64") - Expect(p.PythonName()).To(Equal("ttl_seconds")) - }) - }) - }) - - Describe("GenerateClientPython", func() { - It("should generate valid Python code for a simple service", func() { - svc := Service{ - Name: "SubsonicAPI", - Permission: "subsonicapi", - Interface: "SubsonicAPIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("responseJSON", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for imports - Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass")) - Expect(codeStr).To(ContainSubstring("import extism")) - Expect(codeStr).To(ContainSubstring("import json")) - - // Check for exception class - Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):")) - - // Check for raw import function - Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`)) - Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:")) - - // Check for wrapper function with type hints - Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:")) - - // Check for error handling - Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response[")) - }) - - It("should generate dataclass for multi-value returns", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Methods: []Method{ - { - Name: "GetString", - HasError: true, - Params: []Param{NewParam("key", "string")}, - Returns: []Param{ - NewParam("value", "string"), - NewParam("exists", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for dataclass - Expect(codeStr).To(ContainSubstring("@dataclass")) - Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("value: str")) - Expect(codeStr).To(ContainSubstring("exists: bool")) - - // Check that function returns dataclass - Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("return CacheGetStringResult(")) - }) - - It("should handle methods with no parameters", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoParams", - HasError: true, - Returns: []Param{NewParam("result", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function with no params - Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:")) - // Empty request - Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) - - It("should handle methods with no return values", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoReturn", - HasError: true, - Params: []Param{NewParam("input", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function returns None - Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:")) - }) - - It("should generate correct Python defaults for different types", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "AllTypes", - HasError: true, - Returns: []Param{ - NewParam("strVal", "string"), - NewParam("intVal", "int64"), - NewParam("floatVal", "float64"), - NewParam("boolVal", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check defaults in response.get() calls - Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`)) - Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) - }) - - It("should not import base64 for non-byte 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("import base64")) - }) - - It("should generate base64 encoding/decoding for byte fields", func() { - svc := Service{ - Name: "Codec", - Permission: "codec", - Interface: "CodecService", - Methods: []Method{ - { - Name: "Encode", - HasError: true, - Params: []Param{NewParam("data", "[]byte")}, - Returns: []Param{NewParam("result", "[]byte")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should import base64 - Expect(codeStr).To(ContainSubstring("import base64")) - - // Should base64-encode byte params in request - Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`)) - - // Should base64-decode byte returns in response - Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`)) + It("should handle acronyms correctly", func() { + Expect(ToSnakeCase("ID")).To(Equal("id")) + Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) + Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) + Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) + Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) }) }) @@ -1186,6 +939,73 @@ type TestService interface { Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType =")) Expect(codeStr).To(ContainSubstring(`"retry"`)) }) + + It("emits a deprecated alias and types import for a shared-aliased capability", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}, + }}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo.")) + Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo")) + Expect(out).NotTo(ContainSubstring("type TrackInfo struct")) + }) + + It("emits the types import for a direct types.X field with no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}, + }}}, + // No SharedAliases: the field references the canonical type directly. + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) + + It("emits the types import for a direct types.X method input/output", func() { + cap := Capability{ + Name: "demo", + Interface: "Demo", + Required: true, + Methods: []Export{{ + Name: "Lookup", ExportName: "nd_demo_lookup", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}, + }}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityGo(cap, "demo") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) }) Describe("GenerateCapabilityGoStub", func() { @@ -1287,6 +1107,55 @@ type OnInitOutput struct { }) }) +var _ = Describe("Shared Types Generation", func() { + It("emits a Rust types crate root with serde derives", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};")) + Expect(out).To(ContainSubstring("pub struct ArtistRef {")) + Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`)) + Expect(out).To(ContainSubstring("pub name: String,")) + }) + + It("emits a flat Go types package with no imports", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesGo(structs, "types") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("package types")) + Expect(out).To(ContainSubstring("type ArtistRef struct {")) + Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`")) + Expect(out).To(ContainSubstring("Name string `json:\"name\"`")) + Expect(out).NotTo(ContainSubstring("import")) + }) + + It("emits base64 serde for Vec fields in the Rust types crate", func() { + structs := []StructDef{ + {Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{ + {Name: "Data", Type: "[]byte", JSONTag: "data"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("mod base64_bytes")) + Expect(out).To(ContainSubstring("use base64::Engine as _")) + Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) + }) +}) + var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { It("should return Option::is_none for pointer types", func() { @@ -1486,6 +1355,109 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("Option<")) }) + It("translates a shared type used directly as a method input/output", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The shared type must resolve to the canonical crate path, not pass through + // as the invalid Go selector `types.SongRef`. + Expect(out).To(ContainSubstring("nd_pdk_types::SongRef")) + Expect(out).NotTo(ContainSubstring("types.SongRef")) + }) + + It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Inside the export macro (expanded in the downstream plugin crate, which depends + // on the umbrella nd-pdk only), the shared type must be reachable via $crate, not + // by naming the transitive nd_pdk_types crate directly. + Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>")) + Expect(out).NotTo(ContainSubstring("extism_pdk::Json")) + }) + + It("emits a deprecated Rust type alias for shared types", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`); + // the alias target stays the real crate path so it resolves inside nd-pdk-capabilities. + Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`)) + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;")) + }) + + It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() { + // Wrapper has a field whose type is only in SharedAliases, not Structs. + // The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical + // path), not as the local deprecated alias and not as serde_json::Value. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field must use the canonical nd_pdk_types:: path, not the local alias. + Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,")) + }) + + It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() { + // The capability references the shared type by its canonical qualified + // name (types.Track) while the deprecated alias keeps the old name. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Doc: "Deprecated: use types.Track.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field uses the canonical qualified path (resolved from the types. prefix). + Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,")) + // The deprecated alias is still re-exported under its kept name. + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + }) + It("should include all float types correctly", func() { cap := Capability{ Name: "test", @@ -1637,6 +1609,36 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) }) + It("resolves a shared alias used in a method param/return to its canonical crate path", func() { + svc := Service{ + Name: "Matcher", + Permission: "matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("query", "string")}, + // Return uses the deprecated alias name directly. + Returns: []Param{NewParam("matches", "[]Track")}, + }, + }, + SharedAliases: []SharedAlias{{ + Name: "Track", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The alias must resolve to the shared crate type; a bare `Track` is undefined + // in nd-pdk-host and would not compile. + Expect(out).To(ContainSubstring("nd_pdk_types::Track")) + Expect(out).NotTo(ContainSubstring("Vec")) + }) + It("should not generate base64 module when no byte fields", func() { svc := Service{ Name: "Test", diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d4..4b832d22d 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -27,31 +27,81 @@ var ( keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`) ) -// ParseDirectory parses all Go source files in a directory and extracts host services. -func ParseDirectory(dir string) ([]Service, error) { +// parsedGoFile pairs a source path with its already-parsed AST. +type parsedGoFile struct { + path string + file *ast.File +} + +// parseGoFiles returns the eligible Go source files in dir, each parsed once. +func parseGoFiles(dir string, fset *token.FileSet) ([]parsedGoFile, error) { + paths, err := goSourceFiles(dir) + if err != nil { + return nil, err + } + out := make([]parsedGoFile, 0, len(paths)) + for _, p := range paths { + f, err := parser.ParseFile(fset, p, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(p), err) + } + out = append(out, parsedGoFile{path: p, file: f}) + } + return out, nil +} + +// goSourceFiles returns the Go source file paths in dir, excluding generated, +// test, and doc files. +func goSourceFiles(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading directory: %w", err) } - - var services []Service - fset := token.NewFileSet() - + var paths []string for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { continue } - // Skip generated files and test files - if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") { + if strings.HasSuffix(name, "_gen.go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" { continue } + paths = append(paths, filepath.Join(dir, name)) + } + return paths, nil +} - path := filepath.Join(dir, entry.Name()) - parsed, err := parseFile(fset, path) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) +// ParseDirectory parses all Go source files in a directory and extracts host services. +func ParseDirectory(dir string) ([]Service, error) { + return ParseDirectoryWithShared(dir, nil) +} + +// ParseDirectoryWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) + if err != nil { + return nil, err + } + + // First pass: collect all type aliases from every file so that an alias + // declared in one file is visible when resolving types in a sibling file. + pkgAliasMap := make(map[string]TypeAlias) + for _, pf := range parsed { + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a } - services = append(services, parsed...) + } + + // Second pass: parse services using the package-level alias map. + var services []Service + for _, pf := range parsed { + svcList, err := parseServiceFile(pf.file, pkgAliasMap, shared) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) + } + services = append(services, svcList...) } return services, nil @@ -59,66 +109,72 @@ func ParseDirectory(dir string) ([]Service, error) { // ParseCapabilities parses all Go source files in a directory and extracts capabilities. func ParseCapabilities(dir string) ([]Capability, error) { - entries, err := os.ReadDir(dir) + return ParseCapabilitiesWithShared(dir, nil) +} + +// ParseCapabilitiesWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) if err != nil { - return nil, fmt.Errorf("reading directory: %w", err) + return nil, err } - fset := token.NewFileSet() - - // First pass: collect all structs and type aliases from all files in the package - sharedStructMap := make(map[string]StructDef) - sharedAliasMap := make(map[string]TypeAlias) + // First pass: collect all structs, type aliases, and const groups. + pkgStructMap := make(map[string]StructDef) + pkgAliasMap := make(map[string]TypeAlias) var allConstGroups []ConstGroup - var goFiles []string - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { - continue + for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s } - // Skip generated files, test files, and doc.go - if strings.HasSuffix(entry.Name(), "_gen.go") || - strings.HasSuffix(entry.Name(), "_test.go") || - entry.Name() == "doc.go" { - continue + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a } - goFiles = append(goFiles, filepath.Join(dir, entry.Name())) + allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...) } - for _, path := range goFiles { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing %s for types: %w", filepath.Base(path), err) - } - for _, s := range parseStructs(f) { - sharedStructMap[s.Name] = s - } - for _, a := range parseTypeAliases(f) { - sharedAliasMap[a.Name] = a - } - allConstGroups = append(allConstGroups, parseConstGroups(f)...) - } - - // Second pass: parse capabilities using the shared type maps + // Second pass: parse capabilities using the package-level type maps. var capabilities []Capability - for _, path := range goFiles { - parsed, err := parseCapabilityFile(fset, path, sharedStructMap, sharedAliasMap, allConstGroups) + for _, pf := range parsed { + capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared) if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) } - capabilities = append(capabilities, parsed...) + capabilities = append(capabilities, capList...) } return capabilities, nil } -// parseCapabilityFile parses a single Go source file and extracts capabilities. -func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup) ([]Capability, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err +// LoadSharedTypes parses every struct defined in dir (the shared `types` source +// package) and returns them keyed by name. dir == "" yields an empty map. +func LoadSharedTypes(dir string) (map[string]StructDef, error) { + result := map[string]StructDef{} + if dir == "" { + return result, nil } + paths, err := goSourceFiles(dir) + if err != nil { + return nil, fmt.Errorf("reading shared types directory: %w", err) + } + fset := token.NewFileSet() + for _, path := range paths { + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + } + for _, s := range parseStructs(f) { + result[s.Name] = s + } + } + return result, nil +} +// parseCapabilityFile parses a single Go source file and extracts capabilities. +func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup, shared map[string]StructDef) ([]Capability, error) { var capabilities []Capability for _, decl := range f.Decls { @@ -190,6 +246,21 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // Recursively collect all struct dependencies collectAllStructDependencies(referencedTypes, structMap) + // Resolve shared-type aliases against the registry + sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared) + if err != nil { + return nil, err + } + capability.SharedAliases = sharedAliases + capability.SharedTypes = sharedTypes + + // Build a set of names already covered by SharedAliases so we don't + // emit them again in TypeAliases (which would cause a redeclaration). + sharedAliasNames := make(map[string]bool, len(capability.SharedAliases)) + for _, sa := range capability.SharedAliases { + sharedAliasNames[sa.Name] = true + } + // Sort type names for stable output order sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes)) @@ -200,8 +271,11 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] } } - // Attach referenced type aliases + // Attach referenced type aliases (skip those already in SharedAliases) for _, typeName := range sortedTypeNames { + if sharedAliasNames[typeName] { + continue + } if a, exists := aliasMap[typeName]; exists { capability.TypeAliases = append(capability.TypeAliases, a) } @@ -211,6 +285,9 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // This supports error types that are not directly referenced in method signatures interfaceName := typeSpec.Name.Name for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) { + if sharedAliasNames[typeName] { + continue + } a := aliasMap[typeName] if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] { capability.TypeAliases = append(capability.TypeAliases, a) @@ -237,6 +314,111 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] return capabilities, nil } +// resolveSharedAliases determines which shared `types` package structs a host +// service or capability uses and returns the deprecated re-export aliases to emit +// for them. +// +// A shared type counts as used when a field references it by qualified name +// (e.g. types.Track) or via a declared alias used by bare name (e.g. a field of +// type TrackInfo where `type TrackInfo = types.Track`). The shared struct's own +// fields are followed transitively so nested shared types are picked up too. For +// every used canonical type, each declared `type X = types.Canonical` alias is +// emitted as a SharedAlias so the generated PDK keeps re-exporting it for +// backwards compatibility. +// +// It returns the deprecated re-export aliases to emit and the resolved shapes of +// every used shared type (alias or not, for schema inlining). +// +// Returns an error if a referenced shared type cannot be found in the shared registry. +func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) { + // Index declared shared aliases by the canonical type they target, e.g. + // "Track" -> [TrackInfo]. A canonical type may have more than one alias. + aliasesByCanonical := map[string][]TypeAlias{} + for _, a := range aliasMap { + if a.IsSharedAlias() { + canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix) + aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a) + } + } + + // Walk the referenced types, following nested shared references inside the + // shared structs, to find the set of canonical shared types used. + used := map[string]bool{} + var queue []string + for name := range referenced { + if c, ok := seedSharedCanonical(name, aliasMap); ok { + queue = append(queue, c) + } + } + fieldRefs := map[string]bool{} + for len(queue) > 0 { + canonical := queue[0] + queue = queue[1:] + if used[canonical] { + continue + } + def, ok := shared[canonical] + if !ok { + return nil, nil, fmt.Errorf( + "shared type %q could not be resolved: pass -shared= pointing at the shared types package, and ensure %s is defined there", + canonical, sharedTypesPrefix+canonical, + ) + } + used[canonical] = true + for _, f := range def.Fields { + clear(fieldRefs) + collectReferencedTypes(f.Type, fieldRefs) + for t := range fieldRefs { + if c, ok := nestedSharedCanonical(t, shared); ok { + queue = append(queue, c) + } + } + } + } + + var out []SharedAlias + var usedDefs []StructDef + for canonical := range used { + usedDefs = append(usedDefs, shared[canonical]) + for _, a := range aliasesByCanonical[canonical] { + out = append(out, SharedAlias{Name: a.Name, Target: a.Type, Doc: a.Doc, Def: shared[canonical]}) + } + } + slices.SortFunc(out, func(a, b SharedAlias) int { return strings.Compare(a.Name, b.Name) }) + slices.SortFunc(usedDefs, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return out, usedDefs, nil +} + +// seedSharedCanonical maps a type token referenced by a capability/service field +// to the canonical shared type it denotes. It recognizes qualified references +// (types.X -> X) and declared shared aliases used by bare name (X where +// `type X = types.Y` -> Y). A bare name that is not a declared shared alias is +// not treated as shared, so a local struct sharing a name with a shared type is +// never misclassified. +func seedSharedCanonical(name string, aliasMap map[string]TypeAlias) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if a, ok := aliasMap[name]; ok && a.IsSharedAlias() { + return strings.TrimPrefix(a.Type, sharedTypesPrefix), true + } + return "", false +} + +// nestedSharedCanonical maps a type token found inside a shared struct's own +// fields to a canonical shared type. Within the shared package, types reference +// each other by bare name (e.g. Track.Artists is []ArtistRef), so any bare name +// present in the shared registry counts. +func nestedSharedCanonical(name string, shared map[string]StructDef) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if _, ok := shared[name]; ok { + return name, true + } + return "", false +} + // collectAllStructDependencies recursively collects all struct types referenced by other structs. func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) { // Keep iterating until no new types are added @@ -301,14 +483,10 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri return export, nil } -// parseFile parses a single Go source file and extracts host services. -func parseFile(fset *token.FileSet, path string) ([]Service, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err - } - - // First pass: collect all struct definitions in the file +// parseServiceFile parses a single Go source file and extracts host services. +// pkgAliasMap is the package-wide alias map built from all files in the package. +func parseServiceFile(f *ast.File, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { + // Collect all struct definitions in the file. allStructs := parseStructs(f) structMap := make(map[string]StructDef) for _, s := range allStructs { @@ -382,6 +560,15 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) { } } + // Resolve shared-type aliases against the registry. Host-service schemas + // are not generated (the -schemas pass is capability-only), so the resolved + // shared shapes are not needed here. + sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared) + if err != nil { + return nil, err + } + service.SharedAliases = sharedAliases + // Attach referenced structs to the service (sorted for stable output) for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { if s, exists := structMap[typeName]; exists { @@ -469,9 +656,10 @@ func parseTypeAliases(f *ast.File) []TypeAlias { docText, _ := getDocComment(genDecl, typeSpec) aliases = append(aliases, TypeAlias{ - Name: typeSpec.Name.Name, - Type: typeToString(typeSpec.Type), - Doc: cleanDoc(docText), + Name: typeSpec.Name.Name, + Type: typeToString(typeSpec.Type), + Doc: cleanDoc(docText), + IsAlias: typeSpec.Assign.IsValid(), }) } } @@ -640,6 +828,14 @@ func collectReferencedTypes(goType string, refs map[string]bool) { return } + // Qualified reference to the shared `types` package (e.g. types.Track). + // These start with a lowercase package selector, so they must be collected + // before the uppercase check below would skip them. + if strings.HasPrefix(goType, sharedTypesPrefix) { + refs[goType] = true + return + } + // Check if it's a custom type (starts with uppercase, not a builtin) if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' { switch goType { diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f43578397..93d00f657 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -212,6 +212,78 @@ type RegularInterface interface { Expect(err).NotTo(HaveOccurred()) Expect(services).To(BeEmpty()) }) + + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +type TrackInfo = types.TrackInfo +` + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + Match(ctx context.Context, t TrackInfo) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + _, err := ParseDirectoryWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases declared in a sibling file (package-wide alias map)", func() { + // File A: declares the shared-type alias in the same package + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type Track = types.Track +` + // File B: declares the host service that references Track from file A + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + MatchSongs(ctx context.Context, query string) (results []Track, err error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "Track": { + Name: "Track", + Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artist", Type: "string", JSONTag: "artist"}, + }, + }, + } + + services, err := ParseDirectoryWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range services[0].SharedAliases { + byName[a.Name] = a + } + // Track alias is in a sibling file — must be resolved package-wide + Expect(byName).To(HaveKey("Track")) + Expect(byName["Track"].Target).To(Equal("types.Track")) + }) }) Describe("parseKeyValuePairs", func() { @@ -520,6 +592,182 @@ type Output struct { Expect(capabilities[0].Methods).To(HaveLen(1)) Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod")) }) + + It("distinguishes Go type aliases from defined types", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// ScrobblerError is a sentinel error string. +type ScrobblerError string + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_check + Check(ArtistRef) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + } + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + // ArtistRef is a shared-type alias (types.*): it lands in SharedAliases, not TypeAliases. + sharedByName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + sharedByName[a.Name] = a + } + Expect(sharedByName).To(HaveKey("ArtistRef")) + Expect(sharedByName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // ScrobblerError is a plain defined type: it stays in TypeAliases. + typeByName := map[string]TypeAlias{} + for _, a := range caps[0].TypeAliases { + typeByName[a.Name] = a + } + Expect(typeByName).To(HaveKey("ScrobblerError")) + Expect(typeByName["ScrobblerError"].IsAlias).To(BeFalse()) + }) + }) + + Describe("ParseCapabilitiesWithShared", func() { + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// TrackInfo is an alias for the shared type. +type TrackInfo = types.TrackInfo + +//nd:capability name=nowplaying required=true +type NowPlaying interface { + //nd:export name=nd_now_playing + NowPlaying(TrackInfo) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "nowplaying.go"), []byte(src), 0600)).To(Succeed()) + + _, err := ParseCapabilitiesWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases against the registry", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "TrackInfo": {Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.TrackInfo. +type TrackInfo = types.TrackInfo + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track TrackInfo ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + names := []string{} + for _, a := range caps[0].SharedAliases { + names = append(names, a.Name) + } + // TrackInfo is referenced directly; ArtistRef is pulled in transitively via TrackInfo.Artists. + Expect(names).To(ContainElements("TrackInfo", "ArtistRef")) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + Expect(byName["TrackInfo"].Target).To(Equal("types.TrackInfo")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + }) + + It("resolves shared types from qualified types.X references with a renamed alias", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "Track": {Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type TrackInfo = types.Track + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track types.Track ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + // The deprecated alias keeps its name (TrackInfo) but now targets types.Track. + // ArtistRef is pulled in transitively via Track.Artists. + Expect(byName).To(HaveKey("TrackInfo")) + Expect(byName).To(HaveKey("ArtistRef")) + Expect(byName["TrackInfo"].Target).To(Equal("types.Track")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // The capability struct field keeps the canonical qualified reference. + var nowPlaying StructDef + for _, st := range caps[0].Structs { + if st.Name == "NowPlayingRequest" { + nowPlaying = st + } + } + Expect(nowPlaying.Fields).To(HaveLen(1)) + Expect(nowPlaying.Fields[0].Type).To(Equal("types.Track")) + }) }) Describe("Export helpers", func() { diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index ebcd80739..8b88f367c 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -8,9 +8,21 @@ package {{.Package}} import ( +{{- if .Capability.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 790ed93e4..2ee7ece1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -2,7 +2,7 @@ // // This file contains export wrappers for the {{.Capability.Interface}} capability. // It is intended for use in Navidrome plugins built with extism-pdk. -{{if .Capability.Structs}} +{{if or .Capability.Structs .Capability.SharedAliases}} use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; @@ -24,6 +24,13 @@ fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } {{- end}} +{{- /* Generate deprecated aliases to the shared types crate */ -}} +{{- range .Capability.SharedAliases}} + +#[deprecated(note = "use {{rustSharedNote .Target}}")] +pub type {{.Name}} = {{rustSharedTarget .Target}}; +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} @@ -131,9 +138,9 @@ macro_rules! register_{{snakeCase .Package}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; @@ -178,9 +185,9 @@ macro_rules! {{registerMacroName .Name}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index 90f72be93..59a886f08 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -7,6 +7,19 @@ //go:build !wasip1 package {{.Package}} +{{- if .Capability.ImportsSharedTypes}} + +import "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee04446..b49680d1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -12,10 +12,23 @@ import ( {{- if .Service.HasErrors}} "errors" {{- end}} +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate struct definitions */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl deleted file mode 100644 index 7ccaa6106..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ /dev/null @@ -1,111 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the {{.Service.Name}} 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 - -import extism -import json -{{- if .Service.HasByteFields}} -import base64 -{{- end}} - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - -{{- /* Generate raw host function imports */ -}} -{{range .Service.Methods}} - - -@extism.import_fn("extism:host/user", "{{exportName .}}") -def _{{exportName .}}(offset: int) -> int: - """Raw host function - do not call directly.""" - ... -{{- end}} -{{- /* Generate dataclasses for multi-value returns */ -}} -{{range .Service.Methods}} -{{- if .NeedsResultClass}} - - -@dataclass -class {{pythonResultType .}}: - """Result type for {{pythonFunc .}}.""" -{{- range .Returns}} - {{.PythonName}}: {{.PythonType}} -{{- end}} -{{- end}} -{{- end}} -{{- /* Generate wrapper functions */ -}} -{{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}}: - """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} -{{- if .HasParams}} - - Args: -{{- range .Params}} - {{.PythonName}}: {{.PythonType}} parameter. -{{- end}} -{{- end}} -{{- if .HasReturns}} - - Returns: -{{- if .NeedsResultClass}} - {{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}. -{{- else}} - {{(index .Returns 0).PythonType}}: The result value. -{{- end}} -{{- end}} - - Raises: - HostFunctionError: If the host function returns an error. - """ -{{- if .HasParams}} - request = { -{{- range .Params}} -{{- if .IsByteSlice}} - "{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"), -{{- else}} - "{{.JSONName}}": {{.PythonName}}, -{{- end}} -{{- end}} - } - request_bytes = json.dumps(request).encode("utf-8") -{{- else}} - request_bytes = b"{}" -{{- end}} - request_mem = extism.memory.alloc(request_bytes) - response_offset = _{{exportName .}}(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) -{{if .HasError}} - if response.get("error"): - raise HostFunctionError(response["error"]) -{{end}} -{{- if .NeedsResultClass}} - return {{pythonResultType .}}( -{{- range .Returns}} -{{- if .IsByteSlice}} - {{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")), -{{- else}} - {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}), -{{- end}} -{{- end}} - ) -{{- else if .HasReturns}} -{{- if (index .Returns 0).IsByteSlice}} - return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", "")) -{{- else}} - return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) -{{- end}} -{{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index da19df666..d2a8f3f27 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -8,7 +8,21 @@ package {{.Package}} -import "github.com/stretchr/testify/mock" +import ( +{{- if .Service.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + "github.com/stretchr/testify/mock" +) + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl new file mode 100644 index 000000000..698429b96 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl @@ -0,0 +1,23 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package {{.Package}} holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package {{.Package}} +{{- range .Structs}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- else}} +// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +type {{.Name}} struct { +{{- range .Fields}} +{{- if .Doc}} +{{formatDoc .Doc | indent 1}} +{{- end}} + {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl new file mode 100644 index 000000000..b7bc131ff --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl @@ -0,0 +1,49 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; +{{- if .HasHashMap}} +use std::collections::HashMap; +{{- end}} +{{- if .HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +{{- range .Structs}} + +{{- if .Doc}} +{{rustDocComment .Doc}} +{{- else}} +/// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct {{.Name}} { +{{- range .Fields}} +{{- if .Doc}} +{{rustDocComment .Doc | indent 4}} +{{- end}} +{{- if .OmitEmpty}} + #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] +{{- else}} + #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} + pub {{rustFieldName .Name}}: {{fieldRustType .}}, +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 6132dfbc4..09657d09e 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -5,34 +5,59 @@ import ( "unicode" ) +// sharedTypesPrefix is the package selector a capability or host-service source +// uses to reference the shared types package (e.g. types.Track). +const sharedTypesPrefix = "types." + // Service represents a parsed host service interface. type Service struct { - Name string // Service name from annotation (e.g., "SubsonicAPI") - Permission string // Manifest permission key (e.g., "subsonicapi") - Interface string // Go interface name (e.g., "SubsonicAPIService") - Methods []Method // Methods marked with //nd:hostfunc - Doc string // Documentation comment for the service - Structs []StructDef // Structs used by this service + Name string // Service name from annotation (e.g., "SubsonicAPI") + Permission string // Manifest permission key (e.g., "subsonicapi") + Interface string // Go interface name (e.g., "SubsonicAPIService") + Methods []Method // Methods marked with //nd:hostfunc + Doc string // Documentation comment for the service + Structs []StructDef // Structs used by this service + SharedAliases []SharedAlias // Aliases to types in the shared `types` package } // Capability represents a parsed capability interface for plugin exports. type Capability struct { - Name string // Package name from annotation (e.g., "metadata") - Interface string // Go interface name (e.g., "MetadataAgent") - Required bool // If true, all methods must be implemented - Methods []Export // Methods marked with //nd:export - Doc string // Documentation comment for the capability - Structs []StructDef // Structs used by this capability - TypeAliases []TypeAlias // Type aliases used by this capability - Consts []ConstGroup // Const groups used by this capability - SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + Name string // Package name from annotation (e.g., "metadata") + Interface string // Go interface name (e.g., "MetadataAgent") + Required bool // If true, all methods must be implemented + Methods []Export // Methods marked with //nd:export + Doc string // Documentation comment for the capability + Structs []StructDef // Structs used by this capability + TypeAliases []TypeAlias // Type aliases used by this capability + Consts []ConstGroup // Const groups used by this capability + SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + SharedAliases []SharedAlias // Aliases to types in the shared `types` package + SharedTypes []StructDef // Resolved shapes of every used shared type, keyed by canonical name (for schema inlining, alias or not) } -// TypeAlias represents a type alias definition (e.g., type ScrobblerErrorType string). +// TypeAlias represents a type declaration (e.g. type ScrobblerErrorType string) +// or a Go type alias (e.g. type TrackInfo = types.Track). type TypeAlias struct { - Name string // Type name - Type string // Underlying type - Doc string // Documentation comment + Name string // Type name + Type string // Underlying type (or alias target, e.g. "types.Track") + Doc string // Documentation comment + IsAlias bool // true for `type X = Y` (alias); false for `type X Y` (defined type) +} + +// IsDeprecated reports whether the alias carries a `Deprecated:` doc line. +func (t TypeAlias) IsDeprecated() bool { + for _, line := range strings.Split(t.Doc, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "Deprecated:") { + return true + } + } + return false +} + +// IsSharedAlias reports whether this alias targets the shared types package +// (e.g. `type TrackInfo = types.Track`). +func (t TypeAlias) IsSharedAlias() bool { + return t.IsAlias && strings.HasPrefix(t.Type, sharedTypesPrefix) } // ConstGroup represents a group of const definitions. @@ -48,12 +73,90 @@ type ConstDef struct { Doc string // Documentation comment } -// KnownStructs returns a map of struct names defined in this capability. +// SharedAlias is a deprecated alias from a capability/host package to a type in +// the shared `types` package (e.g. type TrackInfo = types.Track). Def is the +// resolved shared struct, kept for XTP schema inlining. +type SharedAlias struct { + Name string // local name, e.g. "TrackInfo" + Target string // alias target, e.g. "types.Track" + Doc string // doc comment (carries the Deprecated: line) + Def StructDef // resolved shared struct shape +} + +// ImportsSharedTypes reports whether this capability references the shared types package. +// A reference can come from a deprecated re-export alias (e.g. type SongRef = types.SongRef) +// or directly from the canonical form (e.g. types.SongRef) in a struct field or a method +// signature, so the generated Go import must be emitted even when no alias is declared. +func (c Capability) ImportsSharedTypes() bool { + if len(c.SharedAliases) > 0 || structsReferenceSharedTypes(c.Structs) { + return true + } + for _, m := range c.Methods { + if typeReferencesSharedTypes(m.Input.Type) || typeReferencesSharedTypes(m.Output.Type) { + return true + } + } + return false +} + +// ImportsSharedTypes reports whether this service references the shared types package. +func (s Service) ImportsSharedTypes() bool { + if len(s.SharedAliases) > 0 || structsReferenceSharedTypes(s.Structs) { + return true + } + for _, m := range s.Methods { + for _, p := range m.Params { + if typeReferencesSharedTypes(p.Type) { + return true + } + } + for _, r := range m.Returns { + if typeReferencesSharedTypes(r.Type) { + return true + } + } + } + return false +} + +// structsReferenceSharedTypes reports whether any field across the given structs +// refers to the shared types package by its qualified name (e.g. types.SongRef, +// []types.SongRef, map[string]types.SongRef). +func structsReferenceSharedTypes(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if typeReferencesSharedTypes(f.Type) { + return true + } + } + } + return false +} + +// typeReferencesSharedTypes reports whether a Go type expression refers to the +// shared types package by its qualified name, accounting for pointer, slice, and +// map wrappers (e.g. types.SongRef, []types.SongRef, map[string]types.SongRef). +func typeReferencesSharedTypes(goType string) bool { + refs := map[string]bool{} + collectReferencedTypes(goType, refs) + for t := range refs { + if strings.HasPrefix(t, sharedTypesPrefix) { + return true + } + } + return false +} + +// KnownStructs returns a map of struct names defined in this capability, +// including shared-alias names so Rust field-type resolution finds them. func (c Capability) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range c.Structs { result[st.Name] = true } + for _, sa := range c.SharedAliases { + result[sa.Name] = true + } return result } @@ -154,12 +257,16 @@ func (s Service) ExportPrefix() string { return strings.ToLower(s.Name) } -// KnownStructs returns a map of struct names defined in this service. +// KnownStructs returns a map of struct names defined in this service, +// including shared-alias names so Rust field-type resolution finds them. func (s Service) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range s.Structs { result[st.Name] = true } + for _, sa := range s.SharedAliases { + result[sa.Name] = true + } return result } @@ -427,24 +534,6 @@ func toJSONName(name string) string { return string(result) } -// ToPythonType converts a Go type to its Python equivalent. -func ToPythonType(goType string) string { - switch goType { - case "string": - return "str" - case "int", "int32", "int64": - return "int" - case "float32", "float64": - return "float" - case "bool": - return "bool" - case "[]byte": - return "bytes" - default: - return "Any" - } -} - // ToSnakeCase converts a PascalCase or camelCase string to snake_case. // It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id"). func ToSnakeCase(s string) string { @@ -469,31 +558,6 @@ func ToSnakeCase(s string) string { return strings.ToLower(result.String()) } -// PythonFunctionName returns the Python function name for a method. -func (m Method) PythonFunctionName(servicePrefix string) string { - return ToSnakeCase(servicePrefix + m.Name) -} - -// PythonResultTypeName returns the Python dataclass name for multi-value returns. -func (m Method) PythonResultTypeName(serviceName string) string { - return serviceName + m.Name + "Result" -} - -// NeedsResultClass returns true if the method needs a dataclass for returns. -func (m Method) NeedsResultClass() bool { - return len(m.Returns) > 1 -} - -// PythonType returns the Python type for this parameter. -func (p Param) PythonType() string { - return ToPythonType(p.Type) -} - -// PythonName returns the snake_case Python name for this parameter. -func (p Param) PythonName() string { - return ToSnakeCase(p.Name) -} - // ToRustType converts a Go type to its Rust equivalent. func ToRustType(goType string) string { return ToRustTypeWithStructs(goType, nil) @@ -562,6 +626,13 @@ func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustTypeWithShared returns the Rust type, resolving shared-alias names to their +// canonical nd_pdk_types::X crate path (e.g. a return of []Track where +// type Track = types.Track renders as Vec). +func (p Param) RustTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustParamType returns the Rust type for this parameter when used as a function argument. func (p Param) RustParamType() string { return RustParamType(p.Type) @@ -575,6 +646,15 @@ func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustParamTypeWithShared returns the Rust param type, resolving shared-alias +// names to their canonical nd_pdk_types::X crate path. +func (p Param) RustParamTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + if p.Type == "string" { + return "&str" + } + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustName returns the snake_case Rust name for this parameter. func (p Param) RustName() string { return ToSnakeCase(p.Name) @@ -604,9 +684,18 @@ func (f FieldDef) NeedsDefault() bool { // ToRustTypeWithStructs converts a Go type to its Rust equivalent, // using known struct names instead of serde_json::Value. func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { + return toRustType(goType, knownStructs, nil) +} + +// ToRustTypeWithShared resolves shared-alias names to their canonical nd_pdk_types::X path. +func ToRustTypeWithShared(goType string, knownStructs map[string]bool, shared map[string]string) string { + return toRustType(goType, knownStructs, shared) +} + +func toRustType(goType string, knownStructs map[string]bool, shared map[string]string) string { // Handle pointer types if strings.HasPrefix(goType, "*") { - inner := ToRustTypeWithStructs(goType[1:], knownStructs) + inner := toRustType(goType[1:], knownStructs, shared) return "Option<" + inner + ">" } // Handle slice types @@ -614,7 +703,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { if goType == "[]byte" { return "Vec" } - inner := ToRustTypeWithStructs(goType[2:], knownStructs) + inner := toRustType(goType[2:], knownStructs, shared) return "Vec<" + inner + ">" } // Handle map types @@ -636,7 +725,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { } keyType := rest[:keyEnd] valueType := rest[keyEnd+1:] - return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">" + return "std::collections::HashMap<" + toRustType(keyType, knownStructs, shared) + ", " + toRustType(valueType, knownStructs, shared) + ">" } switch goType { @@ -659,6 +748,17 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { case "interface{}", "any": return "serde_json::Value" default: + // Qualified reference to the shared types crate (e.g. types.Track -> + // nd_pdk_types::Track). + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + // Resolve shared-alias names to their canonical nd_pdk_types:: path. + if shared != nil { + if t, ok := shared[goType]; ok { + return t + } + } // Check if this is a known struct type if knownStructs != nil && knownStructs[goType] { return goType diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index cc2a7d0e0..20c067ffa 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -60,16 +60,18 @@ type ( func GenerateSchema(cap Capability) ([]byte, error) { schema := xtpSchema{Version: "v1-draft"} + aliasToCanonical := buildAliasToCanonical(cap) + // Build exports as ordered map if len(cap.Methods) > 0 { schema.Exports = yaml.Node{Kind: yaml.MappingNode} for _, export := range cap.Methods { - addToMap(&schema.Exports, export.ExportName, buildExport(export)) + addToMap(&schema.Exports, export.ExportName, buildExport(export, aliasToCanonical)) } } // Build components/schemas - schemas := buildSchemas(cap) + schemas := buildSchemas(cap, aliasToCanonical) if len(schemas.Content) > 0 { schema.Components = &xtpComponents{Schemas: schemas} } @@ -77,11 +79,22 @@ func GenerateSchema(cap Capability) ([]byte, error) { return yaml.Marshal(schema) } -func buildExport(export Export) xtpExport { +// buildAliasToCanonical maps each deprecated shared-alias name to the canonical +// shared type it targets (e.g. TrackInfo -> Track). Schema components are emitted +// under the canonical name, so every $ref site must resolve through this map. +func buildAliasToCanonical(cap Capability) map[string]string { + m := map[string]string{} + for _, a := range cap.SharedAliases { + m[a.Name] = strings.TrimPrefix(a.Target, sharedTypesPrefix) + } + return m +} + +func buildExport(export Export, aliasToCanonical map[string]string) xtpExport { e := xtpExport{Description: cleanDocForYAML(export.Doc)} if export.Input.Type != "" { e.Input = &xtpIOParam{ - Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"), + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(export.Input.Type), aliasToCanonical), ContentType: "application/json", } } @@ -95,7 +108,7 @@ func buildExport(export Export) xtpExport { } } else { e.Output = &xtpIOParam{ - Ref: "#/components/schemas/" + outputType, + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(outputType), aliasToCanonical), ContentType: "application/json", } } @@ -112,15 +125,38 @@ func isPrimitiveGoType(goType string) bool { return false } -func buildSchemas(cap Capability) yaml.Node { +func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node { schemas := yaml.Node{Kind: yaml.MappingNode} knownTypes := cap.KnownStructs() for _, alias := range cap.TypeAliases { knownTypes[alias.Name] = true } + // Register shared types under their canonical name (e.g. types.Track -> Track) + // and stash their struct shapes for inlining. SharedTypes covers every used + // shared type, including ones referenced directly as types.X with no declared + // deprecated alias; SharedAliases is folded in for completeness. + sharedDefs := map[string]StructDef{} + for _, def := range cap.SharedTypes { + knownTypes[def.Name] = true + sharedDefs[def.Name] = def + } + for _, a := range cap.SharedAliases { + canonical := strings.TrimPrefix(a.Target, sharedTypesPrefix) + knownTypes[canonical] = true + sharedDefs[canonical] = a.Def + } + // Collect types that are actually used by exports - usedTypes := collectUsedTypes(cap, knownTypes) + usedTypes := collectUsedTypes(cap, knownTypes, sharedDefs) + + // A used alias name (e.g. TrackInfo) implies its canonical component (Track) is + // used, since the alias-typed field's $ref resolves to the canonical name. + for alias, canonical := range aliasToCanonical { + if usedTypes[alias] { + usedTypes[canonical] = true + } + } // Sort structs by name for consistent output structNames := make([]string, 0, len(cap.Structs)) @@ -135,7 +171,19 @@ func buildSchemas(cap Capability) yaml.Node { for _, name := range structNames { st := structMap[name] - addToMap(&schemas, name, buildObjectSchema(st, knownTypes)) + addToMap(&schemas, name, buildObjectSchema(st, knownTypes, aliasToCanonical)) + } + + // Emit components for used shared aliases (sorted for deterministic output). + sharedNames := make([]string, 0, len(sharedDefs)) + for name, def := range sharedDefs { + if usedTypes[name] && len(def.Fields) > 0 { + sharedNames = append(sharedNames, name) + } + } + sort.Strings(sharedNames) + for _, name := range sharedNames { + addToMap(&schemas, name, buildObjectSchema(sharedDefs[name], knownTypes, aliasToCanonical)) } // Build enum types from type aliases (only if used by exports) @@ -157,18 +205,18 @@ func buildSchemas(cap Capability) yaml.Node { } // collectUsedTypes returns a set of type names that are reachable from exports. -func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]bool { +func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef) map[string]bool { used := make(map[string]bool) // Start with types directly referenced by exports for _, export := range cap.Methods { if export.Input.Type != "" { - addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used) + addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, sharedDefs, used) } if export.Output.Type != "" { outputType := strings.TrimPrefix(export.Output.Type, "*") if !isPrimitiveGoType(outputType) { - addTypeAndDeps(outputType, cap, knownTypes, used) + addTypeAndDeps(outputType, cap, knownTypes, sharedDefs, used) } } } @@ -177,28 +225,55 @@ func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]boo } // addTypeAndDeps adds a type and all its dependencies to the used set. -func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) { +func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef, used map[string]bool) { + typeName = strings.TrimPrefix(typeName, sharedTypesPrefix) if used[typeName] || !knownTypes[typeName] { return } used[typeName] = true - // Find the struct and add its field types + // Walk fields of capability-local structs. for _, st := range cap.Structs { if st.Name == typeName { for _, field := range st.Fields { - fieldType := strings.TrimPrefix(field.Type, "*") - fieldType = strings.TrimPrefix(fieldType, "[]") - if knownTypes[fieldType] { - addTypeAndDeps(fieldType, cap, knownTypes, used) + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) } } return } } + + // Walk fields of shared structs so their nested refs are also marked used. + if def, ok := sharedDefs[typeName]; ok { + for _, field := range def.Fields { + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) + } + } + } } -func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema { +// fieldBaseType reduces a field type to the base named type used for schema +// lookups: it strips a leading pointer/slice and any shared `types.` selector. +func fieldBaseType(goType string) string { + goType = strings.TrimPrefix(goType, "*") + goType = strings.TrimPrefix(goType, "[]") + return strings.TrimPrefix(goType, sharedTypesPrefix) +} + +// canonicalRefName resolves a deprecated shared-alias name to the canonical type +// the schema component is emitted under (e.g. TrackInfo -> Track). Non-alias +// names pass through unchanged, so $ref targets always point at an emitted +// component instead of a dangling alias name. +func canonicalRefName(name string, aliasToCanonical map[string]string) string { + if canonical, ok := aliasToCanonical[name]; ok { + return canonical + } + return name +} + +func buildObjectSchema(st StructDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpObjectSchema { schema := xtpObjectSchema{ Description: cleanDocForYAML(st.Doc), Properties: yaml.Node{Kind: yaml.MappingNode}, @@ -206,7 +281,7 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema for _, field := range st.Fields { propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) + addToMap(&schema.Properties, propName, buildProperty(field, knownTypes, aliasToCanonical)) if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { schema.Required = append(schema.Required, propName) @@ -228,7 +303,7 @@ func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema { } } -func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { +func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpProperty { goType := field.Type isPointer := strings.HasPrefix(goType, "*") if isPointer { @@ -240,9 +315,10 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { Nullable: isPointer, } - // Handle reference types (use $ref instead of type) - if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") { - prop.Ref = "#/components/schemas/" + goType + // Handle reference types (use $ref instead of type). Qualified shared + // references (types.X) are referenced by their canonical name. + if refType := strings.TrimPrefix(goType, sharedTypesPrefix); isKnownType(refType, knownTypes) && !strings.HasPrefix(goType, "[]") { + prop.Ref = "#/components/schemas/" + canonicalRefName(refType, aliasToCanonical) return prop } @@ -254,11 +330,11 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { // Handle slice types if strings.HasPrefix(goType, "[]") { - elemType := goType[2:] + elemType := strings.TrimPrefix(goType[2:], sharedTypesPrefix) prop.Type = "array" prop.Items = &xtpProperty{} if isKnownType(elemType, knownTypes) { - prop.Items.Ref = "#/components/schemas/" + elemType + prop.Items.Ref = "#/components/schemas/" + canonicalRefName(elemType, aliasToCanonical) } else { prop.Items.Type = goTypeToXTPType(elemType) } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 2e28a75d8..f8702aa4d 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -700,6 +700,115 @@ var _ = Describe("XTP Schema Generation", func() { }) }) + Describe("GenerateSchema with shared aliases", func() { + It("inlines shared-alias shapes as schema components", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + Expect(string(out)).To(ContainSubstring("TrackInfo:")) + Expect(string(out)).To(ContainSubstring("title:")) + }) + + It("names the shared component by its canonical type for qualified types.X fields", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Component is named by the canonical type (Track), not the deprecated alias. + Expect(s).To(ContainSubstring("Track:")) + Expect(s).NotTo(ContainSubstring("TrackInfo:")) + // The field $ref points at the canonical component. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).To(ContainSubstring("title:")) + }) + + It("points an alias-named field at the canonical component for a renamed alias", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "PlayRequest"}}}, + Structs: []StructDef{{Name: "PlayRequest", Fields: []FieldDef{ + // Field is typed with the deprecated alias name, not the canonical types.Track. + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // The component is emitted under the canonical name, and the field $ref must + // point at it — not at a non-existent TrackInfo component (dangling reference). + Expect(s).To(ContainSubstring("Track:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("points an alias-named export input/output at the canonical component", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + // The method takes/returns the deprecated alias name directly. + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "TrackInfo"}, + Output: Param{Name: "output", Type: "TrackInfo"}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Export $ref must resolve to the canonical component, not a missing TrackInfo. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("inlines a directly-referenced shared type that has no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}}}}, + // No SharedAliases: the field references the canonical type directly. + SharedTypes: []StructDef{{Name: "SongRef", Fields: []FieldDef{ + {Name: "Name", Type: "string", JSONTag: "name"}}}}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + Expect(s).To(ContainSubstring("SongRef:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/SongRef'")) + Expect(s).To(ContainSubstring("name:")) + }) + }) + Describe("GenerateSchema enum filtering", func() { It("should only include enums that are actually used by exports", func() { capability := Capability{ diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index b34ee4296..6520a3ecd 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -19,7 +19,7 @@ // // Output directories: // - Host wrappers: $input/_gen.go (server-side, used by Navidrome) -// - Host functions: $output/go/host/, $output/python/host/, $output/rust/host/ +// - Host functions: $output/go/host/, $output/rust/host/ // - Capabilities: $output/go// (e.g., $output/go/metadata/) // - Schemas: $input/.yaml (co-located with Go sources) // @@ -32,8 +32,7 @@ // -host-only Generate PDK client wrappers for calling host functions // -capability-only Generate only capability export wrappers // -schemas Generate XTP YAML schemas from capabilities -// -go Generate Go client wrappers (default: true when not using -python/-rust) -// -python Generate Python client wrappers (default: false) +// -go Generate Go client wrappers (default: true when not using -rust) // -rust Generate Rust client wrappers (default: false) // -v Verbose output // -dry-run Preview generated code without writing files @@ -43,8 +42,10 @@ import ( "flag" "fmt" "go/format" + "maps" "os" "path/filepath" + "slices" "strings" "github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal" @@ -55,16 +56,16 @@ type config struct { inputDir string outputDir string // Base output directory (e.g., plugins/pdk) goOutputDir string // Go output: $outputDir/go/host (for host-only) - pythonOutputDir string // Python output: $outputDir/python/host rustOutputDir string // Rust output: $outputDir/rust/host pkgName string hostOnly bool hostWrappers bool // Generate host wrappers (used by Navidrome server) capabilityOnly bool - schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) - pdkOnly bool // Generate PDK abstraction layer wrapper + schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) + pdkOnly bool // Generate PDK abstraction layer wrapper + sharedTypes bool // Generate the shared types package + sharedDir string // Directory of shared types to load as a registry generateGoClient bool - generatePyClient bool generateRsClient bool verbose bool dryRun bool @@ -85,6 +86,14 @@ func main() { return } + if cfg.sharedTypes { + if err := runSharedTypesGeneration(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + if cfg.pdkOnly { if err := runPDKGeneration(cfg); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -157,6 +166,69 @@ func runSchemaGeneration(cfg *config) error { return generateSchemas(cfg, capabilities) } +// writeGenerated creates dir (if needed) and writes content to name inside it. +// In dry-run mode it prints the content instead. +func writeGenerated(dir, name string, content []byte, dryRun, verbose bool) error { + path := filepath.Join(dir, name) + if dryRun { + fmt.Printf("=== %s ===\n%s\n", path, content) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory %s: %w", dir, err) + } + if err := os.WriteFile(path, content, 0600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + if verbose { + fmt.Printf("Generated: %s\n", path) + } + return nil +} + +// runSharedTypesGeneration handles shared types package generation. +func runSharedTypesGeneration(cfg *config) error { + structs, err := internal.LoadSharedTypes(cfg.inputDir) + if err != nil { + return err + } + if len(structs) == 0 { + return nil + } + list := slices.Collect(maps.Values(structs)) + if cfg.generateGoClient { + code, err := internal.GenerateSharedTypesGo(list, "types") + if err != nil { + return fmt.Errorf("generating Go types: %w", err) + } + formatted, err := format.Source(code) + if err != nil { + return fmt.Errorf("formatting Go types: %w\n%s", err, code) + } + dir := filepath.Join(cfg.outputDir, "go", "types") + if err := writeGenerated(dir, "types.go", formatted, cfg.dryRun, cfg.verbose); err != nil { + return err + } + } + if cfg.generateRsClient { + if err := generateSharedTypesRust(list, cfg); err != nil { + return err + } + } + return nil +} + +// generateSharedTypesRust writes the nd-pdk-types crate root to +// /rust/nd-pdk-types/src/lib.rs. +func generateSharedTypesRust(structs []internal.StructDef, cfg *config) error { + code, err := internal.GenerateSharedTypesRust(structs) + if err != nil { + return fmt.Errorf("generating Rust types: %w", err) + } + dir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-types", "src") + return writeGenerated(dir, "lib.rs", code, cfg.dryRun, cfg.verbose) +} + // runPDKGeneration handles PDK abstraction layer code generation. // This generates the pdk wrapper package that wraps extism/go-pdk // with mockable implementations for unit testing on native platforms. @@ -315,8 +387,9 @@ func parseConfig() (*config, error) { capabilityOnly = flag.Bool("capability-only", false, "Generate only capability export wrappers") schemasOnly = flag.Bool("schemas", false, "Generate XTP YAML schemas from capabilities (output to input directory)") pdkOnly = flag.Bool("extism-pdk", false, "Generate PDK abstraction layer by parsing extism/go-pdk") + sharedTypes = flag.Bool("shared-types", false, "Generate the shared types package") + shared = flag.String("shared", "", "Directory of shared types to load as a registry") goClient = flag.Bool("go", false, "Generate Go client wrappers") - pyClient = flag.Bool("python", false, "Generate Python client wrappers") rsClient = flag.Bool("rust", false, "Generate Rust client wrappers") verbose = flag.Bool("v", false, "Verbose output") dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files") @@ -340,6 +413,9 @@ func parseConfig() (*config, error) { if *pdkOnly { modeCount++ } + if *sharedTypes { + modeCount++ + } // Default to host-only if no mode is specified if modeCount == 0 { @@ -348,7 +424,7 @@ func parseConfig() (*config, error) { // Cannot specify multiple modes if modeCount > 1 { - return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -pdk)") + return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -extism-pdk, -shared-types)") } if *outputDir == "" { @@ -372,23 +448,28 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("resolving output path: %w", err) } + absShared := "" + if *shared != "" { + absShared, err = filepath.Abs(*shared) + if err != nil { + return nil, fmt.Errorf("resolving shared path: %w", err) + } + } + // Set output directories for each language // Go host wrappers: $output/go/host/ - // Python host wrappers: $output/python/host/ // Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate) absGoOutput := filepath.Join(absOutput, "go", "host") - absPythonOutput := filepath.Join(absOutput, "python", "host") absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host") // Determine what to generate // Default: generate Go clients if no language flag is specified - anyLangFlag := *goClient || *pyClient || *rsClient + anyLangFlag := *goClient || *rsClient return &config{ inputDir: absInput, outputDir: absOutput, goOutputDir: absGoOutput, - pythonOutputDir: absPythonOutput, rustOutputDir: absRustOutput, pkgName: *pkgName, hostOnly: *hostOnly, @@ -396,8 +477,9 @@ func parseConfig() (*config, error) { capabilityOnly: *capabilityOnly, schemasOnly: *schemasOnly, pdkOnly: *pdkOnly, + sharedTypes: *sharedTypes, + sharedDir: absShared, generateGoClient: *goClient || !anyLangFlag, - generatePyClient: *pyClient, generateRsClient: *rsClient, verbose: *verbose, dryRun: *dryRun, @@ -412,20 +494,21 @@ func parseServices(cfg *config) ([]internal.Service, error) { if cfg.generateGoClient { fmt.Printf("Go output directory: %s\n", cfg.goOutputDir) } - if cfg.generatePyClient { - fmt.Printf("Python output directory: %s\n", cfg.pythonOutputDir) - } if cfg.generateRsClient { fmt.Printf("Rust output directory: %s\n", cfg.rustOutputDir) } fmt.Printf("Package name: %s\n", cfg.pkgName) fmt.Printf("Host-only mode: %v\n", cfg.hostOnly) fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient) - fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient) fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient) } - services, err := internal.ParseDirectory(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + services, err := internal.ParseDirectoryWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing source files: %w", err) } @@ -455,7 +538,12 @@ func parseCapabilities(cfg *config) ([]internal.Capability, error) { fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly) } - capabilities, err := internal.ParseCapabilities(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + capabilities, err := internal.ParseCapabilitiesWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing capability files: %w", err) } @@ -619,11 +707,6 @@ func generateAllCode(cfg *config, services []internal.Service) error { return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err) } } - if cfg.generatePyClient { - if err := generatePythonClientCode(svc, cfg.pythonOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err) - } - } if cfg.generateRsClient { if err := generateRustClientCode(svc, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err) @@ -757,36 +840,6 @@ func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, d return nil } -// generatePythonClientCode generates Python client-side code for a service. -func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateClientPython(svc) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - // Python code goes directly in the output directory - clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".py") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", clientFile, code) - return nil - } - - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating python client directory: %w", err) - } - - if err := os.WriteFile(clientFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Python client code: %s\n", clientFile) - } - return nil -} - // generateRustClientCode generates Rust client-side code for a service. func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { code, err := internal.GenerateClientRust(svc) diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.py b/plugins/cmd/ndpgen/testdata/codec_client_expected.py deleted file mode 100644 index 5142ffd0e..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.py +++ /dev/null @@ -1,53 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Codec 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "codec_encode") -def _codec_encode(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def codec_encode(data: bytes) -> bytes: - """Call the codec_encode host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _codec_encode(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py deleted file mode 100644 index 93370ddcf..000000000 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py +++ /dev/null @@ -1,342 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Comprehensive 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "comprehensive_simpleparams") -def _comprehensive_simpleparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_structparam") -def _comprehensive_structparam(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mixedparams") -def _comprehensive_mixedparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noerror") -def _comprehensive_noerror(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparams") -def _comprehensive_noparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparamsnoreturns") -def _comprehensive_noparamsnoreturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_pointerparams") -def _comprehensive_pointerparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mapparams") -def _comprehensive_mapparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_multiplereturns") -def _comprehensive_multiplereturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_byteslice") -def _comprehensive_byteslice(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ComprehensiveMultipleReturnsResult: - """Result type for comprehensive_multiple_returns.""" - results: Any - total: int - - -def comprehensive_simple_params(name: str, count: int) -> str: - """Call the comprehensive_simpleparams host function. - - Args: - name: str parameter. - count: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "count": count, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_simpleparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_struct_param(user: Any) -> None: - """Call the comprehensive_structparam host function. - - Args: - user: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_structparam(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_mixed_params(id: str, filter: Any) -> int: - """Call the comprehensive_mixedparams host function. - - Args: - id: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mixedparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) - - -def comprehensive_no_error(name: str) -> str: - """Call the comprehensive_noerror host function. - - Args: - name: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noerror(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_no_params() -> None: - """Call the comprehensive_noparams host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_no_params_no_returns() -> None: - """Call the comprehensive_noparamsnoreturns host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparamsnoreturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_pointer_params(id: Any, user: Any) -> Any: - """Call the comprehensive_pointerparams host function. - - Args: - id: Any parameter. - user: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_pointerparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_map_params(data: Any) -> Any: - """Call the comprehensive_mapparams host function. - - Args: - data: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mapparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_multiple_returns(query: str) -> ComprehensiveMultipleReturnsResult: - """Call the comprehensive_multiplereturns host function. - - Args: - query: str parameter. - - Returns: - ComprehensiveMultipleReturnsResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_multiplereturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ComprehensiveMultipleReturnsResult( - results=response.get("results", None), - total=response.get("total", 0), - ) - - -def comprehensive_byte_slice(data: bytes) -> bytes: - """Call the comprehensive_byteslice host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_byteslice(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.py b/plugins/cmd/ndpgen/testdata/config_client_expected.py deleted file mode 100644 index 370de6d10..000000000 --- a/plugins/cmd/ndpgen/testdata/config_client_expected.py +++ /dev/null @@ -1,126 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_set") -def _config_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_has") -def _config_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Call the config_get host function. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_set(key: str, value: str) -> None: - """Call the config_set host function. - - Args: - key: str parameter. - value: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def config_has(key: str) -> bool: - """Call the config_has host function. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) diff --git a/plugins/cmd/ndpgen/testdata/counter_client_expected.py b/plugins/cmd/ndpgen/testdata/counter_client_expected.py deleted file mode 100644 index 872d407bb..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_client_expected.py +++ /dev/null @@ -1,49 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Counter 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "counter_count") -def _counter_count(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def counter_count(name: str) -> int: - """Call the counter_count host function. - - Args: - name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _counter_count(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("value", 0) diff --git a/plugins/cmd/ndpgen/testdata/echo_client_expected.py b/plugins/cmd/ndpgen/testdata/echo_client_expected.py deleted file mode 100644 index 06565b0d6..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Echo 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "echo_echo") -def _echo_echo(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def echo_echo(message: str) -> str: - """Call the echo_echo host function. - - Args: - message: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _echo_echo(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("reply", "") diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.py b/plugins/cmd/ndpgen/testdata/list_client_expected.py deleted file mode 100644 index 58ccad146..000000000 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the List 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "list_items") -def _list_items(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def list_items(name: str, filter: Any) -> int: - """Call the list_items host function. - - Args: - name: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _list_items(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("count", 0) diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.py b/plugins/cmd/ndpgen/testdata/math_client_expected.py deleted file mode 100644 index f3ea53335..000000000 --- a/plugins/cmd/ndpgen/testdata/math_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Math 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "math_add") -def _math_add(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def math_add(a: int, b: int) -> int: - """Call the math_add host function. - - Args: - a: int parameter. - b: int parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "a": a, - "b": b, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _math_add(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/cmd/ndpgen/testdata/meta_client_expected.py b/plugins/cmd/ndpgen/testdata/meta_client_expected.py deleted file mode 100644 index 4d20c73ff..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_client_expected.py +++ /dev/null @@ -1,81 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Meta 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "meta_get") -def _meta_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "meta_set") -def _meta_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def meta_get(key: str) -> Any: - """Call the meta_get host function. - - Args: - key: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("value", None) - - -def meta_set(data: Any) -> None: - """Call the meta_set host function. - - Args: - data: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/ping_client_expected.py b/plugins/cmd/ndpgen/testdata/ping_client_expected.py deleted file mode 100644 index 4c7d41d8e..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_client_expected.py +++ /dev/null @@ -1,42 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Ping 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "ping_ping") -def _ping_ping(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def ping_ping() -> None: - """Call the ping_ping host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _ping_ping(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.py b/plugins/cmd/ndpgen/testdata/search_client_expected.py deleted file mode 100644 index aa2e98a36..000000000 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.py +++ /dev/null @@ -1,62 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Search 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "search_find") -def _search_find(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SearchFindResult: - """Result type for search_find.""" - results: Any - total: int - - -def search_find(query: str) -> SearchFindResult: - """Call the search_find host function. - - Args: - query: str parameter. - - Returns: - SearchFindResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _search_find(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SearchFindResult( - results=response.get("results", None), - total=response.get("total", 0), - ) diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.py b/plugins/cmd/ndpgen/testdata/store_client_expected.py deleted file mode 100644 index 4a964a497..000000000 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Store 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "store_save") -def _store_save(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def store_save(item: Any) -> str: - """Call the store_save host function. - - Args: - item: Any parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "item": item, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _store_save(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("id", "") diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.py b/plugins/cmd/ndpgen/testdata/users_client_expected.py deleted file mode 100644 index 468b87b98..000000000 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_get") -def _users_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get(id: Any, filter: Any) -> Any: - """Call the users_get host function. - - Args: - id: Any parameter. - filter: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 12bf9ed3e..10c8f8c66 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -18,7 +18,7 @@ use extism_pdk::*; use nd_pdk::host::{artwork, config, scheduler}; use nd_pdk::scrobbler::{ - Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, + Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER, }; use nd_pdk::scheduler::{ @@ -207,6 +207,11 @@ impl Scrobbler for DiscordPlugin { // Discord Rich Presence doesn't need scrobble events - success Ok(()) } + + fn playback_report(&self, _req: PlaybackReportRequest) -> Result<(), ScrobblerError> { + // Discord Rich Presence doesn't need playback reports - success + Ok(()) + } } // ============================================================================ diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index e872d845d..743c03744 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -14,7 +14,7 @@ use extism_pdk::{config, error, http, info, warn, HttpRequest}; use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest, + Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, }; @@ -45,6 +45,15 @@ impl Scrobbler for WebhookPlugin { Ok(()) } + /// Handles playback state reports. This plugin ignores them (webhooks only on scrobble). + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error> { + info!( + "Playback report (ignored): {} - {} for user {} (state: {})", + req.track.artist, req.track.title, req.username, req.state + ); + Ok(()) + } + /// Handles scrobble events by sending HTTP GET requests to configured URLs. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { // Get configured URLs diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index b565ef6f2..fa3e0082e 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" "github.com/navidrome/navidrome/utils/slice" ) @@ -230,7 +231,7 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m // songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single // Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead // of the artists array, they are folded into a one-element Artists list here. -func songRefToAgentSong(s capabilities.SongRef) agents.Song { +func songRefToAgentSong(s types.SongRef) agents.Song { var artists []agents.Artist switch { case len(s.Artists) > 0: @@ -254,7 +255,7 @@ func songRefToAgentSong(s capabilities.SongRef) agents.Song { } // songRefsToAgentSongs converts a slice of SongRef to agents.Song -func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { +func songRefsToAgentSongs(refs []types.SongRef) []agents.Song { return slice.Map(refs, songRefToAgentSong) } diff --git a/plugins/pdk/go/host/nd_host_artwork_stub.go b/plugins/pdk/go/host/nd_host_artwork_stub.go index aa41e440c..3b81e0d6b 100644 --- a/plugins/pdk/go/host/nd_host_artwork_stub.go +++ b/plugins/pdk/go/host/nd_host_artwork_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockArtworkService is the mock implementation for testing. type mockArtworkService struct { diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go index fbd80d13f..46bb44bcf 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockCacheService is the mock implementation for testing. type mockCacheService struct { diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 2b8485ce9..463c29b76 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockConfigService is the mock implementation for testing. type mockConfigService struct { diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 2f15a91a9..09c4d0fcf 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index 83b55d3a8..fce038aa1 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockKVStoreService is the mock implementation for testing. type mockKVStoreService struct { diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 9ad0d97e7..1e4c230c3 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // Library represents the Library data structure. // Library represents a music library with metadata. diff --git a/plugins/pdk/go/host/nd_host_scheduler_stub.go b/plugins/pdk/go/host/nd_host_scheduler_stub.go index 3eaa0087a..ac2a8821f 100644 --- a/plugins/pdk/go/host/nd_host_scheduler_stub.go +++ b/plugins/pdk/go/host/nd_host_scheduler_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSchedulerService is the mock implementation for testing. type mockSchedulerService struct { diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 2fdaf2403..6d3a56b35 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSubsonicAPIService is the mock implementation for testing. type mockSubsonicAPIService struct { diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index 4dde0e234..286d14035 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // QueueConfig represents the QueueConfig data structure. // QueueConfig holds configuration for a task queue. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index f76854894..8858b2109 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // User represents the User data structure. // User represents a Navidrome user with minimal information exposed to plugins. diff --git a/plugins/pdk/go/host/nd_host_websocket_stub.go b/plugins/pdk/go/host/nd_host_websocket_stub.go index 23ac382f0..ca6e39c44 100644 --- a/plugins/pdk/go/host/nd_host_websocket_stub.go +++ b/plugins/pdk/go/host/nd_host_websocket_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockWebSocketService is the mock implementation for testing. type mockWebSocketService struct { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 6696bc4a1..b3c7f29ac 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -9,17 +9,11 @@ package lyrics import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -51,9 +45,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index fb3e3fb1a..76c54d2e5 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -8,15 +8,10 @@ package lyrics -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -48,9 +43,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index cec6ce339..c561c2893 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -9,8 +9,15 @@ package metadata import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -65,16 +72,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -114,7 +111,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -160,31 +157,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -202,7 +175,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index b71a4902f..e72cca103 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -8,6 +8,14 @@ package metadata +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -62,16 +70,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -111,7 +109,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -157,31 +155,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -199,7 +173,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 0d045e597..22ae4c3bf 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -9,8 +9,12 @@ package scrobbler import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -26,16 +30,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -95,9 +89,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index b35e7c40e..722ec5462 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -8,6 +8,11 @@ package scrobbler +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -23,16 +28,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -92,9 +87,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go index 864d13311..fca8bd2c1 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -9,59 +9,32 @@ package sonicsimilarity import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go index cf4cfc7ef..518a166e5 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -8,57 +8,31 @@ package sonicsimilarity -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go new file mode 100644 index 000000000..856be90e1 --- /dev/null +++ b/plugins/pdk/go/types/types.go @@ -0,0 +1,48 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package types holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} diff --git a/plugins/pdk/python/host/nd_host_artwork.py b/plugins/pdk/python/host/nd_host_artwork.py deleted file mode 100644 index 9bcb529ae..000000000 --- a/plugins/pdk/python/host/nd_host_artwork.py +++ /dev/null @@ -1,183 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Artwork 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "artwork_getartisturl") -def _artwork_getartisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getalbumurl") -def _artwork_getalbumurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_gettrackurl") -def _artwork_gettrackurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getplaylisturl") -def _artwork_getplaylisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def artwork_get_artist_url(id: str, size: int) -> str: - """GetArtistUrl generates a public URL for an artist's artwork. - -Parameters: - - id: The artist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getartisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_album_url(id: str, size: int) -> str: - """GetAlbumUrl generates a public URL for an album's artwork. - -Parameters: - - id: The album's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getalbumurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_track_url(id: str, size: int) -> str: - """GetTrackUrl generates a public URL for a track's artwork. - -Parameters: - - id: The track's (media file) unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_gettrackurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_playlist_url(id: str, size: int) -> str: - """GetPlaylistUrl generates a public URL for a playlist's artwork. - -Parameters: - - id: The playlist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getplaylisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") diff --git a/plugins/pdk/python/host/nd_host_cache.py b/plugins/pdk/python/host/nd_host_cache.py deleted file mode 100644 index b24e983cc..000000000 --- a/plugins/pdk/python/host/nd_host_cache.py +++ /dev/null @@ -1,448 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Cache 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "cache_setstring") -def _cache_setstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getstring") -def _cache_getstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setint") -def _cache_setint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getint") -def _cache_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setfloat") -def _cache_setfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getfloat") -def _cache_getfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setbytes") -def _cache_setbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getbytes") -def _cache_getbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_has") -def _cache_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_remove") -def _cache_remove(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class CacheGetStringResult: - """Result type for cache_get_string.""" - value: str - exists: bool - - -@dataclass -class CacheGetIntResult: - """Result type for cache_get_int.""" - value: int - exists: bool - - -@dataclass -class CacheGetFloatResult: - """Result type for cache_get_float.""" - value: float - exists: bool - - -@dataclass -class CacheGetBytesResult: - """Result type for cache_get_bytes.""" - value: bytes - exists: bool - - -def cache_set_string(key: str, value: str, ttl_seconds: int) -> None: - """SetString stores a string value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The string value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: str parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_string(key: str) -> CacheGetStringResult: - """GetString retrieves a string value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a string, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetStringResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetStringResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def cache_set_int(key: str, value: int, ttl_seconds: int) -> None: - """SetInt stores an integer value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The integer value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: int parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_int(key: str) -> CacheGetIntResult: - """GetInt retrieves an integer value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def cache_set_float(key: str, value: float, ttl_seconds: int) -> None: - """SetFloat stores a float value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The float value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: float parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_float(key: str) -> CacheGetFloatResult: - """GetFloat retrieves a float value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a float, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetFloatResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetFloatResult( - value=response.get("value", 0.0), - exists=response.get("exists", False), - ) - - -def cache_set_bytes(key: str, value: bytes, ttl_seconds: int) -> None: - """SetBytes stores a byte slice in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_bytes(key: str) -> CacheGetBytesResult: - """GetBytes retrieves a byte slice from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a byte slice, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetBytesResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetBytesResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def cache_has(key: str) -> bool: - """Has checks if a key exists in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns true if the key exists and has not expired. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def cache_remove(key: str) -> None: - """Remove deletes a value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_remove(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_config.py b/plugins/pdk/python/host/nd_host_config.py deleted file mode 100644 index 1dab2fe0e..000000000 --- a/plugins/pdk/python/host/nd_host_config.py +++ /dev/null @@ -1,145 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_getint") -def _config_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_keys") -def _config_keys(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -@dataclass -class ConfigGetIntResult: - """Result type for config_get_int.""" - value: int - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Get retrieves a configuration value as a string. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_get_int(key: str) -> ConfigGetIntResult: - """GetInt retrieves a configuration value as an integer. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. If the key exists but the -value cannot be parsed as an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - ConfigGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def config_keys(prefix: str) -> Any: - """Keys returns configuration keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by. If empty, returns all keys. - -Returns a sorted slice of matching configuration keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_keys(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("keys", None) diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py deleted file mode 100644 index a806c8456..000000000 --- a/plugins/pdk/python/host/nd_host_http.py +++ /dev/null @@ -1,60 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as Go errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_httpclient.py b/plugins/pdk/python/host/nd_host_httpclient.py deleted file mode 100644 index c6bfb77c0..000000000 --- a/plugins/pdk/python/host/nd_host_httpclient.py +++ /dev/null @@ -1,59 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py deleted file mode 100644 index 33eaffc52..000000000 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ /dev/null @@ -1,362 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the KVStore 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "kvstore_set") -def _kvstore_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_setwithttl") -def _kvstore_setwithttl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_get") -def _kvstore_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getmany") -def _kvstore_getmany(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_has") -def _kvstore_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_list") -def _kvstore_list(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_delete") -def _kvstore_delete(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_deletebyprefix") -def _kvstore_deletebyprefix(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getstorageused") -def _kvstore_getstorageused(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class KVStoreGetResult: - """Result type for kvstore_get.""" - value: bytes - exists: bool - - -def kvstore_set(key: str, value: bytes) -> None: - """Set stores a byte value with the given key. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None: - """SetWithTTL stores a byte value with the given key and a time-to-live. - -After ttlSeconds, the key is treated as non-existent and will be -cleaned up lazily. ttlSeconds must be greater than 0. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (must be > 0) - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_setwithttl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_get(key: str) -> KVStoreGetResult: - """Get retrieves a byte value from storage. - -Parameters: - - key: The storage key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - KVStoreGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return KVStoreGetResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def kvstore_get_many(keys: Any) -> Any: - """GetMany retrieves multiple values in a single call. - -Parameters: - - keys: The storage keys to retrieve - -Returns a map of key to value for keys that exist and have not expired. -Missing or expired keys are omitted from the result. - - Args: - keys: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "keys": keys, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getmany(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("values", None) - - -def kvstore_has(key: str) -> bool: - """Has checks if a key exists in storage. - -Parameters: - - key: The storage key - -Returns true if the key exists. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def kvstore_list(prefix: str) -> Any: - """List returns all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by (empty string returns all keys) - -Returns a slice of matching keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_list(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("keys", None) - - -def kvstore_delete(key: str) -> None: - """Delete removes a value from storage. - -Parameters: - - key: The storage key - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_delete(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_delete_by_prefix(prefix: str) -> int: - """DeleteByPrefix removes all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to match (must not be empty) - -Returns the number of keys deleted. Includes expired keys. - - Args: - prefix: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_deletebyprefix(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("deletedCount", 0) - - -def kvstore_get_storage_used() -> int: - """GetStorageUsed returns the total storage used by this plugin in bytes. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getstorageused(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("bytes", 0) diff --git a/plugins/pdk/python/host/nd_host_library.py b/plugins/pdk/python/host/nd_host_library.py deleted file mode 100644 index 12e1bc4eb..000000000 --- a/plugins/pdk/python/host/nd_host_library.py +++ /dev/null @@ -1,86 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Library 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "library_getlibrary") -def _library_getlibrary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "library_getalllibraries") -def _library_getalllibraries(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def library_get_library(id: int) -> Any: - """GetLibrary retrieves metadata for a specific library by ID. - -Parameters: - - id: The library's unique identifier - -Returns the library metadata, or an error if the library is not found. - - Args: - id: int parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getlibrary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def library_get_all_libraries() -> Any: - """GetAllLibraries retrieves metadata for all configured libraries. - -Returns a slice of all libraries with their metadata. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getalllibraries(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_scheduler.py b/plugins/pdk/python/host/nd_host_scheduler.py deleted file mode 100644 index 7f0d19241..000000000 --- a/plugins/pdk/python/host/nd_host_scheduler.py +++ /dev/null @@ -1,143 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Scheduler 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "scheduler_scheduleonetime") -def _scheduler_scheduleonetime(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_schedulerecurring") -def _scheduler_schedulerecurring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_cancelschedule") -def _scheduler_cancelschedule(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_id: str) -> str: - """ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - delaySeconds: Number of seconds to wait before triggering the event - - payload: Data to be passed to the scheduled event handler - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - delay_seconds: int parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "delaySeconds": delay_seconds, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_scheduleonetime(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str: - """ScheduleRecurring schedules a recurring event using a cron expression. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) - - payload: Data to be passed to each scheduled event handler invocation - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - cron_expression: str parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "cronExpression": cron_expression, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_schedulerecurring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_cancel_schedule(schedule_id: str) -> None: - """CancelSchedule cancels a scheduled job identified by its schedule ID. - -This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -any future events. - -Returns an error if the schedule ID is not found or if cancellation fails. - - Args: - schedule_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_cancelschedule(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py deleted file mode 100644 index cf35bc043..000000000 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ /dev/null @@ -1,101 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the SubsonicAPI 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "subsonicapi_callraw") -def _subsonicapi_callraw(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SubsonicAPICallRawResult: - """Result type for subsonicapi_call_raw.""" - content_type: str - data: bytes - - -def subsonicapi_call(uri: str) -> str: - """Call executes a Subsonic API request and returns the JSON response. - -The uri parameter should be the Subsonic API path without the server prefix, -e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. - - Args: - uri: str parameter. - - Returns: - str: The result value. - - 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_call(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("responseJson", "") - - -def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult: - """CallRaw executes a Subsonic API request and returns the raw binary response. -Designed for binary endpoints like getCoverArt and stream that return -non-JSON data. The data is base64-encoded over JSON on the wire. - - Args: - uri: str parameter. - - Returns: - SubsonicAPICallRawResult containing content_type, data,. - - 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 = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SubsonicAPICallRawResult( - content_type=response.get("contentType", ""), - data=base64.b64decode(response.get("data", "")), - ) diff --git a/plugins/pdk/python/host/nd_host_task.py b/plugins/pdk/python/host/nd_host_task.py deleted file mode 100644 index 5d6e7474c..000000000 --- a/plugins/pdk/python/host/nd_host_task.py +++ /dev/null @@ -1,188 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Task 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "task_createqueue") -def _task_createqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_enqueue") -def _task_enqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_get") -def _task_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_cancel") -def _task_cancel(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_clearqueue") -def _task_clearqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def task_create_queue(name: str, config: Any) -> None: - """CreateQueue creates a named task queue with the given configuration. -Zero-value fields in config use sensible defaults. -If a queue with the same name already exists, returns an error. -On startup, this also recovers any stale "running" tasks from a previous crash. - - Args: - name: str parameter. - config: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "config": config, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_createqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_enqueue(queue_name: str, payload: bytes) -> str: - """Enqueue adds a task to the named queue. Returns the task ID. -payload is opaque bytes passed back to the plugin on execution. - - Args: - queue_name: str parameter. - payload: bytes parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - "payload": base64.b64encode(payload).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_enqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def task_get(task_id: str) -> Any: - """Get returns the current state of a task including its status, -message, and attempt count. - - Args: - task_id: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def task_cancel(task_id: str) -> None: - """Cancel cancels a pending task. Returns error if already -running, completed, or failed. - - Args: - task_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_cancel(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_clear_queue(queue_name: str) -> int: - """ClearQueue removes all pending tasks from the named queue. -Running tasks are not affected. Returns the number of tasks removed. - - Args: - queue_name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_clearqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/pdk/python/host/nd_host_users.py b/plugins/pdk/python/host/nd_host_users.py deleted file mode 100644 index a325156a7..000000000 --- a/plugins/pdk/python/host/nd_host_users.py +++ /dev/null @@ -1,80 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users 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 - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_getusers") -def _users_getusers(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "users_getadmins") -def _users_getadmins(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get_users() -> Any: - """GetUsers returns all users the plugin has been granted access to. -Only minimal user information (userName, name, isAdmin) is returned. -Sensitive fields like password and email are never exposed. - -Returns a slice of users the plugin can access, or an empty slice if none configured. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getusers(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def users_get_admins() -> Any: - """GetAdmins returns only admin users the plugin has been granted access to. -This is a convenience method that filters GetUsers results to include only admins. - -Returns a slice of admin users the plugin can access, or an empty slice if none. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getadmins(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_websocket.py b/plugins/pdk/python/host/nd_host_websocket.py deleted file mode 100644 index 4e882914c..000000000 --- a/plugins/pdk/python/host/nd_host_websocket.py +++ /dev/null @@ -1,182 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the WebSocket 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 - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "websocket_connect") -def _websocket_connect(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendtext") -def _websocket_sendtext(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendbinary") -def _websocket_sendbinary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_closeconnection") -def _websocket_closeconnection(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def websocket_connect(url: str, headers: Any, connection_id: str) -> str: - """Connect establishes a WebSocket connection to the specified URL. - -Plugins that use this function must also implement the WebSocketCallback capability -to receive incoming messages and connection events. - -Parameters: - - url: The WebSocket URL to connect to (ws:// or wss://) - - headers: Optional HTTP headers to include in the handshake request - - connectionID: Optional unique identifier for the connection. If empty, one will be generated - -Returns the connection ID that can be used to send messages or close the connection, -or an error if the connection fails. - - Args: - url: str parameter. - headers: Any parameter. - connection_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "url": url, - "headers": headers, - "connectionId": connection_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_connect(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newConnectionId", "") - - -def websocket_send_text(connection_id: str, message: str) -> None: - """SendText sends a text message over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - message: The text message to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - message: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendtext(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_send_binary(connection_id: str, data: bytes) -> None: - """SendBinary sends binary data over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - data: The binary data to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - data: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendbinary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_close_connection(connection_id: str, code: int, reason: str) -> None: - """CloseConnection gracefully closes a WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - code: WebSocket close status code (e.g., 1000 for normal closure) - - reason: Optional human-readable reason for closing - -Returns an error if the connection is not found or if closing fails. - - Args: - connection_id: str parameter. - code: int parameter. - reason: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "code": code, - "reason": reason, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_closeconnection(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml index 443f19da5..e9fe367da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" crate-type = ["rlib"] [dependencies] +nd-pdk-types = { path = "../nd-pdk-types" } base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index b0361a3a3..d3d2b19aa 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -5,6 +5,8 @@ //! This crate provides type definitions, traits, and registration macros //! for implementing Navidrome plugin capabilities in Rust. +pub use nd_pdk_types as types; + pub mod lifecycle; pub mod lyrics; pub mod metadata; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 0a861a1ed..a8449a5ec 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -18,20 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// GetLyricsRequest contains the track information for lyrics lookup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -77,10 +66,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec, + pub album_artists: Vec, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 1a51ad9fd..38fcae9da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -18,6 +18,12 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -92,20 +98,6 @@ pub struct ArtistMBIDResponse { #[serde(default)] pub mbid: String, } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// ArtistRequest is the common request for artist-related functions. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -162,7 +154,7 @@ pub struct SimilarArtistsRequest { pub struct SimilarArtistsResponse { /// Artists is the list of similar artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, } /// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -227,42 +219,7 @@ pub struct SimilarSongsByTrackRequest { pub struct SimilarSongsResponse { /// Songs is the list of similar songs. #[serde(default)] - pub songs: Vec, -} -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artists: Vec, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, + pub songs: Vec, } /// TopSongsRequest is the request for GetArtistTopSongs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -287,7 +244,7 @@ pub struct TopSongsRequest { pub struct TopSongsResponse { /// Songs is the list of top songs. #[serde(default)] - pub songs: Vec, + pub songs: Vec, } /// Error represents an error from a capability method. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 1e9c51375..b0b843e6c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -18,6 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// ScrobblerError represents an error type for scrobbling operations. pub type ScrobblerError = &'static str; /// ScrobblerErrorNotAuthorized indicates the user is not authorized. @@ -26,20 +29,6 @@ pub const SCROBBLER_ERROR_NOT_AUTHORIZED: ScrobblerError = "scrobbler(not_author pub const SCROBBLER_ERROR_RETRY_LATER: ScrobblerError = "scrobbler(retry_later)"; /// ScrobblerErrorUnrecoverable indicates an unrecoverable error. pub const SCROBBLER_ERROR_UNRECOVERABLE: ScrobblerError = "scrobbler(unrecoverable)"; -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// IsAuthorizedRequest is the request for authorization check. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -126,10 +115,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec, + pub album_artists: Vec, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs index 81b163e5f..a28df9c0a 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -18,28 +18,20 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// FindSonicPathRequest represents the FindSonicPathRequest data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FindSonicPathRequest { #[serde(default)] - pub start_song: SongRef, + pub start_song: nd_pdk_types::SongRef, #[serde(default)] - pub end_song: SongRef, + pub end_song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } @@ -48,51 +40,16 @@ pub struct FindSonicPathRequest { #[serde(rename_all = "camelCase")] pub struct GetSonicSimilarTracksRequest { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artists: Vec, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, -} /// SonicMatch represents the SonicMatch data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SonicMatch { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub similarity: f64, } diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml index 519096110..100ce6a35 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -13,5 +13,6 @@ crate-type = ["rlib"] [dependencies] base64 = "0.22" extism-pdk = "1.2" +nd-pdk-types = { path = "../nd-pdk-types" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-types/Cargo.toml b/plugins/pdk/rust/nd-pdk-types/Cargo.toml new file mode 100644 index 000000000..e20e07021 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nd-pdk-types" +version = "0.1.0" +edition = "2021" +description = "Navidrome shared plugin data types for Rust" +authors = ["Navidrome Team"] +license = "GPL-3.0" + +[lib] +path = "src/lib.rs" +crate-type = ["rlib"] + +[dependencies] +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs new file mode 100644 index 000000000..9b40c4d56 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/src/lib.rs @@ -0,0 +1,75 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// ArtistRef is the minimal information a plugin returns for Navidrome to match an +/// artist against the library. It is a reference, not a full artist entity: it +/// carries only matching keys (name and optional internal/MusicBrainz IDs), never +/// descriptive data such as biographies or images. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// ID is the internal Navidrome artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, +} +/// SongRef is the minimal information exchanged between a plugin and Navidrome to +/// match a song. It is used both as input (a song Navidrome already has) and as +/// output (a song a plugin suggests, which may not be in the library yet). Unlike +/// Track, it is an abstract recording reference carrying only matching keys (IDs, +/// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} diff --git a/plugins/pdk/rust/nd-pdk/Cargo.toml b/plugins/pdk/rust/nd-pdk/Cargo.toml index 34fe9f032..8ca457914 100644 --- a/plugins/pdk/rust/nd-pdk/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["rlib"] [dependencies] nd-pdk-host = { path = "../nd-pdk-host" } nd-pdk-capabilities = { path = "../nd-pdk-capabilities" } +nd-pdk-types = { path = "../nd-pdk-types" } extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk/src/lib.rs b/plugins/pdk/rust/nd-pdk/src/lib.rs index b1389938b..36b2ec316 100644 --- a/plugins/pdk/rust/nd-pdk/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk/src/lib.rs @@ -31,5 +31,8 @@ pub use nd_pdk_host as host; /// Capability wrappers for implementing plugin exports. pub use nd_pdk_capabilities::*; +/// Shared plugin data types. +pub use nd_pdk_types as types; + /// Re-export extism-pdk for convenience. pub use extism_pdk; diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 8abdccf07..b3203a352 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) // CapabilityScrobbler indicates the plugin can receive scrobble events. @@ -154,10 +155,10 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef -func participantsToArtistRefs(participants model.ParticipantList) []capabilities.ArtistRef { - refs := make([]capabilities.ArtistRef, len(participants)) +func participantsToArtistRefs(participants model.ParticipantList) []types.ArtistRef { + refs := make([]types.ArtistRef, len(participants)) for i, p := range participants { - refs[i] = capabilities.ArtistRef{ + refs[i] = types.ArtistRef{ ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index e29684512..2d48b28f0 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) const CapabilitySonicSimilarity Capability = "SonicSimilarity" @@ -61,8 +62,8 @@ func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endM return sonicMatchesToSimilarResults(resp.Matches), nil } -func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { - ref := capabilities.SongRef{ +func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { + ref := types.SongRef{ ID: mf.ID, Name: mf.Title, MBID: mf.MbzRecordingID, diff --git a/plugins/testdata/test-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go index 23e933eb3..bb8a092c6 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/metadata" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -67,9 +68,9 @@ func (t *testMetadataAgent) GetSimilarArtists(input metadata.SimilarArtistsReque if limit == 0 { limit = 5 } - artists := make([]metadata.ArtistRef, 0, limit) + artists := make([]types.ArtistRef, 0, limit) for i := range limit { - artists = append(artists, metadata.ArtistRef{ + artists = append(artists, types.ArtistRef{ ID: "similar-artist-id-" + strconv.Itoa(i+1), Name: input.Name + " Similar " + string(rune('A'+i)), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -86,9 +87,9 @@ func (t *testMetadataAgent) GetArtistTopSongs(input metadata.TopSongsRequest) (* if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "song-id-" + strconv.Itoa(i+1), Name: input.Name + " Song " + strconv.Itoa(i+1), MBID: "song-mbid-" + strconv.Itoa(i+1), @@ -128,9 +129,9 @@ func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "similar-track-id-" + strconv.Itoa(i+1), Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -150,9 +151,9 @@ func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "album-similar-id-" + strconv.Itoa(i+1), Name: "Album Similar #" + strconv.Itoa(i+1), Artist: input.Artist, @@ -170,9 +171,9 @@ func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsB if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "artist-similar-id-" + strconv.Itoa(i+1), Name: input.Name + " Style Song #" + strconv.Itoa(i+1), Artist: input.Name + " Similar Artist", diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go index e315bff15..538e9c8f3 100644 --- a/plugins/testdata/test-sonic-similarity/main.go +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -35,7 +36,7 @@ func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSon matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "similar-track-" + strconv.Itoa(i+1), Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), Artist: input.Song.Artist, @@ -57,7 +58,7 @@ func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathR matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "path-track-" + strconv.Itoa(i+1), Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), Artist: input.StartSong.Artist, diff --git a/plugins/types/types.go b/plugins/types/types.go new file mode 100644 index 000000000..eaccf3469 --- /dev/null +++ b/plugins/types/types.go @@ -0,0 +1,42 @@ +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` // TODO: rename to Title to align with Track.Title and model.MediaFile.Title; kept as Name for now for compatibility. + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +}