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

View File

@@ -78,7 +78,7 @@ jobs:
args: --timeout 2m args: --timeout 2m
- name: Run go goimports - name: Run go goimports
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'` run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
- run: go mod tidy - run: go mod tidy
- name: Verify no changes from goimports and go mod tidy - name: Verify no changes from goimports and go mod tidy
run: | run: |

4
.gitignore vendored
View File

@@ -5,6 +5,7 @@
/navidrome /navidrome
/iTunes*.xml /iTunes*.xml
/tmp /tmp
/bin
data/* data/*
vendor/*/ vendor/*/
wiki wiki
@@ -26,4 +27,5 @@ binaries
navidrome-master navidrome-master
AGENTS.md AGENTS.md
*.exe *.exe
bin/ *.test
*.wasm

View File

@@ -221,6 +221,19 @@ deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead." @echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated .PHONY: deprecated
# Generate Go code from plugins/api/api.proto
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
go generate ./plugins/...
.PHONY: plugin-gen
plugin-examples: check_go_env ##@Development Build all example plugins
$(MAKE) -C plugins/examples clean all
.PHONY: plugin-examples
plugin-tests: check_go_env ##@Development Build all test plugins
$(MAKE) -C plugins/testdata clean all
.PHONY: plugin-tests
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
HELP_FUN = \ HELP_FUN = \

17
cmd/cmd_suite_test.go Normal file
View File

@@ -0,0 +1,17 @@
package cmd
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCmd(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Cmd Suite")
}

704
cmd/plugin.go Normal file
View File

@@ -0,0 +1,704 @@
package cmd
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/cobra"
)
const (
pluginPackageExtension = ".ndp"
pluginDirPermissions = 0700
pluginFilePermissions = 0600
)
func init() {
pluginCmd := &cobra.Command{
Use: "plugin",
Short: "Manage Navidrome plugins",
Long: "Commands for managing Navidrome plugins",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List installed plugins",
Long: "List all installed plugins with their metadata",
Run: pluginList,
}
infoCmd := &cobra.Command{
Use: "info [pluginPackage|pluginName]",
Short: "Show details of a plugin",
Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin",
Args: cobra.ExactArgs(1),
Run: pluginInfo,
}
installCmd := &cobra.Command{
Use: "install [pluginPackage]",
Short: "Install a plugin from a .ndp file",
Long: "Install a Navidrome Plugin Package (.ndp) file",
Args: cobra.ExactArgs(1),
Run: pluginInstall,
}
removeCmd := &cobra.Command{
Use: "remove [pluginName]",
Short: "Remove an installed plugin",
Long: "Remove a plugin by name",
Args: cobra.ExactArgs(1),
Run: pluginRemove,
}
updateCmd := &cobra.Command{
Use: "update [pluginPackage]",
Short: "Update an existing plugin",
Long: "Update an installed plugin with a new version from a .ndp file",
Args: cobra.ExactArgs(1),
Run: pluginUpdate,
}
refreshCmd := &cobra.Command{
Use: "refresh [pluginName]",
Short: "Reload a plugin without restarting Navidrome",
Long: "Reload and recompile a plugin without needing to restart Navidrome",
Args: cobra.ExactArgs(1),
Run: pluginRefresh,
}
devCmd := &cobra.Command{
Use: "dev [folder_path]",
Short: "Create symlink to development folder",
Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
Args: cobra.ExactArgs(1),
Run: pluginDev,
}
pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
rootCmd.AddCommand(pluginCmd)
}
// Validation helpers
func validatePluginPackageFile(path string) error {
if !utils.FileExists(path) {
return fmt.Errorf("plugin package not found: %s", path)
}
if filepath.Ext(path) != pluginPackageExtension {
return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
}
return nil
}
func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
pluginDir := filepath.Join(pluginsDir, pluginName)
if !utils.FileExists(pluginDir) {
return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
}
return pluginDir, nil
}
func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
// Check if it's a directory or a symlink
lstat, err := os.Lstat(pluginDir)
if err != nil {
return "", false, fmt.Errorf("failed to stat plugin: %w", err)
}
isSymlink = lstat.Mode()&os.ModeSymlink != 0
if isSymlink {
// Resolve the symlink target
targetDir, err := os.Readlink(pluginDir)
if err != nil {
return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
}
// If target is a relative path, make it absolute
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
}
// Verify the target exists and is a directory
targetInfo, err := os.Stat(targetDir)
if err != nil {
return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
}
if !targetInfo.IsDir() {
return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
}
return targetDir, true, nil
} else if !lstat.IsDir() {
return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
}
return pluginDir, false, nil
}
// Package handling helpers
func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
if err := validatePluginPackageFile(ndpPath); err != nil {
return nil, err
}
pkg, err := plugins.LoadPackage(ndpPath)
if err != nil {
return nil, fmt.Errorf("failed to load plugin package: %w", err)
}
return pkg, nil
}
func extractAndSetupPlugin(ndpPath, targetDir string) error {
if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
return fmt.Errorf("failed to extract plugin package: %w", err)
}
ensurePluginDirPermissions(targetDir)
return nil
}
// Display helpers
func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
if discovery.Error != nil {
// Handle global errors (like directory read failure)
if discovery.ID == "" {
log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
return
}
// Handle individual plugin errors - show them in the table
fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
return
}
// Mark symlinks with an indicator
nameDisplay := discovery.Manifest.Name
if discovery.IsSymlink {
nameDisplay = nameDisplay + " (dev)"
}
// Convert capabilities to strings
capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
return string(cap)
})
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
discovery.ID,
nameDisplay,
cmp.Or(discovery.Manifest.Author, "-"),
cmp.Or(discovery.Manifest.Version, "-"),
strings.Join(capabilities, ", "),
cmp.Or(discovery.Manifest.Description, "-"))
}
func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
if permissions.Http != nil {
fmt.Printf("%shttp:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
fmt.Printf("%s Allowed URLs:\n", indent)
for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
methods := make([]string, len(methodEnums))
for i, methodEnum := range methodEnums {
methods[i] = string(methodEnum)
}
fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
}
fmt.Println()
}
if permissions.Config != nil {
fmt.Printf("%sconfig:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
fmt.Println()
}
if permissions.Scheduler != nil {
fmt.Printf("%sscheduler:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
fmt.Println()
}
if permissions.Websocket != nil {
fmt.Printf("%swebsocket:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
fmt.Println()
}
if permissions.Cache != nil {
fmt.Printf("%scache:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
fmt.Println()
}
if permissions.Artwork != nil {
fmt.Printf("%sartwork:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
fmt.Println()
}
}
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
fmt.Println("\nPlugin Information:")
fmt.Printf(" Name: %s\n", manifest.Name)
fmt.Printf(" Author: %s\n", manifest.Author)
fmt.Printf(" Version: %s\n", manifest.Version)
fmt.Printf(" Description: %s\n", manifest.Description)
fmt.Print(" Capabilities: ")
capabilities := make([]string, len(manifest.Capabilities))
for i, cap := range manifest.Capabilities {
capabilities[i] = string(cap)
}
fmt.Print(strings.Join(capabilities, ", "))
fmt.Println()
// Display manifest permissions using the typed permissions
fmt.Println(" Required Permissions:")
displayTypedPermissions(manifest.Permissions, " ")
// Print file information if available
if fileInfo != nil {
fmt.Println("Package Information:")
fmt.Printf(" File: %s\n", fileInfo.path)
fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
}
// Print file permissions information if available
if permInfo != nil {
fmt.Println("File Permissions:")
fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
if permInfo.isSymlink {
fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
}
fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
if permInfo.wasmMode != "" {
fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
}
}
}
type pluginFileInfo struct {
path string
size int64
hash string
modTime time.Time
}
type pluginPermissionInfo struct {
dirPath string
dirMode string
isSymlink bool
targetPath string
targetMode string
manifestMode string
wasmMode string
}
func getFileInfo(path string) *pluginFileInfo {
fileInfo, err := os.Stat(path)
if err != nil {
log.Error("Failed to get file information", err)
return nil
}
return &pluginFileInfo{
path: path,
size: fileInfo.Size(),
hash: calculateSHA256(path),
modTime: fileInfo.ModTime(),
}
}
func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
// Get plugin directory permissions
dirInfo, err := os.Lstat(pluginDir)
if err != nil {
log.Error("Failed to get plugin directory permissions", err)
return nil
}
permInfo := &pluginPermissionInfo{
dirPath: pluginDir,
dirMode: dirInfo.Mode().String(),
}
// Check if it's a symlink
if dirInfo.Mode()&os.ModeSymlink != 0 {
permInfo.isSymlink = true
// Get target path and permissions
targetPath, err := os.Readlink(pluginDir)
if err == nil {
if !filepath.IsAbs(targetPath) {
targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
}
permInfo.targetPath = targetPath
if targetInfo, err := os.Stat(targetPath); err == nil {
permInfo.targetMode = targetInfo.Mode().String()
}
}
}
// Get manifest file permissions
manifestPath := filepath.Join(pluginDir, "manifest.json")
if manifestInfo, err := os.Stat(manifestPath); err == nil {
permInfo.manifestMode = manifestInfo.Mode().String()
}
// Get WASM file permissions (look for .wasm files)
entries, err := os.ReadDir(pluginDir)
if err == nil {
for _, entry := range entries {
if filepath.Ext(entry.Name()) == ".wasm" {
wasmPath := filepath.Join(pluginDir, entry.Name())
if wasmInfo, err := os.Stat(wasmPath); err == nil {
permInfo.wasmMode = wasmInfo.Mode().String()
break // Just show the first WASM file found
}
}
}
}
return permInfo
}
// Command implementations
func pluginList(cmd *cobra.Command, args []string) {
discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
for _, discovery := range discoveries {
displayPluginTableRow(w, discovery)
}
w.Flush()
}
func pluginInfo(cmd *cobra.Command, args []string) {
path := args[0]
pluginsDir := conf.Server.Plugins.Folder
var manifest *schema.PluginManifest
var fileInfo *pluginFileInfo
var permInfo *pluginPermissionInfo
if filepath.Ext(path) == pluginPackageExtension {
// It's a package file
pkg, err := loadAndValidatePackage(path)
if err != nil {
log.Fatal("Failed to load plugin package", err)
}
manifest = pkg.Manifest
fileInfo = getFileInfo(path)
// No permission info for package files
} else {
// It's a plugin name
pluginDir, err := validatePluginDirectory(pluginsDir, path)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
manifest, err = plugins.LoadManifest(pluginDir)
if err != nil {
log.Fatal("Failed to load plugin manifest", err)
}
// Get permission info for installed plugins
permInfo = getPermissionInfo(pluginDir)
}
displayPluginDetails(manifest, fileInfo, permInfo)
}
func pluginInstall(cmd *cobra.Command, args []string) {
ndpPath := args[0]
pluginsDir := conf.Server.Plugins.Folder
pkg, err := loadAndValidatePackage(ndpPath)
if err != nil {
log.Fatal("Package validation failed", err)
}
// Create target directory based on plugin name
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
// Check if plugin already exists
if utils.FileExists(targetDir) {
log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
"use", "navidrome plugin update")
}
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
log.Fatal("Plugin installation failed", err)
}
fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
}
func pluginRemove(cmd *cobra.Command, args []string) {
pluginName := args[0]
pluginsDir := conf.Server.Plugins.Folder
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
_, isSymlink, err := resolvePluginPath(pluginDir)
if err != nil {
log.Fatal("Failed to resolve plugin path", err)
}
if isSymlink {
// For symlinked plugins (dev mode), just remove the symlink
if err := os.Remove(pluginDir); err != nil {
log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
}
fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
} else {
// For regular plugins, remove the entire directory
if err := os.RemoveAll(pluginDir); err != nil {
log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
}
fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
}
}
func pluginUpdate(cmd *cobra.Command, args []string) {
ndpPath := args[0]
pluginsDir := conf.Server.Plugins.Folder
pkg, err := loadAndValidatePackage(ndpPath)
if err != nil {
log.Fatal("Package validation failed", err)
}
// Check if plugin exists
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
if !utils.FileExists(targetDir) {
log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
"use", "navidrome plugin install")
}
// Create a backup of the existing plugin
backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
if err := os.Rename(targetDir, backupDir); err != nil {
log.Fatal("Failed to backup existing plugin", err)
}
// Extract the new package
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
// Restore backup if extraction failed
os.RemoveAll(targetDir)
_ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
log.Fatal("Plugin update failed", err)
}
// Remove the backup
os.RemoveAll(backupDir)
fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
}
func pluginRefresh(cmd *cobra.Command, args []string) {
pluginName := args[0]
pluginsDir := conf.Server.Plugins.Folder
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
if err != nil {
log.Fatal("Failed to resolve plugin path", err)
}
if isSymlink {
log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
}
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
// Get the plugin manager and refresh
mgr := plugins.GetManager()
log.Debug("Scanning plugins directory", "path", pluginsDir)
mgr.ScanPlugins()
log.Info("Waiting for plugin compilation to complete", "name", pluginName)
// Wait for compilation to complete
if err := mgr.EnsureCompiled(pluginName); err != nil {
log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
}
log.Info("Plugin compilation completed successfully", "name", pluginName)
fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
}
func pluginDev(cmd *cobra.Command, args []string) {
sourcePath, err := filepath.Abs(args[0])
if err != nil {
log.Fatal("Invalid path", "path", args[0], err)
}
pluginsDir := conf.Server.Plugins.Folder
// Validate source directory and manifest
if err := validateDevSource(sourcePath); err != nil {
log.Fatal("Source validation failed", err)
}
// Load manifest to get plugin name
manifest, err := plugins.LoadManifest(sourcePath)
if err != nil {
log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
}
pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
targetPath := filepath.Join(pluginsDir, pluginName)
// Handle existing target
if err := handleExistingTarget(targetPath, sourcePath); err != nil {
log.Fatal("Failed to handle existing target", err)
}
// Create target directory if needed
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
}
// Create the symlink
if err := os.Symlink(sourcePath, targetPath); err != nil {
log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
}
fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
}
// Utility functions
func validateDevSource(sourcePath string) error {
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
}
if !sourceInfo.IsDir() {
return fmt.Errorf("source path is not a directory: %s", sourcePath)
}
manifestPath := filepath.Join(sourcePath, "manifest.json")
if !utils.FileExists(manifestPath) {
return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
}
return nil
}
func handleExistingTarget(targetPath, sourcePath string) error {
if !utils.FileExists(targetPath) {
return nil // Nothing to handle
}
// Check if it's already a symlink to our source
existingLink, err := os.Readlink(targetPath)
if err == nil && existingLink == sourcePath {
fmt.Printf("Symlink already exists and points to the correct source\n")
return fmt.Errorf("symlink already exists") // This will cause early return in caller
}
// Handle case where target exists but is not a symlink to our source
fmt.Printf("Target path '%s' already exists.\n", targetPath)
fmt.Print("Do you want to replace it? (y/N): ")
var response string
_, err = fmt.Scanln(&response)
if err != nil || strings.ToLower(response) != "y" {
if err != nil {
log.Debug("Error reading input, assuming 'no'", err)
}
return fmt.Errorf("operation canceled")
}
// Remove existing target
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
}
return nil
}
func ensurePluginDirPermissions(dir string) {
if err := os.Chmod(dir, pluginDirPermissions); err != nil {
log.Error("Failed to set plugin directory permissions", "dir", dir, err)
}
// Apply permissions to all files in the directory
entries, err := os.ReadDir(dir)
if err != nil {
log.Error("Failed to read plugin directory", "dir", dir, err)
return
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
info, err := os.Stat(path)
if err != nil {
log.Error("Failed to stat file", "path", path, err)
continue
}
mode := os.FileMode(pluginFilePermissions) // Files
if info.IsDir() {
mode = os.FileMode(pluginDirPermissions) // Directories
ensurePluginDirPermissions(path) // Recursive
}
if err := os.Chmod(path, mode); err != nil {
log.Error("Failed to set file permissions", "path", path, err)
}
}
}
func calculateSHA256(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open file for hashing", err)
return "N/A"
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
log.Error("Failed to calculate hash", err)
return "N/A"
}
return hex.EncodeToString(hasher.Sum(nil))
}

193
cmd/plugin_test.go Normal file
View File

@@ -0,0 +1,193 @@
package cmd
import (
"io"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/cobra"
)
var _ = Describe("Plugin CLI Commands", func() {
var tempDir string
var cmd *cobra.Command
var stdOut *os.File
var origStdout *os.File
var outReader *os.File
// Helper to create a test plugin with the given name and details
createTestPlugin := func(name, author, version string, capabilities []string) string {
pluginDir := filepath.Join(tempDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Create a properly formatted capabilities JSON array
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
manifest := `{
"name": "` + name + `",
"author": "` + author + `",
"version": "` + version + `",
"description": "Plugin for testing",
"website": "https://test.navidrome.org/` + name + `",
"capabilities": [` + capabilitiesJSON + `],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
return pluginDir
}
// Helper to execute a command and return captured output
captureOutput := func(reader io.Reader) string {
stdOut.Close()
outputBytes, err := io.ReadAll(reader)
Expect(err).NotTo(HaveOccurred())
return string(outputBytes)
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
// Setup config
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tempDir
// Create a command for testing
cmd = &cobra.Command{Use: "test"}
// Setup stdout capture
origStdout = os.Stdout
var err error
outReader, stdOut, err = os.Pipe()
Expect(err).NotTo(HaveOccurred())
os.Stdout = stdOut
DeferCleanup(func() {
os.Stdout = origStdout
})
})
AfterEach(func() {
os.Stdout = origStdout
if stdOut != nil {
stdOut.Close()
}
if outReader != nil {
outReader.Close()
}
})
Describe("Plugin list command", func() {
It("should list installed plugins", func() {
// Create test plugins
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
// Execute command
pluginList(cmd, []string{})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("plugin1"))
Expect(output).To(ContainSubstring("Test Author"))
Expect(output).To(ContainSubstring("1.0.0"))
Expect(output).To(ContainSubstring("MetadataAgent"))
Expect(output).To(ContainSubstring("plugin2"))
Expect(output).To(ContainSubstring("Another Author"))
Expect(output).To(ContainSubstring("2.1.0"))
Expect(output).To(ContainSubstring("Scrobbler"))
})
})
Describe("Plugin info command", func() {
It("should display information about an installed plugin", func() {
// Create test plugin with multiple capabilities
createTestPlugin("test-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent", "Scrobbler"})
// Execute command
pluginInfo(cmd, []string{"test-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Name: test-plugin"))
Expect(output).To(ContainSubstring("Author: Test Author"))
Expect(output).To(ContainSubstring("Version: 1.0.0"))
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
})
})
Describe("Plugin remove command", func() {
It("should remove a regular plugin directory", func() {
// Create test plugin
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent"})
// Execute command
pluginRemove(cmd, []string{"regular-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
// Verify directory is actually removed
_, err := os.Stat(pluginDir)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("should remove only the symlink for a development plugin", func() {
// Create a real source directory
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
manifest := `{
"name": "dev-plugin",
"author": "Dev Author",
"version": "0.1.0",
"description": "Development plugin for testing",
"website": "https://test.navidrome.org/dev-plugin",
"capabilities": ["Scrobbler"],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
// Create a symlink in the plugins directory
symlinkPath := filepath.Join(tempDir, "dev-plugin")
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
// Execute command
pluginRemove(cmd, []string{"dev-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
Expect(output).To(ContainSubstring("target directory preserved"))
// Verify the symlink is removed but source directory exists
_, err := os.Lstat(symlinkPath)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(sourceDir)
Expect(err).NotTo(HaveOccurred())
})
})
})

View File

@@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/scheduler"
@@ -82,6 +83,7 @@ func runNavidrome(ctx context.Context) {
g.Go(schedulePeriodicBackup(ctx)) g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx)) g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBOptimizer(ctx)) g.Go(scheduleDBOptimizer(ctx))
g.Go(startPluginManager(ctx))
if conf.Server.Scanner.Enabled { if conf.Server.Scanner.Enabled {
g.Go(runInitialScan(ctx)) g.Go(runInitialScan(ctx))
g.Go(startScanWatcher(ctx)) g.Go(startScanWatcher(ctx))
@@ -147,7 +149,7 @@ func schedulePeriodicScan(ctx context.Context) func() error {
schedulerInstance := scheduler.GetInstance() schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic scan", "schedule", schedule) log.Info("Scheduling periodic scan", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() { _, err := schedulerInstance.Add(schedule, func() {
_, err := s.ScanAll(ctx, false) _, err := s.ScanAll(ctx, false)
if err != nil { if err != nil {
log.Error(ctx, "Error executing periodic scan", err) log.Error(ctx, "Error executing periodic scan", err)
@@ -243,7 +245,7 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
schedulerInstance := scheduler.GetInstance() schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule) log.Info("Scheduling periodic backup", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() { _, err := schedulerInstance.Add(schedule, func() {
start := time.Now() start := time.Now()
path, err := db.Backup(ctx) path, err := db.Backup(ctx)
elapsed := time.Since(start) elapsed := time.Since(start)
@@ -271,7 +273,7 @@ func scheduleDBOptimizer(ctx context.Context) func() error {
return func() error { return func() error {
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
schedulerInstance := scheduler.GetInstance() schedulerInstance := scheduler.GetInstance()
err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() { if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress") log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
return return
@@ -325,6 +327,22 @@ func startPlaybackServer(ctx context.Context) func() error {
} }
} }
// startPluginManager starts the plugin manager, if configured.
func startPluginManager(ctx context.Context) func() error {
return func() error {
if !conf.Server.Plugins.Enabled {
log.Debug("Plugins are DISABLED")
return nil
}
log.Info(ctx, "Starting plugin manager")
// Get the manager instance and scan for plugins
manager := plugins.GetManager()
manager.ScanPlugins()
return nil
}
}
// TODO: Implement some struct tags to map flags to viper // TODO: Implement some struct tags to map flags to viper
func init() { func init() {
cobra.OnInitialize(func() { cobra.OnInitialize(func() {

View File

@@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
@@ -66,7 +67,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore) manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache() transcodingCache := core.GetTranscodingCache()
@@ -79,7 +81,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playlists := core.NewPlaylists(dataStore) playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore) metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore) playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
return router return router
@@ -90,7 +92,8 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore) manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache() transcodingCache := core.GetTranscodingCache()
@@ -134,7 +137,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore) manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
@@ -150,7 +154,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore) manager := plugins.GetManager()
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
@@ -171,4 +176,4 @@ func GetPlaybackServer() playback.PlaybackServer {
// wire_injectors.go: // wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, metrics.NewPrometheusInstance, db.Db) var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), metrics.NewPrometheusInstance, db.Db)

View File

@@ -7,14 +7,17 @@ import (
"github.com/google/wire" "github.com/google/wire"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/lastfm" "github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
@@ -36,6 +39,9 @@ var allProviders = wire.NewSet(
events.GetBroker, events.GetBroker,
scanner.New, scanner.New,
scanner.NewWatcher, scanner.NewWatcher,
plugins.GetManager,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
metrics.NewPrometheusInstance, metrics.NewPrometheusInstance,
db.Db, db.Db,
) )

View File

@@ -88,6 +88,8 @@ type configOptions struct {
PasswordEncryptionKey string PasswordEncryptionKey string
ReverseProxyUserHeader string ReverseProxyUserHeader string
ReverseProxyWhitelist string ReverseProxyWhitelist string
Plugins pluginsOptions
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"` HTTPSecurityHeaders secureOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"` Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"` Scanner scannerOptions `json:",omitzero"`
@@ -123,6 +125,7 @@ type configOptions struct {
DevScannerThreads uint DevScannerThreads uint
DevInsightsInitialDelay time.Duration DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool DevEnablePlayerInsights bool
DevPluginCompilationTimeout time.Duration
} }
type scannerOptions struct { type scannerOptions struct {
@@ -209,6 +212,12 @@ type inspectOptions struct {
BacklogTimeout int BacklogTimeout int
} }
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
}
var ( var (
Server = &configOptions{} Server = &configOptions{}
hooks []func() hooks []func()
@@ -248,6 +257,15 @@ func Load(noConfigDump bool) {
os.Exit(1) os.Exit(1)
} }
if Server.Plugins.Folder == "" {
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
}
err = os.MkdirAll(Server.Plugins.Folder, 0700)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
os.Exit(1)
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed() Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" { if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
@@ -483,6 +501,7 @@ func setViperDefaults() {
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75) viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false) viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true) viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true) viper.SetDefault("enablestarrating", true)
@@ -521,7 +540,7 @@ func setViperDefaults() {
viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true) viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.purgemissing", "never") viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.defaultreportrealpath", false)
@@ -546,7 +565,11 @@ func setViperDefaults() {
viper.SetDefault("inspect.maxrequests", 1) viper.SetDefault("inspect.maxrequests", 1)
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("plugins.folder", "")
viper.SetDefault("plugins.enabled", false)
viper.SetDefault("plugins.cachesize", "100MB")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false) viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false) viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "") viper.SetDefault("devautocreateadminpassword", "")
@@ -566,6 +589,7 @@ func setViperDefaults() {
viper.SetDefault("devscannerthreads", 5) viper.SetDefault("devscannerthreads", 5)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true) viper.SetDefault("devenableplayerinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute)
} }
func init() { func init() {

View File

@@ -2,7 +2,9 @@ package agents
import ( import (
"context" "context"
"slices"
"strings" "strings"
"sync"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@@ -13,43 +15,156 @@ import (
"github.com/navidrome/navidrome/utils/singleton" "github.com/navidrome/navidrome/utils/singleton"
) )
type Agents struct { // PluginLoader defines an interface for loading plugins
ds model.DataStore type PluginLoader interface {
agents []Interface // PluginNames returns the names of all plugins that implement a particular service
PluginNames(serviceName string) []string
// LoadMediaAgent loads and returns a media agent plugin
LoadMediaAgent(name string) (Interface, bool)
} }
func GetAgents(ds model.DataStore) *Agents { type cachedAgent struct {
agent Interface
expiration time.Time
}
// Encapsulates agent caching logic
// agentCache is a simple TTL cache for agents
// Not exported, only used by Agents
type agentCache struct {
mu sync.Mutex
items map[string]cachedAgent
ttl time.Duration
}
// TTL for cached agents
const agentCacheTTL = 5 * time.Minute
func newAgentCache(ttl time.Duration) *agentCache {
return &agentCache{
items: make(map[string]cachedAgent),
ttl: ttl,
}
}
func (c *agentCache) Get(name string) Interface {
c.mu.Lock()
defer c.mu.Unlock()
cached, ok := c.items[name]
if ok && cached.expiration.After(time.Now()) {
return cached.agent
}
return nil
}
func (c *agentCache) Set(name string, agent Interface) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[name] = cachedAgent{
agent: agent,
expiration: time.Now().Add(c.ttl),
}
}
type Agents struct {
ds model.DataStore
pluginLoader PluginLoader
cache *agentCache
}
// GetAgents returns the singleton instance of Agents
func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
return singleton.GetInstance(func() *Agents { return singleton.GetInstance(func() *Agents {
return createAgents(ds) return createAgents(ds, pluginLoader)
}) })
} }
func createAgents(ds model.DataStore) *Agents { // createAgents creates a new Agents instance. Used in tests
var order []string func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
if conf.Server.Agents != "" { return &Agents{
order = strings.Split(conf.Server.Agents, ",") ds: ds,
pluginLoader: pluginLoader,
cache: newAgentCache(agentCacheTTL),
} }
order = append(order, LocalAgentName) }
var res []Interface
var enabled []string
for _, name := range order {
init, ok := Map[name]
if !ok {
log.Error("Invalid agent. Check `Agents` configuration", "name", name, "conf", conf.Server.Agents)
continue
}
agent := init(ds) // getEnabledAgentNames returns the current list of enabled agent names, including:
if agent == nil { // 1. Built-in agents and plugins from config (in the specified order)
log.Debug("Agent not available. Missing configuration?", "name", name) // 2. Always include LocalAgentName
continue // 3. If config is empty, include ONLY LocalAgentName
} func (a *Agents) getEnabledAgentNames() []string {
enabled = append(enabled, name) // If no agents configured, ONLY use the local agent
res = append(res, init(ds)) if conf.Server.Agents == "" {
return []string{LocalAgentName}
} }
log.Debug("List of agents enabled", "names", enabled)
return &Agents{ds: ds, agents: res} // Get all available plugin names
var availablePlugins []string
if a.pluginLoader != nil {
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
}
configuredAgents := strings.Split(conf.Server.Agents, ",")
// Always add LocalAgentName if not already included
hasLocalAgent := false
for _, name := range configuredAgents {
if name == LocalAgentName {
hasLocalAgent = true
break
}
}
if !hasLocalAgent {
configuredAgents = append(configuredAgents, LocalAgentName)
}
// Filter to only include valid agents (built-in or plugins)
var validNames []string
for _, name := range configuredAgents {
// Check if it's a built-in agent
isBuiltIn := Map[name] != nil
// Check if it's a plugin
isPlugin := slices.Contains(availablePlugins, name)
if isBuiltIn || isPlugin {
validNames = append(validNames, name)
} else {
log.Warn("Unknown agent ignored", "name", name)
}
}
return validNames
}
func (a *Agents) getAgent(name string) Interface {
// Check cache first
agent := a.cache.Get(name)
if agent != nil {
return agent
}
// Try to get built-in agent
constructor, ok := Map[name]
if ok {
agent := constructor(a.ds)
if agent != nil {
a.cache.Set(name, agent)
return agent
}
log.Debug("Built-in agent not available. Missing configuration?", "name", name)
}
// Try to load WASM plugin agent (if plugin loader is available)
if a.pluginLoader != nil {
agent, ok := a.pluginLoader.LoadMediaAgent(name)
if ok && agent != nil {
a.cache.Set(name, agent)
return agent
}
}
return nil
} }
func (a *Agents) AgentName() string { func (a *Agents) AgentName() string {
@@ -64,15 +179,19 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
return "", nil return "", nil
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(ArtistMBIDRetriever) retriever, ok := ag.(ArtistMBIDRetriever)
if !ok { if !ok {
continue continue
} }
mbid, err := agent.GetArtistMBID(ctx, id, name) mbid, err := retriever.GetArtistMBID(ctx, id, name)
if mbid != "" && err == nil { if mbid != "" && err == nil {
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start)) log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
return mbid, nil return mbid, nil
@@ -89,15 +208,19 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
return "", nil return "", nil
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(ArtistURLRetriever) retriever, ok := ag.(ArtistURLRetriever)
if !ok { if !ok {
continue continue
} }
url, err := agent.GetArtistURL(ctx, id, name, mbid) url, err := retriever.GetArtistURL(ctx, id, name, mbid)
if url != "" && err == nil { if url != "" && err == nil {
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start)) log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
return url, nil return url, nil
@@ -114,15 +237,19 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return "", nil return "", nil
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(ArtistBiographyRetriever) retriever, ok := ag.(ArtistBiographyRetriever)
if !ok { if !ok {
continue continue
} }
bio, err := agent.GetArtistBiography(ctx, id, name, mbid) bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
if err == nil { if err == nil {
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start)) log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
return bio, nil return bio, nil
@@ -139,15 +266,19 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
return nil, nil return nil, nil
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(ArtistSimilarRetriever) retriever, ok := ag.(ArtistSimilarRetriever)
if !ok { if !ok {
continue continue
} }
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit) similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, limit)
if len(similar) > 0 && err == nil { if len(similar) > 0 && err == nil {
if log.IsGreaterOrEqualTo(log.LevelTrace) { if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
@@ -168,15 +299,19 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return nil, nil return nil, nil
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(ArtistImageRetriever) retriever, ok := ag.(ArtistImageRetriever)
if !ok { if !ok {
continue continue
} }
images, err := agent.GetArtistImages(ctx, id, name, mbid) images, err := retriever.GetArtistImages(ctx, id, name, mbid)
if len(images) > 0 && err == nil { if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start)) log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
return images, nil return images, nil
@@ -193,15 +328,19 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
return nil, nil return nil, nil
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(ArtistTopSongsRetriever) retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok { if !ok {
continue continue
} }
songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count) songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, count)
if len(songs) > 0 && err == nil { if len(songs) > 0 && err == nil {
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, nil return songs, nil
@@ -215,15 +354,19 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound return nil, ErrNotFound
} }
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
agent, ok := ag.(AlbumInfoRetriever) retriever, ok := ag.(AlbumInfoRetriever)
if !ok { if !ok {
continue continue
} }
album, err := agent.GetAlbumInfo(ctx, name, artist, mbid) album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
if err == nil { if err == nil {
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist, log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start)) "mbid", mbid, "elapsed", time.Since(start))
@@ -233,6 +376,33 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound return nil, ErrNotFound
} }
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
if name == consts.UnknownAlbum {
return nil, ErrNotFound
}
start := time.Now()
for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumImageRetriever)
if !ok {
continue
}
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return images, nil
}
}
return nil, ErrNotFound
}
var _ Interface = (*Agents)(nil) var _ Interface = (*Agents)(nil)
var _ ArtistMBIDRetriever = (*Agents)(nil) var _ ArtistMBIDRetriever = (*Agents)(nil)
var _ ArtistURLRetriever = (*Agents)(nil) var _ ArtistURLRetriever = (*Agents)(nil)
@@ -241,3 +411,4 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
var _ ArtistImageRetriever = (*Agents)(nil) var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil) var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil) var _ AlbumInfoRetriever = (*Agents)(nil)
var _ AlbumImageRetriever = (*Agents)(nil)

View File

@@ -0,0 +1,221 @@
package agents
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// MockPluginLoader implements PluginLoader for testing
type MockPluginLoader struct {
pluginNames []string
loadedAgents map[string]*MockAgent
pluginCallCount map[string]int
}
func NewMockPluginLoader() *MockPluginLoader {
return &MockPluginLoader{
pluginNames: []string{},
loadedAgents: make(map[string]*MockAgent),
pluginCallCount: make(map[string]int),
}
}
func (m *MockPluginLoader) PluginNames(serviceName string) []string {
return m.pluginNames
}
func (m *MockPluginLoader) LoadMediaAgent(name string) (Interface, bool) {
m.pluginCallCount[name]++
agent, exists := m.loadedAgents[name]
return agent, exists
}
// MockAgent is a mock agent implementation for testing
type MockAgent struct {
name string
mbid string
}
func (m *MockAgent) AgentName() string {
return m.name
}
func (m *MockAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
return m.mbid, nil
}
var _ Interface = (*MockAgent)(nil)
var _ ArtistMBIDRetriever = (*MockAgent)(nil)
var _ PluginLoader = (*MockPluginLoader)(nil)
var _ = Describe("Agents with Plugin Loading", func() {
var mockLoader *MockPluginLoader
var agents *Agents
BeforeEach(func() {
mockLoader = NewMockPluginLoader()
// Create the agents instance with our mock loader
agents = createAgents(nil, mockLoader)
})
Context("Dynamic agent discovery", func() {
It("should include ONLY local agent when no config is specified", func() {
// Ensure no specific agents are configured
conf.Server.Agents = ""
// Add some plugin agents that should be ignored
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
// Should only include the local agent
agentNames := agents.getEnabledAgentNames()
Expect(agentNames).To(HaveExactElements(LocalAgentName))
})
It("should NOT include plugin agents when no config is specified", func() {
// Ensure no specific agents are configured
conf.Server.Agents = ""
// Add a plugin agent
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
// Should only include the local agent
agentNames := agents.getEnabledAgentNames()
Expect(agentNames).To(HaveExactElements(LocalAgentName))
Expect(agentNames).NotTo(ContainElement("plugin_agent"))
})
It("should include plugin agents in the enabled agents list ONLY when explicitly configured", func() {
// Add a plugin agent
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
// With no config, should not include plugin
conf.Server.Agents = ""
agentNames := agents.getEnabledAgentNames()
Expect(agentNames).To(HaveExactElements(LocalAgentName))
Expect(agentNames).NotTo(ContainElement("plugin_agent"))
// When explicitly configured, should include plugin
conf.Server.Agents = "plugin_agent"
agentNames = agents.getEnabledAgentNames()
Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_agent"))
})
It("should only include configured plugin agents when config is specified", func() {
// Add two plugin agents
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
// Configure only one of them
conf.Server.Agents = "plugin_one"
// Verify only the configured one is included
agentNames := agents.getEnabledAgentNames()
Expect(agentNames).To(ContainElement("plugin_one"))
Expect(agentNames).NotTo(ContainElement("plugin_two"))
})
It("should load plugin agents on demand", func() {
ctx := context.Background()
// Configure to use our plugin
conf.Server.Agents = "plugin_agent"
// Add a plugin agent
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
name: "plugin_agent",
mbid: "plugin-mbid",
}
// Try to get data from it
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
Expect(err).ToNot(HaveOccurred())
Expect(mbid).To(Equal("plugin-mbid"))
Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
})
It("should cache plugin agents", func() {
ctx := context.Background()
// Configure to use our plugin
conf.Server.Agents = "plugin_agent"
// Add a plugin agent
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
name: "plugin_agent",
mbid: "plugin-mbid",
}
// Call multiple times
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
Expect(err).ToNot(HaveOccurred())
_, err = agents.GetArtistMBID(ctx, "123", "Artist")
Expect(err).ToNot(HaveOccurred())
_, err = agents.GetArtistMBID(ctx, "123", "Artist")
Expect(err).ToNot(HaveOccurred())
// Should only load once
Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
})
It("should try both built-in and plugin agents", func() {
// Create a mock built-in agent
Register("built_in", func(ds model.DataStore) Interface {
return &MockAgent{
name: "built_in",
mbid: "built-in-mbid",
}
})
defer func() {
delete(Map, "built_in")
}()
// Configure to use both built-in and plugin
conf.Server.Agents = "built_in,plugin_agent"
// Add a plugin agent
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
name: "plugin_agent",
mbid: "plugin-mbid",
}
// Verify that both are in the enabled list
agentNames := agents.getEnabledAgentNames()
Expect(agentNames).To(ContainElements("built_in", "plugin_agent"))
})
It("should respect the order specified in configuration", func() {
// Create mock built-in agents
Register("agent_a", func(ds model.DataStore) Interface {
return &MockAgent{name: "agent_a"}
})
Register("agent_b", func(ds model.DataStore) Interface {
return &MockAgent{name: "agent_b"}
})
defer func() {
delete(Map, "agent_a")
delete(Map, "agent_b")
}()
// Add plugin agents
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
// Configure specific order - plugin first, then built-ins
conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
// Get the agent names
agentNames := agents.getEnabledAgentNames()
// Verify the order matches configuration, with LocalAgentName at the end
Expect(agentNames).To(HaveExactElements("plugin_y", "agent_b", "plugin_x", "agent_a", LocalAgentName))
})
})
})

View File

@@ -7,7 +7,6 @@ import (
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@@ -29,7 +28,7 @@ var _ = Describe("Agents", func() {
var ag *Agents var ag *Agents
BeforeEach(func() { BeforeEach(func() {
conf.Server.Agents = "" conf.Server.Agents = ""
ag = createAgents(ds) ag = createAgents(ds, nil)
}) })
It("calls the placeholder GetArtistImages", func() { It("calls the placeholder GetArtistImages", func() {
@@ -49,12 +48,18 @@ var _ = Describe("Agents", func() {
Register("disabled", func(model.DataStore) Interface { return nil }) Register("disabled", func(model.DataStore) Interface { return nil })
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} }) Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
conf.Server.Agents = "empty,fake,disabled" conf.Server.Agents = "empty,fake,disabled"
ag = createAgents(ds) ag = createAgents(ds, nil)
Expect(ag.AgentName()).To(Equal("agents")) Expect(ag.AgentName()).To(Equal("agents"))
}) })
It("does not register disabled agents", func() { It("does not register disabled agents", func() {
ags := slice.Map(ag.agents, func(a Interface) string { return a.AgentName() }) var ags []string
for _, name := range ag.getEnabledAgentNames() {
agent := ag.getAgent(name)
if agent != nil {
ags = append(ags, agent.AgentName())
}
}
// local agent is always appended to the end of the agents list // local agent is always appended to the end of the agents list
Expect(ags).To(HaveExactElements("empty", "fake", "local")) Expect(ags).To(HaveExactElements("empty", "fake", "local"))
Expect(ags).ToNot(ContainElement("disabled")) Expect(ags).ToNot(ContainElement("disabled"))
@@ -187,7 +192,7 @@ var _ = Describe("Agents", func() {
It("falls back to the next agent on error", func() { It("falls back to the next agent on error", func() {
conf.Server.Agents = "imgFail,imgOk" conf.Server.Agents = "imgFail,imgOk"
ag = createAgents(ds) ag = createAgents(ds, nil)
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@@ -200,7 +205,7 @@ var _ = Describe("Agents", func() {
first.Err = nil first.Err = nil
first.Images = []ExternalImage{} first.Images = []ExternalImage{}
conf.Server.Agents = "imgFail,imgOk" conf.Server.Agents = "imgFail,imgOk"
ag = createAgents(ds) ag = createAgents(ds, nil)
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@@ -262,18 +267,6 @@ var _ = Describe("Agents", func() {
MBID: "mbid444", MBID: "mbid444",
Description: "A Description", Description: "A Description",
URL: "External URL", URL: "External URL",
Images: []ExternalImage{
{
Size: 174,
URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png",
}, {
Size: 64,
URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png",
}, {
Size: 34,
URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png",
},
},
})) }))
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid")) Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
}) })
@@ -369,18 +362,6 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
MBID: "mbid444", MBID: "mbid444",
Description: "A Description", Description: "A Description",
URL: "External URL", URL: "External URL",
Images: []ExternalImage{
{
Size: 174,
URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png",
}, {
Size: 64,
URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png",
}, {
Size: 34,
URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png",
},
},
}, nil }, nil
} }

View File

@@ -13,12 +13,12 @@ type Interface interface {
AgentName() string AgentName() string
} }
// AlbumInfo contains album metadata (no images)
type AlbumInfo struct { type AlbumInfo struct {
Name string Name string
MBID string MBID string
Description string Description string
URL string URL string
Images []ExternalImage
} }
type Artist struct { type Artist struct {
@@ -40,11 +40,16 @@ var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
) )
// TODO Break up this interface in more specific methods, like artists // AlbumInfoRetriever provides album info (no images)
type AlbumInfoRetriever interface { type AlbumInfoRetriever interface {
GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error)
} }
// AlbumImageRetriever provides album images
type AlbumImageRetriever interface {
GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error)
}
type ArtistMBIDRetriever interface { type ArtistMBIDRetriever interface {
GetArtistMBID(ctx context.Context, id string, name string) (string, error) GetArtistMBID(ctx context.Context, id string, name string) (string, error)
} }

View File

@@ -72,16 +72,23 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
return nil, err return nil, err
} }
response := agents.AlbumInfo{ return &agents.AlbumInfo{
Name: a.Name, Name: a.Name,
MBID: a.MBID, MBID: a.MBID,
Description: a.Description.Summary, Description: a.Description.Summary,
URL: a.URL, URL: a.URL,
Images: make([]agents.ExternalImage, 0), }, nil
}
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
if err != nil {
return nil, err
} }
// Last.fm can return duplicate sizes. // Last.fm can return duplicate sizes.
seenSizes := map[int]bool{} seenSizes := map[int]bool{}
images := make([]agents.ExternalImage, 0)
// This assumes that Last.fm returns images with size small, medium, and large. // This assumes that Last.fm returns images with size small, medium, and large.
// This is true as of December 29, 2022 // This is true as of December 29, 2022
@@ -92,23 +99,20 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size) log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size)
continue continue
} }
numericSize, err := strconv.Atoi(size[0][2:]) numericSize, err := strconv.Atoi(size[0][2:])
if err != nil { if err != nil {
log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err) log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err)
return nil, err return nil, err
} else { }
if _, exists := seenSizes[numericSize]; !exists { if _, exists := seenSizes[numericSize]; !exists {
response.Images = append(response.Images, agents.ExternalImage{ images = append(images, agents.ExternalImage{
Size: numericSize, Size: numericSize,
URL: img.URL, URL: img.URL,
}) })
seenSizes[numericSize] = true seenSizes[numericSize] = true
}
} }
} }
return images, nil
return &response, nil
} }
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
@@ -286,7 +290,7 @@ func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
return track.Artist return track.Artist
} }
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
sk, err := l.sessionKeys.Get(ctx, userId) sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" { if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized return scrobbler.ErrNotAuthorized

View File

@@ -209,7 +209,7 @@ var _ = Describe("lastfmAgent", func() {
It("calls Last.fm with correct params", func() { It("calls Last.fm with correct params", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track) err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
@@ -226,7 +226,7 @@ var _ = Describe("lastfmAgent", func() {
}) })
It("returns ErrNotAuthorized if user is not linked", func() { It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track) err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
}) })
}) })
@@ -345,24 +345,6 @@ var _ = Describe("lastfmAgent", func() {
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.", Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
URL: "https://www.last.fm/music/Cher/Believe", URL: "https://www.last.fm/music/Cher/Believe",
Images: []agents.ExternalImage{
{
URL: "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png",
Size: 34,
},
{
URL: "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png",
Size: 64,
},
{
URL: "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png",
Size: 174,
},
{
URL: "https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png",
Size: 300,
},
},
})) }))
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
@@ -372,9 +354,8 @@ var _ = Describe("lastfmAgent", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json") f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{ Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
Name: "The Definitive Less Damage And More Joy", Name: "The Definitive Less Damage And More Joy",
URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy", URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
Images: []agents.ExternalImage{},
})) }))
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy")) Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))

View File

@@ -73,7 +73,7 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
return li return li
} }
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
sk, err := l.sessionKeys.Get(ctx, userId) sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" { if err != nil || sk == "" {
return errors.Join(err, scrobbler.ErrNotAuthorized) return errors.Join(err, scrobbler.ErrNotAuthorized)

View File

@@ -79,12 +79,12 @@ var _ = Describe("listenBrainzAgent", func() {
It("updates NowPlaying successfully", func() { It("updates NowPlaying successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track) err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
}) })
It("returns ErrNotAuthorized if user is not linked", func() { It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track) err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
}) })
}) })

View File

@@ -190,10 +190,13 @@ type mockAgents struct {
topSongsAgent agents.ArtistTopSongsRetriever topSongsAgent agents.ArtistTopSongsRetriever
similarAgent agents.ArtistSimilarRetriever similarAgent agents.ArtistSimilarRetriever
imageAgent agents.ArtistImageRetriever imageAgent agents.ArtistImageRetriever
albumInfoAgent agents.AlbumInfoRetriever albumInfoAgent interface {
bioAgent agents.ArtistBiographyRetriever agents.AlbumInfoRetriever
mbidAgent agents.ArtistMBIDRetriever agents.AlbumImageRetriever
urlAgent agents.ArtistURLRetriever }
bioAgent agents.ArtistBiographyRetriever
mbidAgent agents.ArtistMBIDRetriever
urlAgent agents.ArtistURLRetriever
agents.Interface agents.Interface
} }
@@ -268,3 +271,14 @@ func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string)
} }
return nil, args.Error(1) return nil, args.Error(1)
} }
func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
if m.albumInfoAgent != nil {
return m.albumInfoAgent.GetAlbumImages(ctx, name, artist, mbid)
}
args := m.Called(ctx, name, artist, mbid)
if args.Get(0) != nil {
return args.Get(0).([]agents.ExternalImage), args.Error(1)
}
return nil, args.Error(1)
}

View File

@@ -60,6 +60,7 @@ type auxArtist struct {
type Agents interface { type Agents interface {
agents.AlbumInfoRetriever agents.AlbumInfoRetriever
agents.AlbumImageRetriever
agents.ArtistBiographyRetriever agents.ArtistBiographyRetriever
agents.ArtistMBIDRetriever agents.ArtistMBIDRetriever
agents.ArtistImageRetriever agents.ArtistImageRetriever
@@ -140,19 +141,20 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description album.Description = info.Description
} }
if len(info.Images) > 0 { images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
sort.Slice(info.Images, func(i, j int) bool { if err == nil && len(images) > 0 {
return info.Images[i].Size > info.Images[j].Size sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size
}) })
album.LargeImageUrl = info.Images[0].URL album.LargeImageUrl = images[0].URL
if len(info.Images) >= 2 { if len(images) >= 2 {
album.MediumImageUrl = info.Images[1].URL album.MediumImageUrl = images[1].URL
} }
if len(info.Images) >= 3 { if len(images) >= 3 {
album.SmallImageUrl = info.Images[2].URL album.SmallImageUrl = images[2].URL
} }
} }
@@ -341,29 +343,28 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err return nil, err
} }
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, agents.ErrNotFound): case errors.Is(err, agents.ErrNotFound):
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
return nil, model.ErrNotFound return nil, model.ErrNotFound
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumInfo call canceled", err) log.Debug(ctx, "GetAlbumImages call canceled", err)
default: default:
log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err) log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
} }
return nil, err return nil, err
} }
if info == nil { if len(images) == 0 {
log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
// Return the biggest image // Return the biggest image
var img agents.ExternalImage var img agents.ExternalImage
for _, i := range info.Images { for _, i := range images {
if img.Size <= i.Size { if img.Size <= i.Size {
img = i img = i
} }

View File

@@ -23,7 +23,6 @@ var _ = Describe("Provider - AlbumImage", func() {
var mockAlbumRepo *mockAlbumRepo var mockAlbumRepo *mockAlbumRepo
var mockMediaFileRepo *mockMediaFileRepo var mockMediaFileRepo *mockMediaFileRepo
var mockAlbumAgent *mockAlbumInfoAgent var mockAlbumAgent *mockAlbumInfoAgent
var agentsCombined *mockAgents
var ctx context.Context var ctx context.Context
BeforeEach(func() { BeforeEach(func() {
@@ -43,10 +42,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumAgent = newMockAlbumInfoAgent() mockAlbumAgent = newMockAlbumInfoAgent()
agentsCombined = &mockAgents{ agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
albumInfoAgent: mockAlbumAgent,
}
provider = NewProvider(ds, agentsCombined) provider = NewProvider(ds, agentsCombined)
// Default mocks // Default mocks
@@ -66,13 +62,11 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{ Return([]agents.ExternalImage{
Images: []agents.ExternalImage{ {URL: "http://example.com/large.jpg", Size: 1000},
{URL: "http://example.com/large.jpg", Size: 1000}, {URL: "http://example.com/medium.jpg", Size: 500},
{URL: "http://example.com/medium.jpg", Size: 500}, {URL: "http://example.com/small.jpg", Size: 200},
{URL: "http://example.com/small.jpg", Size: 200},
},
}, nil).Once() }, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg") expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -82,8 +76,8 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(imgURL).To(Equal(expectedURL)) Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
}) })
It("returns ErrNotFound if the album is not found in the DB", func() { It("returns ErrNotFound if the album is not found in the DB", func() {
@@ -99,7 +93,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
}) })
It("returns the agent error if the agent fails", func() { It("returns the agent error if the agent fails", func() {
@@ -109,7 +103,7 @@ var _ = Describe("Provider - AlbumImage", func() {
agentErr := errors.New("agent failure") agentErr := errors.New("agent failure")
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1") imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -118,7 +112,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
}) })
It("returns ErrNotFound if the agent returns ErrNotFound", func() { It("returns ErrNotFound if the agent returns ErrNotFound", func() {
@@ -127,7 +121,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1") imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -135,7 +129,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(imgURL).To(BeNil()) Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
}) })
It("returns ErrNotFound if the agent returns no images", func() { It("returns ErrNotFound if the agent returns no images", func() {
@@ -144,8 +138,8 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1") imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -153,7 +147,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(imgURL).To(BeNil()) Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
}) })
It("returns context error if context is canceled", func() { It("returns context error if context is canceled", func() {
@@ -163,7 +157,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Expect the agent call even if context is cancelled, returning the context error // Expect the agent call even if context is cancelled, returning the context error
mockAlbumAgent.On("GetAlbumInfo", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
// Cancel the context *before* calling the function under test // Cancel the context *before* calling the function under test
cancelCtx() cancelCtx()
@@ -174,7 +168,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
// Agent should now be called, verify this expectation // Agent should now be called, verify this expectation
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", cctx, "Album One", "", "") mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
}) })
It("derives album ID from MediaFile ID", func() { It("derives album ID from MediaFile ID", func() {
@@ -186,13 +180,11 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{ Return([]agents.ExternalImage{
Images: []agents.ExternalImage{ {URL: "http://example.com/large.jpg", Size: 1000},
{URL: "http://example.com/large.jpg", Size: 1000}, {URL: "http://example.com/medium.jpg", Size: 500},
{URL: "http://example.com/medium.jpg", Size: 500}, {URL: "http://example.com/small.jpg", Size: 200},
{URL: "http://example.com/small.jpg", Size: 200},
},
}, nil).Once() }, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg") expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -206,7 +198,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
}) })
It("handles different image orders from agent", func() { It("handles different image orders from agent", func() {
@@ -214,13 +206,11 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{ Return([]agents.ExternalImage{
Images: []agents.ExternalImage{ {URL: "http://example.com/small.jpg", Size: 200},
{URL: "http://example.com/small.jpg", Size: 200}, {URL: "http://example.com/large.jpg", Size: 1000},
{URL: "http://example.com/large.jpg", Size: 1000}, {URL: "http://example.com/medium.jpg", Size: 500},
{URL: "http://example.com/medium.jpg", Size: 500},
},
}, nil).Once() }, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg") expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -228,7 +218,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
}) })
It("handles agent returning only one image", func() { It("handles agent returning only one image", func() {
@@ -236,11 +226,9 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test // Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{ Return([]agents.ExternalImage{
Images: []agents.ExternalImage{ {URL: "http://example.com/single.jpg", Size: 700},
{URL: "http://example.com/single.jpg", Size: 700},
},
}, nil).Once() }, nil).Once()
expectedURL, _ := url.Parse("http://example.com/single.jpg") expectedURL, _ := url.Parse("http://example.com/single.jpg")
@@ -248,7 +236,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL)) Expect(imgURL).To(Equal(expectedURL))
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
}) })
It("returns ErrNotFound if deriving album ID fails", func() { It("returns ErrNotFound if deriving album ID fails", func() {
@@ -270,14 +258,15 @@ var _ = Describe("Provider - AlbumImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
}) })
}) })
// mockAlbumInfoAgent implementation // mockAlbumInfoAgent implementation
type mockAlbumInfoAgent struct { type mockAlbumInfoAgent struct {
mock.Mock mock.Mock
agents.AlbumInfoRetriever // Embed interface agents.AlbumInfoRetriever
agents.AlbumImageRetriever
} }
func newMockAlbumInfoAgent() *mockAlbumInfoAgent { func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
@@ -299,5 +288,14 @@ func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbi
return args.Get(0).(*agents.AlbumInfo), args.Error(1) return args.Get(0).(*agents.AlbumInfo), args.Error(1)
} }
// Ensure mockAgent implements the interface func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
args := m.Called(ctx, name, artist, mbid)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]agents.ExternalImage), args.Error(1)
}
// Ensure mockAgent implements the interfaces
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)

View File

@@ -59,13 +59,13 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
expectedInfo := &agents.AlbumInfo{ expectedInfo := &agents.AlbumInfo{
URL: "http://example.com/album", URL: "http://example.com/album",
Description: "Album Description", Description: "Album Description",
Images: []agents.ExternalImage{
{URL: "http://example.com/large.jpg", Size: 300},
{URL: "http://example.com/medium.jpg", Size: 200},
{URL: "http://example.com/small.jpg", Size: 100},
},
} }
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil) ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil)
ag.On("GetAlbumImages", ctx, "Test Album", "Test Artist", "mbid-album").Return([]agents.ExternalImage{
{URL: "http://example.com/large.jpg", Size: 300},
{URL: "http://example.com/medium.jpg", Size: 200},
{URL: "http://example.com/small.jpg", Size: 100},
}, nil)
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing") updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing")
@@ -74,9 +74,6 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
Expect(updatedAlbum.ID).To(Equal("al-existing")) Expect(updatedAlbum.ID).To(Equal("al-existing"))
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album")) Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
Expect(updatedAlbum.Description).To(Equal("Album Description")) Expect(updatedAlbum.Description).To(Equal("Album Description"))
Expect(updatedAlbum.LargeImageUrl).To(Equal("http://example.com/large.jpg"))
Expect(updatedAlbum.MediumImageUrl).To(Equal("http://example.com/medium.jpg"))
Expect(updatedAlbum.SmallImageUrl).To(Equal("http://example.com/small.jpg"))
Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil()) Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil())
Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second)) Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))

View File

@@ -10,9 +10,16 @@ import (
) )
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler { func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
b := &bufferedScrobbler{ds: ds, wrapped: s, service: service} ctx, cancel := context.WithCancel(context.Background())
b.wakeSignal = make(chan struct{}, 1) b := &bufferedScrobbler{
go b.run(context.TODO()) ds: ds,
wrapped: s,
service: service,
wakeSignal: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
go b.run(ctx)
return b return b
} }
@@ -21,14 +28,22 @@ type bufferedScrobbler struct {
wrapped Scrobbler wrapped Scrobbler
service string service string
wakeSignal chan struct{} wakeSignal chan struct{}
ctx context.Context
cancel context.CancelFunc
}
func (b *bufferedScrobbler) Stop() {
if b.cancel != nil {
b.cancel()
}
} }
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return b.wrapped.IsAuthorized(ctx, userId) return b.wrapped.IsAuthorized(ctx, userId)
} }
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
return b.wrapped.NowPlaying(ctx, userId, track) return b.wrapped.NowPlaying(ctx, userId, track, position)
} }
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {

View File

@@ -0,0 +1,88 @@
package scrobbler
import (
"context"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("BufferedScrobbler", func() {
var ds model.DataStore
var scr *fakeScrobbler
var bs *bufferedScrobbler
var ctx context.Context
var buffer *tests.MockedScrobbleBufferRepo
BeforeEach(func() {
ctx = context.Background()
buffer = tests.CreateMockedScrobbleBufferRepo()
ds = &tests.MockDataStore{
MockedScrobbleBuffer: buffer,
}
scr = &fakeScrobbler{Authorized: true}
bs = newBufferedScrobbler(ds, scr, "test")
})
It("forwards IsAuthorized calls", func() {
scr.Authorized = true
Expect(bs.IsAuthorized(ctx, "user1")).To(BeTrue())
scr.Authorized = false
Expect(bs.IsAuthorized(ctx, "user1")).To(BeFalse())
})
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
Expect(scr.NowPlayingCalled).To(BeTrue())
Expect(scr.UserID).To(Equal("user1"))
Expect(scr.Track).To(Equal(track))
})
It("enqueues scrobbles to buffer", func() {
track := model.MediaFile{ID: "123", Title: "Test Track"}
now := time.Now()
scrobble := Scrobble{MediaFile: track, TimeStamp: now}
Expect(buffer.Length()).To(Equal(int64(0)))
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
Expect(buffer.Length()).To(Equal(int64(1)))
// Wait for the scrobble to be sent
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load()
Expect(lastScrobble.MediaFile.ID).To(Equal("123"))
Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now))
})
It("stops the background goroutine when Stop is called", func() {
// Replace the real run method with one that signals when it exits
done := make(chan struct{})
// Start our instrumented run function that will signal when it exits
go func() {
defer close(done)
bs.run(bs.ctx)
}()
// Wait a bit to ensure the goroutine is running
time.Sleep(10 * time.Millisecond)
// Call the real Stop method
bs.Stop()
// Wait for the goroutine to exit or timeout
select {
case <-done:
// Success, goroutine exited
case <-time.After(100 * time.Millisecond):
Fail("Goroutine did not exit in time after Stop was called")
}
})
})

View File

@@ -21,7 +21,7 @@ var (
type Scrobbler interface { type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool IsAuthorized(ctx context.Context, userId string) bool
NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
Scrobble(ctx context.Context, userId string, s Scrobble) error Scrobble(ctx context.Context, userId string, s Scrobble) error
} }

View File

@@ -2,7 +2,9 @@ package scrobbler
import ( import (
"context" "context"
"maps"
"sort" "sort"
"sync"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@@ -18,6 +20,7 @@ import (
type NowPlayingInfo struct { type NowPlayingInfo struct {
MediaFile model.MediaFile MediaFile model.MediaFile
Start time.Time Start time.Time
Position int
Username string Username string
PlayerId string PlayerId string
PlayerName string PlayerName string
@@ -29,36 +32,53 @@ type Submission struct {
} }
type PlayTracker interface { type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) error Submit(ctx context.Context, submissions []Submission) error
} }
type playTracker struct { // PluginLoader is a minimal interface for plugin manager usage in PlayTracker
ds model.DataStore // (avoids import cycles)
broker events.Broker type PluginLoader interface {
playMap cache.SimpleCache[string, NowPlayingInfo] PluginNames(service string) []string
scrobblers map[string]Scrobbler LoadScrobbler(name string) (Scrobbler, bool)
} }
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker { type playTracker struct {
ds model.DataStore
broker events.Broker
playMap cache.SimpleCache[string, NowPlayingInfo]
builtinScrobblers map[string]Scrobbler
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
return singleton.GetInstance(func() *playTracker { return singleton.GetInstance(func() *playTracker {
return newPlayTracker(ds, broker) return newPlayTracker(ds, broker, pluginManager)
}) })
} }
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by // This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
// the GetPlayTracker function above // the GetPlayTracker function above
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker { func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]() m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{ds: ds, playMap: m, broker: broker} p := &playTracker{
ds: ds,
playMap: m,
broker: broker,
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
}
if conf.Server.EnableNowPlaying { if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) { m.OnExpiration(func(_ string, _ NowPlayingInfo) {
ctx := events.BroadcastToAll(context.Background()) ctx := events.BroadcastToAll(context.Background())
broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()}) broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()})
}) })
} }
p.scrobblers = make(map[string]Scrobbler)
var enabled []string var enabled []string
for name, constructor := range constructors { for name, constructor := range constructors {
s := constructor(ds) s := constructor(ds)
@@ -68,13 +88,92 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
} }
enabled = append(enabled, name) enabled = append(enabled, name)
s = newBufferedScrobbler(ds, s, name) s = newBufferedScrobbler(ds, s, name)
p.scrobblers[name] = s p.builtinScrobblers[name] = s
} }
log.Debug("List of scrobblers enabled", "names", enabled) log.Debug("List of builtin scrobblers enabled", "names", enabled)
return p return p
} }
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error { // pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
return false
}
for _, name := range pluginNames {
if _, ok := scrobblers[name]; !ok {
return false
}
}
return true
}
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers
func (p *playTracker) refreshPluginScrobblers() {
p.mu.Lock()
defer p.mu.Unlock()
if p.pluginLoader == nil {
return
}
// Get the list of available plugin names
pluginNames := p.pluginLoader.PluginNames("Scrobbler")
// Early return if plugin names match existing scrobblers (no change)
if pluginNamesMatchScrobblers(pluginNames, p.pluginScrobblers) {
return
}
// Build a set of current plugins for faster lookups
current := make(map[string]struct{}, len(pluginNames))
// Process additions - add new plugins
for _, name := range pluginNames {
current[name] = struct{}{}
// Only create a new scrobbler if it doesn't exist
if _, exists := p.pluginScrobblers[name]; !exists {
s, ok := p.pluginLoader.LoadScrobbler(name)
if ok && s != nil {
p.pluginScrobblers[name] = newBufferedScrobbler(p.ds, s, name)
}
}
}
// Process removals - remove plugins that no longer exist
for name, scrobbler := range p.pluginScrobblers {
if _, exists := current[name]; !exists {
// Type assertion to access the Stop method
// We need to ensure this works even with interface objects
if bs, ok := scrobbler.(*bufferedScrobbler); ok {
log.Debug("Stopping buffered scrobbler goroutine", "name", name)
bs.Stop()
} else {
// For tests - try to see if this is a mock with a Stop method
type stoppable interface {
Stop()
}
if s, ok := scrobbler.(stoppable); ok {
log.Debug("Stopping mock scrobbler", "name", name)
s.Stop()
}
}
delete(p.pluginScrobblers, name)
}
}
}
// getActiveScrobblers refreshes plugin scrobblers, acquires a read lock,
// combines builtin and plugin scrobblers into a new map, releases the lock,
// and returns the combined map.
func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
p.refreshPluginScrobblers()
p.mu.RLock()
defer p.mu.RUnlock()
combined := maps.Clone(p.builtinScrobblers)
maps.Copy(combined, p.pluginScrobblers)
return combined
}
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
if err != nil { if err != nil {
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
@@ -85,12 +184,20 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
info := NowPlayingInfo{ info := NowPlayingInfo{
MediaFile: *mf, MediaFile: *mf,
Start: time.Now(), Start: time.Now(),
Position: position,
Username: user.UserName, Username: user.UserName,
PlayerId: playerId, PlayerId: playerId,
PlayerName: playerName, PlayerName: playerName,
} }
ttl := time.Duration(int(mf.Duration)+5) * time.Second // Calculate TTL based on remaining track duration. If position exceeds track duration,
// remaining is set to 0 to avoid negative TTL.
remaining := int(mf.Duration) - position
if remaining < 0 {
remaining = 0
}
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl) _ = p.playMap.AddWithTTL(playerId, info, ttl)
if conf.Server.EnableNowPlaying { if conf.Server.EnableNowPlaying {
ctx = events.BroadcastToAll(ctx) ctx = events.BroadcastToAll(ctx)
@@ -98,22 +205,23 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
} }
player, _ := request.PlayerFrom(ctx) player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled { if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf) p.dispatchNowPlaying(ctx, user.ID, mf, position)
} }
return nil return nil
} }
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) { func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist { if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return return
} }
for name, s := range p.scrobblers { allScrobblers := p.getActiveScrobblers()
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, userId) { if !s.IsAuthorized(ctx, userId) {
continue continue
} }
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist) log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
err := s.NowPlaying(ctx, userId, t) err := s.NowPlaying(ctx, userId, t, position)
if err != nil { if err != nil {
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue continue
@@ -185,9 +293,11 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
return return
} }
allScrobblers := p.getActiveScrobblers()
u, _ := request.UserFrom(ctx) u, _ := request.UserFrom(ctx)
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime} scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
for name, s := range p.scrobblers { for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, u.ID) { if !s.IsAuthorized(ctx, u.ID) {
continue continue
} }

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@@ -19,6 +20,23 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
return m.names
}
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
s, ok := m.scrobblers[name]
return s, ok
}
var _ = Describe("PlayTracker", func() { var _ = Describe("PlayTracker", func() {
var ctx context.Context var ctx context.Context
var ds model.DataStore var ds model.DataStore
@@ -44,8 +62,8 @@ var _ = Describe("PlayTracker", func() {
return nil return nil
}) })
eventBroker = &fakeEventBroker{} eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker) tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
track = model.MediaFile{ track = model.MediaFile{
ID: "123", ID: "123",
@@ -69,13 +87,13 @@ var _ = Describe("PlayTracker", func() {
}) })
It("does not register disabled scrobblers", func() { It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).scrobblers).To(HaveKey("fake")) Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).scrobblers).ToNot(HaveKey("disabled")) Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
}) })
Describe("NowPlaying", func() { Describe("NowPlaying", func() {
It("sends track to agent", func() { It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue()) Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1")) Expect(fake.UserID).To(Equal("u-1"))
@@ -85,7 +103,7 @@ var _ = Describe("PlayTracker", func() {
It("does not send track to agent if user has not authorized", func() { It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false fake.Authorized = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse()) Expect(fake.NowPlayingCalled).To(BeFalse())
@@ -93,7 +111,7 @@ var _ = Describe("PlayTracker", func() {
It("does not send track to agent if player is not enabled to send scrobbles", func() { It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse()) Expect(fake.NowPlayingCalled).To(BeFalse())
@@ -101,14 +119,26 @@ var _ = Describe("PlayTracker", func() {
It("does not send track to agent if artist is unknown", func() { It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist track.Artist = consts.UnknownArtist
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse()) Expect(fake.NowPlayingCalled).To(BeFalse())
}) })
It("stores position when greater than zero", func() {
pos := 42
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() { It("sends event with count", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
eventList := eventBroker.getEvents() eventList := eventBroker.getEvents()
Expect(eventList).ToNot(BeEmpty()) Expect(eventList).ToNot(BeEmpty())
@@ -119,7 +149,7 @@ var _ = Describe("PlayTracker", func() {
It("does not send event when disabled", func() { It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false conf.Server.EnableNowPlaying = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty()) Expect(eventBroker.getEvents()).To(BeEmpty())
}) })
@@ -131,9 +161,9 @@ var _ = Describe("PlayTracker", func() {
track2.ID = "456" track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2) _ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"}) ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123") _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"}) ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456") _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx) playing, err := tracker.GetNowPlaying(ctx)
@@ -164,7 +194,7 @@ var _ = Describe("PlayTracker", func() {
It("does not send event when disabled", func() { It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker) tracker = newPlayTracker(ds, eventBroker, nil)
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
@@ -179,10 +209,12 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled).To(BeTrue()) Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1")) Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.LastScrobble.ID).To(Equal("123")) lastScrobble := fake.LastScrobble.Load()
Expect(fake.LastScrobble.Participants).To(Equal(track.Participants)) Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
Expect(lastScrobble.Participants).To(Equal(track.Participants))
}) })
It("increments play counts in the DB", func() { It("increments play counts in the DB", func() {
@@ -206,7 +238,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled).To(BeFalse()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
}) })
It("does not send track to agent if player is not enabled to send scrobbles", func() { It("does not send track to agent if player is not enabled to send scrobbles", func() {
@@ -215,7 +247,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled).To(BeFalse()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
}) })
It("does not send track to agent if artist is unknown", func() { It("does not send track to agent if artist is unknown", func() {
@@ -224,7 +256,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled).To(BeFalse()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
}) })
It("increments play counts even if it cannot scrobble", func() { It("increments play counts even if it cannot scrobble", func() {
@@ -233,7 +265,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled).To(BeFalse()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
Expect(track.PlayCount).To(Equal(int64(1))) Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1)))
@@ -244,15 +276,111 @@ var _ = Describe("PlayTracker", func() {
}) })
}) })
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake fakeScrobbler
BeforeEach(func() {
pluginFake = fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
}
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = &fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
pluginFake.NowPlayingCalled = false
// Remove plugin
pluginLoader.names = []string{}
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.NowPlayingCalled = false
pluginFake.NowPlayingCalled = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
ts := time.Now()
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
Expect(pluginFake.ScrobbleCalled.Load()).To(BeTrue())
})
})
Describe("Plugin Scrobbler Management", func() {
var pluginScr *fakeScrobbler
var mockPlugin *mockPluginLoader
var pTracker *playTracker
var mockedBS *mockBufferedScrobbler
BeforeEach(func() {
ctx = context.Background()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
// Setup plugin scrobbler
pluginScr = &fakeScrobbler{Authorized: true}
mockPlugin = &mockPluginLoader{
names: []string{"plugin1"},
scrobblers: map[string]Scrobbler{"plugin1": pluginScr},
}
// Create a tracker with the mock plugin loader
pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin)
// Create a mock buffered scrobbler and explicitly cast it to Scrobbler
mockedBS = &mockBufferedScrobbler{
wrapped: pluginScr,
}
// Make sure the instance is added with its concrete type preserved
pTracker.pluginScrobblers["plugin1"] = mockedBS
})
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
mockPlugin.names = []string{}
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
// Verify the Stop method was called
Expect(mockedBS.stopCalled).To(BeTrue())
// Verify the scrobbler was removed from the map
Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1"))
})
})
}) })
type fakeScrobbler struct { type fakeScrobbler struct {
Authorized bool Authorized bool
NowPlayingCalled bool NowPlayingCalled bool
ScrobbleCalled bool ScrobbleCalled atomic.Bool
UserID string UserID string
Track *model.MediaFile Track *model.MediaFile
LastScrobble Scrobble Position int
LastScrobble atomic.Pointer[Scrobble]
Error error Error error
} }
@@ -260,23 +388,24 @@ func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized return f.Error == nil && f.Authorized
} }
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
f.NowPlayingCalled = true f.NowPlayingCalled = true
if f.Error != nil { if f.Error != nil {
return f.Error return f.Error
} }
f.UserID = userId f.UserID = userId
f.Track = track f.Track = track
f.Position = position
return nil return nil
} }
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.ScrobbleCalled = true f.UserID = userId
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil { if f.Error != nil {
return f.Error return f.Error
} }
f.UserID = userId
f.LastScrobble = s
return nil return nil
} }
@@ -307,3 +436,25 @@ func (f *fakeEventBroker) getEvents() []events.Event {
} }
var _ events.Broker = (*fakeEventBroker)(nil) var _ events.Broker = (*fakeEventBroker)(nil)
// mockBufferedScrobbler used to test that Stop is called
type mockBufferedScrobbler struct {
wrapped Scrobbler
stopCalled bool
}
func (m *mockBufferedScrobbler) Stop() {
m.stopCalled = true
}
func (m *mockBufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return m.wrapped.IsAuthorized(ctx, userId)
}
func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
return m.wrapped.NowPlaying(ctx, userId, track, position)
}
func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
return m.wrapped.Scrobble(ctx, userId, s)
}

View File

@@ -12,7 +12,7 @@
gofmtcmd="go tool goimports" gofmtcmd="go tool goimports"
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$') gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$' | grep -v '.pb.go$')
[ -z "$gofiles" ] && exit 0 [ -z "$gofiles" ] && exit 0
unformatted=$($gofmtcmd -l $gofiles) unformatted=$($gofmtcmd -l $gofiles)

39
go.mod
View File

@@ -31,10 +31,12 @@ require (
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/google/wire v0.6.0 github.com/google/wire v0.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.3.0 github.com/jellydator/ttlcache/v3 v3.3.0
github.com/kardianos/service v1.2.2 github.com/kardianos/service v1.2.2
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1 github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/matoous/go-nanoid/v2 v2.1.0 github.com/matoous/go-nanoid/v2 v2.1.0
@@ -54,20 +56,24 @@ require (
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/tetratelabs/wazero v1.9.0
github.com/unrolled/secure v1.17.0 github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
golang.org/x/image v0.28.0 golang.org/x/image v0.27.0
golang.org/x/net v0.41.0 golang.org/x/net v0.40.0
golang.org/x/sync v0.15.0 golang.org/x/sync v0.14.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.33.0
golang.org/x/text v0.26.0 golang.org/x/text v0.25.0
golang.org/x/time v0.12.0 golang.org/x/time v0.11.0
google.golang.org/protobuf v1.36.6
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
dario.cat/mergo v1.0.2 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/reflex v0.3.1 // indirect github.com/cespare/reflex v0.3.1 // indirect
@@ -76,12 +82,13 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
github.com/google/subcommands v1.2.0 // indirect github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -90,40 +97,44 @@ require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ogier/pflag v0.0.1 // indirect github.com/ogier/pflag v0.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.25.0 // indirect golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.34.0 // indirect golang.org/x/tools v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
) )
tool ( tool (
github.com/atombender/go-jsonschema
github.com/cespare/reflex github.com/cespare/reflex
github.com/google/wire/cmd/wire github.com/google/wire/cmd/wire
github.com/onsi/ginkgo/v2/ginkgo github.com/onsi/ginkgo/v2/ginkgo

75
go.sum
View File

@@ -1,3 +1,5 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@@ -6,6 +8,8 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -22,6 +26,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -63,8 +68,8 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -76,6 +81,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -85,8 +92,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -97,6 +104,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -116,6 +125,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI=
github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -130,8 +141,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
@@ -154,6 +165,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -167,6 +180,9 @@ github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -198,6 +214,8 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
@@ -209,12 +227,14 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
@@ -225,6 +245,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -234,6 +255,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
@@ -256,21 +279,21 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -283,8 +306,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -292,8 +315,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -334,10 +357,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -346,8 +369,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=

View File

@@ -203,6 +203,10 @@ func log(level Level, args ...interface{}) {
logger.Log(logrus.Level(level), msg) logger.Log(logrus.Level(level), msg)
} }
func Writer() io.Writer {
return defaultLogger.Writer()
}
func shouldLog(requiredLevel Level, skip int) bool { func shouldLog(requiredLevel Level, skip int) bool {
if currentLevel >= requiredLevel { if currentLevel >= requiredLevel {
return true return true

View File

@@ -42,8 +42,9 @@ func (h *Hook) Fire(e *logrus.Entry) error {
e.Data[k] = "[REDACTED]" e.Data[k] = "[REDACTED]"
continue continue
} }
if v == nil {
// Redact based on value matching in Data fields continue
}
switch reflect.TypeOf(v).Kind() { switch reflect.TypeOf(v).Kind() {
case reflect.String: case reflect.String:
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2") e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")

View File

@@ -152,7 +152,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil()) Expect(entry).ToNot(BeNil())
Expect(entry.EnqueueTime).To(BeTemporally("~", now)) Expect(entry.EnqueueTime).To(BeTemporally("~", now, 100*time.Millisecond))
Expect(entry.MediaFileID).To(Equal(fileId)) Expect(entry.MediaFileID).To(Equal(fileId))
Expect(entry.PlayTime).To(BeTemporally("==", playTime)) Expect(entry.PlayTime).To(BeTemporally("==", playTime))
}, },

1568
plugins/README.md Normal file
View File

File diff suppressed because it is too large Load Diff

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
}

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())
})
})
})

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]
}

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
}

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]
}

1137
plugins/api/api.pb.go Normal file
View File

File diff suppressed because it is too large Load Diff

247
plugins/api/api.proto Normal file
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 {}

1688
plugins/api/api_host.pb.go Normal file
View File

File diff suppressed because it is too large Load Diff

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
}
}

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)
}

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")
}

View File

@@ -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)
}

View File

File diff suppressed because it is too large Load Diff

8
plugins/api/errors.go Normal file
View File

@@ -0,0 +1,8 @@
package api
import "errors"
var (
ErrNotFound = errors.New("plugin:not_found")
ErrNotImplemented = errors.New("plugin:not_implemented")
)

145
plugins/discovery.go Normal file
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
plugins/discovery_test.go Normal file
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
plugins/examples/Makefile Normal file
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

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.

View File

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

View File

@@ -0,0 +1,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
}
}
}

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{})
}

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`.

View File

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

View File

@@ -0,0 +1,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{})
}

View File

@@ -0,0 +1,88 @@
# Discord Rich Presence Plugin
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time
connection to an external service while remaining completely stateless. This plugin is based on the
[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality.
**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the
Navidrome configuration file, which is not secure, and may be against Discord's terms of service.
Use it at your own risk.**
## Overview
The plugin exposes three capabilities:
- **Scrobbler** receives `NowPlaying` notifications from Navidrome
- **WebSocketCallback** handles Discord gateway messages
- **SchedulerCallback** used to clear presence and send periodic heartbeats
It relies on several host services declared in `manifest.json`:
- `http` queries Discord API endpoints
- `websocket` maintains gateway connections
- `scheduler` schedules heartbeats and presence cleanup
- `cache` stores sequence numbers for heartbeats
- `config` retrieves the plugin configuration on each call
- `artwork` resolves track artwork URLs
## Architecture
Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the
scheduler service:
```go
api.RegisterScrobbler(plugin)
api.RegisterWebSocketCallback(plugin.rpc)
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
```
When `NowPlaying` is invoked the plugin:
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
2. Connects to Discord using `WebSocketService` if no connection exists.
3. Sends the activity payload with track details and artwork.
4. Schedules a onetime callback to clear the presence after the track finishes.
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in
`CacheService` to remain available across plugin instances.
The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached.
```go
// The plugin is stateless, we need to load the configuration every time
clientID, users, err := d.getConfig(ctx)
```
## Configuration
Add the following to `navidrome.toml` and adjust for your tokens:
```toml
[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`.

View File

@@ -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"
}
}
}

View File

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

View File

@@ -0,0 +1,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)
}

View File

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

View File

@@ -0,0 +1,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
}
}
}

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{})
}

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)
}

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;
}

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
}

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
}

View File

@@ -0,0 +1,7 @@
//go:build !wasip1
package artwork
func NewArtworkService() ArtworkService {
panic("not implemented")
}

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
plugins/host/cache/cache.pb.go vendored Normal file
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
plugins/host/cache/cache.proto vendored Normal file
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
plugins/host/cache/cache_host.pb.go vendored Normal file
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
plugins/host/cache/cache_plugin.pb.go vendored Normal file
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
}

View File

@@ -0,0 +1,7 @@
//go:build !wasip1
package cache
func NewCacheService() CacheService {
panic("not implemented")
}

2352
plugins/host/cache/cache_vtproto.pb.go vendored Normal file
View File

File diff suppressed because it is too large Load Diff

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)
}

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;
}

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
}

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
}

View File

@@ -0,0 +1,7 @@
//go:build !wasip1
package config
func NewConfigService() ConfigService {
panic("not implemented")
}

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")
)

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)
}

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
}

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
}

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
}

View File

@@ -0,0 +1,7 @@
//go:build !wasip1
package http
func NewHttpService() HttpService {
panic("not implemented")
}

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")
)

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)
}

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
}

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
}

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/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
}

View File

@@ -0,0 +1,7 @@
//go:build !wasip1
package scheduler
func NewSchedulerService() SchedulerService {
panic("not implemented")
}

View File

File diff suppressed because it is too large Load Diff

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)
}

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;
}

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
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
//go:build !wasip1
package websocket
func NewWebSocketService() WebSocketService {
panic("not implemented")
}

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