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
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:
27
plugins/examples/Makefile
Normal file
27
plugins/examples/Makefile
Normal 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
|
||||
31
plugins/examples/README.md
Normal file
31
plugins/examples/README.md
Normal 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.
|
||||
34
plugins/examples/coverartarchive/README.md
Normal file
34
plugins/examples/coverartarchive/README.md
Normal 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}`
|
||||
19
plugins/examples/coverartarchive/manifest.json
Normal file
19
plugins/examples/coverartarchive/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
151
plugins/examples/coverartarchive/plugin.go
Normal file
151
plugins/examples/coverartarchive/plugin.go
Normal 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{})
|
||||
}
|
||||
53
plugins/examples/crypto-ticker/README.md
Normal file
53
plugins/examples/crypto-ticker/README.md
Normal 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`.
|
||||
25
plugins/examples/crypto-ticker/manifest.json
Normal file
25
plugins/examples/crypto-ticker/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
304
plugins/examples/crypto-ticker/plugin.go
Normal file
304
plugins/examples/crypto-ticker/plugin.go
Normal 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", ¤tFloat)
|
||||
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{})
|
||||
}
|
||||
88
plugins/examples/discord-rich-presence/README.md
Normal file
88
plugins/examples/discord-rich-presence/README.md
Normal 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 one‑time 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 comma‑separated 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`.
|
||||
35
plugins/examples/discord-rich-presence/manifest.json
Normal file
35
plugins/examples/discord-rich-presence/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
186
plugins/examples/discord-rich-presence/plugin.go
Normal file
186
plugins/examples/discord-rich-presence/plugin.go
Normal 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() {}
|
||||
402
plugins/examples/discord-rich-presence/rpc.go
Normal file
402
plugins/examples/discord-rich-presence/rpc.go
Normal 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
|
||||
}
|
||||
88
plugins/examples/subsonicapi-demo/README.md
Normal file
88
plugins/examples/subsonicapi-demo/README.md
Normal 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
|
||||
```
|
||||
16
plugins/examples/subsonicapi-demo/manifest.json
Normal file
16
plugins/examples/subsonicapi-demo/manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
68
plugins/examples/subsonicapi-demo/plugin.go
Normal file
68
plugins/examples/subsonicapi-demo/plugin.go
Normal 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{})
|
||||
}
|
||||
32
plugins/examples/wikimedia/README.md
Normal file
32
plugins/examples/wikimedia/README.md
Normal 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`.
|
||||
20
plugins/examples/wikimedia/manifest.json
Normal file
20
plugins/examples/wikimedia/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
391
plugins/examples/wikimedia/plugin.go
Normal file
391
plugins/examples/wikimedia/plugin.go
Normal 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{})
|
||||
}
|
||||
Reference in New Issue
Block a user