refactor(plugins): update scrobbler interface to return errors directly instead of response structs

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-30 11:26:01 -05:00
parent d43724c571
commit f1b85e6a19
18 changed files with 464 additions and 673 deletions

View File

@ -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) }

View File

@ -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)

View File

@ -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 == "" {

View File

@ -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}}

View File

@ -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}}

View File

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_type: Option<String>,
}
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<AuthInput>) -> FnResult<Json
/// Sends a now playing notification to Discord.
#[plugin_fn]
pub fn nd_scrobbler_now_playing(
Json(input): Json<NowPlayingInput>,
) -> FnResult<Json<Option<ScrobblerOutput>>> {
pub fn nd_scrobbler_now_playing(Json(input): Json<NowPlayingInput>) -> 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<ScrobbleInput>) -> FnResult<Json<Option<ScrobblerOutput>>> {
pub fn nd_scrobbler_scrobble(_input: Json<ScrobbleInput>) -> FnResult<()> {
// Discord Rich Presence doesn't need scrobble events - success
Ok(Json(None))
Ok(())
}
// ============================================================================

View File

@ -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
}
// ============================================================================

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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
}

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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
}
}

View File

@ -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))
})
})

View File

@ -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 => ../../..

View File

@ -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() {}