mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
876ecb29c8
commit
905cd613f3
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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())
|
||||
|
||||
135
plugins/scrobbler_adapter.go
Normal file
135
plugins/scrobbler_adapter.go
Normal 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)
|
||||
213
plugins/scrobbler_adapter_test.go
Normal file
213
plugins/scrobbler_adapter_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
62
plugins/scrobbler_types.go
Normal file
62
plugins/scrobbler_types.go
Normal 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"
|
||||
)
|
||||
5
plugins/testdata/fake-scrobbler/go.mod
vendored
Normal file
5
plugins/testdata/fake-scrobbler/go.mod
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
module fake-scrobbler
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/testdata/fake-scrobbler/go.sum
vendored
Normal file
2
plugins/testdata/fake-scrobbler/go.sum
vendored
Normal 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
190
plugins/testdata/fake-scrobbler/main.go
vendored
Normal 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() {}
|
||||
Loading…
x
Reference in New Issue
Block a user