feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-22 16:13:19 -05:00
parent 876ecb29c8
commit 905cd613f3
10 changed files with 776 additions and 15 deletions

View File

@ -74,6 +74,132 @@ Provides artist and album metadata. A plugin has this capability if it exports o
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
### Scrobbler
Provides scrobbling (listening history) integration with external services. A plugin has this capability if it exports one or more of these functions:
| Function | Input | Output | Description |
|------------------------------|-----------------------|-------------------------|-------------------------------|
| `nd_scrobbler_is_authorized` | `{user_id, username}` | `{authorized}` | Check if user is authorized |
| `nd_scrobbler_now_playing` | See NowPlaying Input | `{error?, error_type?}` | Send now playing notification |
| `nd_scrobbler_scrobble` | See Scrobble Input | `{error?, error_type?}` | Submit a scrobble |
#### NowPlaying Input
```json
{
"user_id": "string",
"username": "string",
"track": {
"id": "string",
"title": "string",
"album": "string",
"artist": "string",
"album_artist": "string",
"duration": 180.5,
"track_number": 1,
"disc_number": 1,
"mbz_recording_id": "string",
"mbz_album_id": "string",
"mbz_artist_id": "string",
"mbz_release_group_id": "string",
"mbz_album_artist_id": "string",
"mbz_release_track_id": "string"
},
"position": 30
}
```
#### Scrobble Input
```json
{
"user_id": "string",
"username": "string",
"track": { /* same as NowPlaying */ },
"timestamp": 1703270400
}
```
#### Scrobbler Output
The output for `nd_scrobbler_now_playing` and `nd_scrobbler_scrobble` is **optional on success**. If there is no error, the plugin can return nothing (empty output).
On error, return:
```json
{
"error": "error message",
"error_type": "not_authorized|retry_later|unrecoverable"
}
```
**Error types:**
- `not_authorized`: User needs to re-authorize with the scrobbling service
- `retry_later`: Temporary failure, Navidrome will retry the scrobble later
- `unrecoverable`: Permanent failure, scrobble will be discarded
#### Example Scrobbler Plugin
```go
package main
import (
"encoding/json"
"github.com/extism/go-pdk"
)
type AuthInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
}
type AuthOutput struct {
Authorized bool `json:"authorized"`
}
type ScrobblerOutput struct {
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
}
//go:wasmexport nd_scrobbler_is_authorized
func ndScrobblerIsAuthorized() int32 {
var input AuthInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
// Check if user is authorized with your scrobbling service
// This could check a session key stored in plugin config
sessionKey, hasKey := pdk.GetConfig("session_key_" + input.UserID)
output := AuthOutput{Authorized: hasKey && sessionKey != ""}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
//go:wasmexport nd_scrobbler_scrobble
func ndScrobblerScrobble() int32 {
// Read input, send to external service...
output := ScrobblerOutput{ErrorType: "none"}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
func main() {}
```
Scrobbler plugins are automatically discovered and used by Navidrome's PlayTracker alongside built-in scrobblers (Last.fm, ListenBrainz).
## Developing Plugins
Plugins can be written in any language that compiles to WebAssembly. We recommend using the [Extism PDK](https://extism.org/docs/category/write-a-plug-in) for your language.

View File

@ -9,8 +9,9 @@ const (
// Detected when the plugin exports at least one of the metadata agent functions.
CapabilityMetadataAgent Capability = "MetadataAgent"
// Future capabilities:
// CapabilityScrobbler Capability = "Scrobbler"
// CapabilityScrobbler indicates the plugin can receive scrobble events.
// Detected when the plugin exports at least one of the scrobbler functions.
CapabilityScrobbler Capability = "Scrobbler"
)
// capabilityFunctions maps each capability to its required/optional functions.
@ -26,6 +27,11 @@ var capabilityFunctions = map[Capability][]string{
FuncGetAlbumInfo,
FuncGetAlbumImages,
},
CapabilityScrobbler: {
FuncScrobblerIsAuthorized,
FuncScrobblerNowPlaying,
FuncScrobblerScrobble,
},
}
// functionExistsChecker is an interface for checking if a function exists in a plugin.

View File

@ -202,8 +202,19 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
// LoadScrobbler loads and returns a scrobbler plugin by name.
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
// Scrobbler capability is not yet implemented
return nil, false
m.mu.RLock()
instance, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(instance.capabilities, CapabilityScrobbler) {
return nil, false
}
// Create a new scrobbler adapter for this plugin
return &ScrobblerPlugin{
name: instance.name,
plugin: instance,
}, true
}
// PluginInfo contains basic information about a plugin for metrics/insights.
@ -479,9 +490,11 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *pluginInstanc
return result, fmt.Errorf("plugin call exited with code %d", exit)
}
err = json.Unmarshal(output, &result)
if err != nil {
log.Trace(ctx, "Plugin call failed", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
if len(output) > 0 {
err = json.Unmarshal(output, &result)
if err != nil {
log.Trace(ctx, "Plugin call failed", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
}
}
log.Trace(ctx, "Plugin call succeeded", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))

View File

@ -46,20 +46,29 @@ func buildTestPlugins(t *testing.T, path string) {
}
// createTestManager creates a new plugin Manager with the given plugin config.
// It creates a temp directory, copies the fake plugin, and starts the manager.
// It creates a temp directory, copies the fake-metadata-agent plugin, and starts the manager.
// Returns the manager, temp directory path, and a cleanup function.
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
return createTestManagerWithPlugins(pluginConfig, "fake-metadata-agent.wasm")
}
// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config
// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy test plugin to temp dir
srcPath := filepath.Join(testdataDir, "fake-metadata-agent.wasm")
destPath := filepath.Join(tmpDir, "fake-metadata-agent.wasm")
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Copy test plugins to temp dir
for _, plugin := range plugins {
srcPath := filepath.Join(testdataDir, plugin)
destPath := filepath.Join(tmpDir, plugin)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
}
// Setup config
DeferCleanup(configtest.SetupConfig())

View File

@ -0,0 +1,135 @@
package plugins
import (
"context"
"fmt"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// Scrobbler function names (snake_case as per design)
const (
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
FuncScrobblerScrobble = "nd_scrobbler_scrobble"
)
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
// the scrobbler.Scrobbler interface for scrobbling to external services.
type ScrobblerPlugin struct {
name string
plugin *pluginInstance
}
// IsAuthorized checks if the user is authorized with this scrobbler
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
username := getUsernameFromContext(ctx)
input := scrobblerAuthInput{
UserID: userId,
Username: username,
}
result, err := callPluginFunction[scrobblerAuthInput, scrobblerAuthOutput](ctx, s.plugin, FuncScrobblerIsAuthorized, input)
if err != nil {
return false
}
return result.Authorized
}
// NowPlaying sends a now playing notification to the scrobbler
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
username := getUsernameFromContext(ctx)
input := scrobblerNowPlayingInput{
UserID: userId,
Username: username,
Track: mediaFileToTrackInfo(track),
Position: position,
}
result, err := callPluginFunction[scrobblerNowPlayingInput, scrobblerOutput](ctx, s.plugin, FuncScrobblerNowPlaying, input)
if err != nil {
return err
}
return mapScrobblerError(result)
}
// Scrobble submits a scrobble to the scrobbler
func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobbler.Scrobble) error {
username := getUsernameFromContext(ctx)
input := scrobblerScrobbleInput{
UserID: userId,
Username: username,
Track: mediaFileToTrackInfo(&sc.MediaFile),
Timestamp: sc.TimeStamp.Unix(),
}
result, err := callPluginFunction[scrobblerScrobbleInput, scrobblerOutput](ctx, s.plugin, FuncScrobblerScrobble, input)
if err != nil {
return err
}
return mapScrobblerError(result)
}
// getUsernameFromContext extracts the username from the request context
func getUsernameFromContext(ctx context.Context) string {
if user, ok := request.UserFrom(ctx); ok {
return user.UserName
}
return ""
}
// mediaFileToTrackInfo converts a model.MediaFile to scrobblerTrackInfo
func mediaFileToTrackInfo(mf *model.MediaFile) scrobblerTrackInfo {
return scrobblerTrackInfo{
ID: mf.ID,
Title: mf.Title,
Album: mf.Album,
Artist: mf.Artist,
AlbumArtist: mf.AlbumArtist,
Duration: mf.Duration,
TrackNumber: mf.TrackNumber,
DiscNumber: mf.DiscNumber,
MbzRecordingID: mf.MbzRecordingID,
MbzAlbumID: mf.MbzAlbumID,
MbzArtistID: mf.MbzArtistID,
MbzReleaseGroupID: mf.MbzReleaseGroupID,
MbzAlbumArtistID: mf.MbzAlbumArtistID,
MbzReleaseTrackID: mf.MbzReleaseTrackID,
}
}
// mapScrobblerError converts the plugin output error to a scrobbler error
func mapScrobblerError(output scrobblerOutput) error {
switch output.ErrorType {
case scrobblerErrorNone, "":
return nil
case scrobblerErrorNotAuthorized:
if output.Error != "" {
return fmt.Errorf("%w: %s", scrobbler.ErrNotAuthorized, output.Error)
}
return scrobbler.ErrNotAuthorized
case scrobblerErrorRetryLater:
if output.Error != "" {
return fmt.Errorf("%w: %s", scrobbler.ErrRetryLater, output.Error)
}
return scrobbler.ErrRetryLater
case scrobblerErrorUnrecoverable:
if output.Error != "" {
return fmt.Errorf("%w: %s", scrobbler.ErrUnrecoverable, output.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)
}
}
// Verify interface implementation at compile time
var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil)

View File

@ -0,0 +1,213 @@
//go:build !windows
package plugins
import (
"context"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ScrobblerPlugin", Ordered, func() {
var (
scrobblerManager *Manager
s scrobbler.Scrobbler
ctx context.Context
)
BeforeAll(func() {
ctx = GinkgoT().Context()
// Add user to context for username extraction
ctx = request.WithUser(ctx, model.User{ID: "user-1", UserName: "testuser"})
// Load the scrobbler via a new manager with the fake-scrobbler plugin
scrobblerManager, _ = createTestManagerWithPlugins(nil, "fake-scrobbler.wasm")
var ok bool
s, ok = scrobblerManager.LoadScrobbler("fake-scrobbler")
Expect(ok).To(BeTrue())
})
Describe("LoadScrobbler", func() {
It("returns a scrobbler for a plugin with Scrobbler capability", func() {
Expect(s).ToNot(BeNil())
})
It("returns false for a plugin without Scrobbler capability", func() {
_, ok := testManager.LoadScrobbler("fake-metadata-agent")
Expect(ok).To(BeFalse())
})
It("returns false for non-existent plugin", func() {
_, ok := scrobblerManager.LoadScrobbler("non-existent")
Expect(ok).To(BeFalse())
})
})
Describe("IsAuthorized", func() {
It("returns true when plugin is configured to authorize", func() {
result := s.IsAuthorized(ctx, "user-1")
Expect(result).To(BeTrue())
})
It("returns false when plugin is configured to not authorize", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"fake-scrobbler": {"authorized": "false"},
}, "fake-scrobbler.wasm")
sc, ok := manager.LoadScrobbler("fake-scrobbler")
Expect(ok).To(BeTrue())
result := sc.IsAuthorized(ctx, "user-1")
Expect(result).To(BeFalse())
})
})
Describe("NowPlaying", func() {
It("successfully calls the plugin", func() {
track := &model.MediaFile{
ID: "track-1",
Title: "Test Song",
Album: "Test Album",
Artist: "Test Artist",
AlbumArtist: "Test Album Artist",
Duration: 180,
TrackNumber: 1,
DiscNumber: 1,
}
err := s.NowPlaying(ctx, "user-1", track, 30)
Expect(err).ToNot(HaveOccurred())
})
It("returns error when plugin returns error", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"fake-scrobbler": {"error": "service unavailable", "error_type": "retry_later"},
}, "fake-scrobbler.wasm")
sc, ok := manager.LoadScrobbler("fake-scrobbler")
Expect(ok).To(BeTrue())
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")))
})
})
Describe("Scrobble", func() {
It("successfully calls the plugin", func() {
sc := scrobbler.Scrobble{
MediaFile: model.MediaFile{
ID: "track-1",
Title: "Test Song",
Album: "Test Album",
Artist: "Test Artist",
AlbumArtist: "Test Album Artist",
Duration: 180,
TrackNumber: 1,
DiscNumber: 1,
},
TimeStamp: time.Now(),
}
err := s.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
})
It("returns error when plugin returns not_authorized error", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"fake-scrobbler": {"error": "user not linked", "error_type": "not_authorized"},
}, "fake-scrobbler.wasm")
sc, ok := manager.LoadScrobbler("fake-scrobbler")
Expect(ok).To(BeTrue())
scrobble := scrobbler.Scrobble{
MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
TimeStamp: time.Now(),
}
err := sc.Scrobble(ctx, "user-1", scrobble)
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError(ContainSubstring("not authorized")))
})
It("returns error when plugin returns unrecoverable error", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"fake-scrobbler": {"error": "track rejected", "error_type": "unrecoverable"},
}, "fake-scrobbler.wasm")
sc, ok := manager.LoadScrobbler("fake-scrobbler")
Expect(ok).To(BeTrue())
scrobble := scrobbler.Scrobble{
MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
TimeStamp: time.Now(),
}
err := sc.Scrobble(ctx, "user-1", scrobble)
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError(ContainSubstring("unrecoverable")))
})
})
Describe("PluginNames", func() {
It("returns plugin names with Scrobbler capability", func() {
names := scrobblerManager.PluginNames("Scrobbler")
Expect(names).To(ContainElement("fake-scrobbler"))
})
It("does not return metadata agent plugins for Scrobbler capability", func() {
names := testManager.PluginNames("Scrobbler")
Expect(names).ToNot(ContainElement("fake-metadata-agent"))
})
})
})
var _ = Describe("mapScrobblerError", func() {
It("returns nil for empty error type", func() {
output := scrobblerOutput{ErrorType: ""}
Expect(mapScrobblerError(output)).ToNot(HaveOccurred())
})
It("returns nil for 'none' error type", func() {
output := scrobblerOutput{ErrorType: "none"}
Expect(mapScrobblerError(output)).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized for 'not_authorized' error type", func() {
output := scrobblerOutput{ErrorType: "not_authorized"}
err := mapScrobblerError(output)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrNotAuthorized with message", func() {
output := scrobblerOutput{ErrorType: "not_authorized", 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 := scrobblerOutput{ErrorType: "retry_later"}
err := mapScrobblerError(output)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable for 'unrecoverable' error type", func() {
output := scrobblerOutput{ErrorType: "unrecoverable"}
err := mapScrobblerError(output)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
It("returns error for unknown error type", func() {
output := scrobblerOutput{ErrorType: "unknown"}
err := mapScrobblerError(output)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unknown error type"))
})
})

View File

@ -0,0 +1,62 @@
package plugins
// --- Input/Output JSON structures for Scrobbler plugin calls ---
// scrobblerAuthInput is the input for IsAuthorized
type scrobblerAuthInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
}
// scrobblerAuthOutput is the output for IsAuthorized
type scrobblerAuthOutput struct {
Authorized bool `json:"authorized"`
}
// scrobblerTrackInfo contains track metadata for scrobbling
type scrobblerTrackInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Album string `json:"album"`
Artist string `json:"artist"`
AlbumArtist string `json:"album_artist"`
Duration float32 `json:"duration"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
MbzRecordingID string `json:"mbz_recording_id,omitempty"`
MbzAlbumID string `json:"mbz_album_id,omitempty"`
MbzArtistID string `json:"mbz_artist_id,omitempty"`
MbzReleaseGroupID string `json:"mbz_release_group_id,omitempty"`
MbzAlbumArtistID string `json:"mbz_album_artist_id,omitempty"`
MbzReleaseTrackID string `json:"mbz_release_track_id,omitempty"`
}
// scrobblerNowPlayingInput is the input for NowPlaying
type scrobblerNowPlayingInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Track scrobblerTrackInfo `json:"track"`
Position int `json:"position"`
}
// scrobblerScrobbleInput is the input for Scrobble
type scrobblerScrobbleInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Track scrobblerTrackInfo `json:"track"`
Timestamp int64 `json:"timestamp"`
}
// scrobblerOutput is the output for NowPlaying and Scrobble
type scrobblerOutput struct {
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "none", "not_authorized", "retry_later", "unrecoverable"
}
// scrobbler error type constants
const (
scrobblerErrorNone = "none"
scrobblerErrorNotAuthorized = "not_authorized"
scrobblerErrorRetryLater = "retry_later"
scrobblerErrorUnrecoverable = "unrecoverable"
)

View File

@ -0,0 +1,5 @@
module fake-scrobbler
go 1.23
require github.com/extism/go-pdk v1.1.3

View File

@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

190
plugins/testdata/fake-scrobbler/main.go vendored Normal file
View File

@ -0,0 +1,190 @@
// Fake scrobbler plugin for Navidrome plugin system integration tests.
// Build with: tinygo build -o ../fake-scrobbler.wasm -target wasip1 -buildmode=c-shared ./main.go
package main
import (
"encoding/json"
"strconv"
"github.com/extism/go-pdk"
)
// Manifest types
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
}
// Scrobbler input/output types
type AuthInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
}
type AuthOutput struct {
Authorized bool `json:"authorized"`
}
type TrackInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Album string `json:"album"`
Artist string `json:"artist"`
AlbumArtist string `json:"album_artist"`
Duration float32 `json:"duration"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
MbzRecordingID string `json:"mbz_recording_id,omitempty"`
MbzAlbumID string `json:"mbz_album_id,omitempty"`
MbzArtistID string `json:"mbz_artist_id,omitempty"`
MbzReleaseGroupID string `json:"mbz_release_group_id,omitempty"`
}
type NowPlayingInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Track TrackInfo `json:"track"`
Position int `json:"position"`
}
type ScrobbleInput struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Track TrackInfo `json:"track"`
Timestamp int64 `json:"timestamp"`
}
type ScrobblerOutput struct {
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
}
// 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) {
errMsg, hasErr := pdk.GetConfig("error")
if !hasErr || errMsg == "" {
return false, "", ""
}
errType, _ := pdk.GetConfig("error_type")
if errType == "" {
errType = "unrecoverable"
}
return true, errMsg, errType
}
// checkAuthConfig returns whether the plugin is configured to authorize users.
// If "authorized" config is set to "false", users are not authorized.
// Default is true (authorized).
func checkAuthConfig() bool {
authStr, hasAuth := pdk.GetConfig("authorized")
if !hasAuth {
return true // Default: authorized
}
auth, err := strconv.ParseBool(authStr)
if err != nil {
return true // Default on parse error
}
return auth
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "Fake Scrobbler",
Author: "Navidrome Test",
Version: "1.0.0",
Description: "A fake scrobbler plugin for integration testing",
}
out, err := json.Marshal(manifest)
if err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(out)
return 0
}
//go:wasmexport nd_scrobbler_is_authorized
func ndScrobblerIsAuthorized() int32 {
var input AuthInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
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
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 - no output needed
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
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 - no output needed
return 0
}
func main() {}