refactor(plugins): streamline plugin function signatures and error handling

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-30 10:09:18 -05:00
parent 301b2c850c
commit d43724c571
36 changed files with 566 additions and 801 deletions

View File

@ -13,20 +13,7 @@ package capabilities
type Lifecycle interface {
// OnInit is called after a plugin is fully loaded with all services registered.
// Plugins can use this function to perform one-time initialization tasks.
// The output can contain an error string if initialization failed, which will be
// logged but will not prevent the plugin from being loaded.
// Errors are logged but will not prevent the plugin from being loaded.
//nd:export name=nd_on_init
OnInit(InitRequest) (InitResponse, error)
}
// InitRequest is the request provided to the init callback.
// Currently empty, reserved for future use.
type InitRequest struct{}
// InitResponse is the response from the init callback.
type InitResponse struct {
// Error is the error message if initialization failed.
// Empty string indicates success.
// The error is logged but does not prevent the plugin from being loaded.
Error string `json:"error,omitempty"`
OnInit() error
}

View File

@ -4,29 +4,4 @@ exports:
description: |-
OnInit is called after a plugin is fully loaded with all services registered.
Plugins can use this function to perform one-time initialization tasks.
The output can contain an error string if initialization failed, which will be
logged but will not prevent the plugin from being loaded.
input:
$ref: '#/components/schemas/InitRequest'
contentType: application/json
output:
$ref: '#/components/schemas/InitResponse'
contentType: application/json
components:
schemas:
InitRequest:
description: |-
InitRequest is the request provided to the init callback.
Currently empty, reserved for future use.
type: object
properties: {}
InitResponse:
description: InitResponse is the response from the init callback.
type: object
properties:
error:
type: string
description: |-
Error is the error message if initialization failed.
Empty string indicates success.
The error is logged but does not prevent the plugin from being loaded.
Errors are logged but will not prevent the plugin from being loaded.

View File

@ -8,8 +8,9 @@ package capabilities
//nd:capability name=scheduler
type SchedulerCallback interface {
// OnSchedulerCallback is called when a scheduled task fires.
// Errors are logged but do not affect the scheduling system.
//nd:export name=nd_scheduler_callback
OnSchedulerCallback(SchedulerCallbackRequest) (SchedulerCallbackResponse, error)
OnSchedulerCallback(SchedulerCallbackRequest) error
}
// SchedulerCallbackRequest is the request provided when a scheduled task fires.
@ -24,11 +25,3 @@ type SchedulerCallbackRequest struct {
// false if it's a one-time schedule (created via ScheduleOneTime).
IsRecurring bool `json:"isRecurring"`
}
// SchedulerCallbackResponse is the response from the scheduler callback.
type SchedulerCallbackResponse struct {
// Error is the error message if the callback failed to process the scheduled task.
// Empty string indicates success. The error is logged but does not
// affect the scheduling system.
Error string `json:"error,omitempty"`
}

View File

@ -1,13 +1,12 @@
version: v1-draft
exports:
nd_scheduler_callback:
description: OnSchedulerCallback is called when a scheduled task fires.
description: |-
OnSchedulerCallback is called when a scheduled task fires.
Errors are logged but do not affect the scheduling system.
input:
$ref: '#/components/schemas/SchedulerCallbackRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SchedulerCallbackResponse'
contentType: application/json
components:
schemas:
SchedulerCallbackRequest:
@ -33,13 +32,3 @@ components:
- scheduleId
- payload
- isRecurring
SchedulerCallbackResponse:
description: SchedulerCallbackResponse is the response from the scheduler callback.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed to process the scheduled task.
Empty string indicates success. The error is logged but does not
affect the scheduling system.

View File

@ -11,15 +11,15 @@ package capabilities
type Scrobbler interface {
// IsAuthorized checks if a user is authorized to scrobble to this service.
//nd:export name=nd_scrobbler_is_authorized
IsAuthorized(IsAuthorizedRequest) (IsAuthorizedResponse, error)
IsAuthorized(IsAuthorizedRequest) (*IsAuthorizedResponse, error)
// NowPlaying sends a now playing notification to the scrobbling service.
//nd:export name=nd_scrobbler_now_playing
NowPlaying(NowPlayingRequest) (ScrobblerResponse, error)
NowPlaying(NowPlayingRequest) (*ScrobblerResponse, error)
// Scrobble submits a completed scrobble to the scrobbling service.
//nd:export name=nd_scrobbler_scrobble
Scrobble(ScrobbleRequest) (ScrobblerResponse, error)
Scrobble(ScrobbleRequest) (*ScrobblerResponse, error)
}
// IsAuthorizedRequest is the request for authorization check.

View File

@ -10,19 +10,19 @@ package capabilities
type WebSocketCallback interface {
// OnTextMessage is called when a text message is received on a WebSocket connection.
//nd:export name=nd_websocket_on_text_message
OnTextMessage(OnTextMessageRequest) (OnTextMessageResponse, error)
OnTextMessage(OnTextMessageRequest) error
// OnBinaryMessage is called when a binary message is received on a WebSocket connection.
//nd:export name=nd_websocket_on_binary_message
OnBinaryMessage(OnBinaryMessageRequest) (OnBinaryMessageResponse, error)
OnBinaryMessage(OnBinaryMessageRequest) error
// OnError is called when an error occurs on a WebSocket connection.
//nd:export name=nd_websocket_on_error
OnError(OnErrorRequest) (OnErrorResponse, error)
OnError(OnErrorRequest) error
// OnClose is called when a WebSocket connection is closed.
//nd:export name=nd_websocket_on_close
OnClose(OnCloseRequest) (OnCloseResponse, error)
OnClose(OnCloseRequest) error
}
// OnTextMessageRequest is the request provided when a text message is received.
@ -33,13 +33,6 @@ type OnTextMessageRequest struct {
Message string `json:"message"`
}
// OnTextMessageResponse is the response from the text message handler.
type OnTextMessageResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnBinaryMessageRequest is the request provided when a binary message is received.
type OnBinaryMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
@ -48,13 +41,6 @@ type OnBinaryMessageRequest struct {
Data string `json:"data"`
}
// OnBinaryMessageResponse is the response from the binary message handler.
type OnBinaryMessageResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type OnErrorRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
@ -63,13 +49,6 @@ type OnErrorRequest struct {
Error string `json:"error"`
}
// OnErrorResponse is the response from the error handler.
type OnErrorResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnCloseRequest is the request provided when a WebSocket connection is closed.
type OnCloseRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that was closed.
@ -80,10 +59,3 @@ type OnCloseRequest struct {
// Reason is the human-readable reason for the connection closure, if provided.
Reason string `json:"reason"`
}
// OnCloseResponse is the response from the close handler.
type OnCloseResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}

View File

@ -5,33 +5,21 @@ exports:
input:
$ref: '#/components/schemas/OnTextMessageRequest'
contentType: application/json
output:
$ref: '#/components/schemas/OnTextMessageResponse'
contentType: application/json
nd_websocket_on_binary_message:
description: OnBinaryMessage is called when a binary message is received on a WebSocket connection.
input:
$ref: '#/components/schemas/OnBinaryMessageRequest'
contentType: application/json
output:
$ref: '#/components/schemas/OnBinaryMessageResponse'
contentType: application/json
nd_websocket_on_error:
description: OnError is called when an error occurs on a WebSocket connection.
input:
$ref: '#/components/schemas/OnErrorRequest'
contentType: application/json
output:
$ref: '#/components/schemas/OnErrorResponse'
contentType: application/json
nd_websocket_on_close:
description: OnClose is called when a WebSocket connection is closed.
input:
$ref: '#/components/schemas/OnCloseRequest'
contentType: application/json
output:
$ref: '#/components/schemas/OnCloseResponse'
contentType: application/json
components:
schemas:
OnBinaryMessageRequest:
@ -47,15 +35,6 @@ components:
required:
- connectionId
- data
OnBinaryMessageResponse:
description: OnBinaryMessageResponse is the response from the binary message handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty string indicates success.
OnCloseRequest:
description: OnCloseRequest is the request provided when a WebSocket connection is closed.
type: object
@ -76,15 +55,6 @@ components:
- connectionId
- code
- reason
OnCloseResponse:
description: OnCloseResponse is the response from the close handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty string indicates success.
OnErrorRequest:
description: OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type: object
@ -98,15 +68,6 @@ components:
required:
- connectionId
- error
OnErrorResponse:
description: OnErrorResponse is the response from the error handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty string indicates success.
OnTextMessageRequest:
description: OnTextMessageRequest is the request provided when a text message is received.
type: object
@ -120,12 +81,3 @@ components:
required:
- connectionId
- message
OnTextMessageResponse:
description: OnTextMessageResponse is the response from the text message handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty string indicates success.

View File

@ -4,7 +4,6 @@ import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/capabilities"
)
// CapabilityLifecycle indicates the plugin has lifecycle callback functions.
@ -29,16 +28,11 @@ func callPluginInit(ctx context.Context, instance *plugin) {
log.Debug(ctx, "Calling plugin init function", "plugin", instance.name)
result, err := callPluginFunction[capabilities.InitRequest, capabilities.InitResponse](ctx, instance, FuncOnInit, capabilities.InitRequest{})
err := callPluginFunctionNoInput(ctx, instance, FuncOnInit)
if err != nil {
log.Error(ctx, "Plugin init function failed", "plugin", instance.name, err)
return
}
if result.Error != "" {
log.Error(ctx, "Plugin init function returned error", "plugin", instance.name, "error", result.Error)
return
}
log.Debug(ctx, "Plugin init function completed", "plugin", instance.name)
}

View File

@ -68,7 +68,15 @@ type {{.Name}} struct {
type {{agentName .Capability}} interface {
{{- range .Capability.Methods}}
// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}}
{{- if and .HasInput .HasOutput}}
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
{{- else if .HasInput}}
{{.Name}}({{.Input.Type}}) error
{{- else if .HasOutput}}
{{.Name}}() ({{.Output.Type}}, error)
{{- else}}
{{.Name}}() error
{{- end}}
{{- end}}
}
{{- else}}
@ -87,7 +95,15 @@ type {{agentName .Capability}} interface{}
// {{providerInterface .}} provides the {{.Name}} function.
type {{providerInterface .}} interface {
{{- if and .HasInput .HasOutput}}
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
{{- else if .HasInput}}
{{.Name}}({{.Input.Type}}) error
{{- else if .HasOutput}}
{{.Name}}() ({{.Output.Type}}, error)
{{- else}}
{{.Name}}() error
{{- end}}
}
{{- end}}
{{- end}}
@ -97,7 +113,15 @@ type {{providerInterface .}} interface {
// Internal implementation holders
var (
{{- range .Capability.Methods}}
{{- if and .HasInput .HasOutput}}
{{implVar .}} func({{.Input.Type}}) ({{.Output.Type}}, error)
{{- else if .HasInput}}
{{implVar .}} func({{.Input.Type}}) error
{{- else if .HasOutput}}
{{implVar .}} func() ({{.Output.Type}}, error)
{{- else}}
{{implVar .}} func() error
{{- end}}
{{- end}}
)
@ -133,12 +157,15 @@ func {{exportFunc .}}() int32 {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
{{- if .HasInput}}
var input {{.Input.Type}}
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
{{- end}}
{{- if and .HasInput .HasOutput}}
output, err := {{implVar .}}(input)
if err != nil {
@ -150,6 +177,31 @@ func {{exportFunc .}}() int32 {
pdk.SetError(err)
return -1
}
{{- else if .HasInput}}
if err := {{implVar .}}(input); err != nil {
pdk.SetError(err)
return -1
}
{{- else if .HasOutput}}
output, err := {{implVar .}}()
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
{{- else}}
if err := {{implVar .}}(); err != nil {
pdk.SetError(err)
return -1
}
{{- end}}
return 0
}

View File

@ -65,7 +65,15 @@ type {{.Name}} struct {
type {{agentName .Capability}} interface {
{{- range .Capability.Methods}}
// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}}
{{- if and .HasInput .HasOutput}}
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
{{- else if .HasInput}}
{{.Name}}({{.Input.Type}}) error
{{- else if .HasOutput}}
{{.Name}}() ({{.Output.Type}}, error)
{{- else}}
{{.Name}}() error
{{- end}}
{{- end}}
}
{{- else}}
@ -84,7 +92,15 @@ type {{agentName .Capability}} interface{}
// {{providerInterface .}} provides the {{.Name}} function.
type {{providerInterface .}} interface {
{{- if and .HasInput .HasOutput}}
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
{{- else if .HasInput}}
{{.Name}}({{.Input.Type}}) error
{{- else if .HasOutput}}
{{.Name}}() ({{.Output.Type}}, error)
{{- else}}
{{.Name}}() error
{{- end}}
}
{{- end}}
{{- end}}

View File

@ -113,6 +113,21 @@ func (e Export) ExportFuncName() string {
return result.String()
}
// HasInput returns true if the method has an input parameter.
func (e Export) HasInput() bool {
return e.Input.Type != ""
}
// HasOutput returns true if the method has a non-error return value.
func (e Export) HasOutput() bool {
return e.Output.Type != ""
}
// IsPointerOutput returns true if the output type is a pointer.
func (e Export) IsPointerOutput() bool {
return strings.HasPrefix(e.Output.Type, "*")
}
// StructDef represents a Go struct type definition.
type StructDef struct {
Name string // Go struct name (e.g., "Library")

View File

@ -82,13 +82,13 @@ func buildExport(export Export) xtpExport {
e := xtpExport{Description: cleanDocForYAML(export.Doc)}
if export.Input.Type != "" {
e.Input = &xtpIOParam{
Ref: "#/components/schemas/" + export.Input.Type,
Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"),
ContentType: "application/json",
}
}
if export.Output.Type != "" {
e.Output = &xtpIOParam{
Ref: "#/components/schemas/" + export.Output.Type,
Ref: "#/components/schemas/" + strings.TrimPrefix(export.Output.Type, "*"),
ContentType: "application/json",
}
}

View File

@ -9,7 +9,7 @@ import (
"fmt"
"strings"
pdk "github.com/extism/go-pdk"
"github.com/extism/go-pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
@ -71,7 +71,7 @@ var (
// OnInit is called when the plugin is loaded.
// We use this to establish the initial WebSocket connection.
func (p *cryptoTickerPlugin) OnInit(_ lifecycle.InitRequest) (lifecycle.InitResponse, error) {
func (p *cryptoTickerPlugin) OnInit() error {
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
// Get ticker configuration
@ -84,13 +84,8 @@ func (p *cryptoTickerPlugin) OnInit(_ lifecycle.InitRequest) (lifecycle.InitResp
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
// Connect to WebSocket
err := connectAndSubscribe(tickers)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to connect: %v", err))
// Don't fail init - let reconnect logic handle it
}
return lifecycle.InitResponse{}, nil
// Errors won't fail init - reconnect logic will handle it
return connectAndSubscribe(tickers)
}
// parseTickerSymbols parses a comma-separated list of ticker symbols
@ -143,10 +138,10 @@ func connectAndSubscribe(tickers []string) error {
}
// OnTextMessage is called when a text message is received
func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest) (websocket.OnTextMessageResponse, error) {
func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest) error {
// Only process messages from our connection
if input.ConnectionID != connectionID {
return websocket.OnTextMessageResponse{}, nil
return nil
}
// Try to parse as a ticker message
@ -154,7 +149,7 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
err := json.Unmarshal([]byte(input.Message), &ticker)
if err != nil {
// Not a valid JSON message, ignore
return websocket.OnTextMessageResponse{}, nil
return nil
}
// Only process ticker messages
@ -163,7 +158,7 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
if ticker.Type != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received %s message", ticker.Type))
}
return websocket.OnTextMessageResponse{}, nil
return nil
}
// Calculate 24h change percentage
@ -178,24 +173,24 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
ticker.BestAsk,
))
return websocket.OnTextMessageResponse{}, nil
return nil
}
// OnBinaryMessage is called when a binary message is received
func (p *cryptoTickerPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) (websocket.OnBinaryMessageResponse, error) {
func (p *cryptoTickerPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
// Coinbase doesn't send binary messages, but we implement the handler anyway
pdk.Log(pdk.LogWarn, fmt.Sprintf("Received unexpected binary message on connection %s", input.ConnectionID))
return websocket.OnBinaryMessageResponse{}, nil
return nil
}
// OnError is called when an error occurs on the WebSocket connection
func (p *cryptoTickerPlugin) OnError(input websocket.OnErrorRequest) (websocket.OnErrorResponse, error) {
func (p *cryptoTickerPlugin) OnError(input websocket.OnErrorRequest) error {
pdk.Log(pdk.LogError, fmt.Sprintf("WebSocket error on connection %s: %s", input.ConnectionID, input.Error))
return websocket.OnErrorResponse{}, nil
return nil
}
// OnClose is called when the WebSocket connection is closed
func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) (websocket.OnCloseResponse, error) {
func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection %s closed (code: %d, reason: %s)",
input.ConnectionID, input.Code, input.Reason))
@ -210,14 +205,14 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) (websocket.
}
}
return websocket.OnCloseResponse{}, nil
return nil
}
// OnSchedulerCallback is called when a scheduled task fires
func (p *cryptoTickerPlugin) OnSchedulerCallback(input scheduler.SchedulerCallbackRequest) (scheduler.SchedulerCallbackResponse, error) {
func (p *cryptoTickerPlugin) OnSchedulerCallback(input scheduler.SchedulerCallbackRequest) error {
// Only handle our reconnection schedule
if input.ScheduleID != reconnectScheduleID {
return scheduler.SchedulerCallbackResponse{}, nil
return nil
}
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
@ -244,7 +239,7 @@ func (p *cryptoTickerPlugin) OnSchedulerCallback(input scheduler.SchedulerCallba
pdk.Log(pdk.LogInfo, "Successfully reconnected!")
}
return scheduler.SchedulerCallbackResponse{}, nil
return nil
}
// calculatePercentChange calculates the percentage change between open and current price

View File

@ -154,12 +154,6 @@ struct SchedulerCallbackInput {
is_recurring: bool,
}
#[derive(Serialize, Default)]
struct SchedulerCallbackOutput {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
// ============================================================================
// WebSocket Callback Types
// ============================================================================
@ -172,12 +166,6 @@ struct OnTextMessageInput {
message: String,
}
#[derive(Serialize, Default)]
struct OnTextMessageOutput {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
@ -186,12 +174,6 @@ struct OnBinaryMessageInput {
message: Vec<u8>,
}
#[derive(Serialize, Default)]
struct OnBinaryMessageOutput {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
@ -200,12 +182,6 @@ struct OnErrorInput {
error: String,
}
#[derive(Serialize, Default)]
struct OnErrorOutput {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
@ -215,12 +191,6 @@ struct OnCloseInput {
reason: String,
}
#[derive(Serialize, Default)]
struct OnCloseOutput {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
// ============================================================================
// Scrobbler Plugin Exports
// ============================================================================
@ -248,7 +218,7 @@ pub fn nd_scrobbler_is_authorized(Json(input): Json<AuthInput>) -> FnResult<Json
#[plugin_fn]
pub fn nd_scrobbler_now_playing(
Json(input): Json<NowPlayingInput>,
) -> FnResult<Json<ScrobblerOutput>> {
) -> FnResult<Json<Option<ScrobblerOutput>>> {
info!(
"Setting presence for user {}, track: {}",
input.username, input.track.title
@ -259,10 +229,10 @@ pub fn nd_scrobbler_now_playing(
Ok(config) => config,
Err(e) => {
let err_msg = format!("failed to get config: {:?}", e);
return Ok(Json(ScrobblerOutput {
return Ok(Json(Some(ScrobblerOutput {
error: Some(err_msg),
error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()),
}));
})));
}
};
@ -271,20 +241,20 @@ pub fn nd_scrobbler_now_playing(
Some(token) => token.clone(),
None => {
let err_msg = format!("user '{}' not authorized", input.username);
return Ok(Json(ScrobblerOutput {
return Ok(Json(Some(ScrobblerOutput {
error: Some(err_msg),
error_type: Some(ERROR_TYPE_NOT_AUTHORIZED.to_string()),
}));
})));
}
};
// Connect to Discord
if let Err(e) = rpc::connect(&input.username, &user_token) {
let err_msg = format!("failed to connect to Discord: {:?}", e);
return Ok(Json(ScrobblerOutput {
return Ok(Json(Some(ScrobblerOutput {
error: Some(err_msg),
error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()),
}));
})));
}
// Cancel any existing completion schedule
@ -320,10 +290,10 @@ pub fn nd_scrobbler_now_playing(
},
) {
let err_msg = format!("failed to send activity: {:?}", e);
return Ok(Json(ScrobblerOutput {
return Ok(Json(Some(ScrobblerOutput {
error: Some(err_msg),
error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()),
}));
})));
}
// Schedule a timer to clear the activity after the track completes
@ -336,14 +306,15 @@ pub fn nd_scrobbler_now_playing(
warn!("Failed to schedule completion timer: {:?}", e);
}
Ok(Json(ScrobblerOutput::default()))
// Success - return None to indicate no error
Ok(Json(None))
}
/// Handles scrobble requests (no-op for Discord Rich Presence).
#[plugin_fn]
pub fn nd_scrobbler_scrobble(_input: Json<ScrobbleInput>) -> FnResult<Json<ScrobblerOutput>> {
// Discord Rich Presence doesn't need scrobble events
Ok(Json(ScrobblerOutput::default()))
pub fn nd_scrobbler_scrobble(_input: Json<ScrobbleInput>) -> FnResult<Json<Option<ScrobblerOutput>>> {
// Discord Rich Presence doesn't need scrobble events - success
Ok(Json(None))
}
// ============================================================================
@ -352,34 +323,23 @@ pub fn nd_scrobbler_scrobble(_input: Json<ScrobbleInput>) -> FnResult<Json<Scrob
/// Handles scheduler callbacks for heartbeat and activity clearing.
#[plugin_fn]
pub fn nd_scheduler_callback(
Json(input): Json<SchedulerCallbackInput>,
) -> FnResult<Json<SchedulerCallbackOutput>> {
pub fn nd_scheduler_callback(Json(input): Json<SchedulerCallbackInput>) -> FnResult<()> {
match input.payload.as_str() {
PAYLOAD_HEARTBEAT => {
// Heartbeat callback - schedule_id is the username
if let Err(e) = rpc::handle_heartbeat_callback(&input.schedule_id) {
return Ok(Json(SchedulerCallbackOutput {
error: Some(e.to_string()),
}));
}
rpc::handle_heartbeat_callback(&input.schedule_id)?;
}
PAYLOAD_CLEAR_ACTIVITY => {
// Clear activity callback - schedule_id is "username-clear"
let username = input.schedule_id.trim_end_matches("-clear");
if let Err(e) = rpc::handle_clear_activity_callback(username) {
return Ok(Json(SchedulerCallbackOutput {
error: Some(e.to_string()),
}));
}
rpc::handle_clear_activity_callback(username)?;
}
_ => {
warn!("Unknown scheduler callback payload: {}", input.payload);
}
}
Ok(Json(SchedulerCallbackOutput::default()))
Ok(())
}
// ============================================================================
@ -388,42 +348,34 @@ pub fn nd_scheduler_callback(
/// Handles incoming WebSocket text messages.
#[plugin_fn]
pub fn nd_websocket_on_text_message(
Json(input): Json<OnTextMessageInput>,
) -> FnResult<Json<OnTextMessageOutput>> {
if let Err(e) = rpc::handle_websocket_message(&input.connection_id, &input.message) {
return Ok(Json(OnTextMessageOutput {
error: Some(e.to_string()),
}));
}
Ok(Json(OnTextMessageOutput::default()))
pub fn nd_websocket_on_text_message(Json(input): Json<OnTextMessageInput>) -> FnResult<()> {
rpc::handle_websocket_message(&input.connection_id, &input.message)?;
Ok(())
}
/// Handles incoming WebSocket binary messages.
#[plugin_fn]
pub fn nd_websocket_on_binary_message(
Json(_input): Json<OnBinaryMessageInput>,
) -> FnResult<Json<OnBinaryMessageOutput>> {
pub fn nd_websocket_on_binary_message(Json(_input): Json<OnBinaryMessageInput>) -> FnResult<()> {
// Binary messages are not expected from Discord
Ok(Json(OnBinaryMessageOutput::default()))
Ok(())
}
/// Handles WebSocket errors.
#[plugin_fn]
pub fn nd_websocket_on_error(Json(input): Json<OnErrorInput>) -> FnResult<Json<OnErrorOutput>> {
pub fn nd_websocket_on_error(Json(input): Json<OnErrorInput>) -> FnResult<()> {
warn!(
"WebSocket error for connection '{}': {}",
input.connection_id, input.error
);
Ok(Json(OnErrorOutput::default()))
Ok(())
}
/// Handles WebSocket connection closure.
#[plugin_fn]
pub fn nd_websocket_on_close(Json(input): Json<OnCloseInput>) -> FnResult<Json<OnCloseOutput>> {
pub fn nd_websocket_on_close(Json(input): Json<OnCloseInput>) -> FnResult<()> {
info!(
"WebSocket connection '{}' closed with code {}: {}",
input.connection_id, input.code, input.reason
);
Ok(Json(OnCloseOutput::default()))
Ok(())
}

View File

@ -93,31 +93,31 @@ func getImageURL(trackID string) string {
// ============================================================================
// IsAuthorized checks if a user is authorized for Discord Rich Presence.
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (scrobbler.IsAuthorizedResponse, error) {
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (*scrobbler.IsAuthorizedResponse, error) {
_, users, err := getConfig()
if err != nil {
return scrobbler.IsAuthorizedResponse{}, fmt.Errorf("failed to check user authorization: %w", err)
return nil, fmt.Errorf("failed to check user authorization: %w", err)
}
_, authorized := users[input.Username]
pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized))
return scrobbler.IsAuthorizedResponse{Authorized: authorized}, nil
return &scrobbler.IsAuthorizedResponse{Authorized: authorized}, nil
}
// NowPlaying sends a now playing notification to Discord.
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (scrobbler.ScrobblerResponse, error) {
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (*scrobbler.ScrobblerResponse, error) {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title))
// Load configuration
clientID, users, err := getConfig()
if err != nil {
return scrobbler.ScrobblerResponse{}, fmt.Errorf("failed to get config: %w", err)
return nil, fmt.Errorf("failed to get config: %w", err)
}
// Check authorization
userToken, authorized := users[input.Username]
if !authorized {
return scrobbler.ScrobblerResponse{
return &scrobbler.ScrobblerResponse{
Error: fmt.Sprintf("user '%s' not authorized", input.Username),
ErrorType: scrobbler.ScrobblerErrorNotAuthorized,
}, nil
@ -125,7 +125,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (scrobbler
// Connect to Discord
if err := connect(input.Username, userToken); err != nil {
return scrobbler.ScrobblerResponse{
return &scrobbler.ScrobblerResponse{
Error: fmt.Sprintf("failed to connect to Discord: %v", err),
ErrorType: scrobbler.ScrobblerErrorRetryLater,
}, nil
@ -155,7 +155,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (scrobbler
LargeText: input.Track.Album,
},
}); err != nil {
return scrobbler.ScrobblerResponse{
return &scrobbler.ScrobblerResponse{
Error: fmt.Sprintf("failed to send activity: %v", err),
ErrorType: scrobbler.ScrobblerErrorRetryLater,
}, nil
@ -168,13 +168,13 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (scrobbler
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err))
}
return scrobbler.ScrobblerResponse{}, nil
return &scrobbler.ScrobblerResponse{}, nil
}
// Scrobble handles scrobble requests (no-op for Discord).
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) (scrobbler.ScrobblerResponse, error) {
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) (*scrobbler.ScrobblerResponse, error) {
// Discord Rich Presence doesn't need scrobble events
return scrobbler.ScrobblerResponse{}, nil
return &scrobbler.ScrobblerResponse{}, nil
}
// ============================================================================
@ -182,7 +182,7 @@ func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) (scrobbler.Scrobbl
// ============================================================================
// OnSchedulerCallback handles scheduler callbacks.
func (p *discordPlugin) OnSchedulerCallback(input scheduler.SchedulerCallbackRequest) (scheduler.SchedulerCallbackResponse, error) {
func (p *discordPlugin) OnSchedulerCallback(input scheduler.SchedulerCallbackRequest) error {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring))
// Route based on payload
@ -190,21 +190,21 @@ func (p *discordPlugin) OnSchedulerCallback(input scheduler.SchedulerCallbackReq
case payloadHeartbeat:
// Heartbeat callback - scheduleId is the username
if err := handleHeartbeatCallback(input.ScheduleID); err != nil {
return scheduler.SchedulerCallbackResponse{Error: err.Error()}, nil
return err
}
case payloadClearActivity:
// Clear activity callback - scheduleId is "username-clear"
username := strings.TrimSuffix(input.ScheduleID, "-clear")
if err := handleClearActivityCallback(username); err != nil {
return scheduler.SchedulerCallbackResponse{Error: err.Error()}, nil
return err
}
default:
pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload))
}
return scheduler.SchedulerCallbackResponse{}, nil
return nil
}
// ============================================================================
@ -212,29 +212,26 @@ func (p *discordPlugin) OnSchedulerCallback(input scheduler.SchedulerCallbackReq
// ============================================================================
// OnTextMessage handles incoming WebSocket text messages.
func (p *discordPlugin) OnTextMessage(input websocket.OnTextMessageRequest) (websocket.OnTextMessageResponse, error) {
if err := handleWebSocketMessage(input.ConnectionID, input.Message); err != nil {
return websocket.OnTextMessageResponse{Error: err.Error()}, nil
}
return websocket.OnTextMessageResponse{}, nil
func (p *discordPlugin) OnTextMessage(input websocket.OnTextMessageRequest) error {
return handleWebSocketMessage(input.ConnectionID, input.Message)
}
// OnBinaryMessage handles incoming WebSocket binary messages.
func (p *discordPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) (websocket.OnBinaryMessageResponse, error) {
func (p *discordPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID))
return websocket.OnBinaryMessageResponse{}, nil
return nil
}
// OnError handles WebSocket errors.
func (p *discordPlugin) OnError(input websocket.OnErrorRequest) (websocket.OnErrorResponse, error) {
func (p *discordPlugin) OnError(input websocket.OnErrorRequest) error {
pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error))
return websocket.OnErrorResponse{}, nil
return nil
}
// OnClose handles WebSocket connection closure.
func (p *discordPlugin) OnClose(input websocket.OnCloseRequest) (websocket.OnCloseResponse, error) {
func (p *discordPlugin) OnClose(input websocket.OnCloseRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason))
return websocket.OnCloseResponse{}, nil
return nil
}
func main() {}

View File

@ -117,9 +117,7 @@ def nd_on_init():
except Exception as e:
extism.log(extism.LogLevel.Error, f"Failed to schedule task: {e}")
raise
# Return empty success response
extism.output_str(json.dumps({}))
# No output - lifecycle callbacks don't return responses
@extism.plugin_fn
@ -130,7 +128,6 @@ def nd_scheduler_callback():
# Only handle our schedule
if schedule_id != SCHEDULE_ID:
extism.output_str(json.dumps({}))
return
try:
@ -164,10 +161,8 @@ def nd_scheduler_callback():
extism.LogLevel.Info,
f"🎵 {username} is playing: {artist} - {title} ({album})"
)
extism.output_str(json.dumps({}))
# No output - scheduler callbacks don't return responses
except Exception as e:
error_msg = str(e)
extism.log(extism.LogLevel.Error, f"Failed to get now playing: {error_msg}")
extism.output_str(json.dumps({"error": error_msg}))
extism.log(extism.LogLevel.Error, f"Failed to get now playing: {e}")
# Errors are logged but scheduler callbacks don't return responses

View File

@ -202,17 +202,12 @@ func (s *schedulerServiceImpl) invokeCallback(ctx context.Context, scheduleID st
}
start := time.Now()
result, err := callPluginFunction[capabilities.SchedulerCallbackRequest, capabilities.SchedulerCallbackResponse](ctx, instance, FuncSchedulerCallback, input)
err := callPluginFunctionNoOutput(ctx, instance, FuncSchedulerCallback, input)
if err != nil {
log.Error(ctx, "Scheduler callback failed", "plugin", s.pluginName, "scheduleID", scheduleID, "duration", time.Since(start), err)
return
}
if result.Error != "" {
log.Error(ctx, "Scheduler callback returned error", "plugin", s.pluginName, "scheduleID", scheduleID, "error", result.Error, "duration", time.Since(start))
return
}
log.Debug(ctx, "Scheduler callback completed", "plugin", s.pluginName, "scheduleID", scheduleID, "duration", time.Since(start))
}

View File

@ -330,7 +330,7 @@ func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connecti
}
start := time.Now()
_, err := callPluginFunction[capabilities.OnTextMessageRequest, capabilities.OnTextMessageResponse](ctx, instance, FuncWebSocketOnTextMessage, input)
err := callPluginFunctionNoOutput(ctx, instance, FuncWebSocketOnTextMessage, input)
if err != nil {
// Don't log error if function simply doesn't exist (optional callback)
if !errors.Is(errFunctionNotFound, err) {
@ -351,7 +351,7 @@ func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connec
}
start := time.Now()
_, err := callPluginFunction[capabilities.OnBinaryMessageRequest, capabilities.OnBinaryMessageResponse](ctx, instance, FuncWebSocketOnBinaryMessage, input)
err := callPluginFunctionNoOutput(ctx, instance, FuncWebSocketOnBinaryMessage, input)
if err != nil {
// Don't log error if function simply doesn't exist (optional callback)
if !errors.Is(errFunctionNotFound, err) {
@ -372,7 +372,7 @@ func (s *webSocketServiceImpl) invokeOnError(ctx context.Context, connectionID,
}
start := time.Now()
_, err := callPluginFunction[capabilities.OnErrorRequest, capabilities.OnErrorResponse](ctx, instance, FuncWebSocketOnError, input)
err := callPluginFunctionNoOutput(ctx, instance, FuncWebSocketOnError, input)
if err != nil {
// Don't log error if function simply doesn't exist (optional callback)
if !errors.Is(errFunctionNotFound, err) {
@ -394,7 +394,7 @@ func (s *webSocketServiceImpl) invokeOnClose(ctx context.Context, connectionID s
}
start := time.Now()
_, err := callPluginFunction[capabilities.OnCloseRequest, capabilities.OnCloseResponse](ctx, instance, FuncWebSocketOnClose, input)
err := callPluginFunctionNoOutput(ctx, instance, FuncWebSocketOnClose, input)
if err != nil {
// Don't log error if function simply doesn't exist (optional callback)
if !errors.Is(errFunctionNotFound, err) {

View File

@ -13,6 +13,73 @@ import (
var errFunctionNotFound = errors.New("function not found")
// callPluginFunctionNoInput calls a plugin function that takes no input.
// It only checks for errors, with no response expected.
func callPluginFunctionNoInput(ctx context.Context, plugin *plugin, funcName string) error {
start := time.Now()
// Create plugin instance
p, err := plugin.instance()
if err != nil {
return fmt.Errorf("failed to create plugin: %w", err)
}
defer p.Close(ctx)
if !p.FunctionExists(funcName) {
log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName)
return fmt.Errorf("%w: %s", errFunctionNotFound, funcName)
}
startCall := time.Now()
exit, _, err := p.Call(funcName, nil)
if err != nil {
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
return fmt.Errorf("plugin call failed: %w", err)
}
if exit != 0 {
return fmt.Errorf("plugin call exited with code %d", exit)
}
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
return nil
}
// callPluginFunctionNoOutput calls a plugin function with input but no response expected.
// It handles JSON marshalling for input and only checks for errors.
func callPluginFunctionNoOutput[I any](ctx context.Context, plugin *plugin, funcName string, input I) error {
start := time.Now()
// Create plugin instance
p, err := plugin.instance()
if err != nil {
return fmt.Errorf("failed to create plugin: %w", err)
}
defer p.Close(ctx)
if !p.FunctionExists(funcName) {
log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName)
return fmt.Errorf("%w: %s", errFunctionNotFound, funcName)
}
inputBytes, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("failed to marshal input: %w", err)
}
startCall := time.Now()
exit, _, err := p.Call(funcName, inputBytes)
if err != nil {
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
return fmt.Errorf("plugin call failed: %w", err)
}
if exit != 0 {
return fmt.Errorf("plugin call exited with code %d", exit)
}
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
return nil
}
// callPluginFunction is a helper to call a plugin function with input and output types.
// It handles JSON marshalling/unmarshalling and error checking.
func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcName string, input I) (O, error) {

View File

@ -11,19 +11,6 @@ import (
pdk "github.com/extism/go-pdk"
)
// InitRequest is the request provided to the init callback.
// Currently empty, reserved for future use.
type InitRequest struct {
}
// InitResponse is the response from the init callback.
type InitResponse struct {
// Error is the error message if initialization failed.
// Empty string indicates success.
// The error is logged but does not prevent the plugin from being loaded.
Error string `json:"error,omitempty"`
}
// Lifecycle is the marker interface for lifecycle plugins.
// Implement one or more of the provider interfaces below.
// Lifecycle provides plugin lifecycle hooks.
@ -38,10 +25,10 @@ type Lifecycle interface{}
// InitProvider provides the OnInit function.
type InitProvider interface {
OnInit(InitRequest) (InitResponse, error)
OnInit() error
} // Internal implementation holders
var (
initImpl func(InitRequest) (InitResponse, error)
initImpl func() error
)
// Register registers a lifecycle implementation.
@ -63,19 +50,7 @@ func _NdOnInit() int32 {
return NotImplementedCode
}
var input InitRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := initImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
if err := initImpl(); err != nil {
pdk.SetError(err)
return -1
}

View File

@ -8,19 +8,6 @@
package lifecycle
// InitRequest is the request provided to the init callback.
// Currently empty, reserved for future use.
type InitRequest struct {
}
// InitResponse is the response from the init callback.
type InitResponse struct {
// Error is the error message if initialization failed.
// Empty string indicates success.
// The error is logged but does not prevent the plugin from being loaded.
Error string `json:"error,omitempty"`
}
// Lifecycle is the marker interface for lifecycle plugins.
// Implement one or more of the provider interfaces below.
// Lifecycle provides plugin lifecycle hooks.
@ -35,7 +22,7 @@ type Lifecycle interface{}
// InitProvider provides the OnInit function.
type InitProvider interface {
OnInit(InitRequest) (InitResponse, error)
OnInit() error
}
// NotImplementedCode is the standard return code for unimplemented functions.

View File

@ -11,108 +11,6 @@ import (
pdk "github.com/extism/go-pdk"
)
// ArtistRequest is the common request for artist-related functions.
type ArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
}
// ArtistBiographyResponse is the response for GetArtistBiography.
type ArtistBiographyResponse struct {
// Biography is the artist biography text.
Biography string `json:"biography"`
}
// AlbumInfoResponse is the response for GetAlbumInfo.
type AlbumInfoResponse struct {
// Name is the album name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the album.
MBID string `json:"mbid"`
// Description is the album description/notes.
Description string `json:"description"`
// URL is the external URL for the album.
URL string `json:"url"`
}
// ArtistMBIDRequest is the request for GetArtistMBID.
type ArtistMBIDRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
}
// TopSongsResponse is the response for GetArtistTopSongs.
type TopSongsResponse struct {
// Songs is the list of top songs.
Songs []SongRef `json:"songs"`
}
// ArtistRef is a reference to an artist with name and optional MBID.
type ArtistRef struct {
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid,omitempty"`
}
// ArtistMBIDResponse is the response for GetArtistMBID.
type ArtistMBIDResponse struct {
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid"`
}
// SimilarArtistsResponse is the response for GetSimilarArtists.
type SimilarArtistsResponse struct {
// Artists is the list of similar artists.
Artists []ArtistRef `json:"artists"`
}
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
}
// ImageInfo represents an image with URL and size.
type ImageInfo struct {
// URL is the URL of the image.
URL string `json:"url"`
// Size is the size of the image in pixels (width or height).
Size int32 `json:"size"`
}
// ArtistURLResponse is the response for GetArtistURL.
type ArtistURLResponse struct {
// URL is the external URL for the artist.
URL string `json:"url"`
}
// SimilarArtistsRequest is the request for GetSimilarArtists.
type SimilarArtistsRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
// Limit is the maximum number of similar artists to return.
Limit int32 `json:"limit"`
}
// ArtistImagesResponse is the response for GetArtistImages.
type ArtistImagesResponse struct {
// Images is the list of artist images.
Images []ImageInfo `json:"images"`
}
// TopSongsRequest is the request for GetArtistTopSongs.
type TopSongsRequest struct {
// ID is the internal Navidrome artist ID.
@ -141,6 +39,108 @@ type AlbumImagesResponse struct {
Images []ImageInfo `json:"images"`
}
// ImageInfo represents an image with URL and size.
type ImageInfo struct {
// URL is the URL of the image.
URL string `json:"url"`
// Size is the size of the image in pixels (width or height).
Size int32 `json:"size"`
}
// ArtistRequest is the common request for artist-related functions.
type ArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
}
// ArtistBiographyResponse is the response for GetArtistBiography.
type ArtistBiographyResponse struct {
// Biography is the artist biography text.
Biography string `json:"biography"`
}
// SimilarArtistsRequest is the request for GetSimilarArtists.
type SimilarArtistsRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
// Limit is the maximum number of similar artists to return.
Limit int32 `json:"limit"`
}
// TopSongsResponse is the response for GetArtistTopSongs.
type TopSongsResponse struct {
// Songs is the list of top songs.
Songs []SongRef `json:"songs"`
}
// AlbumInfoResponse is the response for GetAlbumInfo.
type AlbumInfoResponse struct {
// Name is the album name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the album.
MBID string `json:"mbid"`
// Description is the album description/notes.
Description string `json:"description"`
// URL is the external URL for the album.
URL string `json:"url"`
}
// ArtistRef is a reference to an artist with name and optional MBID.
type ArtistRef struct {
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid,omitempty"`
}
// ArtistURLResponse is the response for GetArtistURL.
type ArtistURLResponse struct {
// URL is the external URL for the artist.
URL string `json:"url"`
}
// SimilarArtistsResponse is the response for GetSimilarArtists.
type SimilarArtistsResponse struct {
// Artists is the list of similar artists.
Artists []ArtistRef `json:"artists"`
}
// ArtistImagesResponse is the response for GetArtistImages.
type ArtistImagesResponse struct {
// Images is the list of artist images.
Images []ImageInfo `json:"images"`
}
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
}
// ArtistMBIDRequest is the request for GetArtistMBID.
type ArtistMBIDRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
}
// ArtistMBIDResponse is the response for GetArtistMBID.
type ArtistMBIDResponse struct {
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid"`
}
// Metadata is the marker interface for metadata plugins.
// Implement one or more of the provider interfaces below.
// MetadataAgent provides artist and album metadata retrieval.

View File

@ -8,108 +8,6 @@
package metadata
// ArtistRequest is the common request for artist-related functions.
type ArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
}
// ArtistBiographyResponse is the response for GetArtistBiography.
type ArtistBiographyResponse struct {
// Biography is the artist biography text.
Biography string `json:"biography"`
}
// AlbumInfoResponse is the response for GetAlbumInfo.
type AlbumInfoResponse struct {
// Name is the album name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the album.
MBID string `json:"mbid"`
// Description is the album description/notes.
Description string `json:"description"`
// URL is the external URL for the album.
URL string `json:"url"`
}
// ArtistMBIDRequest is the request for GetArtistMBID.
type ArtistMBIDRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
}
// TopSongsResponse is the response for GetArtistTopSongs.
type TopSongsResponse struct {
// Songs is the list of top songs.
Songs []SongRef `json:"songs"`
}
// ArtistRef is a reference to an artist with name and optional MBID.
type ArtistRef struct {
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid,omitempty"`
}
// ArtistMBIDResponse is the response for GetArtistMBID.
type ArtistMBIDResponse struct {
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid"`
}
// SimilarArtistsResponse is the response for GetSimilarArtists.
type SimilarArtistsResponse struct {
// Artists is the list of similar artists.
Artists []ArtistRef `json:"artists"`
}
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
}
// ImageInfo represents an image with URL and size.
type ImageInfo struct {
// URL is the URL of the image.
URL string `json:"url"`
// Size is the size of the image in pixels (width or height).
Size int32 `json:"size"`
}
// ArtistURLResponse is the response for GetArtistURL.
type ArtistURLResponse struct {
// URL is the external URL for the artist.
URL string `json:"url"`
}
// SimilarArtistsRequest is the request for GetSimilarArtists.
type SimilarArtistsRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
// Limit is the maximum number of similar artists to return.
Limit int32 `json:"limit"`
}
// ArtistImagesResponse is the response for GetArtistImages.
type ArtistImagesResponse struct {
// Images is the list of artist images.
Images []ImageInfo `json:"images"`
}
// TopSongsRequest is the request for GetArtistTopSongs.
type TopSongsRequest struct {
// ID is the internal Navidrome artist ID.
@ -138,6 +36,108 @@ type AlbumImagesResponse struct {
Images []ImageInfo `json:"images"`
}
// ImageInfo represents an image with URL and size.
type ImageInfo struct {
// URL is the URL of the image.
URL string `json:"url"`
// Size is the size of the image in pixels (width or height).
Size int32 `json:"size"`
}
// ArtistRequest is the common request for artist-related functions.
type ArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
}
// ArtistBiographyResponse is the response for GetArtistBiography.
type ArtistBiographyResponse struct {
// Biography is the artist biography text.
Biography string `json:"biography"`
}
// SimilarArtistsRequest is the request for GetSimilarArtists.
type SimilarArtistsRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist (if known).
MBID string `json:"mbid,omitempty"`
// Limit is the maximum number of similar artists to return.
Limit int32 `json:"limit"`
}
// TopSongsResponse is the response for GetArtistTopSongs.
type TopSongsResponse struct {
// Songs is the list of top songs.
Songs []SongRef `json:"songs"`
}
// AlbumInfoResponse is the response for GetAlbumInfo.
type AlbumInfoResponse struct {
// Name is the album name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the album.
MBID string `json:"mbid"`
// Description is the album description/notes.
Description string `json:"description"`
// URL is the external URL for the album.
URL string `json:"url"`
}
// ArtistRef is a reference to an artist with name and optional MBID.
type ArtistRef struct {
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid,omitempty"`
}
// ArtistURLResponse is the response for GetArtistURL.
type ArtistURLResponse struct {
// URL is the external URL for the artist.
URL string `json:"url"`
}
// SimilarArtistsResponse is the response for GetSimilarArtists.
type SimilarArtistsResponse struct {
// Artists is the list of similar artists.
Artists []ArtistRef `json:"artists"`
}
// ArtistImagesResponse is the response for GetArtistImages.
type ArtistImagesResponse struct {
// Images is the list of artist images.
Images []ImageInfo `json:"images"`
}
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
}
// ArtistMBIDRequest is the request for GetArtistMBID.
type ArtistMBIDRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
}
// ArtistMBIDResponse is the response for GetArtistMBID.
type ArtistMBIDResponse struct {
// MBID is the MusicBrainz ID for the artist.
MBID string `json:"mbid"`
}
// Metadata is the marker interface for metadata plugins.
// Implement one or more of the provider interfaces below.
// MetadataAgent provides artist and album metadata retrieval.

View File

@ -24,14 +24,6 @@ type SchedulerCallbackRequest struct {
IsRecurring bool `json:"isRecurring"`
}
// SchedulerCallbackResponse is the response from the scheduler callback.
type SchedulerCallbackResponse struct {
// Error is the error message if the callback failed to process the scheduled task.
// Empty string indicates success. The error is logged but does not
// affect the scheduling system.
Error string `json:"error,omitempty"`
}
// Scheduler is the marker interface for scheduler plugins.
// Implement one or more of the provider interfaces below.
// SchedulerCallback provides scheduled task handling.
@ -42,10 +34,10 @@ type Scheduler interface{}
// SchedulerCallbackProvider provides the OnSchedulerCallback function.
type SchedulerCallbackProvider interface {
OnSchedulerCallback(SchedulerCallbackRequest) (SchedulerCallbackResponse, error)
OnSchedulerCallback(SchedulerCallbackRequest) error
} // Internal implementation holders
var (
schedulerCallbackImpl func(SchedulerCallbackRequest) (SchedulerCallbackResponse, error)
schedulerCallbackImpl func(SchedulerCallbackRequest) error
)
// Register registers a scheduler implementation.
@ -73,13 +65,7 @@ func _NdSchedulerCallback() int32 {
return -1
}
output, err := schedulerCallbackImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
if err := schedulerCallbackImpl(input); err != nil {
pdk.SetError(err)
return -1
}

View File

@ -21,14 +21,6 @@ type SchedulerCallbackRequest struct {
IsRecurring bool `json:"isRecurring"`
}
// SchedulerCallbackResponse is the response from the scheduler callback.
type SchedulerCallbackResponse struct {
// Error is the error message if the callback failed to process the scheduled task.
// Empty string indicates success. The error is logged but does not
// affect the scheduling system.
Error string `json:"error,omitempty"`
}
// Scheduler is the marker interface for scheduler plugins.
// Implement one or more of the provider interfaces below.
// SchedulerCallback provides scheduled task handling.
@ -39,7 +31,7 @@ type Scheduler interface{}
// SchedulerCallbackProvider provides the OnSchedulerCallback function.
type SchedulerCallbackProvider interface {
OnSchedulerCallback(SchedulerCallbackRequest) (SchedulerCallbackResponse, error)
OnSchedulerCallback(SchedulerCallbackRequest) error
}
// NotImplementedCode is the standard return code for unimplemented functions.

View File

@ -112,16 +112,16 @@ type TrackInfo struct {
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
type Scrobbler interface {
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
IsAuthorized(IsAuthorizedRequest) (IsAuthorizedResponse, error)
IsAuthorized(IsAuthorizedRequest) (*IsAuthorizedResponse, error)
// NowPlaying - NowPlaying sends a now playing notification to the scrobbling service.
NowPlaying(NowPlayingRequest) (ScrobblerResponse, error)
NowPlaying(NowPlayingRequest) (*ScrobblerResponse, error)
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
Scrobble(ScrobbleRequest) (ScrobblerResponse, error)
Scrobble(ScrobbleRequest) (*ScrobblerResponse, error)
} // Internal implementation holders
var (
isAuthorizedImpl func(IsAuthorizedRequest) (IsAuthorizedResponse, error)
nowPlayingImpl func(NowPlayingRequest) (ScrobblerResponse, error)
scrobbleImpl func(ScrobbleRequest) (ScrobblerResponse, error)
isAuthorizedImpl func(IsAuthorizedRequest) (*IsAuthorizedResponse, error)
nowPlayingImpl func(NowPlayingRequest) (*ScrobblerResponse, error)
scrobbleImpl func(ScrobbleRequest) (*ScrobblerResponse, error)
)
// Register registers a scrobbler implementation.

View File

@ -109,11 +109,11 @@ type TrackInfo struct {
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
type Scrobbler interface {
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
IsAuthorized(IsAuthorizedRequest) (IsAuthorizedResponse, error)
IsAuthorized(IsAuthorizedRequest) (*IsAuthorizedResponse, error)
// NowPlaying - NowPlaying sends a now playing notification to the scrobbling service.
NowPlaying(NowPlayingRequest) (ScrobblerResponse, error)
NowPlaying(NowPlayingRequest) (*ScrobblerResponse, error)
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
Scrobble(ScrobbleRequest) (ScrobblerResponse, error)
Scrobble(ScrobbleRequest) (*ScrobblerResponse, error)
}
// NotImplementedCode is the standard return code for unimplemented functions.

View File

@ -11,11 +11,12 @@ import (
pdk "github.com/extism/go-pdk"
)
// OnErrorResponse is the response from the error handler.
type OnErrorResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type OnErrorRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
ConnectionID string `json:"connectionId"`
// Error is the error message describing what went wrong.
Error string `json:"error"`
}
// OnCloseRequest is the request provided when a WebSocket connection is closed.
@ -29,13 +30,6 @@ type OnCloseRequest struct {
Reason string `json:"reason"`
}
// OnCloseResponse is the response from the close handler.
type OnCloseResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnTextMessageRequest is the request provided when a text message is received.
type OnTextMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
@ -44,13 +38,6 @@ type OnTextMessageRequest struct {
Message string `json:"message"`
}
// OnTextMessageResponse is the response from the text message handler.
type OnTextMessageResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnBinaryMessageRequest is the request provided when a binary message is received.
type OnBinaryMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
@ -59,21 +46,6 @@ type OnBinaryMessageRequest struct {
Data string `json:"data"`
}
// OnBinaryMessageResponse is the response from the binary message handler.
type OnBinaryMessageResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type OnErrorRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
ConnectionID string `json:"connectionId"`
// Error is the error message describing what went wrong.
Error string `json:"error"`
}
// WebSocket is the marker interface for websocket plugins.
// Implement one or more of the provider interfaces below.
// WebSocketCallback provides WebSocket message handling.
@ -85,28 +57,28 @@ type WebSocket interface{}
// TextMessageProvider provides the OnTextMessage function.
type TextMessageProvider interface {
OnTextMessage(OnTextMessageRequest) (OnTextMessageResponse, error)
OnTextMessage(OnTextMessageRequest) error
}
// BinaryMessageProvider provides the OnBinaryMessage function.
type BinaryMessageProvider interface {
OnBinaryMessage(OnBinaryMessageRequest) (OnBinaryMessageResponse, error)
OnBinaryMessage(OnBinaryMessageRequest) error
}
// ErrorProvider provides the OnError function.
type ErrorProvider interface {
OnError(OnErrorRequest) (OnErrorResponse, error)
OnError(OnErrorRequest) error
}
// CloseProvider provides the OnClose function.
type CloseProvider interface {
OnClose(OnCloseRequest) (OnCloseResponse, error)
OnClose(OnCloseRequest) error
} // Internal implementation holders
var (
textMessageImpl func(OnTextMessageRequest) (OnTextMessageResponse, error)
binaryMessageImpl func(OnBinaryMessageRequest) (OnBinaryMessageResponse, error)
errorImpl func(OnErrorRequest) (OnErrorResponse, error)
closeImpl func(OnCloseRequest) (OnCloseResponse, error)
textMessageImpl func(OnTextMessageRequest) error
binaryMessageImpl func(OnBinaryMessageRequest) error
errorImpl func(OnErrorRequest) error
closeImpl func(OnCloseRequest) error
)
// Register registers a websocket implementation.
@ -143,13 +115,7 @@ func _NdWebsocketOnTextMessage() int32 {
return -1
}
output, err := textMessageImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
if err := textMessageImpl(input); err != nil {
pdk.SetError(err)
return -1
}
@ -170,13 +136,7 @@ func _NdWebsocketOnBinaryMessage() int32 {
return -1
}
output, err := binaryMessageImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
if err := binaryMessageImpl(input); err != nil {
pdk.SetError(err)
return -1
}
@ -197,13 +157,7 @@ func _NdWebsocketOnError() int32 {
return -1
}
output, err := errorImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
if err := errorImpl(input); err != nil {
pdk.SetError(err)
return -1
}
@ -224,13 +178,7 @@ func _NdWebsocketOnClose() int32 {
return -1
}
output, err := closeImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
if err := closeImpl(input); err != nil {
pdk.SetError(err)
return -1
}

View File

@ -8,11 +8,12 @@
package websocket
// OnErrorResponse is the response from the error handler.
type OnErrorResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type OnErrorRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
ConnectionID string `json:"connectionId"`
// Error is the error message describing what went wrong.
Error string `json:"error"`
}
// OnCloseRequest is the request provided when a WebSocket connection is closed.
@ -26,13 +27,6 @@ type OnCloseRequest struct {
Reason string `json:"reason"`
}
// OnCloseResponse is the response from the close handler.
type OnCloseResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnTextMessageRequest is the request provided when a text message is received.
type OnTextMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
@ -41,13 +35,6 @@ type OnTextMessageRequest struct {
Message string `json:"message"`
}
// OnTextMessageResponse is the response from the text message handler.
type OnTextMessageResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnBinaryMessageRequest is the request provided when a binary message is received.
type OnBinaryMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
@ -56,21 +43,6 @@ type OnBinaryMessageRequest struct {
Data string `json:"data"`
}
// OnBinaryMessageResponse is the response from the binary message handler.
type OnBinaryMessageResponse struct {
// Error is the error message if the callback failed.
// Empty string indicates success.
Error string `json:"error,omitempty"`
}
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type OnErrorRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
ConnectionID string `json:"connectionId"`
// Error is the error message describing what went wrong.
Error string `json:"error"`
}
// WebSocket is the marker interface for websocket plugins.
// Implement one or more of the provider interfaces below.
// WebSocketCallback provides WebSocket message handling.
@ -82,22 +54,22 @@ type WebSocket interface{}
// TextMessageProvider provides the OnTextMessage function.
type TextMessageProvider interface {
OnTextMessage(OnTextMessageRequest) (OnTextMessageResponse, error)
OnTextMessage(OnTextMessageRequest) error
}
// BinaryMessageProvider provides the OnBinaryMessage function.
type BinaryMessageProvider interface {
OnBinaryMessage(OnBinaryMessageRequest) (OnBinaryMessageResponse, error)
OnBinaryMessage(OnBinaryMessageRequest) error
}
// ErrorProvider provides the OnError function.
type ErrorProvider interface {
OnError(OnErrorRequest) (OnErrorResponse, error)
OnError(OnErrorRequest) error
}
// CloseProvider provides the OnClose function.
type CloseProvider interface {
OnClose(OnCloseRequest) (OnCloseResponse, error)
OnClose(OnCloseRequest) error
}
// NotImplementedCode is the standard return code for unimplemented functions.

View File

@ -10,7 +10,7 @@ These wrappers provide idiomatic Rust APIs for interacting with Navidrome from W
To regenerate:
```bash
make generate-pdk
make gen
```
## Usage

View File

@ -45,8 +45,8 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool
Username: username,
}
result, err := callPluginFunction[capabilities.IsAuthorizedRequest, capabilities.IsAuthorizedResponse](ctx, s.plugin, FuncScrobblerIsAuthorized, input)
if err != nil {
result, err := callPluginFunction[capabilities.IsAuthorizedRequest, *capabilities.IsAuthorizedResponse](ctx, s.plugin, FuncScrobblerIsAuthorized, input)
if err != nil || result == nil {
return false
}
@ -63,7 +63,7 @@ func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *
Position: int32(position),
}
result, err := callPluginFunction[capabilities.NowPlayingRequest, capabilities.ScrobblerResponse](ctx, s.plugin, FuncScrobblerNowPlaying, input)
result, err := callPluginFunction[capabilities.NowPlayingRequest, *capabilities.ScrobblerResponse](ctx, s.plugin, FuncScrobblerNowPlaying, input)
if err != nil {
return err
}
@ -81,7 +81,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb
Timestamp: sc.TimeStamp.Unix(),
}
result, err := callPluginFunction[capabilities.ScrobbleRequest, capabilities.ScrobblerResponse](ctx, s.plugin, FuncScrobblerScrobble, input)
result, err := callPluginFunction[capabilities.ScrobbleRequest, *capabilities.ScrobblerResponse](ctx, s.plugin, FuncScrobblerScrobble, input)
if err != nil {
return err
}
@ -118,7 +118,10 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
}
// mapScrobblerError converts the plugin output error to a scrobbler error
func mapScrobblerError(output capabilities.ScrobblerResponse) error {
func mapScrobblerError(output *capabilities.ScrobblerResponse) error {
if output == nil {
return nil
}
switch output.ErrorType {
case capabilities.ScrobblerErrorNone, "":
return nil

View File

@ -170,43 +170,47 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
})
var _ = Describe("mapScrobblerError", func() {
It("returns nil for nil output", func() {
Expect(mapScrobblerError(nil)).ToNot(HaveOccurred())
})
It("returns nil for empty error type", func() {
output := capabilities.ScrobblerResponse{ErrorType: ""}
output := &capabilities.ScrobblerResponse{ErrorType: ""}
Expect(mapScrobblerError(output)).ToNot(HaveOccurred())
})
It("returns nil for 'none' error type", func() {
output := capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorNone}
output := &capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorNone}
Expect(mapScrobblerError(output)).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized for 'not_authorized' error type", func() {
output := capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorNotAuthorized}
output := &capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorNotAuthorized}
err := mapScrobblerError(output)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrNotAuthorized with message", func() {
output := capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorNotAuthorized, Error: "user not linked"}
output := &capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorNotAuthorized, Error: "user not linked"}
err := mapScrobblerError(output)
Expect(err).To(MatchError(ContainSubstring("not authorized")))
Expect(err).To(MatchError(ContainSubstring("user not linked")))
})
It("returns ErrRetryLater for 'retry_later' error type", func() {
output := capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorRetryLater}
output := &capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorRetryLater}
err := mapScrobblerError(output)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable for 'unrecoverable' error type", func() {
output := capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorUnrecoverable}
output := &capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorUnrecoverable}
err := mapScrobblerError(output)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
It("returns error for unknown error type", func() {
output := capabilities.ScrobblerResponse{ErrorType: "unknown"}
output := &capabilities.ScrobblerResponse{ErrorType: "unknown"}
err := mapScrobblerError(output)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unknown error type"))

View File

@ -7,29 +7,23 @@ package main
// - "schedule-followup": schedules a one-time task via host function
// - "schedule-recurring": schedules a recurring task via host function
// - "schedule-duplicate:<id>": attempts to schedule with the given ID (for testing duplicate detection)
func NdSchedulerCallback(input SchedulerCallbackInput) (SchedulerCallbackOutput, error) {
func NdSchedulerCallback(input SchedulerCallbackInput) error {
switch {
case input.Payload == "schedule-followup":
_, err := SchedulerScheduleOneTime(1, "followup-created", "followup-id")
if err != nil {
errStr := err.Error()
return SchedulerCallbackOutput{Error: &errStr}, nil
if _, err := SchedulerScheduleOneTime(1, "followup-created", "followup-id"); err != nil {
return err
}
case input.Payload == "schedule-recurring":
_, err := SchedulerScheduleRecurring("@every 1s", "recurring-created", "recurring-from-plugin")
if err != nil {
errStr := err.Error()
return SchedulerCallbackOutput{Error: &errStr}, nil
if _, err := SchedulerScheduleRecurring("@every 1s", "recurring-created", "recurring-from-plugin"); err != nil {
return err
}
case len(input.Payload) > 19 && input.Payload[:19] == "schedule-duplicate:":
duplicateID := input.Payload[19:]
_, err := SchedulerScheduleOneTime(60, "duplicate-attempt", duplicateID)
if err != nil {
errStr := err.Error()
return SchedulerCallbackOutput{Error: &errStr}, nil
if _, err := SchedulerScheduleOneTime(60, "duplicate-attempt", duplicateID); err != nil {
return err
}
}
return SchedulerCallbackOutput{}, nil
return nil
}
func main() {}

View File

@ -7,31 +7,17 @@ import (
//go:wasmexport nd_scheduler_callback
func _NdSchedulerCallback() int32 {
var err error
_ = err
pdk.Log(pdk.LogDebug, "NdSchedulerCallback: getting JSON input")
var input SchedulerCallbackInput
err = pdk.InputJSON(&input)
if err != nil {
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
pdk.Log(pdk.LogDebug, "NdSchedulerCallback: calling implementation function")
output, err := NdSchedulerCallback(input)
if err != nil {
if err := NdSchedulerCallback(input); err != nil {
pdk.SetError(err)
return -1
}
pdk.Log(pdk.LogDebug, "NdSchedulerCallback: setting JSON output")
err = pdk.OutputJSON(output)
if err != nil {
pdk.SetError(err)
return -1
}
pdk.Log(pdk.LogDebug, "NdSchedulerCallback: returning")
return 0
}
@ -47,11 +33,3 @@ type SchedulerCallbackInput struct {
// provided when scheduling, or an auto-generated UUID if none was specified.
ScheduleId string `json:"scheduleId"`
}
// Output from the scheduler callback
type SchedulerCallbackOutput struct {
// Error message if the callback failed to process the scheduled task.
// Empty or null indicates success. The error is logged but does not
// affect the scheduling system.
Error *string `json:"error,omitempty"`
}

View File

@ -48,6 +48,8 @@ type ScrobbleInput struct {
Timestamp int64 `json:"timestamp"`
}
// ScrobblerOutput contains error information from scrobble operations.
// A nil pointer indicates success, non-nil indicates an error with details.
type ScrobblerOutput struct {
Error string `json:"error,omitempty"`
ErrorType string `json:"errorType,omitempty"`
@ -87,16 +89,17 @@ func ndScrobblerIsAuthorized() int32 {
var input AuthInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
return -1
}
output := AuthOutput{
// Return pointer to output
output := &AuthOutput{
Authorized: checkAuthConfig(),
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
return -1
}
return 0
}
@ -106,19 +109,19 @@ func ndScrobblerNowPlaying() int32 {
var input NowPlayingInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
return -1
}
// Check for configured error
// Check for configured error - return pointer to error output
hasErr, errMsg, errType := checkConfigError()
if hasErr {
output := ScrobblerOutput{
output := &ScrobblerOutput{
Error: errMsg,
ErrorType: errType,
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
return -1
}
return 0
}
@ -127,7 +130,11 @@ func ndScrobblerNowPlaying() int32 {
// In a real plugin, this would send to an external service
pdk.Log(pdk.LogInfo, "NowPlaying: "+input.Track.Title+" by "+input.Track.Artist)
// Success - no output needed
// Success - output nil (empty response)
if err := pdk.OutputJSON(nil); err != nil {
pdk.SetError(err)
return -1
}
return 0
}
@ -136,19 +143,19 @@ func ndScrobblerScrobble() int32 {
var input ScrobbleInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
return -1
}
// Check for configured error
// Check for configured error - return pointer to error output
hasErr, errMsg, errType := checkConfigError()
if hasErr {
output := ScrobblerOutput{
output := &ScrobblerOutput{
Error: errMsg,
ErrorType: errType,
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
return -1
}
return 0
}
@ -157,7 +164,11 @@ func ndScrobblerScrobble() int32 {
// In a real plugin, this would send to an external service
pdk.Log(pdk.LogInfo, "Scrobble: "+input.Track.Title+" by "+input.Track.Artist)
// Success - no output needed
// Success - output nil (empty response)
if err := pdk.OutputJSON(nil); err != nil {
pdk.SetError(err)
return -1
}
return 0
}

View File

@ -3,6 +3,8 @@
package main
import (
"errors"
pdk "github.com/extism/go-pdk"
)
@ -12,11 +14,6 @@ type OnTextMessageInput struct {
Message string `json:"message"`
}
// OnTextMessageOutput is the output from nd_websocket_on_text_message callback.
type OnTextMessageOutput struct {
Error *string `json:"error,omitempty"`
}
// nd_websocket_on_text_message is called when a text message is received.
// Magic messages trigger specific behaviors to test host functions:
// - "echo": sends back the same message using SendText host function
@ -28,9 +25,8 @@ type OnTextMessageOutput struct {
func ndWebSocketOnTextMessage() int32 {
var input OnTextMessageInput
if err := pdk.InputJSON(&input); err != nil {
errStr := err.Error()
pdk.OutputJSON(OnTextMessageOutput{Error: &errStr})
return 0
pdk.SetError(err)
return -1
}
// Store all received messages for test verification
@ -38,28 +34,22 @@ func ndWebSocketOnTextMessage() int32 {
switch input.Message {
case "echo":
_, err := WebSocketSendText(input.ConnectionID, "echo:"+input.Message)
if err != nil {
errStr := err.Error()
pdk.OutputJSON(OnTextMessageOutput{Error: &errStr})
return 0
if _, err := WebSocketSendText(input.ConnectionID, "echo:"+input.Message); err != nil {
pdk.SetError(err)
return -1
}
case "close":
_, err := WebSocketCloseConnection(input.ConnectionID, 1000, "closed by plugin")
if err != nil {
errStr := err.Error()
pdk.OutputJSON(OnTextMessageOutput{Error: &errStr})
return 0
if _, err := WebSocketCloseConnection(input.ConnectionID, 1000, "closed by plugin"); err != nil {
pdk.SetError(err)
return -1
}
case "fail":
errStr := "intentional test failure"
pdk.OutputJSON(OnTextMessageOutput{Error: &errStr})
return 0
pdk.SetError(errors.New("intentional test failure"))
return -1
}
pdk.OutputJSON(OnTextMessageOutput{})
return 0
}
@ -69,26 +59,19 @@ type OnBinaryMessageInput struct {
Data string `json:"data"` // Base64 encoded
}
// OnBinaryMessageOutput is the output from nd_websocket_on_binary_message callback.
type OnBinaryMessageOutput struct {
Error *string `json:"error,omitempty"`
}
// nd_websocket_on_binary_message is called when a binary message is received.
//
//go:wasmexport nd_websocket_on_binary_message
func ndWebSocketOnBinaryMessage() int32 {
var input OnBinaryMessageInput
if err := pdk.InputJSON(&input); err != nil {
errStr := err.Error()
pdk.OutputJSON(OnBinaryMessageOutput{Error: &errStr})
return 0
pdk.SetError(err)
return -1
}
// Store received binary data for test verification
storeReceivedMessage("binary:" + input.Data)
pdk.OutputJSON(OnBinaryMessageOutput{})
return 0
}
@ -98,26 +81,19 @@ type OnErrorInput struct {
Error string `json:"error"`
}
// OnErrorOutput is the output from nd_websocket_on_error callback.
type OnErrorOutput struct {
Error *string `json:"error,omitempty"`
}
// nd_websocket_on_error is called when an error occurs on a WebSocket connection.
//
//go:wasmexport nd_websocket_on_error
func ndWebSocketOnError() int32 {
var input OnErrorInput
if err := pdk.InputJSON(&input); err != nil {
errStr := err.Error()
pdk.OutputJSON(OnErrorOutput{Error: &errStr})
return 0
pdk.SetError(err)
return -1
}
// Store error for test verification
storeReceivedMessage("error:" + input.Error)
pdk.OutputJSON(OnErrorOutput{})
return 0
}
@ -128,26 +104,19 @@ type OnCloseInput struct {
Reason string `json:"reason"`
}
// OnCloseOutput is the output from nd_websocket_on_close callback.
type OnCloseOutput struct {
Error *string `json:"error,omitempty"`
}
// nd_websocket_on_close is called when a WebSocket connection is closed.
//
//go:wasmexport nd_websocket_on_close
func ndWebSocketOnClose() int32 {
var input OnCloseInput
if err := pdk.InputJSON(&input); err != nil {
errStr := err.Error()
pdk.OutputJSON(OnCloseOutput{Error: &errStr})
return 0
pdk.SetError(err)
return -1
}
// Store close event for test verification
storeReceivedMessage("close:" + input.Reason)
pdk.OutputJSON(OnCloseOutput{})
return 0
}