mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
feat(plugins): share plugin DTOs via a types package (#5655)
* refactor(plugins): remove Python PDK generation from ndpgen
* feat(plugins): parse Go type aliases distinctly in ndpgen
* feat(plugins): resolve shared-type aliases against a registry in ndpgen
* fix(plugins): resolve host-service shared aliases package-wide
Mirror the capability approach in ParseDirectoryWithShared: do a first
pass over all package files to build a package-wide alias map, then pass
it into parseServiceFile so that a shared-type alias declared in a sibling
file is visible when resolving types in the service interface file.
Add a focused test that writes the alias in one file and the hostservice
in another, confirming RED before the fix and GREEN after. Also
strengthens the existing Task 3 test with an ArtistRef.Target assertion.
* feat(plugins): add ndpgen -shared-types mode for the Go types package
* feat(plugins): generate the nd-pdk-types Rust crate from -shared-types
* feat(plugins): inject types import and emit deprecated aliases in Go output
* feat(plugins): emit deprecated Rust aliases to the shared types crate
* feat(plugins): inline shared-type shapes into XTP schemas
* feat(plugins): add nd-pdk-types crate and wire dependents
* feat(plugins): move shared capability types to plugins/types with deprecated aliases
* fix(plugins): point Rust deprecated-alias note at the replacement type
* fix(plugins): include shared aliases in KnownStructs so Rust fields keep their type
Capability.KnownStructs() and Service.KnownStructs() previously only
registered names from .Structs. After the shared-types migration, types
like ArtistRef/TrackInfo/SongRef live in .SharedAliases instead, so
ToRustTypeWithStructs could not find them and fell back to serde_json::Value
for every struct field referencing a shared type.
Add the shared-alias names to the knownStructs map in both methods.
Regenerate the Rust capability files; track/song/artist fields now render
as their named types (TrackInfo, SongRef, ArtistRef, etc.).
Add a regression test that verifies a struct field whose type is only in
SharedAliases renders as the named type and not serde_json::Value.
* docs(plugins): remove stale Python references from ndpgen and plugins READMEs
ndpgen no longer has a -python flag; remove it from the usage synopsis,
flags table, and defaults note in ndpgen/README.md. Delete the "Python
Client Library" section that described its output.
plugins/README.md referenced plugins/pdk/python/host/ (deleted) as the
source for Python host-service stubs. Remove that paragraph; Python plugins
still work via the XTP-schema / extism-py path (see examples/*-py).
* refactor(plugins): dedupe ndpgen helpers and tidy shared-type codegen
* docs(plugins): restore Python as a supported XTP schema target
The ndpgen-generated Python PDK was removed, but the XTP YAML schemas are
language-neutral and the XTP CLI still generates Python bindings from them
(as the extism-py examples demonstrate). Only the ndpgen Python output was
dropped, not Python support itself.
* test(plugins): use the shared types package in test plugins
The test fixtures referenced the now-deprecated capability aliases
(sonicsimilarity.SongRef, metadata.ArtistRef/SongRef). Point them at the
canonical types package so our own fixtures don't depend on symbols slated
for removal.
* refactor(plugins): use the shared types package in host adapters
Replace deprecated capabilities.TrackInfo, capabilities.ArtistRef, and
capabilities.SongRef aliases with the canonical types.TrackInfo,
types.ArtistRef, and types.SongRef from plugins/types.
* fix(plugins): reference shared types by canonical path in generated Rust
Previously the generator emitted `pub field: SongRef` (the local deprecated
alias) for struct fields whose type came from SharedAliases. Refactored
ToRustTypeWithStructs into a private toRustType that accepts a shared map,
and added ToRustTypeWithShared which resolves shared-alias names to their
canonical nd_pdk_types::X path before falling through to the knownStructs
check. Both rustCapabilityFuncMap and rustFuncMap now build the shared map
from SharedAliases and use it for fieldRustType, so the generated capability
files reference nd_pdk_types::SongRef / nd_pdk_types::TrackInfo directly.
The deprecated pub type aliases remain in place as the external back-compat
surface. Deprecation warning count from cargo build drops to 0.
* fix(examples): implement missing Scrobbler.playback_report in Rust examples
The Scrobbler trait gained a playback_report method but the two Rust example
plugins (webhook-rs and discord-rich-presence-rs) were not updated, causing
E0046 compile errors. Added the missing fn playback_report to both: webhook-rs
logs and returns Ok(()) mirroring its now_playing handler; discord-rich-presence-rs
is a no-op since Discord presence does not need playback reports. make all-rust
now exits 0.
* refactor(plugins): point Rust deprecation notes at the nd_pdk::types umbrella path
Plugin authors depend on the nd-pdk umbrella crate, which re-exports
nd_pdk_types as 'types', so the migration target they should type is
nd_pdk::types::X. The alias target stays nd_pdk_types::X (the real path
inside nd-pdk-capabilities).
* fix(plugins): error when a shared-type alias can't be resolved against the registry
* refactor(plugins): parse each Go source file once in ndpgen
* fix(plugins): correct ndpgen review nits (flag name, unused dep, docs)
* refactor(plugins): drop the now-unused path param from parseServiceFile
* refactor(plugins): use shared types directly, rename TrackInfo to Track
Capability interfaces now reference the shared `types` package by qualified
name (types.Track, types.SongRef, types.ArtistRef) instead of the package-local
deprecated aliases, and the shared TrackInfo type is renamed to Track to match
its role as the plugin-facing projection of a library media file.
The deprecated bare aliases (scrobbler.TrackInfo, metadata.ArtistRef,
sonicsimilarity.SongRef, etc.) are kept as re-exports so existing plugins keep
compiling, with a deprecation warning steering them to the canonical types.
To support this, ndpgen now resolves qualified types.X references: it collects
them during type discovery, maps each used canonical type back to its declared
deprecated alias for re-export, emits nd_pdk_types::X paths in Rust, and names
the XTP schema components by their canonical type. Regenerated the Go and Rust
PDK and the XTP schemas, and added generator tests covering the qualified-ref
path. Also adds clarifying doc comments to the shared types.
* refactor(plugins): extract shared types selector into a named const
Replace the "types." string literal that detects and strips the shared types
package selector with a single sharedTypesPrefix constant across the ndpgen
generator (parser, types, generator, xtp_schema), giving the package one source
of truth for the selector.
Also restore the single reused scratch map (cleared each iteration) in the
resolveSharedAliases BFS instead of allocating a fresh map per shared-struct
field, matching the prior implementation.
Pure cleanup from a /simplify pass: regeneration produces byte-for-byte
identical Go, Rust, and XTP output.
* refactor(plugins): keep TrackInfo in the capability package for now
Move the track type back out of the shared plugins/types package: it is again
defined inline as TrackInfo in plugins/capabilities/scrobbler.go and referenced
directly by the scrobbler and lyrics capabilities, reverting the rename to
types.Track. The host helper is renamed back to mediaFileToTrackInfo and now
returns capabilities.TrackInfo. SongRef and ArtistRef stay in the shared types
package; TrackInfo keeps using types.ArtistRef for its artist lists.
This type is expected to be reshaped in upcoming work, so leaving it in the
capability package avoids churning the shared types twice. Regenerated the Go
and Rust PDK and the XTP schemas accordingly.
* fix(plugins): emit the Go types import for direct shared-type refs
ndpgen's Capability/Service.ImportsSharedTypes only reported a shared-types
dependency when a deprecated re-export alias (type X = types.X) was declared. A
struct field referencing the canonical form directly (e.g. types.SongRef) with
no such alias produced an empty SharedAliases slice, so the Go templates skipped
the import while still emitting fields/signatures using types.SongRef — leaving
generated PDK code for new shared DTOs uncompilable unless an otherwise
unnecessary alias was added.
ImportsSharedTypes now also returns true when any struct field references the
types. package by qualified name, via a new structsReferenceSharedTypes helper
that reuses collectReferencedTypes (so []types.X and map[...]types.X are covered
too).
* fix(plugins): preserve base64 encoding for shared byte fields in Rust
The Rust shared-types crate template rendered a []byte field as a plain Vec<u8>
without the base64_bytes serde override used by the capability/client templates.
Go's encoding/json serializes []byte as a base64 string, so a Rust plugin using
nd_pdk::types would have serialized an array of numbers instead of the wire
format the Go/server side expects.
GenerateSharedTypesRust now registers the base64_bytes partial and passes a
HasByteFields flag (new anyFieldIsByteSlice helper); types.rs.tmpl emits the
base64_bytes module and a #[serde(with = "base64_bytes")] attribute on []byte
fields, mirroring the capability template.
* fix(plugins): include directly-referenced shared types in XTP schemas
buildSchemas registered shared types into the schema components only by iterating
cap.SharedAliases, which records deprecated re-export aliases. A capability that
referenced a shared DTO solely as types.Foo (no declared alias) therefore never
got Foo into the component set, so the self-contained XTP schema rendered the
field as a generic object (or emitted a dangling $ref), breaking the direct
shared-type use case enabled by -shared.
resolveSharedAliases now also returns the resolved shapes of every used shared
type (alias or not); these are carried on the new Capability.SharedTypes field
and registered by buildSchemas alongside SharedAliases. Validated end-to-end with
the xtp CLI: a direct types.Foo reference now produces a proper component plus a
$ref, so xtp generates a typed struct instead of an untyped serde_json::Map.
* fix(plugins): resolve renamed shared aliases to canonical schema refs
When a deprecated alias renames its canonical type (e.g. type TrackInfo =
types.Track) and a capability field is typed with the alias name (TrackInfo),
buildProperty emitted a $ref to #/components/schemas/TrackInfo. Components are
keyed by the canonical name (Track), so no TrackInfo component was emitted,
leaving a dangling reference that crashes the xtp code generator.
buildSchemas now builds an alias->canonical map; buildProperty (and the slice
item path) resolves $ref targets through it, and a used alias name marks its
canonical component used so it is emitted. Validated with the xtp CLI: the
renamed-alias schema previously crashed xtp and now generates cleanly.
* fix(plugins): detect shared types used directly in method signatures
ImportsSharedTypes only inspected struct fields, so a capability method using a
shared type directly in its signature (e.g. types.SongRef as input/output rather
than inside a local struct) was not detected. The generated Go templates still
rendered the provider/export signatures with types.SongRef, so the capability
package omitted the types import and failed to compile; the same gap applied to
service params/returns.
ImportsSharedTypes now also scans capability method input/output types and
service method params/returns, via a typeReferencesSharedTypes helper that reuses
collectReferencedTypes (covering pointer/slice/map wrappers).
* fix(plugins): add base64 dependency to the shared Rust types crate
When a shared DTO has a []byte field, ndpgen emits the base64_bytes serde helper
and use base64::... imports into nd-pdk-types/src/lib.rs, but the crate manifest
declared only serde. In that case make gen produced a crate that failed to
compile with 'unresolved module base64'.
Add base64 = "0.22" (matching nd-pdk-capabilities) so the generated shared types
crate compiles whenever a []byte field is present. Verified by generating a
shared crate with a []byte field and confirming cargo check fails before and
passes after.
* fix(plugins): translate shared method types in generated Rust
A capability method using a shared DTO directly as input/output (e.g.
types.SongRef) was passed through rustOutputType unchanged, so the Rust template
emitted invalid trait and extism_pdk::Json<$crate::pkg::types.SongRef> signatures
that do not compile.
Method input/output types now resolve through the shared registry: trait
signatures use rustTraitType (shared -> nd_pdk_types::X, locals stay bare) and the
export macros use rustMethodType (fully qualified: shared -> nd_pdk_types::X,
primitives -> Rust, locals -> $crate::<pkg>::X). Verified end-to-end by compiling
a generated capability that takes types.SongRef directly against the real
nd-pdk-types crate.
* fix(plugins): canonicalize XTP export refs for renamed shared aliases
buildSchemas canonicalized alias-to-canonical references for struct-field $ref
targets, but buildExport built export input/output $refs straight from
fieldBaseType. A capability whose export used a renamed deprecated alias
directly (e.g. type TrackInfo = types.Track with NowPlaying(TrackInfo)) emitted
$ref: #/components/schemas/TrackInfo, while the component is emitted under the
canonical name Track — a dangling export reference.
Lift the alias-to-canonical map into GenerateSchema (buildAliasToCanonical) and
apply it to export refs via canonicalRefName, the same resolution already used
for field properties.
* fix(plugins): route shared macro types through $crate for plugin builds
When a capability method used a shared type directly, the generated export macro
named the type as nd_pdk_types::SongRef. The macro expands in the downstream
plugin crate, which depends on the umbrella nd-pdk crate and not on nd-pdk-types
directly, so that path is unresolvable there and the plugin fails to build.
rustMethodType (macro-facing) now emits $crate::types::X, and the generated
nd-pdk-capabilities lib.rs re-exports nd_pdk_types as types so $crate resolves
it. Trait signatures keep nd_pdk_types::X since they live in nd-pdk-capabilities,
which has the direct dependency. Verified end-to-end: a plugin crate depending
only on the umbrella that uses a capability with a direct types.X method now
compiles via the macro.
* fix(plugins): add nd-pdk-types dependency to the Rust host crate
When a host service uses a shared type, ndpgen emits nd_pdk_types::X into the
generated nd-pdk-host client wrappers, but the host crate's manifest did not
depend on nd-pdk-types, so the crate failed to compile with 'unresolved module
nd_pdk_types'. Host client wrappers are plain functions resolved in the host
crate's own context (not macros expanded downstream), so a direct dependency is
the right fix.
Add nd-pdk-types = { path = "../nd-pdk-types" } to nd-pdk-host, mirroring
nd-pdk-capabilities. Found while auditing all Rust paths against the realistic
crate topology after the capability-side $crate fix; verified by generating a
host service with a shared-type return and confirming cargo check fails before
and passes after.
* fix(plugins): resolve shared aliases in Rust host signatures
The Rust host client rendered method params and returns through
RustTypeWithStructs, which only consults KnownStructs. A host service using a
shared alias in a signature (e.g. type Track = types.Track plus
MatchSongs(...) ([]Track, error)) therefore emitted a bare Vec<Track>, but the
client template emits no Track alias or import, so the generated nd-pdk-host
crate did not compile. Only struct fields went through the shared map.
rustType/rustParamType now use the shared map too (RustTypeWithShared /
RustParamTypeWithShared), so an aliased param/return resolves to its canonical
nd_pdk_types::X path, matching field handling. Verified by generating a host
service returning a shared alias and confirming cargo check fails before and
passes after.
This commit is contained in:
parent
0fab1861a0
commit
0e5b9e3263
91 changed files with 2130 additions and 4392 deletions
9
Makefile
9
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ This tool is the unified code generator that handle both host function wrappers
|
|||
## Usage
|
||||
|
||||
```bash
|
||||
ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust]
|
||||
ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-rust]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
|
@ -21,10 +21,9 @@ ndpgen -input <dir> -output <dir> [-package <name>] [-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.
|
||||
|
|
|
|||
|
|
@ -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"{}"`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<package>::X. This is used instead of hand-assembling
|
||||
// "$crate::<pkg>::" + 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<T, Error> 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8> 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<nd_pdk_types::SongRef>"))
|
||||
})
|
||||
|
||||
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<Track>"))
|
||||
})
|
||||
|
||||
It("should not generate base64 module when no byte fields", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
|
|
|
|||
|
|
@ -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=<dir> 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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||
23
plugins/cmd/ndpgen/internal/templates/types.go.tmpl
Normal file
23
plugins/cmd/ndpgen/internal/templates/types.go.tmpl
Normal file
|
|
@ -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}}
|
||||
49
plugins/cmd/ndpgen/internal/templates/types.rs.tmpl
Normal file
49
plugins/cmd/ndpgen/internal/templates/types.rs.tmpl
Normal file
|
|
@ -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}}
|
||||
|
|
@ -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<nd_pdk_types::Track>).
|
||||
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<u8>"
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
//
|
||||
// Output directories:
|
||||
// - Host wrappers: $input/<servicename>_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/<capability>/ (e.g., $output/go/metadata/)
|
||||
// - Schemas: $input/<capability>.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
|
||||
// <outputDir>/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)
|
||||
|
|
|
|||
|
|
@ -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", ""))
|
||||
|
|
@ -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", ""))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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", "")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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", "")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
48
plugins/pdk/go/types/types.go
Normal file
48
plugins/pdk/go/types/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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", "")
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
@ -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", "")),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ArtistRef>,
|
||||
pub artists: Vec<nd_pdk_types::ArtistRef>,
|
||||
/// AlbumArtists is the list of album artists.
|
||||
#[serde(default)]
|
||||
pub album_artists: Vec<ArtistRef>,
|
||||
pub album_artists: Vec<nd_pdk_types::ArtistRef>,
|
||||
/// Duration is the track duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: f32,
|
||||
|
|
|
|||
|
|
@ -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<ArtistRef>,
|
||||
pub artists: Vec<nd_pdk_types::ArtistRef>,
|
||||
}
|
||||
/// 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>,
|
||||
}
|
||||
/// 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<ArtistRef>,
|
||||
/// 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<nd_pdk_types::SongRef>,
|
||||
}
|
||||
/// 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<SongRef>,
|
||||
pub songs: Vec<nd_pdk_types::SongRef>,
|
||||
}
|
||||
|
||||
/// Error represents an error from a capability method.
|
||||
|
|
|
|||
|
|
@ -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<ArtistRef>,
|
||||
pub artists: Vec<nd_pdk_types::ArtistRef>,
|
||||
/// AlbumArtists is the list of album artists.
|
||||
#[serde(default)]
|
||||
pub album_artists: Vec<ArtistRef>,
|
||||
pub album_artists: Vec<nd_pdk_types::ArtistRef>,
|
||||
/// Duration is the track duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: f32,
|
||||
|
|
|
|||
|
|
@ -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<ArtistRef>,
|
||||
/// 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
15
plugins/pdk/rust/nd-pdk-types/Cargo.toml
Normal file
15
plugins/pdk/rust/nd-pdk-types/Cargo.toml
Normal file
|
|
@ -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"] }
|
||||
75
plugins/pdk/rust/nd-pdk-types/src/lib.rs
Normal file
75
plugins/pdk/rust/nd-pdk-types/src/lib.rs
Normal file
|
|
@ -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<ArtistRef>,
|
||||
/// 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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
21
plugins/testdata/test-metadata-agent/main.go
vendored
21
plugins/testdata/test-metadata-agent/main.go
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
42
plugins/types/types.go
Normal file
42
plugins/types/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue