380 lines
9.3 KiB
Markdown
380 lines
9.3 KiB
Markdown
|
|
# Navidrome Plugin Development Kit for Go
|
||
|
|
|
||
|
|
This directory contains the auto-generated Go PDK (Plugin Development Kit) for building Navidrome plugins.
|
||
|
|
The PDK provides both **host function wrappers** for interacting with Navidrome and
|
||
|
|
**capability interfaces** for implementing plugin functionality.
|
||
|
|
|
||
|
|
## ⚠️ Auto-Generated Code
|
||
|
|
|
||
|
|
**Do not edit files in this directory manually.** They are generated by the `ndpgen` tool.
|
||
|
|
|
||
|
|
To regenerate:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make gen
|
||
|
|
```
|
||
|
|
|
||
|
|
## Module Structure
|
||
|
|
|
||
|
|
This is a consolidated Go module that includes:
|
||
|
|
|
||
|
|
- `host/` - Host function wrappers for calling Navidrome services from plugins
|
||
|
|
- `lifecycle/` - Plugin lifecycle hooks (initialization)
|
||
|
|
- `metadata/` - Metadata agent capability for artist/album info
|
||
|
|
- `scheduler/` - Scheduler callback capability for scheduled tasks
|
||
|
|
- `scrobbler/` - Scrobbler capability for play tracking
|
||
|
|
- `websocket/` - WebSocket callback capability for real-time messages
|
||
|
|
|
||
|
|
## Usage
|
||
|
|
|
||
|
|
Add this module as a dependency in your plugin's `go.mod`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||
|
|
|
||
|
|
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||
|
|
```
|
||
|
|
|
||
|
|
Then import the packages you need:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||
|
|
)
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
lifecycle.Register(&myPlugin{})
|
||
|
|
scheduler.Register(&myPlugin{})
|
||
|
|
}
|
||
|
|
|
||
|
|
type myPlugin struct{}
|
||
|
|
|
||
|
|
func (p *myPlugin) OnInit() error {
|
||
|
|
// Initialize your plugin
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *myPlugin) OnCallback(req scheduler.SchedulerCallbackRequest) error {
|
||
|
|
// Handle scheduled task
|
||
|
|
return host.WebSocketBroadcast("task-complete", req.ScheduleID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func main() {}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Host Services
|
||
|
|
|
||
|
|
The `host` package provides wrappers for calling Navidrome's host services:
|
||
|
|
|
||
|
|
| Service | Description |
|
||
|
|
|---------------|----------------------------------------------------|
|
||
|
|
| `Artwork` | Access album and artist artwork |
|
||
|
|
| `Cache` | Temporary key-value storage with TTL |
|
||
|
|
| `KVStore` | Persistent key-value storage |
|
||
|
|
| `Library` | Access the music library (albums, artists, tracks) |
|
||
|
|
| `Scheduler` | Schedule one-time and recurring tasks |
|
||
|
|
| `SubsonicAPI` | Make Subsonic API calls |
|
||
|
|
| `WebSocket` | Send real-time messages to clients |
|
||
|
|
|
||
|
|
### Example: Using Host Services
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
|
)
|
||
|
|
|
||
|
|
func myPluginFunction() error {
|
||
|
|
// Use the cache service
|
||
|
|
_, err := host.CacheSetString("my_key", "my_value", 3600)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Schedule a recurring task
|
||
|
|
_, err = host.SchedulerScheduleRecurring("@every 5m", "payload", "task_id")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Access library data with typed structs
|
||
|
|
resp, err := host.LibraryGetAllLibraries()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
for _, lib := range resp.Result {
|
||
|
|
// Library: %s with %d songs", lib.Name, lib.TotalSongs
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Capabilities
|
||
|
|
|
||
|
|
Capabilities define what functionality your plugin implements. Register your implementations
|
||
|
|
in the `init()` function.
|
||
|
|
|
||
|
|
### Lifecycle
|
||
|
|
|
||
|
|
Provides plugin initialization hooks.
|
||
|
|
|
||
|
|
```go
|
||
|
|
import "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
lifecycle.Register(&myPlugin{})
|
||
|
|
}
|
||
|
|
|
||
|
|
type myPlugin struct{}
|
||
|
|
|
||
|
|
func (p *myPlugin) OnInit() error {
|
||
|
|
// Called once when plugin is loaded
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### MetadataAgent
|
||
|
|
|
||
|
|
Provides artist and album metadata from external sources.
|
||
|
|
|
||
|
|
```go
|
||
|
|
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
metadata.Register(&myAgent{})
|
||
|
|
}
|
||
|
|
|
||
|
|
type myAgent struct{}
|
||
|
|
|
||
|
|
func (a *myAgent) GetArtistBiography(req metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
|
||
|
|
return &metadata.ArtistBiographyResponse{
|
||
|
|
Biography: "Artist biography text...",
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *myAgent) GetArtistImages(req metadata.ArtistRequest) (*metadata.ArtistImagesResponse, error) {
|
||
|
|
return &metadata.ArtistImagesResponse{
|
||
|
|
Images: []metadata.ImageInfo{
|
||
|
|
{URL: "https://example.com/image.jpg", Size: 1000},
|
||
|
|
},
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Scheduler
|
||
|
|
|
||
|
|
Handles callbacks from scheduled tasks.
|
||
|
|
|
||
|
|
```go
|
||
|
|
import (
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||
|
|
)
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
scheduler.Register(&myScheduler{})
|
||
|
|
}
|
||
|
|
|
||
|
|
type myScheduler struct{}
|
||
|
|
|
||
|
|
func (s *myScheduler) OnCallback(req scheduler.SchedulerCallbackRequest) error {
|
||
|
|
// Handle the scheduled task
|
||
|
|
if req.Payload == "update-data" {
|
||
|
|
// Do work...
|
||
|
|
return host.WebSocketBroadcast("data-updated", "")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Scrobbler
|
||
|
|
|
||
|
|
Tracks play activity.
|
||
|
|
|
||
|
|
```go
|
||
|
|
import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
scrobbler.Register(&myScrobbler{})
|
||
|
|
}
|
||
|
|
|
||
|
|
type myScrobbler struct{}
|
||
|
|
|
||
|
|
func (s *myScrobbler) Scrobble(req scrobbler.ScrobbleRequest) error {
|
||
|
|
// Track the play
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *myScrobbler) NowPlaying(req scrobbler.NowPlayingRequest) error {
|
||
|
|
// Update now playing status
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### WebSocket
|
||
|
|
|
||
|
|
Handles incoming WebSocket messages.
|
||
|
|
|
||
|
|
```go
|
||
|
|
import "github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
websocket.Register(&myHandler{})
|
||
|
|
}
|
||
|
|
|
||
|
|
type myHandler struct{}
|
||
|
|
|
||
|
|
func (h *myHandler) OnWebSocketMessage(req websocket.WebSocketMessageRequest) error {
|
||
|
|
// Handle incoming message
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Building Plugins
|
||
|
|
|
||
|
|
Go plugins must be compiled to WebAssembly using TinyGo:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared .
|
||
|
|
```
|
||
|
|
|
||
|
|
Or use the provided Makefile targets in plugin examples:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make plugin.wasm
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing Plugins
|
||
|
|
|
||
|
|
The PDK includes [testify/mock](https://github.com/stretchr/testify) implementations for all host services,
|
||
|
|
allowing you to unit test your plugin code on non-WASM platforms (your development machine).
|
||
|
|
|
||
|
|
### PDK Abstraction Layer
|
||
|
|
|
||
|
|
The `pdk` subpackage provides a testable wrapper around the Extism PDK functions. Instead of importing
|
||
|
|
`github.com/extism/go-pdk` directly, import the abstraction layer:
|
||
|
|
|
||
|
|
```go
|
||
|
|
import "github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||
|
|
|
||
|
|
func myFunction() {
|
||
|
|
// Use pdk functions - same API as extism/go-pdk
|
||
|
|
config, ok := pdk.GetConfig("my_setting")
|
||
|
|
if ok {
|
||
|
|
pdk.Log(pdk.LogInfo, "Setting: " + config)
|
||
|
|
}
|
||
|
|
|
||
|
|
var input MyInput
|
||
|
|
if err := pdk.InputJSON(&input); err != nil {
|
||
|
|
pdk.SetError(err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
output := processInput(input)
|
||
|
|
pdk.OutputJSON(output)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
For WASM builds, these functions delegate directly to `extism/go-pdk` with zero overhead.
|
||
|
|
For native builds (tests), they use mocks that you can configure:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package myplugin
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestMyFunction(t *testing.T) {
|
||
|
|
// Reset mock state before each test
|
||
|
|
pdk.ResetMock()
|
||
|
|
|
||
|
|
// Set up expectations
|
||
|
|
pdk.PDKMock.On("GetConfig", "my_setting").Return("test_value", true)
|
||
|
|
pdk.PDKMock.On("Log", pdk.LogInfo, "Setting: test_value").Return()
|
||
|
|
pdk.PDKMock.On("InputJSON", mock.Anything).Return(nil).Run(func(args mock.Arguments) {
|
||
|
|
// Populate the input struct
|
||
|
|
input := args.Get(0).(*MyInput)
|
||
|
|
input.Name = "test"
|
||
|
|
})
|
||
|
|
pdk.PDKMock.On("OutputJSON", mock.Anything).Return(nil)
|
||
|
|
|
||
|
|
// Call your function
|
||
|
|
myFunction()
|
||
|
|
|
||
|
|
// Verify expectations
|
||
|
|
pdk.PDKMock.AssertExpectations(t)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Mock Instances
|
||
|
|
|
||
|
|
Each host service has an auto-instantiated mock instance:
|
||
|
|
|
||
|
|
| Service | Mock Instance |
|
||
|
|
|---------------|--------------------------|
|
||
|
|
| `Artwork` | `host.ArtworkMock` |
|
||
|
|
| `Cache` | `host.CacheMock` |
|
||
|
|
| `Config` | `host.ConfigMock` |
|
||
|
|
| `KVStore` | `host.KVStoreMock` |
|
||
|
|
| `Library` | `host.LibraryMock` |
|
||
|
|
| `Scheduler` | `host.SchedulerMock` |
|
||
|
|
| `SubsonicAPI` | `host.SubsonicAPIMock` |
|
||
|
|
| `WebSocket` | `host.WebSocketMock` |
|
||
|
|
|
||
|
|
### Example Test
|
||
|
|
|
||
|
|
```go
|
||
|
|
package myplugin
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestMyPluginFunction(t *testing.T) {
|
||
|
|
// Set expectations on the mock
|
||
|
|
host.CacheMock.On("GetString", "my-key").Return("cached-value", true, nil)
|
||
|
|
host.CacheMock.On("SetString", "new-key", "new-value", int64(3600)).Return(nil)
|
||
|
|
|
||
|
|
// Call your plugin code that uses host.CacheGetString / host.CacheSetString
|
||
|
|
result, err := myPluginFunction()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Assert the result
|
||
|
|
if result != "expected" {
|
||
|
|
t.Errorf("unexpected result: %s", result)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify all expected calls were made
|
||
|
|
host.CacheMock.AssertExpectations(t)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Running Tests
|
||
|
|
|
||
|
|
Since tests run on your development machine (not WASM), use standard Go testing:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./...
|
||
|
|
```
|
||
|
|
|
||
|
|
The stub files with mocks are only compiled for non-WASM builds (`//go:build !wasip1`),
|
||
|
|
so they won't affect your production WASM binary.
|
||
|
|
|
||
|
|
### Complete Examples
|
||
|
|
|
||
|
|
For more comprehensive examples including HTTP requests, Memory handling, and various testing patterns,
|
||
|
|
see [pdk/example_test.go](pdk/example_test.go).
|