mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(plugins): add support for .ndp plugin packages and update build process
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
78445163bb
commit
e52b757cd4
1
.gitignore
vendored
1
.gitignore
vendored
@ -33,4 +33,5 @@ AGENTS.md
|
||||
*.exe
|
||||
*.test
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
@ -26,7 +26,7 @@ help:
|
||||
@$(foreach p,$(RUST_PLUGINS),echo " $(p)";)
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make <plugin>.wasm Build a specific plugin (e.g., make $(firstword $(PLUGINS)).wasm)"
|
||||
@echo " make <plugin>.ndp Build a specific plugin (e.g., make $(firstword $(PLUGINS)).ndp)"
|
||||
@echo " make all Build all plugins"
|
||||
@echo " make all-go Build all Go plugins"
|
||||
@echo " make all-python Build all Python plugins (requires extism-py)"
|
||||
@ -35,16 +35,26 @@ help:
|
||||
|
||||
all: all-go all-python all-rust
|
||||
|
||||
all-go: $(PLUGINS:%=%.wasm)
|
||||
all-go: $(PLUGINS:%=%.ndp)
|
||||
|
||||
all-python: $(PYTHON_PLUGINS:%=%.wasm)
|
||||
all-python: $(PYTHON_PLUGINS:%=%.ndp)
|
||||
|
||||
all-rust: $(RUST_PLUGINS:%=%.wasm)
|
||||
all-rust: $(RUST_PLUGINS:%=%.ndp)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGINS:%=%.ndp) $(PYTHON_PLUGINS:%=%.ndp) $(RUST_PLUGINS:%=%.ndp)
|
||||
rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm)
|
||||
$(foreach p,$(RUST_PLUGINS),cd $(p) && cargo clean 2>/dev/null || true;)
|
||||
|
||||
# Build .ndp package from .wasm and manifest.json
|
||||
# Go plugins
|
||||
%.ndp: %.wasm %/manifest.json
|
||||
@rm -f $@
|
||||
@cp $< plugin.wasm
|
||||
zip -j $@ $*/manifest.json plugin.wasm
|
||||
@rm -f plugin.wasm
|
||||
@rm -f $<
|
||||
|
||||
%.wasm: %/*.go %/go.mod
|
||||
ifdef TINYGO
|
||||
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
|
||||
|
||||
16
plugins/examples/coverartarchive-py/manifest.json
Normal file
16
plugins/examples/coverartarchive-py/manifest.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Cover Art Archive (Python)",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Album cover art from the Cover Art Archive - Python example",
|
||||
"website": "https://coverartarchive.org",
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Fetch album cover art from Cover Art Archive API",
|
||||
"allowedHosts": [
|
||||
"coverartarchive.org",
|
||||
"*.archive.org"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@
|
||||
# extism-py plugin/__init__.py -o coverartarchive-py.wasm
|
||||
#
|
||||
# Test with:
|
||||
# extism call coverartarchive-py.wasm nd_manifest --wasi
|
||||
# extism call coverartarchive-py.wasm nd_get_album_images --wasi \
|
||||
# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \
|
||||
# --allow-host "coverartarchive.org" --allow-host "archive.org"
|
||||
@ -16,28 +15,6 @@ import extism
|
||||
import json
|
||||
|
||||
|
||||
# Plugin manifest - identifies this plugin to Navidrome
|
||||
@extism.plugin_fn
|
||||
def nd_manifest():
|
||||
manifest = {
|
||||
"name": "Cover Art Archive (Python)",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Album cover art from the Cover Art Archive - Python example",
|
||||
"website": "https://coverartarchive.org",
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Fetch album cover art from Cover Art Archive API",
|
||||
"allowedHosts": [
|
||||
"coverartarchive.org",
|
||||
"*.archive.org"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
extism.output_str(json.dumps(manifest))
|
||||
|
||||
|
||||
@extism.plugin_fn
|
||||
def nd_get_album_images():
|
||||
"""Retrieve album cover images from Cover Art Archive."""
|
||||
|
||||
@ -26,35 +26,6 @@ const (
|
||||
reconnectScheduleID = "crypto-ticker-reconnect"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Config *ConfigPermission `json:"config,omitempty"`
|
||||
WebSocket *WebSocketPermission `json:"websocket,omitempty"`
|
||||
Scheduler *SchedulerPermission `json:"scheduler,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type WebSocketPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty"`
|
||||
}
|
||||
|
||||
type SchedulerPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Coinbase subscription message structure
|
||||
type CoinbaseSubscription struct {
|
||||
Type string `json:"type"`
|
||||
@ -77,38 +48,6 @@ type CoinbaseTicker struct {
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// nd_manifest is required by Navidrome to identify the plugin.
|
||||
//
|
||||
//export nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Crypto Ticker",
|
||||
Author: "Navidrome",
|
||||
Version: "1.0.0",
|
||||
Description: "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
|
||||
Website: "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
|
||||
Permissions: &Permissions{
|
||||
Config: &ConfigPermission{
|
||||
Reason: "To read ticker symbols configuration",
|
||||
},
|
||||
WebSocket: &WebSocketPermission{
|
||||
Reason: "To connect to Coinbase WebSocket API for real-time prices",
|
||||
AllowedHosts: []string{"ws-feed.exchange.coinbase.com"},
|
||||
},
|
||||
Scheduler: &SchedulerPermission{
|
||||
Reason: "To schedule reconnection attempts on connection loss",
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// OnInitInput is the input for nd_on_init (currently empty, reserved for future use)
|
||||
type OnInitInput struct{}
|
||||
|
||||
|
||||
19
plugins/examples/crypto-ticker/manifest.json
Normal file
19
plugins/examples/crypto-ticker/manifest.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Crypto Ticker",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
|
||||
"permissions": {
|
||||
"config": {
|
||||
"reason": "To read ticker symbols configuration"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule reconnection attempts on connection loss"
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To connect to Coinbase WebSocket API for real-time prices",
|
||||
"allowedHosts": ["ws-feed.exchange.coinbase.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -19,78 +18,6 @@ import (
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest contains plugin metadata.
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
HTTP *HTTPPermission `json:"http,omitempty"`
|
||||
WebSocket *WebSocketPermission `json:"websocket,omitempty"`
|
||||
Cache *PermissionReason `json:"cache,omitempty"`
|
||||
Scheduler *PermissionReason `json:"scheduler,omitempty"`
|
||||
Artwork *PermissionReason `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty"`
|
||||
}
|
||||
|
||||
type WebSocketPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionReason struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// nd_manifest returns the plugin manifest.
|
||||
//
|
||||
//export nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Discord Rich Presence",
|
||||
Author: "Navidrome Team",
|
||||
Version: "1.0.0",
|
||||
Description: "Discord Rich Presence integration for Navidrome",
|
||||
Website: "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
|
||||
Permissions: &Permissions{
|
||||
HTTP: &HTTPPermission{
|
||||
Reason: "To communicate with Discord API for gateway discovery and image uploads",
|
||||
AllowedHosts: []string{"discord.com"},
|
||||
},
|
||||
WebSocket: &WebSocketPermission{
|
||||
Reason: "To maintain real-time connection with Discord gateway",
|
||||
AllowedHosts: []string{"gateway.discord.gg"},
|
||||
},
|
||||
Cache: &PermissionReason{
|
||||
Reason: "To store connection state and sequence numbers",
|
||||
},
|
||||
Scheduler: &PermissionReason{
|
||||
Reason: "To schedule heartbeat messages and activity clearing",
|
||||
},
|
||||
Artwork: &PermissionReason{
|
||||
Reason: "To get track artwork URLs for rich presence display",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Configuration keys
|
||||
const (
|
||||
clientIDKey = "clientid"
|
||||
|
||||
26
plugins/examples/discord-rich-presence/manifest.json
Normal file
26
plugins/examples/discord-rich-presence/manifest.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Discord Rich Presence",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord Rich Presence integration for Navidrome",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"allowedHosts": ["discord.com"]
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To maintain real-time connection with Discord gateway",
|
||||
"allowedHosts": ["gateway.discord.gg"]
|
||||
},
|
||||
"cache": {
|
||||
"reason": "To store connection state and sequence numbers"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule heartbeat messages and activity clearing"
|
||||
},
|
||||
"artwork": {
|
||||
"reason": "To get track artwork URLs for rich presence display"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
plugins/examples/library-inspector/manifest.json
Normal file
16
plugins/examples/library-inspector/manifest.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Library Inspector",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Periodically logs library details and finds largest files",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/library-inspector",
|
||||
"permissions": {
|
||||
"library": {
|
||||
"reason": "To read library metadata and scan directories for file sizes",
|
||||
"filesystem": true
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule periodic library inspections"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,37 +16,6 @@ use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
// ============================================================================
|
||||
// Manifest Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Manifest {
|
||||
name: String,
|
||||
author: String,
|
||||
version: String,
|
||||
description: String,
|
||||
website: Option<String>,
|
||||
permissions: Option<Permissions>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Permissions {
|
||||
library: Option<LibraryPermission>,
|
||||
scheduler: Option<SchedulerPermission>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LibraryPermission {
|
||||
reason: String,
|
||||
filesystem: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SchedulerPermission {
|
||||
reason: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Library Types
|
||||
// ============================================================================
|
||||
@ -303,31 +272,6 @@ fn inspect_libraries() {
|
||||
// Plugin Exports
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the plugin manifest with metadata and permissions.
|
||||
#[plugin_fn]
|
||||
pub fn nd_manifest() -> FnResult<Json<Manifest>> {
|
||||
let manifest = Manifest {
|
||||
name: "Library Inspector".to_string(),
|
||||
author: "Navidrome Team".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: "Periodically logs library details and finds largest files".to_string(),
|
||||
website: Some(
|
||||
"https://github.com/navidrome/navidrome/tree/master/plugins/examples/library-inspector"
|
||||
.to_string(),
|
||||
),
|
||||
permissions: Some(Permissions {
|
||||
library: Some(LibraryPermission {
|
||||
reason: "To read library metadata and scan directories for file sizes".to_string(),
|
||||
filesystem: true,
|
||||
}),
|
||||
scheduler: Some(SchedulerPermission {
|
||||
reason: "To schedule periodic library inspections".to_string(),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
Ok(Json(manifest))
|
||||
}
|
||||
|
||||
/// Called when the plugin is initialized. Schedules the recurring inspection task.
|
||||
#[plugin_fn]
|
||||
pub fn nd_on_init() -> FnResult<Json<InitOutput>> {
|
||||
|
||||
@ -4,22 +4,13 @@
|
||||
//
|
||||
// tinygo build -o minimal.wasm -target wasip1 -buildmode=c-shared ./main.go
|
||||
//
|
||||
// Install by copying minimal.wasm to your Navidrome plugins folder.
|
||||
// Install by copying minimal.ndp to your Navidrome plugins folder.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ArtistInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@ -30,23 +21,6 @@ type BiographyOutput struct {
|
||||
Biography string `json:"biography"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Minimal Example",
|
||||
Author: "Navidrome",
|
||||
Version: "1.0.0",
|
||||
Description: "A minimal example plugin",
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:wasmexport nd_get_artist_biography
|
||||
func ndGetArtistBiography() int32 {
|
||||
var input ArtistInput
|
||||
|
||||
6
plugins/examples/minimal/manifest.json
Normal file
6
plugins/examples/minimal/manifest.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Minimal Example",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "A minimal example plugin"
|
||||
}
|
||||
16
plugins/examples/nowplaying-py/manifest.json
Normal file
16
plugins/examples/nowplaying-py/manifest.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Now Playing Logger (Python)",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Periodically logs currently playing tracks - Python example demonstrating Scheduler and SubsonicAPI host services",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/nowplaying-py",
|
||||
"permissions": {
|
||||
"scheduler": {
|
||||
"reason": "Schedule periodic checks for now playing status"
|
||||
},
|
||||
"subsonicapi": {
|
||||
"reason": "Query the getNowPlaying API endpoint",
|
||||
"allowAdmins": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,6 @@
|
||||
# Build with:
|
||||
# extism-py plugin/__init__.py -o nowplaying-py.wasm
|
||||
#
|
||||
# Test manifest with:
|
||||
# extism call nowplaying-py.wasm nd_manifest --wasi
|
||||
#
|
||||
# Configuration:
|
||||
# [PluginConfig.nowplaying-py]
|
||||
# cron = "*/1 * * * *" # Every minute (default)
|
||||
@ -104,28 +101,6 @@ def subsonicapi_call(uri: str) -> dict:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@extism.plugin_fn
|
||||
def nd_manifest():
|
||||
"""Return the plugin manifest with metadata and permissions."""
|
||||
manifest = {
|
||||
"name": "Now Playing Logger (Python)",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Periodically logs currently playing tracks - Python example demonstrating Scheduler and SubsonicAPI host services",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/nowplaying-py",
|
||||
"permissions": {
|
||||
"scheduler": {
|
||||
"reason": "Schedule periodic checks for now playing status"
|
||||
},
|
||||
"subsonicapi": {
|
||||
"reason": "Query the getNowPlaying API endpoint",
|
||||
"allowAdmins": True
|
||||
}
|
||||
}
|
||||
}
|
||||
extism.output_str(json.dumps(manifest))
|
||||
|
||||
|
||||
@extism.plugin_fn
|
||||
def nd_on_init():
|
||||
"""Initialize the plugin by scheduling the recurring task."""
|
||||
|
||||
13
plugins/examples/webhook-rs/manifest.json
Normal file
13
plugins/examples/webhook-rs/manifest.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Webhook Scrobbler",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Sends HTTP webhooks on scrobble events",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/webhook-rs",
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To send webhook notifications to configured URLs",
|
||||
"allowedHosts": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,32 +15,6 @@
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// Manifest Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Manifest {
|
||||
name: String,
|
||||
author: String,
|
||||
version: String,
|
||||
description: String,
|
||||
website: Option<String>,
|
||||
permissions: Option<Permissions>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Permissions {
|
||||
http: Option<HttpPermission>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HttpPermission {
|
||||
reason: String,
|
||||
allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scrobbler Types
|
||||
// ============================================================================
|
||||
@ -105,28 +79,6 @@ struct ScrobblerOutput {
|
||||
// Plugin Exports
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the plugin manifest with metadata and permissions.
|
||||
#[plugin_fn]
|
||||
pub fn nd_manifest() -> FnResult<Json<Manifest>> {
|
||||
let manifest = Manifest {
|
||||
name: "Webhook Scrobbler".to_string(),
|
||||
author: "Navidrome Team".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: "Sends HTTP webhooks on scrobble events".to_string(),
|
||||
website: Some(
|
||||
"https://github.com/navidrome/navidrome/tree/master/plugins/examples/webhook-rs"
|
||||
.to_string(),
|
||||
),
|
||||
permissions: Some(Permissions {
|
||||
http: Some(HttpPermission {
|
||||
reason: "To send webhook notifications to configured URLs".to_string(),
|
||||
allowed_hosts: vec!["*".to_string()],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
Ok(Json(manifest))
|
||||
}
|
||||
|
||||
/// Checks if a user is authorized. This plugin authorizes all users.
|
||||
#[plugin_fn]
|
||||
pub fn nd_scrobbler_is_authorized(Json(input): Json<AuthInput>) -> FnResult<Json<AuthOutput>> {
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
//
|
||||
// tinygo build -o wikimedia.wasm -target wasip1 -buildmode=c-shared .
|
||||
//
|
||||
// Install by copying the .wasm file to your Navidrome plugins folder.
|
||||
// Install by copying the .ndp file to your Navidrome plugins folder.
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -31,25 +31,6 @@ const (
|
||||
mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
|
||||
)
|
||||
|
||||
// Plugin manifest containing metadata about this plugin
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
HTTP *HTTPPermission `json:"http,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty"`
|
||||
}
|
||||
|
||||
// SPARQL response types
|
||||
type SPARQLResult struct {
|
||||
Results struct {
|
||||
@ -83,36 +64,6 @@ type MediaWikiPage struct {
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// nd_manifest is required by Navidrome to identify the plugin.
|
||||
//
|
||||
//export nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Wikimedia",
|
||||
Author: "Navidrome",
|
||||
Version: "1.0.0",
|
||||
Description: "Fetches artist metadata from Wikidata, DBpedia and Wikipedia",
|
||||
Website: "https://navidrome.org",
|
||||
Permissions: &Permissions{
|
||||
HTTP: &HTTPPermission{
|
||||
Reason: "Fetch metadata from Wikimedia APIs",
|
||||
AllowedHosts: []string{
|
||||
"query.wikidata.org",
|
||||
"dbpedia.org",
|
||||
"en.wikipedia.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// sparqlQuery executes a SPARQL query and returns the result
|
||||
func sparqlQuery(endpoint, query string) (*SPARQLResult, error) {
|
||||
form := url.Values{}
|
||||
|
||||
17
plugins/examples/wikimedia/manifest.json
Normal file
17
plugins/examples/wikimedia/manifest.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Wikimedia",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Fetches artist metadata from Wikidata, DBpedia and Wikipedia",
|
||||
"website": "https://navidrome.org",
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Fetch metadata from Wikimedia APIs",
|
||||
"allowedHosts": [
|
||||
"query.wikidata.org",
|
||||
"dbpedia.org",
|
||||
"en.wikipedia.org"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -33,8 +33,8 @@ var _ = Describe("ArtworkService", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-artwork plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-artwork.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-artwork.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-artwork"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-artwork"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
|
||||
@ -329,8 +329,8 @@ var _ = Describe("CacheService Integration", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-cache-plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-cache-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-cache-plugin.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-cache-plugin"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-cache-plugin"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
|
||||
@ -247,8 +247,8 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-library plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-library.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-library.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-library"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-library"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
@ -445,7 +445,10 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
|
||||
Expect(err.Error()).To(ContainSubstring("library not found"))
|
||||
})
|
||||
|
||||
It("should read file from mounted library directory", func() {
|
||||
// Note: This test is slightly flaky due to a potential race condition in wazero's
|
||||
// WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts
|
||||
// to automatically retry on failure.
|
||||
It("should read file from mounted library directory", FlakeAttempts(3), func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
@ -457,7 +460,8 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
|
||||
Expect(output.FileContent).To(Equal("test audio file content"))
|
||||
})
|
||||
|
||||
It("should list files in mounted library directory", func() {
|
||||
// Note: Uses FlakeAttempts for the same reason as the read_file test above
|
||||
It("should list files in mounted library directory", FlakeAttempts(3), func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
|
||||
@ -37,8 +37,8 @@ var _ = Describe("SchedulerService", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-scheduler plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-scheduler.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-scheduler.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-scheduler"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-scheduler"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
|
||||
@ -33,8 +33,8 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy test plugin to temp dir
|
||||
srcPath := filepath.Join(testdataDir, "test-subsonicapi-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-subsonicapi-plugin.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-subsonicapi-plugin"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-subsonicapi-plugin"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
@ -73,7 +73,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
|
||||
|
||||
// Pre-enable the plugin in the mock repo so it loads on startup
|
||||
// Compute SHA256 of the plugin file to match what syncPlugins will compute
|
||||
pluginPath := filepath.Join(tmpDir, "test-subsonicapi-plugin.wasm")
|
||||
pluginPath := filepath.Join(tmpDir, "test-subsonicapi-plugin"+PackageExtension)
|
||||
wasmData, err := os.ReadFile(pluginPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
hash := sha256.Sum256(wasmData)
|
||||
|
||||
@ -37,8 +37,8 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-websocket plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-websocket.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-websocket.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-websocket"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-websocket"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
|
||||
@ -23,10 +23,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// manifestFunction is the name of the function that plugins must export
|
||||
// to provide their manifest.
|
||||
manifestFunction = "nd_manifest"
|
||||
|
||||
// defaultTimeout is the default timeout for plugin function calls
|
||||
defaultTimeout = 30 * time.Second
|
||||
|
||||
|
||||
@ -2,14 +2,9 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@ -31,7 +26,6 @@ type serviceContext struct {
|
||||
type hostServiceEntry struct {
|
||||
name string
|
||||
hasPermission func(*Permissions) bool
|
||||
registerStubs func() []extism.HostFunction
|
||||
create func(*serviceContext) ([]extism.HostFunction, io.Closer)
|
||||
}
|
||||
|
||||
@ -41,7 +35,6 @@ var hostServices = []hostServiceEntry{
|
||||
{
|
||||
name: "SubsonicAPI",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterSubsonicAPIHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Subsonicapi
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, perm)
|
||||
@ -51,7 +44,6 @@ var hostServices = []hostServiceEntry{
|
||||
{
|
||||
name: "Scheduler",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterSchedulerHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance())
|
||||
return host.RegisterSchedulerHostFunctions(service), service
|
||||
@ -60,7 +52,6 @@ var hostServices = []hostServiceEntry{
|
||||
{
|
||||
name: "WebSocket",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterWebSocketHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Websocket
|
||||
service := newWebSocketService(ctx.pluginName, ctx.manager, perm)
|
||||
@ -70,7 +61,6 @@ var hostServices = []hostServiceEntry{
|
||||
{
|
||||
name: "Artwork",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterArtworkHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newArtworkService()
|
||||
return host.RegisterArtworkHostFunctions(service), nil
|
||||
@ -79,7 +69,6 @@ var hostServices = []hostServiceEntry{
|
||||
{
|
||||
name: "Cache",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterCacheHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newCacheService(ctx.pluginName)
|
||||
return host.RegisterCacheHostFunctions(service), service
|
||||
@ -88,7 +77,6 @@ var hostServices = []hostServiceEntry{
|
||||
{
|
||||
name: "Library",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterLibraryHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Library
|
||||
service := newLibraryService(ctx.manager.ds, perm)
|
||||
@ -97,106 +85,27 @@ var hostServices = []hostServiceEntry{
|
||||
},
|
||||
}
|
||||
|
||||
// stubHostFunctions returns the list of stub host functions needed for initial plugin compilation.
|
||||
func stubHostFunctions() []extism.HostFunction {
|
||||
var stubs []extism.HostFunction
|
||||
for _, entry := range hostServices {
|
||||
stubs = append(stubs, entry.registerStubs()...)
|
||||
}
|
||||
return stubs
|
||||
}
|
||||
|
||||
// compiledPluginInfo holds the intermediate compilation result used by both
|
||||
// extractManifest and loadPluginWithConfig.
|
||||
type compiledPluginInfo struct {
|
||||
wasmBytes []byte
|
||||
sha256 string
|
||||
manifest *Manifest
|
||||
compiled *extism.CompiledPlugin
|
||||
}
|
||||
|
||||
// compileAndExtractManifest reads a wasm file, compiles it with cache, and extracts the manifest.
|
||||
// The caller is responsible for closing the returned compiled plugin when done.
|
||||
func (m *Manager) compileAndExtractManifest(ctx context.Context, wasmPath string, config map[string]string) (*compiledPluginInfo, error) {
|
||||
wasmBytes, err := os.ReadFile(wasmPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading wasm file: %w", err)
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash
|
||||
hash := sha256.Sum256(wasmBytes)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Extract plugin name from path for logging
|
||||
pluginName := strings.TrimSuffix(filepath.Base(wasmPath), ".wasm")
|
||||
|
||||
pluginManifest := extism.Manifest{
|
||||
Wasm: []extism.Wasm{
|
||||
extism.WasmData{Data: wasmBytes, Name: "main"},
|
||||
},
|
||||
Config: config,
|
||||
Timeout: uint64(defaultTimeout.Milliseconds()),
|
||||
}
|
||||
extismConfig := extism.PluginConfig{
|
||||
EnableWasi: true,
|
||||
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
|
||||
}
|
||||
|
||||
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, stubHostFunctions())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compiling plugin: %w", err)
|
||||
}
|
||||
|
||||
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
|
||||
if err != nil {
|
||||
compiled.Close(ctx)
|
||||
return nil, fmt.Errorf("creating instance: %w", err)
|
||||
}
|
||||
defer instance.Close(ctx)
|
||||
instance.SetLogger(extismLogger(pluginName))
|
||||
|
||||
exit, manifestBytes, err := instance.Call(manifestFunction, nil)
|
||||
if err != nil {
|
||||
compiled.Close(ctx)
|
||||
return nil, fmt.Errorf("calling manifest function: %w", err)
|
||||
}
|
||||
if exit != 0 {
|
||||
compiled.Close(ctx)
|
||||
return nil, fmt.Errorf("manifest function exited with code %d", exit)
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
compiled.Close(ctx)
|
||||
return nil, fmt.Errorf("parsing manifest: %w", err)
|
||||
}
|
||||
|
||||
return &compiledPluginInfo{
|
||||
wasmBytes: wasmBytes,
|
||||
sha256: hashHex,
|
||||
manifest: &manifest,
|
||||
compiled: compiled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractManifest loads a wasm file, computes its SHA-256 hash, extracts the manifest,
|
||||
// and immediately closes without full plugin initialization.
|
||||
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
|
||||
// This is a lightweight operation used for plugin discovery and change detection.
|
||||
// The compilation is cached to speed up subsequent EnablePlugin calls.
|
||||
func (m *Manager) extractManifest(wasmPath string) (*PluginMetadata, error) {
|
||||
// Unlike the old implementation, this does NOT compile the wasm - just reads the manifest JSON.
|
||||
func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) {
|
||||
if m.stopped.Load() {
|
||||
return nil, fmt.Errorf("manager is stopped")
|
||||
}
|
||||
|
||||
info, err := m.compileAndExtractManifest(context.Background(), wasmPath, nil)
|
||||
manifest, err := readManifest(ndpPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer info.compiled.Close(context.Background())
|
||||
|
||||
sha256Hash, err := computeFileSHA256(ndpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing hash: %w", err)
|
||||
}
|
||||
|
||||
return &PluginMetadata{
|
||||
Manifest: info.manifest,
|
||||
SHA256: info.sha256,
|
||||
Manifest: manifest,
|
||||
SHA256: sha256Hash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -270,7 +179,8 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// loadPluginWithConfig loads a plugin with configuration from DB.
|
||||
func (m *Manager) loadPluginWithConfig(name, wasmPath, configJSON string) error {
|
||||
// The ndpPath should point to an .ndp package file.
|
||||
func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
|
||||
if m.stopped.Load() {
|
||||
return fmt.Errorf("manager is stopped")
|
||||
}
|
||||
@ -291,41 +201,27 @@ func (m *Manager) loadPluginWithConfig(name, wasmPath, configJSON string) error
|
||||
}
|
||||
}
|
||||
|
||||
// Compile and extract manifest using shared helper
|
||||
info, err := m.compileAndExtractManifest(m.ctx, wasmPath, pluginConfig)
|
||||
// Open the .ndp package to get manifest and wasm bytes
|
||||
pkg, err := openPackage(ndpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("opening package: %w", err)
|
||||
}
|
||||
|
||||
// Create instance to detect capabilities
|
||||
instance, err := info.compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
|
||||
if err != nil {
|
||||
info.compiled.Close(m.ctx)
|
||||
return fmt.Errorf("creating instance: %w", err)
|
||||
}
|
||||
instance.SetLogger(extismLogger(name))
|
||||
capabilities := detectCapabilities(instance)
|
||||
instance.Close(m.ctx)
|
||||
|
||||
// Build host functions based on permissions
|
||||
var hostFunctions []extism.HostFunction
|
||||
var closers []io.Closer
|
||||
|
||||
// Build extism manifest for potential recompilation
|
||||
// Build extism manifest
|
||||
pluginManifest := extism.Manifest{
|
||||
Wasm: []extism.Wasm{
|
||||
extism.WasmData{Data: info.wasmBytes, Name: "main"},
|
||||
extism.WasmData{Data: pkg.WasmBytes, Name: "main"},
|
||||
},
|
||||
Config: pluginConfig,
|
||||
Timeout: uint64(defaultTimeout.Milliseconds()),
|
||||
}
|
||||
|
||||
if hosts := info.manifest.AllowedHosts(); len(hosts) > 0 {
|
||||
if hosts := pkg.Manifest.AllowedHosts(); len(hosts) > 0 {
|
||||
pluginManifest.AllowedHosts = hosts
|
||||
}
|
||||
|
||||
// Configure filesystem access for library permission
|
||||
if info.manifest.Permissions != nil && info.manifest.Permissions.Library != nil && info.manifest.Permissions.Library.Filesystem {
|
||||
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
|
||||
adminCtx := adminContext(m.ctx)
|
||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||
if err != nil {
|
||||
@ -339,14 +235,17 @@ func (m *Manager) loadPluginWithConfig(name, wasmPath, configJSON string) error
|
||||
pluginManifest.AllowedPaths = allowedPaths
|
||||
}
|
||||
|
||||
// Register host functions based on permissions using table-driven approach
|
||||
// Build host functions based on permissions from manifest
|
||||
var hostFunctions []extism.HostFunction
|
||||
var closers []io.Closer
|
||||
|
||||
svcCtx := &serviceContext{
|
||||
pluginName: name,
|
||||
manager: m,
|
||||
permissions: info.manifest.Permissions,
|
||||
permissions: pkg.Manifest.Permissions,
|
||||
}
|
||||
for _, entry := range hostServices {
|
||||
if entry.hasPermission(info.manifest.Permissions) {
|
||||
if entry.hasPermission(pkg.Manifest.Permissions) {
|
||||
funcs, closer := entry.create(svcCtx)
|
||||
hostFunctions = append(hostFunctions, funcs...)
|
||||
if closer != nil {
|
||||
@ -355,30 +254,31 @@ func (m *Manager) loadPluginWithConfig(name, wasmPath, configJSON string) error
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the plugin needs to be recompiled with real host functions
|
||||
compiled := info.compiled
|
||||
needsRecompile := len(pluginManifest.AllowedHosts) > 0 || len(hostFunctions) > 0
|
||||
|
||||
// Recompile if needed. It is actually not a "recompile" since the first compilation
|
||||
// should be cached by wazero. We just need to do it this way to provide the real host functions.
|
||||
if needsRecompile {
|
||||
log.Trace(m.ctx, "Recompiling plugin with host functions", "plugin", name)
|
||||
info.compiled.Close(m.ctx)
|
||||
extismConfig := extism.PluginConfig{
|
||||
EnableWasi: true,
|
||||
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
|
||||
}
|
||||
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Compile the plugin with all host functions
|
||||
extismConfig := extism.PluginConfig{
|
||||
EnableWasi: true,
|
||||
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
|
||||
}
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling plugin: %w", err)
|
||||
}
|
||||
|
||||
// Create instance to detect capabilities
|
||||
instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
|
||||
if err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
return fmt.Errorf("creating instance: %w", err)
|
||||
}
|
||||
instance.SetLogger(extismLogger(name))
|
||||
capabilities := detectCapabilities(instance)
|
||||
instance.Close(m.ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[name] = &plugin{
|
||||
name: name,
|
||||
path: wasmPath,
|
||||
manifest: info.manifest,
|
||||
path: ndpPath,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
|
||||
@ -132,10 +132,10 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
|
||||
// Build map of files in folder
|
||||
filesOnDisk := make(map[string]string) // name -> path
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".wasm") {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), ".wasm")
|
||||
name := strings.TrimSuffix(entry.Name(), PackageExtension)
|
||||
filesOnDisk[name] = filepath.Join(folder, entry.Name())
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -83,12 +84,12 @@ func (m *Manager) watcherLoop() {
|
||||
func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
|
||||
path := event.Path()
|
||||
|
||||
// Only process .wasm files
|
||||
if !strings.HasSuffix(path, ".wasm") {
|
||||
// Only process .ndp package files
|
||||
if !strings.HasSuffix(path, PackageExtension) {
|
||||
return
|
||||
}
|
||||
|
||||
pluginName := strings.TrimSuffix(filepath.Base(path), ".wasm")
|
||||
pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension)
|
||||
|
||||
log.Debug(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)
|
||||
|
||||
@ -98,41 +99,43 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
eventType := event.Event()
|
||||
// Note: We don't capture the event type here. Instead, processPluginEvent
|
||||
// checks if the file exists when the timer fires. This handles sequences like
|
||||
// Remove+Create+Rename correctly by checking actual file state after debounce.
|
||||
m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() {
|
||||
m.processPluginEvent(pluginName, eventType)
|
||||
m.processPluginEvent(pluginName)
|
||||
})
|
||||
m.debounceMu.Unlock()
|
||||
}
|
||||
|
||||
// pluginAction represents the action to take on a plugin based on a file event
|
||||
// pluginAction represents the action to take on a plugin based on file state
|
||||
type pluginAction int
|
||||
|
||||
const (
|
||||
actionNone pluginAction = iota // No action needed
|
||||
actionAdd // Add new plugin to DB (disabled)
|
||||
actionUpdate // Update existing plugin in DB (disable if enabled)
|
||||
actionRemove // Remove plugin from DB (unload if enabled)
|
||||
actionUpdate // File exists: add new or update existing plugin in DB
|
||||
actionRemove // File gone: remove plugin from DB (unload if enabled)
|
||||
)
|
||||
|
||||
// determinePluginAction decides what action to take based on the file event type.
|
||||
func determinePluginAction(eventType notify.Event) pluginAction {
|
||||
switch {
|
||||
case eventType¬ify.Remove != 0 || eventType¬ify.Rename != 0:
|
||||
return actionRemove
|
||||
case eventType¬ify.Create != 0:
|
||||
return actionAdd
|
||||
case eventType¬ify.Write != 0:
|
||||
// determinePluginAction decides what action to take based on file existence.
|
||||
// We check file existence rather than relying on event type because:
|
||||
// 1. Events can be coalesced on some systems (macOS FSEvents)
|
||||
// 2. Rename events can mean either "renamed away" (remove) or "renamed to" (add)
|
||||
// 3. Build tools often do atomic writes (write temp file, rename to target)
|
||||
// By checking existence, we handle all these cases correctly.
|
||||
func determinePluginAction(path string) pluginAction {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
// File exists - treat as add/update
|
||||
return actionUpdate
|
||||
}
|
||||
return actionNone
|
||||
// File doesn't exist - it was removed
|
||||
return actionRemove
|
||||
}
|
||||
|
||||
// processPluginEvent handles the actual plugin load/unload/reload after debouncing.
|
||||
// - On file add: extract manifest, create DB record as disabled
|
||||
// - On file change: extract manifest, update DB, disable if was enabled
|
||||
// - On file remove: unload if enabled, delete DB record
|
||||
func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) {
|
||||
// - If file exists: extract manifest, add or update plugin in DB
|
||||
// - If file gone: unload if enabled, delete from DB
|
||||
func (m *Manager) processPluginEvent(pluginName string) {
|
||||
// Don't process if manager is stopping/stopped (atomic check to avoid race with Stop())
|
||||
if m.stopped.Load() {
|
||||
return
|
||||
@ -143,29 +146,19 @@ func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event)
|
||||
delete(m.debounceTimers, pluginName)
|
||||
m.debounceMu.Unlock()
|
||||
|
||||
action := determinePluginAction(eventType)
|
||||
log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action)
|
||||
folder := conf.Server.Plugins.Folder
|
||||
ndpPath := filepath.Join(folder, pluginName+PackageExtension)
|
||||
|
||||
action := determinePluginAction(ndpPath)
|
||||
log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action, "path", ndpPath)
|
||||
|
||||
ctx := adminContext(m.ctx)
|
||||
repo := m.ds.Plugin(ctx)
|
||||
folder := conf.Server.Plugins.Folder
|
||||
wasmPath := filepath.Join(folder, pluginName+".wasm")
|
||||
|
||||
switch action {
|
||||
case actionAdd:
|
||||
// New file - extract manifest and add to DB as disabled
|
||||
metadata, err := m.extractManifest(wasmPath)
|
||||
if err != nil {
|
||||
log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, err)
|
||||
return
|
||||
}
|
||||
if err := m.addPluginToDB(m.ctx, repo, pluginName, wasmPath, metadata); err != nil {
|
||||
log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, err)
|
||||
}
|
||||
|
||||
case actionUpdate:
|
||||
// File changed - check SHA256 first, then extract manifest if needed
|
||||
sha256Hash, err := computeFileSHA256(wasmPath)
|
||||
sha256Hash, err := computeFileSHA256(ndpPath)
|
||||
if err != nil {
|
||||
log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err)
|
||||
return
|
||||
@ -174,12 +167,12 @@ func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event)
|
||||
dbPlugin, err := repo.Get(pluginName)
|
||||
if err != nil {
|
||||
// Plugin not in DB yet, need full manifest extraction to add it
|
||||
metadata, extractErr := m.extractManifest(wasmPath)
|
||||
metadata, extractErr := m.extractManifest(ndpPath)
|
||||
if extractErr != nil {
|
||||
log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, extractErr)
|
||||
return
|
||||
}
|
||||
if addErr := m.addPluginToDB(m.ctx, repo, pluginName, wasmPath, metadata); addErr != nil {
|
||||
if addErr := m.addPluginToDB(m.ctx, repo, pluginName, ndpPath, metadata); addErr != nil {
|
||||
log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr)
|
||||
}
|
||||
return
|
||||
@ -191,7 +184,7 @@ func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event)
|
||||
}
|
||||
|
||||
// Plugin changed - now extract full manifest
|
||||
metadata, err := m.extractManifest(wasmPath)
|
||||
metadata, err := m.extractManifest(ndpPath)
|
||||
if err != nil {
|
||||
log.Error(m.ctx, "Failed to extract manifest from changed plugin", "plugin", pluginName, err)
|
||||
// Update error in DB
|
||||
@ -205,7 +198,7 @@ func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.updatePluginInDB(m.ctx, repo, dbPlugin, wasmPath, metadata); err != nil {
|
||||
if err := m.updatePluginInDB(m.ctx, repo, dbPlugin, ndpPath, metadata); err != nil {
|
||||
log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err)
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/rjeczalik/notify"
|
||||
)
|
||||
|
||||
var _ = Describe("Plugin Watcher", func() {
|
||||
@ -30,13 +29,15 @@ var _ = Describe("Plugin Watcher", func() {
|
||||
|
||||
// Remove the auto-loaded plugin so tests can control loading
|
||||
_ = manager.unloadPlugin("test-metadata-agent")
|
||||
_ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent.wasm"))
|
||||
_ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension))
|
||||
// Also remove from DB so tests start with a clean slate
|
||||
_ = manager.ds.Plugin(ctx).Delete("test-metadata-agent")
|
||||
})
|
||||
|
||||
// Helper to copy test plugin into the temp folder
|
||||
copyTestPlugin := func() {
|
||||
srcPath := filepath.Join(testdataDir, "test-metadata-agent.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-metadata-agent.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "test-metadata-agent"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
@ -47,14 +48,15 @@ var _ = Describe("Plugin Watcher", func() {
|
||||
// These tests verify the DB-driven flow with actual WASM plugin loading.
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up: unload plugin if loaded, remove copied file
|
||||
// Clean up: unload plugin if loaded, remove copied file, delete from DB
|
||||
_ = manager.unloadPlugin("test-metadata-agent")
|
||||
_ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent.wasm"))
|
||||
_ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension))
|
||||
_ = manager.ds.Plugin(ctx).Delete("test-metadata-agent")
|
||||
})
|
||||
|
||||
It("adds plugin to DB on CREATE event", func() {
|
||||
It("adds plugin to DB when file exists", func() {
|
||||
copyTestPlugin()
|
||||
manager.processPluginEvent("test-metadata-agent", notify.Create)
|
||||
manager.processPluginEvent("test-metadata-agent")
|
||||
|
||||
// Plugin should be in DB but not loaded (starts disabled)
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent"))
|
||||
@ -67,11 +69,11 @@ var _ = Describe("Plugin Watcher", func() {
|
||||
Expect(plugin.Enabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("updates DB and disables plugin on WRITE event when file changes", func() {
|
||||
It("updates DB and disables plugin when file changes", func() {
|
||||
copyTestPlugin()
|
||||
|
||||
// First add and enable the plugin
|
||||
manager.processPluginEvent("test-metadata-agent", notify.Create)
|
||||
manager.processPluginEvent("test-metadata-agent")
|
||||
err := manager.EnablePlugin(ctx, "test-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent"))
|
||||
@ -86,7 +88,7 @@ var _ = Describe("Plugin Watcher", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Simulate modification - the plugin should be disabled and unloaded
|
||||
manager.processPluginEvent("test-metadata-agent", notify.Write)
|
||||
manager.processPluginEvent("test-metadata-agent")
|
||||
|
||||
// Should be unloaded
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent"))
|
||||
@ -97,17 +99,17 @@ var _ = Describe("Plugin Watcher", func() {
|
||||
Expect(plugin.Enabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("removes plugin from DB on REMOVE event", func() {
|
||||
It("removes plugin from DB when file is removed", func() {
|
||||
copyTestPlugin()
|
||||
|
||||
// First add and enable the plugin
|
||||
manager.processPluginEvent("test-metadata-agent", notify.Create)
|
||||
manager.processPluginEvent("test-metadata-agent")
|
||||
err := manager.EnablePlugin(ctx, "test-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Simulate removal - plugin should be unloaded and removed from DB
|
||||
_ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent.wasm"))
|
||||
manager.processPluginEvent("test-metadata-agent", notify.Remove)
|
||||
// Remove the file - plugin should be unloaded and removed from DB
|
||||
_ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension))
|
||||
manager.processPluginEvent("test-metadata-agent")
|
||||
|
||||
// Should be unloaded
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent"))
|
||||
@ -151,29 +153,29 @@ var _ = Describe("Plugin Watcher", func() {
|
||||
})
|
||||
|
||||
Describe("determinePluginAction", func() {
|
||||
// These are fast unit tests for the pure routing logic.
|
||||
// No WASM compilation, no file I/O - runs in microseconds.
|
||||
var tmpDir string
|
||||
|
||||
DescribeTable("returns correct action for event type",
|
||||
func(eventType notify.Event, expected pluginAction) {
|
||||
Expect(determinePluginAction(eventType)).To(Equal(expected))
|
||||
},
|
||||
// CREATE events - add to DB
|
||||
Entry("CREATE", notify.Create, actionAdd),
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugin-action-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
// WRITE events - update in DB
|
||||
Entry("WRITE", notify.Write, actionUpdate),
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
// REMOVE events - remove from DB
|
||||
Entry("REMOVE", notify.Remove, actionRemove),
|
||||
It("returns actionUpdate when file exists", func() {
|
||||
filePath := filepath.Join(tmpDir, "test.ndp")
|
||||
err := os.WriteFile(filePath, []byte("test"), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// RENAME events - treated same as REMOVE
|
||||
Entry("RENAME", notify.Rename, actionRemove),
|
||||
)
|
||||
Expect(determinePluginAction(filePath)).To(Equal(actionUpdate))
|
||||
})
|
||||
|
||||
It("returns actionNone for unknown event types", func() {
|
||||
// Event type 0 or other unknown values
|
||||
Expect(determinePluginAction(0)).To(Equal(actionNone))
|
||||
It("returns actionRemove when file does not exist", func() {
|
||||
filePath := filepath.Join(tmpDir, "nonexistent.ndp")
|
||||
Expect(determinePluginAction(filePath)).To(Equal(actionRemove))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
113
plugins/package.go
Normal file
113
plugins/package.go
Normal file
@ -0,0 +1,113 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
// PackageExtension is the file extension for Navidrome plugin packages.
|
||||
PackageExtension = ".ndp"
|
||||
|
||||
// manifestFileName is the name of the manifest file inside the package.
|
||||
manifestFileName = "manifest.json"
|
||||
|
||||
// wasmFileName is the name of the WebAssembly module inside the package.
|
||||
wasmFileName = "plugin.wasm"
|
||||
)
|
||||
|
||||
// ndpPackage represents a loaded .ndp plugin package.
|
||||
// It contains the manifest and wasm bytes read from the archive.
|
||||
type ndpPackage struct {
|
||||
Manifest *Manifest
|
||||
WasmBytes []byte
|
||||
}
|
||||
|
||||
// openPackage opens an .ndp file and extracts the manifest and wasm bytes.
|
||||
// The caller does not need to call Close() - all resources are read into memory.
|
||||
func openPackage(ndpPath string) (*ndpPackage, error) {
|
||||
// Open the zip archive
|
||||
zr, err := zip.OpenReader(ndpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening package: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
var manifestBytes []byte
|
||||
var wasmBytes []byte
|
||||
|
||||
for _, f := range zr.File {
|
||||
switch f.Name {
|
||||
case manifestFileName:
|
||||
manifestBytes, err = readZipFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading manifest: %w", err)
|
||||
}
|
||||
case wasmFileName:
|
||||
wasmBytes, err = readZipFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading wasm: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if manifestBytes == nil {
|
||||
return nil, errors.New("package missing manifest.json")
|
||||
}
|
||||
if wasmBytes == nil {
|
||||
return nil, errors.New("package missing plugin.wasm")
|
||||
}
|
||||
|
||||
// Parse and validate manifest
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parsing manifest: %w", err)
|
||||
}
|
||||
|
||||
return &ndpPackage{
|
||||
Manifest: &manifest,
|
||||
WasmBytes: wasmBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readManifest reads only the manifest from an .ndp file without loading the wasm bytes.
|
||||
// This is useful for quick plugin discovery.
|
||||
func readManifest(ndpPath string) (*Manifest, error) {
|
||||
// Open the zip archive
|
||||
zr, err := zip.OpenReader(ndpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening package: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.Name == manifestFileName {
|
||||
manifestBytes, err := readZipFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading manifest: %w", err)
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parsing manifest: %w", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("package missing manifest.json")
|
||||
}
|
||||
|
||||
// readZipFile reads the contents of a file from a zip archive.
|
||||
func readZipFile(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
270
plugins/package_test.go
Normal file
270
plugins/package_test.go
Normal file
@ -0,0 +1,270 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ndpPackage", func() {
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugin-package-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
Describe("openPackage", func() {
|
||||
It("should load a valid .ndp package", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
||||
manifest := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header
|
||||
|
||||
err := createTestPackage(ndpPath, manifest, wasmBytes)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
pkg, err := openPackage(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pkg.Manifest.Name).To(Equal("Test Plugin"))
|
||||
Expect(pkg.Manifest.Author).To(Equal("Test Author"))
|
||||
Expect(pkg.Manifest.Version).To(Equal("1.0.0"))
|
||||
Expect(pkg.WasmBytes).To(Equal(wasmBytes))
|
||||
})
|
||||
|
||||
It("should return error for missing manifest.json", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "no-manifest.ndp")
|
||||
|
||||
// Create a zip with only plugin.wasm
|
||||
f, err := os.Create(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
|
||||
zw := newTestZipWriter(f)
|
||||
err = zw.addFile("plugin.wasm", []byte{0x00})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = openPackage(ndpPath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("missing manifest.json"))
|
||||
})
|
||||
|
||||
It("should return error for missing plugin.wasm", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "no-wasm.ndp")
|
||||
|
||||
// Create a zip with only manifest.json
|
||||
f, err := os.Create(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
|
||||
zw := newTestZipWriter(f)
|
||||
err = zw.addFile("manifest.json", []byte(`{"name":"Test","author":"Test","version":"1.0.0"}`))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = openPackage(ndpPath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("missing plugin.wasm"))
|
||||
})
|
||||
|
||||
It("should return error for invalid manifest JSON", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "invalid-json.ndp")
|
||||
|
||||
f, err := os.Create(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
|
||||
zw := newTestZipWriter(f)
|
||||
err = zw.addFile("manifest.json", []byte(`{invalid json}`))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.addFile("plugin.wasm", []byte{0x00})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = openPackage(ndpPath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("parsing manifest"))
|
||||
})
|
||||
|
||||
It("should return error for manifest missing required fields", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "invalid-manifest.ndp")
|
||||
|
||||
f, err := os.Create(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
|
||||
zw := newTestZipWriter(f)
|
||||
err = zw.addFile("manifest.json", []byte(`{"name":"Test"}`)) // Missing author and version
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.addFile("plugin.wasm", []byte{0x00})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = openPackage(ndpPath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// JSON schema validation happens during unmarshaling
|
||||
Expect(err.Error()).To(ContainSubstring("parsing manifest"))
|
||||
Expect(err.Error()).To(ContainSubstring("author"))
|
||||
})
|
||||
|
||||
It("should return error for non-existent file", func() {
|
||||
_, err := openPackage(filepath.Join(tmpDir, "nonexistent.ndp"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("opening package"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("readManifest", func() {
|
||||
It("should read only the manifest without loading wasm", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
||||
desc := "A test plugin"
|
||||
manifest := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
Description: &desc,
|
||||
}
|
||||
wasmBytes := make([]byte, 1024*1024) // 1MB of zeros
|
||||
|
||||
err := createTestPackage(ndpPath, manifest, wasmBytes)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
m, err := readManifest(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.Name).To(Equal("Test Plugin"))
|
||||
Expect(*m.Description).To(Equal("A test plugin"))
|
||||
})
|
||||
|
||||
It("should return error for missing manifest", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "no-manifest.ndp")
|
||||
|
||||
f, err := os.Create(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
|
||||
zw := newTestZipWriter(f)
|
||||
err = zw.addFile("plugin.wasm", []byte{0x00})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = zw.close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = readManifest(ndpPath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("missing manifest.json"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ComputePackageSHA256", func() {
|
||||
It("should compute consistent hash for same file", func() {
|
||||
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
||||
manifest := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d}
|
||||
|
||||
err := createTestPackage(ndpPath, manifest, wasmBytes)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash1, err := computeFileSHA256(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash2, err := computeFileSHA256(ndpPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(hash1).To(Equal(hash2))
|
||||
Expect(hash1).To(HaveLen(64)) // SHA-256 produces 64 hex characters
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// testZipHelper is a helper for creating test zip files with specific contents
|
||||
type testZipHelper struct {
|
||||
f *os.File
|
||||
entries []zipEntry
|
||||
}
|
||||
|
||||
type zipEntry struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func newTestZipWriter(f *os.File) *testZipHelper {
|
||||
return &testZipHelper{f: f}
|
||||
}
|
||||
|
||||
func (h *testZipHelper) addFile(name string, data []byte) error {
|
||||
h.entries = append(h.entries, zipEntry{name: name, data: data})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *testZipHelper) close() error {
|
||||
zw := zip.NewWriter(h.f)
|
||||
for _, e := range h.entries {
|
||||
w, err := zw.Create(e.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(e.data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// createTestPackage creates an .ndp package file from a manifest and wasm bytes.
|
||||
// This is primarily used for testing.
|
||||
func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error {
|
||||
f, err := os.Create(ndpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating package file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
// Write manifest.json
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling manifest: %w", err)
|
||||
}
|
||||
|
||||
mw, err := zw.Create(manifestFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating manifest in zip: %w", err)
|
||||
}
|
||||
if _, err := mw.Write(manifestBytes); err != nil {
|
||||
return fmt.Errorf("writing manifest: %w", err)
|
||||
}
|
||||
|
||||
// Write plugin.wasm
|
||||
ww, err := zw.Create(wasmFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating wasm in zip: %w", err)
|
||||
}
|
||||
if _, err := ww.Write(wasmBytes); err != nil {
|
||||
return fmt.Errorf("writing wasm: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -26,7 +26,7 @@ const testDataDir = "plugins/testdata"
|
||||
|
||||
// Shared test state initialized in BeforeSuite
|
||||
var (
|
||||
testdataDir string // Path to testdata folder with test-metadata-agent.wasm
|
||||
testdataDir string // Path to testdata folder with test plugin .ndp packages
|
||||
tmpPluginsDir string // Temp directory for plugin tests that modify files
|
||||
testManager *Manager
|
||||
)
|
||||
@ -54,7 +54,7 @@ func buildTestPlugins(t *testing.T, path string) {
|
||||
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
|
||||
// Returns the manager, temp directory path, and a cleanup function.
|
||||
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
|
||||
return createTestManagerWithPlugins(pluginConfig, "test-metadata-agent.wasm")
|
||||
return createTestManagerWithPlugins(pluginConfig, "test-metadata-agent"+PackageExtension)
|
||||
}
|
||||
|
||||
// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config
|
||||
@ -78,7 +78,7 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
|
||||
// Compute SHA256 for the plugin
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
pluginName := plugin[:len(plugin)-5] // Remove .wasm extension
|
||||
pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension
|
||||
|
||||
// Build config JSON if provided
|
||||
configJSON := ""
|
||||
@ -129,7 +129,7 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
|
||||
}
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
// Get testdata directory (where test-metadata-agent.wasm lives)
|
||||
// Get testdata directory (where test plugin .ndp packages live)
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
Expect(ok).To(BeTrue())
|
||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||
|
||||
@ -26,7 +26,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", UserName: "testuser"})
|
||||
|
||||
// Load the scrobbler via a new manager with the test-scrobbler plugin
|
||||
scrobblerManager, _ = createTestManagerWithPlugins(nil, "test-scrobbler.wasm")
|
||||
scrobblerManager, _ = createTestManagerWithPlugins(nil, "test-scrobbler"+PackageExtension)
|
||||
|
||||
var ok bool
|
||||
s, ok = scrobblerManager.LoadScrobbler("test-scrobbler")
|
||||
@ -58,7 +58,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
It("returns false when plugin is configured to not authorize", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-scrobbler": {"authorized": "false"},
|
||||
}, "test-scrobbler.wasm")
|
||||
}, "test-scrobbler"+PackageExtension)
|
||||
|
||||
sc, ok := manager.LoadScrobbler("test-scrobbler")
|
||||
Expect(ok).To(BeTrue())
|
||||
@ -88,7 +88,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
It("returns error when plugin returns error", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-scrobbler": {"error": "service unavailable", "error_type": "retry_later"},
|
||||
}, "test-scrobbler.wasm")
|
||||
}, "test-scrobbler"+PackageExtension)
|
||||
|
||||
sc, ok := manager.LoadScrobbler("test-scrobbler")
|
||||
Expect(ok).To(BeTrue())
|
||||
@ -123,7 +123,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
It("returns error when plugin returns not_authorized error", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-scrobbler": {"error": "user not linked", "error_type": "not_authorized"},
|
||||
}, "test-scrobbler.wasm")
|
||||
}, "test-scrobbler"+PackageExtension)
|
||||
|
||||
sc, ok := manager.LoadScrobbler("test-scrobbler")
|
||||
Expect(ok).To(BeTrue())
|
||||
@ -140,7 +140,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
It("returns error when plugin returns unrecoverable error", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-scrobbler": {"error": "track rejected", "error_type": "unrecoverable"},
|
||||
}, "test-scrobbler.wasm")
|
||||
}, "test-scrobbler"+PackageExtension)
|
||||
|
||||
sc, ok := manager.LoadScrobbler("test-scrobbler")
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
13
plugins/testdata/Makefile
vendored
13
plugins/testdata/Makefile
vendored
@ -6,11 +6,20 @@ PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod))
|
||||
# makes the tests faster.
|
||||
TINYGO := $(shell command -v tinygo 2> /dev/null)
|
||||
|
||||
all: $(PLUGINS:%=%.wasm)
|
||||
all: $(PLUGINS:%=%.ndp)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGINS:%=%.wasm)
|
||||
rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm)
|
||||
|
||||
# Build the .ndp package (zip containing manifest.json + plugin.wasm)
|
||||
%.ndp: %.wasm %/manifest.json
|
||||
@rm -f $@
|
||||
@cp $< plugin.wasm
|
||||
zip -j $@ $*/manifest.json plugin.wasm
|
||||
@rm -f plugin.wasm
|
||||
@mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp
|
||||
|
||||
# Build the wasm binary
|
||||
%.wasm: %/*.go %/go.mod
|
||||
ifdef TINYGO
|
||||
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
|
||||
|
||||
40
plugins/testdata/test-artwork/main.go
vendored
40
plugins/testdata/test-artwork/main.go
vendored
@ -3,51 +3,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Artwork *ArtworkPermission `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type ArtworkPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Artwork",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test artwork plugin for integration testing",
|
||||
Permissions: &Permissions{
|
||||
Artwork: &ArtworkPermission{
|
||||
Reason: "For testing artwork URL generation",
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// TestInput is the input for nd_test_artwork callback.
|
||||
type TestInput struct {
|
||||
ArtworkType string `json:"artwork_type"` // "artist", "album", "track", "playlist"
|
||||
|
||||
11
plugins/testdata/test-artwork/manifest.json
vendored
Normal file
11
plugins/testdata/test-artwork/manifest.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Test Artwork",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test artwork plugin for integration testing",
|
||||
"permissions": {
|
||||
"artwork": {
|
||||
"reason": "For testing artwork URL generation"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
plugins/testdata/test-cache-plugin/main.go
vendored
41
plugins/testdata/test-cache-plugin/main.go
vendored
@ -3,50 +3,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Cache *CachePermission `json:"cache,omitempty"`
|
||||
}
|
||||
|
||||
type CachePermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Cache Plugin",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test cache plugin for integration testing",
|
||||
Permissions: &Permissions{
|
||||
Cache: &CachePermission{
|
||||
Reason: "For testing cache operations",
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// TestCacheInput is the input for nd_test_cache callback.
|
||||
type TestCacheInput struct {
|
||||
Operation string `json:"operation"` // "set_string", "get_string", "set_int", "get_int", "set_float", "get_float", "set_bytes", "get_bytes", "has", "remove"
|
||||
|
||||
11
plugins/testdata/test-cache-plugin/manifest.json
vendored
Normal file
11
plugins/testdata/test-cache-plugin/manifest.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Test Cache Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test cache plugin for integration testing",
|
||||
"permissions": {
|
||||
"cache": {
|
||||
"reason": "For testing cache operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
plugins/testdata/test-library/main.go
vendored
42
plugins/testdata/test-library/main.go
vendored
@ -5,54 +5,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Library *LibraryPermission `json:"library,omitempty"`
|
||||
}
|
||||
|
||||
type LibraryPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Filesystem bool `json:"filesystem,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Library Plugin",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test library plugin for integration testing",
|
||||
Permissions: &Permissions{
|
||||
Library: &LibraryPermission{
|
||||
Reason: "For testing library metadata and filesystem access",
|
||||
Filesystem: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// TestLibraryInput is the input for nd_test_library callback.
|
||||
type TestLibraryInput struct {
|
||||
Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir"
|
||||
|
||||
12
plugins/testdata/test-library/manifest.json
vendored
Normal file
12
plugins/testdata/test-library/manifest.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Test Library Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test library plugin for integration testing",
|
||||
"permissions": {
|
||||
"library": {
|
||||
"reason": "For testing library metadata and filesystem access",
|
||||
"filesystem": true
|
||||
}
|
||||
}
|
||||
}
|
||||
45
plugins/testdata/test-metadata-agent/main.go
vendored
45
plugins/testdata/test-metadata-agent/main.go
vendored
@ -3,7 +3,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
@ -27,24 +26,6 @@ func checkConfigError() (bool, int32) {
|
||||
return true, exitCode
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
HTTP *HTTPPermission `json:"http,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedURLs map[string][]string `json:"allowedUrls,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@ -121,32 +102,6 @@ type AlbumImagesOutput struct {
|
||||
Images []ArtistImage `json:"images"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test plugin for integration testing",
|
||||
Capabilities: []string{"MetadataAgent"},
|
||||
Permissions: &Permissions{
|
||||
HTTP: &HTTPPermission{
|
||||
Reason: "Test HTTP access",
|
||||
AllowedURLs: map[string][]string{
|
||||
"https://test.example.com/*": {"GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:wasmexport nd_get_artist_mbid
|
||||
func ndGetArtistMBID() int32 {
|
||||
if hasErr, code := checkConfigError(); hasErr {
|
||||
|
||||
15
plugins/testdata/test-metadata-agent/manifest.json
vendored
Normal file
15
plugins/testdata/test-metadata-agent/manifest.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Test Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test plugin for integration testing",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Test HTTP access",
|
||||
"allowedURLs": {
|
||||
"https://test.example.com/*": ["GET"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
plugins/testdata/test-scheduler/main.go
vendored
45
plugins/testdata/test-scheduler/main.go
vendored
@ -2,51 +2,6 @@
|
||||
// Build with: tinygo build -o ../test-scheduler.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Scheduler *SchedulerPermission `json:"scheduler,omitempty"`
|
||||
}
|
||||
|
||||
type SchedulerPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Scheduler",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test scheduler plugin for integration testing",
|
||||
Permissions: &Permissions{
|
||||
Scheduler: &SchedulerPermission{
|
||||
Reason: "For testing scheduler callbacks",
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// NdSchedulerCallback is called when a scheduled task fires.
|
||||
// Magic payloads trigger specific behaviors to test host functions:
|
||||
// - "schedule-followup": schedules a one-time task via host function
|
||||
|
||||
11
plugins/testdata/test-scheduler/manifest.json
vendored
Normal file
11
plugins/testdata/test-scheduler/manifest.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Test Scheduler",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test scheduler plugin for integration testing",
|
||||
"permissions": {
|
||||
"scheduler": {
|
||||
"reason": "For testing scheduler callbacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
plugins/testdata/test-scrobbler/main.go
vendored
26
plugins/testdata/test-scrobbler/main.go
vendored
@ -3,20 +3,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Scrobbler input/output types
|
||||
|
||||
type AuthInput struct {
|
||||
@ -91,23 +82,6 @@ func checkAuthConfig() bool {
|
||||
return auth
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Scrobbler",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test scrobbler plugin for integration testing",
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:wasmexport nd_scrobbler_is_authorized
|
||||
func ndScrobblerIsAuthorized() int32 {
|
||||
var input AuthInput
|
||||
|
||||
6
plugins/testdata/test-scrobbler/manifest.json
vendored
Normal file
6
plugins/testdata/test-scrobbler/manifest.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Test Scrobbler",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test scrobbler plugin for integration testing"
|
||||
}
|
||||
44
plugins/testdata/test-subsonicapi-plugin/main.go
vendored
44
plugins/testdata/test-subsonicapi-plugin/main.go
vendored
@ -3,53 +3,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
SubsonicAPI *SubsonicAPIPermission `json:"subsonicapi,omitempty"`
|
||||
}
|
||||
|
||||
type SubsonicAPIPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
AllowAdmins bool `json:"allowAdmins,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test SubsonicAPI Plugin",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "Test plugin for SubsonicAPI host function",
|
||||
Permissions: &Permissions{
|
||||
SubsonicAPI: &SubsonicAPIPermission{
|
||||
Reason: "Testing SubsonicAPI access",
|
||||
AllowedUsernames: nil, // Allow all users
|
||||
AllowAdmins: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
output, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetErrorString("failed to marshal manifest")
|
||||
return 1
|
||||
}
|
||||
pdk.Output(output)
|
||||
return 0
|
||||
}
|
||||
|
||||
// call_subsonic_api is the exported function that tests the SubsonicAPI host function.
|
||||
// Input: URI string (e.g., "/ping?u=testuser")
|
||||
// Output: The raw JSON response from the Subsonic API
|
||||
|
||||
12
plugins/testdata/test-subsonicapi-plugin/manifest.json
vendored
Normal file
12
plugins/testdata/test-subsonicapi-plugin/manifest.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Test SubsonicAPI Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin for SubsonicAPI host function",
|
||||
"permissions": {
|
||||
"subsonicapi": {
|
||||
"reason": "Testing SubsonicAPI access",
|
||||
"allowAdmins": true
|
||||
}
|
||||
}
|
||||
}
|
||||
43
plugins/testdata/test-websocket/main.go
vendored
43
plugins/testdata/test-websocket/main.go
vendored
@ -3,52 +3,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
WebSocket *WebSocketPermission `json:"websocket,omitempty"`
|
||||
}
|
||||
|
||||
type WebSocketPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test WebSocket",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test WebSocket plugin for integration testing",
|
||||
Permissions: &Permissions{
|
||||
WebSocket: &WebSocketPermission{
|
||||
Reason: "For testing WebSocket callbacks",
|
||||
AllowedHosts: []string{"*.example.com", "localhost:*", "echo.websocket.org"},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// OnTextMessageInput is the input for nd_websocket_on_text_message callback.
|
||||
type OnTextMessageInput struct {
|
||||
ConnectionID string `json:"connection_id"`
|
||||
|
||||
12
plugins/testdata/test-websocket/manifest.json
vendored
Normal file
12
plugins/testdata/test-websocket/manifest.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Test WebSocket",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test WebSocket plugin for integration testing",
|
||||
"permissions": {
|
||||
"websocket": {
|
||||
"reason": "For testing WebSocket callbacks",
|
||||
"allowedHosts": ["*.example.com", "localhost:*", "echo.websocket.org"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user