Deluan Quintão ae0e0c89d9
feat(plugins): add PlaybackReport to scrobbler capability (#5452)
* feat(plugins): add PlaybackReport to Scrobbler interface and all implementations

* feat(plugins): add PlaybackReport worker and dispatch in PlayTracker

* feat(plugins): add PlaybackReportRequest to plugin scrobbler capability

* chore(plugins): regenerate PDK files with PlaybackReport

* feat(plugins): add PlaybackReport to test scrobbler plugin

* feat(plugins): add PlaybackReport to plugin scrobbler adapter

* refactor(plugins): fix double DB fetch in StateStopped and batch getActiveScrobblers

- Hoist mf from scrobble branch so PlaybackReport reuses it instead of
  fetching again from DB
- Call getActiveScrobblers once per drain batch instead of per-entry

* chore(plugins): include generated scrobbler schema with PlaybackReport

* fix(plugins): skip PlaybackReport for plugins that don't export it

Plugins detected as scrobblers only need to export one scrobbler
function. Older plugins that don't export nd_scrobbler_playback_report
would cause noisy error logs on every reportPlayback call. Now
errFunctionNotFound and errNotImplemented are treated as no-ops.

* refactor: rename NowPlayingInfo to PlaybackReport

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename stopNowPlayingWorker to stopBackgroundWorkers

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move NowPlaying and PlaybackReport logic to separate worker files

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scrobbler): rename NowPlayingInfo to PlaybackSession and add expired state

Rename NowPlayingInfo struct to PlaybackSession to better reflect its role
as a complete playback session representation. Add UserId field to make
sessions self-contained, removing redundant userId parameters from
PlaybackReport interface method and internal dispatch functions. Introduce
StateExpired internal state that fires when a session cache entry expires
without an explicit stop, ensuring plugins always receive a terminal event
regardless of client behavior.

* fix(scrobbler): update playback state description to include 'expired'

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scrobbler): resolve data race in OnExpiration callback

Capture conf.Server.EnableNowPlaying at construction time instead of
reading it from the background ttlcache eviction goroutine. The previous
code raced with test config cleanup that writes to the same field
concurrently.

* fix(scrobbler): return error when media file lookup fails in StateStopped

Simplify the MediaFile population logic in the stopped case to return an
error if the track cannot be found. A stop report with an empty MediaFile
is useless to plugins, and returning the error allows clients to retry
or alert the user when auto-scrobble is enabled.

* refactor(scrobbler): use session data directly in PlaybackReport adapter

Use info.Username from PlaybackSession instead of extracting it from
context in the plugin adapter, since the session is now self-contained.
Add debug/trace logging for session expiration and enqueue the expired
report with a user-enriched context so downstream handlers can identify
the user.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-02 16:14:53 -04:00
..

Navidrome Plugin Development Kit for Go

This directory contains the auto-generated Go PDK (Plugin Development Kit) for building Navidrome plugins. The PDK provides both host function wrappers for interacting with Navidrome and capability interfaces for implementing plugin functionality.

⚠️ Auto-Generated Code

Do not edit files in this directory manually. They are generated by the ndpgen tool.

To regenerate:

make gen

Module Structure

This is a consolidated Go module that includes:

  • host/ - Host function wrappers for calling Navidrome services from plugins
  • lifecycle/ - Plugin lifecycle hooks (initialization)
  • metadata/ - Metadata agent capability for artist/album info
  • scheduler/ - Scheduler callback capability for scheduled tasks
  • scrobbler/ - Scrobbler capability for play tracking
  • websocket/ - WebSocket callback capability for real-time messages

Usage

Add this module as a dependency in your plugin's go.mod:

require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0

replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

Then import the packages you need:

package main

import (
    "github.com/navidrome/navidrome/plugins/pdk/go/host"
    "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
    "github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
)

func init() {
    lifecycle.Register(&myPlugin{})
    scheduler.Register(&myPlugin{})
}

type myPlugin struct{}

func (p *myPlugin) OnInit() error {
    // Initialize your plugin
    return nil
}

func (p *myPlugin) OnCallback(req scheduler.SchedulerCallbackRequest) error {
    // Handle scheduled task
    return host.WebSocketBroadcast("task-complete", req.ScheduleID)
}

func main() {}

Host Services

The host package provides wrappers for calling Navidrome's host services:

Service Description
Artwork Access album and artist artwork
Cache Temporary key-value storage with TTL
KVStore Persistent key-value storage
Library Access the music library (albums, artists, tracks)
Scheduler Schedule one-time and recurring tasks
SubsonicAPI Make Subsonic API calls
WebSocket Send real-time messages to clients

Example: Using Host Services

package main

import (
    "github.com/navidrome/navidrome/plugins/pdk/go/host"
)

func myPluginFunction() error {
    // Use the cache service
    _, err := host.CacheSetString("my_key", "my_value", 3600)
    if err != nil {
        return err
    }

    // Schedule a recurring task  
    _, err = host.SchedulerScheduleRecurring("@every 5m", "payload", "task_id")
    if err != nil {
        return err
    }

    // Access library data with typed structs
    resp, err := host.LibraryGetAllLibraries()
    if err != nil {
        return err
    }
    for _, lib := range resp.Result {
        // Library: %s with %d songs", lib.Name, lib.TotalSongs
    }

    return nil
}

Capabilities

Capabilities define what functionality your plugin implements. Register your implementations in the init() function.

Lifecycle

Provides plugin initialization hooks.

import "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"

func init() {
    lifecycle.Register(&myPlugin{})
}

type myPlugin struct{}

func (p *myPlugin) OnInit() error {
    // Called once when plugin is loaded
    return nil
}

MetadataAgent

Provides artist and album metadata from external sources.

import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"

func init() {
    metadata.Register(&myAgent{})
}

type myAgent struct{}

func (a *myAgent) GetArtistBiography(req metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
    return &metadata.ArtistBiographyResponse{
        Biography: "Artist biography text...",
    }, nil
}

func (a *myAgent) GetArtistImages(req metadata.ArtistRequest) (*metadata.ArtistImagesResponse, error) {
    return &metadata.ArtistImagesResponse{
        Images: []metadata.ImageInfo{
            {URL: "https://example.com/image.jpg", Size: 1000},
        },
    }, nil
}

Scheduler

Handles callbacks from scheduled tasks.

import (
    "github.com/navidrome/navidrome/plugins/pdk/go/host"
    "github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
)

func init() {
    scheduler.Register(&myScheduler{})
}

type myScheduler struct{}

func (s *myScheduler) OnCallback(req scheduler.SchedulerCallbackRequest) error {
    // Handle the scheduled task
    if req.Payload == "update-data" {
        // Do work...
        return host.WebSocketBroadcast("data-updated", "")
    }
    return nil
}

Scrobbler

Tracks play activity.

import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"

func init() {
    scrobbler.Register(&myScrobbler{})
}

type myScrobbler struct{}

func (s *myScrobbler) Scrobble(req scrobbler.ScrobbleRequest) error {
    // Track the play
    return nil
}

func (s *myScrobbler) NowPlaying(req scrobbler.NowPlayingRequest) error {
    // Update now playing status
    return nil
}

WebSocket

Handles incoming WebSocket messages.

import "github.com/navidrome/navidrome/plugins/pdk/go/websocket"

func init() {
    websocket.Register(&myHandler{})
}

type myHandler struct{}

func (h *myHandler) OnWebSocketMessage(req websocket.WebSocketMessageRequest) error {
    // Handle incoming message
    return nil
}

Building Plugins

Go plugins must be compiled to WebAssembly using TinyGo:

tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared .

Or use the provided Makefile targets in plugin examples:

make plugin.wasm

Testing Plugins

The PDK includes testify/mock implementations for all host services, allowing you to unit test your plugin code on non-WASM platforms (your development machine).

PDK Abstraction Layer

The pdk subpackage provides a testable wrapper around the Extism PDK functions. Instead of importing github.com/extism/go-pdk directly, import the abstraction layer:

import "github.com/navidrome/navidrome/plugins/pdk/go/pdk"

func myFunction() {
    // Use pdk functions - same API as extism/go-pdk
    config, ok := pdk.GetConfig("my_setting")
    if ok {
        pdk.Log(pdk.LogInfo, "Setting: " + config)
    }
    
    var input MyInput
    if err := pdk.InputJSON(&input); err != nil {
        pdk.SetError(err)
        return
    }
    
    output := processInput(input)
    pdk.OutputJSON(output)
}

For WASM builds, these functions delegate directly to extism/go-pdk with zero overhead. For native builds (tests), they use mocks that you can configure:

package myplugin

import (
    "testing"
    
    "github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)

func TestMyFunction(t *testing.T) {
    // Reset mock state before each test
    pdk.ResetMock()
    
    // Set up expectations
    pdk.PDKMock.On("GetConfig", "my_setting").Return("test_value", true)
    pdk.PDKMock.On("Log", pdk.LogInfo, "Setting: test_value").Return()
    pdk.PDKMock.On("InputJSON", mock.Anything).Return(nil).Run(func(args mock.Arguments) {
        // Populate the input struct
        input := args.Get(0).(*MyInput)
        input.Name = "test"
    })
    pdk.PDKMock.On("OutputJSON", mock.Anything).Return(nil)
    
    // Call your function
    myFunction()
    
    // Verify expectations
    pdk.PDKMock.AssertExpectations(t)
}

Mock Instances

Each host service has an auto-instantiated mock instance:

Service Mock Instance
Artwork host.ArtworkMock
Cache host.CacheMock
Config host.ConfigMock
KVStore host.KVStoreMock
Library host.LibraryMock
Scheduler host.SchedulerMock
SubsonicAPI host.SubsonicAPIMock
WebSocket host.WebSocketMock

Example Test

package myplugin

import (
    "testing"

    "github.com/navidrome/navidrome/plugins/pdk/go/host"
)

func TestMyPluginFunction(t *testing.T) {
    // Set expectations on the mock
    host.CacheMock.On("GetString", "my-key").Return("cached-value", true, nil)
    host.CacheMock.On("SetString", "new-key", "new-value", int64(3600)).Return(nil)

    // Call your plugin code that uses host.CacheGetString / host.CacheSetString
    result, err := myPluginFunction()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    // Assert the result
    if result != "expected" {
        t.Errorf("unexpected result: %s", result)
    }

    // Verify all expected calls were made
    host.CacheMock.AssertExpectations(t)
}

Running Tests

Since tests run on your development machine (not WASM), use standard Go testing:

go test ./...

The stub files with mocks are only compiled for non-WASM builds (//go:build !wasip1), so they won't affect your production WASM binary.

Complete Examples

For more comprehensive examples including HTTP requests, Memory handling, and various testing patterns, see pdk/example_test.go.