feat(plugins): New Plugin System with multi-language PDK support (#4833)

* chore(plugins): remove the old plugins system implementation

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

* feat(plugins): implement new plugin system with using Extism

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

* feat(plugins): add capability detection for plugins based on exported functions

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

* feat(plugins): add auto-reload functionality for plugins with file watcher support

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

* feat(plugins): add auto-reload functionality for plugins with file watcher support

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

* refactor(plugins): standardize variable names and remove superfluous wrapper functions

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

* fix(plugins): improve error handling and logging in plugin manager

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

* refactor(plugins): implement plugin function call helper and refactor MetadataAgent methods

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

* fix(plugins): race condition in plugin manager

* tests(plugins): change BeforeEach to BeforeAll in MetadataAgent tests

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

* tests(plugins): optimize tests

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

* tests(plugins): more optimizations

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

* test(plugins): ignore goroutine leaks from notify library in tests

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

* feat(plugins): add Wikimedia plugin for Navidrome to fetch artist metadata

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

* feat(plugins): enhance plugin logging and set User-Agent header

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

* feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities

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

* feat(plugins): integrate logs

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

* refactor(plugins): clean up manifest struct and improve plugin loading logic

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

* feat(plugins): add metadata agent and scrobbler schemas for bootstrapping plugins

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

* feat(hostgen): add hostgen tool for generating Extism host function wrappers

- Implemented hostgen tool to generate wrappers from annotated Go interfaces.
- Added command-line flags for input/output directories and package name.
- Introduced parsing and code generation logic for host services.
- Created test data for various service interfaces and expected generated code.
- Added documentation for host services and annotations for code generation.
- Implemented SubsonicAPI service with corresponding generated code.

* feat(subsonicapi): update Call method to return JSON string response

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

* feat(plugins): implement SubsonicAPI host function integration with permissions

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

* fix(generator): error-only methods in response handling

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

* feat(plugins): generate client wrappers for host functions

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

* refactor(generator): remove error handling for response.Error in client templates

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

* feat(scheduler): add Scheduler service interface with host function wrappers for scheduling tasks

* feat(plugins): add WASI build constraints to client wrapper templates, to avoid lint errors

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

* feat(scheduler): implement Scheduler service with one-time and recurring scheduling capabilities

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

* refactor(manifest): remove unused ConfigPermission from permissions schema

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

* feat(scheduler): add scheduler callback schema and implementation for plugins

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

* refactor(scheduler): streamline scheduling logic and remove unused callback tracking

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

* refactor(scheduler): add Close method for resource cleanup on plugin unload

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

* docs(scheduler): clarify SchedulerCallback requirement for scheduling functions

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

* fix: update wasm build rule to include all Go files in the directory

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

* feat: rewrite the wikimedia plugin using the XTP CLI

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

* refactor(scheduler): replace uuid with id.NewRandom for schedule ID generation

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

* refactor: capabilities registration

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

* test: add scheduler service isolation test for plugin instances

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

* refactor: update plugin manager initialization and encapsulate logic

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

* feat: add WebSocket service definitions for plugin communication

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

* feat: implement WebSocket service for plugin integration and connection management

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

* feat: add Crypto Ticker example plugin for real-time cryptocurrency price updates via Coinbase WebSocket API

Also add the lifecycle capability

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

* fix: use context.Background() in invokeCallback for scheduled tasks

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

* refactor: rename plugin.create() to plugin.instance()

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

* refactor: rename pluginInstance to plugin for consistency across the codebase

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

* refactor: simplify schedule cloning in Close method and enhance plugin cleanup error handling

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

* feat: implement Artwork service for generating artwork URLs in Navidrome plugins - WIP

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

* refactor: moved public URL builders to avoid import cycles

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

* feat: add Cache service for in-memory TTL-based caching in plugins

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

* feat: add Discord Rich Presence example plugin for Navidrome integration

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

* refactor: host function wrappers to use structured request and response types

- Updated the host function signatures in `nd_host_artwork.go`, `nd_host_scheduler.go`, `nd_host_subsonicapi.go`, and `nd_host_websocket.go` to accept a single parameter for JSON requests.
- Introduced structured request and response types for various cache operations in `nd_host_cache.go`.
- Modified cache functions to marshal requests to JSON and unmarshal responses, improving error handling and code clarity.
- Removed redundant memory allocation for string parameters in favor of JSON marshaling.
- Enhanced error handling in WebSocket and cache operations to return structured error responses.

* refactor: error handling in various plugins to convert response.Error to Go errors

- Updated error handling in `nd_host_scheduler.go`, `nd_host_websocket.go`, `nd_host_artwork.go`, `nd_host_cache.go`, and `nd_host_subsonicapi.go` to convert string errors from responses into Go errors.
- Removed redundant error checks in test data plugins for cleaner code.
- Ensured consistent error handling across all plugins to improve reliability and maintainability.

* refactor: rename fake plugins to test plugins for clarity in integration tests

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

* feat: add help target to Makefile for plugin usage instructions

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

* feat: add Cover Art Archive plugin as an example of Python plugin

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

* feat: update Makefile and README to clarify Go plugin usage

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

* feat: include plugin capabilities in loading log message

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

* feat: add trace logging for plugin availability and error handling in agents

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

* feat: add Now Playing Logger plugin to showcase calling host functions from Python plugins

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

* feat: generate Python client wrappers for various host services

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

* feat: add generated host function wrappers for Scheduler and SubsonicAPI services

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

* feat: update Python plugin documentation and usage instructions for host function wrappers

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

* feat: add Webhook Scrobbler plugin in Rust to send HTTP notifications on scrobble events

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

* feat: enable parallel loading of plugins during startup

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

* docs: update README to include WebSocket callback schema in plugin documentation

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

* feat: extend plugin watcher with improved logging and debounce duration adjustment

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

* add trace message for plugin recompiles

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

* feat: implement plugin cache purging functionality

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

* test: move purgeCacheBySize unit tests

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

* feat(plugins UI): add plugin repository and database support

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

* feat(plugins UI): add plugin management routes and middleware

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

* feat(plugins UI): implement plugin synchronization with database for add, update, and remove actions

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

* feat(plugins UI): add PluginList and PluginShow components with plugin management functionality

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

* feat(plugins): optimize plugin change detection

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

* refactor(plugins UI): improve PluginList structure

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

* feat(plugins UI): enhance PluginShow with author, website, and permissions display

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

* feat(plugins UI): refactor to use MUI and RA components

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

* feat(plugins UI): add error handling for plugin enable/disable actions

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

* refactor(plugins): inject PluginManager into native API

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

* refactor(plugins): update GetManager to accept DataStore parameter

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

* feat(plugins): add subsonicRouter to Manager and refactor host service registration

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

* refactor(plugins): enhance debug logging for plugin actions and recompile logic

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

* refactor(plugins): break manager.go into smaller, focused files

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

* refactor(plugins): streamline error handling and improve plugin retrieval logic

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

* refactor(plugins): update newWebSocketService to use WebSocketPermission for allowed hosts

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

* refactor(plugins): introduce ToggleEnabledSwitch for managing plugin enable/disable state

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

* docs: update READMEs

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

* feat(library): add Library service for metadata access and filesystem integration

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

* feat(plugins): add Library Inspector plugin for periodic library inspection and file size logging

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

* docs: update README to reflect JSON configuration format for plugins

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

* fix(build): update target to wasm32-wasip1 for improved WASI support

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

* feat(plugins): implement configuration management UI with key-value pairs support

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

* feat(ui): adjust grid layout in InfoRow component for improved responsiveness

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

* feat(plugins): rename ErrorIndicator to EnabledOrErrorField and enhance error handling logic

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

* feat(i18n): add Portuguese translations for plugin management and notifications

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

* feat(plugins): add support for .ndp plugin packages and update build process

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

* docs: update README for .ndp plugin packaging and installation instructions

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

* feat(plugins): implement KVStore service for persistent key-value storage

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

* docs: enhance README with Extism plugin development resources and recommendations

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

* feat(plugins): integrate event broker into plugin manager

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

* feat(plugins): update config handling in PluginShow to track last record state

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

* feat(plugins): add Rust host function library and example implementation of Discord Rich Presence plugin in Rust

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

* feat(plugins): generate Rust lib.rs file to expose host function wrappers

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

* refactor(plugins): update JSON field names to camelCase for consistency

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

* refactor: reduce cyclomatic complexity by refactoring main function

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

* feat(plugins): enhance Rust code generation with typed struct support and improved type handling

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

* feat(plugins): add Go client library with host function wrappers and documentation

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

* feat(plugins): generate Go client stubs for non-WASM platforms

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

* feat(plugins): update client template file names for consistency

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

* feat(plugins): add initial implementation of the Navidrome Plugin Development Kit code generator - Pahse 1

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 2

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 2 (2)

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 3

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 4

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 5

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

* refactor(plugins): consistent naming/types across PDK

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

* refactor(plugins): streamline plugin function signatures and error handling

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

* refactor(plugins): update scrobbler interface to return errors directly instead of response structs

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

* test: make all test plugins use the PDK

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

* refactor(plugins): reorganize and sort type definitions for consistency

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

* refactor(plugins): update error handling for methods to return errors directly

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

* refactor(plugins): update function signatures to return values directly instead of response structs

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

* refactor(plugins): update request/response types to use private naming conventions

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

* build: mark .wasm files as intermediate for cleanup after building .ndp

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

* refactor(plugins): consolidate PDK module path and update Go version to 1.25

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

* feat: implement Rust PDK

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

* refactor(plugins): reorganize Rust output structure to follow standard conventions

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

* refactor(plugins): update Discord Rich Presence and Library Inspector plugins to use nd-pdk for service calls and implement lifecycle management

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

* refactor(plugins): update macro names for websocket and metadata registration to improve clarity and consistency

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

* refactor(plugins): rename scheduler callback methods for consistency and clarity

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

* refactor(plugins): update export wrappers to use `//go:wasmexport` for WebAssembly compatibility

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

* docs: update plugin registration docs

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

* fix(plugins): generate host wrappers

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

* test(plugins): conditionally run goleak checks based on CI environment

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

* docs: update README to reflect changes in plugin import paths

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

* refactor(plugins): update plugin instance creation to accept context for cancellation support

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

* fix(plugins): update return types in metadata interfaces to use pointers

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

* fix(plugins): enhance type handling for Rust and XTP output in capability generation

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

* fix(plugins): update IsAuthorized method to return boolean instead of response object

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

* test(plugins): add unit tests for rustOutputType and isPrimitiveRustType functions

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

* feat(plugins): implement XTP JSONSchema validation for generated schemas

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

* fix(plugins): update response types in testMetadataAgent methods to use pointers

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

* docs: update Go and Rust plugin developer sections for clarity

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

* docs: correct example link for library inspector in README

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

* docs: clarify artwork URL generation capabilities in service descriptions

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

* docs: update README to include Rust PDK crate information for plugin developers

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

* fix: handle URL parsing errors and use atomic upsert in plugin repository

Added proper error handling for url.Parse calls in PublicURL and AbsoluteURL
functions. When parsing fails, PublicURL now falls back to AbsoluteURL, and
AbsoluteURL logs the error and returns an empty string, preventing malformed
URLs from being generated.

Replaced the non-atomic UPDATE-then-INSERT pattern in plugin repository Put
method with a single atomic INSERT ... ON CONFLICT statement. This eliminates
potential race conditions and improves consistency with the upsert pattern
already used in host_kvstore.go.

* feat: implement mock service instances for non-WASM builds using testify/mock

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

* refactor: Discord RPC struct to encapsulate WebSocket logic

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

* feat: add support for experimental WebAssembly threads

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

* feat: add PDK abstraction layer with mock support for non-WASM builds

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

* feat: add unit tests for Discord plugin and RPC functionality

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

* fix: update return types in minimalPlugin and wikimediaPlugin methods to use pointers

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

* fix: context cancellation and implement WebSocket callback timeout for improved error handling

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

* feat: conditionally include error handling in generated client code templates

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

* feat: implement ConfigService for plugin configuration management

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

* feat: enhance plugin manager to support metrics recording

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

* refactor: make MockPDK private

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

* refactor: update interface types to use 'any' in plugin repository methods

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

* refactor: rename List method to Keys for clarity in configuration management

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

* test: add ndpgen plugin tests in the pipeline and update Makefile

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

* feat: add users permission management to plugin system

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

* refactor: streamline users integration tests and enhance plugin user management

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

* refactor: remove UserID from scrobbler request structure

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

* test: add integration tests for UsersService enable gate behavior

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

* feat: implement user permissions for SubsonicAPI and scrobbler plugins

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

* fix: show proper error in the UI when enabling a plugin fails

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

* feat: add library permission management to plugin system

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

* feat: add user permission for processing scrobbles in Discord Rich Presence plugin

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

* fix: implement dynamic loading for buffered scrobbler plugins

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

* feat: add GetAdmins method to retrieve admin users from the plugin

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

* feat: update Portuguese translations for user and library permissions

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

* reorder migrations

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

* fix: remove unnecessary bulkActionButtons prop from PluginList component

* feat: add manual plugin rescan functionality and corresponding UI action

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

* feat: implement user/library and plugin management integration with cleanup on deletion

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

* feat: replace core mock services with test-specific implementations to avoid import cycles

* feat: add ID fields to Artist and Song structs and enhance track loading logic by prioritizing ID matches

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

* feat: update plugin permissions from allowedHosts to requiredHosts for better clarity and consistency

* feat: refactor plugin host permissions to use RequiredHosts directly for improved clarity

* fix: don't record metrics for plugin calls that aren't implemented at all

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

* fix: enhance connection management with improved error handling and cleanup logic

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

* feat: introduce ArtistRef struct for better artist representation and update track metadata handling

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

* feat: update user configuration handling to use user key prefix for improved clarity

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

* feat: enhance ConfigCard input fields with multiline support and vertical resizing

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

* fix: rust plugin compilation error

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

* feat: implement IsOptionPattern method for better return type handling in Rust PDK generation

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-01-14 19:22:48 -05:00
committed by GitHub
parent fd4a04339e
commit 03a45753e9
518 changed files with 49456 additions and 34933 deletions
+90 -19
View File
@@ -1,27 +1,98 @@
all: wikimedia coverartarchive crypto-ticker discord-rich-presence subsonicapi-demo
# Build example plugins for Navidrome
# Auto-discover all plugin folders (folders containing go.mod)
PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod))
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
# Auto-discover Python plugins (folders containing plugin/__init__.py)
PYTHON_PLUGINS := $(patsubst %/plugin/__init__.py,%,$(wildcard */plugin/__init__.py))
wikimedia/plugin.wasm: wikimedia/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia
# Auto-discover Rust plugins (folders containing Cargo.toml)
RUST_PLUGINS := $(patsubst %/Cargo.toml,%,$(wildcard */Cargo.toml))
coverartarchive/plugin.wasm: coverartarchive/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive
# Prefer tinygo if available, it produces smaller wasm binaries.
TINYGO := $(shell command -v tinygo 2> /dev/null)
EXTISM_PY := $(shell command -v extism-py 2> /dev/null)
crypto-ticker/plugin.wasm: crypto-ticker/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker
# PDK source files that trigger rebuild when changed (recursive)
PDK_GO_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null)
PDK_PY_SOURCES := $(shell find ../pdk/python -name '*.py' 2>/dev/null)
PDK_RS_SOURCES := $(shell find ../pdk/rust -name '*.rs' 2>/dev/null)
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/...
# Allow building plugins without .ndp extension (e.g., make minimal instead of make minimal.ndp)
.PHONY: $(PLUGINS) $(PYTHON_PLUGINS) $(RUST_PLUGINS)
$(PLUGINS): %: %.ndp
$(PYTHON_PLUGINS): %: %.ndp
$(RUST_PLUGINS): %: %.ndp
subsonicapi-demo/plugin.wasm: subsonicapi-demo/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./subsonicapi-demo
# Default target: show available plugins
.DEFAULT_GOAL := help
help:
@echo "Available Go plugins:"
@$(foreach p,$(PLUGINS),echo " $(p)";)
@echo ""
@echo "Available Python plugins:"
@$(foreach p,$(PYTHON_PLUGINS),echo " $(p)";)
@echo ""
@echo "Available Rust plugins:"
@$(foreach p,$(RUST_PLUGINS),echo " $(p)";)
@echo ""
@echo "Usage:"
@echo " make <plugin> Build a specific plugin (e.g., make $(firstword $(PLUGINS)))"
@echo " make all Build all plugins"
@echo " make all-go Build all Go plugins"
@echo " make all-python Build all Python plugins (requires extism-py)"
@echo " make all-rust Build all Rust plugins (requires cargo)"
@echo " make clean Remove all built plugins (.ndp and .wasm files)"
all: all-go all-python all-rust
all-go: $(PLUGINS:%=%.ndp)
all-python: $(PYTHON_PLUGINS:%=%.ndp)
all-rust: $(RUST_PLUGINS:%=%.ndp)
clean:
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm \
discord-rich-presence/plugin.wasm subsonicapi-demo/plugin.wasm
rm -f $(PLUGINS:%=%.ndp) $(PYTHON_PLUGINS:%=%.ndp) $(RUST_PLUGINS:%=%.ndp)
rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm)
@$(foreach p,$(RUST_PLUGINS),(cd $(p) && cargo clean 2>/dev/null) || true;)
# Mark .wasm files as intermediate so Make deletes them after building .ndp
.INTERMEDIATE: $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm)
# Build .ndp package from .wasm and manifest.json
# Go plugins
%.ndp: %.wasm %/manifest.json
@rm -f $@
@cp $< plugin.wasm
zip -j $@ $*/manifest.json plugin.wasm
@rm -f plugin.wasm
@mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp
# Use secondary expansion to properly track all Go source files
.SECONDEXPANSION:
$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod $(PDK_GO_SOURCES)
ifdef TINYGO
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
else
cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ .
endif
# Python plugin builds (generic rule for any folder with plugin/__init__.py)
# Use secondary expansion to get all .py files in the plugin directory as dependencies
.SECONDEXPANSION:
$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py) $(PDK_PY_SOURCES)
ifndef EXTISM_PY
$(error extism-py is not installed. Install from https://github.com/extism/python-pdk)
endif
cd $* && PYTHONPATH=plugin extism-py plugin/__init__.py -o ../$@
# Rust plugin builds (generic rule for any folder with Cargo.toml)
# Note: Rust crate names use underscores, but plugin names use hyphens
# All Rust plugins use wasm32-wasip1 for WASI support (filesystem, etc.)
RUST_TARGET := wasm32-wasip1
RUSTUP_CARGO := $(shell rustup which cargo 2>/dev/null || echo cargo)
RUSTUP_RUSTC := $(shell rustup which rustc 2>/dev/null)
$(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs) $(PDK_RS_SOURCES)
cd $* && CARGO_BUILD_RUSTC=$(RUSTUP_RUSTC) $(RUSTUP_CARGO) build --release --target $(RUST_TARGET)
cp $*/target/$(RUST_TARGET)/release/$(subst -,_,$*).wasm $@
+169 -19
View File
@@ -1,31 +1,181 @@
# Plugin Examples
# Navidrome Plugin Examples
This directory contains example plugins for Navidrome, intended for demonstration and reference purposes. These plugins are not used in automated tests.
This folder contains example plugins demonstrating various capabilities and languages supported by Navidrome's plugin system.
## Contents
## Available Examples
- `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.
| Plugin | Language | Capabilities | Description |
|-------------------------------------------------------|----------|-------------------------------------------------|--------------------------------|
| [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure |
| [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata |
| [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) |
| [discord-rich-presence](discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration |
| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger |
| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble |
| [library-inspector-rs](library-inspector-rs/) | Rust | Library, Scheduler | Periodic library stats logging |
| [discord-rich-presence-rs](discord-rich-presence-rs/) | Rust | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration (Rust) |
## Building
To build all example plugins, run:
### Prerequisites
```
make
- **Go plugins:** [TinyGo](https://tinygo.org/getting-started/install/) 0.30+
- **Python plugins:** [extism-py](https://github.com/extism/python-pdk)
- **Rust plugins:** [Rust](https://rustup.rs/) with `wasm32-unknown-unknown` target
### Build All Plugins
```bash
make all
```
Or to build a specific plugin:
This creates `.ndp` package files for each plugin.
```
make wikimedia
make coverartarchive
make crypto-ticker
make discord-rich-presence
make subsonicapi-demo
### Build Individual Plugin
```bash
make minimal.ndp
make wikimedia.ndp
make discord-rich-presence.ndp
```
This will produce the corresponding `plugin.wasm` files in each plugin's directory.
### Clean
```bash
make clean
```
## Testing Plugins
### With Extism CLI
Test any plugin without running Navidrome. First extract the `.wasm` file from the `.ndp` package:
```bash
# Install: https://extism.org/docs/install
# Extract the wasm file from the package
unzip -p minimal.ndp plugin.wasm > minimal.wasm
# Test a capability function
extism call minimal.wasm nd_get_artist_biography --wasi \
--input '{"id":"1","name":"The Beatles"}'
```
For plugins that make HTTP requests, allow the hosts:
```bash
unzip -p wikimedia.ndp plugin.wasm > wikimedia.wasm
extism call wikimedia.wasm nd_get_artist_biography --wasi \
--input '{"id":"1","name":"Yussef Dayes"}' \
--allow-host "query.wikidata.org" \
--allow-host "en.wikipedia.org"
```
### With Navidrome
1. Copy the `.ndp` file to your plugins folder
2. Enable plugins in `navidrome.toml`:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
```
3. For metadata agents, add to your agents list:
```toml
Agents = "lastfm,spotify,wikimedia"
```
## Creating Your Own Plugin
### Option 1: Start from Minimal
Copy the [minimal](minimal/) example and modify:
```bash
cp -r minimal my-plugin
cd my-plugin
# Edit main.go and manifest.json
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
zip -j my-plugin.ndp manifest.json plugin.wasm
```
### Option 2: Bootstrap with XTP CLI
Generate boilerplate from a schema:
```bash
# Install XTP: https://docs.xtp.dylibso.com/docs/cli
xtp plugin init \
--schema-file ../schemas/metadata_agent.yaml \
--template go \
--path ./my-plugin \
--name my-plugin
# Then create manifest.json and package
cd my-plugin
xtp plugin build
zip -j my-plugin.ndp manifest.json dist/plugin.wasm
```
Available schemas in [../schemas/](../schemas/):
- `metadata_agent.yaml` Artist/album metadata
- `scrobbler.yaml` Scrobbling integration
- `lifecycle.yaml` Init callbacks
- `scheduler_callback.yaml` Scheduled tasks
- `websocket_callback.yaml` WebSocket events
### Option 3: Different Language
See language-specific examples:
- **Python:** [coverartarchive-py](coverartarchive-py/)
- **Rust:** [webhook-rs](webhook-rs/)
## Example Breakdown
### Minimal (Go)
The simplest possible plugin. Shows:
- Manifest export
- Single capability function
- Basic input/output handling
### Wikimedia (Go)
Real-world metadata agent. Shows:
- HTTP requests to external APIs
- SPARQL queries (Wikidata)
- Error handling
- Host allowlisting
### Discord Rich Presence (Go)
Complex multi-capability plugin. Shows:
- **Scrobbler** Receives play events
- **WebSocket** Maintains Discord gateway connection
- **Scheduler** Heartbeat and timeout management
- **Cache** Connection state storage
- **Artwork** Getting album art URLs
### Cover Art Archive (Python)
Python metadata agent. Shows:
- extism-py plugin structure
- HTTP requests
- JSON handling
### Webhook (Rust)
Rust scrobbler. Shows:
- extism-rs plugin structure
- HTTP POST requests
- Minimal dependencies
## Resources
- [Plugin System Documentation](../README.md)
- [Extism PDK Docs](https://extism.org/docs/concepts/pdk)
- [TinyGo WebAssembly](https://tinygo.org/docs/guides/webassembly/)
- [XTP CLI](https://docs.xtp.dylibso.com/docs/cli)
@@ -0,0 +1,27 @@
# Build the Cover Art Archive Python plugin
.PHONY: build test clean
WASM_FILE = coverartarchive-py.wasm
build: $(WASM_FILE)
$(WASM_FILE): plugin/__init__.py
extism-py plugin/__init__.py -o $(WASM_FILE)
test: build
@echo "Testing nd_manifest..."
extism call $(WASM_FILE) nd_manifest --wasi
@echo ""
@echo "Testing nd_get_album_images with Portishead's Dummy MBID..."
extism call $(WASM_FILE) nd_get_album_images --wasi \
--input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \
--allow-host "coverartarchive.org" --allow-host "archive.org"
test-error: build
@echo "Testing error case (missing MBID)..."
-extism call $(WASM_FILE) nd_get_album_images --wasi \
--input '{"name":"Test Album","artist":"Test Artist"}' \
--allow-host "coverartarchive.org"
clean:
rm -f $(WASM_FILE)
@@ -0,0 +1,73 @@
# Cover Art Archive Plugin (Python)
A Python example plugin that fetches album cover images from the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release MBID.
## Features
- Implements the `nd_get_album_images` method of the MetadataAgent plugin interface
- Returns front cover images for a given release MBID
- Returns `not found` if no MBID is provided or no images are found
- Demonstrates Python plugin development for Navidrome
## Prerequisites
- [extism-py](https://github.com/extism/python-pdk) - Python PDK compiler
```bash
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
```
> **Note:** `extism-py` requires [Binaryen](https://github.com/WebAssembly/binaryen/) (`wasm-merge`, `wasm-opt`) to be installed.
## Building
From the `plugins/examples` directory:
```bash
make coverartarchive-py.ndp
```
Or directly:
```bash
extism-py plugin/__init__.py -o plugin.wasm
zip -j coverartarchive-py.ndp manifest.json plugin.wasm
```
## Installation
1. Copy `coverartarchive-py.ndp` to your Navidrome plugins folder
2. Enable plugins in `navidrome.toml`:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
```
3. Add to your agents list:
```toml
Agents = "coverartarchive-py,spotify,lastfm"
```
## Testing
Extract the wasm file and test:
```bash
unzip -p coverartarchive-py.ndp plugin.wasm > coverartarchive-py.wasm
extism call coverartarchive-py.wasm nd_get_album_images --wasi \
--input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \
--allow-host "coverartarchive.org" --allow-host "archive.org"
```
## How It Works
1. **Album Image Request (`nd_get_album_images`)**: Receives album metadata including the MusicBrainz Release MBID.
2. **API Query**: Fetches cover art metadata from `https://coverartarchive.org/release/{mbid}`.
3. **Response**: Returns the front cover image URL if found.
## API Reference
- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API)
@@ -0,0 +1,16 @@
{
"name": "Cover Art Archive (Python)",
"author": "Navidrome",
"version": "1.0.0",
"description": "Album cover art from the Cover Art Archive - Python example",
"website": "https://coverartarchive.org",
"permissions": {
"http": {
"reason": "Fetch album cover art from Cover Art Archive API",
"requiredHosts": [
"coverartarchive.org",
"*.archive.org"
]
}
}
}
@@ -0,0 +1,105 @@
# Cover Art Archive Plugin for Navidrome
#
# This plugin fetches album cover art from the Cover Art Archive (https://coverartarchive.org/)
# using the MusicBrainz album MBID.
#
# Build with:
# extism-py plugin/__init__.py -o coverartarchive-py.wasm
#
# Test with:
# extism call coverartarchive-py.wasm nd_get_album_images --wasi \
# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \
# --allow-host "coverartarchive.org" --allow-host "archive.org"
import extism
import json
@extism.plugin_fn
def nd_get_album_images():
"""Retrieve album cover images from Cover Art Archive."""
input_data = extism.input_json()
mbid = input_data.get("mbid", "")
if not mbid:
raise Exception("not found: MBID required")
# Query Cover Art Archive API
url = f"https://coverartarchive.org/release/{mbid}"
response = extism.Http.request(url, meth="GET")
if response.status_code != 200:
raise Exception(f"not found: CAA returned status {response.status_code}")
try:
data = json.loads(response.data_str())
except json.JSONDecodeError:
raise Exception("not found: invalid JSON response")
caa_images = data.get("images", [])
if not caa_images:
raise Exception("not found: no images in response")
# Find the front cover image
front_image = find_front_image(caa_images)
if not front_image:
raise Exception("not found: no front cover image")
# Build the response with available image sizes
images = build_image_list(front_image)
if not images:
raise Exception("not found: no usable image URLs")
extism.output_str(json.dumps({"images": images}))
def find_front_image(images):
"""Find the front cover image from CAA response."""
# First, look for an image explicitly marked as front
for img in images:
if img.get("front", False):
return img
# Second, look for an image with "Front" in types
for img in images:
types = img.get("types", [])
if "Front" in types:
return img
# Fallback to first image
if images:
return images[0]
return None
def build_image_list(img):
"""Build list of images with URLs and sizes from CAA image data."""
images = []
thumbnails = img.get("thumbnails", {})
# First, try numeric sizes (250, 500, 1200, etc.)
for size_str, url in thumbnails.items():
if not url:
continue
try:
size = int(size_str)
images.append({"url": url, "size": size})
except ValueError:
pass # Not a numeric size
# If no numeric sizes, fallback to named sizes
if not images:
size_map = {"large": 500, "small": 250}
for size_name, size in size_map.items():
url = thumbnails.get(size_name)
if url:
images.append({"url": url, "size": size})
# If still no images, use the main image URL
if not images:
main_url = img.get("image")
if main_url:
images.append({"url": main_url, "size": 0})
return images
@@ -1,34 +0,0 @@
# 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}`
@@ -1,19 +0,0 @@
{
"$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
View File
@@ -1,151 +0,0 @@
//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{})
}
+57 -19
View File
@@ -6,48 +6,86 @@ This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryp
- 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
- Implements WebSocket callback handlers for message processing
- Automatically reconnects on connection loss using the scheduler service
- Displays price, best bid, best ask, and 24-hour percentage change
## Configuration
In your `navidrome.toml` file, add:
Configure in the Navidrome UI (Settings → Plugins → crypto-ticker):
```toml
[PluginConfig.crypto-ticker]
tickers = "BTC,ETH,SOL,MATIC"
```
| Key | Description | Default |
|-----------|----------------------------------------------------------------------|-----------|
| `tickers` | Comma-separated list of cryptocurrency symbols (e.g., `BTC,ETH,SOL`) | `BTC,ETH` |
- `tickers` is a comma-separated list of cryptocurrency symbols
- The plugin will append `-USD` to any symbol without a trading pair specified
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)
1. On plugin initialization, connects to Coinbase's WebSocket API
2. Subscribes to ticker updates for the configured cryptocurrencies
3. Incoming ticker data is processed via `nd_websocket_on_text_message` callback
4. On connection loss, schedules a reconnection attempt via the scheduler service
5. Reconnection is attempted until successful
## Building
To build the plugin to WASM:
To build the plugin and package as `.ndp`:
```bash
# Using TinyGo (recommended - smaller binary)
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
zip -j crypto-ticker.ndp manifest.json plugin.wasm
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
Or from the `plugins/examples/` directory:
```bash
make crypto-ticker.ndp
```
## Installation
Copy the resulting `plugin.wasm` and create a `manifest.json` file in your Navidrome plugins folder under a `crypto-ticker` directory.
Copy the resulting `crypto-ticker.ndp` to your Navidrome plugins folder.
## 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%
[Crypto] Crypto Ticker Plugin initializing...
[Crypto] Configured tickers: [BTC-USD ETH-USD]
[Crypto] Connected to Coinbase WebSocket API (connection: crypto-ticker-conn)
[Crypto] Subscription message sent to Coinbase WebSocket API
[Crypto] Received subscriptions message
[Crypto] 💰 BTC-USD: $98765.43 (24h: +2.35%) Bid: $98764.00 Ask: $98766.00
[Crypto] 💰 ETH-USD: $3456.78 (24h: -0.54%) Bid: $3455.90 Ask: $3457.80
```
## Permissions Required
- **config**: Read ticker symbols configuration
- **websocket**: Connect to `ws-feed.exchange.coinbase.com`
- **scheduler**: Schedule reconnection attempts
## Files
- `main.go` - Main plugin implementation
- `go.mod` - Go module file
## PDK
This plugin imports the Navidrome PDK subpackages directly:
```go
import (
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
```
The `go.mod` file uses `replace` directives to point to the local packages for development.
---
For more details, see the source code in `plugin.go`.
For more details, see the source code in `main.go`.
+16
View File
@@ -0,0 +1,16 @@
module crypto-ticker
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
+14
View File
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+264
View File
@@ -0,0 +1,264 @@
// Crypto Ticker Plugin - Demonstrates WebSocket host service capabilities.
//
// This plugin connects to Coinbase's WebSocket API to receive real-time
// cryptocurrency price updates and logs them to the Navidrome console.
package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/lifecycle"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
const (
// Coinbase WebSocket API endpoint
coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com"
// Connection ID for our WebSocket connection
connectionID = "crypto-ticker-conn"
// ID for the reconnection schedule
reconnectScheduleID = "crypto-ticker-reconnect"
)
// CoinbaseSubscription message structure
type CoinbaseSubscription struct {
Type string `json:"type"`
ProductIDs []string `json:"product_ids"`
Channels []string `json:"channels"`
}
// CoinbaseTicker 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"`
BestBid string `json:"best_bid"`
BestAsk string `json:"best_ask"`
Time string `json:"time"`
}
// cryptoTickerPlugin implements the lifecycle, websocket and scheduler interfaces.
type cryptoTickerPlugin struct{}
// init registers the plugin capabilities
func init() {
lifecycle.Register(&cryptoTickerPlugin{})
websocket.Register(&cryptoTickerPlugin{})
scheduler.Register(&cryptoTickerPlugin{})
}
// Ensure cryptoTickerPlugin implements the required provider interfaces
var (
_ lifecycle.InitProvider = (*cryptoTickerPlugin)(nil)
_ websocket.TextMessageProvider = (*cryptoTickerPlugin)(nil)
_ websocket.BinaryMessageProvider = (*cryptoTickerPlugin)(nil)
_ websocket.ErrorProvider = (*cryptoTickerPlugin)(nil)
_ websocket.CloseProvider = (*cryptoTickerPlugin)(nil)
_ scheduler.CallbackProvider = (*cryptoTickerPlugin)(nil)
)
// OnInit is called when the plugin is loaded.
// We use this to establish the initial WebSocket connection.
func (p *cryptoTickerPlugin) OnInit() error {
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH" // Default tickers
}
tickers := parseTickerSymbols(tickerConfig)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
// Connect to WebSocket
// Errors won't fail init - reconnect logic will handle it
return connectAndSubscribe(tickers)
}
// parseTickerSymbols parses a comma-separated list of ticker symbols
func parseTickerSymbols(tickerConfig string) []string {
parts := strings.Split(tickerConfig, ",")
tickers := make([]string, 0, len(parts))
for _, ticker := range parts {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
// Add -USD suffix if not present
if !strings.Contains(ticker, "-") {
ticker = ticker + "-USD"
}
tickers = append(tickers, ticker)
}
return tickers
}
// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers
func connectAndSubscribe(tickers []string) error {
// Connect to WebSocket using host function
newConnID, err := host.WebSocketConnect(coinbaseWSEndpoint, nil, connectionID)
if err != nil {
return fmt.Errorf("WebSocket connection error: %w", err)
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("Connected to Coinbase WebSocket API (connection: %s)", newConnID))
// Subscribe to ticker channel
subscription := CoinbaseSubscription{
Type: "subscribe",
ProductIDs: tickers,
Channels: []string{"ticker"},
}
subscriptionJSON, err := json.Marshal(subscription)
if err != nil {
return fmt.Errorf("JSON marshal error: %v", err)
}
// Send subscription message
err = host.WebSocketSendText(connectionID, string(subscriptionJSON))
if err != nil {
return fmt.Errorf("WebSocket send error: %w", err)
}
pdk.Log(pdk.LogInfo, "Subscription message sent to Coinbase WebSocket API")
return nil
}
// OnTextMessage is called when a text message is received
func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest) error {
// Only process messages from our connection
if input.ConnectionID != connectionID {
return nil
}
// Try to parse as a ticker message
var ticker CoinbaseTicker
err := json.Unmarshal([]byte(input.Message), &ticker)
if err != nil {
// Not a valid JSON message, ignore
return nil
}
// Only process ticker messages
if ticker.Type != "ticker" {
// Could be subscription confirmation or heartbeat
if ticker.Type != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received %s message", ticker.Type))
}
return nil
}
// Calculate 24h change percentage
change := calculatePercentChange(ticker.Open24h, ticker.Price)
// Log ticker information
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
return nil
}
// OnBinaryMessage is called when a binary message is received
func (p *cryptoTickerPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
// Coinbase doesn't send binary messages, but we implement the handler anyway
pdk.Log(pdk.LogWarn, fmt.Sprintf("Received unexpected binary message on connection %s", input.ConnectionID))
return nil
}
// OnError is called when an error occurs on the WebSocket connection
func (p *cryptoTickerPlugin) OnError(input websocket.OnErrorRequest) error {
pdk.Log(pdk.LogError, fmt.Sprintf("WebSocket error on connection %s: %s", input.ConnectionID, input.Error))
return nil
}
// OnClose is called when the WebSocket connection is closed
func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection %s closed (code: %d, reason: %s)",
input.ConnectionID, input.Code, input.Reason))
// Only attempt reconnect for our connection
if input.ConnectionID == connectionID {
pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...")
// Schedule a one-time reconnection attempt
_, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
}
}
return nil
}
// OnCallback is called when a scheduled task fires
func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
// Only handle our reconnection schedule
if input.ScheduleID != reconnectScheduleID {
return nil
}
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH"
}
tickers := parseTickerSymbols(tickerConfig)
// Try to connect and subscribe
err := connectAndSubscribe(tickers)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err))
// Schedule another attempt
_, err := host.SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
}
} else {
pdk.Log(pdk.LogInfo, "Successfully reconnected!")
}
return nil
}
// calculatePercentChange calculates the percentage change between open and current price
func calculatePercentChange(open, current string) string {
var openFloat, currentFloat float64
_, err := fmt.Sscanf(open, "%f", &openFloat)
if err != nil || openFloat == 0 {
return "N/A"
}
_, err = fmt.Sscanf(current, "%f", &currentFloat)
if err != nil {
return "N/A"
}
change := ((currentFloat - openFloat) / openFloat) * 100
if change >= 0 {
return fmt.Sprintf("+%.2f", change)
}
return fmt.Sprintf("%.2f", change)
}
func main() {}
+7 -13
View File
@@ -1,25 +1,19 @@
{
"name": "crypto-ticker",
"author": "Navidrome Plugin",
"name": "Crypto Ticker",
"author": "Navidrome",
"version": "1.0.0",
"description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API",
"description": "Real-time cryptocurrency price ticker 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"
"reason": "To read ticker symbols configuration"
},
"scheduler": {
"reason": "To schedule periodic reconnection attempts and status updates"
"reason": "To schedule reconnection attempts on connection loss"
},
"websocket": {
"reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices",
"allowedUrls": ["wss://ws-feed.exchange.coinbase.com"],
"allowLocalNetwork": false
"reason": "To connect to Coinbase WebSocket API for real-time prices",
"requiredHosts": ["ws-feed.exchange.coinbase.com"]
}
}
}
-304
View File
@@ -1,304 +0,0 @@
//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{})
}
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
@@ -0,0 +1,16 @@
[package]
name = "discord-rich-presence-rs"
version = "1.0.0"
edition = "2021"
description = "Discord Rich Presence plugin for Navidrome - Rust implementation"
authors = ["Navidrome Team"]
license = "GPL-3.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
nd-pdk = { path = "../../pdk/rust/nd-pdk" }
extism-pdk = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -0,0 +1,97 @@
# Discord Rich Presence Plugin (Rust)
A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the generated `nd-host` library.
## ⚠️ Warning
This plugin is for **demonstration purposes only**. It requires storing your Discord token in the Navidrome configuration file, which:
1. Is not secure (tokens should never be stored in plain text)
2. May violate Discord's Terms of Service
**Use at your own risk.**
## Features
- Shows currently playing track on Discord Rich Presence
- Displays album artwork
- Shows track progress with start/end timestamps
- Automatically clears presence when track finishes
- Supports multiple users
## Capabilities
This plugin implements three capabilities to demonstrate the nd-host library:
- **Scrobbler**: Receives now-playing events from Navidrome
- **SchedulerCallback**: Handles heartbeat and activity clearing timers
- **WebSocketCallback**: Communicates with Discord gateway
## Configuration
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
| Key | Description | Example |
|---------------|-------------------------------------------|--------------------------------|
| `clientid` | Your Discord application ID | `123456789012345678` |
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
Each user is configured as a separate key with the `user.` prefix.
### Getting Configuration Values
1. **Client ID**: Create a Discord Application at https://discord.com/developers/applications and copy the Application ID
2. **Discord Token**: This requires extracting your user token from Discord (not recommended for security reasons)
3. **Multiple Users**: Add multiple user keys:
```properties
user.user1 = "token1"
user.user2 = "token2"
```
## Building
```bash
# From the plugins/examples directory
make discord-rich-presence-rs.ndp
# This creates discord-rich-presence-rs.ndp containing:
# - manifest.json
# - plugin.wasm
```
## Installation
1. Build the plugin using the command above
2. Copy the `.ndp` file to your Navidrome plugins directory
3. Enable and configure the plugin in the Navidrome UI (Settings → Plugins)
4. Restart Navidrome if needed
## Using nd-host Library
This plugin demonstrates how to use the generated Rust host function wrappers:
```rust
use nd_host::{artwork, cache, scheduler, websocket};
// Get artwork URL
let (url, _) = artwork::artwork_get_track_url(track_id, 300)?;
// Cache operations
cache::cache_set_string("key", "value", 3600)?;
let (value, exists) = cache::cache_get_string("key")?;
// Schedule tasks
scheduler::scheduler_schedule_one_time(60, "payload", "task-id")?;
scheduler::scheduler_schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?;
// WebSocket operations
let conn_id = websocket::websocket_connect("wss://example.com/socket")?;
websocket::websocket_send_text(&conn_id, "Hello")?;
```
## License
GPL-3.0
@@ -0,0 +1,29 @@
{
"name": "Discord Rich Presence (Rust)",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Discord Rich Presence integration for Navidrome - Rust implementation",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence-rs",
"permissions": {
"users": {
"reason": "To process scrobbles on behalf of users"
},
"http": {
"reason": "To communicate with Discord API for gateway discovery and image uploads",
"requiredHosts": ["discord.com"]
},
"websocket": {
"reason": "To maintain real-time connection with Discord gateway",
"requiredHosts": ["gateway.discord.gg"]
},
"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"
}
}
}
@@ -0,0 +1,279 @@
//! Discord Rich Presence Plugin for Navidrome - Rust Implementation
//!
//! This plugin integrates Navidrome with Discord Rich Presence. It demonstrates how to:
//! - Use the nd-pdk crate for host service calls
//! - Implement the Scrobbler capability for now-playing updates
//! - Implement SchedulerCallback for heartbeat and activity clearing
//! - Implement WebSocketCallback for Discord gateway communication
//!
//! ## Configuration
//!
//! ```toml
//! [PluginConfig.discord-rich-presence-rs]
//! clientid = "YOUR_DISCORD_APPLICATION_ID"
//! "user.username1" = "discord_token1"
//! "user.username2" = "discord_token2"
//! ```
//!
//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens
//! in configuration files is not secure and may violate Discord's terms of service.
use extism_pdk::*;
use nd_pdk::host::{artwork, config, scheduler};
use nd_pdk::scrobbler::{
Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest,
ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER,
};
use nd_pdk::scheduler::{
CallbackProvider, Error as SchedulerError, SchedulerCallbackRequest,
};
use nd_pdk::websocket::{
BinaryMessageProvider, CloseProvider, Error as WebSocketError, ErrorProvider,
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
mod rpc;
// Register capabilities using PDK macros
nd_pdk::register_scrobbler!(DiscordPlugin);
nd_pdk::register_scheduler_callback!(DiscordPlugin);
nd_pdk::register_websocket_text_message!(DiscordPlugin);
nd_pdk::register_websocket_binary_message!(DiscordPlugin);
nd_pdk::register_websocket_error!(DiscordPlugin);
nd_pdk::register_websocket_close!(DiscordPlugin);
// ============================================================================
// Constants
// ============================================================================
const CLIENT_ID_KEY: &str = "clientid";
const USER_KEY_PREFIX: &str = "user.";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
// ============================================================================
// Plugin Implementation
// ============================================================================
/// The Discord Rich Presence plugin type.
#[derive(Default)]
struct DiscordPlugin;
// ============================================================================
// Configuration
// ============================================================================
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
let client_id = config::get(CLIENT_ID_KEY)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
// Get all user keys with the "user." prefix
let user_keys = config::keys(USER_KEY_PREFIX)?;
let mut users = std::collections::HashMap::new();
for key in user_keys {
let username = key.strip_prefix(USER_KEY_PREFIX).unwrap_or(&key);
if let Some(token) = config::get(&key)?.filter(|s| !s.is_empty()) {
users.insert(username.to_string(), token);
}
}
Ok((client_id, users))
}
fn get_image_url(track_id: &str) -> String {
match artwork::get_track_url(track_id, 300) {
Ok(url) => {
if url.starts_with("http://localhost") {
String::new()
} else {
url
}
}
Err(e) => {
warn!("Failed to get artwork URL: {:?}", e);
String::new()
}
}
}
// ============================================================================
// Scrobbler Implementation
// ============================================================================
impl Scrobbler for DiscordPlugin {
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, ScrobblerError> {
let (_, users) = match get_config() {
Ok(config) => config,
Err(e) => {
error!("Failed to get config: {:?}", e);
return Ok(false);
}
};
let authorized = users.contains_key(&req.username);
info!("IsAuthorized for user {}: {}", req.username, authorized);
Ok(authorized)
}
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), ScrobblerError> {
info!(
"Setting presence for user {}, track: {}",
req.username, req.track.title
);
// Load configuration
let (client_id, users) = get_config()
.map_err(|e| ScrobblerError::new(format!("{}: failed to get config: {:?}", SCROBBLER_ERROR_RETRY_LATER, e)))?;
// Check authorization
let user_token = users.get(&req.username).cloned().ok_or_else(|| {
ScrobblerError::new(format!(
"{}: user '{}' not authorized",
SCROBBLER_ERROR_NOT_AUTHORIZED, req.username
))
})?;
// Connect to Discord
rpc::connect(&req.username, &user_token)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to connect to Discord: {:?}",
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
// Cancel any existing completion schedule
let _ = scheduler::cancel_schedule(&format!("{}-clear", req.username));
// Calculate timestamps
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let start_time = (now - req.position as i64) * 1000;
let end_time = start_time + (req.track.duration as i64) * 1000;
// Send activity update
rpc::send_activity(
&client_id,
&req.username,
&user_token,
rpc::Activity {
application: client_id.clone(),
name: "Navidrome".to_string(),
activity_type: 2, // Listening
details: req.track.title.clone(),
state: req.track.artist.clone(),
timestamps: rpc::ActivityTimestamps {
start: start_time,
end: end_time,
},
assets: rpc::ActivityAssets {
large_image: get_image_url(&req.track.id),
large_text: req.track.album.clone(),
},
},
)
.map_err(|e| ScrobblerError::new(format!(
"{}: failed to send activity: {:?}",
SCROBBLER_ERROR_RETRY_LATER, e
)))?;
// Schedule a timer to clear the activity after the track completes
let remaining_seconds = (req.track.duration as i32) - req.position + 5;
if let Err(e) = scheduler::schedule_one_time(
remaining_seconds,
PAYLOAD_CLEAR_ACTIVITY,
&format!("{}-clear", req.username),
) {
warn!("Failed to schedule completion timer: {:?}", e);
}
Ok(())
}
fn scrobble(&self, _req: ScrobbleRequest) -> Result<(), ScrobblerError> {
// Discord Rich Presence doesn't need scrobble events - success
Ok(())
}
}
// ============================================================================
// Scheduler Callback Implementation
// ============================================================================
impl CallbackProvider for DiscordPlugin {
fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> {
match req.payload.as_str() {
PAYLOAD_HEARTBEAT => {
// Heartbeat callback - schedule_id is the username
if let Err(e) = rpc::handle_heartbeat_callback(&req.schedule_id) {
// On heartbeat failure, clean up the connection (like the original Go plugin)
// The next NowPlaying call will reconnect if needed
warn!("Heartbeat failed for user {}, cleaning up connection: {:?}", req.schedule_id, e);
rpc::cleanup_connection(&req.schedule_id);
return Err(SchedulerError::new(format!("heartbeat failed, connection cleaned up: {}", e)));
}
}
PAYLOAD_CLEAR_ACTIVITY => {
// Clear activity callback - schedule_id is "username-clear"
let username = req.schedule_id.trim_end_matches("-clear");
info!("Removing presence for user {}", username);
rpc::handle_clear_activity_callback(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
info!("Disconnecting user {}", username);
rpc::disconnect(username)
.map_err(|e| SchedulerError::new(e.to_string()))?;
}
_ => {
warn!("Unknown scheduler callback payload: {}", req.payload);
}
}
Ok(())
}
}
// ============================================================================
// WebSocket Callback Implementations
// ============================================================================
impl TextMessageProvider for DiscordPlugin {
fn on_text_message(&self, req: OnTextMessageRequest) -> Result<(), WebSocketError> {
rpc::handle_websocket_message(&req.connection_id, &req.message)
.map_err(|e| WebSocketError::new(e.to_string()))?;
Ok(())
}
}
impl BinaryMessageProvider for DiscordPlugin {
fn on_binary_message(&self, _req: OnBinaryMessageRequest) -> Result<(), WebSocketError> {
// Binary messages are not expected from Discord
Ok(())
}
}
impl ErrorProvider for DiscordPlugin {
fn on_error(&self, req: OnErrorRequest) -> Result<(), WebSocketError> {
warn!(
"WebSocket error for connection '{}': {}",
req.connection_id, req.error
);
// Clean up all state associated with this connection since it's likely broken
rpc::handle_connection_close(&req.connection_id);
Ok(())
}
}
impl CloseProvider for DiscordPlugin {
fn on_close(&self, req: OnCloseRequest) -> Result<(), WebSocketError> {
info!(
"WebSocket connection '{}' closed with code {}: {}",
req.connection_id, req.code, req.reason
);
// Clean up all state associated with this connection
rpc::handle_connection_close(&req.connection_id);
Ok(())
}
}
@@ -0,0 +1,547 @@
//! Discord Rich Presence Plugin - RPC Communication
//!
//! This module handles all Discord gateway communication including WebSocket connections,
//! presence updates, and heartbeat management.
use extism_pdk::*;
use nd_pdk::host::{cache, scheduler, websocket};
use serde::{Deserialize, Serialize};
// ============================================================================
// Constants
// ============================================================================
const HEARTBEAT_OP_CODE: i32 = 1;
const GATE_OP_CODE: i32 = 2;
const PRESENCE_OP_CODE: i32 = 3;
const HEARTBEAT_INTERVAL: i32 = 41;
const DEFAULT_IMAGE: &str = "https://i.imgur.com/hb3XPzA.png";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
// ============================================================================
// Discord Types
// ============================================================================
#[derive(Serialize)]
pub struct Activity {
pub name: String,
#[serde(rename = "type")]
pub activity_type: i32,
pub details: String,
pub state: String,
#[serde(rename = "application_id")]
pub application: String,
pub timestamps: ActivityTimestamps,
pub assets: ActivityAssets,
}
#[derive(Serialize)]
pub struct ActivityTimestamps {
pub start: i64,
pub end: i64,
}
#[derive(Serialize)]
pub struct ActivityAssets {
pub large_image: String,
pub large_text: String,
}
#[derive(Serialize)]
struct PresencePayload {
activities: Vec<Activity>,
since: i64,
status: String,
afk: bool,
}
#[derive(Serialize)]
struct IdentifyPayload {
token: String,
intents: i32,
properties: IdentifyProperties,
}
#[derive(Serialize)]
struct IdentifyProperties {
os: String,
browser: String,
device: String,
}
#[derive(Serialize)]
struct GatewayMessage<T> {
op: i32,
d: T,
}
#[derive(Deserialize)]
struct GatewayResponse {
op: i32,
#[serde(default)]
#[allow(dead_code)]
d: Option<serde_json::Value>,
#[serde(default)]
s: Option<i64>,
}
// ============================================================================
// Cache Keys
// ============================================================================
fn connection_key(username: &str) -> String {
format!("discord.connection.{}", username)
}
fn token_key(username: &str) -> String {
format!("discord.token.{}", username)
}
fn sequence_key(username: &str) -> String {
format!("discord.sequence.{}", username)
}
// ============================================================================
// Connection Management
// ============================================================================
/// Tests if the connection is still valid by trying to send a heartbeat.
fn is_connected(username: &str) -> bool {
match send_heartbeat(username) {
Ok(_) => true,
Err(e) => {
trace!("Connection test failed for user {}: {:?}", username, e);
false
}
}
}
/// Cleans up a connection for a user.
/// Called when heartbeat fails or connection is lost.
pub fn cleanup_connection(username: &str) {
info!("Cleaning up failed connection for user {}", username);
// Cancel the heartbeat schedule
if let Err(e) = scheduler::cancel_schedule(username) {
warn!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e);
}
// Try to close the WebSocket connection
let conn_key = connection_key(username);
if let Ok(Some(conn_id)) = cache::get_string(&conn_key) {
if !conn_id.is_empty() {
if let Err(e) = websocket::close_connection(&conn_id, 1000, "Reconnecting") {
trace!("Failed to close WebSocket for user {}: {:?}", username, e);
}
// Clean up reverse mapping
let reverse_key = format!("discord.reverse.{}", conn_id);
let _ = cache::remove(&reverse_key);
}
}
// Clean up cache entries
let _ = cache::remove(&conn_key);
let _ = cache::remove(&sequence_key(username));
info!("Cleaned up connection for user {}", username);
}
/// Handles connection close by connection ID (called from WebSocket close callback).
/// This cleans up all state associated with the connection.
pub fn handle_connection_close(connection_id: &str) {
// Find the username for this connection using the reverse mapping
if let Ok(Some(username)) = find_username_for_connection(connection_id) {
info!("Connection closed for user {}, cleaning up", username);
// Cancel the heartbeat schedule
if let Err(e) = scheduler::cancel_schedule(&username) {
// Not an error if schedule doesn't exist
trace!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e);
}
// Cancel any pending clear-activity schedule
let _ = scheduler::cancel_schedule(&format!("{}-clear", username));
// Clean up cache entries
let conn_key = connection_key(&username);
let _ = cache::remove(&conn_key);
let _ = cache::remove(&sequence_key(&username));
// Clean up reverse mapping
let reverse_key = format!("discord.reverse.{}", connection_id);
let _ = cache::remove(&reverse_key);
info!("Cleaned up connection state for user {}", username);
} else {
// Just clean up the reverse mapping if we can't find the username
let reverse_key = format!("discord.reverse.{}", connection_id);
let _ = cache::remove(&reverse_key);
}
}
/// Connects to the Discord gateway for a user.
pub fn connect(username: &str, token: &str) -> Result<(), Error> {
// Check if already connected and connection is valid
if is_connected(username) {
info!("Reusing existing connection for user {}", username);
return Ok(());
}
// Clean up any stale connection state
cleanup_connection(username);
info!("Connecting to Discord gateway for user {}", username);
// Store token for later use
cache::set_string(&token_key(username), token, 86400)?;
// Get Discord Gateway URL
let gateway = get_discord_gateway()?;
info!("Using gateway: {}", gateway);
// Connect to Discord gateway
let headers = std::collections::HashMap::new();
let conn_id = websocket::connect(
&gateway,
headers,
username, // Use username as connection ID for easy lookup
)?;
info!("WebSocket connection established: {}", conn_id);
// Store connection ID
let conn_key = connection_key(username);
cache::set_string(&conn_key, &conn_id, 86400)?;
// Send identify immediately (don't wait for Hello)
identify(username)?;
info!("Successfully connected and identified user {}", username);
Ok(())
}
/// Handles a WebSocket message from Discord.
pub fn handle_websocket_message(connection_id: &str, message: &str) -> Result<(), Error> {
let response: GatewayResponse = serde_json::from_str(message)
.map_err(|e| Error::msg(format!("Failed to parse gateway message: {}", e)))?;
// Update sequence number if present
if let Some(seq) = response.s {
// Find username for this connection
if let Some(username) = find_username_for_connection(connection_id)? {
cache::set_string(&sequence_key(&username), &seq.to_string(), 86400)?;
}
}
match response.op {
10 => {
// Hello - we already identified in connect(), nothing to do
}
11 => {
// Heartbeat ACK - no action needed
}
1 => {
// Heartbeat request - send heartbeat
if let Some(username) = find_username_for_connection(connection_id)? {
send_heartbeat(&username)?;
}
}
_ => {
trace!("Received Discord gateway op: {}", response.op);
}
}
Ok(())
}
/// Handles heartbeat callback from scheduler.
pub fn handle_heartbeat_callback(username: &str) -> Result<(), Error> {
send_heartbeat(username)
}
/// Handles clear activity callback from scheduler.
pub fn handle_clear_activity_callback(username: &str) -> Result<(), Error> {
info!("Clearing activity for user {}", username);
let conn_key = connection_key(username);
if let Some(conn_id) = cache::get_string(&conn_key)?.filter(|s| !s.is_empty()) {
// Send empty presence to clear activity
let msg = GatewayMessage {
op: PRESENCE_OP_CODE,
d: PresencePayload {
activities: vec![],
since: 0,
status: "dnd".to_string(),
afk: false,
},
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
}
Ok(())
}
/// Disconnects from Discord for a user.
pub fn disconnect(username: &str) -> Result<(), Error> {
info!("Disconnecting from Discord for user {}", username);
// Cancel the heartbeat schedule
if let Err(e) = scheduler::cancel_schedule(username) {
warn!("Failed to cancel heartbeat schedule: {:?}", e);
}
// Close the WebSocket connection
let conn_key = connection_key(username);
if let Some(conn_id) = cache::get_string(&conn_key)?.filter(|s| !s.is_empty()) {
if let Err(e) = websocket::close_connection(&conn_id, 1000, "Navidrome disconnect") {
warn!("Failed to close WebSocket connection: {:?}", e);
}
// Clean up reverse mapping
let reverse_key = format!("discord.reverse.{}", conn_id);
let _ = cache::remove(&reverse_key);
}
// Clean up cache entries
let _ = cache::remove(&conn_key);
let _ = cache::remove(&sequence_key(username));
Ok(())
}
/// Sends an activity update to Discord.
pub fn send_activity(
client_id: &str,
username: &str,
token: &str,
mut activity: Activity,
) -> Result<(), Error> {
let conn_key = connection_key(username);
let conn_id = cache::get_string(&conn_key)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("Not connected to Discord"))?;
// Process image URL
activity.assets.large_image = process_image(&activity.assets.large_image, client_id, token)?;
// Send presence update
let msg = GatewayMessage {
op: PRESENCE_OP_CODE,
d: PresencePayload {
activities: vec![activity],
since: 0,
status: "dnd".to_string(),
afk: false,
},
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
Ok(())
}
// ============================================================================
// Internal Functions
// ============================================================================
fn find_username_for_connection(connection_id: &str) -> Result<Option<String>, Error> {
// This is a simple approach - in production you might want to maintain a proper mapping
// For now, we'll use a known pattern to find the username
// The connection ID is stored as cache value, so we need to scan for it
// Since we can't iterate cache, we'll use a workaround with a reverse mapping
let reverse_key = format!("discord.reverse.{}", connection_id);
Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty()))
}
fn get_discord_gateway() -> Result<String, Error> {
let req = HttpRequest::new("https://discord.com/api/gateway")
.with_method("GET");
let resp = http::request::<String>(&req, None::<String>)?;
if resp.status_code() >= 400 {
return Err(Error::msg(format!(
"Failed to get Discord gateway: HTTP {}",
resp.status_code()
)));
}
let body = resp.body();
let data: std::collections::HashMap<String, String> = serde_json::from_slice(&body)
.map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?;
data.get("url")
.map(|url| url.to_string())
.ok_or_else(|| Error::msg("No URL in gateway response"))
}
fn identify(username: &str) -> Result<(), Error> {
info!("Identifying with Discord for user {}", username);
let conn_key = connection_key(username);
let conn_id = cache::get_string(&conn_key)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("No connection found"))?;
let token_k = token_key(username);
let token = cache::get_string(&token_k)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("No token found"))?;
// Store reverse mapping for connection -> username
let reverse_key = format!("discord.reverse.{}", conn_id);
cache::set_string(&reverse_key, username, 86400)?;
// Send identify
let msg = GatewayMessage {
op: GATE_OP_CODE,
d: IdentifyPayload {
token,
intents: 0,
properties: IdentifyProperties {
os: "Windows 10".to_string(),
browser: "Discord Client".to_string(),
device: "Discord Client".to_string(),
},
},
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
// Schedule heartbeat
scheduler::schedule_recurring(
&format!("@every {}s", HEARTBEAT_INTERVAL),
PAYLOAD_HEARTBEAT,
username,
)?;
Ok(())
}
fn send_heartbeat(username: &str) -> Result<(), Error> {
let conn_key = connection_key(username);
let conn_id = cache::get_string(&conn_key)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("No connection found"))?;
// Get sequence number
let seq_key = sequence_key(username);
let seq: Option<i64> = cache::get_string(&seq_key)?
.and_then(|s| s.parse().ok());
// Send heartbeat
let msg = GatewayMessage {
op: HEARTBEAT_OP_CODE,
d: seq,
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
Ok(())
}
fn process_image(image_url: &str, client_id: &str, token: &str) -> Result<String, Error> {
process_image_inner(image_url, client_id, token, false)
}
fn process_image_inner(
image_url: &str,
client_id: &str,
token: &str,
is_default: bool,
) -> Result<String, Error> {
let url = if image_url.is_empty() {
if is_default {
return Err(Error::msg("default image URL is empty"));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
} else {
image_url
};
// Already processed
if url.starts_with("mp:") {
return Ok(url.to_string());
}
// Check cache
let cache_key = format!("discord.image.{:x}", md5_hash(url));
if let Some(cached) = cache::get_string(&cache_key)?.filter(|s| !s.is_empty()) {
return Ok(cached);
}
// Process via Discord API
let body = format!(r#"{{"urls":["{}"]}}"#, url);
let api_url = format!(
"https://discord.com/api/v9/applications/{}/external-assets",
client_id
);
let req = HttpRequest::new(&api_url)
.with_method("POST")
.with_header("Authorization", token)
.with_header("Content-Type", "application/json");
let resp = http::request::<String>(&req, Some(body))?;
if resp.status_code() >= 400 {
if is_default {
return Err(Error::msg(format!(
"failed to process default image: HTTP {}",
resp.status_code()
)));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
}
let body = resp.body();
let data: Vec<std::collections::HashMap<String, String>> = serde_json::from_slice(&body)
.map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?;
if data.is_empty() {
if is_default {
return Err(Error::msg("no data returned for default image"));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
}
let asset_path = data[0]
.get("external_asset_path")
.map(|s| s.as_str())
.unwrap_or("");
if asset_path.is_empty() {
if is_default {
return Err(Error::msg("empty external_asset_path for default image"));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
}
let processed = format!("mp:{}", asset_path);
// Cache the result
let ttl = if is_default { 48 * 60 * 60 } else { 4 * 60 * 60 };
let _ = cache::set_string(&cache_key, &processed, ttl);
Ok(processed)
}
/// Simple hash function for cache keys.
fn md5_hash(input: &str) -> u64 {
// A simple hash - not actual MD5, but sufficient for cache keys
let mut hash: u64 = 0;
for (i, byte) in input.bytes().enumerate() {
hash = hash.wrapping_add((byte as u64).wrapping_mul((i as u64).wrapping_add(1)));
hash = hash.wrapping_mul(31);
}
hash
}
@@ -1,12 +1,8 @@
# 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.
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 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.**
**⚠️ WARNING: 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
@@ -16,73 +12,124 @@ The plugin exposes three capabilities:
- **WebSocketCallback** handles Discord gateway messages
- **SchedulerCallback** used to clear presence and send periodic heartbeats
It relies on several host services declared in `manifest.json`:
It relies on several host services declared in the manifest:
- `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:
The plugin registers capabilities using the PDK Register pattern:
```go
api.RegisterScrobbler(plugin)
api.RegisterWebSocketCallback(plugin.rpc)
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
import (
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
type discordPlugin struct{}
func init() {
scrobbler.Register(&discordPlugin{})
scheduler.Register(&discordPlugin{})
websocket.Register(&discordPlugin{})
}
```
The PDK generates the appropriate export wrappers automatically.
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.
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.
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.
The scheduler callback uses the `payload` field to route to the appropriate handler:
- `"heartbeat"` sends a heartbeat to Discord (recurring)
- `"clear-activity"` clears the presence and disconnects (one-time)
## Stateless Operation
Navidrome plugins are completely stateless each method call instantiates a new plugin instance and discards it
afterwards.
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.
To work within this model the plugin stores no in-memory state. Connections are keyed by username 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`.
## Configuration
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
| Key | Description | Example |
|---------------|-------------------------------------------|--------------------------------|
| `clientid` | Your Discord application ID | `123456789012345678` |
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
Each user is configured as a separate key with the `user.` prefix.
## Building
From the `plugins/examples/` directory:
```sh
make discord-rich-presence.ndp
```
Or manually:
```sh
cd discord-rich-presence
tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm .
zip -j discord-rich-presence.ndp manifest.json plugin.wasm
```
## Installation
Place the resulting `discord-rich-presence.ndp` in your Navidrome plugins folder and enable plugins in your configuration:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
```
## Files
| File | Description |
|-----------|------------------------------------------------------------------|
| `main.go` | Plugin entry point, capability registration, and implementations |
| `rpc.go` | Discord gateway communication and RPC logic |
| `go.mod` | Go module file |
## PDK
This plugin imports the Navidrome PDK subpackages directly:
```go
import (
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
```
The `go.mod` file uses `replace` directives to point to the local packages for development.
## Host Services Used
| Service | Purpose |
|-----------|------------------------------------------------------------------|
| Cache | Store Discord sequence numbers and processed image URLs |
| Scheduler | Schedule heartbeats (recurring) and activity clearing (one-time) |
| WebSocket | Maintain persistent connection to Discord gateway |
| Artwork | Get track artwork URLs for rich presence display |
## Implementation Details
See `main.go` and `rpc.go` for the complete implementation.
@@ -0,0 +1,32 @@
module discord-rich-presence
go 1.25
require (
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/stretchr/testify v1.11.1
)
require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
@@ -0,0 +1,73 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,201 @@
// Discord Rich Presence Plugin for Navidrome
//
// This 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.
//
// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback
//
// 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.
package main
import (
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
// Configuration keys
const (
clientIDKey = "clientid"
userKeyPrefix = "user."
)
// discordPlugin implements the scrobbler and scheduler interfaces.
type discordPlugin struct{}
// rpc handles Discord gateway communication (via websockets).
var rpc = &discordRPC{}
// init registers the plugin capabilities
func init() {
scrobbler.Register(&discordPlugin{})
scheduler.Register(&discordPlugin{})
websocket.Register(rpc)
}
// getConfig loads the plugin configuration.
func getConfig() (clientID string, users map[string]string, err error) {
clientID, ok := pdk.GetConfig(clientIDKey)
if !ok || clientID == "" {
pdk.Log(pdk.LogWarn, "missing ClientID in configuration")
return "", nil, nil
}
// Get all user keys with the "user." prefix
userKeys := host.ConfigKeys(userKeyPrefix)
if len(userKeys) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
users = make(map[string]string)
for _, key := range userKeys {
username := strings.TrimPrefix(key, userKeyPrefix)
token, exists := host.ConfigGet(key)
if exists && token != "" {
users[username] = token
}
}
if len(users) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
return clientID, users, nil
}
// getImageURL retrieves the track artwork URL.
func getImageURL(trackID string) string {
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
return ""
}
// Don't use localhost URLs
if strings.HasPrefix(artworkURL, "http://localhost") {
return ""
}
return artworkURL
}
// ============================================================================
// Scrobbler Implementation
// ============================================================================
// IsAuthorized checks if a user is authorized for Discord Rich Presence.
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) {
_, users, err := getConfig()
if err != nil {
return false, fmt.Errorf("failed to check user authorization: %w", err)
}
_, authorized := users[input.Username]
pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized))
return authorized, nil
}
// NowPlaying sends a now playing notification to Discord.
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title))
// Load configuration
clientID, users, err := getConfig()
if err != nil {
return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err)
}
// Check authorization
userToken, authorized := users[input.Username]
if !authorized {
return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username)
}
// Connect to Discord
if err := rpc.connect(input.Username, userToken); err != nil {
return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err)
}
// Cancel any existing completion schedule
_ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username))
// Calculate timestamps
now := time.Now().Unix()
startTime := (now - int64(input.Position)) * 1000
endTime := startTime + int64(input.Track.Duration)*1000
// Send activity update
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID,
Name: "Navidrome",
Type: 2, // Listening
Details: input.Track.Title,
State: input.Track.Artist,
Timestamps: activityTimestamps{
Start: startTime,
End: endTime,
},
Assets: activityAssets{
LargeImage: getImageURL(input.Track.ID),
LargeText: input.Track.Album,
},
}); err != nil {
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
}
// Schedule a timer to clear the activity after the track completes
remainingSeconds := int32(input.Track.Duration) - input.Position + 5
_, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username))
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err))
}
return nil
}
// Scrobble handles scrobble requests (no-op for Discord).
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error {
// Discord Rich Presence doesn't need scrobble events
return nil
}
// ============================================================================
// Scheduler Callback Implementation
// ============================================================================
// OnCallback handles scheduler callbacks.
func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring))
// Route based on payload
switch input.Payload {
case payloadHeartbeat:
// Heartbeat callback - scheduleId is the username
if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil {
return err
}
case payloadClearActivity:
// Clear activity callback - scheduleId is "username-clear"
username := strings.TrimSuffix(input.ScheduleID, "-clear")
if err := rpc.handleClearActivityCallback(username); err != nil {
return err
}
default:
pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload))
}
return nil
}
func main() {}
@@ -0,0 +1,227 @@
package main
import (
"errors"
"strings"
"testing"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestDiscordPlugin(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Discord Plugin Main Suite")
}
var _ = Describe("discordPlugin", func() {
var plugin discordPlugin
BeforeEach(func() {
plugin = discordPlugin{}
pdk.ResetMock()
host.CacheMock.ExpectedCalls = nil
host.CacheMock.Calls = nil
host.ConfigMock.ExpectedCalls = nil
host.ConfigMock.Calls = nil
host.WebSocketMock.ExpectedCalls = nil
host.WebSocketMock.Calls = nil
host.SchedulerMock.ExpectedCalls = nil
host.SchedulerMock.Calls = nil
host.ArtworkMock.ExpectedCalls = nil
host.ArtworkMock.Calls = nil
})
Describe("getConfig", func() {
It("returns config values when properly set", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.user1", "user.user2"})
host.ConfigMock.On("Get", "user.user1").Return("token1", true)
host.ConfigMock.On("Get", "user.user2").Return("token2", true)
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
clientID, users, err := getConfig()
Expect(err).ToNot(HaveOccurred())
Expect(clientID).To(Equal("test-client-id"))
Expect(users).To(HaveLen(2))
Expect(users["user1"]).To(Equal("token1"))
Expect(users["user2"]).To(Equal("token2"))
})
It("returns empty client ID when not set", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false)
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
clientID, users, err := getConfig()
Expect(err).ToNot(HaveOccurred())
Expect(clientID).To(BeEmpty())
Expect(users).To(BeNil())
})
It("returns nil users when users not configured", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{})
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
clientID, users, err := getConfig()
Expect(err).ToNot(HaveOccurred())
Expect(clientID).To(Equal("test-client-id"))
Expect(users).To(BeNil())
})
})
Describe("IsAuthorized", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns true for authorized user", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"})
host.ConfigMock.On("Get", "user.testuser").Return("token123", true)
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
Username: "testuser",
})
Expect(err).ToNot(HaveOccurred())
Expect(authorized).To(BeTrue())
})
It("returns false for unauthorized user", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"})
host.ConfigMock.On("Get", "user.otheruser").Return("token123", true)
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
Username: "testuser",
})
Expect(err).ToNot(HaveOccurred())
Expect(authorized).To(BeFalse())
})
})
Describe("NowPlaying", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns not authorized error when user not in config", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"})
host.ConfigMock.On("Get", "user.otheruser").Return("token", true)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
Username: "testuser",
Track: scrobbler.TrackInfo{Title: "Test Song"},
})
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue())
})
It("successfully sends now playing update", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"})
host.ConfigMock.On("Get", "user.testuser").Return("test-token", true)
// Connect mocks (isConnected check via heartbeat)
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
// Mock HTTP GET request for gateway discovery
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
gatewayReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
// Mock WebSocket connection
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
// Cancel existing clear schedule (may or may not exist)
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Image mocks - cache miss, will make HTTP request to Discord
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
// Mock HTTP request for Discord external assets API
assetsReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "external-assets")
})).Return(assetsReq)
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
// Schedule clear activity callback
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
Username: "testuser",
Position: 10,
Track: scrobbler.TrackInfo{
ID: "track1",
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
Duration: 180,
},
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Scrobble", func() {
It("does nothing (returns nil)", func() {
err := plugin.Scrobble(scrobbler.ScrobbleRequest{})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("OnCallback", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("handles heartbeat callback", func() {
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
ScheduleID: "testuser",
Payload: payloadHeartbeat,
IsRecurring: true,
})
Expect(err).ToNot(HaveOccurred())
})
It("handles clearActivity callback", func() {
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
ScheduleID: "testuser-clear",
Payload: payloadClearActivity,
})
Expect(err).ToNot(HaveOccurred())
})
It("logs warning for unknown payload", func() {
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
ScheduleID: "testuser",
Payload: "unknown",
})
Expect(err).ToNot(HaveOccurred())
})
})
})
@@ -1,26 +1,24 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
"name": "discord-rich-presence",
"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": {
"users": {
"reason": "To process scrobbles on behalf of users"
},
"http": {
"reason": "To communicate with Discord API for gateway discovery and image uploads",
"allowedUrls": {
"https://discord.com/api/*": ["GET", "POST"]
},
"allowLocalNetwork": false
"requiredHosts": [
"discord.com"
]
},
"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)"
"requiredHosts": [
"gateway.discord.gg"
]
},
"cache": {
"reason": "To store connection state and sequence numbers"
@@ -1,186 +0,0 @@
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() {}
+202 -204
View File
@@ -1,27 +1,20 @@
// Discord Rich Presence Plugin - RPC Communication
//
// This file handles all Discord gateway communication including WebSocket connections,
// presence updates, and heartbeat management. The discordRPC struct implements WebSocket
// callback interfaces and encapsulates all Discord communication logic.
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"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/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
@@ -34,7 +27,43 @@ const (
defaultImage = "https://i.imgur.com/hb3XPzA.png"
)
// Activity is a struct that represents an activity in Discord.
// Scheduler callback payloads for routing
const (
payloadHeartbeat = "heartbeat"
payloadClearActivity = "clear-activity"
)
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
type discordRPC struct{}
// ============================================================================
// WebSocket Callback Implementation
// ============================================================================
// OnTextMessage handles incoming WebSocket text messages.
func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error {
return r.handleWebSocketMessage(input.ConnectionID, input.Message)
}
// OnBinaryMessage handles incoming WebSocket binary messages.
func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID))
return nil
}
// OnError handles WebSocket errors.
func (r *discordRPC) OnError(input websocket.OnErrorRequest) error {
pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error))
return nil
}
// OnClose handles WebSocket connection closure.
func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason))
return nil
}
// activity represents a Discord activity.
type activity struct {
Name string `json:"name"`
Type int `json:"type"`
@@ -55,7 +84,7 @@ type activityAssets struct {
LargeText string `json:"large_text"`
}
// PresencePayload is a struct that represents a presence update in Discord.
// presencePayload represents a Discord presence update.
type presencePayload struct {
Activities []activity `json:"activities"`
Since int64 `json:"since"`
@@ -63,7 +92,7 @@ type presencePayload struct {
Afk bool `json:"afk"`
}
// IdentifyPayload is a struct that represents an identify payload in Discord.
// identifyPayload represents a Discord identify payload.
type identifyPayload struct {
Token string `json:"token"`
Intents int `json:"intents"`
@@ -76,22 +105,17 @@ type identifyProperties struct {
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)
}
// ============================================================================
// Image Processing
// ============================================================================
// processImage processes an image URL for Discord, with fallback to default image.
func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
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)
return r.processImage(defaultImage, clientID, token, true)
}
if strings.HasPrefix(imageURL, "mp:") {
@@ -100,95 +124,77 @@ func (r *discordRPC) processImageWithFallback(ctx context.Context, imageURL stri
// 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
cachedValue, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
return cachedValue, 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),
})
// Process via Discord API
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
req.SetHeader("Authorization", token)
req.SetHeader("Content-Type", "application/json")
req.SetBody([]byte(body))
// Handle HTTP error responses
if resp.Status >= 400 {
resp := req.Send()
if resp.Status() >= 400 {
if isDefaultImage {
return "", fmt.Errorf("failed to process default image: HTTP %d %s", resp.Status, resp.Error)
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
}
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)
return r.processImage(defaultImage, clientID, token, true)
}
var data []map[string]string
if err := json.Unmarshal(resp.Body, &data); err != nil {
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)
return r.processImage(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)
return r.processImage(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)
return r.processImage(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
var ttl int64 = 4 * 60 * 60 // 4 hours for regular images
if isDefaultImage {
ttl = 48 * time.Hour // 48 hours for default image
ttl = 48 * 60 * 60 // 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)
_ = host.CacheSetString(cacheKey, processedImage, ttl)
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", 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)
// ============================================================================
// Activity Management
// ============================================================================
processedImage, err := r.processImage(ctx, data.Assets.LargeImage, clientID, token)
// sendActivity sends an activity update to Discord.
func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State))
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
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
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err))
data.Assets.LargeImage = ""
} else {
log.Printf("Processed image for URL %s: %s", data.Assets.LargeImage, processedImage)
data.Assets.LargeImage = processedImage
}
@@ -197,111 +203,112 @@ func (r *discordRPC) sendActivity(ctx context.Context, clientID, username, token
Status: "dnd",
Afk: false,
}
return r.sendMessage(ctx, username, presenceOpCode, presence)
return r.sendMessage(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{})
// clearActivity clears the Discord activity for a user.
func (r *discordRPC) clearActivity(username string) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username))
return r.sendMessage(username, presenceOpCode, presencePayload{})
}
func (r *discordRPC) sendMessage(ctx context.Context, username string, opCode int, payload any) error {
// ============================================================================
// Low-level Communication
// ============================================================================
// sendMessage sends a message over the WebSocket connection.
func (r *discordRPC) sendMessage(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)
return fmt.Errorf("failed to marshal message: %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)
err = host.WebSocketSendText(username, string(b))
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
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)
// getDiscordGateway retrieves the Discord gateway URL.
func (r *discordRPC) getDiscordGateway() (string, error) {
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
resp := req.Send()
if resp.Status() != 200 {
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status())
}
var result map[string]string
err := json.Unmarshal(resp.Body, &result)
if err != nil {
if err := json.Unmarshal(resp.Body(), &result); 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)
// sendHeartbeat sends a heartbeat to Discord.
func (r *discordRPC) sendHeartbeat(username string) error {
seqNum, _, err := host.CacheGetInt(fmt.Sprintf("discord.seq.%s", username))
if err != nil {
return fmt.Errorf("failed to get sequence number: %w", err)
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending heartbeat for user %s: %d", username, seqNum))
return r.sendMessage(username, heartbeatOpCode, seqNum)
}
func (r *discordRPC) cleanupFailedConnection(ctx context.Context, username string) {
log.Printf("Cleaning up failed connection for user %s", username)
// cleanupFailedConnection cleans up a failed Discord connection.
func (r *discordRPC) cleanupFailedConnection(username string) {
pdk.Log(pdk.LogInfo, fmt.Sprintf("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)
if err := host.SchedulerCancelSchedule(username); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to cancel heartbeat schedule for user %s: %v", username, err))
}
// 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)
if err := host.WebSocketCloseConnection(username, 1000, "Connection lost"); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to close WebSocket connection for user %s: %v", username, err))
}
// 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)})
// Clean up cache entries
_ = host.CacheRemove(fmt.Sprintf("discord.seq.%s", username))
log.Printf("Cleaned up connection for user %s", username)
pdk.Log(pdk.LogInfo, fmt.Sprintf("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)
// isConnected checks if a user is connected to Discord by testing the heartbeat.
func (r *discordRPC) isConnected(username string) bool {
err := r.sendHeartbeat(username)
if err != nil {
log.Printf("Heartbeat test failed for user %s: %v", username, err)
pdk.Log(pdk.LogDebug, fmt.Sprintf("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)
// connect establishes a connection to Discord for a user.
func (r *discordRPC) connect(username, token string) error {
if r.isConnected(username) {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Reusing existing connection for user %s", username))
return nil
}
log.Printf("Creating new connection for user %s", username)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Creating new connection for user %s", username))
// Get Discord Gateway URL
gateway, err := r.getDiscordGateway(ctx)
gateway, err := r.getDiscordGateway()
if err != nil {
return fmt.Errorf("failed to get Discord gateway: %w", err)
}
log.Printf("Using gateway: %s", gateway)
pdk.Log(pdk.LogDebug, fmt.Sprintf("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)
_, err = host.WebSocketConnect(gateway, nil, username)
if err != nil {
return fmt.Errorf("failed to connect to WebSocket: %w", err)
}
// Send identify payload
@@ -314,89 +321,80 @@ func (r *discordRPC) connect(ctx context.Context, username string, token string)
Device: "Discord Client",
},
}
err = r.sendMessage(ctx, username, gateOpCode, payload)
if err != nil {
if err := r.sendMessage(username, gateOpCode, payload); 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)
cronExpr := fmt.Sprintf("@every %ds", heartbeatInterval)
scheduleID, err := host.SchedulerScheduleRecurring(cronExpr, payloadHeartbeat, username)
if err != nil {
return nil, fmt.Errorf("failed to parse WebSocket message: %w", err)
return fmt.Errorf("failed to schedule heartbeat: %w", err)
}
if v := message["s"]; v != nil {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduled heartbeat for user %s with ID %s", username, scheduleID))
pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username))
return nil
}
// disconnect closes the Discord connection for a user.
func (r *discordRPC) disconnect(username string) error {
if err := host.SchedulerCancelSchedule(username); err != nil {
return fmt.Errorf("failed to cancel schedule: %w", err)
}
if err := host.WebSocketCloseConnection(username, 1000, "Navidrome disconnect"); err != nil {
return fmt.Errorf("failed to close WebSocket connection: %w", err)
}
return nil
}
// handleWebSocketMessage processes incoming WebSocket messages from Discord.
func (r *discordRPC) handleWebSocketMessage(connectionID, message string) error {
if len(message) < 1024 {
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s': %s", connectionID, message))
} else {
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s' (truncated): %s...", connectionID, message[:1021]))
}
// Parse the message
var msg map[string]any
if err := json.Unmarshal([]byte(message), &msg); err != nil {
return fmt.Errorf("failed to parse WebSocket message: %w", err)
}
// Store sequence number if present
if v := msg["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)
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received sequence number for connection '%s': %d", connectionID, seq))
if err := host.CacheSetInt(fmt.Sprintf("discord.seq.%s", connectionID), seq, int64(heartbeatInterval*2)); err != nil {
return fmt.Errorf("failed to store sequence number for user %s: %w", connectionID, err)
}
}
return nil, nil
return 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 {
// handleHeartbeatCallback processes heartbeat scheduler callbacks.
func (r *discordRPC) handleHeartbeatCallback(username string) error {
if err := r.sendHeartbeat(username); 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)
pdk.Log(pdk.LogWarn, fmt.Sprintf("Heartbeat failed for user %s, cleaning up connection: %v", username, err))
r.cleanupFailedConnection(username)
return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
}
return nil
}
// handleClearActivityCallback processes clear activity scheduler callbacks.
func (r *discordRPC) handleClearActivityCallback(username string) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username))
if err := r.clearActivity(username); err != nil {
return fmt.Errorf("failed to clear activity: %w", err)
}
return nil, nil
pdk.Log(pdk.LogInfo, fmt.Sprintf("Disconnecting user %s", username))
if err := r.disconnect(username); err != nil {
return fmt.Errorf("failed to disconnect from Discord: %w", err)
}
return nil
}
@@ -0,0 +1,279 @@
package main
import (
"errors"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("discordRPC", func() {
var r *discordRPC
BeforeEach(func() {
r = &discordRPC{}
pdk.ResetMock()
host.CacheMock.ExpectedCalls = nil
host.CacheMock.Calls = nil
host.WebSocketMock.ExpectedCalls = nil
host.WebSocketMock.Calls = nil
host.SchedulerMock.ExpectedCalls = nil
host.SchedulerMock.Calls = nil
})
Describe("sendMessage", func() {
It("sends JSON message over WebSocket", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`)
})).Return(nil)
err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"})
Expect(err).ToNot(HaveOccurred())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
It("returns error when WebSocket send fails", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", mock.Anything, mock.Anything).
Return(errors.New("connection closed"))
err := r.sendMessage("testuser", presenceOpCode, map[string]string{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("connection closed"))
})
})
Describe("sendHeartbeat", func() {
It("retrieves sequence number from cache and sends heartbeat", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123")
})).Return(nil)
err := r.sendHeartbeat("testuser")
Expect(err).ToNot(HaveOccurred())
host.CacheMock.AssertExpectations(GinkgoT())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
It("returns error when cache get fails", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error"))
err := r.sendHeartbeat("testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cache error"))
})
})
Describe("connect", func() {
It("establishes WebSocket connection and sends identify payload", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
// Mock HTTP GET request for gateway discovery
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
// Mock WebSocket connection
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token")
})).Return(nil)
host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser").
Return("testuser", nil)
err := r.connect("testuser", "test-token")
Expect(err).ToNot(HaveOccurred())
})
It("reuses existing connection if connected", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
err := r.connect("testuser", "test-token")
Expect(err).ToNot(HaveOccurred())
host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything)
})
})
Describe("disconnect", func() {
It("cancels schedule and closes WebSocket connection", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
err := r.disconnect("testuser")
Expect(err).ToNot(HaveOccurred())
host.SchedulerMock.AssertExpectations(GinkgoT())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
})
Describe("cleanupFailedConnection", func() {
It("cancels schedule, closes WebSocket, and clears cache", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
r.cleanupFailedConnection("testuser")
host.SchedulerMock.AssertExpectations(GinkgoT())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
})
Describe("handleHeartbeatCallback", func() {
It("sends heartbeat successfully", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
err := r.handleHeartbeatCallback("testuser")
Expect(err).ToNot(HaveOccurred())
})
It("cleans up connection on heartbeat failure", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss"))
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
err := r.handleHeartbeatCallback("testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("connection cleaned up"))
})
})
Describe("handleClearActivityCallback", func() {
It("clears activity and disconnects", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
})).Return(nil)
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
err := r.handleClearActivityCallback("testuser")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("WebSocket callbacks", func() {
Describe("OnTextMessage", func() {
It("handles valid JSON message", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil)
err := r.OnTextMessage(websocket.OnTextMessageRequest{
ConnectionID: "testuser",
Message: `{"s":42}`,
})
Expect(err).ToNot(HaveOccurred())
})
It("returns error for invalid JSON", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnTextMessage(websocket.OnTextMessageRequest{
ConnectionID: "testuser",
Message: `not json`,
})
Expect(err).To(HaveOccurred())
})
})
Describe("OnBinaryMessage", func() {
It("handles binary message without error", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{
ConnectionID: "testuser",
Data: "AQID", // base64 encoded [0x01, 0x02, 0x03]
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("OnError", func() {
It("handles error without returning error", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnError(websocket.OnErrorRequest{
ConnectionID: "testuser",
Error: "test error",
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("OnClose", func() {
It("handles close without returning error", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnClose(websocket.OnCloseRequest{
ConnectionID: "testuser",
Code: 1000,
Reason: "normal close",
})
Expect(err).ToNot(HaveOccurred())
})
})
})
Describe("sendActivity", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
// Mock HTTP request for Discord external assets API (image processing)
// When processImage is called, it makes an HTTP request
httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
})
It("sends activity update to Discord", func() {
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) &&
strings.Contains(msg, `"name":"Test Song"`) &&
strings.Contains(msg, `"state":"Test Artist"`)
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: "Test Song",
Type: 2,
State: "Test Artist",
Details: "Test Album",
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("clearActivity", func() {
It("sends presence update with nil activities", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
})).Return(nil)
err := r.clearActivity("testuser")
Expect(err).ToNot(HaveOccurred())
})
})
})
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
@@ -0,0 +1,5 @@
# Rust build artifacts
/target/
# Cargo.lock is not needed for library crates (this is a cdylib)
Cargo.lock
@@ -0,0 +1,16 @@
[package]
name = "library-inspector-rs"
version = "1.0.0"
edition = "2021"
description = "Navidrome plugin that periodically logs library details and finds largest files"
authors = ["Navidrome Team"]
license = "GPL-3.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
nd-pdk = { path = "../../pdk/rust/nd-pdk" }
extism-pdk = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -0,0 +1,93 @@
# Library Inspector Plugin
A Navidrome plugin written in Rust that demonstrates the Library host service. It periodically logs details about all configured music libraries and finds the largest file in the root of each library directory.
## Features
- Logs comprehensive library statistics (songs, albums, artists, size, duration)
- Lists the largest file found in each library's root directory
- Configurable inspection interval via cron expression
- Runs an initial inspection on plugin load
## Requirements
- Rust toolchain with `wasm32-wasip1` target
- Navidrome with plugins enabled
## Building
```bash
# Install the WASM target if you haven't already
rustup target add wasm32-wasip1
# Build the plugin
cargo build --target wasm32-wasip1 --release
# Package as .ndp
zip -j library-inspector.ndp manifest.json target/wasm32-wasip1/release/library_inspector.wasm
```
Or use the provided Makefile from the examples directory:
```bash
cd plugins/examples
make library-inspector.ndp
```
## Installation
1. Copy the `.ndp` file to your Navidrome plugins folder
2. Enable plugins in your Navidrome configuration:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
```
3. Restart Navidrome and enable the plugin in the UI
## Configuration
Configure the inspection interval in the Navidrome UI (Settings → Plugins → library-inspector):
| Key | Description | Default |
|--------|------------------------------------------|--------------|
| `cron` | Cron expression for inspection interval | `@every 1m` |
## Permissions
This plugin requires:
- **Library** (with filesystem): To read library metadata and scan directories
- **Scheduler**: To schedule periodic inspections
## Example Output
```
=== Library Inspection Started ===
Found 2 libraries
----------------------------------------
Library: My Music (ID: 1)
Songs: 5432 tracks
Albums: 456
Artists: 234
Size: 45.67 GB
Duration: 312h 45m
Mount: /libraries/1
Largest file in root: cover.jpg (2.34 MB)
----------------------------------------
Library: Podcasts (ID: 2)
Songs: 128 tracks
Albums: 12
Artists: 8
Size: 3.21 GB
Duration: 48h 15m
Mount: /libraries/2
Largest file in root: episode-001.mp3 (156.78 MB)
=== Library Inspection Complete ===
```
## License
GPL-3.0 - Same as Navidrome
@@ -0,0 +1,16 @@
{
"name": "Library Inspector",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Periodically logs library details and finds largest files",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/library-inspector",
"permissions": {
"library": {
"reason": "To read library metadata and scan directories for file sizes",
"filesystem": true
},
"scheduler": {
"reason": "To schedule periodic library inspections"
}
}
}
@@ -0,0 +1,207 @@
//! Library Inspector Plugin for Navidrome
//!
//! This plugin demonstrates how to use the nd-pdk crate for accessing Navidrome
//! host services and implementing capabilities in Rust. It periodically logs details
//! about all music libraries and finds the largest file in the root of each library.
//!
//! ## Configuration
//!
//! Set the `cron` config key to customize the schedule (default: "@every 1m"):
//! ```toml
//! [PluginConfig.library-inspector]
//! cron = "@every 5m"
//! ```
use extism_pdk::*;
use nd_pdk::host::{library, scheduler};
use nd_pdk::lifecycle::{Error as LifecycleError, InitProvider};
use nd_pdk::scheduler::{CallbackProvider, Error as SchedulerError, SchedulerCallbackRequest};
use std::fs;
// Register capabilities using PDK macros
nd_pdk::register_lifecycle_init!(LibraryInspector);
nd_pdk::register_scheduler_callback!(LibraryInspector);
// ============================================================================
// Plugin Implementation
// ============================================================================
/// The library inspector plugin type.
#[derive(Default)]
struct LibraryInspector;
impl InitProvider for LibraryInspector {
fn on_init(&self) -> Result<(), LifecycleError> {
info!("Library Inspector plugin initializing...");
// Get cron expression from config, default to every minute
let cron = config::get("cron")
.ok()
.flatten()
.unwrap_or_else(|| "@every 1m".to_string());
info!("Scheduling library inspection with cron: {}", cron);
// Schedule the recurring task using nd-pdk host scheduler
match scheduler::schedule_recurring(&cron, "inspect", "library-inspect") {
Ok(schedule_id) => {
info!("Scheduled inspection task with ID: {}", schedule_id);
}
Err(e) => {
let error_msg = format!("Failed to schedule inspection: {}", e);
error!("{}", error_msg);
return Err(LifecycleError::new(error_msg));
}
}
// Run an initial inspection
inspect_libraries();
info!("Library Inspector plugin initialized successfully");
Ok(())
}
}
impl CallbackProvider for LibraryInspector {
fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> {
info!(
"Scheduler callback fired: schedule_id={}, payload={}, recurring={}",
req.schedule_id, req.payload, req.is_recurring
);
if req.payload == "inspect" {
inspect_libraries();
}
Ok(())
}
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Format bytes into human-readable size
fn format_size(bytes: i64) -> String {
const KB: i64 = 1024;
const MB: i64 = KB * 1024;
const GB: i64 = MB * 1024;
const TB: i64 = GB * 1024;
if bytes >= TB {
format!("{:.2} TB", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
/// Format duration in seconds to human-readable format
fn format_duration(seconds: f64) -> String {
let total_seconds = seconds as i64;
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
if hours > 0 {
format!("{}h {}m", hours, minutes)
} else {
format!("{}m", minutes)
}
}
/// Find the largest file in a directory (non-recursive)
fn find_largest_file(mount_point: &str) -> Option<(String, u64)> {
let entries = match fs::read_dir(mount_point) {
Ok(entries) => entries,
Err(e) => {
warn!("Failed to read directory {}: {}", mount_point, e);
return None;
}
};
let mut largest: Option<(String, u64)> = None;
for entry in entries.flatten() {
let path = entry.path();
// Only consider files, not directories
if !path.is_file() {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let size = metadata.len();
let name = entry.file_name().to_string_lossy().to_string();
match &largest {
None => largest = Some((name, size)),
Some((_, current_size)) if size > *current_size => {
largest = Some((name, size));
}
_ => {}
}
}
largest
}
/// Inspect and log all library details
fn inspect_libraries() {
info!("=== Library Inspection Started ===");
let libraries = match library::get_all_libraries() {
Ok(libs) => libs,
Err(e) => {
error!("Failed to get libraries: {}", e);
return;
}
};
if libraries.is_empty() {
info!("No libraries configured");
return;
}
info!("Found {} libraries", libraries.len());
for lib in &libraries {
info!("----------------------------------------");
info!("Library: {} (ID: {})", lib.name, lib.id);
info!(" Songs: {} tracks", lib.total_songs);
info!(" Albums: {}", lib.total_albums);
info!(" Artists: {}", lib.total_artists);
info!(" Size: {}", format_size(lib.total_size));
info!(" Duration: {}", format_duration(lib.total_duration));
// If we have filesystem access, find the largest file
if !lib.mount_point.is_empty() {
info!(" Mount: {}", lib.mount_point);
match find_largest_file(&lib.mount_point) {
Some((name, size)) => {
info!(
" Largest file in root: {} ({})",
name,
format_size(size as i64)
);
}
None => {
info!(" Largest file in root: (no files found)");
}
}
} else {
info!(" (Filesystem access not enabled)");
}
}
info!("=== Library Inspection Complete ===");
}
+72
View File
@@ -0,0 +1,72 @@
# Minimal Navidrome Plugin Example
This is a minimal example demonstrating how to create a Navidrome plugin using Go and the Navidrome PDK.
## Building
1. Install [TinyGo](https://tinygo.org/getting-started/install/)
2. Build the plugin:
```bash
go mod tidy
tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
zip -j minimal.ndp manifest.json plugin.wasm
```
Or using the examples Makefile:
```bash
cd plugins/examples
make minimal.ndp
```
## Installing
Copy `minimal.ndp` to your Navidrome plugins folder (default: `<data-folder>/plugins/`).
## Configuration
Enable plugins in your `navidrome.toml`:
```toml
[Plugins]
Enabled = true
# Add the plugin to your agents list
Agents = "lastfm,spotify,minimal"
```
## What This Example Demonstrates
- Plugin package structure (`.ndp` = zip with `manifest.json` + `plugin.wasm`)
- Using the Navidrome PDK `metadata` subpackage
- Implementing the `ArtistBiographyProvider` interface
- Registration pattern with `metadata.Register()`
## PDK Usage
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
type myPlugin struct{}
func init() {
metadata.Register(&myPlugin{})
}
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (metadata.ArtistBiographyResponse, error) {
return metadata.ArtistBiographyResponse{Biography: "..."}, nil
}
```
## Extending the Example
To add more capabilities, implement additional provider interfaces from the `metadata` package:
- `ArtistMBIDProvider` - Get MusicBrainz ID for an artist
- `ArtistURLProvider` - Get external URL for an artist
- `SimilarArtistsProvider` - Get similar artists
- `ArtistImagesProvider` - Get artist images
- `ArtistTopSongsProvider` - Get top songs for an artist
- `AlbumInfoProvider` - Get album information
- `AlbumImagesProvider` - Get album images
See the full documentation in `/plugins/README.md` for input/output formats.
+16
View File
@@ -0,0 +1,16 @@
module minimal-plugin
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
+14
View File
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31
View File
@@ -0,0 +1,31 @@
// Minimal example Navidrome plugin demonstrating the MetadataAgent capability.
//
// Build with:
//
// tinygo build -o minimal.wasm -target wasip1 -buildmode=c-shared .
//
// Install by copying minimal.ndp to your Navidrome plugins folder.
package main
import (
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
)
// minimalPlugin implements the metadata provider interfaces.
type minimalPlugin struct{}
// init registers the plugin implementation
func init() {
metadata.Register(&minimalPlugin{})
}
var _ metadata.ArtistBiographyProvider = (*minimalPlugin)(nil)
// GetArtistBiography returns a placeholder biography for the artist.
func (p *minimalPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
return &metadata.ArtistBiographyResponse{
Biography: "This is a placeholder biography for " + input.Name + ".",
}, nil
}
func main() {}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Minimal Example",
"author": "Navidrome",
"version": "1.0.0",
"description": "A minimal example plugin"
}
+12
View File
@@ -0,0 +1,12 @@
# Build the Now Playing Logger Python plugin
.PHONY: build test clean
WASM_FILE = nowplaying-py.wasm
build: $(WASM_FILE)
$(WASM_FILE): plugin/__init__.py
extism-py plugin/__init__.py -o $(WASM_FILE)
clean:
rm -f $(WASM_FILE)
+112
View File
@@ -0,0 +1,112 @@
# Now Playing Logger Plugin (Python)
A Python example plugin that demonstrates the **Scheduler** and **SubsonicAPI** host services by periodically logging what is currently playing in Navidrome.
## Features
- Uses `scheduler_schedulerecurring` host function to set up a recurring task
- Uses `subsonicapi_call` host function to query the `getNowPlaying` API
- Configurable cron expression and user via plugin config
- Demonstrates Python host function imports using `@extism.import_fn`
## Prerequisites
- [extism-py](https://github.com/extism/python-pdk) - Python PDK compiler
```bash
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
```
> **Note:** `extism-py` requires [Binaryen](https://github.com/WebAssembly/binaryen/) (`wasm-merge`, `wasm-opt`) to be installed.
## Building
From the `plugins/examples` directory:
```bash
make nowplaying-py.ndp
```
Or directly:
```bash
extism-py plugin/__init__.py -o plugin.wasm
zip -j nowplaying-py.ndp manifest.json plugin.wasm
```
## Installation
1. Copy `nowplaying-py.ndp` to your Navidrome plugins folder
2. Enable plugins in `navidrome.toml`:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
```
3. Configure the plugin in the UI (Settings → Plugins → nowplaying-py)
## Configuration
| Key | Description | Default |
|--------|-------------------------------------|---------------|
| `cron` | Cron expression for check frequency | `*/1 * * * *` |
| `user` | Navidrome user for SubsonicAPI | `admin` |
## Testing
Test the manifest:
```bash
extism call nowplaying-py.wasm nd_manifest --wasi
```
## Output
When running, the plugin logs messages like:
```
🎵 john is playing: Pink Floyd - Comfortably Numb (The Wall)
🎵 jane is playing: Radiohead - Paranoid Android (OK Computer)
```
Or when no one is playing:
```
🎵 No users currently playing music
```
## How It Works
1. **Initialization (`nd_on_init`)**: Reads the cron expression from config and schedules a recurring task using the Scheduler host service.
2. **Callback (`nd_scheduler_callback`)**: When the scheduled task fires, calls the SubsonicAPI `getNowPlaying` endpoint and logs the results.
## Host Function Usage (Python)
This plugin demonstrates how to call Navidrome host functions from Python:
```python
import extism
import json
# Import the host function
@extism.import_fn("extism:host/user", "subsonicapi_call")
def _subsonicapi_call(offset: int) -> int:
"""Raw host function - returns memory offset."""
...
# Wrapper for JSON marshalling
def subsonicapi_call(uri: str) -> dict:
request = {"uri": uri}
request_bytes = json.dumps(request).encode('utf-8')
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_call(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise Exception(response["error"])
return json.loads(response.get("responseJSON", "{}"))
```
@@ -0,0 +1,18 @@
{
"name": "Now Playing Logger (Python)",
"author": "Navidrome",
"version": "1.0.0",
"description": "Periodically logs currently playing tracks - Python example demonstrating Scheduler and SubsonicAPI host services",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/nowplaying-py",
"permissions": {
"scheduler": {
"reason": "Schedule periodic checks for now playing status"
},
"subsonicapi": {
"reason": "Query the getNowPlaying API endpoint"
},
"users": {
"reason": "Access user information for SubsonicAPI authorization"
}
}
}
@@ -0,0 +1,168 @@
# Now Playing Logger Plugin for Navidrome
#
# This plugin demonstrates the Scheduler and SubsonicAPI host services by
# periodically logging what is currently playing in Navidrome.
#
# Build with:
# extism-py plugin/__init__.py -o nowplaying-py.wasm
#
# Configuration:
# [PluginConfig.nowplaying-py]
# cron = "*/1 * * * *" # Every minute (default)
# user = "admin" # User to query getNowPlaying (default)
import extism
import json
# Schedule ID for our recurring task
SCHEDULE_ID = "nowplaying-check"
# =============================================================================
# Host Function Imports
# =============================================================================
# These are custom host functions provided by Navidrome.
# We import them using the extism:host/user namespace.
@extism.import_fn("extism:host/user", "scheduler_schedulerecurring")
def _scheduler_schedulerecurring(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "subsonicapi_call")
def _subsonicapi_call(offset: int) -> int:
"""Raw host function - do not call directly."""
...
# =============================================================================
# Host Function Wrappers
# =============================================================================
# These wrappers handle JSON marshalling/unmarshalling and memory management.
# They were copied from plugins/host/python due to extism-py limitations.
def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str:
"""Schedule a recurring task using a cron expression.
Args:
cron_expression: Cron format (e.g., "*/1 * * * *" for every minute)
payload: Data to pass to the callback
schedule_id: Unique identifier for the schedule
Returns:
The schedule ID (same as input or auto-generated)
"""
request = {
"cronExpression": cron_expression,
"payload": payload,
"scheduleId": schedule_id
}
request_bytes = json.dumps(request).encode('utf-8')
request_mem = extism.memory.alloc(request_bytes)
response_offset = _scheduler_schedulerecurring(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise Exception(response["error"])
return response.get("newScheduleId", schedule_id)
def subsonicapi_call(uri: str) -> dict:
"""Call a Subsonic API endpoint.
Args:
uri: API path (e.g., "getNowPlaying")
Returns:
Parsed JSON response from the API
"""
request = {"uri": uri}
request_bytes = json.dumps(request).encode('utf-8')
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_call(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise Exception(response["error"])
# Parse the nested JSON response
response_json = response.get("responseJson", "{}")
return json.loads(response_json)
# =============================================================================
# Plugin Exports
# =============================================================================
@extism.plugin_fn
def nd_on_init():
"""Initialize the plugin by scheduling the recurring task."""
# Read cron expression from config, default to every minute
cron = extism.Config.get_str("cron")
if not cron:
cron = "*/1 * * * *"
extism.log(extism.LogLevel.Info, f"Now Playing Logger initializing with cron: {cron}")
try:
schedule_id = scheduler_schedule_recurring(cron, "check", SCHEDULE_ID)
extism.log(extism.LogLevel.Info, f"Scheduled recurring task with ID: {schedule_id}")
except Exception as e:
extism.log(extism.LogLevel.Error, f"Failed to schedule task: {e}")
raise
# No output - lifecycle callbacks don't return responses
@extism.plugin_fn
def nd_scheduler_callback():
"""Handle scheduler callback - check and log now playing tracks."""
input_data = extism.input_json()
schedule_id = input_data.get("scheduleId", "")
# Only handle our schedule
if schedule_id != SCHEDULE_ID:
return
try:
# Read user from config, default to admin
user = extism.Config.get_str("user")
if not user:
user = "admin"
# Call the getNowPlaying API
response = subsonicapi_call(f"getNowPlaying?u={user}")
# Extract the subsonic-response
subsonic_response = response.get("subsonic-response", {})
now_playing = subsonic_response.get("nowPlaying", {})
entries = now_playing.get("entry", [])
if not entries:
extism.log(extism.LogLevel.Info, "🎵 No users currently playing music")
else:
# Handle both single entry and list of entries
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
artist = entry.get("artist", "Unknown Artist")
title = entry.get("title", "Unknown Title")
album = entry.get("album", "Unknown Album")
username = entry.get("username", "Unknown User")
extism.log(
extism.LogLevel.Info,
f"🎵 {username} is playing: {artist} - {title} ({album})"
)
# No output - scheduler callbacks don't return responses
except Exception as e:
extism.log(extism.LogLevel.Error, f"Failed to get now playing: {e}")
# Errors are logged but scheduler callbacks don't return responses
@@ -1,88 +0,0 @@
# 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
```
@@ -1,16 +0,0 @@
{
"$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"]
}
}
}
@@ -1,68 +0,0 @@
//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{})
}
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "webhook-rs"
version = "1.0.0"
edition = "2021"
description = "Navidrome webhook plugin that sends HTTP requests on scrobble events"
authors = ["Navidrome Team"]
license = "GPL-3.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
nd-pdk = { path = "../../pdk/rust/nd-pdk" }
extism-pdk = "1.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+77
View File
@@ -0,0 +1,77 @@
# Webhook Scrobbler Plugin (Rust)
A Navidrome plugin written in Rust that sends HTTP webhook notifications when tracks are scrobbled. This is useful for integrating with external services like home automation systems, Discord bots, monitoring tools, or any service that can receive HTTP requests.
## Features
- Sends HTTP GET requests to configured URLs on every scrobble event
- Includes track metadata (title, artist, album, username, timestamp) as query parameters
- Supports multiple webhook URLs (comma-separated)
- All users are automatically authorized (no external service authentication required)
- Now playing events are ignored (webhooks fire only on completed scrobbles)
## Prerequisites
- [Rust](https://rustup.rs/) toolchain
- WebAssembly target: `rustup target add wasm32-unknown-unknown`
## Building
From the `plugins/examples` directory:
```bash
make webhook-rs.ndp
```
Or build directly with cargo:
```bash
cd webhook-rs
cargo build --release
zip -j webhook-rs.ndp manifest.json target/wasm32-unknown-unknown/release/webhook_rs.wasm
```
## Installation
Copy `webhook-rs.ndp` to your Navidrome plugins folder (configured via `Plugins.Folder` in your config).
## Configuration
Configure in the Navidrome UI (Settings → Plugins → webhook-rs):
| Key | Description | Example |
|--------|--------------------------------------|-----------------------------------------------------------|
| `urls` | Comma-separated list of webhook URLs | `https://example.com/hook1,https://example.com/hook2` |
## Webhook Request Format
When a scrobble occurs, the plugin sends an HTTP GET request to each configured URL with the following query parameters:
| Parameter | Description |
|-------------|-----------------------------------------------|
| `title` | Track title |
| `artist` | Track artist |
| `album` | Album name |
| `user` | Username who scrobbled |
| `timestamp` | Unix timestamp when the track started playing |
Example request:
```
GET https://example.com/webhook?title=Song%20Name&artist=Artist%20Name&album=Album%20Name&user=john&timestamp=1703270400
```
## Use Cases
- **Home Automation**: Trigger lights or displays when music starts playing
- **Discord/Slack Notifications**: Post currently playing tracks to a channel
- **Logging/Analytics**: Track listening history in an external system
- **IFTTT/Zapier Integration**: Connect to thousands of services via webhook triggers
## Development
The plugin is built using the [Extism Rust PDK](https://github.com/extism/rust-pdk). Key exports:
- `nd_manifest` - Returns plugin metadata and permissions
- `nd_scrobbler_is_authorized` - Always returns `true` (all users authorized)
- `nd_scrobbler_now_playing` - No-op (returns success without action)
- `nd_scrobbler_scrobble` - Sends webhooks to configured URLs
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Webhook Scrobbler",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Sends HTTP webhooks on scrobble events",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/webhook-rs",
"permissions": {
"http": {
"reason": "To send webhook notifications to configured URLs",
"requiredHosts": ["*"]
},
"users": {
"reason": "Receive scrobble events for users assigned to this plugin"
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Webhook Scrobbler Plugin for Navidrome
//!
//! This plugin demonstrates how to build a Navidrome plugin in Rust using the nd-pdk crate.
//! It implements the Scrobbler capability and sends HTTP GET requests to configured URLs
//! whenever a track is scrobbled.
//!
//! ## Configuration
//!
//! Set the `urls` config key to a comma-separated list of webhook URLs:
//! ```toml
//! [PluginConfig.webhook-rs]
//! urls = "https://example.com/webhook1,https://example.com/webhook2"
//! ```
use extism_pdk::{config, error, http, info, warn, HttpRequest};
use nd_pdk::scrobbler::{
Error, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest,
Scrobbler,
};
// Register the WASM exports for the Scrobbler capability
nd_pdk::register_scrobbler!(WebhookPlugin);
// ============================================================================
// Plugin Implementation
// ============================================================================
/// The webhook plugin type. Implements the Scrobbler trait.
#[derive(Default)]
struct WebhookPlugin;
impl Scrobbler for WebhookPlugin {
/// Checks if a user is authorized. This plugin authorizes all users.
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error> {
info!("Authorization check for user: {}", req.username);
Ok(true)
}
/// Handles now playing notifications. This plugin ignores them (webhooks only on scrobble).
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> {
info!(
"Now playing (ignored): {} - {} for user {}",
req.track.artist, req.track.title, req.username
);
Ok(())
}
/// Handles scrobble events by sending HTTP GET requests to configured URLs.
fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> {
// Get configured URLs
let urls_config = match config::get("urls") {
Ok(Some(urls)) if !urls.is_empty() => urls,
_ => {
warn!("No webhook URLs configured. Set 'urls' in plugin config.");
return Ok(());
}
};
info!(
"Scrobble: {} - {} by user {}",
req.track.artist, req.track.title, req.username
);
// Build query parameters
let query = format!(
"?title={}&artist={}&album={}&user={}&timestamp={}",
urlencode(&req.track.title),
urlencode(&req.track.artist),
urlencode(&req.track.album),
urlencode(&req.username),
req.timestamp
);
// Send requests to each configured URL
for url in urls_config.split(',') {
let url = url.trim();
if url.is_empty() {
continue;
}
let full_url = format!("{}{}", url, query);
info!("Sending webhook to: {}", full_url);
let http_req = HttpRequest::new(&full_url);
match http::request::<()>(&http_req, None) {
Ok(res) => {
let status = res.status_code();
if status >= 200 && status < 300 {
info!("Webhook succeeded: {} (status {})", url, status);
} else {
warn!("Webhook returned non-2xx status: {} (status {})", url, status);
}
}
Err(e) => {
error!("Webhook failed for {}: {:?}", url, e);
}
}
}
Ok(())
}
}
/// Simple URL encoding for query parameters.
fn urlencode(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
' ' => result.push_str("%20"),
_ => {
for b in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", b));
}
}
}
}
result
}
+129 -17
View File
@@ -1,32 +1,144 @@
# Wikimedia Artist Metadata Plugin
# Wikimedia Plugin for Navidrome
This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint.
A Navidrome plugin that fetches artist metadata from Wikidata, DBpedia, and Wikipedia.
## Implemented Methods
## Generating the Plugin
- `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.
This plugin was generated using the XTP CLI:
All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia.
```bash
xtp plugin init \
--schema-file plugins/schemas/metadata_agent.yaml \
--template go \
--path ./wikimedia \
--name wikimedia-plugin
```
## How it Works
## Features
- 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.
- **Artist URL**: Fetches Wikipedia URL for an artist using Wikidata (by MBID or name), DBpedia, or falls back to a Wikipedia search URL
- **Artist Biography**: Fetches the introductory text from the artist's Wikipedia page
- **Artist Images**: Fetches artist images from Wikidata
## Building
To build the plugin to WASM:
### Using TinyGo
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
```bash
tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm .
zip -j wikimedia.ndp manifest.json plugin.wasm
```
## Usage
### Using the Makefile
Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory.
From the `plugins/examples` directory:
---
```bash
make wikimedia.ndp
```
For more details, see the source code in `plugin.go`.
### Using XTP CLI
```bash
xtp plugin build
zip -j wikimedia.ndp manifest.json dist/plugin.wasm
```
## Installation
Copy the `.ndp` file to your Navidrome plugins folder:
```bash
cp wikimedia.ndp /path/to/navidrome/plugins/
```
Then enable plugins in your `navidrome.toml`:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/navidrome/plugins"
```
Add the plugin to your agents list:
```toml
Agents = "lastfm,spotify,wikimedia"
```
## Testing with Extism CLI
Install the [Extism CLI](https://extism.org/docs/install):
```bash
brew install extism/tap/extism # macOS
# or see https://extism.org/docs/install for other platforms
```
Extract the wasm file from the package and test:
```bash
# Extract wasm from package
unzip -p wikimedia.ndp plugin.wasm > wikimedia.wasm
# Test artist URL lookup with MBID (The Beatles)
extism call wikimedia.wasm nd_get_artist_url --wasi \
--input '{"id":"1","name":"The Beatles","mbid":"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}' \
--allow-host "query.wikidata.org"
```
Expected output:
```json
{"url":"https://en.wikipedia.org/wiki/The_Beatles"}
```
### Test artist biography
```bash
extism call wikimedia.wasm nd_get_artist_biography --wasi \
--input '{"id":"1","name":"The Beatles","mbid":"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}' \
--allow-host "query.wikidata.org" \
--allow-host "en.wikipedia.org"
```
### Test artist images
```bash
extism call wikimedia.wasm nd_get_artist_images --wasi \
--input '{"id":"1","name":"The Beatles","mbid":"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}' \
--allow-host "query.wikidata.org"
```
Expected output:
```json
{"images":[{"url":"http://commons.wikimedia.org/wiki/Special:FilePath/Beatles%20ad%201965%20just%20the%20beatles%20crop.jpg","size":0}]}
```
## Project Structure
```
wikimedia/
├── main.go # Plugin implementation with Wikimedia API logic
├── pdk.gen.go # Generated types and export wrappers (DO NOT EDIT)
├── go.mod # Go module file
├── go.sum # Go module checksums
├── prepare.sh # Build preparation script
└── xtp.toml # XTP plugin configuration
```
## API Endpoints Used
| Service | Endpoint | Purpose |
|-----------|--------------------------------------|-----------------------------------------------------------|
| Wikidata | `https://query.wikidata.org/sparql` | SPARQL queries for Wikipedia URLs and images |
| DBpedia | `https://dbpedia.org/sparql` | Fallback SPARQL queries for Wikipedia URLs and short bios |
| Wikipedia | `https://en.wikipedia.org/w/api.php` | MediaWiki API for article extracts |
## Implemented Functions
| Function | Description |
|---------------------------|-----------------------------------------------|
| `nd_manifest` | Returns plugin manifest with HTTP permissions |
| `nd_get_artist_url` | Returns Wikipedia URL for an artist |
| `nd_get_artist_biography` | Returns artist biography from Wikipedia |
| `nd_get_artist_images` | Returns artist image URLs from Wikidata |
+16
View File
@@ -0,0 +1,16 @@
module wikimedia-plugin
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
+14
View File
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+351
View File
@@ -0,0 +1,351 @@
// Wikimedia plugin for Navidrome - fetches artist metadata from Wikidata, DBpedia and Wikipedia.
//
// Build with:
//
// tinygo build -o wikimedia.wasm -target wasip1 -buildmode=c-shared .
//
// Install by copying the .ndp file to your Navidrome plugins folder.
package main
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// wikimediaPlugin implements the metadata provider interfaces for the methods we support.
type wikimediaPlugin struct{}
// init registers the plugin implementation
func init() {
metadata.Register(&wikimediaPlugin{})
}
// Ensure wikimediaPlugin implements the provider interfaces
var (
_ metadata.ArtistURLProvider = (*wikimediaPlugin)(nil)
_ metadata.ArtistBiographyProvider = (*wikimediaPlugin)(nil)
_ metadata.ArtistImagesProvider = (*wikimediaPlugin)(nil)
)
const (
wikidataEndpoint = "https://query.wikidata.org/sparql"
dbpediaEndpoint = "https://dbpedia.org/sparql"
mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
)
// SPARQL response types
type SPARQLResult struct {
Results struct {
Bindings []SPARQLBinding `json:"bindings"`
} `json:"results"`
}
type SPARQLBinding struct {
Sitelink *SPARQLValue `json:"sitelink,omitempty"`
Wiki *SPARQLValue `json:"wiki,omitempty"`
Comment *SPARQLValue `json:"comment,omitempty"`
Img *SPARQLValue `json:"img,omitempty"`
}
type SPARQLValue struct {
Value string `json:"value"`
}
// MediaWiki API response types
type MediaWikiExtractResult struct {
Query struct {
Pages map[string]MediaWikiPage `json:"pages"`
} `json:"query"`
}
type MediaWikiPage struct {
PageID int `json:"pageid"`
Ns int `json:"ns"`
Title string `json:"title"`
Extract string `json:"extract"`
Missing bool `json:"missing"`
}
// sparqlQuery executes a SPARQL query and returns the result
func sparqlQuery(endpoint, query string) (*SPARQLResult, error) {
form := url.Values{}
form.Set("query", query)
req := pdk.NewHTTPRequest(pdk.MethodPost, endpoint)
req.SetHeader("Accept", "application/sparql-results+json")
req.SetHeader("Content-Type", "application/x-www-form-urlencoded")
req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0")
req.SetBody([]byte(form.Encode()))
pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query))
resp := req.Send()
if resp.Status() != 200 {
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, errors.New("not found")
}
return &result, nil
}
// mediawikiQuery executes a MediaWiki API query
func mediawikiQuery(params url.Values) ([]byte, error) {
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
req := pdk.NewHTTPRequest(pdk.MethodGet, apiURL)
req.SetHeader("Accept", "application/json")
req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0")
resp := req.Send()
if resp.Status() != 200 {
return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.Status())
}
return resp.Body(), nil
}
// getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name
func getWikidataWikipediaURL(mbid, name string) (string, error) {
var q string
if mbid != "" {
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, "\"", "\\\"")
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(wikidataEndpoint, q)
if err != nil {
return "", err
}
if result.Results.Bindings[0].Sitelink != nil {
return result.Results.Bindings[0].Sitelink.Value, nil
}
return "", errors.New("not found")
}
// getDBpediaWikipediaURL fetches the Wikipedia URL from DBpedia using name
func getDBpediaWikipediaURL(name string) (string, error) {
if name == "" {
return "", errors.New("not found")
}
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(dbpediaEndpoint, q)
if err != nil {
return "", err
}
if result.Results.Bindings[0].Wiki != nil {
return result.Results.Bindings[0].Wiki.Value, nil
}
return "", errors.New("not found")
}
// getDBpediaComment fetches the DBpedia comment (short bio) for an artist
func getDBpediaComment(name string) (string, error) {
if name == "" {
return "", errors.New("not found")
}
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(dbpediaEndpoint, q)
if err != nil {
return "", err
}
if result.Results.Bindings[0].Comment != nil {
return result.Results.Bindings[0].Comment.Value, nil
}
return "", errors.New("not found")
}
// getWikipediaExtract fetches the intro text from Wikipedia
func getWikipediaExtract(pageTitle string) (string, error) {
if pageTitle == "" {
return "", errors.New("page title required")
}
params := url.Values{}
params.Set("action", "query")
params.Set("format", "json")
params.Set("prop", "extracts")
params.Set("exintro", "true")
params.Set("explaintext", "true")
params.Set("titles", pageTitle)
params.Set("redirects", "1")
body, err := mediawikiQuery(params)
if err != nil {
return "", err
}
var result MediaWikiExtractResult
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse MediaWiki response: %w", err)
}
for _, page := range result.Query.Pages {
if page.Missing {
continue
}
if page.Extract != "" {
return strings.TrimSpace(page.Extract), nil
}
}
return "", errors.New("not found")
}
// extractPageTitleFromURL extracts the page title from a Wikipedia 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
}
// GetArtistURL returns the Wikipedia URL for an artist
func (*wikimediaPlugin) GetArtistURL(input metadata.ArtistRequest) (*metadata.ArtistURLResponse, error) {
pdk.Log(pdk.LogDebug, fmt.Sprintf("GetArtistURL: name=%s, mbid=%s", input.Name, input.MBID))
// 1. Try Wikidata (MBID first, then name)
wikiURL, err := getWikidataWikipediaURL(input.MBID, input.Name)
if err == nil && wikiURL != "" {
return &metadata.ArtistURLResponse{URL: wikiURL}, nil
}
if err != nil {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Wikidata URL failed: %v", err))
}
// 2. Try DBpedia (Name only)
if input.Name != "" {
wikiURL, err = getDBpediaWikipediaURL(input.Name)
if err == nil && wikiURL != "" {
return &metadata.ArtistURLResponse{URL: wikiURL}, nil
}
if err != nil {
pdk.Log(pdk.LogDebug, fmt.Sprintf("DBpedia URL failed: %v", err))
}
}
// 3. Fallback to search URL
if input.Name != "" {
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(input.Name))
pdk.Log(pdk.LogInfo, fmt.Sprintf("URL not found, falling back to search URL: %s", searchURL))
return &metadata.ArtistURLResponse{URL: searchURL}, nil
}
return nil, errors.New("could not determine Wikipedia URL")
}
// GetArtistBiography returns the biography for an artist from Wikipedia
func (*wikimediaPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
pdk.Log(pdk.LogDebug, fmt.Sprintf("GetArtistBiography: name=%s, mbid=%s", input.Name, input.MBID))
// 1. Get Wikipedia URL (using the logic from GetArtistURL)
wikiURL := ""
tempURL, wdErr := getWikidataWikipediaURL(input.MBID, input.Name)
if wdErr == nil && tempURL != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Found Wikidata URL: %s", tempURL))
wikiURL = tempURL
} else if input.Name != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Wikidata URL failed (%v), trying DBpedia", wdErr))
tempURL, dbErr := getDBpediaWikipediaURL(input.Name)
if dbErr == nil && tempURL != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Found DBpedia URL: %s", tempURL))
wikiURL = tempURL
} else {
pdk.Log(pdk.LogDebug, fmt.Sprintf("DBpedia URL failed: %v", dbErr))
}
}
// 2. If Wikipedia URL found, try MediaWiki API
if wikiURL != "" {
pageTitle, err := extractPageTitleFromURL(wikiURL)
if err == nil {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Extracted page title: %s", pageTitle))
bio, err := getWikipediaExtract(pageTitle)
if err == nil && bio != "" {
pdk.Log(pdk.LogDebug, "Found Wikipedia extract")
return &metadata.ArtistBiographyResponse{Biography: bio}, nil
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Wikipedia extract failed: %v", err))
} else {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Error extracting page title from URL '%s': %v", wikiURL, err))
}
}
// 3. Fallback to DBpedia Comment (Name only)
if input.Name != "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Falling back to DBpedia comment for name: %s", input.Name))
bio, err := getDBpediaComment(input.Name)
if err == nil && bio != "" {
pdk.Log(pdk.LogDebug, "Found DBpedia comment")
return &metadata.ArtistBiographyResponse{Biography: bio}, nil
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("DBpedia comment failed: %v", err))
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("Biography not found for: %s (%s)", input.Name, input.MBID))
return nil, errors.New("biography not found")
}
// GetArtistImages returns artist images from Wikidata
func (*wikimediaPlugin) GetArtistImages(input metadata.ArtistRequest) (*metadata.ArtistImagesResponse, error) {
pdk.Log(pdk.LogDebug, fmt.Sprintf("GetArtistImages: name=%s, mbid=%s", input.Name, input.MBID))
var q string
if input.MBID != "" {
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, input.MBID)
} else if input.Name != "" {
escapedName := strings.ReplaceAll(input.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(wikidataEndpoint, q)
if err != nil {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Image not found for: %s (%s)", input.Name, input.MBID))
return nil, errors.New("image not found")
}
if result.Results.Bindings[0].Img != nil {
return &metadata.ArtistImagesResponse{
Images: []metadata.ImageInfo{{URL: result.Results.Bindings[0].Img.Value, Size: 0}},
}, nil
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("Image not found for: %s (%s)", input.Name, input.MBID))
return nil, errors.New("image not found")
}
// Required main function - init() handles registration
func main() {}
+9 -12
View File
@@ -1,20 +1,17 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
"name": "wikimedia",
"name": "Wikimedia",
"author": "Navidrome",
"version": "1.0.0",
"description": "Artist information and images from Wikimedia Commons",
"website": "https://commons.wikimedia.org",
"capabilities": ["MetadataAgent"],
"description": "Fetches artist metadata from Wikidata, DBpedia and Wikipedia",
"website": "https://navidrome.org",
"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
"reason": "Fetch metadata from Wikimedia APIs",
"requiredHosts": [
"query.wikidata.org",
"dbpedia.org",
"en.wikipedia.org"
]
}
}
}
-391
View File
@@ -1,391 +0,0 @@
//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{})
}
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
set -eou pipefail
# Function to check if a command exists
command_exists () {
command -v "$1" >/dev/null 2>&1
}
# Function to compare version numbers for "less than"
version_lt() {
test "$(echo "$@" | tr " " "\n" | sort -V | head -n 1)" = "$1" && test "$1" != "$2"
}
missing_deps=0
# Check for Go
if ! (command_exists go); then
missing_deps=1
echo "❌ Go (supported version between 1.20 - 1.24) is not installed."
echo ""
echo "To install Go, visit the official download page:"
echo "👉 https://go.dev/dl/"
echo ""
echo "Or install it using a package manager:"
echo ""
echo "🔹 macOS (Homebrew):"
echo " brew install go"
echo ""
echo "🔹 Ubuntu/Debian:"
echo " sudo apt-get -y install golang-go"
echo ""
echo "🔹 Arch Linux:"
echo " sudo pacman -S go"
echo ""
echo "🔹 Windows:"
echo " scoop install go"
echo ""
fi
# Check for the right version of Go, needed by TinyGo (supports go 1.20 - 1.24)
if (command_exists go); then
compat=0
for v in `seq 20 24`; do
if (go version | grep -q "go1.$v"); then
compat=1
fi
done
if [ $compat -eq 0 ]; then
echo "❌ Supported Go version is not installed. Must be Go 1.20 - 1.24."
echo ""
fi
fi
ARCH=$(arch)
# Check for TinyGo and its version
if ! (command_exists tinygo); then
missing_deps=1
echo "❌ TinyGo is not installed."
echo ""
echo "To install TinyGo, visit the official download page:"
echo "👉 https://tinygo.org/getting-started/install/"
echo ""
echo "Or install it using a package manager:"
echo ""
echo "🔹 macOS (Homebrew):"
echo " brew tap tinygo-org/tools"
echo " brew install tinygo"
echo ""
echo "🔹 Ubuntu/Debian:"
echo " wget https://github.com/tinygo-org/tinygo/releases/download/v0.34.0/tinygo_0.34.0_$ARCH.deb"
echo " sudo dpkg -i tinygo_0.34.0_$ARCH.deb"
echo ""
echo "🔹 Arch Linux:"
echo " pacman -S extra/tinygo"
echo ""
echo "🔹 Windows:"
echo " scoop install tinygo"
echo ""
else
# Check TinyGo version
tinygo_version=$(tinygo version | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -n1)
if version_lt "$tinygo_version" "0.34.0"; then
missing_deps=1
echo "❌ TinyGo version must be >= 0.34.0 (current version: $tinygo_version)"
echo "Please update TinyGo to a newer version."
echo ""
fi
fi
go install golang.org/x/tools/cmd/goimports@latest
+17
View File
@@ -0,0 +1,17 @@
app_id = ""
# This is where 'xtp plugin push' expects to find the wasm file after the build script has run.
bin = "dist/plugin.wasm"
extension_point_id = ""
name = "wikimedia-plugin"
[scripts]
# xtp plugin build runs this script to generate the wasm file
build = "mkdir -p dist && tinygo build -buildmode c-shared -target wasip1 -o dist/plugin.wasm ."
# xtp plugin init runs this script to format the plugin code
format = "go fmt && go mod tidy && goimports -w main.go"
# xtp plugin init runs this script before running the format script
prepare = "bash prepare.sh && go get ./..."