From f1b85e6a19d080977203506881b2c619edc1cb0e Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 30 Dec 2025 11:26:01 -0500 Subject: [PATCH] refactor(plugins): update scrobbler interface to return errors directly instead of response structs Signed-off-by: Deluan --- plugins/capabilities/scrobbler.go | 25 +-- plugins/capabilities/scrobbler.yaml | 27 +-- plugins/cmd/ndpgen/internal/parser.go | 10 + .../internal/templates/capability.go.tmpl | 15 ++ .../templates/capability_stub.go.tmpl | 15 ++ .../discord-rich-presence-rs/src/lib.rs | 65 +++--- .../examples/discord-rich-presence/main.go | 25 +-- plugins/manager_call.go | 67 +----- plugins/pdk/go/metadata/metadata.go | 204 +++++++++--------- plugins/pdk/go/metadata/metadata_stub.go | 204 +++++++++--------- plugins/pdk/go/scrobbler/scrobbler.go | 73 +++---- plugins/pdk/go/scrobbler/scrobbler_stub.go | 53 ++--- plugins/pdk/go/websocket/websocket.go | 32 +-- plugins/pdk/go/websocket/websocket_stub.go | 32 +-- plugins/scrobbler_adapter.go | 50 ++--- plugins/scrobbler_adapter_test.go | 56 ++--- plugins/testdata/test-scrobbler/go.mod | 9 +- plugins/testdata/test-scrobbler/main.go | 175 ++++----------- 18 files changed, 464 insertions(+), 673 deletions(-) diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index e39bad89e..13a0fc795 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -15,11 +15,11 @@ type Scrobbler interface { // NowPlaying sends a now playing notification to the scrobbling service. //nd:export name=nd_scrobbler_now_playing - NowPlaying(NowPlayingRequest) (*ScrobblerResponse, error) + NowPlaying(NowPlayingRequest) error // Scrobble submits a completed scrobble to the scrobbling service. //nd:export name=nd_scrobbler_scrobble - Scrobble(ScrobbleRequest) (*ScrobblerResponse, error) + Scrobble(ScrobbleRequest) error } // IsAuthorizedRequest is the request for authorization check. @@ -92,24 +92,17 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } -// ScrobblerErrorType indicates how Navidrome should handle scrobbler errors. -type ScrobblerErrorType string +// ScrobblerError represents an error type for scrobbling operations. +type ScrobblerError string const ( - // ScrobblerErrorNone indicates no error occurred. - ScrobblerErrorNone ScrobblerErrorType = "none" // ScrobblerErrorNotAuthorized indicates the user is not authorized. - ScrobblerErrorNotAuthorized ScrobblerErrorType = "not_authorized" + ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)" // ScrobblerErrorRetryLater indicates the operation should be retried later. - ScrobblerErrorRetryLater ScrobblerErrorType = "retry_later" + ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)" // ScrobblerErrorUnrecoverable indicates an unrecoverable error. - ScrobblerErrorUnrecoverable ScrobblerErrorType = "unrecoverable" + ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)" ) -// ScrobblerResponse is the response for scrobbler operations. -type ScrobblerResponse struct { - // Error is the error message if the operation failed. - Error string `json:"error,omitempty"` - // ErrorType indicates how Navidrome should handle the error. - ErrorType ScrobblerErrorType `json:"errorType,omitempty"` -} +// Error implements the error interface for ScrobblerError. +func (e ScrobblerError) Error() string { return string(e) } diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index ab0e2f1f3..b966adecc 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -13,17 +13,11 @@ exports: input: $ref: '#/components/schemas/NowPlayingRequest' contentType: application/json - output: - $ref: '#/components/schemas/ScrobblerResponse' - contentType: application/json nd_scrobbler_scrobble: description: Scrobble submits a completed scrobble to the scrobbling service. input: $ref: '#/components/schemas/ScrobbleRequest' contentType: application/json - output: - $ref: '#/components/schemas/ScrobblerResponse' - contentType: application/json components: schemas: IsAuthorizedRequest: @@ -92,16 +86,6 @@ components: - username - track - timestamp - ScrobblerResponse: - description: ScrobblerResponse is the response for scrobbler operations. - type: object - properties: - error: - type: string - description: Error is the error message if the operation failed. - errorType: - $ref: '#/components/schemas/ScrobblerErrorType' - description: ErrorType indicates how Navidrome should handle the error. TrackInfo: description: TrackInfo contains track metadata for scrobbling. type: object @@ -160,11 +144,10 @@ components: - duration - trackNumber - discNumber - ScrobblerErrorType: - description: ScrobblerErrorType indicates how Navidrome should handle scrobbler errors. + ScrobblerError: + description: ScrobblerError represents an error type for scrobbling operations. type: string enum: - - none - - not_authorized - - retry_later - - unrecoverable + - scrobbler(not_authorized) + - scrobbler(retry_later) + - scrobbler(unrecoverable) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index e83407475..4ee6f595d 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -194,6 +194,16 @@ func parseCapabilityFile(fset *token.FileSet, path string) ([]Capability, error) } } + // Also attach type aliases prefixed with interface name (e.g., ScrobblerError for Scrobbler interface) + // This supports error types that are not directly referenced in method signatures + interfaceName := typeSpec.Name.Name + for typeName, a := range aliasMap { + if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] { + capability.TypeAliases = append(capability.TypeAliases, a) + referencedTypes[typeName] = true // Mark as referenced for const lookup + } + } + // Attach const groups that match referenced type aliases for _, group := range allConstGroups { if group.Type == "" { diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index 265627b6b..11e424383 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -40,6 +40,21 @@ const ( {{- end}} {{- end}} +{{- /* Generate Error() methods for string type aliases with const values (implements error interface) */ -}} +{{- $consts := .Capability.Consts}} +{{- range .Capability.TypeAliases}} +{{- if eq .Type "string"}} +{{- $typeName := .Name}} +{{- range $consts}} +{{- if eq .Type $typeName}} + +// Error implements the error interface for {{$typeName}}. +func (e {{$typeName}}) Error() string { return string(e) } +{{- end}} +{{- end}} +{{- end}} +{{- end}} + {{- /* Generate struct definitions */ -}} {{- range .Capability.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index e997bec2c..90f72be93 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -37,6 +37,21 @@ const ( {{- end}} {{- end}} +{{- /* Generate Error() methods for string type aliases with const values (implements error interface) */ -}} +{{- $consts := .Capability.Consts}} +{{- range .Capability.TypeAliases}} +{{- if eq .Type "string"}} +{{- $typeName := .Name}} +{{- range $consts}} +{{- if eq .Type $typeName}} + +// Error implements the error interface for {{$typeName}}. +func (e {{$typeName}}) Error() string { return string(e) } +{{- end}} +{{- end}} +{{- end}} +{{- end}} + {{- /* Generate struct definitions */ -}} {{- range .Capability.Structs}} diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 72fa10eb4..52f0876cc 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -129,15 +129,6 @@ struct ScrobbleInput { timestamp: i64, } -#[derive(Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct ScrobblerOutput { - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error_type: Option, -} - const ERROR_TYPE_NOT_AUTHORIZED: &str = "not_authorized"; const ERROR_TYPE_RETRY_LATER: &str = "retry_later"; @@ -216,9 +207,7 @@ pub fn nd_scrobbler_is_authorized(Json(input): Json) -> FnResult, -) -> FnResult>> { +pub fn nd_scrobbler_now_playing(Json(input): Json) -> FnResult<()> { info!( "Setting presence for user {}, track: {}", input.username, input.track.title @@ -228,11 +217,10 @@ pub fn nd_scrobbler_now_playing( let (client_id, users) = match get_config() { Ok(config) => config, Err(e) => { - let err_msg = format!("failed to get config: {:?}", e); - return Ok(Json(Some(ScrobblerOutput { - error: Some(err_msg), - error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()), - }))); + return Err(WithReturnCode::new( + Error::msg(format!("{}: failed to get config: {:?}", ERROR_TYPE_RETRY_LATER, e)), + -1, + )); } }; @@ -240,21 +228,25 @@ pub fn nd_scrobbler_now_playing( let user_token = match users.get(&input.username) { Some(token) => token.clone(), None => { - let err_msg = format!("user '{}' not authorized", input.username); - return Ok(Json(Some(ScrobblerOutput { - error: Some(err_msg), - error_type: Some(ERROR_TYPE_NOT_AUTHORIZED.to_string()), - }))); + return Err(WithReturnCode::new( + Error::msg(format!( + "{}: user '{}' not authorized", + ERROR_TYPE_NOT_AUTHORIZED, input.username + )), + -1, + )); } }; // 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(Some(ScrobblerOutput { - error: Some(err_msg), - error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()), - }))); + return Err(WithReturnCode::new( + Error::msg(format!( + "{}: failed to connect to Discord: {:?}", + ERROR_TYPE_RETRY_LATER, e + )), + -1, + )); } // Cancel any existing completion schedule @@ -289,11 +281,13 @@ pub fn nd_scrobbler_now_playing( }, }, ) { - let err_msg = format!("failed to send activity: {:?}", e); - return Ok(Json(Some(ScrobblerOutput { - error: Some(err_msg), - error_type: Some(ERROR_TYPE_RETRY_LATER.to_string()), - }))); + return Err(WithReturnCode::new( + Error::msg(format!( + "{}: failed to send activity: {:?}", + ERROR_TYPE_RETRY_LATER, e + )), + -1, + )); } // Schedule a timer to clear the activity after the track completes @@ -306,15 +300,14 @@ pub fn nd_scrobbler_now_playing( warn!("Failed to schedule completion timer: {:?}", e); } - // Success - return None to indicate no error - Ok(Json(None)) + Ok(()) } /// Handles scrobble requests (no-op for Discord Rich Presence). #[plugin_fn] -pub fn nd_scrobbler_scrobble(_input: Json) -> FnResult>> { +pub fn nd_scrobbler_scrobble(_input: Json) -> FnResult<()> { // Discord Rich Presence doesn't need scrobble events - success - Ok(Json(None)) + Ok(()) } // ============================================================================ diff --git a/plugins/examples/discord-rich-presence/main.go b/plugins/examples/discord-rich-presence/main.go index 4626a7328..d36213bff 100644 --- a/plugins/examples/discord-rich-presence/main.go +++ b/plugins/examples/discord-rich-presence/main.go @@ -105,30 +105,24 @@ func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (*scro } // 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) 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 nil, fmt.Errorf("failed to get config: %w", err) + return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err) } // Check authorization userToken, authorized := users[input.Username] if !authorized { - return &scrobbler.ScrobblerResponse{ - Error: fmt.Sprintf("user '%s' not authorized", input.Username), - ErrorType: scrobbler.ScrobblerErrorNotAuthorized, - }, nil + return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username) } // Connect to Discord if err := connect(input.Username, userToken); err != nil { - return &scrobbler.ScrobblerResponse{ - Error: fmt.Sprintf("failed to connect to Discord: %v", err), - ErrorType: scrobbler.ScrobblerErrorRetryLater, - }, nil + return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err) } // Cancel any existing completion schedule @@ -155,10 +149,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (*scrobble LargeText: input.Track.Album, }, }); err != nil { - return &scrobbler.ScrobblerResponse{ - Error: fmt.Sprintf("failed to send activity: %v", err), - ErrorType: scrobbler.ScrobblerErrorRetryLater, - }, nil + return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err) } // Schedule a timer to clear the activity after the track completes @@ -168,13 +159,13 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) (*scrobble pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err)) } - return &scrobbler.ScrobblerResponse{}, nil + return nil } // Scrobble handles scrobble requests (no-op for Discord). -func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) (*scrobbler.ScrobblerResponse, error) { +func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error { // Discord Rich Presence doesn't need scrobble events - return &scrobbler.ScrobblerResponse{}, nil + return nil } // ============================================================================ diff --git a/plugins/manager_call.go b/plugins/manager_call.go index 63f0f0ce0..312a9a5d3 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -13,71 +13,16 @@ 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. +// callPluginFunctionNoInput is a helper to call a plugin function with no input and output. 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 + _, err := callPluginFunction[struct{}, struct{}](ctx, plugin, funcName, struct{}{}) + return err } -// callPluginFunctionNoOutput calls a plugin function with input but no response expected. -// It handles JSON marshalling for input and only checks for errors. +// callPluginFunctionNoOutput is a helper to call a plugin function with input and no output. 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 + _, err := callPluginFunction[I, struct{}](ctx, plugin, funcName, input) + return err } // callPluginFunction is a helper to call a plugin function with input and output types. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index 1a5ab2b14..cd11aea16 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -11,6 +11,108 @@ import ( pdk "github.com/extism/go-pdk" ) +// ArtistBiographyResponse is the response for GetArtistBiography. +type ArtistBiographyResponse struct { + // Biography is the artist biography text. + Biography string `json:"biography"` +} + +// SimilarArtistsResponse is the response for GetSimilarArtists. +type SimilarArtistsResponse struct { + // Artists is the list of similar artists. + Artists []ArtistRef `json:"artists"` +} + +// 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"` +} + +// ArtistImagesResponse is the response for GetArtistImages. +type ArtistImagesResponse struct { + // Images is the list of artist images. + Images []ImageInfo `json:"images"` +} + +// 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"` +} + +// 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"` +} + +// ArtistMBIDResponse is the response for GetArtistMBID. +type ArtistMBIDResponse struct { + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid"` +} + +// ArtistURLResponse is the response for GetArtistURL. +type ArtistURLResponse struct { + // URL is the external URL for the artist. + URL string `json:"url"` +} + +// 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"` +} + +// 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"` +} + +// 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"` +} + // TopSongsRequest is the request for GetArtistTopSongs. type TopSongsRequest struct { // ID is the internal Navidrome artist ID. @@ -39,108 +141,6 @@ 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. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index 87bf45f7b..e17705406 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -8,6 +8,108 @@ package metadata +// ArtistBiographyResponse is the response for GetArtistBiography. +type ArtistBiographyResponse struct { + // Biography is the artist biography text. + Biography string `json:"biography"` +} + +// SimilarArtistsResponse is the response for GetSimilarArtists. +type SimilarArtistsResponse struct { + // Artists is the list of similar artists. + Artists []ArtistRef `json:"artists"` +} + +// 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"` +} + +// ArtistImagesResponse is the response for GetArtistImages. +type ArtistImagesResponse struct { + // Images is the list of artist images. + Images []ImageInfo `json:"images"` +} + +// 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"` +} + +// 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"` +} + +// ArtistMBIDResponse is the response for GetArtistMBID. +type ArtistMBIDResponse struct { + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid"` +} + +// ArtistURLResponse is the response for GetArtistURL. +type ArtistURLResponse struct { + // URL is the external URL for the artist. + URL string `json:"url"` +} + +// 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"` +} + +// 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"` +} + +// 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"` +} + // TopSongsRequest is the request for GetArtistTopSongs. type TopSongsRequest struct { // ID is the internal Navidrome artist ID. @@ -36,108 +138,6 @@ 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. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index d6ffd71d3..def08ef70 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -11,33 +11,20 @@ import ( pdk "github.com/extism/go-pdk" ) -// ScrobblerErrorType indicates how Navidrome should handle scrobbler errors. -type ScrobblerErrorType string +// ScrobblerError represents an error type for scrobbling operations. +type ScrobblerError string const ( - // ScrobblerErrorNone indicates no error occurred. - ScrobblerErrorNone ScrobblerErrorType = "none" // ScrobblerErrorNotAuthorized indicates the user is not authorized. - ScrobblerErrorNotAuthorized ScrobblerErrorType = "not_authorized" + ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)" // ScrobblerErrorRetryLater indicates the operation should be retried later. - ScrobblerErrorRetryLater ScrobblerErrorType = "retry_later" + ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)" // ScrobblerErrorUnrecoverable indicates an unrecoverable error. - ScrobblerErrorUnrecoverable ScrobblerErrorType = "unrecoverable" + ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)" ) -// IsAuthorizedRequest is the request for authorization check. -type IsAuthorizedRequest struct { - // UserID is the internal Navidrome user ID. - UserID string `json:"userId"` - // Username is the username of the user. - Username string `json:"username"` -} - -// IsAuthorizedResponse is the response for authorization check. -type IsAuthorizedResponse struct { - // Authorized indicates whether the user is authorized to scrobble. - Authorized bool `json:"authorized"` -} +// Error implements the error interface for ScrobblerError. +func (e ScrobblerError) Error() string { return string(e) } // NowPlayingRequest is the request for now playing notification. type NowPlayingRequest struct { @@ -51,14 +38,6 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } -// ScrobblerResponse is the response for scrobbler operations. -type ScrobblerResponse struct { - // Error is the error message if the operation failed. - Error string `json:"error,omitempty"` - // ErrorType indicates how Navidrome should handle the error. - ErrorType ScrobblerErrorType `json:"errorType,omitempty"` -} - // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // UserID is the internal Navidrome user ID. @@ -103,6 +82,20 @@ type TrackInfo struct { MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` } +// IsAuthorizedRequest is the request for authorization check. +type IsAuthorizedRequest struct { + // UserID is the internal Navidrome user ID. + UserID string `json:"userId"` + // Username is the username of the user. + Username string `json:"username"` +} + +// IsAuthorizedResponse is the response for authorization check. +type IsAuthorizedResponse struct { + // Authorized indicates whether the user is authorized to scrobble. + Authorized bool `json:"authorized"` +} + // Scrobbler requires all methods to be implemented. // Scrobbler provides scrobbling functionality to external services. // This capability allows plugins to submit listening history to services like Last.fm, @@ -114,14 +107,14 @@ type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (*IsAuthorizedResponse, error) // NowPlaying - NowPlaying sends a now playing notification to the scrobbling service. - NowPlaying(NowPlayingRequest) (*ScrobblerResponse, error) + NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. - Scrobble(ScrobbleRequest) (*ScrobblerResponse, error) + Scrobble(ScrobbleRequest) error } // Internal implementation holders var ( isAuthorizedImpl func(IsAuthorizedRequest) (*IsAuthorizedResponse, error) - nowPlayingImpl func(NowPlayingRequest) (*ScrobblerResponse, error) - scrobbleImpl func(ScrobbleRequest) (*ScrobblerResponse, error) + nowPlayingImpl func(NowPlayingRequest) error + scrobbleImpl func(ScrobbleRequest) error ) // Register registers a scrobbler implementation. @@ -176,13 +169,7 @@ func _NdScrobblerNowPlaying() int32 { return -1 } - output, err := nowPlayingImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { + if err := nowPlayingImpl(input); err != nil { pdk.SetError(err) return -1 } @@ -203,13 +190,7 @@ func _NdScrobblerScrobble() int32 { return -1 } - output, err := scrobbleImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { + if err := scrobbleImpl(input); err != nil { pdk.SetError(err) return -1 } diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 1f64aa891..35972cc15 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -8,33 +8,20 @@ package scrobbler -// ScrobblerErrorType indicates how Navidrome should handle scrobbler errors. -type ScrobblerErrorType string +// ScrobblerError represents an error type for scrobbling operations. +type ScrobblerError string const ( - // ScrobblerErrorNone indicates no error occurred. - ScrobblerErrorNone ScrobblerErrorType = "none" // ScrobblerErrorNotAuthorized indicates the user is not authorized. - ScrobblerErrorNotAuthorized ScrobblerErrorType = "not_authorized" + ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)" // ScrobblerErrorRetryLater indicates the operation should be retried later. - ScrobblerErrorRetryLater ScrobblerErrorType = "retry_later" + ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)" // ScrobblerErrorUnrecoverable indicates an unrecoverable error. - ScrobblerErrorUnrecoverable ScrobblerErrorType = "unrecoverable" + ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)" ) -// IsAuthorizedRequest is the request for authorization check. -type IsAuthorizedRequest struct { - // UserID is the internal Navidrome user ID. - UserID string `json:"userId"` - // Username is the username of the user. - Username string `json:"username"` -} - -// IsAuthorizedResponse is the response for authorization check. -type IsAuthorizedResponse struct { - // Authorized indicates whether the user is authorized to scrobble. - Authorized bool `json:"authorized"` -} +// Error implements the error interface for ScrobblerError. +func (e ScrobblerError) Error() string { return string(e) } // NowPlayingRequest is the request for now playing notification. type NowPlayingRequest struct { @@ -48,14 +35,6 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } -// ScrobblerResponse is the response for scrobbler operations. -type ScrobblerResponse struct { - // Error is the error message if the operation failed. - Error string `json:"error,omitempty"` - // ErrorType indicates how Navidrome should handle the error. - ErrorType ScrobblerErrorType `json:"errorType,omitempty"` -} - // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // UserID is the internal Navidrome user ID. @@ -100,6 +79,20 @@ type TrackInfo struct { MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` } +// IsAuthorizedRequest is the request for authorization check. +type IsAuthorizedRequest struct { + // UserID is the internal Navidrome user ID. + UserID string `json:"userId"` + // Username is the username of the user. + Username string `json:"username"` +} + +// IsAuthorizedResponse is the response for authorization check. +type IsAuthorizedResponse struct { + // Authorized indicates whether the user is authorized to scrobble. + Authorized bool `json:"authorized"` +} + // Scrobbler requires all methods to be implemented. // Scrobbler provides scrobbling functionality to external services. // This capability allows plugins to submit listening history to services like Last.fm, @@ -111,9 +104,9 @@ type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (*IsAuthorizedResponse, error) // NowPlaying - NowPlaying sends a now playing notification to the scrobbling service. - NowPlaying(NowPlayingRequest) (*ScrobblerResponse, error) + NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. - Scrobble(ScrobbleRequest) (*ScrobblerResponse, error) + Scrobble(ScrobbleRequest) error } // NotImplementedCode is the standard return code for unimplemented functions. diff --git a/plugins/pdk/go/websocket/websocket.go b/plugins/pdk/go/websocket/websocket.go index bcaed3126..0da113a45 100644 --- a/plugins/pdk/go/websocket/websocket.go +++ b/plugins/pdk/go/websocket/websocket.go @@ -11,6 +11,22 @@ import ( pdk "github.com/extism/go-pdk" ) +// 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. + ConnectionID string `json:"connectionId"` + // Message is the text message content received from the WebSocket. + Message string `json:"message"` +} + +// 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. + ConnectionID string `json:"connectionId"` + // Data is the binary data received from the WebSocket, encoded as base64. + Data string `json:"data"` +} + // 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. @@ -30,22 +46,6 @@ type OnCloseRequest struct { Reason string `json:"reason"` } -// 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. - ConnectionID string `json:"connectionId"` - // Message is the text message content received from the WebSocket. - Message string `json:"message"` -} - -// 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. - ConnectionID string `json:"connectionId"` - // Data is the binary data received from the WebSocket, encoded as base64. - Data string `json:"data"` -} - // WebSocket is the marker interface for websocket plugins. // Implement one or more of the provider interfaces below. // WebSocketCallback provides WebSocket message handling. diff --git a/plugins/pdk/go/websocket/websocket_stub.go b/plugins/pdk/go/websocket/websocket_stub.go index 487f0dd26..1a4ab5143 100644 --- a/plugins/pdk/go/websocket/websocket_stub.go +++ b/plugins/pdk/go/websocket/websocket_stub.go @@ -8,6 +8,22 @@ package websocket +// 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. + ConnectionID string `json:"connectionId"` + // Message is the text message content received from the WebSocket. + Message string `json:"message"` +} + +// 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. + ConnectionID string `json:"connectionId"` + // Data is the binary data received from the WebSocket, encoded as base64. + Data string `json:"data"` +} + // 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. @@ -27,22 +43,6 @@ type OnCloseRequest struct { Reason string `json:"reason"` } -// 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. - ConnectionID string `json:"connectionId"` - // Message is the text message content received from the WebSocket. - Message string `json:"message"` -} - -// 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. - ConnectionID string `json:"connectionId"` - // Data is the binary data received from the WebSocket, encoded as base64. - Data string `json:"data"` -} - // WebSocket is the marker interface for websocket plugins. // Implement one or more of the provider interfaces below. // WebSocketCallback provides WebSocket message handling. diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 4753f3784..f7934aa52 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -2,7 +2,7 @@ package plugins import ( "context" - "fmt" + "strings" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" @@ -63,12 +63,8 @@ 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) - if err != nil { - return err - } - - return mapScrobblerError(result) + err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerNowPlaying, input) + return mapScrobblerError(err) } // Scrobble submits a scrobble to the scrobbler @@ -81,12 +77,8 @@ 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) - if err != nil { - return err - } - - return mapScrobblerError(result) + err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerScrobble, input) + return mapScrobblerError(err) } // getUsernameFromContext extracts the username from the request context @@ -117,34 +109,22 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo { } } -// mapScrobblerError converts the plugin output error to a scrobbler error -func mapScrobblerError(output *capabilities.ScrobblerResponse) error { - if output == nil { +// mapScrobblerError converts plugin errors to scrobbler errors based on error message, as errors are returned as +// strings from plugins. +func mapScrobblerError(err error) error { + if err == nil { return nil } - switch output.ErrorType { - case capabilities.ScrobblerErrorNone, "": - return nil - case capabilities.ScrobblerErrorNotAuthorized: - if output.Error != "" { - return fmt.Errorf("%w: %s", scrobbler.ErrNotAuthorized, output.Error) - } + errMsg := err.Error() + switch { + case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): return scrobbler.ErrNotAuthorized - case capabilities.ScrobblerErrorRetryLater: - if output.Error != "" { - return fmt.Errorf("%w: %s", scrobbler.ErrRetryLater, output.Error) - } + case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()): return scrobbler.ErrRetryLater - case capabilities.ScrobblerErrorUnrecoverable: - if output.Error != "" { - return fmt.Errorf("%w: %s", scrobbler.ErrUnrecoverable, output.Error) - } + case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): return scrobbler.ErrUnrecoverable default: - if output.Error != "" { - return fmt.Errorf("unknown error type %q: %s", output.ErrorType, output.Error) - } - return fmt.Errorf("unknown error type: %s", output.ErrorType) + return scrobbler.ErrUnrecoverable } } diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 81adc0221..14bffb4aa 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -4,12 +4,12 @@ package plugins import ( "context" + "errors" "time" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -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": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, }, "test-scrobbler"+PackageExtension) sc, ok := manager.LoadScrobbler("test-scrobbler") @@ -97,7 +97,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { track := &model.MediaFile{ID: "track-1", Title: "Test Song"} err := sc.NowPlaying(ctx, "user-1", track, 30) Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("retry later"))) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) }) @@ -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": {"error": "user not linked", "error_type": "scrobbler(not_authorized)"}, }, "test-scrobbler"+PackageExtension) sc, ok := manager.LoadScrobbler("test-scrobbler") @@ -135,12 +135,12 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { } err := sc.Scrobble(ctx, "user-1", scrobble) Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("not authorized"))) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) }) 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": {"error": "track rejected", "error_type": "scrobbler(unrecoverable)"}, }, "test-scrobbler"+PackageExtension) sc, ok := manager.LoadScrobbler("test-scrobbler") @@ -152,7 +152,7 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { } err := sc.Scrobble(ctx, "user-1", scrobble) Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("unrecoverable"))) + Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) }) @@ -170,49 +170,27 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }) var _ = Describe("mapScrobblerError", func() { - It("returns nil for nil output", func() { + It("returns nil for nil error", func() { Expect(mapScrobblerError(nil)).ToNot(HaveOccurred()) }) - It("returns nil for empty error type", func() { - output := &capabilities.ScrobblerResponse{ErrorType: ""} - Expect(mapScrobblerError(output)).ToNot(HaveOccurred()) - }) - - It("returns nil for 'none' error type", func() { - 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} - err := mapScrobblerError(output) + It("returns ErrNotAuthorized for error containing 'not_authorized'", func() { + err := mapScrobblerError(errors.New("plugin error: scrobbler(not_authorized)")) Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) }) - It("returns ErrNotAuthorized with message", func() { - 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} - err := mapScrobblerError(output) + It("returns ErrRetryLater for error containing 'retry_later'", func() { + err := mapScrobblerError(errors.New("temporary failure: scrobbler(retry_later)")) Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) - It("returns ErrUnrecoverable for 'unrecoverable' error type", func() { - output := &capabilities.ScrobblerResponse{ErrorType: capabilities.ScrobblerErrorUnrecoverable} - err := mapScrobblerError(output) + It("returns ErrUnrecoverable for error containing 'unrecoverable'", func() { + err := mapScrobblerError(errors.New("fatal error: scrobbler(unrecoverable)")) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) - It("returns error for unknown error type", func() { - output := &capabilities.ScrobblerResponse{ErrorType: "unknown"} - err := mapScrobblerError(output) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("unknown error type")) + It("returns ErrUnrecoverable for unknown error", func() { + err := mapScrobblerError(errors.New("some unknown error")) + Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) }) diff --git a/plugins/testdata/test-scrobbler/go.mod b/plugins/testdata/test-scrobbler/go.mod index b61f4d474..27ddb7056 100644 --- a/plugins/testdata/test-scrobbler/go.mod +++ b/plugins/testdata/test-scrobbler/go.mod @@ -1,5 +1,10 @@ module test-scrobbler -go 1.23 +go 1.25 -require github.com/extism/go-pdk v1.1.3 +require ( + github.com/extism/go-pdk v1.1.3 + github.com/navidrome/navidrome v0.0.0 +) + +replace github.com/navidrome/navidrome => ../../.. diff --git a/plugins/testdata/test-scrobbler/main.go b/plugins/testdata/test-scrobbler/main.go index 19e4ea7d1..93f8e5889 100644 --- a/plugins/testdata/test-scrobbler/main.go +++ b/plugins/testdata/test-scrobbler/main.go @@ -3,70 +3,67 @@ package main import ( + "fmt" "strconv" - "github.com/extism/go-pdk" + pdk "github.com/extism/go-pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" ) -// Scrobbler input/output types - -type AuthInput struct { - UserID string `json:"userId"` - Username string `json:"username"` +func init() { + scrobbler.Register(&testScrobbler{}) } -type AuthOutput struct { - Authorized bool `json:"authorized"` +type testScrobbler struct{} + +// IsAuthorized checks if a user is authorized. +func (t *testScrobbler) IsAuthorized(scrobbler.IsAuthorizedRequest) (*scrobbler.IsAuthorizedResponse, error) { + return &scrobbler.IsAuthorizedResponse{ + Authorized: checkAuthConfig(), + }, nil } -type TrackInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Album string `json:"album"` - Artist string `json:"artist"` - AlbumArtist string `json:"albumArtist"` - Duration float32 `json:"duration"` - TrackNumber int `json:"trackNumber"` - DiscNumber int `json:"discNumber"` - MbzRecordingID string `json:"mbzRecordingId,omitempty"` - MbzAlbumID string `json:"mbzAlbumId,omitempty"` - MbzArtistID string `json:"mbzArtistId,omitempty"` - MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` +// NowPlaying sends a now playing notification. +func (t *testScrobbler) NowPlaying(input scrobbler.NowPlayingRequest) error { + // Check for configured error + if err := checkConfigError(); err != nil { + return err + } + + // Log the now playing (for potential debugging) + pdk.Log(pdk.LogInfo, "NowPlaying: "+input.Track.Title+" by "+input.Track.Artist) + return nil } -type NowPlayingInput struct { - UserID string `json:"userId"` - Username string `json:"username"` - Track TrackInfo `json:"track"` - Position int `json:"position"` -} +// Scrobble submits a scrobble. +func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error { + // Check for configured error + if err := checkConfigError(); err != nil { + return err + } -type ScrobbleInput struct { - UserID string `json:"userId"` - Username string `json:"username"` - Track TrackInfo `json:"track"` - 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"` + // Log the scrobble (for potential debugging) + pdk.Log(pdk.LogInfo, "Scrobble: "+input.Track.Title+" by "+input.Track.Artist) + return nil } // checkConfigError checks if the plugin is configured to return an error. -// If "error" config is set, it returns the error message and error type. -func checkConfigError() (bool, string, string) { +// If "error" config is set, it returns the appropriate ScrobblerError. +// Error types: "not_authorized", "retry_later", "unrecoverable" +func checkConfigError() error { errMsg, hasErr := pdk.GetConfig("error") if !hasErr || errMsg == "" { - return false, "", "" + return nil } errType, _ := pdk.GetConfig("error_type") - if errType == "" { - errType = "unrecoverable" + switch errType { + case scrobbler.ScrobblerErrorNotAuthorized.Error(): + return fmt.Errorf("%w: %s", scrobbler.ScrobblerErrorNotAuthorized, errMsg) + case scrobbler.ScrobblerErrorRetryLater.Error(): + return fmt.Errorf("%w: %s", scrobbler.ScrobblerErrorRetryLater, errMsg) + default: + return fmt.Errorf("%w: %s", scrobbler.ScrobblerErrorUnrecoverable, errMsg) } - return true, errMsg, errType } // checkAuthConfig returns whether the plugin is configured to authorize users. @@ -84,92 +81,4 @@ func checkAuthConfig() bool { return auth } -//go:wasmexport nd_scrobbler_is_authorized -func ndScrobblerIsAuthorized() int32 { - var input AuthInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - // Return pointer to output - output := &AuthOutput{ - Authorized: checkAuthConfig(), - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - return 0 -} - -//go:wasmexport nd_scrobbler_now_playing -func ndScrobblerNowPlaying() int32 { - var input NowPlayingInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - // Check for configured error - return pointer to error output - hasErr, errMsg, errType := checkConfigError() - if hasErr { - output := &ScrobblerOutput{ - Error: errMsg, - ErrorType: errType, - } - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - return 0 - } - - // Log the now playing (for potential debugging) - // In a real plugin, this would send to an external service - pdk.Log(pdk.LogInfo, "NowPlaying: "+input.Track.Title+" by "+input.Track.Artist) - - // Success - output nil (empty response) - if err := pdk.OutputJSON(nil); err != nil { - pdk.SetError(err) - return -1 - } - return 0 -} - -//go:wasmexport nd_scrobbler_scrobble -func ndScrobblerScrobble() int32 { - var input ScrobbleInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - // Check for configured error - return pointer to error output - hasErr, errMsg, errType := checkConfigError() - if hasErr { - output := &ScrobblerOutput{ - Error: errMsg, - ErrorType: errType, - } - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - return 0 - } - - // Log the scrobble (for potential debugging) - // In a real plugin, this would send to an external service - pdk.Log(pdk.LogInfo, "Scrobble: "+input.Track.Title+" by "+input.Track.Artist) - - // Success - output nil (empty response) - if err := pdk.OutputJSON(nil); err != nil { - pdk.SetError(err) - return -1 - } - return 0 -} - func main() {}