Deluan Quintão 7b7721f002
feat(matcher): match similar/top songs by multiple artists (#5668)
* feat(matcher): add Song.Artists (agents.Artist) and field-wise song dedup

* refactor(matcher): make song equality an agents.Song.Equals method via hashstructure

Move the sameSong free function from core/matcher into an Equals method on
agents.Song, following the model.MediaFile/Album.Equals convention. Uses strict
hashstructure hashing (nil opts, no IgnoreZeroValue) to preserve the original
whole-value equality contract. Tests moved to core/agents.

* feat(matcher): match by multiple artists with overlap ranking and artist-ID fast-path

* refactor(matcher): rank artist overlap and specificity above the preferred-track flag

Identity signals (specificityLevel, artistOverlap) now outrank the taste
signal (preferredMatch) in betterThan. A starred/4-star track that is a worse
identity match no longer beats a more specific or higher-overlap track.
PreferStarred still breaks ties when specificity and overlap are equal.

* fix(matcher): score artist-MBID specificity against all credited artists, not just the last

sanitizedTrack.artistMBID (string) replaced with artistMBIDs (map[string]struct{}) so
bucketTracks collects all credited owned MBIDs per query instead of last-write-wins.
computeSpecificityLevel tests set membership, letting each of a collaboration's
MBID-bearing artists reach the proper specificity level (4/5) independently.

* feat(plugins): carry multiple artists (with IDs) through SongRef conversions

* feat(plugins): regenerate schemas and PDK wrappers for multi-artist SongRef

* refactor(matcher): tidy bucketTracks accumulator and artist resolution

Replace bucketTracks' two parallel per-track maps (overlapByQuery/mbidsByQuery)
with a named queryAccum struct (F2). Collapse resolveArtists' four hand-mutated
parallel maps into a pendingArtist slice with derived nameToQueries/mbidToQueries
maps (F1). Replace the own() method on resolvedArtists with a package-level
addToSet helper that drops the method/receiver indirection (F3).

* docs(matcher): trim comments that restate the code

* fix(plugins): use Vec::is_empty for slice fields in generated Rust PDK

* fix(matcher): treat a resolved artist ID as an identity match for specificity

* docs(matcher): reflect artist-ID identity in the specificity ladder
2026-06-26 14:46:09 -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.