From 905cd613f36a025d284b830a10e774f44b50e917 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 22 Dec 2025 16:13:19 -0500 Subject: [PATCH] feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities Signed-off-by: Deluan --- plugins/README.md | 126 ++++++++++++++ plugins/capabilities.go | 10 +- plugins/manager.go | 23 ++- plugins/plugins_suite_test.go | 25 ++- plugins/scrobbler_adapter.go | 135 +++++++++++++++ plugins/scrobbler_adapter_test.go | 213 ++++++++++++++++++++++++ plugins/scrobbler_types.go | 62 +++++++ plugins/testdata/fake-scrobbler/go.mod | 5 + plugins/testdata/fake-scrobbler/go.sum | 2 + plugins/testdata/fake-scrobbler/main.go | 190 +++++++++++++++++++++ 10 files changed, 776 insertions(+), 15 deletions(-) create mode 100644 plugins/scrobbler_adapter.go create mode 100644 plugins/scrobbler_adapter_test.go create mode 100644 plugins/scrobbler_types.go create mode 100644 plugins/testdata/fake-scrobbler/go.mod create mode 100644 plugins/testdata/fake-scrobbler/go.sum create mode 100644 plugins/testdata/fake-scrobbler/main.go diff --git a/plugins/README.md b/plugins/README.md index 01f90efd3..015e9323f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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. diff --git a/plugins/capabilities.go b/plugins/capabilities.go index 09d2ca93c..289d8beae 100644 --- a/plugins/capabilities.go +++ b/plugins/capabilities.go @@ -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. diff --git a/plugins/manager.go b/plugins/manager.go index d96dd6193..56904d768 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -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)) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 7caae405e..38799bf3f 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -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()) diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go new file mode 100644 index 000000000..0e54c9296 --- /dev/null +++ b/plugins/scrobbler_adapter.go @@ -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) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go new file mode 100644 index 000000000..6ad7db47a --- /dev/null +++ b/plugins/scrobbler_adapter_test.go @@ -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")) + }) +}) diff --git a/plugins/scrobbler_types.go b/plugins/scrobbler_types.go new file mode 100644 index 000000000..11314e5ee --- /dev/null +++ b/plugins/scrobbler_types.go @@ -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" +) diff --git a/plugins/testdata/fake-scrobbler/go.mod b/plugins/testdata/fake-scrobbler/go.mod new file mode 100644 index 000000000..1b318b1b7 --- /dev/null +++ b/plugins/testdata/fake-scrobbler/go.mod @@ -0,0 +1,5 @@ +module fake-scrobbler + +go 1.23 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/testdata/fake-scrobbler/go.sum b/plugins/testdata/fake-scrobbler/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/plugins/testdata/fake-scrobbler/go.sum @@ -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= diff --git a/plugins/testdata/fake-scrobbler/main.go b/plugins/testdata/fake-scrobbler/main.go new file mode 100644 index 000000000..b80bdfe82 --- /dev/null +++ b/plugins/testdata/fake-scrobbler/main.go @@ -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() {}