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:
+90
-19
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
Executable
+16
@@ -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
|
||||
@@ -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=
|
||||
Executable
+264
@@ -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", ¤tFloat)
|
||||
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() {}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", ¤tFloat)
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
if openFloat == 0 {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
change := ((currentFloat - openFloat) / openFloat) * 100
|
||||
return fmt.Sprintf("%.2f", change)
|
||||
}
|
||||
|
||||
// Required by Go WASI build
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line, prepend [Crypto]
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Crypto] ")
|
||||
|
||||
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
|
||||
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
|
||||
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
|
||||
}
|
||||
@@ -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 one‑time 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 comma‑separated list of `username:token` pairs used for authorization
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/...
|
||||
```
|
||||
|
||||
Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins
|
||||
directory.
|
||||
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() {}
|
||||
@@ -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 ===");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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() {}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Minimal Example",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "A minimal example plugin"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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×tamp=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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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={}×tamp={}",
|
||||
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
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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() {}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
Executable
+17
@@ -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 ./..."
|
||||
Reference in New Issue
Block a user