feat(plugins): experimental support for plugins (#3998)

* feat(plugins): add minimal test agent plugin with API definitions

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

* feat: add plugin manager with auto-registration and unique agent names

Introduced a plugin manager that scans the plugins folder for subdirectories containing plugin.wasm files and auto-registers them as agents using the directory name as the unique agent name. Updated the configuration to support plugins with enabled/folder options, and ensured the plugin manager is started as a concurrent task during server startup. The wasmAgent now returns the plugin directory name for AgentName, ensuring each plugin agent is uniquely identifiable. This enables dynamic plugin discovery and integration with the agents orchestrator.

* test: add Ginkgo suite and test for plugin manager auto-registration

Added a Ginkgo v2 suite bootstrap (plugins_suite_test.go) for the plugins package and a test (manager_test.go) to verify that plugins in the testdata folder are auto-registered and can be loaded as agents. The test uses a mock DataStore and asserts that the agent is registered and its AgentName matches the plugin directory. Updated go.mod and go.sum for wazero dependency required by plugin WASM support.

* test(plugins): ensure test WASM plugin is always freshly built before running suite; add real-plugin Ginkgo tests. Add BeforeSuite to plugins suite to build plugins/testdata/agent/plugin.wasm using Go WASI build command, matching README instructions. Remove plugin.wasm before build to guarantee a clean build. Add full real-plugin Ginkgo/Gomega tests for wasmAgent, covering all methods and error cases. Fix manager_test.go to use pointer to Manager. This ensures plugin tests are always run against a freshly compiled WASM binary, increasing reliability and reproducibility.

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

* feat(plugins): implement persistent compilation cache for WASM agent plugins

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

* feat(plugins): implement instance pooling for wasmAgent to improve resource management

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

* feat(plugins): enhance logging for wasmAgent and plugin manager operations

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

* feat(plugins): implement HttpService for handling HTTP requests in WASM plugins

Also add a sample Wikimedia plugin

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

* feat(plugins): standardize error handling in wasmAgent and MinimalAgent

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

* refactor: clean up wikimedia plugin code

Standardized error creation using 'errors.New' where formatting was not needed. Introduced a constant for HTTP request timeouts. Removed commented-out log statement. Improved code comments for clarity and accuracy.

* refactor: use unified SPARQLResult struct and parser for SPARQL responses

Introduced a single SPARQLResult struct to represent all possible SPARQL response fields (sitelink, wiki, comment, img). Added a parseSPARQLResult helper to unmarshal and check for empty results, simplifying all fetch functions and improving type safety and maintainability.

* feat(plugins): improve error handling in HTTP request processing

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

* fix: background plugin compilation, logging, and race safety

Implemented background WASM plugin compilation with concurrency limits, proper closure capture, and global compilation cache to avoid data races. Added debug and warning logs for plugin compilation results, including elapsed time. Ensured plugin registration is correct and all tests pass.

* perf: implement true lazy loading for agents

Changed agent instantiation to be fully lazy. The Agents struct now stores agent names in order and only instantiates each agent on first use, caching the result. This preserves agent call order, improves server startup time, and ensures thread safety. Updated all agent methods and tests to use the new pattern. No changes to agent registration or interface. All tests pass.

* fix: ensure wasm plugin instances are closed via runtime.AddCleanup

Introduced runtime.AddCleanup to guarantee that the Close method of WASM plugin instances is called, even if they are garbage collected from the sync.Pool. Modified the sync.Pool.New function in manager.go to register a cleanup function for each loaded instance that implements Close. Updated agent.go to handle the pooledInstance wrapper containing the instance and its cleanup handle. Ensured cleanup.Stop() is called before explicitly closing an instance (on error or agent shutdown) to prevent double closing. This fixes a potential resource leak where instances could be GC'd from the pool without proper cleanup.

* refactor: break down long functions in plugin manager and agent

Refactored plugins/manager.go and plugins/agent.go to improve readability and reduce function length. Extracted pool initialization logic into newPluginPool and background compilation/agent factory logic into precompilePlugin/createAgentFactory in manager.go. Extracted pool retrieval/validation and cleanup function creation into getValidPooledInstance/createPoolCleanupFunc in agent.go.

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

* refactor(plugins): rename wasmAgent to wasmArtistAgent

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

* feat(api): add AlbumMetadataService with AlbumInfo and AlbumImages requests

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

* refactor(plugin): rename MinimalAgent for artist metadata service

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

* feat(api): implement wasmAlbumAgent for album metadata service with GetAlbumInfo and GetAlbumImages methods

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

* refactor(plugins): simplify wasmAlbumAgent and wasmArtistAgent by using wasmBasePlugin

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

* feat(plugins): add support for ArtistMetadataService and AlbumMetadataService in plugin manager

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

* feat(plugins): enhance plugin pool creation with custom runtime and precompilation support

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

* refactor(plugins): implement generic plugin pool and agent factory for improved service handling

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

* refactor(plugins): reorganize plugin management

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

* refactor(plugins): improve function signatures for clarity and consistency

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

* feat(plugins): implement background precompilation for plugins and agent factory creation

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

* refactor(plugins): include instanceID in logging for better traceability

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

* test(plugins): add tests for plugin pre-compilation and agent factory synchronization

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

* feat(plugins): add minimal album test agent plugin for AlbumMetadataService

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

* feat(plugins): rename fake artist and album test agent plugins for metadata services

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

* feat(makefile): add Makefile for building plugin WASM binaries

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

* feat(plugins): add FakeMultiAgent plugin implementing Artist and Album metadata services

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

* refactor(plugins): remove log statements from FakeArtistAgent and FakeMultiAgent methods

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

* refactor: split AlbumInfoRetriever and AlbumImageRetriever, update all usages

Split the AlbumInfoRetriever interface into two: AlbumInfoRetriever (for album metadata) and AlbumImageRetriever (for album images), to better separate concerns and simplify implementations. Updated all agents, providers, plugins, and tests to use the new interfaces and methods. Removed the now-unnecessary mockAlbumAgents in favor of the shared mockAgents. Fixed a missing images slice declaration in lastfm agent. All tests pass except for known ignored persistence tests. This change reduces code duplication, improves clarity, and keeps the codebase clean and organized.

* feat(plugins): add Cover Art Archive AlbumMetadataService plugin for album cover images

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

* refactor: remove wasm module pooling

it was causing issues with the GC and the Close methods

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

* refactor: rename metadata service files to adapter naming convention

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

* refactor: unify album and artist method calls by introducing callMethod function

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

* refactor: unify album and artist method calls by introducing callMethod function

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

* fix: handle nil values in data redaction process

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

* fix: add timeout for plugin compilation to prevent indefinite blocking

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

* feat: implement ScrobblerService plugin with authorization and scrobbling capabilities

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

* refactor: simplify generalization

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

* fix: tests

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

* refactor: enhance plugin management by improving scanning and loading mechanisms

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

* refactor: update plugin creation functions to return specific interfaces for better type safety

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

* refactor: enhance wasmBasePlugin to support specific plugin types for improved type safety

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

* refactor: implement MediaMetadataService with combined artist and album methods

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

* refactor: improve MediaMetadataService plugin implementation and testing structure

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

* refactor: add tests for Adapter Media Agent and improve plugin documentation

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

* docs: add README for Navidrome Plugin System with detailed architecture and usage guidelines

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

* refactor: enhance agent management with plugin loading and caching

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

* refactor: update agent discovery logic to include only local agent when no config is specified

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

* refactor: encapsulate agent caching logic in agentCache struct\n\nReplaced direct map/mutex usage for agent caching in Agents with a dedicated agentCache struct. This improves readability, maintainability, and testability by centralizing TTL and concurrency logic. Cleaned up comments and ensured all linter and test requirements are met.

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

* fix: correct file extension filter in goimports command

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

* refactor: use defer to unlock the mutex

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

* chore: move Cover Art Archive AlbumMetadataService plugins to an example folder

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

* fix: handle errors when creating media metadata and scrobbler service plugins

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

* fix: increase compilation timeout to one minute

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

* feat: add configurable plugin compilation timeout

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

* feat: implement plugin scrobbler support in PlayTracker

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

* feat: add context management and Stop method to buffered scrobbler

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

* feat: add username field to scrobbler requests and update logging

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

* fix: data race in test

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

* refactor: rename http proto files to host and update references

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

* refactor: remove unused plugin registration methods from manager

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

* feat: extend plugin manifests and implement plugin management commands

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

* Update utils/files.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix for code scanning alert no. 43: Arbitrary file access during archive extraction ("Zip Slip")

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* feat: add plugin dev workflow support

Added new CLI commands to improve plugin development workflow: 'plugin dev' to create symlinks from development directories to plugins folder, 'plugin refresh' to reload plugins without restarting Navidrome, enhanced 'plugin remove' to handle symlinked development plugins correctly, and updated 'plugin list' to display development plugins with '(dev)' indicator. These changes make the plugin development workflow more efficient by allowing developers to work on plugins in their own directories, link them to Navidrome without copying files, refresh plugins after changes without restart, and clean up safely.

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

* feat(plugins): implement timer service with register and cancel functionality - WIP

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

* feat(plugins): implement timer service with register and cancel functionality - WIP

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

* feat(plugins): implement timer service with register and cancel functionality - WIP

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

* feat(plugins): implement timer service with register and cancel functionality

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

* fix: lint errors

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

* feat(README): update documentation to include TimerCallbackService and its functionality

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

* feat(plugins): add InitService with OnInit method and initialization tracking - WIP

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

* feat(plugins): add tests for InitService and plugin initialization tracking

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

* feat(plugins): expand documentation on plugin system implementation and architecture

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

* fix: panic

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

* feat(plugins): redirect plugins' stderr to logs

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

* feat(plugins): add safe accessor methods for TimerService

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

* feat(plugins): add plugin-specific configuration support in InitRequest and documentation

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

* feat(plugins): add TimerCallbackService plugin adapter and integration

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

* refactor(plugins): rename services for consistency and clarity

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

* feat(plugins): add mutex for configuration access and clone plugin config

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

* refactor(tests): remove configtest dependency to prevent data races in integration tests

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

* refactor(plugins): remove PluginName method from WASM plugin implementations and update LoadPlugin to accept service type

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

* feat(plugins): implement instance pooling for wasmBasePlugin to improve performance - WIP

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

* feat(plugins): add wasmInstancePool for managing WASM plugin instances with TTL and max size

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

* fix(plugins): correctly pass error to done function in wasmBasePlugin

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

* refactor(plugins): rename service types to capabilities for consistency

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

* refactor(plugins): simplify instance management in wasmBasePlugin by removing error handling in closure

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

* refactor(plugins): update wasmBasePlugin and wasmInstancePool to return errors for better error handling

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

* refactor(plugins): rename InitService to LifecycleManagement for consistency

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

* refactor(plugins): fix instance ID logging in wasmBasePlugin

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

* refactor(plugins): extract instance ID logging to a separate function in wasmBasePlugin, to avoid vet error

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

* refactor(plugins): make timers be isolated per plugin

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

* refactor(plugins): make timers be isolated per plugin

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

* refactor(plugins): rename HttpServiceImpl to httpServiceImpl for consistency and improve logging

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

* feat(plugins): add config service for plugin-specific configuration management

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

* Update plugins/manager.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update plugins/manager.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat(crontab): implement crontab service for scheduling and canceling jobs

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

* fix(singleton): fix deadlock issue when a constructor calls GetSingleton again

Signed-off-by: Deluan <deluan@navidrome.org> (+1 squashed commit)
Squashed commits:
[325a96ea2] fix(singleton): fix deadlock issue when a constructor calls GetSingleton again

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

* feat(scheduler): implement Scheduler for one-time and recurring job scheduling, merging CrontabService and TimerService

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

* fix(scheduler): race condition in the scheduleOneTime and scheduleRecurring methods when replacing jobs with the same ID

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

* refactor(scheduler): consolidate job scheduling logic into a single helper function

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

* refactor(plugin): rename GetInstance method to Instantiate for clarity

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

* feat(plugins): add WebSocket service for handling connections and messages

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

* feat(crypto-ticker): add WebSocket plugin for real-time cryptocurrency price tracking

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

* feat(websocket): enhance connection management and callback handling

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

* feat(manager): only create one adapter instance for each adapter/capability pair

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

* fix(websocket): ensure proper resource management by closing response body and use defer to unlocking mutexes

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

* fix: flaky test

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

* feat(plugin): refactor WebSocket service integration and improve error logging

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

* feat(plugin): add SchedulerCallback support and improve reconnection logic

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

* fix: test panic

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

* docs: add crypto-ticker plugin example to README

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

* feat(manager): add LoadAllPlugins and LoadAllMediaAgents methods with slice.Map integration

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

* feat(api): add Timestamp field to ScrobblerNowPlayingRequest and update related methods

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

* feat(websocket): add error field to response messages for better error handling

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

* feat(cache): implement CacheService with string, int, float, and byte operations

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

* feat(tests): update buffered scrobbler tests for improved scrobble verification and use RWMutex in mock repo

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

* refactor(cache): simplify cache service implementation and remove unnecessary synchronization

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

* feat(tests): add build step for test plugins in the test suite

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

* wip

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

* feat(scheduler): implement named scheduler callbacks and enhance Discord plugin integration

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

* feat(rpc): enhance activity image processing and improve error handling in Discord integration

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

* feat(discord): enhance activity state with artist list and add large text asset

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

* fix tests

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

* feat(artwork): implement ArtworkService for retrieving artwork URLs

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

* Add playback position to scrobble NowPlaying (#4089)

* test(playtracker): cover playback position

* address review comment

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

---------

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

* fix merge

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

* refactor: remove unnecessary check for empty slice in Map function

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

* fix: update reflex.conf to include .wasm file extension

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

* fix(scanner): normalize attribute strings and add edge case tests for PID calculation

Relates to https://github.com/navidrome/navidrome/issues/4183#issuecomment-2952729458

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

* test(ui): fix warnings (#4187)

* fix(ui): address test warnings

* ignore lint error in test

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

---------

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

* refactor(server): optimize top songs lookup (#4189)

* optimize top songs lookup

* Optimize title matching queries

* refactor: simplify top songs matching

* improve error handling and logging in track loading functions

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

* test: add cases for fallback to title matching and combined MBID/title matching

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

---------

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

* fix(ui): playlist details overflow in spotify-based themes (#4184)

* test: ensure playlist details width

* fix(test): simplify expectation for minWidth in NDPlaylistDetails

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

* fix(test): test all themes

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

---------

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

* chore(deps): update TagLib to version 2.1 (#4185)

* chore: update cross-taglib

* fix(taglib): add logging for TagLib version

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

---------

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

* test: verify agents fallback (#4191)

* build(docker): downgrade Alpine version from 3.21 to 3.19, oldest supported version.

This is to reduce the image size, as we don't really need the latest.

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

* fix tests

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

* feat(runtime): implement pooled WASM runtime and module for better instance management

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

* fix(discord-plugin): adjust timer delay calculation for track completion

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

* resolve PR comments

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

* feat(plugins): implement cache cleanup by size functionality

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

* fix(manager): return error from getCompilationCache and handle it in ScanPlugins

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

* fix possible rce condition

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

* feat(docs): update README to include Cache and Artwork services

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

* feat(manager): add permissions support for host services in custom runtime - WIP

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

* feat(manifest): add permissions field to plugin manifests - WIP

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

* test(permissions): implement permission validation and testing for plugins - WIP

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

* feat(plugins): add unauthorized_plugin to test permission enforcement - WIP

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

* feat(docs): add Plugin Permission System section to README - WIP

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

* feat(manifest): add detailed reasons for permissions in plugin manifests - WIP

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

* feat(permissions): implement granular HTTP permissions for plugins - WIP

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

* feat(permissions): implement HTTP and WebSocket permissions for plugins - WIP

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

* refactor

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

* refactor: unexport all plugins package private symbols

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

* update docs

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

* refactor: rename plugin_lifecycle_manager

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

* docs: add discord-rich-presence plugin example to README

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

* feat: add support for PATCH, HEAD, and OPTIONS HTTP methods

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

* feat: use folder names as unique identifiers for plugins

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

* fix: read config just once, to avoid data race in tests

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

* refactor: rename pluginName to pluginID for consistency across services

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

* fix: use symlink name instead of folder name for plugin registration

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

* feat: update plugin output format to include ID and enhance README with symlink usage

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

* refactor: implement shared plugin discovery function to streamline plugin scanning and error handling

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

* feat: show plugin permissions in `plugin info`

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

* feat: add JSON schema for Navidrome Plugin manifest and generate corresponding Go types - WIP

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

* feat: implement typed permissions for plugins to enhance permission handling

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

* feat: refactor plugin permissions to use typed schema and improve validation - WIP

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

* feat: update HTTP permissions handling to use typed schema for allowed URLs - WIP

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

* feat: remove unused JSON schema validation for plugin manifests

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

* feat: remove unused fields from PluginPackage struct in package.go

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

* feat: update file permissions in tests and remove unused permission parsing function

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

* feat: refactor test plugin creation to use typed permissions and remove legacy helper

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

* feat: add website field to plugin manifests and update test cases

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

* refactor: permission schema to use basePermission structure for consistency

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

* feat: enhance host service management by adding permission checks for each service

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

* refactor: reorganize code files

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

* refactor: simplify custom runtime creation by removing compilation cache parameter

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

* doc: add WebSocketService and update ConfigService for plugin-specific configuration

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

* feat: implement WASM loading optimization to enhance plugin instance creation speed

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

* refactor: rename custom runtime functions and update related tests for clarity

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

* refactor: enhance plugin structure with compilation handling and error reporting

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

* refactor: improve logging and context tracing in runtime and wasm base plugin

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

* refactor: enhance runtime management with scoped runtime and caching improvements

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

* refactor: implement EnsureCompiled method for improved plugin compilation handling

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

* refactor: implement cached module management with TTL for improved performance

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

* refactor: replace map with sync.Map

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

* refactor: adjust time tolerance in scrobble buffer repository tests to avoid flakiness

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

* refactor: enhance image processing with fallback mechanism for improved error handling

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

* docs: review test plugins readme

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

* feat: set default timeout for HTTP client to 10 seconds

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

* feat: enhance wasm instance pool with concurrency limits and timeout settings

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

* feat(discordrp): implement caching for processed image URLs with configurable TTL

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Deluan Quintão
2025-06-22 20:45:38 -04:00
committed by GitHub
parent 7640c474cf
commit f1fc2cd9b9
162 changed files with 34692 additions and 339 deletions
+1568
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/tetratelabs/wazero"
)
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
func newWasmMediaAgent(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmMediaAgent{
wasmBasePlugin: &wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilityMetadataAgent,
loader: loader,
loadFunc: func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
return l.Load(ctx, path)
},
},
}
}
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
type wasmMediaAgent struct {
*wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]
}
func (w *wasmMediaAgent) AgentName() string {
return w.id
}
func (w *wasmMediaAgent) mapError(err error) error {
if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
return agents.ErrNotFound
}
return err
}
// Album-related methods
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
return callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*agents.AlbumInfo, error) {
res, err := inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
if err != nil {
return nil, w.mapError(err)
}
if res == nil || res.Info == nil {
return nil, agents.ErrNotFound
}
info := res.Info
return &agents.AlbumInfo{
Name: info.Name,
MBID: info.Mbid,
Description: info.Description,
URL: info.Url,
}, nil
})
}
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
return callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
res, err := inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
if err != nil {
return nil, w.mapError(err)
}
return convertExternalImages(res.Images), nil
})
}
// Artist-related methods
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
return callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (string, error) {
res, err := inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
if err != nil {
return "", w.mapError(err)
}
return res.GetMbid(), nil
})
}
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
return callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (string, error) {
res, err := inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
if err != nil {
return "", w.mapError(err)
}
return res.GetUrl(), nil
})
}
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
return callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (string, error) {
res, err := inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
if err != nil {
return "", w.mapError(err)
}
return res.GetBiography(), nil
})
}
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
return callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) ([]agents.Artist, error) {
resp, err := inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
if err != nil {
return nil, w.mapError(err)
}
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
for _, a := range resp.GetArtists() {
artists = append(artists, agents.Artist{
Name: a.GetName(),
MBID: a.GetMbid(),
})
}
return artists, nil
})
}
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
return callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
res, err := inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
if err != nil {
return nil, w.mapError(err)
}
return convertExternalImages(res.Images), nil
})
}
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
return callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) ([]agents.Song, error) {
resp, err := inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
if err != nil {
return nil, w.mapError(err)
}
songs := make([]agents.Song, 0, len(resp.GetSongs()))
for _, s := range resp.GetSongs() {
songs = append(songs, agents.Song{
Name: s.GetName(),
MBID: s.GetMbid(),
})
}
return songs, nil
})
}
// Helper function to convert ExternalImage objects from the API to the agents package
func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
result := make([]agents.ExternalImage, 0, len(images))
for _, img := range images {
result = append(result, agents.ExternalImage{
URL: img.GetUrl(),
Size: int(img.GetSize()),
})
}
return result
}
+220
View File
@@ -0,0 +1,220 @@
package plugins
import (
"context"
"errors"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Adapter Media Agent", func() {
var ctx context.Context
var mgr *Manager
BeforeEach(func() {
ctx = GinkgoT().Context()
// Ensure plugins folder is set to testdata
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir
mgr = createManager()
mgr.ScanPlugins()
})
Describe("AgentName and PluginName", func() {
It("should return the plugin name", func() {
agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
Expect(agent.PluginID()).To(Equal("multi_plugin"))
})
It("should return the agent name", func() {
agent, ok := mgr.LoadMediaAgent("multi_plugin")
Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
Expect(agent.AgentName()).To(Equal("multi_plugin"))
})
})
Describe("Album methods", func() {
var agent *wasmMediaAgent
BeforeEach(func() {
a, ok := mgr.LoadMediaAgent("fake_album_agent")
Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
agent = a.(*wasmMediaAgent)
})
Context("GetAlbumInfo", func() {
It("should return album information", func() {
info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
Expect(err).NotTo(HaveOccurred())
Expect(info).NotTo(BeNil())
Expect(info.Name).To(Equal("Test Album"))
Expect(info.MBID).To(Equal("album-mbid-123"))
Expect(info.Description).To(Equal("This is a test album description"))
Expect(info.URL).To(Equal("https://example.com/album"))
})
It("should return ErrNotFound when plugin returns not found", func() {
_, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
Expect(err).To(Equal(agents.ErrNotFound))
})
It("should return ErrNotFound when plugin returns nil response", func() {
_, err := agent.GetAlbumInfo(ctx, "", "", "")
Expect(err).To(Equal(agents.ErrNotFound))
})
})
Context("GetAlbumImages", func() {
It("should return album images", func() {
images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
Expect(err).NotTo(HaveOccurred())
Expect(images).To(Equal([]agents.ExternalImage{
{URL: "https://example.com/album1.jpg", Size: 300},
{URL: "https://example.com/album2.jpg", Size: 400},
}))
})
})
})
Describe("Artist methods", func() {
var agent *wasmMediaAgent
BeforeEach(func() {
a, ok := mgr.LoadMediaAgent("fake_artist_agent")
Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
agent = a.(*wasmMediaAgent)
})
Context("GetArtistMBID", func() {
It("should return artist MBID", func() {
mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
Expect(err).NotTo(HaveOccurred())
Expect(mbid).To(Equal("1234567890"))
})
It("should return ErrNotFound when plugin returns not found", func() {
_, err := agent.GetArtistMBID(ctx, "artist-id", "")
Expect(err).To(Equal(agents.ErrNotFound))
})
})
Context("GetArtistURL", func() {
It("should return artist URL", func() {
url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("https://example.com"))
})
})
Context("GetArtistBiography", func() {
It("should return artist biography", func() {
bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
Expect(err).NotTo(HaveOccurred())
Expect(bio).To(Equal("This is a test biography"))
})
})
Context("GetSimilarArtists", func() {
It("should return similar artists", func() {
artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
Expect(err).NotTo(HaveOccurred())
Expect(artists).To(Equal([]agents.Artist{
{Name: "Similar Artist 1", MBID: "mbid1"},
{Name: "Similar Artist 2", MBID: "mbid2"},
}))
})
})
Context("GetArtistImages", func() {
It("should return artist images", func() {
images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
Expect(err).NotTo(HaveOccurred())
Expect(images).To(Equal([]agents.ExternalImage{
{URL: "https://example.com/image1.jpg", Size: 100},
{URL: "https://example.com/image2.jpg", Size: 200},
}))
})
})
Context("GetArtistTopSongs", func() {
It("should return artist top songs", func() {
songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
Expect(err).NotTo(HaveOccurred())
Expect(songs).To(Equal([]agents.Song{
{Name: "Song 1", MBID: "mbid1"},
{Name: "Song 2", MBID: "mbid2"},
}))
})
})
})
Describe("Helper functions", func() {
It("convertExternalImages should convert API image objects to agent image objects", func() {
apiImages := []*api.ExternalImage{
{Url: "https://example.com/image1.jpg", Size: 100},
{Url: "https://example.com/image2.jpg", Size: 200},
}
agentImages := convertExternalImages(apiImages)
Expect(agentImages).To(HaveLen(2))
for i, img := range agentImages {
Expect(img.URL).To(Equal(apiImages[i].Url))
Expect(img.Size).To(Equal(int(apiImages[i].Size)))
}
})
It("convertExternalImages should handle empty slice", func() {
agentImages := convertExternalImages([]*api.ExternalImage{})
Expect(agentImages).To(BeEmpty())
})
It("convertExternalImages should handle nil", func() {
agentImages := convertExternalImages(nil)
Expect(agentImages).To(BeEmpty())
})
})
Describe("Error mapping", func() {
var agent wasmMediaAgent
It("should map API ErrNotFound to agents.ErrNotFound", func() {
err := agent.mapError(api.ErrNotFound)
Expect(err).To(Equal(agents.ErrNotFound))
})
It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
err := agent.mapError(api.ErrNotImplemented)
Expect(err).To(Equal(agents.ErrNotFound))
})
It("should pass through other errors", func() {
testErr := errors.New("test error")
err := agent.mapError(testErr)
Expect(err).To(Equal(testErr))
})
It("should handle nil error", func() {
err := agent.mapError(nil)
Expect(err).To(BeNil())
})
})
})
+34
View File
@@ -0,0 +1,34 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/tetratelabs/wazero"
)
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
func newWasmSchedulerCallback(wasmPath, pluginName string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating scheduler callback plugin", "plugin", pluginName, "path", wasmPath, err)
return nil
}
return &wasmSchedulerCallback{
wasmBasePlugin: &wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]{
wasmPath: wasmPath,
id: pluginName,
capability: CapabilitySchedulerCallback,
loader: loader,
loadFunc: func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
return l.Load(ctx, path)
},
},
}
}
// wasmSchedulerCallback adapts a SchedulerCallback plugin
type wasmSchedulerCallback struct {
*wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
}
+153
View File
@@ -0,0 +1,153 @@
package plugins
import (
"context"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/api"
"github.com/tetratelabs/wazero"
)
func newWasmScrobblerPlugin(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmScrobblerPlugin{
wasmBasePlugin: &wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilityScrobbler,
loader: loader,
loadFunc: func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
return l.Load(ctx, path)
},
},
}
}
type wasmScrobblerPlugin struct {
*wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]
}
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
username, _ := request.UsernameFrom(ctx)
if username == "" {
u, ok := request.UserFrom(ctx)
if ok {
username = u.UserName
}
}
result, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (bool, error) {
resp, err := inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
UserId: userId,
Username: username,
})
if err != nil {
return false, err
}
if resp.Error != "" {
return false, nil
}
return resp.Authorized, nil
})
return err == nil && result
}
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
username, _ := request.UsernameFrom(ctx)
if username == "" {
u, ok := request.UserFrom(ctx)
if ok {
username = u.UserName
}
}
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
for _, a := range track.Participants[model.RoleArtist] {
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
}
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
for _, a := range track.Participants[model.RoleAlbumArtist] {
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
}
trackInfo := &api.TrackInfo{
Id: track.ID,
Mbid: track.MbzRecordingID,
Name: track.Title,
Album: track.Album,
AlbumMbid: track.MbzAlbumID,
Artists: artists,
AlbumArtists: albumArtists,
Length: int32(track.Duration),
Position: int32(position),
}
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
UserId: userId,
Username: username,
Track: trackInfo,
Timestamp: time.Now().Unix(),
})
if err != nil {
return struct{}{}, err
}
if resp.Error != "" {
return struct{}{}, nil
}
return struct{}{}, nil
})
return err
}
func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
username, _ := request.UsernameFrom(ctx)
if username == "" {
u, ok := request.UserFrom(ctx)
if ok {
username = u.UserName
}
}
track := &s.MediaFile
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
for _, a := range track.Participants[model.RoleArtist] {
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
}
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
for _, a := range track.Participants[model.RoleAlbumArtist] {
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
}
trackInfo := &api.TrackInfo{
Id: track.ID,
Mbid: track.MbzRecordingID,
Name: track.Title,
Album: track.Album,
AlbumMbid: track.MbzAlbumID,
Artists: artists,
AlbumArtists: albumArtists,
Length: int32(track.Duration),
}
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
UserId: userId,
Username: username,
Track: trackInfo,
Timestamp: s.TimeStamp.Unix(),
})
if err != nil {
return struct{}{}, err
}
if resp.Error != "" {
return struct{}{}, nil
}
return struct{}{}, nil
})
return err
}
+34
View File
@@ -0,0 +1,34 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/tetratelabs/wazero"
)
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
func newWasmWebSocketCallback(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil {
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmWebSocketCallback{
wasmBasePlugin: &wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilityWebSocketCallback,
loader: loader,
loadFunc: func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
return l.Load(ctx, path)
},
},
}
}
// wasmWebSocketCallback adapts a WebSocketCallback plugin
type wasmWebSocketCallback struct {
*wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
}
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
syntax = "proto3";
package api;
option go_package = "github.com/navidrome/navidrome/plugins/api;api";
// go:plugin type=plugin version=1
service MetadataAgent {
// Artist metadata methods
rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse);
rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse);
rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse);
rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse);
rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse);
rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse);
// Album metadata methods
rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse);
rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse);
}
message ArtistMBIDRequest {
string id = 1;
string name = 2;
}
message ArtistMBIDResponse {
string mbid = 1;
}
message ArtistURLRequest {
string id = 1;
string name = 2;
string mbid = 3;
}
message ArtistURLResponse {
string url = 1;
}
message ArtistBiographyRequest {
string id = 1;
string name = 2;
string mbid = 3;
}
message ArtistBiographyResponse {
string biography = 1;
}
message ArtistSimilarRequest {
string id = 1;
string name = 2;
string mbid = 3;
int32 limit = 4;
}
message Artist {
string name = 1;
string mbid = 2;
}
message ArtistSimilarResponse {
repeated Artist artists = 1;
}
message ArtistImageRequest {
string id = 1;
string name = 2;
string mbid = 3;
}
message ExternalImage {
string url = 1;
int32 size = 2;
}
message ArtistImageResponse {
repeated ExternalImage images = 1;
}
message ArtistTopSongsRequest {
string id = 1;
string artistName = 2;
string mbid = 3;
int32 count = 4;
}
message Song {
string name = 1;
string mbid = 2;
}
message ArtistTopSongsResponse {
repeated Song songs = 1;
}
message AlbumInfoRequest {
string name = 1;
string artist = 2;
string mbid = 3;
}
message AlbumInfo {
string name = 1;
string mbid = 2;
string description = 3;
string url = 4;
}
message AlbumInfoResponse {
AlbumInfo info = 1;
}
message AlbumImagesRequest {
string name = 1;
string artist = 2;
string mbid = 3;
}
message AlbumImagesResponse {
repeated ExternalImage images = 1;
}
// go:plugin type=plugin version=1
service Scrobbler {
rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse);
rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse);
rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse);
}
message ScrobblerIsAuthorizedRequest {
string user_id = 1;
string username = 2;
}
message ScrobblerIsAuthorizedResponse {
bool authorized = 1;
string error = 2;
}
message TrackInfo {
string id = 1;
string mbid = 2;
string name = 3;
string album = 4;
string album_mbid = 5;
repeated Artist artists = 6;
repeated Artist album_artists = 7;
int32 length = 8; // seconds
int32 position = 9; // seconds
}
message ScrobblerNowPlayingRequest {
string user_id = 1;
string username = 2;
TrackInfo track = 3;
int64 timestamp = 4;
}
message ScrobblerNowPlayingResponse {
string error = 1;
}
message ScrobblerScrobbleRequest {
string user_id = 1;
string username = 2;
TrackInfo track = 3;
int64 timestamp = 4;
}
message ScrobblerScrobbleResponse {
string error = 1;
}
// go:plugin type=plugin version=1
service SchedulerCallback {
rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
}
message SchedulerCallbackRequest {
string schedule_id = 1; // ID of the scheduled job that triggered this callback
bytes payload = 2; // The data passed when the job was scheduled
bool is_recurring = 3; // Whether this is from a recurring schedule (cron job)
}
message SchedulerCallbackResponse {
string error = 1; // Error message if the callback failed
}
// go:plugin type=plugin version=1
service LifecycleManagement {
rpc OnInit(InitRequest) returns (InitResponse);
}
message InitRequest {
// Empty for now
map<string, string> config = 1; // Configuration specific to this plugin
}
message InitResponse {
string error = 1; // Error message if initialization failed
}
// go:plugin type=plugin version=1
service WebSocketCallback {
// Called when a text message is received
rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
// Called when a binary message is received
rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
// Called when an error occurs
rpc OnError(OnErrorRequest) returns (OnErrorResponse);
// Called when the connection is closed
rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
}
message OnTextMessageRequest {
string connection_id = 1;
string message = 2;
}
message OnTextMessageResponse {}
message OnBinaryMessageRequest {
string connection_id = 1;
bytes data = 2;
}
message OnBinaryMessageResponse {}
message OnErrorRequest {
string connection_id = 1;
string error = 2;
}
message OnErrorResponse {}
message OnCloseRequest {
string connection_id = 1;
int32 code = 2;
string reason = 3;
}
message OnCloseResponse {}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: api/api.proto
package api
import (
context "context"
wazero "github.com/tetratelabs/wazero"
wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
type wazeroConfigOption func(plugin *WazeroConfig)
type WazeroNewRuntime func(context.Context) (wazero.Runtime, error)
type WazeroConfig struct {
newRuntime func(context.Context) (wazero.Runtime, error)
moduleConfig wazero.ModuleConfig
}
func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption {
return func(h *WazeroConfig) {
h.newRuntime = newRuntime
}
}
func DefaultWazeroRuntime() WazeroNewRuntime {
return func(ctx context.Context) (wazero.Runtime, error) {
r := wazero.NewRuntime(ctx)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
return nil, err
}
return r, nil
}
}
func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption {
return func(h *WazeroConfig) {
h.moduleConfig = moduleConfig
}
}
+487
View File
@@ -0,0 +1,487 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: api/api.proto
package api
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
)
const MetadataAgentPluginAPIVersion = 1
//go:wasmexport metadata_agent_api_version
func _metadata_agent_api_version() uint64 {
return MetadataAgentPluginAPIVersion
}
var metadataAgent MetadataAgent
func RegisterMetadataAgent(p MetadataAgent) {
metadataAgent = p
}
//go:wasmexport metadata_agent_get_artist_mbid
func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ArtistMBIDRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetArtistMBID(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_artist_url
func _metadata_agent_get_artist_url(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ArtistURLRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetArtistURL(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_artist_biography
func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ArtistBiographyRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetArtistBiography(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_similar_artists
func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ArtistSimilarRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetSimilarArtists(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_artist_images
func _metadata_agent_get_artist_images(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ArtistImageRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetArtistImages(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_artist_top_songs
func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ArtistTopSongsRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetArtistTopSongs(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_album_info
func _metadata_agent_get_album_info(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(AlbumInfoRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetAlbumInfo(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport metadata_agent_get_album_images
func _metadata_agent_get_album_images(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(AlbumImagesRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := metadataAgent.GetAlbumImages(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
const ScrobblerPluginAPIVersion = 1
//go:wasmexport scrobbler_api_version
func _scrobbler_api_version() uint64 {
return ScrobblerPluginAPIVersion
}
var scrobbler Scrobbler
func RegisterScrobbler(p Scrobbler) {
scrobbler = p
}
//go:wasmexport scrobbler_is_authorized
func _scrobbler_is_authorized(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ScrobblerIsAuthorizedRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := scrobbler.IsAuthorized(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport scrobbler_now_playing
func _scrobbler_now_playing(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ScrobblerNowPlayingRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := scrobbler.NowPlaying(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport scrobbler_scrobble
func _scrobbler_scrobble(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(ScrobblerScrobbleRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := scrobbler.Scrobble(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
const SchedulerCallbackPluginAPIVersion = 1
//go:wasmexport scheduler_callback_api_version
func _scheduler_callback_api_version() uint64 {
return SchedulerCallbackPluginAPIVersion
}
var schedulerCallback SchedulerCallback
func RegisterSchedulerCallback(p SchedulerCallback) {
schedulerCallback = p
}
//go:wasmexport scheduler_callback_on_scheduler_callback
func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(SchedulerCallbackRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
const LifecycleManagementPluginAPIVersion = 1
//go:wasmexport lifecycle_management_api_version
func _lifecycle_management_api_version() uint64 {
return LifecycleManagementPluginAPIVersion
}
var lifecycleManagement LifecycleManagement
func RegisterLifecycleManagement(p LifecycleManagement) {
lifecycleManagement = p
}
//go:wasmexport lifecycle_management_on_init
func _lifecycle_management_on_init(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(InitRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := lifecycleManagement.OnInit(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
const WebSocketCallbackPluginAPIVersion = 1
//go:wasmexport web_socket_callback_api_version
func _web_socket_callback_api_version() uint64 {
return WebSocketCallbackPluginAPIVersion
}
var webSocketCallback WebSocketCallback
func RegisterWebSocketCallback(p WebSocketCallback) {
webSocketCallback = p
}
//go:wasmexport web_socket_callback_on_text_message
func _web_socket_callback_on_text_message(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(OnTextMessageRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := webSocketCallback.OnTextMessage(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport web_socket_callback_on_binary_message
func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(OnBinaryMessageRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := webSocketCallback.OnBinaryMessage(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport web_socket_callback_on_error
func _web_socket_callback_on_error(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(OnErrorRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := webSocketCallback.OnError(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
//go:wasmexport web_socket_callback_on_close
func _web_socket_callback_on_close(ptr, size uint32) uint64 {
b := wasm.PtrToByte(ptr, size)
req := new(OnCloseRequest)
if err := req.UnmarshalVT(b); err != nil {
return 0
}
response, err := webSocketCallback.OnClose(context.Background(), req)
if err != nil {
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
return (uint64(ptr) << uint64(32)) | uint64(size) |
// Indicate that this is the error string by setting the 32-th bit, assuming that
// no data exceeds 31-bit size (2 GiB).
(1 << 31)
}
b, err = response.MarshalVT()
if err != nil {
return 0
}
ptr, size = wasm.ByteToPtr(b)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
+34
View File
@@ -0,0 +1,34 @@
//go:build !wasip1
package api
import "github.com/navidrome/navidrome/plugins/host/scheduler"
// This file exists to provide stubs for the plugin registration functions when building for non-WASM targets.
// This is useful for testing and development purposes, as it allows you to build and run your plugin code
// without having to compile it to WASM.
// In a real-world scenario, you would compile your plugin to WASM and use the generated registration functions.
func RegisterMetadataAgent(MetadataAgent) {
panic("not implemented")
}
func RegisterScrobbler(Scrobbler) {
panic("not implemented")
}
func RegisterSchedulerCallback(SchedulerCallback) {
panic("not implemented")
}
func RegisterLifecycleManagement(LifecycleManagement) {
panic("not implemented")
}
func RegisterWebSocketCallback(WebSocketCallback) {
panic("not implemented")
}
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
panic("not implemented")
}
@@ -0,0 +1,90 @@
//go:build wasip1
package api
import (
"context"
"strings"
"github.com/navidrome/navidrome/plugins/host/scheduler"
)
var callbacks = make(namedCallbacks)
// RegisterNamedSchedulerCallback registers a named scheduler callback. Named callbacks allow multiple callbacks to be registered
// within the same plugin, and for the schedules to be scoped to the named callback. If you only need a single callback, you can use
// the default (unnamed) callback registration function, RegisterSchedulerCallback.
// It returns a scheduler.SchedulerService that can be used to schedule jobs for the named callback.
//
// Notes:
//
// - You can't mix named and unnamed callbacks within the same plugin.
// - The name should be unique within the plugin, and it's recommended to use a short, descriptive name.
// - The name is case-sensitive.
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
callbacks[name] = cb
RegisterSchedulerCallback(&callbacks)
return &namedSchedulerService{name: name, svc: scheduler.NewSchedulerService()}
}
const zwsp = string('\u200b')
// namedCallbacks is a map of named scheduler callbacks. The key is the name of the callback, and the value is the callback itself.
type namedCallbacks map[string]SchedulerCallback
func parseKey(key string) (string, string) {
parts := strings.SplitN(key, zwsp, 2)
if len(parts) != 2 {
return "", ""
}
return parts[0], parts[1]
}
func (n *namedCallbacks) OnSchedulerCallback(ctx context.Context, req *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) {
name, scheduleId := parseKey(req.ScheduleId)
cb, exists := callbacks[name]
if !exists {
return nil, nil
}
req.ScheduleId = scheduleId
return cb.OnSchedulerCallback(ctx, req)
}
// namedSchedulerService is a wrapper around the host scheduler service that prefixes the schedule IDs with the
// callback name. It is returned by RegisterNamedSchedulerCallback, and should be used by the plugin to schedule
// jobs for the named callback.
type namedSchedulerService struct {
name string
cb SchedulerCallback
svc scheduler.SchedulerService
}
func (n *namedSchedulerService) makeKey(id string) string {
return n.name + zwsp + id
}
func (n *namedSchedulerService) mapResponse(resp *scheduler.ScheduleResponse, err error) (*scheduler.ScheduleResponse, error) {
if err != nil {
return nil, err
}
_, resp.ScheduleId = parseKey(resp.ScheduleId)
return resp, nil
}
func (n *namedSchedulerService) ScheduleOneTime(ctx context.Context, request *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
key := n.makeKey(request.ScheduleId)
request.ScheduleId = key
return n.mapResponse(n.svc.ScheduleOneTime(ctx, request))
}
func (n *namedSchedulerService) ScheduleRecurring(ctx context.Context, request *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
key := n.makeKey(request.ScheduleId)
request.ScheduleId = key
return n.mapResponse(n.svc.ScheduleRecurring(ctx, request))
}
func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
key := n.makeKey(request.ScheduleId)
request.ScheduleId = key
return n.svc.CancelSchedule(ctx, request)
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
package api
import "errors"
var (
ErrNotFound = errors.New("plugin:not_found")
ErrNotImplemented = errors.New("plugin:not_implemented")
)
+145
View File
@@ -0,0 +1,145 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/schema"
)
// PluginDiscoveryEntry represents the result of plugin discovery
type PluginDiscoveryEntry struct {
ID string // Plugin ID (directory name)
Path string // Resolved plugin directory path
WasmPath string // Path to the WASM file
Manifest *schema.PluginManifest // Loaded manifest (nil if failed)
IsSymlink bool // Whether the plugin is a development symlink
Error error // Error encountered during discovery
}
// DiscoverPlugins scans the plugins directory and returns information about all discoverable plugins
// This shared function eliminates duplication between ScanPlugins and plugin list commands
func DiscoverPlugins(pluginsDir string) []PluginDiscoveryEntry {
var discoveries []PluginDiscoveryEntry
entries, err := os.ReadDir(pluginsDir)
if err != nil {
// Return a single entry with the error
return []PluginDiscoveryEntry{{
Error: fmt.Errorf("failed to read plugins directory %s: %w", pluginsDir, err),
}}
}
for _, entry := range entries {
name := entry.Name()
pluginPath := filepath.Join(pluginsDir, name)
// Skip hidden files
if name[0] == '.' {
continue
}
// Check if it's a directory or symlink
info, err := os.Lstat(pluginPath)
if err != nil {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
Error: fmt.Errorf("failed to stat entry %s: %w", pluginPath, err),
})
continue
}
isSymlink := info.Mode()&os.ModeSymlink != 0
isDir := info.IsDir()
// Skip if not a directory or symlink
if !isDir && !isSymlink {
continue
}
// Resolve symlinks
pluginDir := pluginPath
if isSymlink {
targetDir, err := os.Readlink(pluginPath)
if err != nil {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
IsSymlink: true,
Error: fmt.Errorf("failed to resolve symlink %s: %w", pluginPath, err),
})
continue
}
// If target is a relative path, make it absolute
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(pluginPath), targetDir)
}
// Verify that the target is a directory
targetInfo, err := os.Stat(targetDir)
if err != nil {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
IsSymlink: true,
Error: fmt.Errorf("failed to stat symlink target %s: %w", targetDir, err),
})
continue
}
if !targetInfo.IsDir() {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
IsSymlink: true,
Error: fmt.Errorf("symlink target is not a directory: %s", targetDir),
})
continue
}
pluginDir = targetDir
}
// Check for WASM file
wasmPath := filepath.Join(pluginDir, "plugin.wasm")
if _, err := os.Stat(wasmPath); err != nil {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
Path: pluginDir,
Error: fmt.Errorf("no plugin.wasm found: %w", err),
})
continue
}
// Load manifest
manifest, err := LoadManifest(pluginDir)
if err != nil {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
Path: pluginDir,
Error: fmt.Errorf("failed to load manifest: %w", err),
})
continue
}
// Check for capabilities
if len(manifest.Capabilities) == 0 {
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
Path: pluginDir,
Error: fmt.Errorf("no capabilities found in manifest"),
})
continue
}
// Success!
discoveries = append(discoveries, PluginDiscoveryEntry{
ID: name,
Path: pluginDir,
WasmPath: wasmPath,
Manifest: manifest,
IsSymlink: isSymlink,
})
}
return discoveries
}
+402
View File
@@ -0,0 +1,402 @@
package plugins
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("DiscoverPlugins", func() {
var tempPluginsDir string
// Helper to create a valid plugin for discovery testing
createValidPlugin := func(name, manifestName, author, version string, capabilities []string) {
pluginDir := filepath.Join(tempPluginsDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Copy real WASM file from testdata
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
manifest := `{
"name": "` + manifestName + `",
"version": "` + version + `",
"capabilities": [`
for i, cap := range capabilities {
if i > 0 {
manifest += `, `
}
manifest += `"` + cap + `"`
}
manifest += `],
"author": "` + author + `",
"description": "Test Plugin",
"website": "https://test.navidrome.org/` + manifestName + `",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
}
createManifestOnlyPlugin := func(name string) {
pluginDir := filepath.Join(tempPluginsDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
manifest := `{
"name": "manifest-only",
"version": "1.0.0",
"capabilities": ["MetadataAgent"],
"author": "Test Author",
"description": "Test Plugin",
"website": "https://test.navidrome.org/manifest-only",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
}
createWasmOnlyPlugin := func(name string) {
pluginDir := filepath.Join(tempPluginsDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Copy real WASM file from testdata
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
}
createInvalidManifestPlugin := func(name string) {
pluginDir := filepath.Join(tempPluginsDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Copy real WASM file from testdata
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
invalidManifest := `{ "invalid": "json" }`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidManifest), 0600)).To(Succeed())
}
createEmptyCapabilitiesPlugin := func(name string) {
pluginDir := filepath.Join(tempPluginsDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Copy real WASM file from testdata
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
manifest := `{
"name": "empty-capabilities",
"version": "1.0.0",
"capabilities": [],
"author": "Test Author",
"description": "Test Plugin",
"website": "https://test.navidrome.org/empty-capabilities",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
}
BeforeEach(func() {
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-discovery-test-*")
DeferCleanup(func() {
_ = os.RemoveAll(tempPluginsDir)
})
})
Context("Valid plugins", func() {
It("should discover valid plugins with all required files", func() {
createValidPlugin("test-plugin", "Test Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
createValidPlugin("another-plugin", "Another Plugin", "Another Author", "2.0.0", []string{"Scrobbler"})
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(2))
// Find each plugin by ID
var testPlugin, anotherPlugin *PluginDiscoveryEntry
for i := range discoveries {
switch discoveries[i].ID {
case "test-plugin":
testPlugin = &discoveries[i]
case "another-plugin":
anotherPlugin = &discoveries[i]
}
}
Expect(testPlugin).NotTo(BeNil())
Expect(testPlugin.Error).To(BeNil())
Expect(testPlugin.Manifest.Name).To(Equal("Test Plugin"))
Expect(string(testPlugin.Manifest.Capabilities[0])).To(Equal("MetadataAgent"))
Expect(anotherPlugin).NotTo(BeNil())
Expect(anotherPlugin.Error).To(BeNil())
Expect(anotherPlugin.Manifest.Name).To(Equal("Another Plugin"))
Expect(string(anotherPlugin.Manifest.Capabilities[0])).To(Equal("Scrobbler"))
})
It("should handle plugins with same manifest name in different directories", func() {
createValidPlugin("lastfm-official", "lastfm", "Official Author", "1.0.0", []string{"MetadataAgent"})
createValidPlugin("lastfm-custom", "lastfm", "Custom Author", "2.0.0", []string{"MetadataAgent"})
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(2))
// Find each plugin by ID
var officialPlugin, customPlugin *PluginDiscoveryEntry
for i := range discoveries {
switch discoveries[i].ID {
case "lastfm-official":
officialPlugin = &discoveries[i]
case "lastfm-custom":
customPlugin = &discoveries[i]
}
}
Expect(officialPlugin).NotTo(BeNil())
Expect(officialPlugin.Error).To(BeNil())
Expect(officialPlugin.Manifest.Name).To(Equal("lastfm"))
Expect(officialPlugin.Manifest.Author).To(Equal("Official Author"))
Expect(customPlugin).NotTo(BeNil())
Expect(customPlugin.Error).To(BeNil())
Expect(customPlugin.Manifest.Name).To(Equal("lastfm"))
Expect(customPlugin.Manifest.Author).To(Equal("Custom Author"))
})
})
Context("Missing files", func() {
It("should report error for plugins missing WASM files", func() {
createManifestOnlyPlugin("manifest-only")
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("manifest-only"))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("no plugin.wasm found"))
})
It("should skip directories missing manifest files", func() {
createWasmOnlyPlugin("wasm-only")
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("wasm-only"))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest"))
})
})
Context("Invalid content", func() {
It("should report error for invalid manifest JSON", func() {
createInvalidManifestPlugin("invalid-manifest")
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("invalid-manifest"))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest"))
})
It("should report error for plugins with empty capabilities", func() {
createEmptyCapabilitiesPlugin("empty-capabilities")
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("empty-capabilities"))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("field capabilities length: must be >= 1"))
})
})
Context("Symlinks", func() {
It("should discover symlinked plugins correctly", func() {
// Create a real plugin directory outside tempPluginsDir
realPluginDir, err := os.MkdirTemp("", "navidrome-real-plugin-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.RemoveAll(realPluginDir)
})
// Create plugin files in the real directory
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
manifest := `{
"name": "symlinked-plugin",
"version": "1.0.0",
"capabilities": ["MetadataAgent"],
"author": "Test Author",
"description": "Test Plugin",
"website": "https://test.navidrome.org/symlinked-plugin",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create symlink
symlinkPath := filepath.Join(tempPluginsDir, "symlinked-plugin")
Expect(os.Symlink(realPluginDir, symlinkPath)).To(Succeed())
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("symlinked-plugin"))
Expect(discoveries[0].Error).To(BeNil())
Expect(discoveries[0].IsSymlink).To(BeTrue())
Expect(discoveries[0].Path).To(Equal(realPluginDir))
Expect(discoveries[0].Manifest.Name).To(Equal("symlinked-plugin"))
})
It("should handle relative symlinks", func() {
// Create a real plugin directory in the same parent as tempPluginsDir
parentDir := filepath.Dir(tempPluginsDir)
realPluginDir := filepath.Join(parentDir, "real-plugin-dir")
Expect(os.MkdirAll(realPluginDir, 0755)).To(Succeed())
DeferCleanup(func() {
_ = os.RemoveAll(realPluginDir)
})
// Create plugin files in the real directory
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
manifest := `{
"name": "relative-symlinked-plugin",
"version": "1.0.0",
"capabilities": ["MetadataAgent"],
"author": "Test Author",
"description": "Test Plugin",
"website": "https://test.navidrome.org/relative-symlinked-plugin",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create relative symlink
symlinkPath := filepath.Join(tempPluginsDir, "relative-symlinked-plugin")
relativeTarget := "../real-plugin-dir"
Expect(os.Symlink(relativeTarget, symlinkPath)).To(Succeed())
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("relative-symlinked-plugin"))
Expect(discoveries[0].Error).To(BeNil())
Expect(discoveries[0].IsSymlink).To(BeTrue())
Expect(discoveries[0].Path).To(Equal(realPluginDir))
Expect(discoveries[0].Manifest.Name).To(Equal("relative-symlinked-plugin"))
})
It("should report error for broken symlinks", func() {
symlinkPath := filepath.Join(tempPluginsDir, "broken-symlink")
nonExistentTarget := "/non/existent/path"
Expect(os.Symlink(nonExistentTarget, symlinkPath)).To(Succeed())
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("broken-symlink"))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to stat symlink target"))
Expect(discoveries[0].IsSymlink).To(BeTrue())
})
It("should report error for symlinks pointing to files", func() {
// Create a regular file
regularFile := filepath.Join(tempPluginsDir, "regular-file.txt")
Expect(os.WriteFile(regularFile, []byte("content"), 0600)).To(Succeed())
// Create symlink pointing to the file
symlinkPath := filepath.Join(tempPluginsDir, "symlink-to-file")
Expect(os.Symlink(regularFile, symlinkPath)).To(Succeed())
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("symlink-to-file"))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("symlink target is not a directory"))
Expect(discoveries[0].IsSymlink).To(BeTrue())
})
})
Context("Directory filtering", func() {
It("should ignore hidden directories", func() {
createValidPlugin(".hidden-plugin", "Hidden Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
createValidPlugin("visible-plugin", "Visible Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("visible-plugin"))
})
It("should ignore regular files", func() {
// Create a regular file
Expect(os.WriteFile(filepath.Join(tempPluginsDir, "regular-file.txt"), []byte("content"), 0600)).To(Succeed())
createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].ID).To(Equal("valid-plugin"))
})
It("should handle mixed valid and invalid plugins", func() {
createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
createManifestOnlyPlugin("manifest-only")
createInvalidManifestPlugin("invalid-manifest")
createValidPlugin("another-valid", "Another Valid", "Test Author", "1.0.0", []string{"Scrobbler"})
discoveries := DiscoverPlugins(tempPluginsDir)
Expect(discoveries).To(HaveLen(4))
var validCount int
var errorCount int
for _, discovery := range discoveries {
if discovery.Error == nil {
validCount++
} else {
errorCount++
}
}
Expect(validCount).To(Equal(2))
Expect(errorCount).To(Equal(2))
})
})
Context("Error handling", func() {
It("should handle non-existent plugins directory", func() {
nonExistentDir := "/non/existent/plugins/dir"
discoveries := DiscoverPlugins(nonExistentDir)
Expect(discoveries).To(HaveLen(1))
Expect(discoveries[0].Error).To(HaveOccurred())
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to read plugins directory"))
})
})
})
+22
View File
@@ -0,0 +1,22 @@
all: wikimedia coverartarchive crypto-ticker discord-rich-presence
wikimedia: wikimedia/plugin.wasm
coverartarchive: coverartarchive/plugin.wasm
crypto-ticker: crypto-ticker/plugin.wasm
discord-rich-presence: discord-rich-presence/plugin.wasm
wikimedia/plugin.wasm: wikimedia/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia
coverartarchive/plugin.wasm: coverartarchive/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive
crypto-ticker/plugin.wasm: crypto-ticker/plugin.go
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker
DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go")
discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES)
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/...
clean:
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm discord-rich-presence/plugin.wasm
+29
View File
@@ -0,0 +1,29 @@
# Plugin Examples
This directory contains example plugins for Navidrome, intended for demonstration and reference purposes. These plugins are not used in automated tests.
## Contents
- `wikimedia/`: Example plugin that retrieves artist information from Wikidata.
- `coverartarchive/`: Example plugin that retrieves album cover images from the Cover Art Archive.
- `crypto-ticker/`: Example plugin using websockets to log real-time crypto currency prices.
- `discord-rich-presence/`: Example plugin that integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
## Building
To build all example plugins, run:
```
make
```
Or to build a specific plugin:
```
make wikimedia
make coverartarchive
make crypto-ticker
make discord-rich-presence
```
This will produce the corresponding `plugin.wasm` files in each plugin's directory.
@@ -0,0 +1,34 @@
# Cover Art Archive AlbumMetadataService Plugin
This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release Group MBID.
## Features
- Implements only the `GetAlbumImages` method of the AlbumMetadataService plugin interface.
- Returns front cover images for a given release-group MBID.
- Returns `not found` if no MBID is provided or no images are found.
## Requirements
- Go 1.24 or newer (with WASI support)
- The Navidrome repository (with generated plugin API code in `plugins/api`)
## How to Compile
To build the WASM plugin, run the following command from the project root:
```sh
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugins/testdata/coverartarchive/plugin.wasm ./plugins/testdata/coverartarchive
```
This will produce `plugin.wasm` in this directory.
## Usage
- The plugin can be loaded by Navidrome for integration and end-to-end tests of the plugin system.
- It is intended for testing and development purposes only.
## API Reference
- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API)
- This plugin uses the endpoint: `https://coverartarchive.org/release-group/{mbid}`
@@ -0,0 +1,18 @@
{
"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
}
}
}
+147
View File
@@ -0,0 +1,147 @@
//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() {
api.RegisterMetadataAgent(CoverArtArchiveAgent{})
}
+53
View File
@@ -0,0 +1,53 @@
# Crypto Ticker Plugin
This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase.
## Features
- Connects to Coinbase WebSocket API to receive real-time ticker updates
- Configurable to track multiple cryptocurrency pairs
- Implements WebSocketCallback and LifecycleManagement interfaces
- Automatically reconnects on connection loss
- Displays price, best bid, best ask, and 24-hour percentage change
## Configuration
In your `navidrome.toml` file, add:
```toml
[PluginSettings.crypto-ticker]
tickers = "BTC,ETH,SOL,MATIC"
```
- `tickers` is a comma-separated list of cryptocurrency symbols
- The plugin will append `-USD` to any symbol without a trading pair specified
## How it Works
- The plugin connects to Coinbase's WebSocket API upon initialization
- It subscribes to ticker updates for the configured cryptocurrencies
- Incoming ticker data is processed and logged
- On connection loss, it automatically attempts to reconnect (TODO)
## Building
To build the plugin to WASM:
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
```
## Installation
Copy the resulting `plugin.wasm` and create a `manifest.json` file in your Navidrome plugins folder under a `crypto-ticker` directory.
## Example Output
```
CRYPTO TICKER: BTC-USD Price: 65432.50 Best Bid: 65431.25 Best Ask: 65433.75 24h Change: 2.75%
CRYPTO TICKER: ETH-USD Price: 3456.78 Best Bid: 3455.90 Best Ask: 3457.80 24h Change: 1.25%
```
---
For more details, see the source code in `plugin.go`.
@@ -0,0 +1,25 @@
{
"name": "crypto-ticker",
"author": "Navidrome Plugin",
"version": "1.0.0",
"description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
"capabilities": [
"WebSocketCallback",
"LifecycleManagement",
"SchedulerCallback"
],
"permissions": {
"config": {
"reason": "To read API configuration and WebSocket endpoint settings"
},
"scheduler": {
"reason": "To schedule periodic reconnection attempts and status updates"
},
"websocket": {
"reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices",
"allowedUrls": ["wss://ws-feed.exchange.coinbase.com"],
"allowLocalNetwork": false
}
}
}
+300
View File
@@ -0,0 +1,300 @@
//go:build wasip1
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/config"
"github.com/navidrome/navidrome/plugins/host/scheduler"
"github.com/navidrome/navidrome/plugins/host/websocket"
)
const (
// Coinbase WebSocket API endpoint
coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com"
// Connection ID for our WebSocket connection
connectionID = "crypto-ticker-connection"
// ID for the reconnection schedule
reconnectScheduleID = "crypto-ticker-reconnect"
)
var (
// Store ticker symbols from the configuration
tickers []string
)
// WebSocketService instance used to manage WebSocket connections and communication.
var wsService = websocket.NewWebSocketService()
// ConfigService instance for accessing plugin configuration.
var configService = config.NewConfigService()
// SchedulerService instance for scheduling tasks.
var schedService = scheduler.NewSchedulerService()
// CryptoTickerPlugin implements WebSocketCallback, LifecycleManagement, and SchedulerCallback interfaces
type CryptoTickerPlugin struct{}
// Coinbase subscription message structure
type CoinbaseSubscription struct {
Type string `json:"type"`
ProductIDs []string `json:"product_ids"`
Channels []string `json:"channels"`
}
// Coinbase ticker message structure
type CoinbaseTicker struct {
Type string `json:"type"`
Sequence int64 `json:"sequence"`
ProductID string `json:"product_id"`
Price string `json:"price"`
Open24h string `json:"open_24h"`
Volume24h string `json:"volume_24h"`
Low24h string `json:"low_24h"`
High24h string `json:"high_24h"`
Volume30d string `json:"volume_30d"`
BestBid string `json:"best_bid"`
BestAsk string `json:"best_ask"`
Side string `json:"side"`
Time string `json:"time"`
TradeID int `json:"trade_id"`
LastSize string `json:"last_size"`
}
// OnInit is called when the plugin is loaded
func (CryptoTickerPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
log.Printf("Crypto Ticker Plugin initializing...")
// Check if ticker configuration exists
tickerConfig, ok := req.Config["tickers"]
if !ok {
return &api.InitResponse{Error: "Missing 'tickers' configuration"}, nil
}
// Parse ticker symbols
tickers := parseTickerSymbols(tickerConfig)
log.Printf("Configured tickers: %v", tickers)
// Connect to WebSocket and subscribe to tickers
err := connectAndSubscribe(ctx, tickers)
if err != nil {
return &api.InitResponse{Error: err.Error()}, nil
}
return &api.InitResponse{}, nil
}
// Helper function to parse ticker symbols from a comma-separated string
func parseTickerSymbols(tickerConfig string) []string {
tickers := strings.Split(tickerConfig, ",")
for i, ticker := range tickers {
tickers[i] = strings.TrimSpace(ticker)
// Add -USD suffix if not present
if !strings.Contains(tickers[i], "-") {
tickers[i] = tickers[i] + "-USD"
}
}
return tickers
}
// Helper function to connect to WebSocket and subscribe to tickers
func connectAndSubscribe(ctx context.Context, tickers []string) error {
// Connect to the WebSocket API
_, err := wsService.Connect(ctx, &websocket.ConnectRequest{
Url: coinbaseWSEndpoint,
ConnectionId: connectionID,
})
if err != nil {
log.Printf("Failed to connect to Coinbase WebSocket API: %v", err)
return fmt.Errorf("WebSocket connection error: %v", err)
}
log.Printf("Connected to Coinbase WebSocket API")
// Subscribe to ticker channel for the configured symbols
subscription := CoinbaseSubscription{
Type: "subscribe",
ProductIDs: tickers,
Channels: []string{"ticker"},
}
subscriptionJSON, err := json.Marshal(subscription)
if err != nil {
log.Printf("Failed to marshal subscription message: %v", err)
return fmt.Errorf("JSON marshal error: %v", err)
}
// Send subscription message
_, err = wsService.SendText(ctx, &websocket.SendTextRequest{
ConnectionId: connectionID,
Message: string(subscriptionJSON),
})
if err != nil {
log.Printf("Failed to send subscription message: %v", err)
return fmt.Errorf("WebSocket send error: %v", err)
}
log.Printf("Subscription message sent to Coinbase WebSocket API")
return nil
}
// OnTextMessage is called when a text message is received from the WebSocket
func (CryptoTickerPlugin) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) {
// Only process messages from our connection
if req.ConnectionId != connectionID {
log.Printf("Received message from unexpected connection: %s", req.ConnectionId)
return &api.OnTextMessageResponse{}, nil
}
// Try to parse as a ticker message
var ticker CoinbaseTicker
err := json.Unmarshal([]byte(req.Message), &ticker)
if err != nil {
log.Printf("Failed to parse ticker message: %v", err)
return &api.OnTextMessageResponse{}, nil
}
// If the message is not a ticker or has an error, just log it
if ticker.Type != "ticker" {
// This could be subscription confirmation or other messages
log.Printf("Received non-ticker message: %s", req.Message)
return &api.OnTextMessageResponse{}, nil
}
// Format and print ticker information
log.Printf("CRYPTO TICKER: %s Price: %s Best Bid: %s Best Ask: %s 24h Change: %s%%\n",
ticker.ProductID,
ticker.Price,
ticker.BestBid,
ticker.BestAsk,
calculatePercentChange(ticker.Open24h, ticker.Price),
)
return &api.OnTextMessageResponse{}, nil
}
// OnBinaryMessage is called when a binary message is received
func (CryptoTickerPlugin) OnBinaryMessage(ctx context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) {
// Not expected from Coinbase WebSocket API
return &api.OnBinaryMessageResponse{}, nil
}
// OnError is called when an error occurs on the WebSocket connection
func (CryptoTickerPlugin) OnError(ctx context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) {
log.Printf("WebSocket error: %s", req.Error)
return &api.OnErrorResponse{}, nil
}
// OnClose is called when the WebSocket connection is closed
func (CryptoTickerPlugin) OnClose(ctx context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) {
log.Printf("WebSocket connection closed with code %d: %s", req.Code, req.Reason)
// Try to reconnect if this is our connection
if req.ConnectionId == connectionID {
log.Printf("Scheduling reconnection attempts every 2 seconds...")
// Create a recurring schedule to attempt reconnection every 2 seconds
resp, err := schedService.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
// Run every 2 seconds using cron expression
CronExpression: "*/2 * * * * *",
ScheduleId: reconnectScheduleID,
})
if err != nil {
log.Printf("Failed to schedule reconnection attempts: %v", err)
} else {
log.Printf("Reconnection schedule created with ID: %s", resp.ScheduleId)
}
}
return &api.OnCloseResponse{}, nil
}
// OnSchedulerCallback is called when a scheduled event triggers
func (CryptoTickerPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
// Only handle our reconnection schedule
if req.ScheduleId != reconnectScheduleID {
log.Printf("Received callback for unknown schedule: %s", req.ScheduleId)
return &api.SchedulerCallbackResponse{}, nil
}
log.Printf("Attempting to reconnect to Coinbase WebSocket API...")
// Get the current ticker configuration
configResp, err := configService.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
if err != nil {
log.Printf("Failed to get plugin configuration: %v", err)
return &api.SchedulerCallbackResponse{Error: fmt.Sprintf("Config error: %v", err)}, nil
}
// Check if ticker configuration exists
tickerConfig, ok := configResp.Config["tickers"]
if !ok {
log.Printf("Missing 'tickers' configuration")
return &api.SchedulerCallbackResponse{Error: "Missing 'tickers' configuration"}, nil
}
// Parse ticker symbols
tickers := parseTickerSymbols(tickerConfig)
log.Printf("Reconnecting with tickers: %v", tickers)
// Try to connect and subscribe
err = connectAndSubscribe(ctx, tickers)
if err != nil {
log.Printf("Reconnection attempt failed: %v", err)
return &api.SchedulerCallbackResponse{Error: err.Error()}, nil
}
// Successfully reconnected, cancel the reconnection schedule
_, err = schedService.CancelSchedule(ctx, &scheduler.CancelRequest{
ScheduleId: reconnectScheduleID,
})
if err != nil {
log.Printf("Failed to cancel reconnection schedule: %v", err)
} else {
log.Printf("Reconnection schedule canceled after successful reconnection")
}
return &api.SchedulerCallbackResponse{}, nil
}
// Helper function to calculate percent change
func calculatePercentChange(open, current string) string {
var openFloat, currentFloat float64
_, err := fmt.Sscanf(open, "%f", &openFloat)
if err != nil {
return "N/A"
}
_, err = fmt.Sscanf(current, "%f", &currentFloat)
if err != nil {
return "N/A"
}
if openFloat == 0 {
return "N/A"
}
change := ((currentFloat - openFloat) / openFloat) * 100
return fmt.Sprintf("%.2f", change)
}
// Required by Go WASI build
func main() {}
func init() {
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
}
@@ -0,0 +1,88 @@
# Discord Rich Presence Plugin
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time
connection to an external service while remaining completely stateless. This plugin is based on the
[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality.
**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the
Navidrome configuration file, which is not secure, and may be against Discord's terms of service.
Use it at your own risk.**
## Overview
The plugin exposes three capabilities:
- **Scrobbler** receives `NowPlaying` notifications from Navidrome
- **WebSocketCallback** handles Discord gateway messages
- **SchedulerCallback** used to clear presence and send periodic heartbeats
It relies on several host services declared in `manifest.json`:
- `http` queries Discord API endpoints
- `websocket` maintains gateway connections
- `scheduler` schedules heartbeats and presence cleanup
- `cache` stores sequence numbers for heartbeats
- `config` retrieves the plugin configuration on each call
- `artwork` resolves track artwork URLs
## Architecture
Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the
scheduler service:
```go
api.RegisterScrobbler(plugin)
api.RegisterWebSocketCallback(plugin.rpc)
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
```
When `NowPlaying` is invoked the plugin:
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
2. Connects to Discord using `WebSocketService` if no connection exists.
3. Sends the activity payload with track details and artwork.
4. Schedules a onetime callback to clear the presence after the track finishes.
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in
`CacheService` to remain available across plugin instances.
The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached.
```go
// The plugin is stateless, we need to load the configuration every time
clientID, users, err := d.getConfig(ctx)
```
## Configuration
Add the following to `navidrome.toml` and adjust for your tokens:
```toml
[PluginSettings.discord-rich-presence]
ClientID = "123456789012345678"
Users = "alice:token123,bob:token456"
```
- `clientid` is your Discord application ID
- `users` is a commaseparated list of `username:token` pairs used for authorization
## Building
```sh
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/...
```
Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins
directory.
## Stateless Operation
Navidrome plugins are completely stateless each method call instantiates a new plugin instance and discards it
afterwards.
To work within this model the plugin stores no in-memory state. Connections are keyed by user name inside the host
services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every
method call.
For more implementation details see `plugin.go` and `rpc.go`.
@@ -0,0 +1,34 @@
{
"name": "discord-rich-presence",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Discord Rich Presence integration for Navidrome",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
"capabilities": ["Scrobbler", "SchedulerCallback", "WebSocketCallback"],
"permissions": {
"http": {
"reason": "To communicate with Discord API for gateway discovery and image uploads",
"allowedUrls": {
"https://discord.com/api/*": ["GET", "POST"]
},
"allowLocalNetwork": false
},
"websocket": {
"reason": "To maintain real-time connection with Discord gateway",
"allowedUrls": ["wss://gateway.discord.gg"],
"allowLocalNetwork": false
},
"config": {
"reason": "To access plugin configuration (client ID and user tokens)"
},
"cache": {
"reason": "To store connection state and sequence numbers"
},
"scheduler": {
"reason": "To schedule heartbeat messages and activity clearing"
},
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
}
}
@@ -0,0 +1,186 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/artwork"
"github.com/navidrome/navidrome/plugins/host/cache"
"github.com/navidrome/navidrome/plugins/host/config"
"github.com/navidrome/navidrome/plugins/host/http"
"github.com/navidrome/navidrome/plugins/host/scheduler"
"github.com/navidrome/navidrome/plugins/host/websocket"
"github.com/navidrome/navidrome/utils/slice"
)
type DiscordRPPlugin struct {
rpc *discordRPC
cfg config.ConfigService
artwork artwork.ArtworkService
sched scheduler.SchedulerService
}
func (d *DiscordRPPlugin) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) {
// Get plugin configuration
_, users, err := d.getConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to check user authorization: %w", err)
}
// Check if the user has a Discord token configured
_, authorized := users[req.Username]
log.Printf("IsAuthorized for user %s: %v", req.Username, authorized)
return &api.ScrobblerIsAuthorizedResponse{
Authorized: authorized,
}, nil
}
func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) {
log.Printf("Setting presence for user %s, track: %s", request.Username, request.Track.Name)
// The plugin is stateless, we need to load the configuration every time
clientID, users, err := d.getConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}
// Check if the user has a Discord token configured
userToken, authorized := users[request.Username]
if !authorized {
return nil, fmt.Errorf("user '%s' not authorized", request.Username)
}
// Make sure we have a connection
if err := d.rpc.connect(ctx, request.Username, userToken); err != nil {
return nil, fmt.Errorf("failed to connect to Discord: %w", err)
}
// Cancel any existing completion schedule
if resp, _ := d.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: request.Username}); resp.Error != "" {
log.Printf("Ignoring failure to cancel schedule: %s", resp.Error)
}
// Send activity update
if err := d.rpc.sendActivity(ctx, clientID, request.Username, userToken, activity{
Application: clientID,
Name: "Navidrome",
Type: 2,
Details: request.Track.Name,
State: d.getArtistList(request.Track),
Timestamps: activityTimestamps{
Start: (request.Timestamp - int64(request.Track.Position)) * 1000,
End: (request.Timestamp - int64(request.Track.Position) + int64(request.Track.Length)) * 1000,
},
Assets: activityAssets{
LargeImage: d.imageURL(ctx, request),
LargeText: request.Track.Album,
},
}); err != nil {
return nil, fmt.Errorf("failed to send activity: %w", err)
}
// Schedule a timer to clear the activity after the track completes
_, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{
ScheduleId: request.Username,
DelaySeconds: request.Track.Length - request.Track.Position + 5,
})
if err != nil {
return nil, fmt.Errorf("failed to schedule completion timer: %w", err)
}
return nil, nil
}
func (d *DiscordRPPlugin) imageURL(ctx context.Context, request *api.ScrobblerNowPlayingRequest) string {
imageResp, _ := d.artwork.GetTrackUrl(ctx, &artwork.GetArtworkUrlRequest{Id: request.Track.Id, Size: 300})
imageURL := imageResp.Url
if strings.HasPrefix(imageURL, "http://localhost") {
return ""
}
return imageURL
}
func (d *DiscordRPPlugin) getArtistList(track *api.TrackInfo) string {
return strings.Join(slice.Map(track.Artists, func(a *api.Artist) string { return a.Name }), " • ")
}
func (d *DiscordRPPlugin) Scrobble(context.Context, *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) {
return nil, nil
}
func (d *DiscordRPPlugin) getConfig(ctx context.Context) (string, map[string]string, error) {
const (
clientIDKey = "clientid"
usersKey = "users"
)
confResp, err := d.cfg.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
if err != nil {
return "", nil, fmt.Errorf("unable to load config: %w", err)
}
conf := confResp.GetConfig()
if len(conf) < 1 {
log.Print("missing configuration")
return "", nil, nil
}
clientID := conf[clientIDKey]
if clientID == "" {
log.Printf("missing ClientID: %v", conf)
return "", nil, nil
}
cfgUsers := conf[usersKey]
if len(cfgUsers) == 0 {
log.Print("no users configured")
return "", nil, nil
}
users := map[string]string{}
for _, user := range strings.Split(cfgUsers, ",") {
tuple := strings.Split(user, ":")
if len(tuple) != 2 {
return clientID, nil, fmt.Errorf("invalid user config: %s", user)
}
users[tuple[0]] = tuple[1]
}
return clientID, users, nil
}
func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
log.Printf("Removing presence for user %s", req.ScheduleId)
if err := d.rpc.clearActivity(ctx, req.ScheduleId); err != nil {
return nil, fmt.Errorf("failed to clear activity: %w", err)
}
log.Printf("Disconnecting user %s", req.ScheduleId)
if err := d.rpc.disconnect(ctx, req.ScheduleId); err != nil {
return nil, fmt.Errorf("failed to disconnect from Discord: %w", err)
}
return nil, nil
}
// Creates a new instance of the DiscordRPPlugin, with all host services as dependencies
var plugin = &DiscordRPPlugin{
cfg: config.NewConfigService(),
artwork: artwork.NewArtworkService(),
rpc: &discordRPC{
ws: websocket.NewWebSocketService(),
web: http.NewHttpService(),
mem: cache.NewCacheService(),
},
}
func init() {
// Configure logging: No timestamps, no source file/line, prepend [Discord]
log.SetFlags(0)
log.SetPrefix("[Discord] ")
// Register plugin capabilities
api.RegisterScrobbler(plugin)
api.RegisterWebSocketCallback(plugin.rpc)
// Register named scheduler callbacks, and get the scheduler service for each
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
}
func main() {}
@@ -0,0 +1,365 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/cache"
"github.com/navidrome/navidrome/plugins/host/http"
"github.com/navidrome/navidrome/plugins/host/scheduler"
"github.com/navidrome/navidrome/plugins/host/websocket"
)
type discordRPC struct {
ws websocket.WebSocketService
web http.HttpService
mem cache.CacheService
sched scheduler.SchedulerService
}
// Discord WebSocket Gateway constants
const (
heartbeatOpCode = 1 // Heartbeat operation code
gateOpCode = 2 // Identify operation code
presenceOpCode = 3 // Presence update operation code
)
const (
heartbeatInterval = 41 // Heartbeat interval in seconds
defaultImage = "https://i.imgur.com/hb3XPzA.png"
)
// Activity is a struct that represents an activity in Discord.
type activity struct {
Name string `json:"name"`
Type int `json:"type"`
Details string `json:"details"`
State string `json:"state"`
Application string `json:"application_id"`
Timestamps activityTimestamps `json:"timestamps"`
Assets activityAssets `json:"assets"`
}
type activityTimestamps struct {
Start int64 `json:"start"`
End int64 `json:"end"`
}
type activityAssets struct {
LargeImage string `json:"large_image"`
LargeText string `json:"large_text"`
}
// PresencePayload is a struct that represents a presence update in Discord.
type presencePayload struct {
Activities []activity `json:"activities"`
Since int64 `json:"since"`
Status string `json:"status"`
Afk bool `json:"afk"`
}
// IdentifyPayload is a struct that represents an identify payload in Discord.
type identifyPayload struct {
Token string `json:"token"`
Intents int `json:"intents"`
Properties identifyProperties `json:"properties"`
}
type identifyProperties struct {
OS string `json:"os"`
Browser string `json:"browser"`
Device string `json:"device"`
}
func (r *discordRPC) processImage(ctx context.Context, imageURL string, clientID string, token string) (string, error) {
return r.processImageWithFallback(ctx, imageURL, clientID, token, false)
}
func (r *discordRPC) processImageWithFallback(ctx context.Context, imageURL string, clientID string, token string, isDefaultImage bool) (string, error) {
// Check if context is canceled
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("context canceled: %w", err)
}
if imageURL == "" {
if isDefaultImage {
// We're already processing the default image and it's empty, return error
return "", fmt.Errorf("default image URL is empty")
}
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
if strings.HasPrefix(imageURL, "mp:") {
return imageURL, nil
}
// Check cache first
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
cacheResp, _ := r.mem.GetString(ctx, &cache.GetRequest{Key: cacheKey})
if cacheResp.Exists {
log.Printf("Cache hit for image URL: %s", imageURL)
return cacheResp.Value, nil
}
resp, _ := r.web.Post(ctx, &http.HttpRequest{
Url: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
Headers: map[string]string{
"Authorization": token,
"Content-Type": "application/json",
},
Body: fmt.Appendf(nil, `{"urls":[%q]}`, imageURL),
})
// Handle HTTP error responses
if resp.Status >= 400 {
if isDefaultImage {
return "", fmt.Errorf("failed to process default image: HTTP %d %s", resp.Status, resp.Error)
}
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
if resp.Error != "" {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("failed to process default image: %s", resp.Error)
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
var data []map[string]string
if err := json.Unmarshal(resp.Body, &data); err != nil {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
if len(data) == 0 {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("no data returned for default image")
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
image := data[0]["external_asset_path"]
if image == "" {
if isDefaultImage {
// If we're already processing the default image and it fails, return error
return "", fmt.Errorf("empty external_asset_path for default image")
}
// Try with default image
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
}
processedImage := fmt.Sprintf("mp:%s", image)
// Cache the processed image URL
var ttl = 4 * time.Hour // 4 hours for regular images
if isDefaultImage {
ttl = 48 * time.Hour // 48 hours for default image
}
_, _ = r.mem.SetString(ctx, &cache.SetStringRequest{
Key: cacheKey,
Value: processedImage,
TtlSeconds: int64(ttl.Seconds()),
})
log.Printf("Cached processed image URL for %s (TTL: %s seconds)", imageURL, ttl)
return processedImage, nil
}
func (r *discordRPC) sendActivity(ctx context.Context, clientID, username, token string, data activity) error {
log.Printf("Sending activity to for user %s: %#v", username, data)
processedImage, err := r.processImage(ctx, data.Assets.LargeImage, clientID, token)
if err != nil {
log.Printf("Failed to process image for user %s, continuing without image: %v", username, err)
// Clear the image and continue without it
data.Assets.LargeImage = ""
} else {
log.Printf("Processed image for URL %s: %s", data.Assets.LargeImage, processedImage)
data.Assets.LargeImage = processedImage
}
presence := presencePayload{
Activities: []activity{data},
Status: "dnd",
Afk: false,
}
return r.sendMessage(ctx, username, presenceOpCode, presence)
}
func (r *discordRPC) clearActivity(ctx context.Context, username string) error {
log.Printf("Clearing activity for user %s", username)
return r.sendMessage(ctx, username, presenceOpCode, presencePayload{})
}
func (r *discordRPC) sendMessage(ctx context.Context, username string, opCode int, payload any) error {
message := map[string]any{
"op": opCode,
"d": payload,
}
b, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal presence update: %w", err)
}
resp, _ := r.ws.SendText(ctx, &websocket.SendTextRequest{
ConnectionId: username,
Message: string(b),
})
if resp.Error != "" {
return fmt.Errorf("failed to send presence update: %s", resp.Error)
}
return nil
}
func (r *discordRPC) getDiscordGateway(ctx context.Context) (string, error) {
resp, _ := r.web.Get(ctx, &http.HttpRequest{
Url: "https://discord.com/api/gateway",
})
if resp.Error != "" {
return "", fmt.Errorf("failed to get Discord gateway: %s", resp.Error)
}
var result map[string]string
err := json.Unmarshal(resp.Body, &result)
if err != nil {
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
}
return result["url"], nil
}
func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error {
resp, _ := r.mem.GetInt(ctx, &cache.GetRequest{
Key: fmt.Sprintf("discord.seq.%s", username),
})
log.Printf("Sending heartbeat for user %s: %d", username, resp.Value)
return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value)
}
func (r *discordRPC) isConnected(ctx context.Context, username string) bool {
err := r.sendHeartbeat(ctx, username)
return err == nil
}
func (r *discordRPC) connect(ctx context.Context, username string, token string) error {
if r.isConnected(ctx, username) {
log.Printf("Reusing existing connection for user %s", username)
return nil
}
log.Printf("Creating new connection for user %s", username)
// Get Discord Gateway URL
gateway, err := r.getDiscordGateway(ctx)
if err != nil {
return fmt.Errorf("failed to get Discord gateway: %w", err)
}
log.Printf("Using gateway: %s", gateway)
// Connect to Discord Gateway
resp, _ := r.ws.Connect(ctx, &websocket.ConnectRequest{
ConnectionId: username,
Url: gateway,
})
if resp.Error != "" {
return fmt.Errorf("failed to connect to WebSocket: %s", resp.Error)
}
// Send identify payload
payload := identifyPayload{
Token: token,
Intents: 0,
Properties: identifyProperties{
OS: "Windows 10",
Browser: "Discord Client",
Device: "Discord Client",
},
}
err = r.sendMessage(ctx, username, gateOpCode, payload)
if err != nil {
return fmt.Errorf("failed to send identify payload: %w", err)
}
// Schedule heartbeats for this user/connection
cronResp, _ := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
CronExpression: fmt.Sprintf("@every %ds", heartbeatInterval),
ScheduleId: username,
})
log.Printf("Scheduled heartbeat for user %s with ID %s", username, cronResp.ScheduleId)
log.Printf("Successfully authenticated user %s", username)
return nil
}
func (r *discordRPC) disconnect(ctx context.Context, username string) error {
if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
return fmt.Errorf("failed to cancel schedule: %s", resp.Error)
}
resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
ConnectionId: username,
Code: 1000,
Reason: "Navidrome disconnect",
})
if resp.Error != "" {
return fmt.Errorf("failed to close WebSocket connection: %s", resp.Error)
}
return nil
}
func (r *discordRPC) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) {
if len(req.Message) < 1024 {
log.Printf("Received WebSocket message for connection '%s': %s", req.ConnectionId, req.Message)
} else {
log.Printf("Received WebSocket message for connection '%s' (truncated): %s...", req.ConnectionId, req.Message[:1021])
}
// Parse the message. If it's a heartbeat_ack, store the sequence number.
message := map[string]any{}
err := json.Unmarshal([]byte(req.Message), &message)
if err != nil {
return nil, fmt.Errorf("failed to parse WebSocket message: %w", err)
}
if v := message["s"]; v != nil {
seq := int64(v.(float64))
log.Printf("Received heartbeat_ack for connection '%s': %d", req.ConnectionId, seq)
resp, _ := r.mem.SetInt(ctx, &cache.SetIntRequest{
Key: fmt.Sprintf("discord.seq.%s", req.ConnectionId),
Value: seq,
TtlSeconds: heartbeatInterval * 2,
})
if !resp.Success {
return nil, fmt.Errorf("failed to store sequence number for user %s", req.ConnectionId)
}
}
return nil, nil
}
func (r *discordRPC) OnBinaryMessage(_ context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) {
log.Printf("Received unexpected binary message for connection '%s'", req.ConnectionId)
return nil, nil
}
func (r *discordRPC) OnError(_ context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) {
log.Printf("WebSocket error for connection '%s': %s", req.ConnectionId, req.Error)
return nil, nil
}
func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) {
log.Printf("WebSocket connection '%s' closed with code %d: %s", req.ConnectionId, req.Code, req.Reason)
return nil, nil
}
func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
return nil, r.sendHeartbeat(ctx, req.ScheduleId)
}
+32
View File
@@ -0,0 +1,32 @@
# Wikimedia Artist Metadata Plugin
This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint.
## Implemented Methods
- `GetArtistBiography`: Returns the artist's English biography/description from Wikidata.
- `GetArtistURL`: Returns the artist's official website (if available) from Wikidata.
- `GetArtistImages`: Returns the artist's main image (Wikimedia Commons) from Wikidata.
All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia.
## How it Works
- The plugin uses the host-provided HTTP service (`HttpService`) to make SPARQL queries to the Wikidata endpoint.
- No network requests are made directly from the plugin; all HTTP is routed through the host.
## Building
To build the plugin to WASM:
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
```
## Usage
Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory.
---
For more details, see the source code in `plugin.go`.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "wikimedia",
"author": "Navidrome",
"version": "1.0.0",
"description": "Artist information and images from Wikimedia Commons",
"website": "https://commons.wikimedia.org",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch artist information and images from Wikimedia Commons API",
"allowedUrls": {
"https://*.wikimedia.org": ["GET"],
"https://*.wikipedia.org": ["GET"],
"https://commons.wikimedia.org": ["GET"]
},
"allowLocalNetwork": false
}
}
}
+387
View File
@@ -0,0 +1,387 @@
//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() {
api.RegisterMetadataAgent(WikimediaAgent{})
}
+73
View File
@@ -0,0 +1,73 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/artwork/artwork.proto
package artwork
import (
context "context"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetArtworkUrlRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Optional, 0 means original size
}
func (x *GetArtworkUrlRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetArtworkUrlRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *GetArtworkUrlRequest) GetSize() int32 {
if x != nil {
return x.Size
}
return 0
}
type GetArtworkUrlResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
}
func (x *GetArtworkUrlResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetArtworkUrlResponse) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
// go:plugin type=host version=1
type ArtworkService interface {
GetArtistUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
GetAlbumUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
GetTrackUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
}
+21
View File
@@ -0,0 +1,21 @@
syntax = "proto3";
package artwork;
option go_package = "github.com/navidrome/navidrome/plugins/host/artwork;artwork";
// go:plugin type=host version=1
service ArtworkService {
rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
}
message GetArtworkUrlRequest {
string id = 1;
int32 size = 2; // Optional, 0 means original size
}
message GetArtworkUrlResponse {
string url = 1;
}
+130
View File
@@ -0,0 +1,130 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/artwork/artwork.proto
package artwork
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
wazero "github.com/tetratelabs/wazero"
api "github.com/tetratelabs/wazero/api"
)
const (
i32 = api.ValueTypeI32
i64 = api.ValueTypeI64
)
type _artworkService struct {
ArtworkService
}
// Instantiate a Go-defined module named "env" that exports host functions.
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ArtworkService) error {
envBuilder := r.NewHostModuleBuilder("env")
h := _artworkService{hostFunctions}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetArtistUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_artist_url")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetAlbumUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_album_url")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetTrackUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_track_url")
_, err := envBuilder.Instantiate(ctx)
return err
}
func (h _artworkService) _GetArtistUrl(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetArtworkUrlRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetArtistUrl(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _artworkService) _GetAlbumUrl(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetArtworkUrlRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetAlbumUrl(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _artworkService) _GetTrackUrl(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetArtworkUrlRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetTrackUrl(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
+90
View File
@@ -0,0 +1,90 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/artwork/artwork.proto
package artwork
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
_ "unsafe"
)
type artworkService struct{}
func NewArtworkService() ArtworkService {
return artworkService{}
}
//go:wasmimport env get_artist_url
func _get_artist_url(ptr uint32, size uint32) uint64
func (h artworkService) GetArtistUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_artist_url(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetArtworkUrlResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env get_album_url
func _get_album_url(ptr uint32, size uint32) uint64
func (h artworkService) GetAlbumUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_album_url(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetArtworkUrlResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env get_track_url
func _get_track_url(ptr uint32, size uint32) uint64
func (h artworkService) GetTrackUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_track_url(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetArtworkUrlResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
@@ -0,0 +1,7 @@
//go:build !wasip1
package artwork
func NewArtworkService() ArtworkService {
panic("not implemented")
}
+425
View File
@@ -0,0 +1,425 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/artwork/artwork.proto
package artwork
import (
fmt "fmt"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
io "io"
bits "math/bits"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
func (m *GetArtworkUrlRequest) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *GetArtworkUrlRequest) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *GetArtworkUrlRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if m.Size != 0 {
i = encodeVarint(dAtA, i, uint64(m.Size))
i--
dAtA[i] = 0x10
}
if len(m.Id) > 0 {
i -= len(m.Id)
copy(dAtA[i:], m.Id)
i = encodeVarint(dAtA, i, uint64(len(m.Id)))
i--
dAtA[i] = 0xa
}
return len(dAtA) - i, nil
}
func (m *GetArtworkUrlResponse) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *GetArtworkUrlResponse) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *GetArtworkUrlResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.Url) > 0 {
i -= len(m.Url)
copy(dAtA[i:], m.Url)
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
i--
dAtA[i] = 0xa
}
return len(dAtA) - i, nil
}
func encodeVarint(dAtA []byte, offset int, v uint64) int {
offset -= sov(v)
base := offset
for v >= 1<<7 {
dAtA[offset] = uint8(v&0x7f | 0x80)
v >>= 7
offset++
}
dAtA[offset] = uint8(v)
return base
}
func (m *GetArtworkUrlRequest) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
l = len(m.Id)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
if m.Size != 0 {
n += 1 + sov(uint64(m.Size))
}
n += len(m.unknownFields)
return n
}
func (m *GetArtworkUrlResponse) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
l = len(m.Url)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
n += len(m.unknownFields)
return n
}
func sov(x uint64) (n int) {
return (bits.Len64(x|1) + 6) / 7
}
func soz(x uint64) (n int) {
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
}
func (m *GetArtworkUrlRequest) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: GetArtworkUrlRequest: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: GetArtworkUrlRequest: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Id = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
case 2:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType)
}
m.Size = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.Size |= int32(b&0x7F) << shift
if b < 0x80 {
break
}
}
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func (m *GetArtworkUrlResponse) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: GetArtworkUrlResponse: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: GetArtworkUrlResponse: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Url = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skip(dAtA []byte) (n int, err error) {
l := len(dAtA)
iNdEx := 0
depth := 0
for iNdEx < l {
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= (uint64(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
wireType := int(wire & 0x7)
switch wireType {
case 0:
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
iNdEx++
if dAtA[iNdEx-1] < 0x80 {
break
}
}
case 1:
iNdEx += 8
case 2:
var length int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
length |= (int(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
if length < 0 {
return 0, ErrInvalidLength
}
iNdEx += length
case 3:
depth++
case 4:
if depth == 0 {
return 0, ErrUnexpectedEndOfGroup
}
depth--
case 5:
iNdEx += 4
default:
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
}
if iNdEx < 0 {
return 0, ErrInvalidLength
}
if depth == 0 {
return iNdEx, nil
}
}
return 0, io.ErrUnexpectedEOF
}
var (
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
)
+420
View File
@@ -0,0 +1,420 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/cache/cache.proto
package cache
import (
context "context"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Request to store a string value
type SetStringRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // String value to store
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
}
func (x *SetStringRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SetStringRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *SetStringRequest) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
func (x *SetStringRequest) GetTtlSeconds() int64 {
if x != nil {
return x.TtlSeconds
}
return 0
}
// Request to store an integer value
type SetIntRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // Integer value to store
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
}
func (x *SetIntRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SetIntRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *SetIntRequest) GetValue() int64 {
if x != nil {
return x.Value
}
return 0
}
func (x *SetIntRequest) GetTtlSeconds() int64 {
if x != nil {
return x.TtlSeconds
}
return 0
}
// Request to store a float value
type SetFloatRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Float value to store
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
}
func (x *SetFloatRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SetFloatRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *SetFloatRequest) GetValue() float64 {
if x != nil {
return x.Value
}
return 0
}
func (x *SetFloatRequest) GetTtlSeconds() int64 {
if x != nil {
return x.TtlSeconds
}
return 0
}
// Request to store a byte slice value
type SetBytesRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // Byte slice value to store
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
}
func (x *SetBytesRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SetBytesRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *SetBytesRequest) GetValue() []byte {
if x != nil {
return x.Value
}
return nil
}
func (x *SetBytesRequest) GetTtlSeconds() int64 {
if x != nil {
return x.TtlSeconds
}
return 0
}
// Response after setting a value
type SetResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful
}
func (x *SetResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SetResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
// Request to get a value
type GetRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
}
func (x *GetRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
// Response containing a string value
type GetStringResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The string value (if exists is true)
}
func (x *GetStringResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetStringResponse) GetExists() bool {
if x != nil {
return x.Exists
}
return false
}
func (x *GetStringResponse) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
// Response containing an integer value
type GetIntResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // The integer value (if exists is true)
}
func (x *GetIntResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetIntResponse) GetExists() bool {
if x != nil {
return x.Exists
}
return false
}
func (x *GetIntResponse) GetValue() int64 {
if x != nil {
return x.Value
}
return 0
}
// Response containing a float value
type GetFloatResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // The float value (if exists is true)
}
func (x *GetFloatResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetFloatResponse) GetExists() bool {
if x != nil {
return x.Exists
}
return false
}
func (x *GetFloatResponse) GetValue() float64 {
if x != nil {
return x.Value
}
return 0
}
// Response containing a byte slice value
type GetBytesResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The byte slice value (if exists is true)
}
func (x *GetBytesResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetBytesResponse) GetExists() bool {
if x != nil {
return x.Exists
}
return false
}
func (x *GetBytesResponse) GetValue() []byte {
if x != nil {
return x.Value
}
return nil
}
// Request to remove a value
type RemoveRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
}
func (x *RemoveRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *RemoveRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
// Response after removing a value
type RemoveResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful
}
func (x *RemoveResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *RemoveResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
// Request to check if a key exists
type HasRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
}
func (x *HasRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *HasRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
// Response indicating if a key exists
type HasResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
}
func (x *HasResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *HasResponse) GetExists() bool {
if x != nil {
return x.Exists
}
return false
}
// go:plugin type=host version=1
type CacheService interface {
// Set a string value in the cache
SetString(context.Context, *SetStringRequest) (*SetResponse, error)
// Get a string value from the cache
GetString(context.Context, *GetRequest) (*GetStringResponse, error)
// Set an integer value in the cache
SetInt(context.Context, *SetIntRequest) (*SetResponse, error)
// Get an integer value from the cache
GetInt(context.Context, *GetRequest) (*GetIntResponse, error)
// Set a float value in the cache
SetFloat(context.Context, *SetFloatRequest) (*SetResponse, error)
// Get a float value from the cache
GetFloat(context.Context, *GetRequest) (*GetFloatResponse, error)
// Set a byte slice value in the cache
SetBytes(context.Context, *SetBytesRequest) (*SetResponse, error)
// Get a byte slice value from the cache
GetBytes(context.Context, *GetRequest) (*GetBytesResponse, error)
// Remove a value from the cache
Remove(context.Context, *RemoveRequest) (*RemoveResponse, error)
// Check if a key exists in the cache
Has(context.Context, *HasRequest) (*HasResponse, error)
}
+120
View File
@@ -0,0 +1,120 @@
syntax = "proto3";
package cache;
option go_package = "github.com/navidrome/navidrome/plugins/host/cache;cache";
// go:plugin type=host version=1
service CacheService {
// Set a string value in the cache
rpc SetString(SetStringRequest) returns (SetResponse);
// Get a string value from the cache
rpc GetString(GetRequest) returns (GetStringResponse);
// Set an integer value in the cache
rpc SetInt(SetIntRequest) returns (SetResponse);
// Get an integer value from the cache
rpc GetInt(GetRequest) returns (GetIntResponse);
// Set a float value in the cache
rpc SetFloat(SetFloatRequest) returns (SetResponse);
// Get a float value from the cache
rpc GetFloat(GetRequest) returns (GetFloatResponse);
// Set a byte slice value in the cache
rpc SetBytes(SetBytesRequest) returns (SetResponse);
// Get a byte slice value from the cache
rpc GetBytes(GetRequest) returns (GetBytesResponse);
// Remove a value from the cache
rpc Remove(RemoveRequest) returns (RemoveResponse);
// Check if a key exists in the cache
rpc Has(HasRequest) returns (HasResponse);
}
// Request to store a string value
message SetStringRequest {
string key = 1; // Cache key
string value = 2; // String value to store
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
}
// Request to store an integer value
message SetIntRequest {
string key = 1; // Cache key
int64 value = 2; // Integer value to store
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
}
// Request to store a float value
message SetFloatRequest {
string key = 1; // Cache key
double value = 2; // Float value to store
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
}
// Request to store a byte slice value
message SetBytesRequest {
string key = 1; // Cache key
bytes value = 2; // Byte slice value to store
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
}
// Response after setting a value
message SetResponse {
bool success = 1; // Whether the operation was successful
}
// Request to get a value
message GetRequest {
string key = 1; // Cache key
}
// Response containing a string value
message GetStringResponse {
bool exists = 1; // Whether the key exists
string value = 2; // The string value (if exists is true)
}
// Response containing an integer value
message GetIntResponse {
bool exists = 1; // Whether the key exists
int64 value = 2; // The integer value (if exists is true)
}
// Response containing a float value
message GetFloatResponse {
bool exists = 1; // Whether the key exists
double value = 2; // The float value (if exists is true)
}
// Response containing a byte slice value
message GetBytesResponse {
bool exists = 1; // Whether the key exists
bytes value = 2; // The byte slice value (if exists is true)
}
// Request to remove a value
message RemoveRequest {
string key = 1; // Cache key
}
// Response after removing a value
message RemoveResponse {
bool success = 1; // Whether the operation was successful
}
// Request to check if a key exists
message HasRequest {
string key = 1; // Cache key
}
// Response indicating if a key exists
message HasResponse {
bool exists = 1; // Whether the key exists
}
+374
View File
@@ -0,0 +1,374 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/cache/cache.proto
package cache
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
wazero "github.com/tetratelabs/wazero"
api "github.com/tetratelabs/wazero/api"
)
const (
i32 = api.ValueTypeI32
i64 = api.ValueTypeI64
)
type _cacheService struct {
CacheService
}
// Instantiate a Go-defined module named "env" that exports host functions.
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions CacheService) error {
envBuilder := r.NewHostModuleBuilder("env")
h := _cacheService{hostFunctions}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._SetString), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("set_string")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetString), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_string")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._SetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("set_int")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_int")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._SetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("set_float")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_float")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._SetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("set_bytes")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_bytes")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Remove), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("remove")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Has), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("has")
_, err := envBuilder.Instantiate(ctx)
return err
}
// Set a string value in the cache
func (h _cacheService) _SetString(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(SetStringRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.SetString(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Get a string value from the cache
func (h _cacheService) _GetString(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetString(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Set an integer value in the cache
func (h _cacheService) _SetInt(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(SetIntRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.SetInt(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Get an integer value from the cache
func (h _cacheService) _GetInt(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetInt(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Set a float value in the cache
func (h _cacheService) _SetFloat(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(SetFloatRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.SetFloat(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Get a float value from the cache
func (h _cacheService) _GetFloat(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetFloat(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Set a byte slice value in the cache
func (h _cacheService) _SetBytes(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(SetBytesRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.SetBytes(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Get a byte slice value from the cache
func (h _cacheService) _GetBytes(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetBytes(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Remove a value from the cache
func (h _cacheService) _Remove(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(RemoveRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Remove(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Check if a key exists in the cache
func (h _cacheService) _Has(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HasRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Has(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
+251
View File
@@ -0,0 +1,251 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/cache/cache.proto
package cache
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
_ "unsafe"
)
type cacheService struct{}
func NewCacheService() CacheService {
return cacheService{}
}
//go:wasmimport env set_string
func _set_string(ptr uint32, size uint32) uint64
func (h cacheService) SetString(ctx context.Context, request *SetStringRequest) (*SetResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _set_string(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(SetResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env get_string
func _get_string(ptr uint32, size uint32) uint64
func (h cacheService) GetString(ctx context.Context, request *GetRequest) (*GetStringResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_string(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetStringResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env set_int
func _set_int(ptr uint32, size uint32) uint64
func (h cacheService) SetInt(ctx context.Context, request *SetIntRequest) (*SetResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _set_int(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(SetResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env get_int
func _get_int(ptr uint32, size uint32) uint64
func (h cacheService) GetInt(ctx context.Context, request *GetRequest) (*GetIntResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_int(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetIntResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env set_float
func _set_float(ptr uint32, size uint32) uint64
func (h cacheService) SetFloat(ctx context.Context, request *SetFloatRequest) (*SetResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _set_float(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(SetResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env get_float
func _get_float(ptr uint32, size uint32) uint64
func (h cacheService) GetFloat(ctx context.Context, request *GetRequest) (*GetFloatResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_float(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetFloatResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env set_bytes
func _set_bytes(ptr uint32, size uint32) uint64
func (h cacheService) SetBytes(ctx context.Context, request *SetBytesRequest) (*SetResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _set_bytes(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(SetResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env get_bytes
func _get_bytes(ptr uint32, size uint32) uint64
func (h cacheService) GetBytes(ctx context.Context, request *GetRequest) (*GetBytesResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_bytes(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetBytesResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env remove
func _remove(ptr uint32, size uint32) uint64
func (h cacheService) Remove(ctx context.Context, request *RemoveRequest) (*RemoveResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _remove(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(RemoveResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env has
func _has(ptr uint32, size uint32) uint64
func (h cacheService) Has(ctx context.Context, request *HasRequest) (*HasResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _has(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HasResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !wasip1
package cache
func NewCacheService() CacheService {
panic("not implemented")
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/config/config.proto
package config
import (
context "context"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetPluginConfigRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetPluginConfigRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
type GetPluginConfigResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}
func (x *GetPluginConfigResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *GetPluginConfigResponse) GetConfig() map[string]string {
if x != nil {
return x.Config
}
return nil
}
// go:plugin type=host version=1
type ConfigService interface {
GetPluginConfig(context.Context, *GetPluginConfigRequest) (*GetPluginConfigResponse, error)
}
+18
View File
@@ -0,0 +1,18 @@
syntax = "proto3";
package config;
option go_package = "github.com/navidrome/navidrome/plugins/host/config;config";
// go:plugin type=host version=1
service ConfigService {
rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse);
}
message GetPluginConfigRequest {
// No fields needed; plugin name is inferred from context
}
message GetPluginConfigResponse {
map<string, string> config = 1;
}
+66
View File
@@ -0,0 +1,66 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/config/config.proto
package config
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
wazero "github.com/tetratelabs/wazero"
api "github.com/tetratelabs/wazero/api"
)
const (
i32 = api.ValueTypeI32
i64 = api.ValueTypeI64
)
type _configService struct {
ConfigService
}
// Instantiate a Go-defined module named "env" that exports host functions.
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ConfigService) error {
envBuilder := r.NewHostModuleBuilder("env")
h := _configService{hostFunctions}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._GetPluginConfig), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get_plugin_config")
_, err := envBuilder.Instantiate(ctx)
return err
}
func (h _configService) _GetPluginConfig(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(GetPluginConfigRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.GetPluginConfig(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
+44
View File
@@ -0,0 +1,44 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/config/config.proto
package config
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
_ "unsafe"
)
type configService struct{}
func NewConfigService() ConfigService {
return configService{}
}
//go:wasmimport env get_plugin_config
func _get_plugin_config(ptr uint32, size uint32) uint64
func (h configService) GetPluginConfig(ctx context.Context, request *GetPluginConfigRequest) (*GetPluginConfigResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get_plugin_config(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(GetPluginConfigResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !wasip1
package config
func NewConfigService() ConfigService {
panic("not implemented")
}
+466
View File
@@ -0,0 +1,466 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/config/config.proto
package config
import (
fmt "fmt"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
io "io"
bits "math/bits"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
func (m *GetPluginConfigRequest) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *GetPluginConfigRequest) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *GetPluginConfigRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
return len(dAtA) - i, nil
}
func (m *GetPluginConfigResponse) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *GetPluginConfigResponse) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *GetPluginConfigResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.Config) > 0 {
for k := range m.Config {
v := m.Config[k]
baseI := i
i -= len(v)
copy(dAtA[i:], v)
i = encodeVarint(dAtA, i, uint64(len(v)))
i--
dAtA[i] = 0x12
i -= len(k)
copy(dAtA[i:], k)
i = encodeVarint(dAtA, i, uint64(len(k)))
i--
dAtA[i] = 0xa
i = encodeVarint(dAtA, i, uint64(baseI-i))
i--
dAtA[i] = 0xa
}
}
return len(dAtA) - i, nil
}
func encodeVarint(dAtA []byte, offset int, v uint64) int {
offset -= sov(v)
base := offset
for v >= 1<<7 {
dAtA[offset] = uint8(v&0x7f | 0x80)
v >>= 7
offset++
}
dAtA[offset] = uint8(v)
return base
}
func (m *GetPluginConfigRequest) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
n += len(m.unknownFields)
return n
}
func (m *GetPluginConfigResponse) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
if len(m.Config) > 0 {
for k, v := range m.Config {
_ = k
_ = v
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
}
}
n += len(m.unknownFields)
return n
}
func sov(x uint64) (n int) {
return (bits.Len64(x|1) + 6) / 7
}
func soz(x uint64) (n int) {
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
}
func (m *GetPluginConfigRequest) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: GetPluginConfigRequest: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: GetPluginConfigRequest: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func (m *GetPluginConfigResponse) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: GetPluginConfigResponse: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: GetPluginConfigResponse: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType)
}
var msglen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
msglen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if msglen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + msglen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
if m.Config == nil {
m.Config = make(map[string]string)
}
var mapkey string
var mapvalue string
for iNdEx < postIndex {
entryPreIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
if fieldNum == 1 {
var stringLenmapkey uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLenmapkey |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLenmapkey := int(stringLenmapkey)
if intStringLenmapkey < 0 {
return ErrInvalidLength
}
postStringIndexmapkey := iNdEx + intStringLenmapkey
if postStringIndexmapkey < 0 {
return ErrInvalidLength
}
if postStringIndexmapkey > l {
return io.ErrUnexpectedEOF
}
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
iNdEx = postStringIndexmapkey
} else if fieldNum == 2 {
var stringLenmapvalue uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLenmapvalue |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLenmapvalue := int(stringLenmapvalue)
if intStringLenmapvalue < 0 {
return ErrInvalidLength
}
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
if postStringIndexmapvalue < 0 {
return ErrInvalidLength
}
if postStringIndexmapvalue > l {
return io.ErrUnexpectedEOF
}
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
iNdEx = postStringIndexmapvalue
} else {
iNdEx = entryPreIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > postIndex {
return io.ErrUnexpectedEOF
}
iNdEx += skippy
}
}
m.Config[mapkey] = mapvalue
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skip(dAtA []byte) (n int, err error) {
l := len(dAtA)
iNdEx := 0
depth := 0
for iNdEx < l {
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= (uint64(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
wireType := int(wire & 0x7)
switch wireType {
case 0:
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
iNdEx++
if dAtA[iNdEx-1] < 0x80 {
break
}
}
case 1:
iNdEx += 8
case 2:
var length int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
length |= (int(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
if length < 0 {
return 0, ErrInvalidLength
}
iNdEx += length
case 3:
depth++
case 4:
if depth == 0 {
return 0, ErrUnexpectedEndOfGroup
}
depth--
case 5:
iNdEx += 4
default:
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
}
if iNdEx < 0 {
return 0, ErrInvalidLength
}
if depth == 0 {
return iNdEx, nil
}
}
return 0, io.ErrUnexpectedEOF
}
var (
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
)
+117
View File
@@ -0,0 +1,117 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/http/http.proto
package http
import (
context "context"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type HttpRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
TimeoutMs int32 `protobuf:"varint,3,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"`
Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` // Ignored for GET/DELETE/HEAD/OPTIONS
}
func (x *HttpRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *HttpRequest) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
func (x *HttpRequest) GetHeaders() map[string]string {
if x != nil {
return x.Headers
}
return nil
}
func (x *HttpRequest) GetTimeoutMs() int32 {
if x != nil {
return x.TimeoutMs
}
return 0
}
func (x *HttpRequest) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
type HttpResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
Headers map[string]string `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if network/protocol error
}
func (x *HttpResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *HttpResponse) GetStatus() int32 {
if x != nil {
return x.Status
}
return 0
}
func (x *HttpResponse) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
func (x *HttpResponse) GetHeaders() map[string]string {
if x != nil {
return x.Headers
}
return nil
}
func (x *HttpResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// go:plugin type=host version=1
type HttpService interface {
Get(context.Context, *HttpRequest) (*HttpResponse, error)
Post(context.Context, *HttpRequest) (*HttpResponse, error)
Put(context.Context, *HttpRequest) (*HttpResponse, error)
Delete(context.Context, *HttpRequest) (*HttpResponse, error)
Patch(context.Context, *HttpRequest) (*HttpResponse, error)
Head(context.Context, *HttpRequest) (*HttpResponse, error)
Options(context.Context, *HttpRequest) (*HttpResponse, error)
}
+30
View File
@@ -0,0 +1,30 @@
syntax = "proto3";
package http;
option go_package = "github.com/navidrome/navidrome/plugins/host/http;http";
// go:plugin type=host version=1
service HttpService {
rpc Get(HttpRequest) returns (HttpResponse);
rpc Post(HttpRequest) returns (HttpResponse);
rpc Put(HttpRequest) returns (HttpResponse);
rpc Delete(HttpRequest) returns (HttpResponse);
rpc Patch(HttpRequest) returns (HttpResponse);
rpc Head(HttpRequest) returns (HttpResponse);
rpc Options(HttpRequest) returns (HttpResponse);
}
message HttpRequest {
string url = 1;
map<string, string> headers = 2;
int32 timeout_ms = 3;
bytes body = 4; // Ignored for GET/DELETE/HEAD/OPTIONS
}
message HttpResponse {
int32 status = 1;
bytes body = 2;
map<string, string> headers = 3;
string error = 4; // Non-empty if network/protocol error
}
+258
View File
@@ -0,0 +1,258 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/http/http.proto
package http
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
wazero "github.com/tetratelabs/wazero"
api "github.com/tetratelabs/wazero/api"
)
const (
i32 = api.ValueTypeI32
i64 = api.ValueTypeI64
)
type _httpService struct {
HttpService
}
// Instantiate a Go-defined module named "env" that exports host functions.
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions HttpService) error {
envBuilder := r.NewHostModuleBuilder("env")
h := _httpService{hostFunctions}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Get), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("get")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Post), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("post")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Put), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("put")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Delete), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("delete")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Patch), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("patch")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Head), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("head")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Options), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("options")
_, err := envBuilder.Instantiate(ctx)
return err
}
func (h _httpService) _Get(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Get(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _httpService) _Post(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Post(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _httpService) _Put(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Put(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _httpService) _Delete(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Delete(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _httpService) _Patch(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Patch(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _httpService) _Head(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Head(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
func (h _httpService) _Options(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(HttpRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Options(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
+182
View File
@@ -0,0 +1,182 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/http/http.proto
package http
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
_ "unsafe"
)
type httpService struct{}
func NewHttpService() HttpService {
return httpService{}
}
//go:wasmimport env get
func _get(ptr uint32, size uint32) uint64
func (h httpService) Get(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _get(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env post
func _post(ptr uint32, size uint32) uint64
func (h httpService) Post(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _post(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env put
func _put(ptr uint32, size uint32) uint64
func (h httpService) Put(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _put(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env delete
func _delete(ptr uint32, size uint32) uint64
func (h httpService) Delete(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _delete(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env patch
func _patch(ptr uint32, size uint32) uint64
func (h httpService) Patch(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _patch(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env head
func _head(ptr uint32, size uint32) uint64
func (h httpService) Head(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _head(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env options
func _options(ptr uint32, size uint32) uint64
func (h httpService) Options(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _options(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(HttpResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !wasip1
package http
func NewHttpService() HttpService {
panic("not implemented")
}
+850
View File
@@ -0,0 +1,850 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/http/http.proto
package http
import (
fmt "fmt"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
io "io"
bits "math/bits"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
func (m *HttpRequest) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *HttpRequest) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *HttpRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.Body) > 0 {
i -= len(m.Body)
copy(dAtA[i:], m.Body)
i = encodeVarint(dAtA, i, uint64(len(m.Body)))
i--
dAtA[i] = 0x22
}
if m.TimeoutMs != 0 {
i = encodeVarint(dAtA, i, uint64(m.TimeoutMs))
i--
dAtA[i] = 0x18
}
if len(m.Headers) > 0 {
for k := range m.Headers {
v := m.Headers[k]
baseI := i
i -= len(v)
copy(dAtA[i:], v)
i = encodeVarint(dAtA, i, uint64(len(v)))
i--
dAtA[i] = 0x12
i -= len(k)
copy(dAtA[i:], k)
i = encodeVarint(dAtA, i, uint64(len(k)))
i--
dAtA[i] = 0xa
i = encodeVarint(dAtA, i, uint64(baseI-i))
i--
dAtA[i] = 0x12
}
}
if len(m.Url) > 0 {
i -= len(m.Url)
copy(dAtA[i:], m.Url)
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
i--
dAtA[i] = 0xa
}
return len(dAtA) - i, nil
}
func (m *HttpResponse) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *HttpResponse) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *HttpResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.Error) > 0 {
i -= len(m.Error)
copy(dAtA[i:], m.Error)
i = encodeVarint(dAtA, i, uint64(len(m.Error)))
i--
dAtA[i] = 0x22
}
if len(m.Headers) > 0 {
for k := range m.Headers {
v := m.Headers[k]
baseI := i
i -= len(v)
copy(dAtA[i:], v)
i = encodeVarint(dAtA, i, uint64(len(v)))
i--
dAtA[i] = 0x12
i -= len(k)
copy(dAtA[i:], k)
i = encodeVarint(dAtA, i, uint64(len(k)))
i--
dAtA[i] = 0xa
i = encodeVarint(dAtA, i, uint64(baseI-i))
i--
dAtA[i] = 0x1a
}
}
if len(m.Body) > 0 {
i -= len(m.Body)
copy(dAtA[i:], m.Body)
i = encodeVarint(dAtA, i, uint64(len(m.Body)))
i--
dAtA[i] = 0x12
}
if m.Status != 0 {
i = encodeVarint(dAtA, i, uint64(m.Status))
i--
dAtA[i] = 0x8
}
return len(dAtA) - i, nil
}
func encodeVarint(dAtA []byte, offset int, v uint64) int {
offset -= sov(v)
base := offset
for v >= 1<<7 {
dAtA[offset] = uint8(v&0x7f | 0x80)
v >>= 7
offset++
}
dAtA[offset] = uint8(v)
return base
}
func (m *HttpRequest) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
l = len(m.Url)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
if len(m.Headers) > 0 {
for k, v := range m.Headers {
_ = k
_ = v
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
}
}
if m.TimeoutMs != 0 {
n += 1 + sov(uint64(m.TimeoutMs))
}
l = len(m.Body)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
n += len(m.unknownFields)
return n
}
func (m *HttpResponse) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
if m.Status != 0 {
n += 1 + sov(uint64(m.Status))
}
l = len(m.Body)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
if len(m.Headers) > 0 {
for k, v := range m.Headers {
_ = k
_ = v
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
}
}
l = len(m.Error)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
n += len(m.unknownFields)
return n
}
func sov(x uint64) (n int) {
return (bits.Len64(x|1) + 6) / 7
}
func soz(x uint64) (n int) {
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
}
func (m *HttpRequest) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: HttpRequest: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: HttpRequest: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Url = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
case 2:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType)
}
var msglen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
msglen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if msglen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + msglen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
if m.Headers == nil {
m.Headers = make(map[string]string)
}
var mapkey string
var mapvalue string
for iNdEx < postIndex {
entryPreIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
if fieldNum == 1 {
var stringLenmapkey uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLenmapkey |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLenmapkey := int(stringLenmapkey)
if intStringLenmapkey < 0 {
return ErrInvalidLength
}
postStringIndexmapkey := iNdEx + intStringLenmapkey
if postStringIndexmapkey < 0 {
return ErrInvalidLength
}
if postStringIndexmapkey > l {
return io.ErrUnexpectedEOF
}
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
iNdEx = postStringIndexmapkey
} else if fieldNum == 2 {
var stringLenmapvalue uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLenmapvalue |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLenmapvalue := int(stringLenmapvalue)
if intStringLenmapvalue < 0 {
return ErrInvalidLength
}
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
if postStringIndexmapvalue < 0 {
return ErrInvalidLength
}
if postStringIndexmapvalue > l {
return io.ErrUnexpectedEOF
}
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
iNdEx = postStringIndexmapvalue
} else {
iNdEx = entryPreIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > postIndex {
return io.ErrUnexpectedEOF
}
iNdEx += skippy
}
}
m.Headers[mapkey] = mapvalue
iNdEx = postIndex
case 3:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field TimeoutMs", wireType)
}
m.TimeoutMs = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.TimeoutMs |= int32(b&0x7F) << shift
if b < 0x80 {
break
}
}
case 4:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType)
}
var byteLen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
byteLen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if byteLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + byteLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...)
if m.Body == nil {
m.Body = []byte{}
}
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func (m *HttpResponse) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: HttpResponse: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: HttpResponse: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field Status", wireType)
}
m.Status = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.Status |= int32(b&0x7F) << shift
if b < 0x80 {
break
}
}
case 2:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType)
}
var byteLen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
byteLen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if byteLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + byteLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...)
if m.Body == nil {
m.Body = []byte{}
}
iNdEx = postIndex
case 3:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType)
}
var msglen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
msglen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if msglen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + msglen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
if m.Headers == nil {
m.Headers = make(map[string]string)
}
var mapkey string
var mapvalue string
for iNdEx < postIndex {
entryPreIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
if fieldNum == 1 {
var stringLenmapkey uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLenmapkey |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLenmapkey := int(stringLenmapkey)
if intStringLenmapkey < 0 {
return ErrInvalidLength
}
postStringIndexmapkey := iNdEx + intStringLenmapkey
if postStringIndexmapkey < 0 {
return ErrInvalidLength
}
if postStringIndexmapkey > l {
return io.ErrUnexpectedEOF
}
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
iNdEx = postStringIndexmapkey
} else if fieldNum == 2 {
var stringLenmapvalue uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLenmapvalue |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLenmapvalue := int(stringLenmapvalue)
if intStringLenmapvalue < 0 {
return ErrInvalidLength
}
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
if postStringIndexmapvalue < 0 {
return ErrInvalidLength
}
if postStringIndexmapvalue > l {
return io.ErrUnexpectedEOF
}
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
iNdEx = postStringIndexmapvalue
} else {
iNdEx = entryPreIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > postIndex {
return io.ErrUnexpectedEOF
}
iNdEx += skippy
}
}
m.Headers[mapkey] = mapvalue
iNdEx = postIndex
case 4:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Error = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skip(dAtA []byte) (n int, err error) {
l := len(dAtA)
iNdEx := 0
depth := 0
for iNdEx < l {
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= (uint64(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
wireType := int(wire & 0x7)
switch wireType {
case 0:
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
iNdEx++
if dAtA[iNdEx-1] < 0x80 {
break
}
}
case 1:
iNdEx += 8
case 2:
var length int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflow
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
length |= (int(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
if length < 0 {
return 0, ErrInvalidLength
}
iNdEx += length
case 3:
depth++
case 4:
if depth == 0 {
return 0, ErrUnexpectedEndOfGroup
}
depth--
case 5:
iNdEx += 4
default:
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
}
if iNdEx < 0 {
return 0, ErrInvalidLength
}
if depth == 0 {
return iNdEx, nil
}
}
return 0, io.ErrUnexpectedEOF
}
var (
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
)
+165
View File
@@ -0,0 +1,165 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/scheduler/scheduler.proto
package scheduler
import (
context "context"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ScheduleOneTimeRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
DelaySeconds int32 `protobuf:"varint,1,opt,name=delay_seconds,json=delaySeconds,proto3" json:"delay_seconds,omitempty"` // Delay in seconds
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback
ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated)
}
func (x *ScheduleOneTimeRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *ScheduleOneTimeRequest) GetDelaySeconds() int32 {
if x != nil {
return x.DelaySeconds
}
return 0
}
func (x *ScheduleOneTimeRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *ScheduleOneTimeRequest) GetScheduleId() string {
if x != nil {
return x.ScheduleId
}
return ""
}
type ScheduleRecurringRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
CronExpression string `protobuf:"bytes,1,opt,name=cron_expression,json=cronExpression,proto3" json:"cron_expression,omitempty"` // Cron expression (e.g. "0 0 * * *" for daily at midnight)
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback
ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated)
}
func (x *ScheduleRecurringRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *ScheduleRecurringRequest) GetCronExpression() string {
if x != nil {
return x.CronExpression
}
return ""
}
func (x *ScheduleRecurringRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *ScheduleRecurringRequest) GetScheduleId() string {
if x != nil {
return x.ScheduleId
}
return ""
}
type ScheduleResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID to reference this scheduled job
}
func (x *ScheduleResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *ScheduleResponse) GetScheduleId() string {
if x != nil {
return x.ScheduleId
}
return ""
}
type CancelRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the schedule to cancel
}
func (x *CancelRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *CancelRequest) GetScheduleId() string {
if x != nil {
return x.ScheduleId
}
return ""
}
type CancelResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether cancellation was successful
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Error message if cancellation failed
}
func (x *CancelResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *CancelResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *CancelResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// go:plugin type=host version=1
type SchedulerService interface {
// One-time event scheduling
ScheduleOneTime(context.Context, *ScheduleOneTimeRequest) (*ScheduleResponse, error)
// Recurring event scheduling
ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error)
// Cancel any scheduled job
CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error)
}
+42
View File
@@ -0,0 +1,42 @@
syntax = "proto3";
package scheduler;
option go_package = "github.com/navidrome/navidrome/plugins/host/scheduler;scheduler";
// go:plugin type=host version=1
service SchedulerService {
// One-time event scheduling
rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse);
// Recurring event scheduling
rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse);
// Cancel any scheduled job
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
}
message ScheduleOneTimeRequest {
int32 delay_seconds = 1; // Delay in seconds
bytes payload = 2; // Serialized data to pass to the callback
string schedule_id = 3; // Optional custom ID (if not provided, one will be generated)
}
message ScheduleRecurringRequest {
string cron_expression = 1; // Cron expression (e.g. "0 0 * * *" for daily at midnight)
bytes payload = 2; // Serialized data to pass to the callback
string schedule_id = 3; // Optional custom ID (if not provided, one will be generated)
}
message ScheduleResponse {
string schedule_id = 1; // ID to reference this scheduled job
}
message CancelRequest {
string schedule_id = 1; // ID of the schedule to cancel
}
message CancelResponse {
bool success = 1; // Whether cancellation was successful
string error = 2; // Error message if cancellation failed
}
+136
View File
@@ -0,0 +1,136 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/scheduler/scheduler.proto
package scheduler
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
wazero "github.com/tetratelabs/wazero"
api "github.com/tetratelabs/wazero/api"
)
const (
i32 = api.ValueTypeI32
i64 = api.ValueTypeI64
)
type _schedulerService struct {
SchedulerService
}
// Instantiate a Go-defined module named "env" that exports host functions.
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerService) error {
envBuilder := r.NewHostModuleBuilder("env")
h := _schedulerService{hostFunctions}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._ScheduleOneTime), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("schedule_one_time")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._ScheduleRecurring), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("schedule_recurring")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._CancelSchedule), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("cancel_schedule")
_, err := envBuilder.Instantiate(ctx)
return err
}
// One-time event scheduling
func (h _schedulerService) _ScheduleOneTime(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(ScheduleOneTimeRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.ScheduleOneTime(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Recurring event scheduling
func (h _schedulerService) _ScheduleRecurring(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(ScheduleRecurringRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.ScheduleRecurring(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Cancel any scheduled job
func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(CancelRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.CancelSchedule(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
@@ -0,0 +1,90 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/scheduler/scheduler.proto
package scheduler
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
_ "unsafe"
)
type schedulerService struct{}
func NewSchedulerService() SchedulerService {
return schedulerService{}
}
//go:wasmimport env schedule_one_time
func _schedule_one_time(ptr uint32, size uint32) uint64
func (h schedulerService) ScheduleOneTime(ctx context.Context, request *ScheduleOneTimeRequest) (*ScheduleResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _schedule_one_time(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(ScheduleResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env schedule_recurring
func _schedule_recurring(ptr uint32, size uint32) uint64
func (h schedulerService) ScheduleRecurring(ctx context.Context, request *ScheduleRecurringRequest) (*ScheduleResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _schedule_recurring(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(ScheduleResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env cancel_schedule
func _cancel_schedule(ptr uint32, size uint32) uint64
func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelRequest) (*CancelResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _cancel_schedule(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(CancelResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
@@ -0,0 +1,7 @@
//go:build !wasip1
package scheduler
func NewSchedulerService() SchedulerService {
panic("not implemented")
}
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/websocket/websocket.proto
package websocket
import (
context "context"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ConnectRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
ConnectionId string `protobuf:"bytes,3,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
}
func (x *ConnectRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *ConnectRequest) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
func (x *ConnectRequest) GetHeaders() map[string]string {
if x != nil {
return x.Headers
}
return nil
}
func (x *ConnectRequest) GetConnectionId() string {
if x != nil {
return x.ConnectionId
}
return ""
}
type ConnectResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
}
func (x *ConnectResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *ConnectResponse) GetConnectionId() string {
if x != nil {
return x.ConnectionId
}
return ""
}
func (x *ConnectResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type SendTextRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
}
func (x *SendTextRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SendTextRequest) GetConnectionId() string {
if x != nil {
return x.ConnectionId
}
return ""
}
func (x *SendTextRequest) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type SendTextResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
}
func (x *SendTextResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SendTextResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type SendBinaryRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
}
func (x *SendBinaryRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SendBinaryRequest) GetConnectionId() string {
if x != nil {
return x.ConnectionId
}
return ""
}
func (x *SendBinaryRequest) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
type SendBinaryResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
}
func (x *SendBinaryResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *SendBinaryResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type CloseRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"`
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
}
func (x *CloseRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *CloseRequest) GetConnectionId() string {
if x != nil {
return x.ConnectionId
}
return ""
}
func (x *CloseRequest) GetCode() int32 {
if x != nil {
return x.Code
}
return 0
}
func (x *CloseRequest) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
type CloseResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
}
func (x *CloseResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *CloseResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// go:plugin type=host version=1
type WebSocketService interface {
// Connect to a WebSocket endpoint
Connect(context.Context, *ConnectRequest) (*ConnectResponse, error)
// Send a text message
SendText(context.Context, *SendTextRequest) (*SendTextResponse, error)
// Send binary data
SendBinary(context.Context, *SendBinaryRequest) (*SendBinaryResponse, error)
// Close a connection
Close(context.Context, *CloseRequest) (*CloseResponse, error)
}
+57
View File
@@ -0,0 +1,57 @@
syntax = "proto3";
package websocket;
option go_package = "github.com/navidrome/navidrome/plugins/host/websocket";
// go:plugin type=host version=1
service WebSocketService {
// Connect to a WebSocket endpoint
rpc Connect(ConnectRequest) returns (ConnectResponse);
// Send a text message
rpc SendText(SendTextRequest) returns (SendTextResponse);
// Send binary data
rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse);
// Close a connection
rpc Close(CloseRequest) returns (CloseResponse);
}
message ConnectRequest {
string url = 1;
map<string, string> headers = 2;
string connection_id = 3;
}
message ConnectResponse {
string connection_id = 1;
string error = 2;
}
message SendTextRequest {
string connection_id = 1;
string message = 2;
}
message SendTextResponse {
string error = 1;
}
message SendBinaryRequest {
string connection_id = 1;
bytes data = 2;
}
message SendBinaryResponse {
string error = 1;
}
message CloseRequest {
string connection_id = 1;
int32 code = 2;
string reason = 3;
}
message CloseResponse {
string error = 1;
}
+170
View File
@@ -0,0 +1,170 @@
//go:build !wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/websocket/websocket.proto
package websocket
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
wazero "github.com/tetratelabs/wazero"
api "github.com/tetratelabs/wazero/api"
)
const (
i32 = api.ValueTypeI32
i64 = api.ValueTypeI64
)
type _webSocketService struct {
WebSocketService
}
// Instantiate a Go-defined module named "env" that exports host functions.
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions WebSocketService) error {
envBuilder := r.NewHostModuleBuilder("env")
h := _webSocketService{hostFunctions}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Connect), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("connect")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._SendText), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("send_text")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._SendBinary), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("send_binary")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._Close), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("close")
_, err := envBuilder.Instantiate(ctx)
return err
}
// Connect to a WebSocket endpoint
func (h _webSocketService) _Connect(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(ConnectRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Connect(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Send a text message
func (h _webSocketService) _SendText(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(SendTextRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.SendText(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Send binary data
func (h _webSocketService) _SendBinary(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(SendBinaryRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.SendBinary(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Close a connection
func (h _webSocketService) _Close(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(CloseRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.Close(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
@@ -0,0 +1,113 @@
//go:build wasip1
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
// versions:
// protoc-gen-go-plugin v0.1.0
// protoc v5.29.3
// source: host/websocket/websocket.proto
package websocket
import (
context "context"
wasm "github.com/knqyf263/go-plugin/wasm"
_ "unsafe"
)
type webSocketService struct{}
func NewWebSocketService() WebSocketService {
return webSocketService{}
}
//go:wasmimport env connect
func _connect(ptr uint32, size uint32) uint64
func (h webSocketService) Connect(ctx context.Context, request *ConnectRequest) (*ConnectResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _connect(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(ConnectResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env send_text
func _send_text(ptr uint32, size uint32) uint64
func (h webSocketService) SendText(ctx context.Context, request *SendTextRequest) (*SendTextResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _send_text(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(SendTextResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env send_binary
func _send_binary(ptr uint32, size uint32) uint64
func (h webSocketService) SendBinary(ctx context.Context, request *SendBinaryRequest) (*SendBinaryResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _send_binary(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(SendBinaryResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
//go:wasmimport env close
func _close(ptr uint32, size uint32) uint64
func (h webSocketService) Close(ctx context.Context, request *CloseRequest) (*CloseResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _close(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(CloseResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
@@ -0,0 +1,7 @@
//go:build !wasip1
package websocket
func NewWebSocketService() WebSocketService {
panic("not implemented")
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
package plugins
import (
"context"
"fmt"
"net/http"
"net/url"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host/artwork"
"github.com/navidrome/navidrome/server/public"
)
type artworkServiceImpl struct{}
func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) {
artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: req.Id}
imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size))
return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil
}
func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) {
artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: req.Id}
imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size))
return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil
}
func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) {
artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: req.Id}
imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size))
return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil
}
func (a *artworkServiceImpl) createRequest() *http.Request {
var scheme, host string
if conf.Server.ShareURL != "" {
shareURL, _ := url.Parse(conf.Server.ShareURL)
scheme = shareURL.Scheme
host = shareURL.Host
} else {
scheme = "http"
host = "localhost"
}
r, _ := http.NewRequest("GET", fmt.Sprintf("%s://%s", scheme, host), nil)
return r
}
+58
View File
@@ -0,0 +1,58 @@
package plugins
import (
"context"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/plugins/host/artwork"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ArtworkService", func() {
var svc *artworkServiceImpl
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Setup auth for tests
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
svc = &artworkServiceImpl{}
})
Context("with ShareURL configured", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://music.example.com"
})
It("returns artist artwork URL", func() {
resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123", Size: 300})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Url).To(ContainSubstring("https://music.example.com"))
Expect(resp.Url).To(ContainSubstring("size=300"))
})
It("returns album artwork URL", func() {
resp, err := svc.GetAlbumUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "456"})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Url).To(ContainSubstring("https://music.example.com"))
})
It("returns track artwork URL", func() {
resp, err := svc.GetTrackUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "789", Size: 150})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Url).To(ContainSubstring("https://music.example.com"))
Expect(resp.Url).To(ContainSubstring("size=150"))
})
})
Context("without ShareURL configured", func() {
It("returns localhost URLs", func() {
resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123"})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Url).To(ContainSubstring("http://localhost"))
})
})
})
+152
View File
@@ -0,0 +1,152 @@
package plugins
import (
"context"
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
"github.com/navidrome/navidrome/log"
cacheproto "github.com/navidrome/navidrome/plugins/host/cache"
)
const (
defaultCacheTTL = 24 * time.Hour
)
// cacheServiceImpl implements the cache.CacheService interface
type cacheServiceImpl struct {
pluginID string
defaultTTL time.Duration
}
var (
_cache *ttlcache.Cache[string, any]
initCacheOnce sync.Once
)
// newCacheService creates a new cacheServiceImpl instance
func newCacheService(pluginID string) *cacheServiceImpl {
initCacheOnce.Do(func() {
opts := []ttlcache.Option[string, any]{
ttlcache.WithTTL[string, any](defaultCacheTTL),
}
_cache = ttlcache.New[string, any](opts...)
// Start the janitor goroutine to clean up expired entries
go _cache.Start()
})
return &cacheServiceImpl{
pluginID: pluginID,
defaultTTL: defaultCacheTTL,
}
}
// mapKey combines the plugin name and a provided key to create a unique cache key.
func (s *cacheServiceImpl) mapKey(key string) string {
return s.pluginID + ":" + key
}
// getTTL converts seconds to a duration, using default if 0
func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration {
if seconds <= 0 {
return s.defaultTTL
}
return time.Duration(seconds) * time.Second
}
// setCacheValue is a generic function to set a value in the cache
func setCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, value T, ttlSeconds int64) (*cacheproto.SetResponse, error) {
ttl := cs.getTTL(ttlSeconds)
key = cs.mapKey(key)
_cache.Set(key, value, ttl)
return &cacheproto.SetResponse{Success: true}, nil
}
// getCacheValue is a generic function to get a value from the cache
func getCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, typeName string) (T, bool, error) {
key = cs.mapKey(key)
var zero T
item := _cache.Get(key)
if item == nil {
return zero, false, nil
}
value, ok := item.Value().(T)
if !ok {
log.Debug(ctx, "Type mismatch in cache", "plugin", cs.pluginID, "key", key, "expected", typeName)
return zero, false, nil
}
return value, true, nil
}
// SetString sets a string value in the cache
func (s *cacheServiceImpl) SetString(ctx context.Context, req *cacheproto.SetStringRequest) (*cacheproto.SetResponse, error) {
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
}
// GetString gets a string value from the cache
func (s *cacheServiceImpl) GetString(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetStringResponse, error) {
value, exists, err := getCacheValue[string](ctx, s, req.Key, "string")
if err != nil {
return nil, err
}
return &cacheproto.GetStringResponse{Exists: exists, Value: value}, nil
}
// SetInt sets an integer value in the cache
func (s *cacheServiceImpl) SetInt(ctx context.Context, req *cacheproto.SetIntRequest) (*cacheproto.SetResponse, error) {
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
}
// GetInt gets an integer value from the cache
func (s *cacheServiceImpl) GetInt(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetIntResponse, error) {
value, exists, err := getCacheValue[int64](ctx, s, req.Key, "int64")
if err != nil {
return nil, err
}
return &cacheproto.GetIntResponse{Exists: exists, Value: value}, nil
}
// SetFloat sets a float value in the cache
func (s *cacheServiceImpl) SetFloat(ctx context.Context, req *cacheproto.SetFloatRequest) (*cacheproto.SetResponse, error) {
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
}
// GetFloat gets a float value from the cache
func (s *cacheServiceImpl) GetFloat(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetFloatResponse, error) {
value, exists, err := getCacheValue[float64](ctx, s, req.Key, "float64")
if err != nil {
return nil, err
}
return &cacheproto.GetFloatResponse{Exists: exists, Value: value}, nil
}
// SetBytes sets a byte slice value in the cache
func (s *cacheServiceImpl) SetBytes(ctx context.Context, req *cacheproto.SetBytesRequest) (*cacheproto.SetResponse, error) {
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
}
// GetBytes gets a byte slice value from the cache
func (s *cacheServiceImpl) GetBytes(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetBytesResponse, error) {
value, exists, err := getCacheValue[[]byte](ctx, s, req.Key, "[]byte")
if err != nil {
return nil, err
}
return &cacheproto.GetBytesResponse{Exists: exists, Value: value}, nil
}
// Remove removes a value from the cache
func (s *cacheServiceImpl) Remove(ctx context.Context, req *cacheproto.RemoveRequest) (*cacheproto.RemoveResponse, error) {
key := s.mapKey(req.Key)
_cache.Delete(key)
return &cacheproto.RemoveResponse{Success: true}, nil
}
// Has checks if a key exists in the cache
func (s *cacheServiceImpl) Has(ctx context.Context, req *cacheproto.HasRequest) (*cacheproto.HasResponse, error) {
key := s.mapKey(req.Key)
item := _cache.Get(key)
return &cacheproto.HasResponse{Exists: item != nil}, nil
}
+171
View File
@@ -0,0 +1,171 @@
package plugins
import (
"context"
"time"
"github.com/navidrome/navidrome/plugins/host/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CacheService", func() {
var service *cacheServiceImpl
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
service = newCacheService("test_plugin")
})
Describe("getTTL", func() {
It("returns default TTL when seconds is 0", func() {
ttl := service.getTTL(0)
Expect(ttl).To(Equal(defaultCacheTTL))
})
It("returns default TTL when seconds is negative", func() {
ttl := service.getTTL(-10)
Expect(ttl).To(Equal(defaultCacheTTL))
})
It("returns correct duration when seconds is positive", func() {
ttl := service.getTTL(60)
Expect(ttl).To(Equal(time.Minute))
})
})
Describe("String Operations", func() {
It("sets and gets a string value", func() {
_, err := service.SetString(ctx, &cache.SetStringRequest{
Key: "string_key",
Value: "test_value",
TtlSeconds: 300,
})
Expect(err).NotTo(HaveOccurred())
res, err := service.GetString(ctx, &cache.GetRequest{Key: "string_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeTrue())
Expect(res.Value).To(Equal("test_value"))
})
It("returns not exists for missing key", func() {
res, err := service.GetString(ctx, &cache.GetRequest{Key: "missing_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeFalse())
})
})
Describe("Integer Operations", func() {
It("sets and gets an integer value", func() {
_, err := service.SetInt(ctx, &cache.SetIntRequest{
Key: "int_key",
Value: 42,
TtlSeconds: 300,
})
Expect(err).NotTo(HaveOccurred())
res, err := service.GetInt(ctx, &cache.GetRequest{Key: "int_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeTrue())
Expect(res.Value).To(Equal(int64(42)))
})
})
Describe("Float Operations", func() {
It("sets and gets a float value", func() {
_, err := service.SetFloat(ctx, &cache.SetFloatRequest{
Key: "float_key",
Value: 3.14,
TtlSeconds: 300,
})
Expect(err).NotTo(HaveOccurred())
res, err := service.GetFloat(ctx, &cache.GetRequest{Key: "float_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeTrue())
Expect(res.Value).To(Equal(3.14))
})
})
Describe("Bytes Operations", func() {
It("sets and gets a bytes value", func() {
byteData := []byte("hello world")
_, err := service.SetBytes(ctx, &cache.SetBytesRequest{
Key: "bytes_key",
Value: byteData,
TtlSeconds: 300,
})
Expect(err).NotTo(HaveOccurred())
res, err := service.GetBytes(ctx, &cache.GetRequest{Key: "bytes_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeTrue())
Expect(res.Value).To(Equal(byteData))
})
})
Describe("Type mismatch handling", func() {
It("returns not exists when type doesn't match the getter", func() {
// Set string
_, err := service.SetString(ctx, &cache.SetStringRequest{
Key: "mixed_key",
Value: "string value",
})
Expect(err).NotTo(HaveOccurred())
// Try to get as int
res, err := service.GetInt(ctx, &cache.GetRequest{Key: "mixed_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeFalse())
})
})
Describe("Remove Operation", func() {
It("removes a value from the cache", func() {
// Set a value
_, err := service.SetString(ctx, &cache.SetStringRequest{
Key: "remove_key",
Value: "to be removed",
})
Expect(err).NotTo(HaveOccurred())
// Verify it exists
res, err := service.Has(ctx, &cache.HasRequest{Key: "remove_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeTrue())
// Remove it
_, err = service.Remove(ctx, &cache.RemoveRequest{Key: "remove_key"})
Expect(err).NotTo(HaveOccurred())
// Verify it's gone
res, err = service.Has(ctx, &cache.HasRequest{Key: "remove_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeFalse())
})
})
Describe("Has Operation", func() {
It("returns true for existing key", func() {
// Set a value
_, err := service.SetString(ctx, &cache.SetStringRequest{
Key: "existing_key",
Value: "exists",
})
Expect(err).NotTo(HaveOccurred())
// Check if it exists
res, err := service.Has(ctx, &cache.HasRequest{Key: "existing_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeTrue())
})
It("returns false for non-existing key", func() {
res, err := service.Has(ctx, &cache.HasRequest{Key: "non_existing_key"})
Expect(err).NotTo(HaveOccurred())
Expect(res.Exists).To(BeFalse())
})
})
})
+22
View File
@@ -0,0 +1,22 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/plugins/host/config"
)
type configServiceImpl struct {
pluginID string
}
func (c *configServiceImpl) GetPluginConfig(ctx context.Context, req *config.GetPluginConfigRequest) (*config.GetPluginConfigResponse, error) {
cfg, ok := conf.Server.PluginConfig[c.pluginID]
if !ok {
cfg = map[string]string{}
}
return &config.GetPluginConfigResponse{
Config: cfg,
}, nil
}
+46
View File
@@ -0,0 +1,46 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/conf"
hostconfig "github.com/navidrome/navidrome/plugins/host/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("configServiceImpl", func() {
var (
svc *configServiceImpl
pluginName string
)
BeforeEach(func() {
pluginName = "testplugin"
svc = &configServiceImpl{pluginID: pluginName}
conf.Server.PluginConfig = map[string]map[string]string{
pluginName: {"foo": "bar", "baz": "qux"},
}
})
It("returns config for known plugin", func() {
resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{})
Expect(err).To(BeNil())
Expect(resp.Config).To(HaveKeyWithValue("foo", "bar"))
Expect(resp.Config).To(HaveKeyWithValue("baz", "qux"))
})
It("returns error for unknown plugin", func() {
svc.pluginID = "unknown"
resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{})
Expect(err).To(BeNil())
Expect(resp.Config).To(BeEmpty())
})
It("returns empty config if plugin config is empty", func() {
conf.Server.PluginConfig[pluginName] = map[string]string{}
resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{})
Expect(err).To(BeNil())
Expect(resp.Config).To(BeEmpty())
})
})
+114
View File
@@ -0,0 +1,114 @@
package plugins
import (
"bytes"
"cmp"
"context"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/log"
hosthttp "github.com/navidrome/navidrome/plugins/host/http"
)
type httpServiceImpl struct {
pluginID string
permissions *httpPermissions
}
const defaultTimeout = 10 * time.Second
func (s *httpServiceImpl) Get(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodGet, req)
}
func (s *httpServiceImpl) Post(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodPost, req)
}
func (s *httpServiceImpl) Put(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodPut, req)
}
func (s *httpServiceImpl) Delete(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodDelete, req)
}
func (s *httpServiceImpl) Patch(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodPatch, req)
}
func (s *httpServiceImpl) Head(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodHead, req)
}
func (s *httpServiceImpl) Options(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
return s.doHttp(ctx, http.MethodOptions, req)
}
func (s *httpServiceImpl) doHttp(ctx context.Context, method string, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
// Check permissions if they exist
if s.permissions != nil {
if err := s.permissions.IsRequestAllowed(req.Url, method); err != nil {
log.Warn(ctx, "HTTP request blocked by permissions", "plugin", s.pluginID, "url", req.Url, "method", method, err)
return &hosthttp.HttpResponse{Error: "Request blocked by plugin permissions: " + err.Error()}, nil
}
}
client := &http.Client{
Timeout: cmp.Or(time.Duration(req.TimeoutMs)*time.Millisecond, defaultTimeout),
}
// Configure redirect policy based on permissions
if s.permissions != nil {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
// Enforce maximum redirect limit
if len(via) >= httpMaxRedirects {
log.Warn(ctx, "HTTP redirect limit exceeded", "plugin", s.pluginID, "url", req.URL.String(), "redirectCount", len(via))
return http.ErrUseLastResponse
}
// Check if redirect destination is allowed
if err := s.permissions.IsRequestAllowed(req.URL.String(), req.Method); err != nil {
log.Warn(ctx, "HTTP redirect blocked by permissions", "plugin", s.pluginID, "url", req.URL.String(), "method", req.Method, err)
return http.ErrUseLastResponse
}
return nil // Allow redirect
}
}
var body io.Reader
if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch {
body = bytes.NewReader(req.Body)
}
httpReq, err := http.NewRequestWithContext(ctx, method, req.Url, body)
if err != nil {
return nil, err
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
resp, err := client.Do(httpReq)
if err != nil {
log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, err)
return &hosthttp.HttpResponse{Error: err.Error()}, nil
}
log.Trace(ctx, "HttpService request", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode)
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode, err)
return &hosthttp.HttpResponse{Error: err.Error()}, nil
}
headers := map[string]string{}
for k, v := range resp.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
return &hosthttp.HttpResponse{
Status: int32(resp.StatusCode),
Body: respBody,
Headers: headers,
}, nil
}
+90
View File
@@ -0,0 +1,90 @@
package plugins
import (
"fmt"
"strings"
"github.com/navidrome/navidrome/plugins/schema"
)
// Maximum number of HTTP redirects allowed for plugin requests
const httpMaxRedirects = 5
// HTTPPermissions represents granular HTTP access permissions for plugins
type httpPermissions struct {
*networkPermissionsBase
AllowedUrls map[string][]string `json:"allowedUrls"`
matcher *urlMatcher
}
// parseHTTPPermissions extracts HTTP permissions from the schema
func parseHTTPPermissions(permData *schema.PluginManifestPermissionsHttp) (*httpPermissions, error) {
base := &networkPermissionsBase{
AllowLocalNetwork: permData.AllowLocalNetwork,
}
if len(permData.AllowedUrls) == 0 {
return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern")
}
allowedUrls := make(map[string][]string)
for urlPattern, methodEnums := range permData.AllowedUrls {
methods := make([]string, len(methodEnums))
for i, methodEnum := range methodEnums {
methods[i] = string(methodEnum)
}
allowedUrls[urlPattern] = methods
}
return &httpPermissions{
networkPermissionsBase: base,
AllowedUrls: allowedUrls,
matcher: newURLMatcher(),
}, nil
}
// IsRequestAllowed checks if a specific network request is allowed by the permissions
func (p *httpPermissions) IsRequestAllowed(requestURL, operation string) error {
if _, err := checkURLPolicy(requestURL, p.AllowLocalNetwork); err != nil {
return err
}
// allowedUrls is now required - no fallback to allow all URLs
if p.AllowedUrls == nil || len(p.AllowedUrls) == 0 {
return fmt.Errorf("no allowed URLs configured for plugin")
}
matcher := newURLMatcher()
// Check URL patterns and operations
// First try exact matches, then wildcard matches
operation = strings.ToUpper(operation)
// Phase 1: Check for exact matches first
for urlPattern, allowedOperations := range p.AllowedUrls {
if !strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) {
// Check if operation is allowed
for _, allowedOperation := range allowedOperations {
if allowedOperation == "*" || allowedOperation == operation {
return nil
}
}
return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern)
}
}
// Phase 2: Check wildcard patterns
for urlPattern, allowedOperations := range p.AllowedUrls {
if strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) {
// Check if operation is allowed
for _, allowedOperation := range allowedOperations {
if allowedOperation == "*" || allowedOperation == operation {
return nil
}
}
return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern)
}
}
return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL)
}
+187
View File
@@ -0,0 +1,187 @@
package plugins
import (
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("HTTP Permissions", func() {
Describe("parseHTTPPermissions", func() {
It("should parse valid HTTP permissions", func() {
permData := &schema.PluginManifestPermissionsHttp{
Reason: "Need to fetch album artwork",
AllowLocalNetwork: false,
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"https://api.example.com/*": {
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET,
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemPOST,
},
"https://cdn.example.com/*": {
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET,
},
},
}
perms, err := parseHTTPPermissions(permData)
Expect(err).To(BeNil())
Expect(perms).ToNot(BeNil())
Expect(perms.AllowLocalNetwork).To(BeFalse())
Expect(perms.AllowedUrls).To(HaveLen(2))
Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"GET", "POST"}))
Expect(perms.AllowedUrls["https://cdn.example.com/*"]).To(Equal([]string{"GET"}))
})
It("should fail if allowedUrls is empty", func() {
permData := &schema.PluginManifestPermissionsHttp{
Reason: "Need to fetch album artwork",
AllowLocalNetwork: false,
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{},
}
_, err := parseHTTPPermissions(permData)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern"))
})
It("should handle method enum types correctly", func() {
permData := &schema.PluginManifestPermissionsHttp{
Reason: "Need to fetch album artwork",
AllowLocalNetwork: false,
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"https://api.example.com/*": {
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard, // "*"
},
},
}
perms, err := parseHTTPPermissions(permData)
Expect(err).To(BeNil())
Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"*"}))
})
})
Describe("IsRequestAllowed", func() {
var perms *httpPermissions
Context("HTTP method-specific validation", func() {
BeforeEach(func() {
perms = &httpPermissions{
networkPermissionsBase: &networkPermissionsBase{
Reason: "Test permissions",
AllowLocalNetwork: false,
},
AllowedUrls: map[string][]string{
"https://api.example.com": {"GET", "POST"},
"https://upload.example.com": {"PUT", "PATCH"},
"https://admin.example.com": {"DELETE"},
"https://webhook.example.com": {"*"},
},
matcher: newURLMatcher(),
}
})
DescribeTable("method-specific access control",
func(url, method string, shouldSucceed bool) {
err := perms.IsRequestAllowed(url, method)
if shouldSucceed {
Expect(err).ToNot(HaveOccurred())
} else {
Expect(err).To(HaveOccurred())
}
},
// Allowed methods
Entry("GET to api", "https://api.example.com", "GET", true),
Entry("POST to api", "https://api.example.com", "POST", true),
Entry("PUT to upload", "https://upload.example.com", "PUT", true),
Entry("PATCH to upload", "https://upload.example.com", "PATCH", true),
Entry("DELETE to admin", "https://admin.example.com", "DELETE", true),
Entry("any method to webhook", "https://webhook.example.com", "OPTIONS", true),
Entry("any method to webhook", "https://webhook.example.com", "HEAD", true),
// Disallowed methods
Entry("DELETE to api", "https://api.example.com", "DELETE", false),
Entry("GET to upload", "https://upload.example.com", "GET", false),
Entry("POST to admin", "https://admin.example.com", "POST", false),
)
})
Context("case insensitive method handling", func() {
BeforeEach(func() {
perms = &httpPermissions{
networkPermissionsBase: &networkPermissionsBase{
Reason: "Test permissions",
AllowLocalNetwork: false,
},
AllowedUrls: map[string][]string{
"https://api.example.com": {"GET", "POST"}, // Both uppercase for consistency
},
matcher: newURLMatcher(),
}
})
DescribeTable("case insensitive method matching",
func(method string, shouldSucceed bool) {
err := perms.IsRequestAllowed("https://api.example.com", method)
if shouldSucceed {
Expect(err).ToNot(HaveOccurred())
} else {
Expect(err).To(HaveOccurred())
}
},
Entry("uppercase GET", "GET", true),
Entry("lowercase get", "get", true),
Entry("mixed case Get", "Get", true),
Entry("uppercase POST", "POST", true),
Entry("lowercase post", "post", true),
Entry("mixed case Post", "Post", true),
Entry("disallowed method", "DELETE", false),
)
})
Context("with complex URL patterns and HTTP methods", func() {
BeforeEach(func() {
perms = &httpPermissions{
networkPermissionsBase: &networkPermissionsBase{
Reason: "Test permissions",
AllowLocalNetwork: false,
},
AllowedUrls: map[string][]string{
"https://api.example.com/v1/*": {"GET"},
"https://api.example.com/v1/users": {"POST", "PUT"},
"https://*.example.com/public/*": {"GET", "HEAD"},
"https://admin.*.example.com": {"*"},
},
matcher: newURLMatcher(),
}
})
DescribeTable("complex pattern and method combinations",
func(url, method string, shouldSucceed bool) {
err := perms.IsRequestAllowed(url, method)
if shouldSucceed {
Expect(err).ToNot(HaveOccurred())
} else {
Expect(err).To(HaveOccurred())
}
},
// Path wildcards with specific methods
Entry("GET to v1 path", "https://api.example.com/v1/posts", "GET", true),
Entry("POST to v1 path", "https://api.example.com/v1/posts", "POST", false),
Entry("POST to specific users endpoint", "https://api.example.com/v1/users", "POST", true),
Entry("PUT to specific users endpoint", "https://api.example.com/v1/users", "PUT", true),
Entry("DELETE to specific users endpoint", "https://api.example.com/v1/users", "DELETE", false),
// Subdomain wildcards with specific methods
Entry("GET to public path on subdomain", "https://cdn.example.com/public/assets", "GET", true),
Entry("HEAD to public path on subdomain", "https://static.example.com/public/files", "HEAD", true),
Entry("POST to public path on subdomain", "https://api.example.com/public/upload", "POST", false),
// Admin subdomain with all methods
Entry("GET to admin subdomain", "https://admin.prod.example.com", "GET", true),
Entry("POST to admin subdomain", "https://admin.staging.example.com", "POST", true),
Entry("DELETE to admin subdomain", "https://admin.dev.example.com", "DELETE", true),
)
})
})
})
+190
View File
@@ -0,0 +1,190 @@
package plugins
import (
"context"
"net/http"
"net/http/httptest"
"time"
hosthttp "github.com/navidrome/navidrome/plugins/host/http"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("httpServiceImpl", func() {
var (
svc *httpServiceImpl
ts *httptest.Server
)
BeforeEach(func() {
svc = &httpServiceImpl{}
})
AfterEach(func() {
if ts != nil {
ts.Close()
}
})
It("should handle GET requests", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test", "ok")
w.WriteHeader(201)
_, _ = w.Write([]byte("hello"))
}))
resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
Headers: map[string]string{"A": "B"},
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(resp.Status).To(Equal(int32(201)))
Expect(string(resp.Body)).To(Equal("hello"))
Expect(resp.Headers["X-Test"]).To(Equal("ok"))
})
It("should handle POST requests with body", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
_, _ = w.Write([]byte("got:" + string(b)))
}))
resp, err := svc.Post(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
Body: []byte("abc"),
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(string(resp.Body)).To(Equal("got:abc"))
})
It("should handle PUT requests with body", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
_, _ = w.Write([]byte("put:" + string(b)))
}))
resp, err := svc.Put(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
Body: []byte("xyz"),
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(string(resp.Body)).To(Equal("put:xyz"))
})
It("should handle DELETE requests", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}))
resp, err := svc.Delete(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(resp.Status).To(Equal(int32(204)))
})
It("should handle PATCH requests with body", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
_, _ = w.Write([]byte("patch:" + string(b)))
}))
resp, err := svc.Patch(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
Body: []byte("test-patch"),
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(string(resp.Body)).To(Equal("patch:test-patch"))
})
It("should handle HEAD requests", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", "42")
w.WriteHeader(200)
// HEAD responses shouldn't have a body, but the headers should be present
}))
resp, err := svc.Head(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(resp.Status).To(Equal(int32(200)))
Expect(resp.Headers["Content-Type"]).To(Equal("application/json"))
Expect(resp.Headers["Content-Length"]).To(Equal("42"))
Expect(resp.Body).To(BeEmpty()) // HEAD responses have no body
})
It("should handle OPTIONS requests", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")
w.WriteHeader(200)
}))
resp, err := svc.Options(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp.Error).To(BeEmpty())
Expect(resp.Status).To(Equal(int32(200)))
Expect(resp.Headers["Allow"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"))
Expect(resp.Headers["Access-Control-Allow-Methods"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"))
})
It("should handle timeouts and errors", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{
Url: ts.URL,
TimeoutMs: 1,
})
Expect(err).To(BeNil())
Expect(resp).NotTo(BeNil())
Expect(resp.Error).To(ContainSubstring("deadline exceeded"))
})
It("should return error on context timeout", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
resp, err := svc.Get(ctx, &hosthttp.HttpRequest{
Url: ts.URL,
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp).NotTo(BeNil())
Expect(resp.Error).To(ContainSubstring("context deadline exceeded"))
})
It("should return error on context cancellation", func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(1 * time.Millisecond)
cancel()
}()
resp, err := svc.Get(ctx, &hosthttp.HttpRequest{
Url: ts.URL,
TimeoutMs: 1000,
})
Expect(err).To(BeNil())
Expect(resp).NotTo(BeNil())
Expect(resp.Error).To(ContainSubstring("context canceled"))
})
})
+192
View File
@@ -0,0 +1,192 @@
package plugins
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
)
// NetworkPermissionsBase contains common functionality for network-based permissions
type networkPermissionsBase struct {
Reason string `json:"reason"`
AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty"`
}
// URLMatcher provides URL pattern matching functionality
type urlMatcher struct{}
// newURLMatcher creates a new URL matcher instance
func newURLMatcher() *urlMatcher {
return &urlMatcher{}
}
// checkURLPolicy performs common checks for a URL against network policies.
func checkURLPolicy(requestURL string, allowLocalNetwork bool) (*url.URL, error) {
parsedURL, err := url.Parse(requestURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
// Check local network restrictions
if !allowLocalNetwork {
if err := checkLocalNetwork(parsedURL); err != nil {
return nil, err
}
}
return parsedURL, nil
}
// MatchesURLPattern checks if a URL matches a given pattern
func (m *urlMatcher) MatchesURLPattern(requestURL, pattern string) bool {
// Handle wildcard pattern
if pattern == "*" {
return true
}
// Parse both URLs to handle path matching correctly
reqURL, err := url.Parse(requestURL)
if err != nil {
return false
}
patternURL, err := url.Parse(pattern)
if err != nil {
// If pattern is not a valid URL, treat it as a simple string pattern
regexPattern := m.urlPatternToRegex(pattern)
matched, err := regexp.MatchString(regexPattern, requestURL)
if err != nil {
return false
}
return matched
}
// Match scheme
if patternURL.Scheme != "" && patternURL.Scheme != reqURL.Scheme {
return false
}
// Match host with wildcard support
if !m.matchesHost(reqURL.Host, patternURL.Host) {
return false
}
// Match path with wildcard support
// Special case: if pattern URL has empty path and contains wildcards, allow any path (domain-only wildcard matching)
if (patternURL.Path == "" || patternURL.Path == "/") && strings.Contains(pattern, "*") {
// This is a domain-only wildcard pattern, allow any path
return true
}
if !m.matchesPath(reqURL.Path, patternURL.Path) {
return false
}
return true
}
// urlPatternToRegex converts a URL pattern with wildcards to a regex pattern
func (m *urlMatcher) urlPatternToRegex(pattern string) string {
// Escape special regex characters except *
escaped := regexp.QuoteMeta(pattern)
// Replace escaped \* with regex pattern for wildcard matching
// For subdomain: *.example.com -> [^.]*\.example\.com
// For path: /api/* -> /api/.*
escaped = strings.ReplaceAll(escaped, "\\*", ".*")
// Anchor the pattern to match the full URL
return "^" + escaped + "$"
}
// matchesHost checks if a host matches a pattern with wildcard support
func (m *urlMatcher) matchesHost(host, pattern string) bool {
if pattern == "" {
return true
}
if pattern == "*" {
return true
}
// Handle wildcard patterns anywhere in the host
if strings.Contains(pattern, "*") {
patterns := []string{
strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[0-9.]+"), // IP pattern
strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[^.]*"), // Domain pattern
}
for _, regexPattern := range patterns {
fullPattern := "^" + regexPattern + "$"
if matched, err := regexp.MatchString(fullPattern, host); err == nil && matched {
return true
}
}
return false
}
return host == pattern
}
// matchesPath checks if a path matches a pattern with wildcard support
func (m *urlMatcher) matchesPath(path, pattern string) bool {
// Normalize empty paths to "/"
if path == "" {
path = "/"
}
if pattern == "" {
pattern = "/"
}
if pattern == "*" {
return true
}
// Handle wildcard paths
if strings.HasSuffix(pattern, "/*") {
prefix := pattern[:len(pattern)-2] // Remove "/*"
if prefix == "" {
prefix = "/"
}
return strings.HasPrefix(path, prefix)
}
return path == pattern
}
// CheckLocalNetwork checks if the URL is accessing local network resources
func checkLocalNetwork(parsedURL *url.URL) error {
host := parsedURL.Hostname()
// Check for localhost variants
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return fmt.Errorf("requests to localhost are not allowed")
}
// Try to parse as IP address
ip := net.ParseIP(host)
if ip != nil && isPrivateIP(ip) {
return fmt.Errorf("requests to private IP addresses are not allowed")
}
return nil
}
// IsPrivateIP checks if an IP is loopback, private, or link-local (IPv4/IPv6).
func isPrivateIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() {
return true
}
// IPv4 link-local: 169.254.0.0/16
if ip4 := ip.To4(); ip4 != nil {
return ip4[0] == 169 && ip4[1] == 254
}
// IPv6 link-local: fe80::/10
if ip16 := ip.To16(); ip16 != nil && ip.To4() == nil {
return ip16[0] == 0xfe && (ip16[1]&0xc0) == 0x80
}
return false
}
@@ -0,0 +1,119 @@
package plugins
import (
"net"
"net/url"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("networkPermissionsBase", func() {
Describe("urlMatcher", func() {
var matcher *urlMatcher
BeforeEach(func() {
matcher = newURLMatcher()
})
Describe("MatchesURLPattern", func() {
DescribeTable("exact URL matching",
func(requestURL, pattern string, expected bool) {
result := matcher.MatchesURLPattern(requestURL, pattern)
Expect(result).To(Equal(expected))
},
Entry("exact match", "https://api.example.com", "https://api.example.com", true),
Entry("different domain", "https://api.example.com", "https://api.other.com", false),
Entry("different scheme", "http://api.example.com", "https://api.example.com", false),
Entry("different path", "https://api.example.com/v1", "https://api.example.com/v2", false),
)
DescribeTable("wildcard pattern matching",
func(requestURL, pattern string, expected bool) {
result := matcher.MatchesURLPattern(requestURL, pattern)
Expect(result).To(Equal(expected))
},
Entry("universal wildcard", "https://api.example.com", "*", true),
Entry("subdomain wildcard match", "https://api.example.com", "https://*.example.com", true),
Entry("subdomain wildcard non-match", "https://api.other.com", "https://*.example.com", false),
Entry("path wildcard match", "https://api.example.com/v1/users", "https://api.example.com/*", true),
Entry("path wildcard non-match", "https://other.example.com/v1", "https://api.example.com/*", false),
Entry("port wildcard match", "https://api.example.com:8080", "https://api.example.com:*", true),
)
})
})
Describe("isPrivateIP", func() {
DescribeTable("IPv4 private IP detection",
func(ip string, expected bool) {
parsedIP := net.ParseIP(ip)
Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip)
result := isPrivateIP(parsedIP)
Expect(result).To(Equal(expected))
},
// Private IPv4 ranges
Entry("10.0.0.1 (10.0.0.0/8)", "10.0.0.1", true),
Entry("10.255.255.255 (10.0.0.0/8)", "10.255.255.255", true),
Entry("172.16.0.1 (172.16.0.0/12)", "172.16.0.1", true),
Entry("172.31.255.255 (172.16.0.0/12)", "172.31.255.255", true),
Entry("192.168.1.1 (192.168.0.0/16)", "192.168.1.1", true),
Entry("192.168.255.255 (192.168.0.0/16)", "192.168.255.255", true),
Entry("127.0.0.1 (localhost)", "127.0.0.1", true),
Entry("127.255.255.255 (localhost)", "127.255.255.255", true),
Entry("169.254.1.1 (link-local)", "169.254.1.1", true),
Entry("169.254.255.255 (link-local)", "169.254.255.255", true),
// Public IPv4 addresses
Entry("8.8.8.8 (Google DNS)", "8.8.8.8", false),
Entry("1.1.1.1 (Cloudflare DNS)", "1.1.1.1", false),
Entry("208.67.222.222 (OpenDNS)", "208.67.222.222", false),
Entry("172.15.255.255 (just outside 172.16.0.0/12)", "172.15.255.255", false),
Entry("172.32.0.1 (just outside 172.16.0.0/12)", "172.32.0.1", false),
)
DescribeTable("IPv6 private IP detection",
func(ip string, expected bool) {
parsedIP := net.ParseIP(ip)
Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip)
result := isPrivateIP(parsedIP)
Expect(result).To(Equal(expected))
},
// Private IPv6 ranges
Entry("::1 (IPv6 localhost)", "::1", true),
Entry("fe80::1 (link-local)", "fe80::1", true),
Entry("fc00::1 (unique local)", "fc00::1", true),
Entry("fd00::1 (unique local)", "fd00::1", true),
// Public IPv6 addresses
Entry("2001:4860:4860::8888 (Google DNS)", "2001:4860:4860::8888", false),
Entry("2606:4700:4700::1111 (Cloudflare DNS)", "2606:4700:4700::1111", false),
)
})
Describe("checkLocalNetwork", func() {
DescribeTable("local network detection",
func(urlStr string, shouldError bool, expectedErrorSubstring string) {
parsedURL, err := url.Parse(urlStr)
Expect(err).ToNot(HaveOccurred())
err = checkLocalNetwork(parsedURL)
if shouldError {
Expect(err).To(HaveOccurred())
if expectedErrorSubstring != "" {
Expect(err.Error()).To(ContainSubstring(expectedErrorSubstring))
}
} else {
Expect(err).ToNot(HaveOccurred())
}
},
Entry("localhost", "http://localhost:8080", true, "localhost"),
Entry("127.0.0.1", "http://127.0.0.1:3000", true, "localhost"),
Entry("::1", "http://[::1]:8080", true, "localhost"),
Entry("private IP 192.168.1.100", "http://192.168.1.100", true, "private IP"),
Entry("private IP 10.0.0.1", "http://10.0.0.1", true, "private IP"),
Entry("private IP 172.16.0.1", "http://172.16.0.1", true, "private IP"),
Entry("public IP 8.8.8.8", "http://8.8.8.8", false, ""),
Entry("public domain", "https://api.example.com", false, ""),
)
})
})
+347
View File
@@ -0,0 +1,347 @@
package plugins
import (
"context"
"fmt"
"sync"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/scheduler"
navidsched "github.com/navidrome/navidrome/scheduler"
)
const (
ScheduleTypeOneTime = "one-time"
ScheduleTypeRecurring = "recurring"
)
// ScheduledCallback represents a registered schedule callback
type ScheduledCallback struct {
ID string
PluginID string
Type string // "one-time" or "recurring"
Payload []byte
EntryID int // Used for recurring schedules via the scheduler
Cancel context.CancelFunc // Used for one-time schedules
}
// SchedulerHostFunctions implements the scheduler.SchedulerService interface
type SchedulerHostFunctions struct {
ss *schedulerService
pluginID string
}
func (s SchedulerHostFunctions) ScheduleOneTime(ctx context.Context, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
return s.ss.scheduleOneTime(ctx, s.pluginID, req)
}
func (s SchedulerHostFunctions) ScheduleRecurring(ctx context.Context, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
return s.ss.scheduleRecurring(ctx, s.pluginID, req)
}
func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
return s.ss.cancelSchedule(ctx, s.pluginID, req)
}
type schedulerService struct {
// Map of schedule IDs to their callback info
schedules map[string]*ScheduledCallback
manager *Manager
navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs
mu sync.Mutex
}
// newSchedulerService creates a new schedulerService instance
func newSchedulerService(manager *Manager) *schedulerService {
return &schedulerService{
schedules: make(map[string]*ScheduledCallback),
manager: manager,
navidSched: navidsched.GetInstance(),
}
}
func (s *schedulerService) HostFunctions(pluginID string) SchedulerHostFunctions {
return SchedulerHostFunctions{
ss: s,
pluginID: pluginID,
}
}
// Safe accessor methods for tests
// hasSchedule safely checks if a schedule exists
func (s *schedulerService) hasSchedule(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, exists := s.schedules[id]
return exists
}
// scheduleCount safely returns the number of schedules
func (s *schedulerService) scheduleCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.schedules)
}
// getScheduleType safely returns the type of a schedule
func (s *schedulerService) getScheduleType(id string) string {
s.mu.Lock()
defer s.mu.Unlock()
if cb, exists := s.schedules[id]; exists {
return cb.Type
}
return ""
}
// scheduleJob is a helper function that handles the common logic for scheduling jobs
func (s *schedulerService) scheduleJob(pluginID string, scheduleId string, jobType string, payload []byte) (string, *ScheduledCallback, context.CancelFunc, error) {
if s.manager == nil {
return "", nil, nil, fmt.Errorf("scheduler service not properly initialized")
}
// Original scheduleId (what the plugin will see)
originalScheduleId := scheduleId
if originalScheduleId == "" {
// Generate a random ID if one wasn't provided
originalScheduleId, _ = gonanoid.New(10)
}
// Internal scheduleId (prefixed with plugin name to avoid conflicts)
internalScheduleId := pluginID + ":" + originalScheduleId
// Store any existing cancellation function to call after we've updated the map
var cancelExisting context.CancelFunc
// Check if there's an existing schedule with the same ID, we'll cancel it after updating the map
if existingSchedule, ok := s.schedules[internalScheduleId]; ok {
log.Debug("Replacing existing schedule with same ID", "plugin", pluginID, "scheduleID", originalScheduleId)
// Store cancel information but don't call it yet
if existingSchedule.Type == ScheduleTypeOneTime && existingSchedule.Cancel != nil {
// We'll set the Cancel to nil to prevent the old job from removing the new one
cancelExisting = existingSchedule.Cancel
existingSchedule.Cancel = nil
} else if existingSchedule.Type == ScheduleTypeRecurring {
existingRecurringEntryID := existingSchedule.EntryID
if existingRecurringEntryID != 0 {
s.navidSched.Remove(existingRecurringEntryID)
}
}
}
// Create the callback object
callback := &ScheduledCallback{
ID: originalScheduleId,
PluginID: pluginID,
Type: jobType,
Payload: payload,
}
return internalScheduleId, callback, cancelExisting, nil
}
// scheduleOneTime registers a new one-time scheduled job
func (s *schedulerService) scheduleOneTime(_ context.Context, pluginID string, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeOneTime, req.Payload)
if err != nil {
return nil, err
}
// Create a context with cancel for this one-time schedule
scheduleCtx, cancel := context.WithCancel(context.Background())
callback.Cancel = cancel
// Store the callback info
s.schedules[internalScheduleId] = callback
// Now that the new job is in the map, we can safely cancel the old one
if cancelExisting != nil {
// Cancel in a goroutine to avoid deadlock since we're already holding the lock
go cancelExisting()
}
log.Debug("One-time schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId)
// Start the timer goroutine with the internal ID
go s.runOneTimeSchedule(scheduleCtx, internalScheduleId, time.Duration(req.DelaySeconds)*time.Second)
// Return the original ID to the plugin
return &scheduler.ScheduleResponse{
ScheduleId: callback.ID,
}, nil
}
// scheduleRecurring registers a new recurring scheduled job
func (s *schedulerService) scheduleRecurring(_ context.Context, pluginID string, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeRecurring, req.Payload)
if err != nil {
return nil, err
}
// Schedule the job with the Navidrome scheduler
entryID, err := s.navidSched.Add(req.CronExpression, func() {
s.executeCallback(context.Background(), internalScheduleId, true)
})
if err != nil {
return nil, fmt.Errorf("failed to schedule recurring job: %w", err)
}
// Store the entry ID so we can cancel it later
callback.EntryID = entryID
// Store the callback info
s.schedules[internalScheduleId] = callback
// Now that the new job is in the map, we can safely cancel the old one
if cancelExisting != nil {
// Cancel in a goroutine to avoid deadlock since we're already holding the lock
go cancelExisting()
}
log.Debug("Recurring schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId, "cron", req.CronExpression)
// Return the original ID to the plugin
return &scheduler.ScheduleResponse{
ScheduleId: callback.ID,
}, nil
}
// cancelSchedule cancels a scheduled job (either one-time or recurring)
func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
internalScheduleId := pluginID + ":" + req.ScheduleId
callback, exists := s.schedules[internalScheduleId]
if !exists {
return &scheduler.CancelResponse{
Success: false,
Error: "schedule not found",
}, nil
}
// Store the cancel functions to call after we've updated the schedule map
var cancelFunc context.CancelFunc
var recurringEntryID int
// Store cancel information but don't call it yet
if callback.Type == ScheduleTypeOneTime && callback.Cancel != nil {
cancelFunc = callback.Cancel
callback.Cancel = nil // Set to nil to prevent the cancel handler from removing the job
} else if callback.Type == ScheduleTypeRecurring {
recurringEntryID = callback.EntryID
}
// First remove from the map
delete(s.schedules, internalScheduleId)
// Now perform the cancellation safely
if cancelFunc != nil {
// Execute in a goroutine to avoid deadlock since we're already holding the lock
go cancelFunc()
}
if recurringEntryID != 0 {
s.navidSched.Remove(recurringEntryID)
}
log.Debug("Schedule canceled", "plugin", pluginID, "scheduleID", req.ScheduleId, "internalID", internalScheduleId, "type", callback.Type)
return &scheduler.CancelResponse{
Success: true,
}, nil
}
// runOneTimeSchedule handles the one-time schedule execution and callback
func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) {
tmr := time.NewTimer(delay)
defer tmr.Stop()
select {
case <-ctx.Done():
// Schedule was cancelled via its context
// We're no longer removing the schedule here because that's handled by the code that
// cancelled the context
log.Debug("One-time schedule context canceled", "internalID", internalScheduleId)
return
case <-tmr.C:
// Timer fired, execute the callback
s.executeCallback(ctx, internalScheduleId, false)
}
}
// executeCallback calls the plugin's OnSchedulerCallback method
func (s *schedulerService) executeCallback(ctx context.Context, internalScheduleId string, isRecurring bool) {
s.mu.Lock()
callback := s.schedules[internalScheduleId]
// Only remove one-time schedules from the map after execution
if callback != nil && callback.Type == ScheduleTypeOneTime {
delete(s.schedules, internalScheduleId)
}
s.mu.Unlock()
if callback == nil {
log.Error("Schedule not found for callback", "internalID", internalScheduleId)
return
}
callbackType := "one-time"
if isRecurring {
callbackType = "recurring"
}
log.Debug("Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType)
start := time.Now()
// Create a SchedulerCallbackRequest
req := &api.SchedulerCallbackRequest{
ScheduleId: callback.ID,
Payload: callback.Payload,
IsRecurring: isRecurring,
}
// Get the plugin
p := s.manager.LoadPlugin(callback.PluginID, CapabilitySchedulerCallback)
if p == nil {
log.Error("Plugin not found for callback", "plugin", callback.PluginID)
return
}
// Get instance
inst, closeFn, err := p.Instantiate(ctx)
if err != nil {
log.Error("Error getting plugin instance for callback", "plugin", callback.PluginID, err)
return
}
defer closeFn()
// Type-check the plugin
plugin, ok := inst.(api.SchedulerCallback)
if !ok {
log.Error("Plugin does not implement SchedulerCallback", "plugin", callback.PluginID)
return
}
// Call the plugin's OnSchedulerCallback method
log.Trace(ctx, "Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType)
resp, err := plugin.OnSchedulerCallback(ctx, req)
if err != nil {
log.Error("Error executing schedule callback", "plugin", callback.PluginID, "elapsed", time.Since(start), err)
return
}
log.Debug("Schedule callback executed", "plugin", callback.PluginID, "elapsed", time.Since(start))
if resp.Error != "" {
log.Error("Plugin reported error in schedule callback", "plugin", callback.PluginID, resp.Error)
}
}
+166
View File
@@ -0,0 +1,166 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/plugins/host/scheduler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SchedulerService", func() {
var (
ss *schedulerService
manager *Manager
pluginName = "test_plugin"
)
BeforeEach(func() {
manager = createManager()
ss = manager.schedulerService
})
Describe("One-time scheduling", func() {
It("schedules one-time jobs successfully", func() {
req := &scheduler.ScheduleOneTimeRequest{
DelaySeconds: 1,
Payload: []byte("test payload"),
ScheduleId: "test-job",
}
resp, err := ss.scheduleOneTime(context.Background(), pluginName, req)
Expect(err).ToNot(HaveOccurred())
Expect(resp.ScheduleId).To(Equal("test-job"))
Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeTrue())
Expect(ss.getScheduleType(pluginName + ":" + "test-job")).To(Equal(ScheduleTypeOneTime))
// Test auto-generated ID
req.ScheduleId = ""
resp, err = ss.scheduleOneTime(context.Background(), pluginName, req)
Expect(err).ToNot(HaveOccurred())
Expect(resp.ScheduleId).ToNot(BeEmpty())
})
It("cancels one-time jobs successfully", func() {
req := &scheduler.ScheduleOneTimeRequest{
DelaySeconds: 10,
ScheduleId: "test-job",
}
_, err := ss.scheduleOneTime(context.Background(), pluginName, req)
Expect(err).ToNot(HaveOccurred())
cancelReq := &scheduler.CancelRequest{
ScheduleId: "test-job",
}
resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Success).To(BeTrue())
Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeFalse())
})
})
Describe("Recurring scheduling", func() {
It("schedules recurring jobs successfully", func() {
req := &scheduler.ScheduleRecurringRequest{
CronExpression: "* * * * *", // Every minute
Payload: []byte("test payload"),
ScheduleId: "test-cron",
}
resp, err := ss.scheduleRecurring(context.Background(), pluginName, req)
Expect(err).ToNot(HaveOccurred())
Expect(resp.ScheduleId).To(Equal("test-cron"))
Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeTrue())
Expect(ss.getScheduleType(pluginName + ":" + "test-cron")).To(Equal(ScheduleTypeRecurring))
// Test auto-generated ID
req.ScheduleId = ""
resp, err = ss.scheduleRecurring(context.Background(), pluginName, req)
Expect(err).ToNot(HaveOccurred())
Expect(resp.ScheduleId).ToNot(BeEmpty())
})
It("cancels recurring jobs successfully", func() {
req := &scheduler.ScheduleRecurringRequest{
CronExpression: "* * * * *", // Every minute
ScheduleId: "test-cron",
}
_, err := ss.scheduleRecurring(context.Background(), pluginName, req)
Expect(err).ToNot(HaveOccurred())
cancelReq := &scheduler.CancelRequest{
ScheduleId: "test-cron",
}
resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Success).To(BeTrue())
Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeFalse())
})
})
Describe("Replace existing schedules", func() {
It("replaces one-time jobs with new ones", func() {
// Create first job
req1 := &scheduler.ScheduleOneTimeRequest{
DelaySeconds: 10,
Payload: []byte("test payload 1"),
ScheduleId: "replace-job",
}
_, err := ss.scheduleOneTime(context.Background(), pluginName, req1)
Expect(err).ToNot(HaveOccurred())
// Verify that the initial job exists
scheduleId := pluginName + ":" + "replace-job"
Expect(ss.hasSchedule(scheduleId)).To(BeTrue(), "Initial schedule should exist")
beforeCount := ss.scheduleCount()
// Replace with second job using same ID
req2 := &scheduler.ScheduleOneTimeRequest{
DelaySeconds: 60, // Use a longer delay to ensure it doesn't execute during the test
Payload: []byte("test payload 2"),
ScheduleId: "replace-job",
}
_, err = ss.scheduleOneTime(context.Background(), pluginName, req2)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool {
return ss.hasSchedule(scheduleId)
}).Should(BeTrue(), "Schedule should exist after replacement")
Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement")
})
It("replaces recurring jobs with new ones", func() {
// Create first job
req1 := &scheduler.ScheduleRecurringRequest{
CronExpression: "0 * * * *",
Payload: []byte("test payload 1"),
ScheduleId: "replace-cron",
}
_, err := ss.scheduleRecurring(context.Background(), pluginName, req1)
Expect(err).ToNot(HaveOccurred())
beforeCount := ss.scheduleCount()
// Replace with second job using same ID
req2 := &scheduler.ScheduleRecurringRequest{
CronExpression: "*/5 * * * *",
Payload: []byte("test payload 2"),
ScheduleId: "replace-cron",
}
_, err = ss.scheduleRecurring(context.Background(), pluginName, req2)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool {
return ss.hasSchedule(pluginName + ":" + "replace-cron")
}).Should(BeTrue(), "Schedule should exist after replacement")
Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement")
})
})
})
+414
View File
@@ -0,0 +1,414 @@
package plugins
import (
"context"
"encoding/binary"
"fmt"
"strings"
"sync"
"time"
gorillaws "github.com/gorilla/websocket"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/host/websocket"
)
// WebSocketConnection represents a WebSocket connection
type WebSocketConnection struct {
Conn *gorillaws.Conn
PluginName string
ConnectionID string
Done chan struct{}
mu sync.Mutex
}
// WebSocketHostFunctions implements the websocket.WebSocketService interface
type WebSocketHostFunctions struct {
ws *websocketService
pluginID string
permissions *webSocketPermissions
}
func (s WebSocketHostFunctions) Connect(ctx context.Context, req *websocket.ConnectRequest) (*websocket.ConnectResponse, error) {
return s.ws.connect(ctx, s.pluginID, req, s.permissions)
}
func (s WebSocketHostFunctions) SendText(ctx context.Context, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) {
return s.ws.sendText(ctx, s.pluginID, req)
}
func (s WebSocketHostFunctions) SendBinary(ctx context.Context, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) {
return s.ws.sendBinary(ctx, s.pluginID, req)
}
func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseRequest) (*websocket.CloseResponse, error) {
return s.ws.close(ctx, s.pluginID, req)
}
// websocketService implements the WebSocket service functionality
type websocketService struct {
connections map[string]*WebSocketConnection
manager *Manager
mu sync.RWMutex
}
// newWebsocketService creates a new websocketService instance
func newWebsocketService(manager *Manager) *websocketService {
return &websocketService{
connections: make(map[string]*WebSocketConnection),
manager: manager,
}
}
// HostFunctions returns the WebSocketHostFunctions for the given plugin
func (s *websocketService) HostFunctions(pluginID string, permissions *webSocketPermissions) WebSocketHostFunctions {
return WebSocketHostFunctions{
ws: s,
pluginID: pluginID,
permissions: permissions,
}
}
// Safe accessor methods
// hasConnection safely checks if a connection exists
func (s *websocketService) hasConnection(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, exists := s.connections[id]
return exists
}
// connectionCount safely returns the number of connections
func (s *websocketService) connectionCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.connections)
}
// getConnection safely retrieves a connection by internal ID
func (s *websocketService) getConnection(internalConnectionID string) (*WebSocketConnection, error) {
s.mu.RLock()
defer s.mu.RUnlock()
conn, exists := s.connections[internalConnectionID]
if !exists {
return nil, fmt.Errorf("connection not found")
}
return conn, nil
}
// internalConnectionID builds the internal connection ID from plugin and connection ID
func internalConnectionID(pluginName, connectionID string) string {
return pluginName + ":" + connectionID
}
// extractConnectionID extracts the original connection ID from an internal ID
func extractConnectionID(internalID string) (string, error) {
parts := strings.Split(internalID, ":")
if len(parts) != 2 {
return "", fmt.Errorf("invalid internal connection ID format: %s", internalID)
}
return parts[1], nil
}
// connect establishes a new WebSocket connection
func (s *websocketService) connect(ctx context.Context, pluginID string, req *websocket.ConnectRequest, permissions *webSocketPermissions) (*websocket.ConnectResponse, error) {
if s.manager == nil {
return nil, fmt.Errorf("websocket service not properly initialized")
}
// Check permissions if they exist
if permissions != nil {
if err := permissions.IsConnectionAllowed(req.Url); err != nil {
log.Warn(ctx, "WebSocket connection blocked by permissions", "plugin", pluginID, "url", req.Url, err)
return &websocket.ConnectResponse{Error: "Connection blocked by plugin permissions: " + err.Error()}, nil
}
}
// Create websocket dialer with the headers
dialer := gorillaws.DefaultDialer
header := make(map[string][]string)
for k, v := range req.Headers {
header[k] = []string{v}
}
// Connect to the WebSocket server
conn, resp, err := dialer.DialContext(ctx, req.Url, header)
if err != nil {
return nil, fmt.Errorf("failed to connect to WebSocket server: %w", err)
}
defer resp.Body.Close()
// Generate a connection ID
if req.ConnectionId == "" {
req.ConnectionId, _ = gonanoid.New(10)
}
connectionID := req.ConnectionId
internal := internalConnectionID(pluginID, connectionID)
// Create the connection object
wsConn := &WebSocketConnection{
Conn: conn,
PluginName: pluginID,
ConnectionID: connectionID,
Done: make(chan struct{}),
}
// Store the connection
s.mu.Lock()
defer s.mu.Unlock()
s.connections[internal] = wsConn
log.Debug("WebSocket connection established", "plugin", pluginID, "connectionID", connectionID, "url", req.Url)
// Start the message handling goroutine
go s.handleMessages(internal, wsConn)
return &websocket.ConnectResponse{
ConnectionId: connectionID,
}, nil
}
// writeMessage is a helper to send messages to a websocket connection
func (s *websocketService) writeMessage(pluginID string, connID string, messageType int, data []byte) error {
internal := internalConnectionID(pluginID, connID)
conn, err := s.getConnection(internal)
if err != nil {
return err
}
conn.mu.Lock()
defer conn.mu.Unlock()
if err := conn.Conn.WriteMessage(messageType, data); err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
return nil
}
// sendText sends a text message over a WebSocket connection
func (s *websocketService) sendText(ctx context.Context, pluginID string, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) {
if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.TextMessage, []byte(req.Message)); err != nil {
return &websocket.SendTextResponse{Error: err.Error()}, nil //nolint:nilerr
}
return &websocket.SendTextResponse{}, nil
}
// sendBinary sends binary data over a WebSocket connection
func (s *websocketService) sendBinary(ctx context.Context, pluginID string, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) {
if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.BinaryMessage, req.Data); err != nil {
return &websocket.SendBinaryResponse{Error: err.Error()}, nil //nolint:nilerr
}
return &websocket.SendBinaryResponse{}, nil
}
// close closes a WebSocket connection
func (s *websocketService) close(ctx context.Context, pluginID string, req *websocket.CloseRequest) (*websocket.CloseResponse, error) {
internal := internalConnectionID(pluginID, req.ConnectionId)
s.mu.Lock()
conn, exists := s.connections[internal]
if !exists {
s.mu.Unlock()
return &websocket.CloseResponse{Error: "connection not found"}, nil
}
delete(s.connections, internal)
s.mu.Unlock()
// Signal the message handling goroutine to stop
close(conn.Done)
// Close the connection with the specified code and reason
conn.mu.Lock()
defer conn.mu.Unlock()
err := conn.Conn.WriteControl(
gorillaws.CloseMessage,
gorillaws.FormatCloseMessage(int(req.Code), req.Reason),
time.Now().Add(time.Second),
)
if err != nil {
log.Error("Error sending close message", "plugin", pluginID, "error", err)
}
if err := conn.Conn.Close(); err != nil {
return nil, fmt.Errorf("error closing connection: %w", err)
}
log.Debug("WebSocket connection closed", "plugin", pluginID, "connectionID", req.ConnectionId)
return &websocket.CloseResponse{}, nil
}
// handleMessages processes incoming WebSocket messages
func (s *websocketService) handleMessages(internalID string, conn *WebSocketConnection) {
// Get the original connection ID (without plugin prefix)
connectionID, err := extractConnectionID(internalID)
if err != nil {
log.Error("Invalid internal connection ID", "id", internalID, "error", err)
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer func() {
// Ensure the connection is removed from the map if not already removed
s.mu.Lock()
defer s.mu.Unlock()
delete(s.connections, internalID)
log.Debug("WebSocket message handler stopped", "plugin", conn.PluginName, "connectionID", connectionID)
}()
// Add connection info to context
ctx = log.NewContext(ctx,
"connectionID", connectionID,
"plugin", conn.PluginName,
)
for {
select {
case <-conn.Done:
// Connection was closed by a Close call
return
default:
// Set a read deadline
_ = conn.Conn.SetReadDeadline(time.Now().Add(time.Second * 60))
// Read the next message
messageType, message, err := conn.Conn.ReadMessage()
if err != nil {
s.notifyErrorCallback(ctx, connectionID, conn, err.Error())
return
}
// Reset the read deadline
_ = conn.Conn.SetReadDeadline(time.Time{})
// Process the message based on its type
switch messageType {
case gorillaws.TextMessage:
s.notifyTextCallback(ctx, connectionID, conn, string(message))
case gorillaws.BinaryMessage:
s.notifyBinaryCallback(ctx, connectionID, conn, message)
case gorillaws.CloseMessage:
code := gorillaws.CloseNormalClosure
reason := ""
if len(message) >= 2 {
code = int(binary.BigEndian.Uint16(message[:2]))
if len(message) > 2 {
reason = string(message[2:])
}
}
s.notifyCloseCallback(ctx, connectionID, conn, code, reason)
return
}
}
}
}
// executeCallback is a common function that handles the plugin loading and execution
// for all types of callbacks
func (s *websocketService) executeCallback(ctx context.Context, pluginID string, fn func(context.Context, api.WebSocketCallback) error) {
log.Debug(ctx, "WebSocket received")
start := time.Now()
// Get the plugin
p := s.manager.LoadPlugin(pluginID, CapabilityWebSocketCallback)
if p == nil {
log.Error(ctx, "Plugin not found for WebSocket callback")
return
}
// Get instance
inst, closeFn, err := p.Instantiate(ctx)
if err != nil {
log.Error(ctx, "Error getting plugin instance for WebSocket callback", err)
return
}
defer closeFn()
// Type-check the plugin
plugin, ok := inst.(api.WebSocketCallback)
if !ok {
log.Error(ctx, "Plugin does not implement WebSocketCallback")
return
}
// Call the appropriate callback function
log.Trace(ctx, "Executing WebSocket callback")
if err = fn(ctx, plugin); err != nil {
log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err)
return
}
log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start))
}
// notifyTextCallback notifies the plugin of a text message
func (s *websocketService) notifyTextCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, message string) {
req := &api.OnTextMessageRequest{
ConnectionId: connectionID,
Message: message,
}
ctx = log.NewContext(ctx, "callback", "OnTextMessage", "size", len(message))
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
_, err := plugin.OnTextMessage(ctx, req)
return err
})
}
// notifyBinaryCallback notifies the plugin of a binary message
func (s *websocketService) notifyBinaryCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, data []byte) {
req := &api.OnBinaryMessageRequest{
ConnectionId: connectionID,
Data: data,
}
ctx = log.NewContext(ctx, "callback", "OnBinaryMessage", "size", len(data))
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
_, err := plugin.OnBinaryMessage(ctx, req)
return err
})
}
// notifyErrorCallback notifies the plugin of an error
func (s *websocketService) notifyErrorCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, errorMsg string) {
req := &api.OnErrorRequest{
ConnectionId: connectionID,
Error: errorMsg,
}
ctx = log.NewContext(ctx, "callback", "OnError", "error", errorMsg)
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
_, err := plugin.OnError(ctx, req)
return err
})
}
// notifyCloseCallback notifies the plugin that the connection was closed
func (s *websocketService) notifyCloseCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, code int, reason string) {
req := &api.OnCloseRequest{
ConnectionId: connectionID,
Code: int32(code),
Reason: reason,
}
ctx = log.NewContext(ctx, "callback", "OnClose", "code", code, "reason", reason)
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
_, err := plugin.OnClose(ctx, req)
return err
})
}
+76
View File
@@ -0,0 +1,76 @@
package plugins
import (
"fmt"
"github.com/navidrome/navidrome/plugins/schema"
)
// WebSocketPermissions represents granular WebSocket access permissions for plugins
type webSocketPermissions struct {
*networkPermissionsBase
AllowedUrls []string `json:"allowedUrls"`
matcher *urlMatcher
}
// parseWebSocketPermissions extracts WebSocket permissions from the schema
func parseWebSocketPermissions(permData *schema.PluginManifestPermissionsWebsocket) (*webSocketPermissions, error) {
if len(permData.AllowedUrls) == 0 {
return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern")
}
return &webSocketPermissions{
networkPermissionsBase: &networkPermissionsBase{
AllowLocalNetwork: permData.AllowLocalNetwork,
},
AllowedUrls: permData.AllowedUrls,
matcher: newURLMatcher(),
}, nil
}
// IsConnectionAllowed checks if a WebSocket connection is allowed
func (w *webSocketPermissions) IsConnectionAllowed(requestURL string) error {
if _, err := checkURLPolicy(requestURL, w.AllowLocalNetwork); err != nil {
return err
}
// allowedUrls is required - no fallback to allow all URLs
if len(w.AllowedUrls) == 0 {
return fmt.Errorf("no allowed URLs configured for plugin")
}
// Check URL patterns
// First try exact matches, then wildcard matches
// Phase 1: Check for exact matches first
for _, urlPattern := range w.AllowedUrls {
if urlPattern == "*" || (!containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern)) {
return nil
}
}
// Phase 2: Check wildcard patterns
for _, urlPattern := range w.AllowedUrls {
if containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern) {
return nil
}
}
return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL)
}
// containsWildcard checks if a URL pattern contains wildcard characters
func containsWildcard(pattern string) bool {
if pattern == "*" {
return true
}
// Check for wildcards anywhere in the pattern
for _, char := range pattern {
if char == '*' {
return true
}
}
return false
}
@@ -0,0 +1,79 @@
package plugins
import (
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("WebSocket Permissions", func() {
Describe("parseWebSocketPermissions", func() {
It("should parse valid WebSocket permissions", func() {
permData := &schema.PluginManifestPermissionsWebsocket{
Reason: "Need to connect to WebSocket API",
AllowLocalNetwork: false,
AllowedUrls: []string{"wss://api.example.com/ws", "wss://cdn.example.com/*"},
}
perms, err := parseWebSocketPermissions(permData)
Expect(err).To(BeNil())
Expect(perms).ToNot(BeNil())
Expect(perms.AllowLocalNetwork).To(BeFalse())
Expect(perms.AllowedUrls).To(Equal([]string{"wss://api.example.com/ws", "wss://cdn.example.com/*"}))
})
It("should fail if allowedUrls is empty", func() {
permData := &schema.PluginManifestPermissionsWebsocket{
Reason: "Need to connect to WebSocket API",
AllowLocalNetwork: false,
AllowedUrls: []string{},
}
_, err := parseWebSocketPermissions(permData)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern"))
})
It("should handle wildcard patterns", func() {
permData := &schema.PluginManifestPermissionsWebsocket{
Reason: "Need to connect to any WebSocket",
AllowLocalNetwork: true,
AllowedUrls: []string{"wss://*"},
}
perms, err := parseWebSocketPermissions(permData)
Expect(err).To(BeNil())
Expect(perms.AllowLocalNetwork).To(BeTrue())
Expect(perms.AllowedUrls).To(Equal([]string{"wss://*"}))
})
Context("URL matching", func() {
var perms *webSocketPermissions
BeforeEach(func() {
permData := &schema.PluginManifestPermissionsWebsocket{
Reason: "Need to connect to external services",
AllowLocalNetwork: true,
AllowedUrls: []string{"wss://api.example.com/*", "ws://localhost:8080"},
}
var err error
perms, err = parseWebSocketPermissions(permData)
Expect(err).To(BeNil())
})
It("should allow connections to URLs matching patterns", func() {
err := perms.IsConnectionAllowed("wss://api.example.com/v1/stream")
Expect(err).To(BeNil())
err = perms.IsConnectionAllowed("ws://localhost:8080")
Expect(err).To(BeNil())
})
It("should deny connections to URLs not matching patterns", func() {
err := perms.IsConnectionAllowed("wss://malicious.com/stream")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("does not match any allowed URL patterns"))
})
})
})
})
+225
View File
@@ -0,0 +1,225 @@
package plugins
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
gorillaws "github.com/gorilla/websocket"
"github.com/navidrome/navidrome/plugins/host/websocket"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("WebSocket Host Service", func() {
var (
wsService *websocketService
manager *Manager
ctx context.Context
server *httptest.Server
upgrader gorillaws.Upgrader
serverMessages []string
serverMu sync.Mutex
)
// WebSocket echo server handler
echoHandler := func(w http.ResponseWriter, r *http.Request) {
// Check headers
if r.Header.Get("X-Test-Header") != "test-value" {
http.Error(w, "Missing or invalid X-Test-Header", http.StatusBadRequest)
return
}
// Upgrade connection to WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// Echo messages back
for {
mt, message, err := conn.ReadMessage()
if err != nil {
break
}
// Store the received message for verification
if mt == gorillaws.TextMessage {
msg := string(message)
serverMu.Lock()
serverMessages = append(serverMessages, msg)
serverMu.Unlock()
}
// Echo it back
err = conn.WriteMessage(mt, message)
if err != nil {
break
}
// If message is "close", close the connection
if mt == gorillaws.TextMessage && string(message) == "close" {
_ = conn.WriteControl(
gorillaws.CloseMessage,
gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "bye"),
time.Now().Add(time.Second),
)
break
}
}
}
BeforeEach(func() {
ctx = context.Background()
serverMessages = make([]string, 0)
serverMu = sync.Mutex{}
// Create a test WebSocket server
//upgrader = gorillaws.Upgrader{}
server = httptest.NewServer(http.HandlerFunc(echoHandler))
DeferCleanup(server.Close)
// Create a new manager and websocket service
manager = createManager()
wsService = newWebsocketService(manager)
})
Describe("WebSocket operations", func() {
var (
pluginName string
connectionID string
wsURL string
)
BeforeEach(func() {
pluginName = "test-plugin"
connectionID = "test-connection-id"
wsURL = "ws" + strings.TrimPrefix(server.URL, "http")
})
It("connects to a WebSocket server", func() {
// Connect to the WebSocket server
req := &websocket.ConnectRequest{
Url: wsURL,
Headers: map[string]string{
"X-Test-Header": "test-value",
},
ConnectionId: connectionID,
}
resp, err := wsService.connect(ctx, pluginName, req, nil)
Expect(err).ToNot(HaveOccurred())
Expect(resp.ConnectionId).ToNot(BeEmpty())
connectionID = resp.ConnectionId
// Verify that the connection was added to the service
internalID := pluginName + ":" + connectionID
Expect(wsService.hasConnection(internalID)).To(BeTrue())
})
It("sends and receives text messages", func() {
// Connect to the WebSocket server
req := &websocket.ConnectRequest{
Url: wsURL,
Headers: map[string]string{
"X-Test-Header": "test-value",
},
ConnectionId: connectionID,
}
resp, err := wsService.connect(ctx, pluginName, req, nil)
Expect(err).ToNot(HaveOccurred())
connectionID = resp.ConnectionId
// Send a text message
textReq := &websocket.SendTextRequest{
ConnectionId: connectionID,
Message: "hello websocket",
}
_, err = wsService.sendText(ctx, pluginName, textReq)
Expect(err).ToNot(HaveOccurred())
// Wait a bit for the message to be processed
Eventually(func() []string {
serverMu.Lock()
defer serverMu.Unlock()
return serverMessages
}, "1s").Should(ContainElement("hello websocket"))
})
It("closes a WebSocket connection", func() {
// Connect to the WebSocket server
req := &websocket.ConnectRequest{
Url: wsURL,
Headers: map[string]string{
"X-Test-Header": "test-value",
},
ConnectionId: connectionID,
}
resp, err := wsService.connect(ctx, pluginName, req, nil)
Expect(err).ToNot(HaveOccurred())
connectionID = resp.ConnectionId
initialCount := wsService.connectionCount()
// Close the connection
closeReq := &websocket.CloseRequest{
ConnectionId: connectionID,
Code: 1000, // Normal closure
Reason: "test complete",
}
_, err = wsService.close(ctx, pluginName, closeReq)
Expect(err).ToNot(HaveOccurred())
// Verify that the connection was removed
Eventually(func() int {
return wsService.connectionCount()
}, "1s").Should(Equal(initialCount - 1))
internalID := pluginName + ":" + connectionID
Expect(wsService.hasConnection(internalID)).To(BeFalse())
})
It("handles connection errors gracefully", func() {
// Try to connect to an invalid URL
req := &websocket.ConnectRequest{
Url: "ws://invalid-url-that-does-not-exist",
Headers: map[string]string{},
ConnectionId: connectionID,
}
_, err := wsService.connect(ctx, pluginName, req, nil)
Expect(err).To(HaveOccurred())
})
It("returns error when attempting to use non-existent connection", func() {
// Try to send a message to a non-existent connection
textReq := &websocket.SendTextRequest{
ConnectionId: "non-existent-connection",
Message: "this should fail",
}
sendResp, err := wsService.sendText(ctx, pluginName, textReq)
Expect(err).ToNot(HaveOccurred())
Expect(sendResp.Error).To(ContainSubstring("connection not found"))
// Try to close a non-existent connection
closeReq := &websocket.CloseRequest{
ConnectionId: "non-existent-connection",
Code: 1000,
Reason: "test complete",
}
closeResp, err := wsService.close(ctx, pluginName, closeReq)
Expect(err).ToNot(HaveOccurred())
Expect(closeResp.Error).To(ContainSubstring("connection not found"))
})
})
})
+365
View File
@@ -0,0 +1,365 @@
package plugins
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative api/api.proto
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/http/http.proto
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/config/config.proto
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/websocket/websocket.proto
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto
import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/navidrome/navidrome/utils/slice"
"github.com/tetratelabs/wazero"
)
const (
CapabilityMetadataAgent = "MetadataAgent"
CapabilityScrobbler = "Scrobbler"
CapabilitySchedulerCallback = "SchedulerCallback"
CapabilityWebSocketCallback = "WebSocketCallback"
CapabilityLifecycleManagement = "LifecycleManagement"
)
// pluginCreators maps capability types to their respective creator functions
type pluginConstructor func(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
var pluginCreators = map[string]pluginConstructor{
CapabilityMetadataAgent: newWasmMediaAgent,
CapabilityScrobbler: newWasmScrobblerPlugin,
CapabilitySchedulerCallback: newWasmSchedulerCallback,
CapabilityWebSocketCallback: newWasmWebSocketCallback,
}
// WasmPlugin is the base interface that all WASM plugins implement
type WasmPlugin interface {
// PluginID returns the unique identifier of the plugin (folder name)
PluginID() string
// Instantiate creates a new instance of the plugin and returns it along with a cleanup function
Instantiate(ctx context.Context) (any, func(), error)
}
type plugin struct {
ID string
Path string
Capabilities []string
WasmPath string
Manifest *schema.PluginManifest // Loaded manifest
Runtime api.WazeroNewRuntime
ModConfig wazero.ModuleConfig
compilationReady chan struct{}
compilationErr error
}
func (p *plugin) waitForCompilation() error {
timeout := pluginCompilationTimeout()
select {
case <-p.compilationReady:
case <-time.After(timeout):
err := fmt.Errorf("timed out waiting for plugin %s to compile", p.ID)
log.Error("Timed out waiting for plugin compilation", "name", p.ID, "path", p.WasmPath, "timeout", timeout, "err", err)
return err
}
if p.compilationErr != nil {
log.Error("Failed to compile plugin", "name", p.ID, "path", p.WasmPath, p.compilationErr)
}
return p.compilationErr
}
// Manager is a singleton that manages plugins
type Manager struct {
plugins map[string]*plugin // Map of plugin folder name to plugin info
mu sync.RWMutex // Protects plugins map
schedulerService *schedulerService // Service for handling scheduled tasks
websocketService *websocketService // Service for handling WebSocket connections
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
}
// GetManager returns the singleton instance of Manager
func GetManager() *Manager {
return singleton.GetInstance(func() *Manager {
return createManager()
})
}
// createManager creates a new Manager instance. Used in tests
func createManager() *Manager {
m := &Manager{
plugins: make(map[string]*plugin),
lifecycle: newPluginLifecycleManager(),
}
// Create the host services
m.schedulerService = newSchedulerService(m)
m.websocketService = newWebsocketService(m)
return m
}
// registerPlugin adds a plugin to the registry with the given parameters
// Used internally by ScanPlugins to register plugins
func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
// Create custom runtime function
customRuntime := m.createRuntime(pluginID, manifest.Permissions)
// Configure module and determine plugin name
mc := newWazeroModuleConfig()
// Check if it's a symlink, indicating development mode
isSymlink := false
if fileInfo, err := os.Lstat(pluginDir); err == nil {
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
}
// Store plugin info
p := &plugin{
ID: pluginID,
Path: pluginDir,
Capabilities: slice.Map(manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { return string(cap) }),
WasmPath: wasmPath,
Manifest: manifest,
Runtime: customRuntime,
ModConfig: mc,
compilationReady: make(chan struct{}),
}
// Start pre-compilation of WASM module in background
go func() {
precompilePlugin(p)
// Check if this plugin implements InitService and hasn't been initialized yet
m.initializePluginIfNeeded(p)
}()
// Register the plugin
m.mu.Lock()
defer m.mu.Unlock()
m.plugins[pluginID] = p
// Register one plugin adapter for each capability
for _, capability := range manifest.Capabilities {
capabilityStr := string(capability)
constructor := pluginCreators[capabilityStr]
if constructor == nil {
// Warn about unknown capabilities, except for LifecycleManagement (it does not have an adapter)
if capability != CapabilityLifecycleManagement {
log.Warn("Unknown plugin capability type", "capability", capability, "plugin", pluginID)
}
continue
}
adapter := constructor(wasmPath, pluginID, customRuntime, mc)
m.adapters[pluginID+"_"+capabilityStr] = adapter
}
log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink)
return m.plugins[pluginID]
}
// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement
func (m *Manager) initializePluginIfNeeded(plugin *plugin) {
// Skip if already initialized
if m.lifecycle.isInitialized(plugin) {
return
}
// Check if the plugin implements LifecycleManagement
for _, capability := range plugin.Manifest.Capabilities {
if capability == CapabilityLifecycleManagement {
m.lifecycle.callOnInit(plugin)
m.lifecycle.markInitialized(plugin)
break
}
}
}
// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use.
func (m *Manager) ScanPlugins() {
// Clear existing plugins
m.mu.Lock()
m.plugins = make(map[string]*plugin)
m.adapters = make(map[string]WasmPlugin)
m.mu.Unlock()
// Get plugins directory from config
root := conf.Server.Plugins.Folder
log.Debug("Scanning plugins folder", "root", root)
// Fail fast if the compilation cache cannot be initialized
_, err := getCompilationCache()
if err != nil {
log.Error("Failed to initialize plugins compilation cache. Disabling plugins", err)
return
}
// Discover all plugins using the shared discovery function
discoveries := DiscoverPlugins(root)
var validPluginNames []string
for _, discovery := range discoveries {
if discovery.Error != nil {
// Handle global errors (like directory read failure)
if discovery.ID == "" {
log.Error("Plugin discovery failed", discovery.Error)
return
}
// Handle individual plugin errors
log.Error("Failed to process plugin", "plugin", discovery.ID, discovery.Error)
continue
}
// Log discovery details
log.Debug("Processing entry", "name", discovery.ID, "isSymlink", discovery.IsSymlink)
if discovery.IsSymlink {
log.Debug("Processing symlinked plugin directory", "name", discovery.ID, "target", discovery.Path)
}
log.Debug("Checking for plugin.wasm", "wasmPath", discovery.WasmPath)
log.Debug("Manifest loaded successfully", "folder", discovery.ID, "name", discovery.Manifest.Name, "capabilities", discovery.Manifest.Capabilities)
validPluginNames = append(validPluginNames, discovery.ID)
// Register the plugin
m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest)
}
log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames)
}
// PluginNames returns the folder names of all plugins that implement the specified capability
func (m *Manager) PluginNames(capability string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
var names []string
for name, plugin := range m.plugins {
for _, c := range plugin.Manifest.Capabilities {
if string(c) == capability {
names = append(names, name)
break
}
}
}
return names
}
func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) {
m.mu.RLock()
defer m.mu.RUnlock()
info, infoOk := m.plugins[name]
adapter, adapterOk := m.adapters[name+"_"+capability]
if !infoOk {
log.Warn("Plugin not found", "name", name)
return nil, nil
}
if !adapterOk {
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
return nil, nil
}
return info, adapter
}
// LoadPlugin instantiates and returns a plugin by folder name
func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin {
info, adapter := m.getPlugin(name, capability)
if info == nil {
log.Warn("Plugin not found", "name", name, "capability", capability)
return nil
}
log.Debug("Loading plugin", "name", name, "path", info.Path)
// Wait for the plugin to be ready before using it.
if err := info.waitForCompilation(); err != nil {
log.Error("Plugin is not ready, cannot be loaded", "plugin", name, "capability", capability, "err", err)
return nil
}
if adapter == nil {
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
return nil
}
return adapter
}
// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error.
// This is useful when you need to wait for compilation without loading a specific capability,
// such as during plugin refresh operations or health checks.
func (m *Manager) EnsureCompiled(name string) error {
m.mu.RLock()
plugin, ok := m.plugins[name]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("plugin not found: %s", name)
}
return plugin.waitForCompilation()
}
// LoadAllPlugins instantiates and returns all plugins that implement the specified capability
func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin {
names := m.PluginNames(capability)
if len(names) == 0 {
return nil
}
var plugins []WasmPlugin
for _, name := range names {
plugin := m.LoadPlugin(name, capability)
if plugin != nil {
plugins = append(plugins, plugin)
}
}
return plugins
}
// LoadMediaAgent instantiates and returns a media agent plugin by folder name
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
plugin := m.LoadPlugin(name, CapabilityMetadataAgent)
if plugin == nil {
return nil, false
}
agent, ok := plugin.(*wasmMediaAgent)
return agent, ok
}
// LoadAllMediaAgents instantiates and returns all media agent plugins
func (m *Manager) LoadAllMediaAgents() []agents.Interface {
plugins := m.LoadAllPlugins(CapabilityMetadataAgent)
return slice.Map(plugins, func(p WasmPlugin) agents.Interface {
return p.(agents.Interface)
})
}
// LoadScrobbler instantiates and returns a scrobbler plugin by folder name
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
plugin := m.LoadPlugin(name, CapabilityScrobbler)
if plugin == nil {
return nil, false
}
s, ok := plugin.(scrobbler.Scrobbler)
return s, ok
}
// LoadAllScrobblers instantiates and returns all scrobbler plugins
func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler {
plugins := m.LoadAllPlugins(CapabilityScrobbler)
return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler {
return p.(scrobbler.Scrobbler)
})
}
+257
View File
@@ -0,0 +1,257 @@
package plugins
import (
"context"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Plugin Manager", func() {
var mgr *Manager
var ctx context.Context
BeforeEach(func() {
// We change the plugins folder to random location to avoid conflicts with other tests,
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
// data races.
originalPluginsFolder := conf.Server.Plugins.Folder
DeferCleanup(func() {
conf.Server.Plugins.Folder = originalPluginsFolder
})
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = testDataDir
ctx = GinkgoT().Context()
mgr = createManager()
mgr.ScanPlugins()
})
It("should scan and discover plugins from the testdata folder", func() {
Expect(mgr).NotTo(BeNil())
mediaAgentNames := mgr.PluginNames("MetadataAgent")
Expect(mediaAgentNames).To(HaveLen(4))
Expect(mediaAgentNames).To(ContainElement("fake_artist_agent"))
Expect(mediaAgentNames).To(ContainElement("fake_album_agent"))
Expect(mediaAgentNames).To(ContainElement("multi_plugin"))
Expect(mediaAgentNames).To(ContainElement("unauthorized_plugin"))
scrobblerNames := mgr.PluginNames("Scrobbler")
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
initServiceNames := mgr.PluginNames("LifecycleManagement")
Expect(initServiceNames).To(ContainElement("multi_plugin"))
Expect(initServiceNames).To(ContainElement("fake_init_service"))
})
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent)
Expect(plugin).NotTo(BeNil())
agent, ok := plugin.(agents.Interface)
Expect(ok).To(BeTrue(), "plugin should implement agents.Interface")
Expect(agent.AgentName()).To(Equal("fake_artist_agent"))
mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever)
Expect(ok).To(BeTrue())
mbid, err := mbidRetriever.GetArtistMBID(ctx, "123", "The Beatles")
Expect(err).NotTo(HaveOccurred())
Expect(mbid).To(Equal("1234567890"))
})
It("should load all MetadataAgent plugins", func() {
agents := mgr.LoadAllMediaAgents()
Expect(agents).To(HaveLen(4))
var names []string
for _, a := range agents {
names = append(names, a.AgentName())
}
Expect(names).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
})
Describe("ScanPlugins", func() {
var tempPluginsDir string
var m *Manager
BeforeEach(func() {
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*")
DeferCleanup(func() {
_ = os.RemoveAll(tempPluginsDir)
})
conf.Server.Plugins.Folder = tempPluginsDir
m = createManager()
})
// Helper to create a complete valid plugin for manager testing
createValidPlugin := func(folderName, manifestName string) {
pluginDir := filepath.Join(tempPluginsDir, folderName)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Copy real WASM file from testdata
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
sourceWasm, err := os.ReadFile(sourceWasmPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
manifest := `{
"name": "` + manifestName + `",
"version": "1.0.0",
"capabilities": ["MetadataAgent"],
"author": "Test Author",
"description": "Test Plugin",
"website": "https://test.navidrome.org/` + manifestName + `",
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
}
It("should register and compile discovered plugins", func() {
createValidPlugin("test-plugin", "test-plugin")
m.ScanPlugins()
// Focus on manager behavior: registration and compilation
Expect(m.plugins).To(HaveLen(1))
Expect(m.plugins).To(HaveKey("test-plugin"))
plugin := m.plugins["test-plugin"]
Expect(plugin.ID).To(Equal("test-plugin"))
Expect(plugin.Manifest.Name).To(Equal("test-plugin"))
// Verify plugin can be loaded (compilation successful)
loadedPlugin := m.LoadPlugin("test-plugin", CapabilityMetadataAgent)
Expect(loadedPlugin).NotTo(BeNil())
})
It("should handle multiple plugins with different IDs but same manifest names", func() {
// This tests manager-specific behavior: how it handles ID conflicts
createValidPlugin("lastfm-official", "lastfm")
createValidPlugin("lastfm-custom", "lastfm")
m.ScanPlugins()
// Both should be registered with their folder names as IDs
Expect(m.plugins).To(HaveLen(2))
Expect(m.plugins).To(HaveKey("lastfm-official"))
Expect(m.plugins).To(HaveKey("lastfm-custom"))
// Both should be loadable independently
official := m.LoadPlugin("lastfm-official", CapabilityMetadataAgent)
custom := m.LoadPlugin("lastfm-custom", CapabilityMetadataAgent)
Expect(official).NotTo(BeNil())
Expect(custom).NotTo(BeNil())
Expect(official.PluginID()).To(Equal("lastfm-official"))
Expect(custom.PluginID()).To(Equal("lastfm-custom"))
})
})
Describe("LoadPlugin", func() {
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent)
Expect(plugin).NotTo(BeNil())
agent, ok := plugin.(agents.Interface)
Expect(ok).To(BeTrue(), "plugin should implement agents.Interface")
Expect(agent.AgentName()).To(Equal("fake_artist_agent"))
mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever)
Expect(ok).To(BeTrue())
mbid, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist")
Expect(err).NotTo(HaveOccurred())
Expect(mbid).To(Equal("1234567890"))
})
})
Describe("EnsureCompiled", func() {
It("should successfully wait for plugin compilation", func() {
err := mgr.EnsureCompiled("fake_artist_agent")
Expect(err).NotTo(HaveOccurred())
})
It("should return error for non-existent plugin", func() {
err := mgr.EnsureCompiled("non-existent-plugin")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("plugin not found: non-existent-plugin"))
})
It("should wait for compilation to complete for all valid plugins", func() {
pluginNames := []string{"fake_artist_agent", "fake_album_agent", "multi_plugin", "fake_scrobbler"}
for _, name := range pluginNames {
err := mgr.EnsureCompiled(name)
Expect(err).NotTo(HaveOccurred(), "plugin %s should compile successfully", name)
}
})
})
Describe("Invoke Methods", func() {
It("should load all MetadataAgent plugins and invoke methods", func() {
mediaAgentNames := mgr.PluginNames("MetadataAgent")
Expect(mediaAgentNames).NotTo(BeEmpty())
plugins := mgr.LoadAllPlugins("MetadataAgent")
Expect(plugins).To(HaveLen(len(mediaAgentNames)))
var fakeAlbumPlugin agents.Interface
for _, p := range plugins {
if agent, ok := p.(agents.Interface); ok {
if agent.AgentName() == "fake_album_agent" {
fakeAlbumPlugin = agent
break
}
}
}
Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded")
// Test GetAlbumInfo method - need to cast to the specific interface
albumRetriever, ok := fakeAlbumPlugin.(agents.AlbumInfoRetriever)
Expect(ok).To(BeTrue(), "fake_album_agent should implement AlbumInfoRetriever")
info, err := albumRetriever.GetAlbumInfo(ctx, "Test Album", "Test Artist", "123")
Expect(err).NotTo(HaveOccurred())
Expect(info).NotTo(BeNil())
Expect(info.Name).To(Equal("Test Album"))
})
})
Describe("Permission Enforcement Integration", func() {
It("should fail when plugin tries to access unauthorized services", func() {
// This plugin tries to access config service but has no permissions
plugin := mgr.LoadPlugin("unauthorized_plugin", CapabilityMetadataAgent)
Expect(plugin).NotTo(BeNil())
agent, ok := plugin.(agents.Interface)
Expect(ok).To(BeTrue())
// This should fail because the plugin tries to access unauthorized config service
// The exact behavior depends on the plugin implementation, but it should either:
// 1. Fail during instantiation, or
// 2. Return an error when trying to call config methods
// Try to use one of the available methods - let's test with GetArtistMBID
mbidRetriever, isMBIDRetriever := agent.(agents.ArtistMBIDRetriever)
if isMBIDRetriever {
_, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist")
if err == nil {
// If no error, the plugin should still be working
// but any config access should fail silently or return default values
Expect(agent.AgentName()).To(Equal("unauthorized_plugin"))
} else {
// If there's an error, it should be related to missing permissions
Expect(err.Error()).To(ContainSubstring(""))
}
} else {
// If the plugin doesn't implement the interface, that's also acceptable
Expect(agent.AgentName()).To(Equal("unauthorized_plugin"))
}
})
})
})
+30
View File
@@ -0,0 +1,30 @@
package plugins
//go:generate go tool go-jsonschema --schema-root-type navidrome://plugins/manifest=PluginManifest -p schema --output schema/manifest_gen.go schema/manifest.schema.json
import (
_ "embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/schema"
)
// LoadManifest loads and parses the manifest.json file from the given plugin directory.
// Returns the generated schema.PluginManifest type with full validation and type safety.
func LoadManifest(pluginDir string) (*schema.PluginManifest, error) {
manifestPath := filepath.Join(pluginDir, "manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("failed to read manifest file: %w", err)
}
var manifest schema.PluginManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("invalid manifest: %w", err)
}
return &manifest, nil
}
+525
View File
@@ -0,0 +1,525 @@
package plugins
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Helper function to create test plugins with typed permissions
func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPermissions) string {
pluginDir := filepath.Join(tempDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Use the generated PluginManifest type directly - it handles JSON marshaling automatically
manifest := schema.PluginManifest{
Name: name,
Author: "Test Author",
Version: "1.0.0",
Description: "Test plugin for permissions",
Website: "https://test.navidrome.org/" + name,
Capabilities: []schema.PluginManifestCapabilitiesElem{
schema.PluginManifestCapabilitiesElemMetadataAgent,
},
Permissions: permissions,
}
// Marshal the typed manifest directly - gets all validation for free
manifestData, err := json.Marshal(manifest)
Expect(err).NotTo(HaveOccurred())
manifestPath := filepath.Join(pluginDir, "manifest.json")
Expect(os.WriteFile(manifestPath, manifestData, 0600)).To(Succeed())
// Create fake WASM file (since plugin discovery checks for it)
wasmPath := filepath.Join(pluginDir, "plugin.wasm")
Expect(os.WriteFile(wasmPath, []byte("fake wasm content"), 0600)).To(Succeed())
return pluginDir
}
var _ = Describe("Plugin Permissions", func() {
var (
mgr *Manager
tempDir string
ctx context.Context
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
mgr = createManager()
tempDir = GinkgoT().TempDir()
})
Describe("Permission Enforcement in createRuntime", func() {
It("should only load services specified in permissions", func() {
// Test with limited permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
runtimeFunc := mgr.createRuntime("test-plugin", permissions)
// Create runtime to test service availability
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// The runtime was created successfully with the specified permissions
Expect(runtime).NotTo(BeNil())
// Note: The actual verification of which specific host functions are available
// would require introspecting the WASM runtime, which is complex.
// The key test is that the runtime creation succeeds with valid permissions.
})
It("should create runtime with empty permissions", func() {
permissions := schema.PluginManifestPermissions{}
runtimeFunc := mgr.createRuntime("empty-permissions-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Should succeed but with no host services available
Expect(runtime).NotTo(BeNil())
})
It("should handle all available permissions", func() {
// Test with all possible permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic tasks",
},
Websocket: &schema.PluginManifestPermissionsWebsocket{
Reason: "To handle real-time communication",
AllowedUrls: []string{"wss://api.example.com"},
AllowLocalNetwork: false,
},
Cache: &schema.PluginManifestPermissionsCache{
Reason: "To cache data and reduce API calls",
},
Artwork: &schema.PluginManifestPermissionsArtwork{
Reason: "To generate artwork URLs",
},
}
runtimeFunc := mgr.createRuntime("full-permissions-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
Expect(runtime).NotTo(BeNil())
})
})
Describe("Plugin Discovery with Permissions", func() {
BeforeEach(func() {
conf.Server.Plugins.Folder = tempDir
})
It("should discover plugin with valid permissions manifest", func() {
// Create plugin with http permission using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch metadata from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
},
}
createTestPlugin(tempDir, "valid-plugin", permissions)
// Scan for plugins
mgr.ScanPlugins()
// Verify plugin was discovered (even without valid WASM)
pluginNames := mgr.PluginNames("MetadataAgent")
Expect(pluginNames).To(ContainElement("valid-plugin"))
})
It("should discover plugin with no permissions", func() {
// Create plugin with empty permissions using typed structs
permissions := schema.PluginManifestPermissions{}
createTestPlugin(tempDir, "no-perms-plugin", permissions)
mgr.ScanPlugins()
pluginNames := mgr.PluginNames("MetadataAgent")
Expect(pluginNames).To(ContainElement("no-perms-plugin"))
})
It("should discover plugin with multiple permissions", func() {
// Create plugin with multiple permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch metadata from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read plugin configuration settings",
},
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic data updates",
},
}
createTestPlugin(tempDir, "multi-perms-plugin", permissions)
mgr.ScanPlugins()
pluginNames := mgr.PluginNames("MetadataAgent")
Expect(pluginNames).To(ContainElement("multi-perms-plugin"))
})
})
Describe("Existing Plugin Permissions", func() {
BeforeEach(func() {
// Use the testdata directory with updated plugins
conf.Server.Plugins.Folder = testDataDir
mgr.ScanPlugins()
})
It("should discover fake_scrobbler with empty permissions", func() {
scrobblerNames := mgr.PluginNames(CapabilityScrobbler)
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
})
It("should discover multi_plugin with scheduler permissions", func() {
agentNames := mgr.PluginNames(CapabilityMetadataAgent)
Expect(agentNames).To(ContainElement("multi_plugin"))
})
It("should discover all test plugins successfully", func() {
// All test plugins should be discovered with their updated permissions
testPlugins := []struct {
name string
capability string
}{
{"fake_album_agent", CapabilityMetadataAgent},
{"fake_artist_agent", CapabilityMetadataAgent},
{"fake_scrobbler", CapabilityScrobbler},
{"multi_plugin", CapabilityMetadataAgent},
{"fake_init_service", CapabilityLifecycleManagement},
}
for _, testPlugin := range testPlugins {
pluginNames := mgr.PluginNames(testPlugin.capability)
Expect(pluginNames).To(ContainElement(testPlugin.name), "Plugin %s should be discovered", testPlugin.name)
}
})
})
Describe("Permission Validation", func() {
It("should enforce permissions are required in manifest", func() {
// Create a manifest JSON string without the permissions field
manifestContent := `{
"name": "test-plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": ["MetadataAgent"]
}`
manifestPath := filepath.Join(tempDir, "manifest.json")
err := os.WriteFile(manifestPath, []byte(manifestContent), 0600)
Expect(err).NotTo(HaveOccurred())
_, err = LoadManifest(tempDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("field permissions in PluginManifest: required"))
})
It("should allow unknown permission keys", func() {
// Create manifest with both known and unknown permission types
pluginDir := filepath.Join(tempDir, "unknown-perms")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
manifestContent := `{
"name": "unknown-perms",
"author": "Test Author",
"version": "1.0.0",
"description": "Manifest with unknown permissions",
"website": "https://test.navidrome.org/unknown-perms",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch data from external APIs",
"allowedUrls": {
"*": ["*"]
}
},
"unknown": {
"customField": "customValue"
}
}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
// Test manifest loading directly - should succeed even with unknown permissions
loadedManifest, err := LoadManifest(pluginDir)
Expect(err).NotTo(HaveOccurred())
Expect(loadedManifest).NotTo(BeNil())
// With typed permissions, we check the specific fields
Expect(loadedManifest.Permissions.Http).NotTo(BeNil())
Expect(loadedManifest.Permissions.Http.Reason).To(Equal("To fetch data from external APIs"))
// The key point is that the manifest loads successfully despite unknown permissions
// The actual handling of AdditionalProperties depends on the JSON schema implementation
})
})
Describe("Runtime Pool with Permissions", func() {
It("should create separate runtimes for different permission sets", func() {
// Create two different permission sets using typed structs
permissions1 := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
}
permissions2 := schema.PluginManifestPermissions{
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
runtimeFunc1 := mgr.createRuntime("plugin1", permissions1)
runtimeFunc2 := mgr.createRuntime("plugin2", permissions2)
runtime1, err1 := runtimeFunc1(ctx)
Expect(err1).NotTo(HaveOccurred())
defer runtime1.Close(ctx)
runtime2, err2 := runtimeFunc2(ctx)
Expect(err2).NotTo(HaveOccurred())
defer runtime2.Close(ctx)
// Should be different runtime instances
Expect(runtime1).NotTo(BeIdenticalTo(runtime2))
})
})
Describe("Permission System Integration", func() {
It("should successfully validate manifests with permissions", func() {
// Create a valid manifest with permissions
pluginDir := filepath.Join(tempDir, "valid-manifest")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
manifestContent := `{
"name": "valid-manifest",
"author": "Test Author",
"version": "1.0.0",
"description": "Valid manifest with permissions",
"website": "https://test.navidrome.org/valid-manifest",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch metadata from external APIs",
"allowedUrls": {
"*": ["*"]
}
},
"config": {
"reason": "To read plugin configuration settings"
}
}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
// Load the manifest - should succeed
manifest, err := LoadManifest(pluginDir)
Expect(err).NotTo(HaveOccurred())
Expect(manifest).NotTo(BeNil())
// With typed permissions, check the specific permission fields
Expect(manifest.Permissions.Http).NotTo(BeNil())
Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata from external APIs"))
Expect(manifest.Permissions.Config).NotTo(BeNil())
Expect(manifest.Permissions.Config.Reason).To(Equal("To read plugin configuration settings"))
})
It("should track which services are requested per plugin", func() {
// Test that different plugins can have different permission sets
permissions1 := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
permissions2 := schema.PluginManifestPermissions{
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic tasks",
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration for scheduler",
},
}
permissions3 := schema.PluginManifestPermissions{} // Empty permissions
createTestPlugin(tempDir, "plugin-with-http", permissions1)
createTestPlugin(tempDir, "plugin-with-scheduler", permissions2)
createTestPlugin(tempDir, "plugin-with-none", permissions3)
conf.Server.Plugins.Folder = tempDir
mgr.ScanPlugins()
// All should be discovered
pluginNames := mgr.PluginNames(CapabilityMetadataAgent)
Expect(pluginNames).To(ContainElement("plugin-with-http"))
Expect(pluginNames).To(ContainElement("plugin-with-scheduler"))
Expect(pluginNames).To(ContainElement("plugin-with-none"))
})
})
Describe("Runtime Service Access Control", func() {
It("should successfully create runtime with permitted services", func() {
// Create runtime with HTTP permission using typed struct
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
}
runtimeFunc := mgr.createRuntime("http-only-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should be created successfully - host functions are loaded during runtime creation
Expect(runtime).NotTo(BeNil())
})
It("should successfully create runtime with multiple permitted services", func() {
// Create runtime with multiple permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic tasks",
},
}
runtimeFunc := mgr.createRuntime("multi-service-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should be created successfully
Expect(runtime).NotTo(BeNil())
})
It("should create runtime with no services when no permissions granted", func() {
// Create runtime with empty permissions using typed struct
emptyPermissions := schema.PluginManifestPermissions{}
runtimeFunc := mgr.createRuntime("no-service-plugin", emptyPermissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should still be created, but with no host services
Expect(runtime).NotTo(BeNil())
})
It("should demonstrate secure-by-default behavior", func() {
// Test that default (empty permissions) provides no services
defaultPermissions := schema.PluginManifestPermissions{}
runtimeFunc := mgr.createRuntime("default-plugin", defaultPermissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should be created but with no host services
Expect(runtime).NotTo(BeNil())
})
It("should test permission enforcement by simulating unauthorized service access", func() {
// This test demonstrates that plugins would fail at runtime when trying to call
// host functions they don't have permission for, since those functions are simply
// not loaded into the WASM runtime environment.
// Create two different runtimes with different permissions using typed structs
httpOnlyPermissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
}
configOnlyPermissions := schema.PluginManifestPermissions{
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
httpRuntime, err := mgr.createRuntime("http-only", httpOnlyPermissions)(ctx)
Expect(err).NotTo(HaveOccurred())
defer httpRuntime.Close(ctx)
configRuntime, err := mgr.createRuntime("config-only", configOnlyPermissions)(ctx)
Expect(err).NotTo(HaveOccurred())
defer configRuntime.Close(ctx)
// Both runtimes should be created successfully, but they will have different
// sets of host functions available. A plugin trying to call unauthorized
// functions would get "function not found" errors during instantiation or execution.
Expect(httpRuntime).NotTo(BeNil())
Expect(configRuntime).NotTo(BeNil())
})
})
})
+144
View File
@@ -0,0 +1,144 @@
package plugins
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Plugin Manifest", func() {
var tempDir string
BeforeEach(func() {
tempDir = GinkgoT().TempDir()
})
It("should load and parse a valid manifest", func() {
manifestPath := filepath.Join(tempDir, "manifest.json")
manifestContent := []byte(`{
"name": "test-plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": ["MetadataAgent", "Scrobbler"],
"permissions": {
"http": {
"reason": "To fetch metadata",
"allowedUrls": {
"https://api.example.com/*": ["GET"]
}
}
}
}`)
err := os.WriteFile(manifestPath, manifestContent, 0600)
Expect(err).NotTo(HaveOccurred())
manifest, err := LoadManifest(tempDir)
Expect(err).NotTo(HaveOccurred())
Expect(manifest).NotTo(BeNil())
Expect(manifest.Name).To(Equal("test-plugin"))
Expect(manifest.Author).To(Equal("Test Author"))
Expect(manifest.Version).To(Equal("1.0.0"))
Expect(manifest.Description).To(Equal("A test plugin"))
Expect(manifest.Capabilities).To(HaveLen(2))
Expect(manifest.Capabilities[0]).To(Equal(schema.PluginManifestCapabilitiesElemMetadataAgent))
Expect(manifest.Capabilities[1]).To(Equal(schema.PluginManifestCapabilitiesElemScrobbler))
Expect(manifest.Permissions.Http).NotTo(BeNil())
Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata"))
})
It("should fail with proper error for non-existent manifest", func() {
_, err := LoadManifest(filepath.Join(tempDir, "non-existent"))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to read manifest file"))
})
It("should fail with JSON parse error for invalid JSON", func() {
// Create invalid JSON
invalidJSON := `{
"name": "test-plugin",
"author": "Test Author"
"version": "1.0.0"
"description": "A test plugin",
"capabilities": ["MetadataAgent"],
"permissions": {}
}`
pluginDir := filepath.Join(tempDir, "invalid-json")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidJSON), 0600)).To(Succeed())
// Test validation fails
_, err := LoadManifest(pluginDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid manifest"))
})
It("should validate manifest against schema with detailed error for missing required field", func() {
// Create manifest missing required name field
manifestContent := `{
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": ["MetadataAgent"],
"permissions": {}
}`
pluginDir := filepath.Join(tempDir, "test-plugin")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
_, err := LoadManifest(pluginDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("field name in PluginManifest: required"))
})
It("should validate manifest with wrong capability type", func() {
// Create manifest with invalid capability
manifestContent := `{
"name": "test-plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": ["UnsupportedService"],
"permissions": {}
}`
pluginDir := filepath.Join(tempDir, "test-plugin")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
_, err := LoadManifest(pluginDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid value"))
Expect(err.Error()).To(ContainSubstring("UnsupportedService"))
})
It("should validate manifest with empty capabilities array", func() {
// Create manifest with empty capabilities array
manifestContent := `{
"name": "test-plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": [],
"permissions": {}
}`
pluginDir := filepath.Join(tempDir, "test-plugin")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
_, err := LoadManifest(pluginDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("field capabilities length: must be >= 1"))
})
})
+177
View File
@@ -0,0 +1,177 @@
package plugins
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/plugins/schema"
)
// PluginPackage represents a Navidrome Plugin Package (.ndp file)
type PluginPackage struct {
ManifestJSON []byte
Manifest *schema.PluginManifest
WasmBytes []byte
Docs map[string][]byte
}
// ExtractPackage extracts a .ndp file to the target directory
func ExtractPackage(ndpPath, targetDir string) error {
r, err := zip.OpenReader(ndpPath)
if err != nil {
return fmt.Errorf("error opening .ndp file: %w", err)
}
defer r.Close()
// Create target directory if it doesn't exist
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("error creating plugin directory: %w", err)
}
// Define a reasonable size limit for plugin files to prevent decompression bombs
const maxFileSize = 10 * 1024 * 1024 // 10 MB limit
// Extract all files from the zip
for _, f := range r.File {
// Skip directories (they will be created as needed)
if f.FileInfo().IsDir() {
continue
}
// Create the file path for extraction
// Validate the file name to prevent directory traversal or absolute paths
if strings.Contains(f.Name, "..") || filepath.IsAbs(f.Name) {
return fmt.Errorf("illegal file path in plugin package: %s", f.Name)
}
// Create the file path for extraction
targetPath := filepath.Join(targetDir, f.Name) // #nosec G305
// Clean the path to prevent directory traversal.
cleanedPath := filepath.Clean(targetPath)
// Ensure the cleaned path is still within the target directory.
// We resolve both paths to absolute paths to be sure.
absTargetDir, err := filepath.Abs(targetDir)
if err != nil {
return fmt.Errorf("failed to resolve target directory path: %w", err)
}
absTargetPath, err := filepath.Abs(cleanedPath)
if err != nil {
return fmt.Errorf("failed to resolve extracted file path: %w", err)
}
if !strings.HasPrefix(absTargetPath, absTargetDir+string(os.PathSeparator)) && absTargetPath != absTargetDir {
return fmt.Errorf("illegal file path in plugin package: %s", f.Name)
}
// Open the file inside the zip
rc, err := f.Open()
if err != nil {
return fmt.Errorf("error opening file in plugin package: %w", err)
}
// Create parent directories if they don't exist
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
rc.Close()
return fmt.Errorf("error creating directory structure: %w", err)
}
// Create the file
outFile, err := os.Create(targetPath)
if err != nil {
rc.Close()
return fmt.Errorf("error creating extracted file: %w", err)
}
// Copy the file contents with size limit
if _, err := io.CopyN(outFile, rc, maxFileSize); err != nil && !errors.Is(err, io.EOF) {
outFile.Close()
rc.Close()
if errors.Is(err, io.ErrUnexpectedEOF) { // File size exceeds limit
return fmt.Errorf("error extracting file: size exceeds limit (%d bytes) for %s", maxFileSize, f.Name)
}
return fmt.Errorf("error writing extracted file: %w", err)
}
outFile.Close()
rc.Close()
// Set appropriate file permissions (0600 - readable only by owner)
if err := os.Chmod(targetPath, 0600); err != nil {
return fmt.Errorf("error setting permissions on extracted file: %w", err)
}
}
return nil
}
// LoadPackage loads and validates an .ndp file without extracting it
func LoadPackage(ndpPath string) (*PluginPackage, error) {
r, err := zip.OpenReader(ndpPath)
if err != nil {
return nil, fmt.Errorf("error opening .ndp file: %w", err)
}
defer r.Close()
pkg := &PluginPackage{
Docs: make(map[string][]byte),
}
// Required files
var hasManifest, hasWasm bool
// Read all files in the zip
for _, f := range r.File {
// Skip directories
if f.FileInfo().IsDir() {
continue
}
// Get file content
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("error opening file in plugin package: %w", err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("error reading file in plugin package: %w", err)
}
// Process based on file name
switch strings.ToLower(f.Name) {
case "manifest.json":
pkg.ManifestJSON = content
hasManifest = true
case "plugin.wasm":
pkg.WasmBytes = content
hasWasm = true
default:
// Store other files as documentation
pkg.Docs[f.Name] = content
}
}
// Ensure required files exist
if !hasManifest {
return nil, fmt.Errorf("plugin package missing required manifest.json")
}
if !hasWasm {
return nil, fmt.Errorf("plugin package missing required plugin.wasm")
}
// Parse and validate the manifest
var manifest schema.PluginManifest
if err := json.Unmarshal(pkg.ManifestJSON, &manifest); err != nil {
return nil, fmt.Errorf("invalid manifest: %w", err)
}
pkg.Manifest = &manifest
return pkg, nil
}
+116
View File
@@ -0,0 +1,116 @@
package plugins
import (
"archive/zip"
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Plugin Package", func() {
var tempDir string
var ndpPath string
BeforeEach(func() {
tempDir = GinkgoT().TempDir()
// Create a test .ndp file
ndpPath = filepath.Join(tempDir, "test-plugin.ndp")
// Create the required plugin files
manifestContent := []byte(`{
"name": "test-plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": ["MetadataAgent"],
"permissions": {}
}`)
wasmContent := []byte("dummy wasm content")
readmeContent := []byte("# Test Plugin\nThis is a test plugin")
// Create the zip file
zipFile, err := os.Create(ndpPath)
Expect(err).NotTo(HaveOccurred())
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// Add manifest.json
manifestWriter, err := zipWriter.Create("manifest.json")
Expect(err).NotTo(HaveOccurred())
_, err = manifestWriter.Write(manifestContent)
Expect(err).NotTo(HaveOccurred())
// Add plugin.wasm
wasmWriter, err := zipWriter.Create("plugin.wasm")
Expect(err).NotTo(HaveOccurred())
_, err = wasmWriter.Write(wasmContent)
Expect(err).NotTo(HaveOccurred())
// Add README.md
readmeWriter, err := zipWriter.Create("README.md")
Expect(err).NotTo(HaveOccurred())
_, err = readmeWriter.Write(readmeContent)
Expect(err).NotTo(HaveOccurred())
})
It("should load and validate a plugin package", func() {
pkg, err := LoadPackage(ndpPath)
Expect(err).NotTo(HaveOccurred())
Expect(pkg).NotTo(BeNil())
// Check manifest was parsed
Expect(pkg.Manifest).NotTo(BeNil())
Expect(pkg.Manifest.Name).To(Equal("test-plugin"))
Expect(pkg.Manifest.Author).To(Equal("Test Author"))
Expect(pkg.Manifest.Version).To(Equal("1.0.0"))
Expect(pkg.Manifest.Description).To(Equal("A test plugin"))
Expect(pkg.Manifest.Capabilities).To(HaveLen(1))
Expect(pkg.Manifest.Capabilities[0]).To(Equal(schema.PluginManifestCapabilitiesElemMetadataAgent))
// Check WASM file was loaded
Expect(pkg.WasmBytes).NotTo(BeEmpty())
// Check docs were loaded
Expect(pkg.Docs).To(HaveKey("README.md"))
})
It("should extract a plugin package to a directory", func() {
targetDir := filepath.Join(tempDir, "extracted")
err := ExtractPackage(ndpPath, targetDir)
Expect(err).NotTo(HaveOccurred())
// Check files were extracted
Expect(filepath.Join(targetDir, "manifest.json")).To(BeARegularFile())
Expect(filepath.Join(targetDir, "plugin.wasm")).To(BeARegularFile())
Expect(filepath.Join(targetDir, "README.md")).To(BeARegularFile())
})
It("should fail to load an invalid package", func() {
// Create an invalid package (missing required files)
invalidPath := filepath.Join(tempDir, "invalid.ndp")
zipFile, err := os.Create(invalidPath)
Expect(err).NotTo(HaveOccurred())
zipWriter := zip.NewWriter(zipFile)
// Only add a README, missing manifest and wasm
readmeWriter, err := zipWriter.Create("README.md")
Expect(err).NotTo(HaveOccurred())
_, err = readmeWriter.Write([]byte("Invalid package"))
Expect(err).NotTo(HaveOccurred())
zipWriter.Close()
zipFile.Close()
// Test loading fails
_, err = LoadPackage(invalidPath)
Expect(err).To(HaveOccurred())
})
})
+86
View File
@@ -0,0 +1,86 @@
package plugins
import (
"context"
"maps"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/api"
)
// pluginLifecycleManager tracks which plugins have been initialized and manages their lifecycle
type pluginLifecycleManager struct {
plugins sync.Map // string -> bool
config map[string]map[string]string
}
// newPluginLifecycleManager creates a new plugin lifecycle manager
func newPluginLifecycleManager() *pluginLifecycleManager {
config := maps.Clone(conf.Server.PluginConfig)
return &pluginLifecycleManager{
config: config,
}
}
// isInitialized checks if a plugin has been initialized
func (m *pluginLifecycleManager) isInitialized(plugin *plugin) bool {
key := plugin.ID + consts.Zwsp + plugin.Manifest.Version
value, exists := m.plugins.Load(key)
return exists && value.(bool)
}
// markInitialized marks a plugin as initialized
func (m *pluginLifecycleManager) markInitialized(plugin *plugin) {
key := plugin.ID + consts.Zwsp + plugin.Manifest.Version
m.plugins.Store(key, true)
}
// callOnInit calls the OnInit method on a plugin that implements LifecycleManagement
func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
ctx := context.Background()
log.Debug("Initializing plugin", "name", plugin.ID)
start := time.Now()
// Create LifecycleManagement plugin instance
loader, err := api.NewLifecycleManagementPlugin(ctx, api.WazeroRuntime(plugin.Runtime), api.WazeroModuleConfig(plugin.ModConfig))
if loader == nil || err != nil {
log.Error("Error creating LifecycleManagement plugin", "plugin", plugin.ID, err)
return
}
initPlugin, err := loader.Load(ctx, plugin.WasmPath)
if err != nil {
log.Error("Error loading LifecycleManagement plugin", "plugin", plugin.ID, "path", plugin.WasmPath, err)
return
}
defer initPlugin.Close(ctx)
// Prepare the request with plugin-specific configuration
req := &api.InitRequest{}
// Add plugin configuration if available
if m.config != nil {
if pluginConfig, ok := m.config[plugin.ID]; ok && len(pluginConfig) > 0 {
req.Config = maps.Clone(pluginConfig)
log.Debug("Passing configuration to plugin", "plugin", plugin.ID, "configKeys", len(pluginConfig))
}
}
// Call OnInit
resp, err := initPlugin.OnInit(ctx, req)
if err != nil {
log.Error("Error initializing plugin", "plugin", plugin.ID, "elapsed", time.Since(start), err)
return
}
if resp.Error != "" {
log.Error("Plugin reported error during initialization", "plugin", plugin.ID, "error", resp.Error)
return
}
log.Debug("Plugin initialized successfully", "plugin", plugin.ID, "elapsed", time.Since(start))
}
+144
View File
@@ -0,0 +1,144 @@
package plugins
import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Helper function to check if a plugin implements LifecycleManagement
func hasInitService(info *plugin) bool {
for _, c := range info.Capabilities {
if c == CapabilityLifecycleManagement {
return true
}
}
return false
}
var _ = Describe("LifecycleManagement", func() {
Describe("Plugin Lifecycle Manager", func() {
var lifecycleManager *pluginLifecycleManager
BeforeEach(func() {
lifecycleManager = newPluginLifecycleManager()
})
It("should track initialization state of plugins", func() {
// Create test plugins
plugin1 := &plugin{
ID: "test-plugin",
Capabilities: []string{CapabilityLifecycleManagement},
Manifest: &schema.PluginManifest{
Version: "1.0.0",
},
}
plugin2 := &plugin{
ID: "another-plugin",
Capabilities: []string{CapabilityLifecycleManagement},
Manifest: &schema.PluginManifest{
Version: "0.5.0",
},
}
// Initially, no plugins should be initialized
Expect(lifecycleManager.isInitialized(plugin1)).To(BeFalse())
Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse())
// Mark first plugin as initialized
lifecycleManager.markInitialized(plugin1)
// Check state
Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue())
Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse())
// Mark second plugin as initialized
lifecycleManager.markInitialized(plugin2)
// Both should be initialized now
Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue())
Expect(lifecycleManager.isInitialized(plugin2)).To(BeTrue())
})
It("should handle plugins with same name but different versions", func() {
plugin1 := &plugin{
ID: "test-plugin",
Capabilities: []string{CapabilityLifecycleManagement},
Manifest: &schema.PluginManifest{
Version: "1.0.0",
},
}
plugin2 := &plugin{
ID: "test-plugin", // Same name
Capabilities: []string{CapabilityLifecycleManagement},
Manifest: &schema.PluginManifest{
Version: "2.0.0", // Different version
},
}
// Mark v1 as initialized
lifecycleManager.markInitialized(plugin1)
// v1 should be initialized but not v2
Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue())
Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse())
// Mark v2 as initialized
lifecycleManager.markInitialized(plugin2)
// Both versions should be initialized now
Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue())
Expect(lifecycleManager.isInitialized(plugin2)).To(BeTrue())
// Verify the keys used for tracking
key1 := plugin1.ID + consts.Zwsp + plugin1.Manifest.Version
key2 := plugin1.ID + consts.Zwsp + plugin2.Manifest.Version
_, exists1 := lifecycleManager.plugins.Load(key1)
_, exists2 := lifecycleManager.plugins.Load(key2)
Expect(exists1).To(BeTrue())
Expect(exists2).To(BeTrue())
Expect(key1).NotTo(Equal(key2))
})
It("should only consider plugins that implement LifecycleManagement", func() {
// Plugin that implements LifecycleManagement
initPlugin := &plugin{
ID: "init-plugin",
Capabilities: []string{CapabilityLifecycleManagement},
Manifest: &schema.PluginManifest{
Version: "1.0.0",
},
}
// Plugin that doesn't implement LifecycleManagement
regularPlugin := &plugin{
ID: "regular-plugin",
Capabilities: []string{"MetadataAgent"},
Manifest: &schema.PluginManifest{
Version: "1.0.0",
},
}
// Check if plugins can be initialized
Expect(hasInitService(initPlugin)).To(BeTrue())
Expect(hasInitService(regularPlugin)).To(BeFalse())
})
It("should properly construct the plugin key", func() {
plugin := &plugin{
ID: "test-plugin",
Manifest: &schema.PluginManifest{
Version: "1.0.0",
},
}
expectedKey := "test-plugin" + consts.Zwsp + "1.0.0"
actualKey := plugin.ID + consts.Zwsp + plugin.Manifest.Version
Expect(actualKey).To(Equal(expectedKey))
})
})
})
+32
View File
@@ -0,0 +1,32 @@
package plugins
import (
"os/exec"
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const testDataDir = "plugins/testdata"
func TestPlugins(t *testing.T) {
tests.Init(t, false)
buildTestPlugins(t, testDataDir)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}
func buildTestPlugins(t *testing.T, path string) {
t.Helper()
t.Logf("[BeforeSuite] Current working directory: %s", path)
cmd := exec.Command("make", "-C", path)
out, err := cmd.CombinedOutput()
t.Logf("[BeforeSuite] Make output: %s", string(out))
if err != nil {
t.Fatalf("Failed to build test plugins: %v", err)
}
}
+602
View File
@@ -0,0 +1,602 @@
package plugins
import (
"context"
"crypto/md5"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/dustin/go-humanize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"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/plugins/schema"
"github.com/tetratelabs/wazero"
wazeroapi "github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
const maxParallelCompilations = 2 // Limit to 2 concurrent compilations
var (
compileSemaphore = make(chan struct{}, maxParallelCompilations)
compilationCache wazero.CompilationCache
cacheOnce sync.Once
runtimePool sync.Map // map[string]*cachingRuntime
)
// createRuntime returns a function that creates a new wazero runtime and instantiates the required host functions
// based on the given plugin permissions
func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime {
return func(ctx context.Context) (wazero.Runtime, error) {
// Check if runtime already exists
if rt, ok := runtimePool.Load(pluginID); ok {
log.Trace(ctx, "Using existing runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", rt))
// Return a new wrapper for each call, so each instance gets its own module capture
return newScopedRuntime(rt.(wazero.Runtime)), nil
}
// Create new runtime with all the setup
cachingRT, err := m.createCachingRuntime(ctx, pluginID, permissions)
if err != nil {
return nil, err
}
// Use LoadOrStore to atomically check and store, preventing race conditions
if existing, loaded := runtimePool.LoadOrStore(pluginID, cachingRT); loaded {
// Another goroutine created the runtime first, close ours and return the existing one
log.Trace(ctx, "Race condition detected, using existing runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", existing))
_ = cachingRT.Close(ctx)
return newScopedRuntime(existing.(wazero.Runtime)), nil
}
log.Trace(ctx, "Created new runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", cachingRT))
return newScopedRuntime(cachingRT), nil
}
}
// createCachingRuntime handles the complex logic of setting up a new cachingRuntime
func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) {
// Get compilation cache
compCache, err := getCompilationCache()
if err != nil {
return nil, fmt.Errorf("failed to get compilation cache: %w", err)
}
// Create the runtime
runtimeConfig := wazero.NewRuntimeConfig().WithCompilationCache(compCache)
r := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
return nil, err
}
// Setup host services
if err := m.setupHostServices(ctx, r, pluginID, permissions); err != nil {
_ = r.Close(ctx)
return nil, err
}
return newCachingRuntime(r, pluginID), nil
}
// setupHostServices configures all the permitted host services for a plugin
func (m *Manager) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error {
// Define all available host services
type hostService struct {
name string
isPermitted bool
loadFunc func() (map[string]wazeroapi.FunctionDefinition, error)
}
// List of all available host services with their permissions and loading functions
availableServices := []hostService{
{"config", permissions.Config != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
return loadHostLibrary[config.ConfigService](ctx, config.Instantiate, &configServiceImpl{pluginID: pluginID})
}},
{"scheduler", permissions.Scheduler != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
return loadHostLibrary[scheduler.SchedulerService](ctx, scheduler.Instantiate, m.schedulerService.HostFunctions(pluginID))
}},
{"cache", permissions.Cache != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
return loadHostLibrary[cache.CacheService](ctx, cache.Instantiate, newCacheService(pluginID))
}},
{"artwork", permissions.Artwork != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
return loadHostLibrary[artwork.ArtworkService](ctx, artwork.Instantiate, &artworkServiceImpl{})
}},
{"http", permissions.Http != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
httpPerms, err := parseHTTPPermissions(permissions.Http)
if err != nil {
return nil, fmt.Errorf("invalid http permissions for plugin %s: %w", pluginID, err)
}
return loadHostLibrary[http.HttpService](ctx, http.Instantiate, &httpServiceImpl{
pluginID: pluginID,
permissions: httpPerms,
})
}},
{"websocket", permissions.Websocket != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
wsPerms, err := parseWebSocketPermissions(permissions.Websocket)
if err != nil {
return nil, fmt.Errorf("invalid websocket permissions for plugin %s: %w", pluginID, err)
}
return loadHostLibrary[websocket.WebSocketService](ctx, websocket.Instantiate, m.websocketService.HostFunctions(pluginID, wsPerms))
}},
}
// Load only permitted services
var grantedPermissions []string
var libraries []map[string]wazeroapi.FunctionDefinition
for _, service := range availableServices {
if service.isPermitted {
lib, err := service.loadFunc()
if err != nil {
return fmt.Errorf("error loading %s lib: %w", service.name, err)
}
libraries = append(libraries, lib)
grantedPermissions = append(grantedPermissions, service.name)
}
}
log.Trace(ctx, "Granting permissions for plugin", "plugin", pluginID, "permissions", grantedPermissions)
// Combine the permitted libraries
return combineLibraries(ctx, r, libraries...)
}
// purgeCacheBySize removes the oldest files in dir until its total size is
// lower than or equal to maxSize. maxSize should be a human-readable string
// like "10MB" or "200K". If parsing fails or maxSize is "0", the function is
// a no-op.
func purgeCacheBySize(dir, maxSize string) {
sizeLimit, err := humanize.ParseBytes(maxSize)
if err != nil || sizeLimit == 0 {
return
}
type fileInfo struct {
path string
size uint64
mod int64
}
var files []fileInfo
var total uint64
walk := func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Trace("Failed to access plugin cache entry", "path", path, err)
return nil //nolint:nilerr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
log.Trace("Failed to get file info for plugin cache entry", "path", path, err)
return nil //nolint:nilerr
}
files = append(files, fileInfo{
path: path,
size: uint64(info.Size()),
mod: info.ModTime().UnixMilli(),
})
total += uint64(info.Size())
return nil
}
if err := filepath.WalkDir(dir, walk); err != nil {
if !os.IsNotExist(err) {
log.Warn("Failed to traverse plugin cache directory", "path", dir, err)
}
return
}
log.Trace("Current plugin cache size", "path", dir, "size", humanize.Bytes(total), "sizeLimit", humanize.Bytes(sizeLimit))
if total <= sizeLimit {
return
}
log.Debug("Purging plugin cache", "path", dir, "sizeLimit", humanize.Bytes(sizeLimit), "currentSize", humanize.Bytes(total))
sort.Slice(files, func(i, j int) bool { return files[i].mod < files[j].mod })
for _, f := range files {
if total <= sizeLimit {
break
}
if err := os.Remove(f.path); err != nil {
log.Warn("Failed to remove plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), err)
continue
}
total -= f.size
log.Debug("Removed plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), "time", time.UnixMilli(f.mod), "remainingSize", humanize.Bytes(total))
// Remove empty parent directories
dirPath := filepath.Dir(f.path)
for dirPath != dir {
if err := os.Remove(dirPath); err != nil {
break
}
dirPath = filepath.Dir(dirPath)
}
}
}
// getCompilationCache returns the global compilation cache, creating it if necessary
func getCompilationCache() (wazero.CompilationCache, error) {
var err error
cacheOnce.Do(func() {
cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins")
purgeCacheBySize(cacheDir, conf.Server.Plugins.CacheSize)
compilationCache, err = wazero.NewCompilationCacheWithDir(cacheDir)
})
return compilationCache, err
}
// newWazeroModuleConfig creates the correct ModuleConfig for plugins
func newWazeroModuleConfig() wazero.ModuleConfig {
return wazero.NewModuleConfig().WithStartFunctions("_initialize").WithStderr(log.Writer())
}
// pluginCompilationTimeout returns the timeout for plugin compilation
func pluginCompilationTimeout() time.Duration {
if conf.Server.DevPluginCompilationTimeout > 0 {
return conf.Server.DevPluginCompilationTimeout
}
return time.Minute
}
// precompilePlugin compiles the WASM module in the background and updates the pluginState.
func precompilePlugin(p *plugin) {
compileSemaphore <- struct{}{}
defer func() { <-compileSemaphore }()
ctx := context.Background()
r, err := p.Runtime(ctx)
if err != nil {
p.compilationErr = fmt.Errorf("failed to create runtime for plugin %s: %w", p.ID, err)
close(p.compilationReady)
return
}
b, err := os.ReadFile(p.WasmPath)
if err != nil {
p.compilationErr = fmt.Errorf("failed to read wasm file: %w", err)
close(p.compilationReady)
return
}
// We know r is always a *scopedRuntime from createRuntime
scopedRT := r.(*scopedRuntime)
cachingRT := scopedRT.GetCachingRuntime()
if cachingRT == nil {
p.compilationErr = fmt.Errorf("failed to get cachingRuntime for plugin %s", p.ID)
close(p.compilationReady)
return
}
_, err = cachingRT.CompileModule(ctx, b)
if err != nil {
p.compilationErr = fmt.Errorf("failed to compile WASM for plugin %s: %w", p.ID, err)
log.Warn("Plugin compilation failed", "name", p.ID, "path", p.WasmPath, "err", err)
} else {
p.compilationErr = nil
log.Debug("Plugin compilation completed", "name", p.ID, "path", p.WasmPath)
}
close(p.compilationReady)
}
// loadHostLibrary loads the given host library and returns its exported functions
func loadHostLibrary[S any](
ctx context.Context,
instantiateFn func(context.Context, wazero.Runtime, S) error,
service S,
) (map[string]wazeroapi.FunctionDefinition, error) {
r := wazero.NewRuntime(ctx)
if err := instantiateFn(ctx, r, service); err != nil {
return nil, err
}
m := r.Module("env")
return m.ExportedFunctionDefinitions(), nil
}
// combineLibraries combines the given host libraries into a single "env" module
func combineLibraries(ctx context.Context, r wazero.Runtime, libs ...map[string]wazeroapi.FunctionDefinition) error {
// Merge the libraries
hostLib := map[string]wazeroapi.FunctionDefinition{}
for _, lib := range libs {
maps.Copy(hostLib, lib)
}
// Create the combined host module
envBuilder := r.NewHostModuleBuilder("env")
for name, fd := range hostLib {
fn, ok := fd.GoFunction().(wazeroapi.GoModuleFunction)
if !ok {
return fmt.Errorf("invalid function definition: %s", fd.DebugName())
}
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(fn, fd.ParamTypes(), fd.ResultTypes()).
WithParameterNames(fd.ParamNames()...).Export(name)
}
// Instantiate the combined host module
if _, err := envBuilder.Instantiate(ctx); err != nil {
return err
}
return nil
}
const (
// WASM Instance pool configuration
// defaultPoolSize is the maximum number of instances per plugin that are kept in the pool for reuse
defaultPoolSize = 8
// defaultInstanceTTL is the time after which an instance is considered stale and can be evicted
defaultInstanceTTL = time.Minute
// defaultMaxConcurrentInstances is the hard limit on total instances that can exist simultaneously
defaultMaxConcurrentInstances = 10
// defaultGetTimeout is the maximum time to wait when getting an instance if at the concurrent limit
defaultGetTimeout = 5 * time.Second
// Compiled module cache configuration
// defaultCompiledModuleTTL is the time after which a compiled module is evicted from the cache
defaultCompiledModuleTTL = 5 * time.Minute
)
// cachedCompiledModule encapsulates a compiled WebAssembly module with TTL management
type cachedCompiledModule struct {
module wazero.CompiledModule
hash [16]byte
lastAccess time.Time
timer *time.Timer
mu sync.Mutex
pluginID string // for logging purposes
}
// newCachedCompiledModule creates a new cached compiled module with TTL management
func newCachedCompiledModule(module wazero.CompiledModule, wasmBytes []byte, pluginID string) *cachedCompiledModule {
c := &cachedCompiledModule{
module: module,
hash: md5.Sum(wasmBytes),
lastAccess: time.Now(),
pluginID: pluginID,
}
// Set up the TTL timer
c.timer = time.AfterFunc(defaultCompiledModuleTTL, c.evict)
return c
}
// get returns the cached module if the hash matches, nil otherwise
// Also resets the TTL timer on successful access
func (c *cachedCompiledModule) get(wasmHash [16]byte) wazero.CompiledModule {
c.mu.Lock() // Use write lock because we modify state in resetTimer
defer c.mu.Unlock()
if c.module != nil && c.hash == wasmHash {
// Reset TTL timer on access
c.resetTimer()
return c.module
}
return nil
}
// resetTimer resets the TTL timer (must be called with lock held)
func (c *cachedCompiledModule) resetTimer() {
c.lastAccess = time.Now()
if c.timer != nil {
c.timer.Stop()
c.timer = time.AfterFunc(defaultCompiledModuleTTL, c.evict)
}
}
// evict removes the cached module and cleans up resources
func (c *cachedCompiledModule) evict() {
c.mu.Lock()
defer c.mu.Unlock()
if c.module != nil {
log.Trace("cachedCompiledModule: evicting due to TTL expiry", "plugin", c.pluginID, "ttl", defaultCompiledModuleTTL)
c.module.Close(context.Background())
c.module = nil
c.hash = [16]byte{}
c.lastAccess = time.Time{}
}
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
}
// close cleans up the cached module and stops the timer
func (c *cachedCompiledModule) close(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
if c.module != nil {
c.module.Close(ctx)
c.module = nil
}
}
// pooledModule wraps a wazero Module and returns it to the pool when closed.
type pooledModule struct {
wazeroapi.Module
pool *wasmInstancePool[wazeroapi.Module]
closed bool
}
func (m *pooledModule) Close(ctx context.Context) error {
if !m.closed {
m.closed = true
m.pool.Put(ctx, m.Module)
}
return nil
}
func (m *pooledModule) CloseWithExitCode(ctx context.Context, exitCode uint32) error {
return m.Close(ctx)
}
func (m *pooledModule) IsClosed() bool {
return m.closed
}
// newScopedRuntime creates a new scopedRuntime that wraps the given runtime
func newScopedRuntime(runtime wazero.Runtime) *scopedRuntime {
return &scopedRuntime{Runtime: runtime}
}
// scopedRuntime wraps a cachingRuntime and captures a specific module
// so that Close() only affects that module, not the entire shared runtime
type scopedRuntime struct {
wazero.Runtime
capturedModule wazeroapi.Module
}
func (w *scopedRuntime) InstantiateModule(ctx context.Context, code wazero.CompiledModule, config wazero.ModuleConfig) (wazeroapi.Module, error) {
module, err := w.Runtime.InstantiateModule(ctx, code, config)
if err != nil {
return nil, err
}
// Capture the module for later cleanup
w.capturedModule = module
log.Trace(ctx, "scopedRuntime: captured module", "moduleID", getInstanceID(module))
return module, nil
}
func (w *scopedRuntime) Close(ctx context.Context) error {
// Close only the captured module, not the entire runtime
if w.capturedModule != nil {
log.Trace(ctx, "scopedRuntime: closing captured module", "moduleID", getInstanceID(w.capturedModule))
return w.capturedModule.Close(ctx)
}
log.Trace(ctx, "scopedRuntime: no captured module to close")
return nil
}
func (w *scopedRuntime) CloseWithExitCode(ctx context.Context, exitCode uint32) error {
return w.Close(ctx)
}
// GetCachingRuntime returns the underlying cachingRuntime for internal use
func (w *scopedRuntime) GetCachingRuntime() *cachingRuntime {
if cr, ok := w.Runtime.(*cachingRuntime); ok {
return cr
}
return nil
}
// cachingRuntime wraps wazero.Runtime and pools module instances per plugin,
// while also caching the compiled module in memory.
type cachingRuntime struct {
wazero.Runtime
// pluginID is required to differentiate between different plugins that use the same file to initialize their
// runtime. The runtime will serve as a singleton for all instances of a given plugin.
pluginID string
// cachedModule manages the compiled module cache with TTL
cachedModule atomic.Pointer[cachedCompiledModule]
// pool manages reusable module instances
pool *wasmInstancePool[wazeroapi.Module]
// poolInitOnce ensures the pool is initialized only once
poolInitOnce sync.Once
}
func newCachingRuntime(runtime wazero.Runtime, pluginID string) *cachingRuntime {
return &cachingRuntime{
Runtime: runtime,
pluginID: pluginID,
}
}
func (r *cachingRuntime) initPool(code wazero.CompiledModule, config wazero.ModuleConfig) {
r.poolInitOnce.Do(func() {
r.pool = newWasmInstancePool[wazeroapi.Module](r.pluginID, defaultPoolSize, defaultMaxConcurrentInstances, defaultGetTimeout, defaultInstanceTTL, func(ctx context.Context) (wazeroapi.Module, error) {
log.Trace(ctx, "cachingRuntime: creating new module instance", "plugin", r.pluginID)
return r.Runtime.InstantiateModule(ctx, code, config)
})
})
}
func (r *cachingRuntime) InstantiateModule(ctx context.Context, code wazero.CompiledModule, config wazero.ModuleConfig) (wazeroapi.Module, error) {
r.initPool(code, config)
mod, err := r.pool.Get(ctx)
if err != nil {
return nil, err
}
wrapped := &pooledModule{Module: mod, pool: r.pool}
log.Trace(ctx, "cachingRuntime: created wrapper for module", "plugin", r.pluginID, "underlyingModuleID", fmt.Sprintf("%p", mod), "wrapperID", fmt.Sprintf("%p", wrapped))
return wrapped, nil
}
func (r *cachingRuntime) Close(ctx context.Context) error {
log.Trace(ctx, "cachingRuntime: closing runtime", "plugin", r.pluginID)
// Clean up compiled module cache
if cached := r.cachedModule.Swap(nil); cached != nil {
cached.close(ctx)
}
// Close the instance pool
if r.pool != nil {
r.pool.Close(ctx)
}
// Close the underlying runtime
return r.Runtime.Close(ctx)
}
// setCachedModule stores a newly compiled module in the cache with TTL management
func (r *cachingRuntime) setCachedModule(module wazero.CompiledModule, wasmBytes []byte) {
newCached := newCachedCompiledModule(module, wasmBytes, r.pluginID)
// Replace old cached module and clean it up
if old := r.cachedModule.Swap(newCached); old != nil {
old.close(context.Background())
}
}
// CompileModule checks if the provided bytes match our cached hash and returns
// the cached compiled module if so, avoiding both file read and compilation.
func (r *cachingRuntime) CompileModule(ctx context.Context, wasmBytes []byte) (wazero.CompiledModule, error) {
incomingHash := md5.Sum(wasmBytes)
// Try to get from cache
if cached := r.cachedModule.Load(); cached != nil {
if module := cached.get(incomingHash); module != nil {
log.Trace(ctx, "cachingRuntime: using cached compiled module", "plugin", r.pluginID)
return module, nil
}
}
// Fall back to normal compilation for different bytes
log.Trace(ctx, "cachingRuntime: hash doesn't match cache, compiling normally", "plugin", r.pluginID)
module, err := r.Runtime.CompileModule(ctx, wasmBytes)
if err != nil {
return nil, err
}
// Cache the newly compiled module
r.setCachedModule(module, wasmBytes)
return module, nil
}
+171
View File
@@ -0,0 +1,171 @@
package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/tetratelabs/wazero"
)
var _ = Describe("Runtime", func() {
Describe("pluginCompilationTimeout", func() {
It("should use DevPluginCompilationTimeout config for plugin compilation timeout", func() {
originalTimeout := conf.Server.DevPluginCompilationTimeout
DeferCleanup(func() {
conf.Server.DevPluginCompilationTimeout = originalTimeout
})
conf.Server.DevPluginCompilationTimeout = 123 * time.Second
Expect(pluginCompilationTimeout()).To(Equal(123 * time.Second))
conf.Server.DevPluginCompilationTimeout = 0
Expect(pluginCompilationTimeout()).To(Equal(time.Minute))
})
})
})
var _ = Describe("CachingRuntime", func() {
var (
ctx context.Context
mgr *Manager
plugin *wasmScrobblerPlugin
)
BeforeEach(func() {
ctx = GinkgoT().Context()
mgr = createManager()
// Add permissions for the test plugin using typed struct
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "For testing HTTP functionality",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "For testing config functionality",
},
}
rtFunc := mgr.createRuntime("fake_scrobbler", permissions)
plugin = newWasmScrobblerPlugin(
filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"),
"fake_scrobbler",
rtFunc,
wazero.NewModuleConfig().WithStartFunctions("_initialize"),
).(*wasmScrobblerPlugin)
// runtime will be created on first plugin load
})
It("reuses module instances across calls", func() {
// First call to create the runtime and pool
_, done, err := plugin.getInstance(ctx, "first")
Expect(err).ToNot(HaveOccurred())
done()
val, ok := runtimePool.Load("fake_scrobbler")
Expect(ok).To(BeTrue())
cachingRT := val.(*cachingRuntime)
// Verify the pool exists and is initialized
Expect(cachingRT.pool).ToNot(BeNil())
// Test that multiple calls work without error (indicating pool reuse)
for i := 0; i < 5; i++ {
inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("call_%d", i))
Expect(err).ToNot(HaveOccurred())
Expect(inst).ToNot(BeNil())
done()
}
// Test concurrent access to verify pool handles concurrency
const numGoroutines = 3
errChan := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("concurrent_%d", id))
if err != nil {
errChan <- err
return
}
defer done()
// Verify we got a valid instance
if inst == nil {
errChan <- fmt.Errorf("got nil instance")
return
}
errChan <- nil
}(i)
}
// Check all goroutines succeeded
for i := 0; i < numGoroutines; i++ {
err := <-errChan
Expect(err).To(BeNil())
}
})
})
var _ = Describe("purgeCacheBySize", func() {
var tmpDir string
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "cache_test")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(os.RemoveAll, tmpDir)
})
It("removes oldest entries when above the size limit", func() {
oldDir := filepath.Join(tmpDir, "d1")
newDir := filepath.Join(tmpDir, "d2")
Expect(os.Mkdir(oldDir, 0700)).To(Succeed())
Expect(os.Mkdir(newDir, 0700)).To(Succeed())
oldFile := filepath.Join(oldDir, "old")
newFile := filepath.Join(newDir, "new")
Expect(os.WriteFile(oldFile, []byte("xx"), 0600)).To(Succeed())
Expect(os.WriteFile(newFile, []byte("xx"), 0600)).To(Succeed())
oldTime := time.Now().Add(-2 * time.Hour)
Expect(os.Chtimes(oldFile, oldTime, oldTime)).To(Succeed())
purgeCacheBySize(tmpDir, "3")
_, err := os.Stat(oldFile)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(oldDir)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(newFile)
Expect(err).ToNot(HaveOccurred())
})
It("does nothing when below the size limit", func() {
dir1 := filepath.Join(tmpDir, "a")
dir2 := filepath.Join(tmpDir, "b")
Expect(os.Mkdir(dir1, 0700)).To(Succeed())
Expect(os.Mkdir(dir2, 0700)).To(Succeed())
file1 := filepath.Join(dir1, "f1")
file2 := filepath.Join(dir2, "f2")
Expect(os.WriteFile(file1, []byte("x"), 0600)).To(Succeed())
Expect(os.WriteFile(file2, []byte("x"), 0600)).To(Succeed())
purgeCacheBySize(tmpDir, "10MB")
_, err := os.Stat(file1)
Expect(err).ToNot(HaveOccurred())
_, err = os.Stat(file2)
Expect(err).ToNot(HaveOccurred())
})
})
+178
View File
@@ -0,0 +1,178 @@
{
"$id": "navidrome://plugins/manifest",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Navidrome Plugin Manifest",
"description": "Schema for Navidrome Plugin manifest.json files",
"type": "object",
"required": [
"name",
"author",
"version",
"description",
"website",
"capabilities",
"permissions"
],
"properties": {
"name": {
"type": "string",
"description": "Name of the plugin"
},
"author": {
"type": "string",
"description": "Author or organization that created the plugin"
},
"version": {
"type": "string",
"description": "Plugin version using semantic versioning format"
},
"description": {
"type": "string",
"description": "A brief description of the plugin's functionality"
},
"website": {
"type": "string",
"format": "uri",
"description": "Website URL for the plugin or its documentation"
},
"capabilities": {
"type": "array",
"description": "List of capabilities implemented by this plugin",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"MetadataAgent",
"Scrobbler",
"SchedulerCallback",
"LifecycleManagement",
"WebSocketCallback"
]
}
},
"permissions": {
"type": "object",
"description": "Host services the plugin is allowed to access",
"additionalProperties": true,
"properties": {
"http": {
"allOf": [
{ "$ref": "#/$defs/basePermission" },
{
"type": "object",
"description": "HTTP service permissions",
"required": ["allowedUrls"],
"properties": {
"allowedUrls": {
"type": "object",
"description": "Map of URL patterns (e.g., 'https://api.example.com/*') to allowed HTTP methods. Redirect destinations must also be included.",
"additionalProperties": {
"type": "array",
"items": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"HEAD",
"OPTIONS",
"*"
]
},
"minItems": 1,
"uniqueItems": true
},
"minProperties": 1
},
"allowLocalNetwork": {
"type": "boolean",
"description": "Whether to allow requests to local/private network addresses",
"default": false
}
}
}
]
},
"config": {
"allOf": [
{ "$ref": "#/$defs/basePermission" },
{
"type": "object",
"description": "Configuration service permissions"
}
]
},
"scheduler": {
"allOf": [
{ "$ref": "#/$defs/basePermission" },
{
"type": "object",
"description": "Scheduler service permissions"
}
]
},
"websocket": {
"allOf": [
{ "$ref": "#/$defs/basePermission" },
{
"type": "object",
"description": "WebSocket service permissions",
"required": ["allowedUrls"],
"properties": {
"allowedUrls": {
"type": "array",
"description": "List of WebSocket URL patterns that the plugin is allowed to connect to",
"items": {
"type": "string",
"pattern": "^wss?://.*$"
},
"minItems": 1,
"uniqueItems": true
},
"allowLocalNetwork": {
"type": "boolean",
"description": "Whether to allow connections to local/private network addresses",
"default": false
}
}
}
]
},
"cache": {
"allOf": [
{ "$ref": "#/$defs/basePermission" },
{
"type": "object",
"description": "Cache service permissions"
}
]
},
"artwork": {
"allOf": [
{ "$ref": "#/$defs/basePermission" },
{
"type": "object",
"description": "Artwork service permissions"
}
]
}
}
}
},
"$defs": {
"basePermission": {
"type": "object",
"required": ["reason"],
"properties": {
"reason": {
"type": "string",
"minLength": 1,
"description": "Explanation of why this permission is needed"
}
},
"additionalProperties": false
}
}
}
+387
View File
@@ -0,0 +1,387 @@
// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
package schema
import "encoding/json"
import "fmt"
import "reflect"
type BasePermission struct {
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *BasePermission) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in BasePermission: required")
}
type Plain BasePermission
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = BasePermission(plain)
return nil
}
// Schema for Navidrome Plugin manifest.json files
type PluginManifest struct {
// Author or organization that created the plugin
Author string `json:"author" yaml:"author" mapstructure:"author"`
// List of capabilities implemented by this plugin
Capabilities []PluginManifestCapabilitiesElem `json:"capabilities" yaml:"capabilities" mapstructure:"capabilities"`
// A brief description of the plugin's functionality
Description string `json:"description" yaml:"description" mapstructure:"description"`
// Name of the plugin
Name string `json:"name" yaml:"name" mapstructure:"name"`
// Host services the plugin is allowed to access
Permissions PluginManifestPermissions `json:"permissions" yaml:"permissions" mapstructure:"permissions"`
// Plugin version using semantic versioning format
Version string `json:"version" yaml:"version" mapstructure:"version"`
// Website URL for the plugin or its documentation
Website string `json:"website" yaml:"website" mapstructure:"website"`
}
type PluginManifestCapabilitiesElem string
const PluginManifestCapabilitiesElemLifecycleManagement PluginManifestCapabilitiesElem = "LifecycleManagement"
const PluginManifestCapabilitiesElemMetadataAgent PluginManifestCapabilitiesElem = "MetadataAgent"
const PluginManifestCapabilitiesElemSchedulerCallback PluginManifestCapabilitiesElem = "SchedulerCallback"
const PluginManifestCapabilitiesElemScrobbler PluginManifestCapabilitiesElem = "Scrobbler"
const PluginManifestCapabilitiesElemWebSocketCallback PluginManifestCapabilitiesElem = "WebSocketCallback"
var enumValues_PluginManifestCapabilitiesElem = []interface{}{
"MetadataAgent",
"Scrobbler",
"SchedulerCallback",
"LifecycleManagement",
"WebSocketCallback",
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestCapabilitiesElem) UnmarshalJSON(value []byte) error {
var v string
if err := json.Unmarshal(value, &v); err != nil {
return err
}
var ok bool
for _, expected := range enumValues_PluginManifestCapabilitiesElem {
if reflect.DeepEqual(v, expected) {
ok = true
break
}
}
if !ok {
return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_PluginManifestCapabilitiesElem, v)
}
*j = PluginManifestCapabilitiesElem(v)
return nil
}
// Host services the plugin is allowed to access
type PluginManifestPermissions struct {
// Artwork corresponds to the JSON schema field "artwork".
Artwork *PluginManifestPermissionsArtwork `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"`
// Cache corresponds to the JSON schema field "cache".
Cache *PluginManifestPermissionsCache `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"`
// Config corresponds to the JSON schema field "config".
Config *PluginManifestPermissionsConfig `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"`
// Http corresponds to the JSON schema field "http".
Http *PluginManifestPermissionsHttp `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
// Scheduler corresponds to the JSON schema field "scheduler".
Scheduler *PluginManifestPermissionsScheduler `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"`
// Websocket corresponds to the JSON schema field "websocket".
Websocket *PluginManifestPermissionsWebsocket `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"`
AdditionalProperties interface{} `mapstructure:",remain"`
}
// Artwork service permissions
type PluginManifestPermissionsArtwork struct {
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsArtwork) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in PluginManifestPermissionsArtwork: required")
}
type Plain PluginManifestPermissionsArtwork
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = PluginManifestPermissionsArtwork(plain)
return nil
}
// Cache service permissions
type PluginManifestPermissionsCache struct {
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsCache) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in PluginManifestPermissionsCache: required")
}
type Plain PluginManifestPermissionsCache
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = PluginManifestPermissionsCache(plain)
return nil
}
// Configuration service permissions
type PluginManifestPermissionsConfig struct {
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsConfig) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in PluginManifestPermissionsConfig: required")
}
type Plain PluginManifestPermissionsConfig
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = PluginManifestPermissionsConfig(plain)
return nil
}
// HTTP service permissions
type PluginManifestPermissionsHttp struct {
// Whether to allow requests to local/private network addresses
AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty" yaml:"allowLocalNetwork,omitempty" mapstructure:"allowLocalNetwork,omitempty"`
// Map of URL patterns (e.g., 'https://api.example.com/*') to allowed HTTP
// methods. Redirect destinations must also be included.
AllowedUrls map[string][]PluginManifestPermissionsHttpAllowedUrlsValueElem `json:"allowedUrls" yaml:"allowedUrls" mapstructure:"allowedUrls"`
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
type PluginManifestPermissionsHttpAllowedUrlsValueElem string
const PluginManifestPermissionsHttpAllowedUrlsValueElemDELETE PluginManifestPermissionsHttpAllowedUrlsValueElem = "DELETE"
const PluginManifestPermissionsHttpAllowedUrlsValueElemGET PluginManifestPermissionsHttpAllowedUrlsValueElem = "GET"
const PluginManifestPermissionsHttpAllowedUrlsValueElemHEAD PluginManifestPermissionsHttpAllowedUrlsValueElem = "HEAD"
const PluginManifestPermissionsHttpAllowedUrlsValueElemOPTIONS PluginManifestPermissionsHttpAllowedUrlsValueElem = "OPTIONS"
const PluginManifestPermissionsHttpAllowedUrlsValueElemPATCH PluginManifestPermissionsHttpAllowedUrlsValueElem = "PATCH"
const PluginManifestPermissionsHttpAllowedUrlsValueElemPOST PluginManifestPermissionsHttpAllowedUrlsValueElem = "POST"
const PluginManifestPermissionsHttpAllowedUrlsValueElemPUT PluginManifestPermissionsHttpAllowedUrlsValueElem = "PUT"
const PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard PluginManifestPermissionsHttpAllowedUrlsValueElem = "*"
var enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem = []interface{}{
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"HEAD",
"OPTIONS",
"*",
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsHttpAllowedUrlsValueElem) UnmarshalJSON(value []byte) error {
var v string
if err := json.Unmarshal(value, &v); err != nil {
return err
}
var ok bool
for _, expected := range enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem {
if reflect.DeepEqual(v, expected) {
ok = true
break
}
}
if !ok {
return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem, v)
}
*j = PluginManifestPermissionsHttpAllowedUrlsValueElem(v)
return nil
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsHttp) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["allowedUrls"]; raw != nil && !ok {
return fmt.Errorf("field allowedUrls in PluginManifestPermissionsHttp: required")
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in PluginManifestPermissionsHttp: required")
}
type Plain PluginManifestPermissionsHttp
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if v, ok := raw["allowLocalNetwork"]; !ok || v == nil {
plain.AllowLocalNetwork = false
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = PluginManifestPermissionsHttp(plain)
return nil
}
// Scheduler service permissions
type PluginManifestPermissionsScheduler struct {
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsScheduler) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in PluginManifestPermissionsScheduler: required")
}
type Plain PluginManifestPermissionsScheduler
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = PluginManifestPermissionsScheduler(plain)
return nil
}
// WebSocket service permissions
type PluginManifestPermissionsWebsocket struct {
// Whether to allow connections to local/private network addresses
AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty" yaml:"allowLocalNetwork,omitempty" mapstructure:"allowLocalNetwork,omitempty"`
// List of WebSocket URL patterns that the plugin is allowed to connect to
AllowedUrls []string `json:"allowedUrls" yaml:"allowedUrls" mapstructure:"allowedUrls"`
// Explanation of why this permission is needed
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifestPermissionsWebsocket) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["allowedUrls"]; raw != nil && !ok {
return fmt.Errorf("field allowedUrls in PluginManifestPermissionsWebsocket: required")
}
if _, ok := raw["reason"]; raw != nil && !ok {
return fmt.Errorf("field reason in PluginManifestPermissionsWebsocket: required")
}
type Plain PluginManifestPermissionsWebsocket
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if v, ok := raw["allowLocalNetwork"]; !ok || v == nil {
plain.AllowLocalNetwork = false
}
if plain.AllowedUrls != nil && len(plain.AllowedUrls) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "allowedUrls", 1)
}
if len(plain.Reason) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
}
*j = PluginManifestPermissionsWebsocket(plain)
return nil
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *PluginManifest) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["author"]; raw != nil && !ok {
return fmt.Errorf("field author in PluginManifest: required")
}
if _, ok := raw["capabilities"]; raw != nil && !ok {
return fmt.Errorf("field capabilities in PluginManifest: required")
}
if _, ok := raw["description"]; raw != nil && !ok {
return fmt.Errorf("field description in PluginManifest: required")
}
if _, ok := raw["name"]; raw != nil && !ok {
return fmt.Errorf("field name in PluginManifest: required")
}
if _, ok := raw["permissions"]; raw != nil && !ok {
return fmt.Errorf("field permissions in PluginManifest: required")
}
if _, ok := raw["version"]; raw != nil && !ok {
return fmt.Errorf("field version in PluginManifest: required")
}
if _, ok := raw["website"]; raw != nil && !ok {
return fmt.Errorf("field website in PluginManifest: required")
}
type Plain PluginManifest
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if plain.Capabilities != nil && len(plain.Capabilities) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "capabilities", 1)
}
*j = PluginManifest(plain)
return nil
}

Some files were not shown because too many files have changed in this diff Show More