update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

27
plugins/examples/Makefile Normal file
View File

@@ -0,0 +1,27 @@
all: wikimedia coverartarchive crypto-ticker discord-rich-presence subsonicapi-demo
wikimedia: wikimedia/plugin.wasm
coverartarchive: coverartarchive/plugin.wasm
crypto-ticker: crypto-ticker/plugin.wasm
discord-rich-presence: discord-rich-presence/plugin.wasm
subsonicapi-demo: subsonicapi-demo/plugin.wasm
wikimedia/plugin.wasm: wikimedia/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia
coverartarchive/plugin.wasm: coverartarchive/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive
crypto-ticker/plugin.wasm: crypto-ticker/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker
DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go")
discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES)
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/...
subsonicapi-demo/plugin.wasm: subsonicapi-demo/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./subsonicapi-demo
clean:
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm \
discord-rich-presence/plugin.wasm subsonicapi-demo/plugin.wasm

View File

@@ -0,0 +1,31 @@
# Plugin Examples
This directory contains example plugins for Navidrome, intended for demonstration and reference purposes. These plugins are not used in automated tests.
## Contents
- `wikimedia/`: Retrieves artist information from Wikidata.
- `coverartarchive/`: Fetches album cover images from the Cover Art Archive.
- `crypto-ticker/`: Uses websockets to log real-time cryptocurrency prices.
- `discord-rich-presence/`: Integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
- `subsonicapi-demo/`: Demonstrates interaction with Navidrome's Subsonic API from a plugin.
## Building
To build all example plugins, run:
```
make
```
Or to build a specific plugin:
```
make wikimedia
make coverartarchive
make crypto-ticker
make discord-rich-presence
make subsonicapi-demo
```
This will produce the corresponding `plugin.wasm` files in each plugin's directory.

View File

@@ -0,0 +1,34 @@
# Cover Art Archive AlbumMetadataService Plugin
This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release Group MBID.
## Features
- Implements only the `GetAlbumImages` method of the AlbumMetadataService plugin interface.
- Returns front cover images for a given release-group MBID.
- Returns `not found` if no MBID is provided or no images are found.
## Requirements
- Go 1.24 or newer (with WASI support)
- The Navidrome repository (with generated plugin API code in `plugins/api`)
## How to Compile
To build the WASM plugin, run the following command from the project root:
```sh
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugins/testdata/coverartarchive/plugin.wasm ./plugins/testdata/coverartarchive
```
This will produce `plugin.wasm` in this directory.
## Usage
- The plugin can be loaded by Navidrome for integration and end-to-end tests of the plugin system.
- It is intended for testing and development purposes only.
## API Reference
- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API)
- This plugin uses the endpoint: `https://coverartarchive.org/release-group/{mbid}`

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
"name": "coverartarchive",
"author": "Navidrome",
"version": "1.0.0",
"description": "Album cover art from the Cover Art Archive",
"website": "https://coverartarchive.org",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch album cover art from the Cover Art Archive API",
"allowedUrls": {
"https://coverartarchive.org": ["GET"],
"https://*.archive.org": ["GET"]
},
"allowLocalNetwork": false
}
}
}

View File

@@ -0,0 +1,151 @@
//go:build wasip1
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/http"
)
type CoverArtArchiveAgent struct{}
var ErrNotFound = api.ErrNotFound
type caaImage struct {
Image string `json:"image"`
Front bool `json:"front"`
Types []string `json:"types"`
Thumbnails map[string]string `json:"thumbnails"`
}
var client = http.NewHttpService()
func (CoverArtArchiveAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) {
if req.Mbid == "" {
return nil, ErrNotFound
}
url := "https://coverartarchive.org/release/" + req.Mbid
resp, err := client.Get(ctx, &http.HttpRequest{Url: url, TimeoutMs: 5000})
if err != nil || resp.Status != 200 {
log.Printf("[CAA] Error getting album images from CoverArtArchive (status: %d): %v", resp.Status, err)
return nil, ErrNotFound
}
images, err := extractFrontImages(resp.Body)
if err != nil || len(images) == 0 {
return nil, ErrNotFound
}
return &api.AlbumImagesResponse{Images: images}, nil
}
func extractFrontImages(body []byte) ([]*api.ExternalImage, error) {
var data struct {
Images []caaImage `json:"images"`
}
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
img := findFrontImage(data.Images)
if img == nil {
return nil, ErrNotFound
}
return buildImageList(img), nil
}
func findFrontImage(images []caaImage) *caaImage {
for i, img := range images {
if img.Front {
return &images[i]
}
}
for i, img := range images {
for _, t := range img.Types {
if t == "Front" {
return &images[i]
}
}
}
if len(images) > 0 {
return &images[0]
}
return nil
}
func buildImageList(img *caaImage) []*api.ExternalImage {
var images []*api.ExternalImage
// First, try numeric sizes only
for sizeStr, url := range img.Thumbnails {
if url == "" {
continue
}
size := 0
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil {
images = append(images, &api.ExternalImage{Url: url, Size: int32(size)})
}
}
// If no numeric sizes, fallback to large/small
if len(images) == 0 {
for sizeStr, url := range img.Thumbnails {
if url == "" {
continue
}
var size int
switch sizeStr {
case "large":
size = 500
case "small":
size = 250
default:
continue
}
images = append(images, &api.ExternalImage{Url: url, Size: int32(size)})
}
}
if len(images) == 0 && img.Image != "" {
images = append(images, &api.ExternalImage{Url: img.Image, Size: 0})
}
return images
}
func (CoverArtArchiveAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) {
return nil, api.ErrNotImplemented
}
func (CoverArtArchiveAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) {
return nil, api.ErrNotImplemented
}
func (CoverArtArchiveAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) {
return nil, api.ErrNotImplemented
}
func (CoverArtArchiveAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) {
return nil, api.ErrNotImplemented
}
func (CoverArtArchiveAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) {
return nil, api.ErrNotImplemented
}
func (CoverArtArchiveAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) {
return nil, api.ErrNotImplemented
}
func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) {
return nil, api.ErrNotImplemented
}
func main() {}
func init() {
// Configure logging: No timestamps, no source file/line
log.SetFlags(0)
log.SetPrefix("[CAA] ")
api.RegisterMetadataAgent(CoverArtArchiveAgent{})
}

View File

@@ -0,0 +1,53 @@
# Crypto Ticker Plugin
This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase.
## Features
- Connects to Coinbase WebSocket API to receive real-time ticker updates
- Configurable to track multiple cryptocurrency pairs
- Implements WebSocketCallback and LifecycleManagement interfaces
- Automatically reconnects on connection loss
- Displays price, best bid, best ask, and 24-hour percentage change
## Configuration
In your `navidrome.toml` file, add:
```toml
[PluginConfig.crypto-ticker]
tickers = "BTC,ETH,SOL,MATIC"
```
- `tickers` is a comma-separated list of cryptocurrency symbols
- The plugin will append `-USD` to any symbol without a trading pair specified
## How it Works
- The plugin connects to Coinbase's WebSocket API upon initialization
- It subscribes to ticker updates for the configured cryptocurrencies
- Incoming ticker data is processed and logged
- On connection loss, it automatically attempts to reconnect (TODO)
## Building
To build the plugin to WASM:
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
```
## Installation
Copy the resulting `plugin.wasm` and create a `manifest.json` file in your Navidrome plugins folder under a `crypto-ticker` directory.
## Example Output
```
CRYPTO TICKER: BTC-USD Price: 65432.50 Best Bid: 65431.25 Best Ask: 65433.75 24h Change: 2.75%
CRYPTO TICKER: ETH-USD Price: 3456.78 Best Bid: 3455.90 Best Ask: 3457.80 24h Change: 1.25%
```
---
For more details, see the source code in `plugin.go`.

View File

@@ -0,0 +1,25 @@
{
"name": "crypto-ticker",
"author": "Navidrome Plugin",
"version": "1.0.0",
"description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
"capabilities": [
"WebSocketCallback",
"LifecycleManagement",
"SchedulerCallback"
],
"permissions": {
"config": {
"reason": "To read API configuration and WebSocket endpoint settings"
},
"scheduler": {
"reason": "To schedule periodic reconnection attempts and status updates"
},
"websocket": {
"reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices",
"allowedUrls": ["wss://ws-feed.exchange.coinbase.com"],
"allowLocalNetwork": false
}
}
}

View File

@@ -0,0 +1,304 @@
//go:build wasip1
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/config"
"github.com/navidrome/navidrome/plugins/host/scheduler"
"github.com/navidrome/navidrome/plugins/host/websocket"
)
const (
// Coinbase WebSocket API endpoint
coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com"
// Connection ID for our WebSocket connection
connectionID = "crypto-ticker-connection"
// ID for the reconnection schedule
reconnectScheduleID = "crypto-ticker-reconnect"
)
var (
// Store ticker symbols from the configuration
tickers []string
)
// WebSocketService instance used to manage WebSocket connections and communication.
var wsService = websocket.NewWebSocketService()
// ConfigService instance for accessing plugin configuration.
var configService = config.NewConfigService()
// SchedulerService instance for scheduling tasks.
var schedService = scheduler.NewSchedulerService()
// CryptoTickerPlugin implements WebSocketCallback, LifecycleManagement, and SchedulerCallback interfaces
type CryptoTickerPlugin struct{}
// Coinbase subscription message structure
type CoinbaseSubscription struct {
Type string `json:"type"`
ProductIDs []string `json:"product_ids"`
Channels []string `json:"channels"`
}
// Coinbase ticker message structure
type CoinbaseTicker struct {
Type string `json:"type"`
Sequence int64 `json:"sequence"`
ProductID string `json:"product_id"`
Price string `json:"price"`
Open24h string `json:"open_24h"`
Volume24h string `json:"volume_24h"`
Low24h string `json:"low_24h"`
High24h string `json:"high_24h"`
Volume30d string `json:"volume_30d"`
BestBid string `json:"best_bid"`
BestAsk string `json:"best_ask"`
Side string `json:"side"`
Time string `json:"time"`
TradeID int `json:"trade_id"`
LastSize string `json:"last_size"`
}
// OnInit is called when the plugin is loaded
func (CryptoTickerPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
log.Printf("Crypto Ticker Plugin initializing...")
// Check if ticker configuration exists
tickerConfig, ok := req.Config["tickers"]
if !ok {
return &api.InitResponse{Error: "Missing 'tickers' configuration"}, nil
}
// Parse ticker symbols
tickers := parseTickerSymbols(tickerConfig)
log.Printf("Configured tickers: %v", tickers)
// Connect to WebSocket and subscribe to tickers
err := connectAndSubscribe(ctx, tickers)
if err != nil {
return &api.InitResponse{Error: err.Error()}, nil
}
return &api.InitResponse{}, nil
}
// Helper function to parse ticker symbols from a comma-separated string
func parseTickerSymbols(tickerConfig string) []string {
tickers := strings.Split(tickerConfig, ",")
for i, ticker := range tickers {
tickers[i] = strings.TrimSpace(ticker)
// Add -USD suffix if not present
if !strings.Contains(tickers[i], "-") {
tickers[i] = tickers[i] + "-USD"
}
}
return tickers
}
// Helper function to connect to WebSocket and subscribe to tickers
func connectAndSubscribe(ctx context.Context, tickers []string) error {
// Connect to the WebSocket API
_, err := wsService.Connect(ctx, &websocket.ConnectRequest{
Url: coinbaseWSEndpoint,
ConnectionId: connectionID,
})
if err != nil {
log.Printf("Failed to connect to Coinbase WebSocket API: %v", err)
return fmt.Errorf("WebSocket connection error: %v", err)
}
log.Printf("Connected to Coinbase WebSocket API")
// Subscribe to ticker channel for the configured symbols
subscription := CoinbaseSubscription{
Type: "subscribe",
ProductIDs: tickers,
Channels: []string{"ticker"},
}
subscriptionJSON, err := json.Marshal(subscription)
if err != nil {
log.Printf("Failed to marshal subscription message: %v", err)
return fmt.Errorf("JSON marshal error: %v", err)
}
// Send subscription message
_, err = wsService.SendText(ctx, &websocket.SendTextRequest{
ConnectionId: connectionID,
Message: string(subscriptionJSON),
})
if err != nil {
log.Printf("Failed to send subscription message: %v", err)
return fmt.Errorf("WebSocket send error: %v", err)
}
log.Printf("Subscription message sent to Coinbase WebSocket API")
return nil
}
// OnTextMessage is called when a text message is received from the WebSocket
func (CryptoTickerPlugin) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) {
// Only process messages from our connection
if req.ConnectionId != connectionID {
log.Printf("Received message from unexpected connection: %s", req.ConnectionId)
return &api.OnTextMessageResponse{}, nil
}
// Try to parse as a ticker message
var ticker CoinbaseTicker
err := json.Unmarshal([]byte(req.Message), &ticker)
if err != nil {
log.Printf("Failed to parse ticker message: %v", err)
return &api.OnTextMessageResponse{}, nil
}
// If the message is not a ticker or has an error, just log it
if ticker.Type != "ticker" {
// This could be subscription confirmation or other messages
log.Printf("Received non-ticker message: %s", req.Message)
return &api.OnTextMessageResponse{}, nil
}
// Format and print ticker information
log.Printf("CRYPTO TICKER: %s Price: %s Best Bid: %s Best Ask: %s 24h Change: %s%%\n",
ticker.ProductID,
ticker.Price,
ticker.BestBid,
ticker.BestAsk,
calculatePercentChange(ticker.Open24h, ticker.Price),
)
return &api.OnTextMessageResponse{}, nil
}
// OnBinaryMessage is called when a binary message is received
func (CryptoTickerPlugin) OnBinaryMessage(ctx context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) {
// Not expected from Coinbase WebSocket API
return &api.OnBinaryMessageResponse{}, nil
}
// OnError is called when an error occurs on the WebSocket connection
func (CryptoTickerPlugin) OnError(ctx context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) {
log.Printf("WebSocket error: %s", req.Error)
return &api.OnErrorResponse{}, nil
}
// OnClose is called when the WebSocket connection is closed
func (CryptoTickerPlugin) OnClose(ctx context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) {
log.Printf("WebSocket connection closed with code %d: %s", req.Code, req.Reason)
// Try to reconnect if this is our connection
if req.ConnectionId == connectionID {
log.Printf("Scheduling reconnection attempts every 2 seconds...")
// Create a recurring schedule to attempt reconnection every 2 seconds
resp, err := schedService.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
// Run every 2 seconds using cron expression
CronExpression: "*/2 * * * * *",
ScheduleId: reconnectScheduleID,
})
if err != nil {
log.Printf("Failed to schedule reconnection attempts: %v", err)
} else {
log.Printf("Reconnection schedule created with ID: %s", resp.ScheduleId)
}
}
return &api.OnCloseResponse{}, nil
}
// OnSchedulerCallback is called when a scheduled event triggers
func (CryptoTickerPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
// Only handle our reconnection schedule
if req.ScheduleId != reconnectScheduleID {
log.Printf("Received callback for unknown schedule: %s", req.ScheduleId)
return &api.SchedulerCallbackResponse{}, nil
}
log.Printf("Attempting to reconnect to Coinbase WebSocket API...")
// Get the current ticker configuration
configResp, err := configService.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
if err != nil {
log.Printf("Failed to get plugin configuration: %v", err)
return &api.SchedulerCallbackResponse{Error: fmt.Sprintf("Config error: %v", err)}, nil
}
// Check if ticker configuration exists
tickerConfig, ok := configResp.Config["tickers"]
if !ok {
log.Printf("Missing 'tickers' configuration")
return &api.SchedulerCallbackResponse{Error: "Missing 'tickers' configuration"}, nil
}
// Parse ticker symbols
tickers := parseTickerSymbols(tickerConfig)
log.Printf("Reconnecting with tickers: %v", tickers)
// Try to connect and subscribe
err = connectAndSubscribe(ctx, tickers)
if err != nil {
log.Printf("Reconnection attempt failed: %v", err)
return &api.SchedulerCallbackResponse{Error: err.Error()}, nil
}
// Successfully reconnected, cancel the reconnection schedule
_, err = schedService.CancelSchedule(ctx, &scheduler.CancelRequest{
ScheduleId: reconnectScheduleID,
})
if err != nil {
log.Printf("Failed to cancel reconnection schedule: %v", err)
} else {
log.Printf("Reconnection schedule canceled after successful reconnection")
}
return &api.SchedulerCallbackResponse{}, nil
}
// Helper function to calculate percent change
func calculatePercentChange(open, current string) string {
var openFloat, currentFloat float64
_, err := fmt.Sscanf(open, "%f", &openFloat)
if err != nil {
return "N/A"
}
_, err = fmt.Sscanf(current, "%f", &currentFloat)
if err != nil {
return "N/A"
}
if openFloat == 0 {
return "N/A"
}
change := ((currentFloat - openFloat) / openFloat) * 100
return fmt.Sprintf("%.2f", change)
}
// Required by Go WASI build
func main() {}
func init() {
// Configure logging: No timestamps, no source file/line, prepend [Crypto]
log.SetFlags(0)
log.SetPrefix("[Crypto] ")
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
}

View File

@@ -0,0 +1,88 @@
# Discord Rich Presence Plugin
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time
connection to an external service while remaining completely stateless. This plugin is based on the
[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality.
**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the
Navidrome configuration file, which is not secure, and may be against Discord's terms of service.
Use it at your own risk.**
## Overview
The plugin exposes three capabilities:
- **Scrobbler** receives `NowPlaying` notifications from Navidrome
- **WebSocketCallback** handles Discord gateway messages
- **SchedulerCallback** used to clear presence and send periodic heartbeats
It relies on several host services declared in `manifest.json`:
- `http` queries Discord API endpoints
- `websocket` maintains gateway connections
- `scheduler` schedules heartbeats and presence cleanup
- `cache` stores sequence numbers for heartbeats
- `config` retrieves the plugin configuration on each call
- `artwork` resolves track artwork URLs
## Architecture
Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the
scheduler service:
```go
api.RegisterScrobbler(plugin)
api.RegisterWebSocketCallback(plugin.rpc)
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
```
When `NowPlaying` is invoked the plugin:
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
2. Connects to Discord using `WebSocketService` if no connection exists.
3. Sends the activity payload with track details and artwork.
4. Schedules a onetime callback to clear the presence after the track finishes.
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in
`CacheService` to remain available across plugin instances.
The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached.
```go
// The plugin is stateless, we need to load the configuration every time
clientID, users, err := d.getConfig(ctx)
```
## Configuration
Add the following to `navidrome.toml` and adjust for your tokens:
```toml
[PluginConfig.discord-rich-presence]
ClientID = "123456789012345678"
Users = "alice:token123,bob:token456"
```
- `clientid` is your Discord application ID
- `users` is a commaseparated list of `username:token` pairs used for authorization
## Building
```sh
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/...
```
Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins
directory.
## Stateless Operation
Navidrome plugins are completely stateless each method call instantiates a new plugin instance and discards it
afterwards.
To work within this model the plugin stores no in-memory state. Connections are keyed by user name inside the host
services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every
method call.
For more implementation details see `plugin.go` and `rpc.go`.

View File

@@ -0,0 +1,35 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
"name": "discord-rich-presence",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Discord Rich Presence integration for Navidrome",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
"capabilities": ["Scrobbler", "SchedulerCallback", "WebSocketCallback"],
"permissions": {
"http": {
"reason": "To communicate with Discord API for gateway discovery and image uploads",
"allowedUrls": {
"https://discord.com/api/*": ["GET", "POST"]
},
"allowLocalNetwork": false
},
"websocket": {
"reason": "To maintain real-time connection with Discord gateway",
"allowedUrls": ["wss://gateway.discord.gg"],
"allowLocalNetwork": false
},
"config": {
"reason": "To access plugin configuration (client ID and user tokens)"
},
"cache": {
"reason": "To store connection state and sequence numbers"
},
"scheduler": {
"reason": "To schedule heartbeat messages and activity clearing"
},
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
}
}

View File

@@ -0,0 +1,186 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/artwork"
"github.com/navidrome/navidrome/plugins/host/cache"
"github.com/navidrome/navidrome/plugins/host/config"
"github.com/navidrome/navidrome/plugins/host/http"
"github.com/navidrome/navidrome/plugins/host/scheduler"
"github.com/navidrome/navidrome/plugins/host/websocket"
"github.com/navidrome/navidrome/utils/slice"
)
type DiscordRPPlugin struct {
rpc *discordRPC
cfg config.ConfigService
artwork artwork.ArtworkService
sched scheduler.SchedulerService
}
func (d *DiscordRPPlugin) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) {
// Get plugin configuration
_, users, err := d.getConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to check user authorization: %w", err)
}
// Check if the user has a Discord token configured
_, authorized := users[req.Username]
log.Printf("IsAuthorized for user %s: %v", req.Username, authorized)
return &api.ScrobblerIsAuthorizedResponse{
Authorized: authorized,
}, nil
}
func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) {
log.Printf("Setting presence for user %s, track: %s", request.Username, request.Track.Name)
// The plugin is stateless, we need to load the configuration every time
clientID, users, err := d.getConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}
// Check if the user has a Discord token configured
userToken, authorized := users[request.Username]
if !authorized {
return nil, fmt.Errorf("user '%s' not authorized", request.Username)
}
// Make sure we have a connection
if err := d.rpc.connect(ctx, request.Username, userToken); err != nil {
return nil, fmt.Errorf("failed to connect to Discord: %w", err)
}
// Cancel any existing completion schedule
if resp, _ := d.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: request.Username}); resp.Error != "" {
log.Printf("Ignoring failure to cancel schedule: %s", resp.Error)
}
// Send activity update
if err := d.rpc.sendActivity(ctx, clientID, request.Username, userToken, activity{
Application: clientID,
Name: "Navidrome",
Type: 2,
Details: request.Track.Name,
State: d.getArtistList(request.Track),
Timestamps: activityTimestamps{
Start: (request.Timestamp - int64(request.Track.Position)) * 1000,
End: (request.Timestamp - int64(request.Track.Position) + int64(request.Track.Length)) * 1000,
},
Assets: activityAssets{
LargeImage: d.imageURL(ctx, request),
LargeText: request.Track.Album,
},
}); err != nil {
return nil, fmt.Errorf("failed to send activity: %w", err)
}
// Schedule a timer to clear the activity after the track completes
_, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{
ScheduleId: request.Username,
DelaySeconds: request.Track.Length - request.Track.Position + 5,
})
if err != nil {
return nil, fmt.Errorf("failed to schedule completion timer: %w", err)
}
return nil, nil
}
func (d *DiscordRPPlugin) imageURL(ctx context.Context, request *api.ScrobblerNowPlayingRequest) string {
imageResp, _ := d.artwork.GetTrackUrl(ctx, &artwork.GetArtworkUrlRequest{Id: request.Track.Id, Size: 300})
imageURL := imageResp.Url
if strings.HasPrefix(imageURL, "http://localhost") {
return ""
}
return imageURL
}
func (d *DiscordRPPlugin) getArtistList(track *api.TrackInfo) string {
return strings.Join(slice.Map(track.Artists, func(a *api.Artist) string { return a.Name }), " • ")
}
func (d *DiscordRPPlugin) Scrobble(context.Context, *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) {
return nil, nil
}
func (d *DiscordRPPlugin) getConfig(ctx context.Context) (string, map[string]string, error) {
const (
clientIDKey = "clientid"
usersKey = "users"
)
confResp, err := d.cfg.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
if err != nil {
return "", nil, fmt.Errorf("unable to load config: %w", err)
}
conf := confResp.GetConfig()
if len(conf) < 1 {
log.Print("missing configuration")
return "", nil, nil
}
clientID := conf[clientIDKey]
if clientID == "" {
log.Printf("missing ClientID: %v", conf)
return "", nil, nil
}
cfgUsers := conf[usersKey]
if len(cfgUsers) == 0 {
log.Print("no users configured")
return "", nil, nil
}
users := map[string]string{}
for _, user := range strings.Split(cfgUsers, ",") {
tuple := strings.Split(user, ":")
if len(tuple) != 2 {
return clientID, nil, fmt.Errorf("invalid user config: %s", user)
}
users[tuple[0]] = tuple[1]
}
return clientID, users, nil
}
func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
log.Printf("Removing presence for user %s", req.ScheduleId)
if err := d.rpc.clearActivity(ctx, req.ScheduleId); err != nil {
return nil, fmt.Errorf("failed to clear activity: %w", err)
}
log.Printf("Disconnecting user %s", req.ScheduleId)
if err := d.rpc.disconnect(ctx, req.ScheduleId); err != nil {
return nil, fmt.Errorf("failed to disconnect from Discord: %w", err)
}
return nil, nil
}
// Creates a new instance of the DiscordRPPlugin, with all host services as dependencies
var plugin = &DiscordRPPlugin{
cfg: config.NewConfigService(),
artwork: artwork.NewArtworkService(),
rpc: &discordRPC{
ws: websocket.NewWebSocketService(),
web: http.NewHttpService(),
mem: cache.NewCacheService(),
},
}
func init() {
// Configure logging: No timestamps, no source file/line, prepend [Discord]
log.SetFlags(0)
log.SetPrefix("[Discord] ")
// Register plugin capabilities
api.RegisterScrobbler(plugin)
api.RegisterWebSocketCallback(plugin.rpc)
// Register named scheduler callbacks, and get the scheduler service for each
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
}
func main() {}

View File

@@ -0,0 +1,402 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/cache"
"github.com/navidrome/navidrome/plugins/host/http"
"github.com/navidrome/navidrome/plugins/host/scheduler"
"github.com/navidrome/navidrome/plugins/host/websocket"
)
type discordRPC struct {
ws websocket.WebSocketService
web http.HttpService
mem cache.CacheService
sched scheduler.SchedulerService
}
// Discord WebSocket Gateway constants
const (
heartbeatOpCode = 1 // Heartbeat operation code
gateOpCode = 2 // Identify operation code
presenceOpCode = 3 // Presence update operation code
)
const (
heartbeatInterval = 41 // Heartbeat interval in seconds
defaultImage = "https://i.imgur.com/hb3XPzA.png"
)
// Activity is a struct that represents an activity in Discord.
type activity struct {
Name string `json:"name"`
Type int `json:"type"`
Details string `json:"details"`
State string `json:"state"`
Application string `json:"application_id"`
Timestamps activityTimestamps `json:"timestamps"`
Assets activityAssets `json:"assets"`
}
type activityTimestamps struct {
Start int64 `json:"start"`
End int64 `json:"end"`
}
type activityAssets struct {
LargeImage string `json:"large_image"`
LargeText string `json:"large_text"`
}
// PresencePayload is a struct that represents a presence update in Discord.
type presencePayload struct {
Activities []activity `json:"activities"`
Since int64 `json:"since"`
Status string `json:"status"`
Afk bool `json:"afk"`
}
// IdentifyPayload is a struct that represents an identify payload in Discord.
type identifyPayload struct {
Token string `json:"token"`
Intents int `json:"intents"`
Properties identifyProperties `json:"properties"`
}
type identifyProperties struct {
OS string `json:"os"`
Browser string `json:"browser"`
Device string `json:"device"`
}
func (r *discordRPC) processImage(ctx context.Context, imageURL string, clientID string, token string) (string, error) {
return r.processImageWithFallback(ctx, imageURL, clientID, token, false)
}
func (r *discordRPC) processImageWithFallback(ctx context.Context, imageURL string, clientID string, token string, isDefaultImage bool) (string, error) {
// Check if context is canceled
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("context canceled: %w", err)
}
if imageURL == "" {
if isDefaultImage {
// We're already processing the default image and it's empty, return error
return "", fmt.Errorf("default image URL is empty")
}
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
if strings.HasPrefix(imageURL, "mp:") {
return imageURL, nil
}
// Check cache first
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
cacheResp, _ := r.mem.GetString(ctx, &cache.GetRequest{Key: cacheKey})
if cacheResp.Exists {
log.Printf("Cache hit for image URL: %s", imageURL)
return cacheResp.Value, nil
}
resp, _ := r.web.Post(ctx, &http.HttpRequest{
Url: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
Headers: map[string]string{
"Authorization": token,
"Content-Type": "application/json",
},
Body: fmt.Appendf(nil, `{"urls":[%q]}`, imageURL),
})
// Handle HTTP error responses
if resp.Status >= 400 {
if isDefaultImage {
return "", fmt.Errorf("failed to process default image: HTTP %d %s", resp.Status, resp.Error)
}
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
if resp.Error != "" {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("failed to process default image: %s", resp.Error)
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
var data []map[string]string
if err := json.Unmarshal(resp.Body, &data); err != nil {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
if len(data) == 0 {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("no data returned for default image")
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
image := data[0]["external_asset_path"]
if image == "" {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("empty external_asset_path for default image")
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
processedImage := fmt.Sprintf("mp:%s", image)
// Cache the processed image URL
var ttl = 4 * time.Hour // 4 hours for regular images
if isDefaultImage {
ttl = 48 * time.Hour // 48 hours for default image
}
_, _ = r.mem.SetString(ctx, &cache.SetStringRequest{
Key: cacheKey,
Value: processedImage,
TtlSeconds: int64(ttl.Seconds()),
})
log.Printf("Cached processed image URL for %s (TTL: %s seconds)", imageURL, ttl)
return processedImage, nil
}
func (r *discordRPC) sendActivity(ctx context.Context, clientID, username, token string, data activity) error {
log.Printf("Sending activity to for user %s: %#v", username, data)
processedImage, err := r.processImage(ctx, data.Assets.LargeImage, clientID, token)
if err != nil {
log.Printf("Failed to process image for user %s, continuing without image: %v", username, err)
// Clear the image and continue without it
data.Assets.LargeImage = ""
} else {
log.Printf("Processed image for URL %s: %s", data.Assets.LargeImage, processedImage)
data.Assets.LargeImage = processedImage
}
presence := presencePayload{
Activities: []activity{data},
Status: "dnd",
Afk: false,
}
return r.sendMessage(ctx, username, presenceOpCode, presence)
}
func (r *discordRPC) clearActivity(ctx context.Context, username string) error {
log.Printf("Clearing activity for user %s", username)
return r.sendMessage(ctx, username, presenceOpCode, presencePayload{})
}
func (r *discordRPC) sendMessage(ctx context.Context, username string, opCode int, payload any) error {
message := map[string]any{
"op": opCode,
"d": payload,
}
b, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal presence update: %w", err)
}
resp, _ := r.ws.SendText(ctx, &websocket.SendTextRequest{
ConnectionId: username,
Message: string(b),
})
if resp.Error != "" {
return fmt.Errorf("failed to send presence update: %s", resp.Error)
}
return nil
}
func (r *discordRPC) getDiscordGateway(ctx context.Context) (string, error) {
resp, _ := r.web.Get(ctx, &http.HttpRequest{
Url: "https://discord.com/api/gateway",
})
if resp.Error != "" {
return "", fmt.Errorf("failed to get Discord gateway: %s", resp.Error)
}
var result map[string]string
err := json.Unmarshal(resp.Body, &result)
if err != nil {
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
}
return result["url"], nil
}
func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error {
resp, _ := r.mem.GetInt(ctx, &cache.GetRequest{
Key: fmt.Sprintf("discord.seq.%s", username),
})
log.Printf("Sending heartbeat for user %s: %d", username, resp.Value)
return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value)
}
func (r *discordRPC) cleanupFailedConnection(ctx context.Context, username string) {
log.Printf("Cleaning up failed connection for user %s", username)
// Cancel the heartbeat schedule
if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
log.Printf("Failed to cancel heartbeat schedule for user %s: %s", username, resp.Error)
}
// Close the WebSocket connection
if resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
ConnectionId: username,
Code: 1000,
Reason: "Connection lost",
}); resp.Error != "" {
log.Printf("Failed to close WebSocket connection for user %s: %s", username, resp.Error)
}
// Clean up cache entries (just the sequence number, no failure tracking needed)
_, _ = r.mem.Remove(ctx, &cache.RemoveRequest{Key: fmt.Sprintf("discord.seq.%s", username)})
log.Printf("Cleaned up connection for user %s", username)
}
func (r *discordRPC) isConnected(ctx context.Context, username string) bool {
// Try to send a heartbeat to test the connection
err := r.sendHeartbeat(ctx, username)
if err != nil {
log.Printf("Heartbeat test failed for user %s: %v", username, err)
return false
}
return true
}
func (r *discordRPC) connect(ctx context.Context, username string, token string) error {
if r.isConnected(ctx, username) {
log.Printf("Reusing existing connection for user %s", username)
return nil
}
log.Printf("Creating new connection for user %s", username)
// Get Discord Gateway URL
gateway, err := r.getDiscordGateway(ctx)
if err != nil {
return fmt.Errorf("failed to get Discord gateway: %w", err)
}
log.Printf("Using gateway: %s", gateway)
// Connect to Discord Gateway
resp, _ := r.ws.Connect(ctx, &websocket.ConnectRequest{
ConnectionId: username,
Url: gateway,
})
if resp.Error != "" {
return fmt.Errorf("failed to connect to WebSocket: %s", resp.Error)
}
// Send identify payload
payload := identifyPayload{
Token: token,
Intents: 0,
Properties: identifyProperties{
OS: "Windows 10",
Browser: "Discord Client",
Device: "Discord Client",
},
}
err = r.sendMessage(ctx, username, gateOpCode, payload)
if err != nil {
return fmt.Errorf("failed to send identify payload: %w", err)
}
// Schedule heartbeats for this user/connection
cronResp, _ := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
CronExpression: fmt.Sprintf("@every %ds", heartbeatInterval),
ScheduleId: username,
})
log.Printf("Scheduled heartbeat for user %s with ID %s", username, cronResp.ScheduleId)
log.Printf("Successfully authenticated user %s", username)
return nil
}
func (r *discordRPC) disconnect(ctx context.Context, username string) error {
if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
return fmt.Errorf("failed to cancel schedule: %s", resp.Error)
}
resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
ConnectionId: username,
Code: 1000,
Reason: "Navidrome disconnect",
})
if resp.Error != "" {
return fmt.Errorf("failed to close WebSocket connection: %s", resp.Error)
}
return nil
}
func (r *discordRPC) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) {
if len(req.Message) < 1024 {
log.Printf("Received WebSocket message for connection '%s': %s", req.ConnectionId, req.Message)
} else {
log.Printf("Received WebSocket message for connection '%s' (truncated): %s...", req.ConnectionId, req.Message[:1021])
}
// Parse the message. If it's a heartbeat_ack, store the sequence number.
message := map[string]any{}
err := json.Unmarshal([]byte(req.Message), &message)
if err != nil {
return nil, fmt.Errorf("failed to parse WebSocket message: %w", err)
}
if v := message["s"]; v != nil {
seq := int64(v.(float64))
log.Printf("Received heartbeat_ack for connection '%s': %d", req.ConnectionId, seq)
resp, _ := r.mem.SetInt(ctx, &cache.SetIntRequest{
Key: fmt.Sprintf("discord.seq.%s", req.ConnectionId),
Value: seq,
TtlSeconds: heartbeatInterval * 2,
})
if !resp.Success {
return nil, fmt.Errorf("failed to store sequence number for user %s", req.ConnectionId)
}
}
return nil, nil
}
func (r *discordRPC) OnBinaryMessage(_ context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) {
log.Printf("Received unexpected binary message for connection '%s'", req.ConnectionId)
return nil, nil
}
func (r *discordRPC) OnError(_ context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) {
log.Printf("WebSocket error for connection '%s': %s", req.ConnectionId, req.Error)
return nil, nil
}
func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) {
log.Printf("WebSocket connection '%s' closed with code %d: %s", req.ConnectionId, req.Code, req.Reason)
return nil, nil
}
func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
err := r.sendHeartbeat(ctx, req.ScheduleId)
if err != nil {
// On first heartbeat failure, immediately clean up the connection
// The next NowPlaying call will reconnect if needed
log.Printf("Heartbeat failed for user %s, cleaning up connection: %v", req.ScheduleId, err)
r.cleanupFailedConnection(ctx, req.ScheduleId)
return nil, fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
}
return nil, nil
}

View File

@@ -0,0 +1,88 @@
# SubsonicAPI Demo Plugin
This example plugin demonstrates how to use the SubsonicAPI host service to access Navidrome's Subsonic API from within a plugin.
## What it does
The plugin performs the following operations during initialization:
1. **Ping the server**: Calls `/rest/ping` to check if the Subsonic API is responding
2. **Get license info**: Calls `/rest/getLicense` to retrieve server license information
## Key Features
- Shows how to request `subsonicapi` permission in the manifest
- Demonstrates making Subsonic API calls using the `subsonicapi.Call()` method
- Handles both successful responses and errors
- Uses proper lifecycle management with `OnInit`
## Usage
### Manifest Configuration
```json
{
"permissions": {
"subsonicapi": {
"reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins",
"allowAdmins": true
}
}
}
```
### Plugin Implementation
```go
import "github.com/navidrome/navidrome/plugins/host/subsonicapi"
var subsonicService = subsonicapi.NewSubsonicAPIService()
// OnInit is called when the plugin is loaded
func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
// Make API calls
response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
Url: "/rest/ping?u=admin",
})
// Handle response...
}
```
When running Navidrome with this plugin installed, it will automatically call the Subsonic API endpoints during the
server startup, and you can see the results in the logs:
```agsl
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI Demo Plugin initializing...
DEBU[0000] API: New request /ping client=subsonicapi-demo username=admin version=1.16.1
DEBU[0000] API: Successful response endpoint=/ping status=OK
DEBU[0000] API: New request /getLicense client=subsonicapi-demo username=admin version=1.16.1
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI ping response: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true}}
DEBU[0000] API: Successful response endpoint=/getLicense status=OK
DEBU[0000] Plugin initialized successfully elapsed=41.9ms plugin=subsonicapi-demo
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI license info: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true,"license":{"valid":true}}}
```
## Important Notes
1. **Authentication**: The plugin must provide valid authentication parameters in the URL:
- **Required**: `u` (username) - The service validates this parameter is present
- Example: `"/rest/ping?u=admin"`
2. **URL Format**: Only the path and query parameters from the URL are used - host, protocol, and method are ignored
3. **Automatic Parameters**: The service automatically adds:
- `c`: Plugin name (client identifier)
- `v`: Subsonic API version (1.16.1)
- `f`: Response format (json)
4. **Internal Authentication**: The service sets up internal authentication using the `u` parameter
5. **Lifecycle**: This plugin uses `LifecycleManagement` with only the `OnInit` method
## Building
This plugin uses the `wasip1` build constraint and must be compiled for WebAssembly:
```bash
# Using the project's make target (recommended)
make plugin-examples
# Manual compilation (when using the proper toolchain)
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
```

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
"name": "subsonicapi-demo",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Example plugin demonstrating SubsonicAPI host service usage",
"website": "https://github.com/navidrome/navidrome",
"capabilities": ["LifecycleManagement"],
"permissions": {
"subsonicapi": {
"reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins",
"allowAdmins": true,
"allowedUsernames": ["admin"]
}
}
}

View File

@@ -0,0 +1,68 @@
//go:build wasip1
package main
import (
"context"
"log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
)
// SubsonicAPIService instance for making API calls
var subsonicService = subsonicapi.NewSubsonicAPIService()
// SubsonicAPIDemoPlugin implements LifecycleManagement interface
type SubsonicAPIDemoPlugin struct{}
// OnInit is called when the plugin is loaded
func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
log.Printf("SubsonicAPI Demo Plugin initializing...")
// Example: Call the ping endpoint to check if the server is alive
response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
Url: "/rest/ping?u=admin",
})
if err != nil {
log.Printf("SubsonicAPI call failed: %v", err)
return &api.InitResponse{Error: err.Error()}, nil
}
if response.Error != "" {
log.Printf("SubsonicAPI returned error: %s", response.Error)
return &api.InitResponse{Error: response.Error}, nil
}
log.Printf("SubsonicAPI ping response: %s", response.Json)
// Example: Get server info
infoResponse, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
Url: "/rest/getLicense?u=admin",
})
if err != nil {
log.Printf("SubsonicAPI getLicense call failed: %v", err)
return &api.InitResponse{Error: err.Error()}, nil
}
if infoResponse.Error != "" {
log.Printf("SubsonicAPI getLicense returned error: %s", infoResponse.Error)
return &api.InitResponse{Error: infoResponse.Error}, nil
}
log.Printf("SubsonicAPI license info: %s", infoResponse.Json)
return &api.InitResponse{}, nil
}
func main() {}
func init() {
// Configure logging: No timestamps, no source file/line
log.SetFlags(0)
log.SetPrefix("[Subsonic Plugin] ")
api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{})
}

View File

@@ -0,0 +1,32 @@
# Wikimedia Artist Metadata Plugin
This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint.
## Implemented Methods
- `GetArtistBiography`: Returns the artist's English biography/description from Wikidata.
- `GetArtistURL`: Returns the artist's official website (if available) from Wikidata.
- `GetArtistImages`: Returns the artist's main image (Wikimedia Commons) from Wikidata.
All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia.
## How it Works
- The plugin uses the host-provided HTTP service (`HttpService`) to make SPARQL queries to the Wikidata endpoint.
- No network requests are made directly from the plugin; all HTTP is routed through the host.
## Building
To build the plugin to WASM:
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
```
## Usage
Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory.
---
For more details, see the source code in `plugin.go`.

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
"name": "wikimedia",
"author": "Navidrome",
"version": "1.0.0",
"description": "Artist information and images from Wikimedia Commons",
"website": "https://commons.wikimedia.org",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch artist information and images from Wikimedia Commons API",
"allowedUrls": {
"https://*.wikimedia.org": ["GET"],
"https://*.wikipedia.org": ["GET"],
"https://commons.wikimedia.org": ["GET"]
},
"allowLocalNetwork": false
}
}
}

View File

@@ -0,0 +1,391 @@
//go:build wasip1
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"strings"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/http"
)
const (
wikidataEndpoint = "https://query.wikidata.org/sparql"
dbpediaEndpoint = "https://dbpedia.org/sparql"
mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
requestTimeoutMs = 5000
)
var (
ErrNotFound = api.ErrNotFound
ErrNotImplemented = api.ErrNotImplemented
client = http.NewHttpService()
)
// SPARQLResult struct for all possible fields
// Only the needed field will be non-nil in each context
// (Sitelink, Wiki, Comment, Img)
type SPARQLResult struct {
Results struct {
Bindings []struct {
Sitelink *struct{ Value string } `json:"sitelink,omitempty"`
Wiki *struct{ Value string } `json:"wiki,omitempty"`
Comment *struct{ Value string } `json:"comment,omitempty"`
Img *struct{ Value string } `json:"img,omitempty"`
} `json:"bindings"`
} `json:"results"`
}
// MediaWikiExtractResult is used to unmarshal MediaWiki API extract responses
// (for getWikipediaExtract)
type MediaWikiExtractResult struct {
Query struct {
Pages map[string]struct {
PageID int `json:"pageid"`
Ns int `json:"ns"`
Title string `json:"title"`
Extract string `json:"extract"`
Missing bool `json:"missing"`
} `json:"pages"`
} `json:"query"`
}
// --- SPARQL Query Helper ---
func sparqlQuery(ctx context.Context, client http.HttpService, endpoint, query string) (*SPARQLResult, error) {
form := url.Values{}
form.Set("query", query)
req := &http.HttpRequest{
Url: endpoint,
Headers: map[string]string{
"Accept": "application/sparql-results+json",
"Content-Type": "application/x-www-form-urlencoded", // Required by SPARQL endpoints
"User-Agent": "NavidromeWikimediaPlugin/0.1",
},
Body: []byte(form.Encode()), // Send encoded form data
TimeoutMs: requestTimeoutMs,
}
log.Printf("[Wikimedia Query] Attempting SPARQL query to %s (query length: %d):\n%s", endpoint, len(query), query)
resp, err := client.Post(ctx, req)
if err != nil {
return nil, fmt.Errorf("SPARQL request error: %w", err)
}
if resp.Status != 200 {
log.Printf("[Wikimedia Query] SPARQL HTTP error %d for query to %s. Body: %s", resp.Status, endpoint, string(resp.Body))
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status)
}
var result SPARQLResult
if err := json.Unmarshal(resp.Body, &result); err != nil {
return nil, fmt.Errorf("failed to parse SPARQL response: %w", err)
}
if len(result.Results.Bindings) == 0 {
return nil, ErrNotFound
}
return &result, nil
}
// --- MediaWiki API Helper ---
func mediawikiQuery(ctx context.Context, client http.HttpService, params url.Values) ([]byte, error) {
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
req := &http.HttpRequest{
Url: apiURL,
Headers: map[string]string{
"Accept": "application/json",
"User-Agent": "NavidromeWikimediaPlugin/0.1",
},
TimeoutMs: requestTimeoutMs,
}
resp, err := client.Get(ctx, req)
if err != nil {
return nil, fmt.Errorf("MediaWiki request error: %w", err)
}
if resp.Status != 200 {
return nil, fmt.Errorf("MediaWiki HTTP error: status %d, body: %s", resp.Status, string(resp.Body))
}
return resp.Body, nil
}
// --- Wikidata Fetch Functions ---
func getWikidataWikipediaURL(ctx context.Context, client http.HttpService, mbid, name string) (string, error) {
var q string
if mbid != "" {
// Using property chain: ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>.
q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist wdt:P434 "%s". ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>. } LIMIT 1`, mbid)
} else if name != "" {
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
// Using property chain: ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>.
q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist rdfs:label "%s"@en. ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>. } LIMIT 1`, escapedName)
} else {
return "", errors.New("MBID or Name required for Wikidata URL lookup")
}
result, err := sparqlQuery(ctx, client, wikidataEndpoint, q)
if err != nil {
return "", fmt.Errorf("Wikidata SPARQL query failed: %w", err)
}
if result.Results.Bindings[0].Sitelink != nil {
return result.Results.Bindings[0].Sitelink.Value, nil
}
return "", ErrNotFound
}
// --- DBpedia Fetch Functions ---
func getDBpediaWikipediaURL(ctx context.Context, client http.HttpService, name string) (string, error) {
if name == "" {
return "", ErrNotFound
}
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
q := fmt.Sprintf(`SELECT ?wiki WHERE { ?artist foaf:name "%s"@en; foaf:isPrimaryTopicOf ?wiki. FILTER regex(str(?wiki), "^https://en.wikipedia.org/") } LIMIT 1`, escapedName)
result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q)
if err != nil {
return "", fmt.Errorf("DBpedia SPARQL query failed: %w", err)
}
if result.Results.Bindings[0].Wiki != nil {
return result.Results.Bindings[0].Wiki.Value, nil
}
return "", ErrNotFound
}
func getDBpediaComment(ctx context.Context, client http.HttpService, name string) (string, error) {
if name == "" {
return "", ErrNotFound
}
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
q := fmt.Sprintf(`SELECT ?comment WHERE { ?artist foaf:name "%s"@en; rdfs:comment ?comment. FILTER (lang(?comment) = 'en') } LIMIT 1`, escapedName)
result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q)
if err != nil {
return "", fmt.Errorf("DBpedia comment SPARQL query failed: %w", err)
}
if result.Results.Bindings[0].Comment != nil {
return result.Results.Bindings[0].Comment.Value, nil
}
return "", ErrNotFound
}
// --- Wikipedia API Fetch Function ---
func getWikipediaExtract(ctx context.Context, client http.HttpService, pageTitle string) (string, error) {
if pageTitle == "" {
return "", errors.New("page title required for Wikipedia API lookup")
}
params := url.Values{}
params.Set("action", "query")
params.Set("format", "json")
params.Set("prop", "extracts")
params.Set("exintro", "true") // Intro section only
params.Set("explaintext", "true") // Plain text
params.Set("titles", pageTitle)
params.Set("redirects", "1") // Follow redirects
body, err := mediawikiQuery(ctx, client, params)
if err != nil {
return "", fmt.Errorf("MediaWiki query failed: %w", err)
}
var result MediaWikiExtractResult
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse MediaWiki response: %w", err)
}
// Iterate through the pages map (usually only one page)
for _, page := range result.Query.Pages {
if page.Missing {
continue // Skip missing pages
}
if page.Extract != "" {
return strings.TrimSpace(page.Extract), nil
}
}
return "", ErrNotFound
}
// --- Helper to get Wikipedia Page Title from URL ---
func extractPageTitleFromURL(wikiURL string) (string, error) {
parsedURL, err := url.Parse(wikiURL)
if err != nil {
return "", err
}
if parsedURL.Host != "en.wikipedia.org" {
return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host)
}
pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/")
if len(pathParts) < 2 || pathParts[0] != "wiki" {
return "", fmt.Errorf("URL path does not match /wiki/<title> format: %s", parsedURL.Path)
}
title := pathParts[1]
if title == "" {
return "", errors.New("extracted title is empty")
}
decodedTitle, err := url.PathUnescape(title)
if err != nil {
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
}
return decodedTitle, nil
}
// --- Agent Implementation ---
type WikimediaAgent struct{}
// GetArtistURL fetches the Wikipedia URL.
// Order: Wikidata(MBID/Name) -> DBpedia(Name) -> Search URL
func (WikimediaAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) {
var wikiURL string
var err error
// 1. Try Wikidata (MBID first, then name)
wikiURL, err = getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name)
if err == nil && wikiURL != "" {
return &api.ArtistURLResponse{Url: wikiURL}, nil
}
if err != nil && err != ErrNotFound {
log.Printf("[Wikimedia] Error fetching Wikidata URL: %v\n", err)
// Don't stop, try DBpedia
}
// 2. Try DBpedia (Name only)
if req.Name != "" {
wikiURL, err = getDBpediaWikipediaURL(ctx, client, req.Name)
if err == nil && wikiURL != "" {
return &api.ArtistURLResponse{Url: wikiURL}, nil
}
if err != nil && err != ErrNotFound {
log.Printf("[Wikimedia] Error fetching DBpedia URL: %v\n", err)
// Don't stop, generate search URL
}
}
// 3. Fallback to search URL
if req.Name != "" {
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(req.Name))
log.Printf("[Wikimedia] URL not found, falling back to search URL: %s\n", searchURL)
return &api.ArtistURLResponse{Url: searchURL}, nil
}
log.Printf("[Wikimedia] Could not determine Wikipedia URL for: %s (%s)\n", req.Name, req.Mbid)
return nil, ErrNotFound
}
// GetArtistBiography fetches the long biography.
// Order: Wikipedia API (via Wikidata/DBpedia URL) -> DBpedia Comment (Name)
func (WikimediaAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) {
var bio string
var err error
log.Printf("[Wikimedia Bio] Fetching for Name: %s, MBID: %s", req.Name, req.Mbid)
// 1. Get Wikipedia URL (using the logic from GetArtistURL)
wikiURL := ""
// Try Wikidata first
tempURL, wdErr := getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name)
if wdErr == nil && tempURL != "" {
log.Printf("[Wikimedia Bio] Found Wikidata URL: %s", tempURL)
wikiURL = tempURL
} else if req.Name != "" {
// Try DBpedia if Wikidata failed or returned not found
log.Printf("[Wikimedia Bio] Wikidata URL failed (%v), trying DBpedia URL", wdErr)
tempURL, dbErr := getDBpediaWikipediaURL(ctx, client, req.Name)
if dbErr == nil && tempURL != "" {
log.Printf("[Wikimedia Bio] Found DBpedia URL: %s", tempURL)
wikiURL = tempURL
} else {
log.Printf("[Wikimedia Bio] DBpedia URL failed (%v)", dbErr)
}
}
// 2. If Wikipedia URL found, try MediaWiki API
if wikiURL != "" {
pageTitle, err := extractPageTitleFromURL(wikiURL)
if err == nil {
log.Printf("[Wikimedia Bio] Extracted page title: %s", pageTitle)
bio, err = getWikipediaExtract(ctx, client, pageTitle)
if err == nil && bio != "" {
log.Printf("[Wikimedia Bio] Found Wikipedia extract.")
return &api.ArtistBiographyResponse{Biography: bio}, nil
}
log.Printf("[Wikimedia Bio] Wikipedia extract failed: %v", err)
if err != nil && err != ErrNotFound {
log.Printf("[Wikimedia Bio] Error fetching Wikipedia extract for '%s': %v", pageTitle, err)
// Don't stop, try DBpedia comment
}
} else {
log.Printf("[Wikimedia Bio] Error extracting page title from URL '%s': %v", wikiURL, err)
// Don't stop, try DBpedia comment
}
}
// 3. Fallback to DBpedia Comment (Name only)
if req.Name != "" {
log.Printf("[Wikimedia Bio] Falling back to DBpedia comment for name: %s", req.Name)
bio, err = getDBpediaComment(ctx, client, req.Name)
if err == nil && bio != "" {
log.Printf("[Wikimedia Bio] Found DBpedia comment.")
return &api.ArtistBiographyResponse{Biography: bio}, nil
}
log.Printf("[Wikimedia Bio] DBpedia comment failed: %v", err)
if err != nil && err != ErrNotFound {
log.Printf("[Wikimedia Bio] Error fetching DBpedia comment for '%s': %v", req.Name, err)
}
}
log.Printf("[Wikimedia Bio] Final: Biography not found for: %s (%s)", req.Name, req.Mbid)
return nil, ErrNotFound
}
// GetArtistImages fetches images (Wikidata only for now)
func (WikimediaAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) {
var q string
if req.Mbid != "" {
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, req.Mbid)
} else if req.Name != "" {
escapedName := strings.ReplaceAll(req.Name, "\"", "\\\"")
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist rdfs:label "%s"@en; wdt:P18 ?img } LIMIT 1`, escapedName)
} else {
return nil, errors.New("MBID or Name required for Wikidata Image lookup")
}
result, err := sparqlQuery(ctx, client, wikidataEndpoint, q)
if err != nil {
log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid)
return nil, ErrNotFound
}
if result.Results.Bindings[0].Img != nil {
return &api.ArtistImageResponse{Images: []*api.ExternalImage{{Url: result.Results.Bindings[0].Img.Value, Size: 0}}}, nil
}
log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid)
return nil, ErrNotFound
}
// Not implemented methods
func (WikimediaAgent) GetArtistMBID(context.Context, *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) {
return nil, ErrNotImplemented
}
func (WikimediaAgent) GetSimilarArtists(context.Context, *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) {
return nil, ErrNotImplemented
}
func (WikimediaAgent) GetArtistTopSongs(context.Context, *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) {
return nil, ErrNotImplemented
}
func (WikimediaAgent) GetAlbumInfo(context.Context, *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) {
return nil, ErrNotImplemented
}
func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) {
return nil, ErrNotImplemented
}
func main() {}
func init() {
// Configure logging: No timestamps, no source file/line
log.SetFlags(0)
log.SetPrefix("[Wikimedia] ")
api.RegisterMetadataAgent(WikimediaAgent{})
}