mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
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:
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
args: --timeout 2m
|
||||
|
||||
- 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
|
||||
- name: Verify no changes from goimports and go mod tidy
|
||||
run: |
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
/navidrome
|
||||
/iTunes*.xml
|
||||
/tmp
|
||||
/bin
|
||||
data/*
|
||||
vendor/*/
|
||||
wiki
|
||||
@@ -26,4 +27,5 @@ binaries
|
||||
navidrome-master
|
||||
AGENTS.md
|
||||
*.exe
|
||||
bin/
|
||||
*.test
|
||||
*.wasm
|
||||
13
Makefile
13
Makefile
@@ -221,6 +221,19 @@ deprecated:
|
||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||
.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
|
||||
|
||||
HELP_FUN = \
|
||||
|
||||
17
cmd/cmd_suite_test.go
Normal file
17
cmd/cmd_suite_test.go
Normal 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
704
cmd/plugin.go
Normal 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
193
cmd/plugin_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
24
cmd/root.go
24
cmd/root.go
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
@@ -82,6 +83,7 @@ func runNavidrome(ctx context.Context) {
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBOptimizer(ctx))
|
||||
g.Go(startPluginManager(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
g.Go(runInitialScan(ctx))
|
||||
g.Go(startScanWatcher(ctx))
|
||||
@@ -147,7 +149,7 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
log.Info("Scheduling periodic scan", "schedule", schedule)
|
||||
err := schedulerInstance.Add(schedule, func() {
|
||||
_, err := schedulerInstance.Add(schedule, func() {
|
||||
_, err := s.ScanAll(ctx, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error executing periodic scan", err)
|
||||
@@ -243,7 +245,7 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
log.Info("Scheduling periodic backup", "schedule", schedule)
|
||||
err := schedulerInstance.Add(schedule, func() {
|
||||
_, err := schedulerInstance.Add(schedule, func() {
|
||||
start := time.Now()
|
||||
path, err := db.Backup(ctx)
|
||||
elapsed := time.Since(start)
|
||||
@@ -271,7 +273,7 @@ func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||
if scanner.IsScanning() {
|
||||
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||
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
|
||||
func init() {
|
||||
cobra.OnInitialize(func() {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
@@ -66,7 +67,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
manager := plugins.GetManager()
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
@@ -79,7 +81,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
return router
|
||||
@@ -90,7 +92,8 @@ func CreatePublicRouter() *public.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
manager := plugins.GetManager()
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
@@ -134,7 +137,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
manager := plugins.GetManager()
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
@@ -150,7 +154,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
manager := plugins.GetManager()
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
@@ -171,4 +176,4 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
@@ -36,6 +39,9 @@ var allProviders = wire.NewSet(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -88,6 +88,8 @@ type configOptions struct {
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
Plugins pluginsOptions
|
||||
PluginConfig map[string]map[string]string
|
||||
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
||||
Prometheus prometheusOptions `json:",omitzero"`
|
||||
Scanner scannerOptions `json:",omitzero"`
|
||||
@@ -123,6 +125,7 @@ type configOptions struct {
|
||||
DevScannerThreads uint
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@@ -209,6 +212,12 @@ type inspectOptions struct {
|
||||
BacklogTimeout int
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -248,6 +257,15 @@ func Load(noConfigDump bool) {
|
||||
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()
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
@@ -483,6 +501,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
@@ -521,7 +540,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.purgemissing", "never")
|
||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
@@ -546,7 +565,11 @@ func setViperDefaults() {
|
||||
viper.SetDefault("inspect.maxrequests", 1)
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
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("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
@@ -566,6 +589,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devscannerthreads", 5)
|
||||
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
|
||||
viper.SetDefault("devenableplayerinsights", true)
|
||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -2,7 +2,9 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -13,43 +15,156 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
agents []Interface
|
||||
// PluginLoader defines an interface for loading plugins
|
||||
type PluginLoader 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 createAgents(ds)
|
||||
return createAgents(ds, pluginLoader)
|
||||
})
|
||||
}
|
||||
|
||||
func createAgents(ds model.DataStore) *Agents {
|
||||
var order []string
|
||||
if conf.Server.Agents != "" {
|
||||
order = strings.Split(conf.Server.Agents, ",")
|
||||
// createAgents creates a new Agents instance. Used in tests
|
||||
func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
||||
return &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)
|
||||
if agent == nil {
|
||||
log.Debug("Agent not available. Missing configuration?", "name", name)
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
res = append(res, init(ds))
|
||||
// getEnabledAgentNames returns the current list of enabled agent names, including:
|
||||
// 1. Built-in agents and plugins from config (in the specified order)
|
||||
// 2. Always include LocalAgentName
|
||||
// 3. If config is empty, include ONLY LocalAgentName
|
||||
func (a *Agents) getEnabledAgentNames() []string {
|
||||
// If no agents configured, ONLY use the local agent
|
||||
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 {
|
||||
@@ -64,15 +179,19 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
||||
return "", nil
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistMBIDRetriever)
|
||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mbid, err := agent.GetArtistMBID(ctx, id, name)
|
||||
mbid, err := retriever.GetArtistMBID(ctx, id, name)
|
||||
if mbid != "" && err == nil {
|
||||
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
return mbid, nil
|
||||
@@ -89,15 +208,19 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
||||
return "", nil
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistURLRetriever)
|
||||
retriever, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
url, err := agent.GetArtistURL(ctx, id, name, mbid)
|
||||
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
if url != "" && err == nil {
|
||||
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
|
||||
return url, nil
|
||||
@@ -114,15 +237,19 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||
return "", nil
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistBiographyRetriever)
|
||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bio, err := agent.GetArtistBiography(ctx, id, name, mbid)
|
||||
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, nil
|
||||
@@ -139,15 +266,19 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
return nil, nil
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistSimilarRetriever)
|
||||
retriever, ok := ag.(ArtistSimilarRetriever)
|
||||
if !ok {
|
||||
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 log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistImageRetriever)
|
||||
retriever, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
images, err := agent.GetArtistImages(ctx, id, name, mbid)
|
||||
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
|
||||
return images, nil
|
||||
@@ -193,15 +328,19 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||
return nil, nil
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistTopSongsRetriever)
|
||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
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 {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, nil
|
||||
@@ -215,15 +354,19 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
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) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(AlbumInfoRetriever)
|
||||
retriever, ok := ag.(AlbumInfoRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
album, err := agent.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||
"mbid", mbid, "elapsed", time.Since(start))
|
||||
@@ -233,6 +376,33 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
||||
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 _ ArtistMBIDRetriever = (*Agents)(nil)
|
||||
var _ ArtistURLRetriever = (*Agents)(nil)
|
||||
@@ -241,3 +411,4 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
|
||||
var _ ArtistImageRetriever = (*Agents)(nil)
|
||||
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
||||
var _ AlbumInfoRetriever = (*Agents)(nil)
|
||||
var _ AlbumImageRetriever = (*Agents)(nil)
|
||||
|
||||
221
core/agents/agents_plugin_test.go
Normal file
221
core/agents/agents_plugin_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -29,7 +28,7 @@ var _ = Describe("Agents", func() {
|
||||
var ag *Agents
|
||||
BeforeEach(func() {
|
||||
conf.Server.Agents = ""
|
||||
ag = createAgents(ds)
|
||||
ag = createAgents(ds, nil)
|
||||
})
|
||||
|
||||
It("calls the placeholder GetArtistImages", func() {
|
||||
@@ -49,12 +48,18 @@ var _ = Describe("Agents", func() {
|
||||
Register("disabled", func(model.DataStore) Interface { return nil })
|
||||
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
|
||||
conf.Server.Agents = "empty,fake,disabled"
|
||||
ag = createAgents(ds)
|
||||
ag = createAgents(ds, nil)
|
||||
Expect(ag.AgentName()).To(Equal("agents"))
|
||||
})
|
||||
|
||||
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
|
||||
Expect(ags).To(HaveExactElements("empty", "fake", "local"))
|
||||
Expect(ags).ToNot(ContainElement("disabled"))
|
||||
@@ -187,7 +192,7 @@ var _ = Describe("Agents", func() {
|
||||
|
||||
It("falls back to the next agent on error", func() {
|
||||
conf.Server.Agents = "imgFail,imgOk"
|
||||
ag = createAgents(ds)
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -200,7 +205,7 @@ var _ = Describe("Agents", func() {
|
||||
first.Err = nil
|
||||
first.Images = []ExternalImage{}
|
||||
conf.Server.Agents = "imgFail,imgOk"
|
||||
ag = createAgents(ds)
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -262,18 +267,6 @@ var _ = Describe("Agents", func() {
|
||||
MBID: "mbid444",
|
||||
Description: "A Description",
|
||||
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"))
|
||||
})
|
||||
@@ -369,18 +362,6 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
|
||||
MBID: "mbid444",
|
||||
Description: "A Description",
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ type Interface interface {
|
||||
AgentName() string
|
||||
}
|
||||
|
||||
// AlbumInfo contains album metadata (no images)
|
||||
type AlbumInfo struct {
|
||||
Name string
|
||||
MBID string
|
||||
Description string
|
||||
URL string
|
||||
Images []ExternalImage
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
@@ -40,11 +40,16 @@ var (
|
||||
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 {
|
||||
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 {
|
||||
GetArtistMBID(ctx context.Context, id string, name string) (string, error)
|
||||
}
|
||||
|
||||
@@ -72,16 +72,23 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := agents.AlbumInfo{
|
||||
return &agents.AlbumInfo{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
Description: a.Description.Summary,
|
||||
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.
|
||||
seenSizes := map[int]bool{}
|
||||
images := make([]agents.ExternalImage, 0)
|
||||
|
||||
// This assumes that Last.fm returns images with size small, medium, and large.
|
||||
// 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)
|
||||
continue
|
||||
}
|
||||
|
||||
numericSize, err := strconv.Atoi(size[0][2:])
|
||||
if err != nil {
|
||||
log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err)
|
||||
return nil, err
|
||||
} else {
|
||||
if _, exists := seenSizes[numericSize]; !exists {
|
||||
response.Images = append(response.Images, agents.ExternalImage{
|
||||
Size: numericSize,
|
||||
URL: img.URL,
|
||||
})
|
||||
seenSizes[numericSize] = true
|
||||
}
|
||||
}
|
||||
if _, exists := seenSizes[numericSize]; !exists {
|
||||
images = append(images, agents.ExternalImage{
|
||||
Size: numericSize,
|
||||
URL: img.URL,
|
||||
})
|
||||
seenSizes[numericSize] = true
|
||||
}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
return images, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
|
||||
@@ -209,7 +209,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
It("calls Last.fm with correct params", func() {
|
||||
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(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
@@ -226,7 +226,7 @@ var _ = Describe("lastfmAgent", 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))
|
||||
})
|
||||
})
|
||||
@@ -345,24 +345,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||
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>.",
|
||||
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.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")
|
||||
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{
|
||||
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",
|
||||
Images: []agents.ExternalImage{},
|
||||
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",
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
|
||||
|
||||
@@ -73,7 +73,7 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
||||
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)
|
||||
if err != nil || sk == "" {
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
|
||||
@@ -79,12 +79,12 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
It("updates NowPlaying successfully", func() {
|
||||
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())
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
22
core/external/extdata_helper_test.go
vendored
22
core/external/extdata_helper_test.go
vendored
@@ -190,10 +190,13 @@ type mockAgents struct {
|
||||
topSongsAgent agents.ArtistTopSongsRetriever
|
||||
similarAgent agents.ArtistSimilarRetriever
|
||||
imageAgent agents.ArtistImageRetriever
|
||||
albumInfoAgent agents.AlbumInfoRetriever
|
||||
bioAgent agents.ArtistBiographyRetriever
|
||||
mbidAgent agents.ArtistMBIDRetriever
|
||||
urlAgent agents.ArtistURLRetriever
|
||||
albumInfoAgent interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
bioAgent agents.ArtistBiographyRetriever
|
||||
mbidAgent agents.ArtistMBIDRetriever
|
||||
urlAgent agents.ArtistURLRetriever
|
||||
agents.Interface
|
||||
}
|
||||
|
||||
@@ -268,3 +271,14 @@ func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
31
core/external/provider.go
vendored
31
core/external/provider.go
vendored
@@ -60,6 +60,7 @@ type auxArtist struct {
|
||||
|
||||
type Agents interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
agents.ArtistBiographyRetriever
|
||||
agents.ArtistMBIDRetriever
|
||||
agents.ArtistImageRetriever
|
||||
@@ -140,19 +141,20 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
album.Description = info.Description
|
||||
}
|
||||
|
||||
if len(info.Images) > 0 {
|
||||
sort.Slice(info.Images, func(i, j int) bool {
|
||||
return info.Images[i].Size > info.Images[j].Size
|
||||
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err == nil && len(images) > 0 {
|
||||
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 {
|
||||
album.MediumImageUrl = info.Images[1].URL
|
||||
if len(images) >= 2 {
|
||||
album.MediumImageUrl = images[1].URL
|
||||
}
|
||||
|
||||
if len(info.Images) >= 3 {
|
||||
album.SmallImageUrl = info.Images[2].URL
|
||||
if len(images) >= 3 {
|
||||
album.SmallImageUrl = images[2].URL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,29 +343,28 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
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 {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumInfo call canceled", err)
|
||||
log.Debug(ctx, "GetAlbumImages call canceled", err)
|
||||
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
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
if len(images) == 0 {
|
||||
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
// Return the biggest image
|
||||
var img agents.ExternalImage
|
||||
for _, i := range info.Images {
|
||||
for _, i := range images {
|
||||
if img.Size <= i.Size {
|
||||
img = i
|
||||
}
|
||||
|
||||
96
core/external/provider_albumimage_test.go
vendored
96
core/external/provider_albumimage_test.go
vendored
@@ -23,7 +23,6 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockAlbumAgent *mockAlbumInfoAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -43,10 +42,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
albumInfoAgent: mockAlbumAgent,
|
||||
}
|
||||
|
||||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks
|
||||
@@ -66,13 +62,11 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
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()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
},
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
@@ -82,8 +76,8 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
|
||||
})
|
||||
|
||||
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")
|
||||
mockAlbumRepo.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() {
|
||||
@@ -109,7 +103,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
|
||||
agentErr := errors.New("agent failure")
|
||||
// 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")
|
||||
|
||||
@@ -118,7 +112,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-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() {
|
||||
@@ -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()
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -135,7 +129,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.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() {
|
||||
@@ -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()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
@@ -153,7 +147,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.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() {
|
||||
@@ -163,7 +157,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
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()
|
||||
// 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
|
||||
cancelCtx()
|
||||
|
||||
@@ -174,7 +168,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
// 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() {
|
||||
@@ -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()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
},
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
@@ -206,7 +198,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-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() {
|
||||
@@ -214,13 +206,11 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
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()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
},
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
@@ -228,7 +218,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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() {
|
||||
@@ -236,11 +226,9 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
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()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
},
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||||
@@ -248,7 +236,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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() {
|
||||
@@ -270,14 +258,15 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.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
|
||||
type mockAlbumInfoAgent struct {
|
||||
mock.Mock
|
||||
agents.AlbumInfoRetriever // Embed interface
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
|
||||
13
core/external/provider_updatealbuminfo_test.go
vendored
13
core/external/provider_updatealbuminfo_test.go
vendored
@@ -59,13 +59,13 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
expectedInfo := &agents.AlbumInfo{
|
||||
URL: "http://example.com/album",
|
||||
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("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")
|
||||
|
||||
@@ -74,9 +74,6 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
Expect(updatedAlbum.ID).To(Equal("al-existing"))
|
||||
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
|
||||
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).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
|
||||
@@ -10,9 +10,16 @@ import (
|
||||
)
|
||||
|
||||
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
||||
b := &bufferedScrobbler{ds: ds, wrapped: s, service: service}
|
||||
b.wakeSignal = make(chan struct{}, 1)
|
||||
go b.run(context.TODO())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b := &bufferedScrobbler{
|
||||
ds: ds,
|
||||
wrapped: s,
|
||||
service: service,
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
go b.run(ctx)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -21,14 +28,22 @@ type bufferedScrobbler struct {
|
||||
wrapped Scrobbler
|
||||
service string
|
||||
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 {
|
||||
return b.wrapped.IsAuthorized(ctx, userId)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
return b.wrapped.NowPlaying(ctx, userId, track)
|
||||
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
return b.wrapped.NowPlaying(ctx, userId, track, position)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||
|
||||
88
core/scrobbler/buffered_scrobbler_test.go
Normal file
88
core/scrobbler/buffered_scrobbler_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ var (
|
||||
|
||||
type Scrobbler interface {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
type NowPlayingInfo struct {
|
||||
MediaFile model.MediaFile
|
||||
Start time.Time
|
||||
Position int
|
||||
Username string
|
||||
PlayerId string
|
||||
PlayerName string
|
||||
@@ -29,36 +32,53 @@ type Submission struct {
|
||||
}
|
||||
|
||||
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)
|
||||
Submit(ctx context.Context, submissions []Submission) error
|
||||
}
|
||||
|
||||
type playTracker struct {
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
playMap cache.SimpleCache[string, NowPlayingInfo]
|
||||
scrobblers map[string]Scrobbler
|
||||
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
|
||||
// (avoids import cycles)
|
||||
type PluginLoader interface {
|
||||
PluginNames(service string) []string
|
||||
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 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
|
||||
// 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]()
|
||||
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 {
|
||||
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
|
||||
ctx := events.BroadcastToAll(context.Background())
|
||||
broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()})
|
||||
})
|
||||
}
|
||||
p.scrobblers = make(map[string]Scrobbler)
|
||||
|
||||
var enabled []string
|
||||
for name, constructor := range constructors {
|
||||
s := constructor(ds)
|
||||
@@ -68,13 +88,92 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
}
|
||||
enabled = append(enabled, 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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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{
|
||||
MediaFile: *mf,
|
||||
Start: time.Now(),
|
||||
Position: position,
|
||||
Username: user.UserName,
|
||||
PlayerId: playerId,
|
||||
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)
|
||||
if conf.Server.EnableNowPlaying {
|
||||
ctx = events.BroadcastToAll(ctx)
|
||||
@@ -98,22 +205,23 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
}
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchNowPlaying(ctx, user.ID, mf)
|
||||
p.dispatchNowPlaying(ctx, user.ID, mf, position)
|
||||
}
|
||||
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 {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
for name, s := range p.scrobblers {
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
err := s.NowPlaying(ctx, userId, t)
|
||||
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
|
||||
err := s.NowPlaying(ctx, userId, t, position)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
allScrobblers := p.getActiveScrobblers()
|
||||
u, _ := request.UserFrom(ctx)
|
||||
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
|
||||
for name, s := range p.scrobblers {
|
||||
for name, s := range allScrobblers {
|
||||
if !s.IsAuthorized(ctx, u.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -19,6 +20,23 @@ import (
|
||||
. "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 ctx context.Context
|
||||
var ds model.DataStore
|
||||
@@ -44,8 +62,8 @@ var _ = Describe("PlayTracker", func() {
|
||||
return nil
|
||||
})
|
||||
eventBroker = &fakeEventBroker{}
|
||||
tracker = newPlayTracker(ds, eventBroker)
|
||||
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
@@ -69,13 +87,13 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does not register disabled scrobblers", func() {
|
||||
Expect(tracker.(*playTracker).scrobblers).To(HaveKey("fake"))
|
||||
Expect(tracker.(*playTracker).scrobblers).ToNot(HaveKey("disabled"))
|
||||
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
|
||||
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
|
||||
})
|
||||
|
||||
Describe("NowPlaying", 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(fake.NowPlayingCalled).To(BeTrue())
|
||||
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() {
|
||||
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(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() {
|
||||
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(fake.NowPlayingCalled).To(BeFalse())
|
||||
@@ -101,14 +119,26 @@ var _ = Describe("PlayTracker", func() {
|
||||
It("does not send track to agent if artist is unknown", func() {
|
||||
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(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() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
eventList := eventBroker.getEvents()
|
||||
Expect(eventList).ToNot(BeEmpty())
|
||||
@@ -119,7 +149,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
It("does not send event when disabled", func() {
|
||||
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(eventBroker.getEvents()).To(BeEmpty())
|
||||
})
|
||||
@@ -131,9 +161,9 @@ var _ = Describe("PlayTracker", func() {
|
||||
track2.ID = "456"
|
||||
_ = ds.MediaFile(ctx).Put(&track2)
|
||||
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"})
|
||||
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456")
|
||||
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
|
||||
@@ -164,7 +194,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
It("does not send event when disabled", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
tracker = newPlayTracker(ds, eventBroker)
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
|
||||
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}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeTrue())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
Expect(fake.UserID).To(Equal("u-1"))
|
||||
Expect(fake.LastScrobble.ID).To(Equal("123"))
|
||||
Expect(fake.LastScrobble.Participants).To(Equal(track.Participants))
|
||||
lastScrobble := fake.LastScrobble.Load()
|
||||
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() {
|
||||
@@ -206,7 +238,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
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() {
|
||||
@@ -215,7 +247,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
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() {
|
||||
@@ -224,7 +256,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
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() {
|
||||
@@ -233,7 +265,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
||||
|
||||
Expect(track.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 {
|
||||
Authorized bool
|
||||
NowPlayingCalled bool
|
||||
ScrobbleCalled bool
|
||||
ScrobbleCalled atomic.Bool
|
||||
UserID string
|
||||
Track *model.MediaFile
|
||||
LastScrobble Scrobble
|
||||
Position int
|
||||
LastScrobble atomic.Pointer[Scrobble]
|
||||
Error error
|
||||
}
|
||||
|
||||
@@ -260,23 +388,24 @@ func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
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
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
f.UserID = userId
|
||||
f.Track = track
|
||||
f.Position = position
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return f.Error
|
||||
}
|
||||
f.UserID = userId
|
||||
f.LastScrobble = s
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -307,3 +436,25 @@ func (f *fakeEventBroker) getEvents() []events.Event {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
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
|
||||
|
||||
unformatted=$($gofmtcmd -l $gofiles)
|
||||
|
||||
39
go.mod
39
go.mod
@@ -31,10 +31,12 @@ require (
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||
github.com/google/uuid v1.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/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
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/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
@@ -54,20 +56,24 @@ require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
|
||||
golang.org/x/image v0.27.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.26.0
|
||||
golang.org/x/time v0.12.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.11.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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/beorn7/perks v1.0.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/decred/dcrd/dcrec/secp256k1/v4 v4.4.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-viper/mapstructure/v2 v2.2.1 // 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/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/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -90,40 +97,44 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // 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/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // 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/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/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // 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/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/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/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/atombender/go-jsonschema
|
||||
github.com/cespare/reflex
|
||||
github.com/google/wire/cmd/wire
|
||||
github.com/onsi/ginkgo/v2/ginkgo
|
||||
|
||||
75
go.sum
75
go.sum
@@ -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/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
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/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
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.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/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-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
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.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
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/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-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/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
|
||||
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-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/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
|
||||
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/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
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.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
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/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
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/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/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
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/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
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/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
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/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
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/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
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/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
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/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
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/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
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/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
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.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
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/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
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.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
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.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
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.8.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.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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
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.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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
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-20220722155255-886fb9371eb4/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.7.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.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
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-20180926160741-c2ed4eda69e7/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.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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
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-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
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.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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
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=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
|
||||
@@ -203,6 +203,10 @@ func log(level Level, args ...interface{}) {
|
||||
logger.Log(logrus.Level(level), msg)
|
||||
}
|
||||
|
||||
func Writer() io.Writer {
|
||||
return defaultLogger.Writer()
|
||||
}
|
||||
|
||||
func shouldLog(requiredLevel Level, skip int) bool {
|
||||
if currentLevel >= requiredLevel {
|
||||
return true
|
||||
|
||||
@@ -42,8 +42,9 @@ func (h *Hook) Fire(e *logrus.Entry) error {
|
||||
e.Data[k] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
|
||||
// Redact based on value matching in Data fields
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.String:
|
||||
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
|
||||
|
||||
@@ -152,7 +152,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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.PlayTime).To(BeTemporally("==", playTime))
|
||||
},
|
||||
|
||||
1568
plugins/README.md
Normal file
1568
plugins/README.md
Normal file
File diff suppressed because it is too large
Load Diff
165
plugins/adapter_media_agent.go
Normal file
165
plugins/adapter_media_agent.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
|
||||
func newWasmMediaAgent(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmMediaAgent{
|
||||
wasmBasePlugin: &wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]{
|
||||
wasmPath: wasmPath,
|
||||
id: pluginID,
|
||||
capability: CapabilityMetadataAgent,
|
||||
loader: loader,
|
||||
loadFunc: func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
|
||||
type wasmMediaAgent struct {
|
||||
*wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) AgentName() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) mapError(err error) error {
|
||||
if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
|
||||
return agents.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Album-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
return callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*agents.AlbumInfo, error) {
|
||||
res, err := inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
if res == nil || res.Info == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
info := res.Info
|
||||
return &agents.AlbumInfo{
|
||||
Name: info.Name,
|
||||
MBID: info.Mbid,
|
||||
Description: info.Description,
|
||||
URL: info.Url,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
return callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
|
||||
res, err := inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
})
|
||||
}
|
||||
|
||||
// Artist-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
return callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (string, error) {
|
||||
res, err := inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetMbid(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
return callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (string, error) {
|
||||
res, err := inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetUrl(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
return callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (string, error) {
|
||||
res, err := inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetBiography(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
return callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) ([]agents.Artist, error) {
|
||||
resp, err := inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
||||
for _, a := range resp.GetArtists() {
|
||||
artists = append(artists, agents.Artist{
|
||||
Name: a.GetName(),
|
||||
MBID: a.GetMbid(),
|
||||
})
|
||||
}
|
||||
return artists, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
return callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
|
||||
res, err := inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
return callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) ([]agents.Song, error) {
|
||||
resp, err := inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
||||
for _, s := range resp.GetSongs() {
|
||||
songs = append(songs, agents.Song{
|
||||
Name: s.GetName(),
|
||||
MBID: s.GetMbid(),
|
||||
})
|
||||
}
|
||||
return songs, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to convert ExternalImage objects from the API to the agents package
|
||||
func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
|
||||
result := make([]agents.ExternalImage, 0, len(images))
|
||||
for _, img := range images {
|
||||
result = append(result, agents.ExternalImage{
|
||||
URL: img.GetUrl(),
|
||||
Size: int(img.GetSize()),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
220
plugins/adapter_media_agent_test.go
Normal file
220
plugins/adapter_media_agent_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Adapter Media Agent", func() {
|
||||
var ctx context.Context
|
||||
var mgr *Manager
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Ensure plugins folder is set to testdata
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
|
||||
mgr = createManager()
|
||||
mgr.ScanPlugins()
|
||||
})
|
||||
|
||||
Describe("AgentName and PluginName", func() {
|
||||
It("should return the plugin name", func() {
|
||||
agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
|
||||
Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
|
||||
Expect(agent.PluginID()).To(Equal("multi_plugin"))
|
||||
})
|
||||
It("should return the agent name", func() {
|
||||
agent, ok := mgr.LoadMediaAgent("multi_plugin")
|
||||
Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
|
||||
Expect(agent.AgentName()).To(Equal("multi_plugin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_album_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetAlbumInfo", func() {
|
||||
It("should return album information", func() {
|
||||
info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(info).NotTo(BeNil())
|
||||
Expect(info.Name).To(Equal("Test Album"))
|
||||
Expect(info.MBID).To(Equal("album-mbid-123"))
|
||||
Expect(info.Description).To(Equal("This is a test album description"))
|
||||
Expect(info.URL).To(Equal("https://example.com/album"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns nil response", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "", "", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetAlbumImages", func() {
|
||||
It("should return album images", func() {
|
||||
images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/album1.jpg", Size: 300},
|
||||
{URL: "https://example.com/album2.jpg", Size: 400},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Artist methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_artist_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetArtistMBID", func() {
|
||||
It("should return artist MBID", func() {
|
||||
mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mbid).To(Equal("1234567890"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetArtistMBID(ctx, "artist-id", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistURL", func() {
|
||||
It("should return artist URL", func() {
|
||||
url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(url).To(Equal("https://example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistBiography", func() {
|
||||
It("should return artist biography", func() {
|
||||
bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(bio).To(Equal("This is a test biography"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetSimilarArtists", func() {
|
||||
It("should return similar artists", func() {
|
||||
artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(artists).To(Equal([]agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistImages", func() {
|
||||
It("should return artist images", func() {
|
||||
images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/image1.jpg", Size: 100},
|
||||
{URL: "https://example.com/image2.jpg", Size: 200},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistTopSongs", func() {
|
||||
It("should return artist top songs", func() {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(songs).To(Equal([]agents.Song{
|
||||
{Name: "Song 1", MBID: "mbid1"},
|
||||
{Name: "Song 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Helper functions", func() {
|
||||
It("convertExternalImages should convert API image objects to agent image objects", func() {
|
||||
apiImages := []*api.ExternalImage{
|
||||
{Url: "https://example.com/image1.jpg", Size: 100},
|
||||
{Url: "https://example.com/image2.jpg", Size: 200},
|
||||
}
|
||||
|
||||
agentImages := convertExternalImages(apiImages)
|
||||
Expect(agentImages).To(HaveLen(2))
|
||||
|
||||
for i, img := range agentImages {
|
||||
Expect(img.URL).To(Equal(apiImages[i].Url))
|
||||
Expect(img.Size).To(Equal(int(apiImages[i].Size)))
|
||||
}
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle empty slice", func() {
|
||||
agentImages := convertExternalImages([]*api.ExternalImage{})
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle nil", func() {
|
||||
agentImages := convertExternalImages(nil)
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error mapping", func() {
|
||||
var agent wasmMediaAgent
|
||||
|
||||
It("should map API ErrNotFound to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotFound)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotImplemented)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should pass through other errors", func() {
|
||||
testErr := errors.New("test error")
|
||||
err := agent.mapError(testErr)
|
||||
Expect(err).To(Equal(testErr))
|
||||
})
|
||||
|
||||
It("should handle nil error", func() {
|
||||
err := agent.mapError(nil)
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
34
plugins/adapter_scheduler_callback.go
Normal file
34
plugins/adapter_scheduler_callback.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
|
||||
func newWasmSchedulerCallback(wasmPath, pluginName string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scheduler callback plugin", "plugin", pluginName, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmSchedulerCallback{
|
||||
wasmBasePlugin: &wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]{
|
||||
wasmPath: wasmPath,
|
||||
id: pluginName,
|
||||
capability: CapabilitySchedulerCallback,
|
||||
loader: loader,
|
||||
loadFunc: func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// wasmSchedulerCallback adapts a SchedulerCallback plugin
|
||||
type wasmSchedulerCallback struct {
|
||||
*wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
|
||||
}
|
||||
153
plugins/adapter_scrobbler.go
Normal file
153
plugins/adapter_scrobbler.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
func newWasmScrobblerPlugin(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmScrobblerPlugin{
|
||||
wasmBasePlugin: &wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]{
|
||||
wasmPath: wasmPath,
|
||||
id: pluginID,
|
||||
capability: CapabilityScrobbler,
|
||||
loader: loader,
|
||||
loadFunc: func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type wasmScrobblerPlugin struct {
|
||||
*wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
result, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (bool, error) {
|
||||
resp, err := inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return false, nil
|
||||
}
|
||||
return resp.Authorized, nil
|
||||
})
|
||||
return err == nil && result
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
Position: int32(position),
|
||||
}
|
||||
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
track := &s.MediaFile
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
}
|
||||
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: s.TimeStamp.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
34
plugins/adapter_websocket_callback.go
Normal file
34
plugins/adapter_websocket_callback.go
Normal 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
1137
plugins/api/api.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
247
plugins/api/api.proto
Normal file
247
plugins/api/api.proto
Normal 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
1688
plugins/api/api_host.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
47
plugins/api/api_options.pb.go
Normal file
47
plugins/api/api_options.pb.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
type wazeroConfigOption func(plugin *WazeroConfig)
|
||||
|
||||
type WazeroNewRuntime func(context.Context) (wazero.Runtime, error)
|
||||
|
||||
type WazeroConfig struct {
|
||||
newRuntime func(context.Context) (wazero.Runtime, error)
|
||||
moduleConfig wazero.ModuleConfig
|
||||
}
|
||||
|
||||
func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.newRuntime = newRuntime
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultWazeroRuntime() WazeroNewRuntime {
|
||||
return func(ctx context.Context) (wazero.Runtime, error) {
|
||||
r := wazero.NewRuntime(ctx)
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.moduleConfig = moduleConfig
|
||||
}
|
||||
}
|
||||
487
plugins/api/api_plugin.pb.go
Normal file
487
plugins/api/api_plugin.pb.go
Normal file
@@ -0,0 +1,487 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
)
|
||||
|
||||
const MetadataAgentPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport metadata_agent_api_version
|
||||
func _metadata_agent_api_version() uint64 {
|
||||
return MetadataAgentPluginAPIVersion
|
||||
}
|
||||
|
||||
var metadataAgent MetadataAgent
|
||||
|
||||
func RegisterMetadataAgent(p MetadataAgent) {
|
||||
metadataAgent = p
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_mbid
|
||||
func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistMBIDRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistMBID(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_url
|
||||
func _metadata_agent_get_artist_url(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistURLRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistURL(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_biography
|
||||
func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistBiographyRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistBiography(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_similar_artists
|
||||
func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistSimilarRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetSimilarArtists(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_images
|
||||
func _metadata_agent_get_artist_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistImageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_top_songs
|
||||
func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistTopSongsRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistTopSongs(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_info
|
||||
func _metadata_agent_get_album_info(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumInfoRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumInfo(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_images
|
||||
func _metadata_agent_get_album_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumImagesRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const ScrobblerPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scrobbler_api_version
|
||||
func _scrobbler_api_version() uint64 {
|
||||
return ScrobblerPluginAPIVersion
|
||||
}
|
||||
|
||||
var scrobbler Scrobbler
|
||||
|
||||
func RegisterScrobbler(p Scrobbler) {
|
||||
scrobbler = p
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_is_authorized
|
||||
func _scrobbler_is_authorized(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerIsAuthorizedRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.IsAuthorized(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_now_playing
|
||||
func _scrobbler_now_playing(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerNowPlayingRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.NowPlaying(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_scrobble
|
||||
func _scrobbler_scrobble(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerScrobbleRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.Scrobble(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const SchedulerCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scheduler_callback_api_version
|
||||
func _scheduler_callback_api_version() uint64 {
|
||||
return SchedulerCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var schedulerCallback SchedulerCallback
|
||||
|
||||
func RegisterSchedulerCallback(p SchedulerCallback) {
|
||||
schedulerCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport scheduler_callback_on_scheduler_callback
|
||||
func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(SchedulerCallbackRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const LifecycleManagementPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport lifecycle_management_api_version
|
||||
func _lifecycle_management_api_version() uint64 {
|
||||
return LifecycleManagementPluginAPIVersion
|
||||
}
|
||||
|
||||
var lifecycleManagement LifecycleManagement
|
||||
|
||||
func RegisterLifecycleManagement(p LifecycleManagement) {
|
||||
lifecycleManagement = p
|
||||
}
|
||||
|
||||
//go:wasmexport lifecycle_management_on_init
|
||||
func _lifecycle_management_on_init(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(InitRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := lifecycleManagement.OnInit(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const WebSocketCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport web_socket_callback_api_version
|
||||
func _web_socket_callback_api_version() uint64 {
|
||||
return WebSocketCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var webSocketCallback WebSocketCallback
|
||||
|
||||
func RegisterWebSocketCallback(p WebSocketCallback) {
|
||||
webSocketCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_text_message
|
||||
func _web_socket_callback_on_text_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnTextMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnTextMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_binary_message
|
||||
func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnBinaryMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnBinaryMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_error
|
||||
func _web_socket_callback_on_error(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnErrorRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnError(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_close
|
||||
func _web_socket_callback_on_close(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnCloseRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnClose(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
34
plugins/api/api_plugin_dev.go
Normal file
34
plugins/api/api_plugin_dev.go
Normal 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")
|
||||
}
|
||||
90
plugins/api/api_plugin_dev_named_registry.go
Normal file
90
plugins/api/api_plugin_dev_named_registry.go
Normal 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)
|
||||
}
|
||||
7315
plugins/api/api_vtproto.pb.go
Normal file
7315
plugins/api/api_vtproto.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
8
plugins/api/errors.go
Normal file
8
plugins/api/errors.go
Normal 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
145
plugins/discovery.go
Normal 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
402
plugins/discovery_test.go
Normal 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
22
plugins/examples/Makefile
Normal file
@@ -0,0 +1,22 @@
|
||||
all: wikimedia coverartarchive crypto-ticker discord-rich-presence
|
||||
|
||||
wikimedia: wikimedia/plugin.wasm
|
||||
coverartarchive: coverartarchive/plugin.wasm
|
||||
crypto-ticker: crypto-ticker/plugin.wasm
|
||||
discord-rich-presence: discord-rich-presence/plugin.wasm
|
||||
|
||||
wikimedia/plugin.wasm: wikimedia/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia
|
||||
|
||||
coverartarchive/plugin.wasm: coverartarchive/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive
|
||||
|
||||
crypto-ticker/plugin.wasm: crypto-ticker/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker
|
||||
|
||||
DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go")
|
||||
discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES)
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/...
|
||||
|
||||
clean:
|
||||
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm discord-rich-presence/plugin.wasm
|
||||
29
plugins/examples/README.md
Normal file
29
plugins/examples/README.md
Normal 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.
|
||||
34
plugins/examples/coverartarchive/README.md
Normal file
34
plugins/examples/coverartarchive/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Cover Art Archive AlbumMetadataService Plugin
|
||||
|
||||
This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release Group MBID.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements only the `GetAlbumImages` method of the AlbumMetadataService plugin interface.
|
||||
- Returns front cover images for a given release-group MBID.
|
||||
- Returns `not found` if no MBID is provided or no images are found.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.24 or newer (with WASI support)
|
||||
- The Navidrome repository (with generated plugin API code in `plugins/api`)
|
||||
|
||||
## How to Compile
|
||||
|
||||
To build the WASM plugin, run the following command from the project root:
|
||||
|
||||
```sh
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugins/testdata/coverartarchive/plugin.wasm ./plugins/testdata/coverartarchive
|
||||
```
|
||||
|
||||
This will produce `plugin.wasm` in this directory.
|
||||
|
||||
## Usage
|
||||
|
||||
- The plugin can be loaded by Navidrome for integration and end-to-end tests of the plugin system.
|
||||
- It is intended for testing and development purposes only.
|
||||
|
||||
## API Reference
|
||||
|
||||
- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API)
|
||||
- This plugin uses the endpoint: `https://coverartarchive.org/release-group/{mbid}`
|
||||
18
plugins/examples/coverartarchive/manifest.json
Normal file
18
plugins/examples/coverartarchive/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
147
plugins/examples/coverartarchive/plugin.go
Normal file
147
plugins/examples/coverartarchive/plugin.go
Normal file
@@ -0,0 +1,147 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
)
|
||||
|
||||
type CoverArtArchiveAgent struct{}
|
||||
|
||||
var ErrNotFound = api.ErrNotFound
|
||||
|
||||
type caaImage struct {
|
||||
Image string `json:"image"`
|
||||
Front bool `json:"front"`
|
||||
Types []string `json:"types"`
|
||||
Thumbnails map[string]string `json:"thumbnails"`
|
||||
}
|
||||
|
||||
var client = http.NewHttpService()
|
||||
|
||||
func (CoverArtArchiveAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) {
|
||||
if req.Mbid == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
url := "https://coverartarchive.org/release/" + req.Mbid
|
||||
resp, err := client.Get(ctx, &http.HttpRequest{Url: url, TimeoutMs: 5000})
|
||||
if err != nil || resp.Status != 200 {
|
||||
log.Printf("[CAA] Error getting album images from CoverArtArchive (status: %d): %v", resp.Status, err)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
images, err := extractFrontImages(resp.Body)
|
||||
if err != nil || len(images) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &api.AlbumImagesResponse{Images: images}, nil
|
||||
}
|
||||
|
||||
func extractFrontImages(body []byte) ([]*api.ExternalImage, error) {
|
||||
var data struct {
|
||||
Images []caaImage `json:"images"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := findFrontImage(data.Images)
|
||||
if img == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return buildImageList(img), nil
|
||||
}
|
||||
|
||||
func findFrontImage(images []caaImage) *caaImage {
|
||||
for i, img := range images {
|
||||
if img.Front {
|
||||
return &images[i]
|
||||
}
|
||||
}
|
||||
for i, img := range images {
|
||||
for _, t := range img.Types {
|
||||
if t == "Front" {
|
||||
return &images[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(images) > 0 {
|
||||
return &images[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildImageList(img *caaImage) []*api.ExternalImage {
|
||||
var images []*api.ExternalImage
|
||||
// First, try numeric sizes only
|
||||
for sizeStr, url := range img.Thumbnails {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
size := 0
|
||||
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil {
|
||||
images = append(images, &api.ExternalImage{Url: url, Size: int32(size)})
|
||||
}
|
||||
}
|
||||
// If no numeric sizes, fallback to large/small
|
||||
if len(images) == 0 {
|
||||
for sizeStr, url := range img.Thumbnails {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
var size int
|
||||
switch sizeStr {
|
||||
case "large":
|
||||
size = 500
|
||||
case "small":
|
||||
size = 250
|
||||
default:
|
||||
continue
|
||||
}
|
||||
images = append(images, &api.ExternalImage{Url: url, Size: int32(size)})
|
||||
}
|
||||
}
|
||||
if len(images) == 0 && img.Image != "" {
|
||||
images = append(images, &api.ExternalImage{Url: img.Image, Size: 0})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
func (CoverArtArchiveAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
api.RegisterMetadataAgent(CoverArtArchiveAgent{})
|
||||
}
|
||||
53
plugins/examples/crypto-ticker/README.md
Normal file
53
plugins/examples/crypto-ticker/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Crypto Ticker Plugin
|
||||
|
||||
This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase.
|
||||
|
||||
## Features
|
||||
|
||||
- Connects to Coinbase WebSocket API to receive real-time ticker updates
|
||||
- Configurable to track multiple cryptocurrency pairs
|
||||
- Implements WebSocketCallback and LifecycleManagement interfaces
|
||||
- Automatically reconnects on connection loss
|
||||
- Displays price, best bid, best ask, and 24-hour percentage change
|
||||
|
||||
## Configuration
|
||||
|
||||
In your `navidrome.toml` file, add:
|
||||
|
||||
```toml
|
||||
[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`.
|
||||
25
plugins/examples/crypto-ticker/manifest.json
Normal file
25
plugins/examples/crypto-ticker/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "crypto-ticker",
|
||||
"author": "Navidrome Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
|
||||
"capabilities": [
|
||||
"WebSocketCallback",
|
||||
"LifecycleManagement",
|
||||
"SchedulerCallback"
|
||||
],
|
||||
"permissions": {
|
||||
"config": {
|
||||
"reason": "To read API configuration and WebSocket endpoint settings"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule periodic reconnection attempts and status updates"
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices",
|
||||
"allowedUrls": ["wss://ws-feed.exchange.coinbase.com"],
|
||||
"allowLocalNetwork": false
|
||||
}
|
||||
}
|
||||
}
|
||||
300
plugins/examples/crypto-ticker/plugin.go
Normal file
300
plugins/examples/crypto-ticker/plugin.go
Normal 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", ¤tFloat)
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
if openFloat == 0 {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
change := ((currentFloat - openFloat) / openFloat) * 100
|
||||
return fmt.Sprintf("%.2f", change)
|
||||
}
|
||||
|
||||
// Required by Go WASI build
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
|
||||
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
|
||||
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
|
||||
}
|
||||
88
plugins/examples/discord-rich-presence/README.md
Normal file
88
plugins/examples/discord-rich-presence/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Discord Rich Presence Plugin
|
||||
|
||||
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time
|
||||
connection to an external service while remaining completely stateless. This plugin is based on the
|
||||
[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality.
|
||||
|
||||
**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the
|
||||
Navidrome configuration file, which is not secure, and may be against Discord's terms of service.
|
||||
Use it at your own risk.**
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin exposes three capabilities:
|
||||
|
||||
- **Scrobbler** – receives `NowPlaying` notifications from Navidrome
|
||||
- **WebSocketCallback** – handles Discord gateway messages
|
||||
- **SchedulerCallback** – used to clear presence and send periodic heartbeats
|
||||
|
||||
It relies on several host services declared in `manifest.json`:
|
||||
|
||||
- `http` – queries Discord API endpoints
|
||||
- `websocket` – maintains gateway connections
|
||||
- `scheduler` – schedules heartbeats and presence cleanup
|
||||
- `cache` – stores sequence numbers for heartbeats
|
||||
- `config` – retrieves the plugin configuration on each call
|
||||
- `artwork` – resolves track artwork URLs
|
||||
|
||||
## Architecture
|
||||
|
||||
Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the
|
||||
scheduler service:
|
||||
|
||||
```go
|
||||
api.RegisterScrobbler(plugin)
|
||||
api.RegisterWebSocketCallback(plugin.rpc)
|
||||
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
|
||||
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
|
||||
```
|
||||
|
||||
When `NowPlaying` is invoked the plugin:
|
||||
|
||||
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
|
||||
2. Connects to Discord using `WebSocketService` if no connection exists.
|
||||
3. Sends the activity payload with track details and artwork.
|
||||
4. Schedules a one‑time callback to clear the presence after the track finishes.
|
||||
|
||||
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in
|
||||
`CacheService` to remain available across plugin instances.
|
||||
|
||||
The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached.
|
||||
|
||||
```go
|
||||
// The plugin is stateless, we need to load the configuration every time
|
||||
clientID, users, err := d.getConfig(ctx)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following to `navidrome.toml` and adjust for your tokens:
|
||||
|
||||
```toml
|
||||
[PluginSettings.discord-rich-presence]
|
||||
ClientID = "123456789012345678"
|
||||
Users = "alice:token123,bob:token456"
|
||||
```
|
||||
|
||||
- `clientid` is your Discord application ID
|
||||
- `users` is a comma‑separated list of `username:token` pairs used for authorization
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/...
|
||||
```
|
||||
|
||||
Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins
|
||||
directory.
|
||||
|
||||
## Stateless Operation
|
||||
|
||||
Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it
|
||||
afterwards.
|
||||
|
||||
To work within this model the plugin stores no in-memory state. Connections are keyed by user name inside the host
|
||||
services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every
|
||||
method call.
|
||||
|
||||
For more implementation details see `plugin.go` and `rpc.go`.
|
||||
34
plugins/examples/discord-rich-presence/manifest.json
Normal file
34
plugins/examples/discord-rich-presence/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
186
plugins/examples/discord-rich-presence/plugin.go
Normal file
186
plugins/examples/discord-rich-presence/plugin.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/artwork"
|
||||
"github.com/navidrome/navidrome/plugins/host/cache"
|
||||
"github.com/navidrome/navidrome/plugins/host/config"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type DiscordRPPlugin struct {
|
||||
rpc *discordRPC
|
||||
cfg config.ConfigService
|
||||
artwork artwork.ArtworkService
|
||||
sched scheduler.SchedulerService
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) {
|
||||
// Get plugin configuration
|
||||
_, users, err := d.getConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check user authorization: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user has a Discord token configured
|
||||
_, authorized := users[req.Username]
|
||||
log.Printf("IsAuthorized for user %s: %v", req.Username, authorized)
|
||||
return &api.ScrobblerIsAuthorizedResponse{
|
||||
Authorized: authorized,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) {
|
||||
log.Printf("Setting presence for user %s, track: %s", request.Username, request.Track.Name)
|
||||
|
||||
// The plugin is stateless, we need to load the configuration every time
|
||||
clientID, users, err := d.getConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user has a Discord token configured
|
||||
userToken, authorized := users[request.Username]
|
||||
if !authorized {
|
||||
return nil, fmt.Errorf("user '%s' not authorized", request.Username)
|
||||
}
|
||||
|
||||
// Make sure we have a connection
|
||||
if err := d.rpc.connect(ctx, request.Username, userToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Discord: %w", err)
|
||||
}
|
||||
|
||||
// Cancel any existing completion schedule
|
||||
if resp, _ := d.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: request.Username}); resp.Error != "" {
|
||||
log.Printf("Ignoring failure to cancel schedule: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Send activity update
|
||||
if err := d.rpc.sendActivity(ctx, clientID, request.Username, userToken, activity{
|
||||
Application: clientID,
|
||||
Name: "Navidrome",
|
||||
Type: 2,
|
||||
Details: request.Track.Name,
|
||||
State: d.getArtistList(request.Track),
|
||||
Timestamps: activityTimestamps{
|
||||
Start: (request.Timestamp - int64(request.Track.Position)) * 1000,
|
||||
End: (request.Timestamp - int64(request.Track.Position) + int64(request.Track.Length)) * 1000,
|
||||
},
|
||||
Assets: activityAssets{
|
||||
LargeImage: d.imageURL(ctx, request),
|
||||
LargeText: request.Track.Album,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to send activity: %w", err)
|
||||
}
|
||||
|
||||
// Schedule a timer to clear the activity after the track completes
|
||||
_, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{
|
||||
ScheduleId: request.Username,
|
||||
DelaySeconds: request.Track.Length - request.Track.Position + 5,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to schedule completion timer: %w", err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) imageURL(ctx context.Context, request *api.ScrobblerNowPlayingRequest) string {
|
||||
imageResp, _ := d.artwork.GetTrackUrl(ctx, &artwork.GetArtworkUrlRequest{Id: request.Track.Id, Size: 300})
|
||||
imageURL := imageResp.Url
|
||||
if strings.HasPrefix(imageURL, "http://localhost") {
|
||||
return ""
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) getArtistList(track *api.TrackInfo) string {
|
||||
return strings.Join(slice.Map(track.Artists, func(a *api.Artist) string { return a.Name }), " • ")
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) Scrobble(context.Context, *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) getConfig(ctx context.Context) (string, map[string]string, error) {
|
||||
const (
|
||||
clientIDKey = "clientid"
|
||||
usersKey = "users"
|
||||
)
|
||||
confResp, err := d.cfg.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to load config: %w", err)
|
||||
}
|
||||
conf := confResp.GetConfig()
|
||||
if len(conf) < 1 {
|
||||
log.Print("missing configuration")
|
||||
return "", nil, nil
|
||||
}
|
||||
clientID := conf[clientIDKey]
|
||||
if clientID == "" {
|
||||
log.Printf("missing ClientID: %v", conf)
|
||||
return "", nil, nil
|
||||
}
|
||||
cfgUsers := conf[usersKey]
|
||||
if len(cfgUsers) == 0 {
|
||||
log.Print("no users configured")
|
||||
return "", nil, nil
|
||||
}
|
||||
users := map[string]string{}
|
||||
for _, user := range strings.Split(cfgUsers, ",") {
|
||||
tuple := strings.Split(user, ":")
|
||||
if len(tuple) != 2 {
|
||||
return clientID, nil, fmt.Errorf("invalid user config: %s", user)
|
||||
}
|
||||
users[tuple[0]] = tuple[1]
|
||||
}
|
||||
return clientID, users, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
|
||||
log.Printf("Removing presence for user %s", req.ScheduleId)
|
||||
if err := d.rpc.clearActivity(ctx, req.ScheduleId); err != nil {
|
||||
return nil, fmt.Errorf("failed to clear activity: %w", err)
|
||||
}
|
||||
log.Printf("Disconnecting user %s", req.ScheduleId)
|
||||
if err := d.rpc.disconnect(ctx, req.ScheduleId); err != nil {
|
||||
return nil, fmt.Errorf("failed to disconnect from Discord: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Creates a new instance of the DiscordRPPlugin, with all host services as dependencies
|
||||
var plugin = &DiscordRPPlugin{
|
||||
cfg: config.NewConfigService(),
|
||||
artwork: artwork.NewArtworkService(),
|
||||
rpc: &discordRPC{
|
||||
ws: websocket.NewWebSocketService(),
|
||||
web: http.NewHttpService(),
|
||||
mem: cache.NewCacheService(),
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line, prepend [Discord]
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Discord] ")
|
||||
|
||||
// Register plugin capabilities
|
||||
api.RegisterScrobbler(plugin)
|
||||
api.RegisterWebSocketCallback(plugin.rpc)
|
||||
|
||||
// Register named scheduler callbacks, and get the scheduler service for each
|
||||
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
|
||||
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
|
||||
}
|
||||
|
||||
func main() {}
|
||||
365
plugins/examples/discord-rich-presence/rpc.go
Normal file
365
plugins/examples/discord-rich-presence/rpc.go
Normal 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)
|
||||
}
|
||||
32
plugins/examples/wikimedia/README.md
Normal file
32
plugins/examples/wikimedia/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Wikimedia Artist Metadata Plugin
|
||||
|
||||
This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint.
|
||||
|
||||
## Implemented Methods
|
||||
|
||||
- `GetArtistBiography`: Returns the artist's English biography/description from Wikidata.
|
||||
- `GetArtistURL`: Returns the artist's official website (if available) from Wikidata.
|
||||
- `GetArtistImages`: Returns the artist's main image (Wikimedia Commons) from Wikidata.
|
||||
|
||||
All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia.
|
||||
|
||||
## How it Works
|
||||
|
||||
- The plugin uses the host-provided HTTP service (`HttpService`) to make SPARQL queries to the Wikidata endpoint.
|
||||
- No network requests are made directly from the plugin; all HTTP is routed through the host.
|
||||
|
||||
## Building
|
||||
|
||||
To build the plugin to WASM:
|
||||
|
||||
```
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory.
|
||||
|
||||
---
|
||||
|
||||
For more details, see the source code in `plugin.go`.
|
||||
19
plugins/examples/wikimedia/manifest.json
Normal file
19
plugins/examples/wikimedia/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "wikimedia",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Artist information and images from Wikimedia Commons",
|
||||
"website": "https://commons.wikimedia.org",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To fetch artist information and images from Wikimedia Commons API",
|
||||
"allowedUrls": {
|
||||
"https://*.wikimedia.org": ["GET"],
|
||||
"https://*.wikipedia.org": ["GET"],
|
||||
"https://commons.wikimedia.org": ["GET"]
|
||||
},
|
||||
"allowLocalNetwork": false
|
||||
}
|
||||
}
|
||||
}
|
||||
387
plugins/examples/wikimedia/plugin.go
Normal file
387
plugins/examples/wikimedia/plugin.go
Normal file
@@ -0,0 +1,387 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
)
|
||||
|
||||
const (
|
||||
wikidataEndpoint = "https://query.wikidata.org/sparql"
|
||||
dbpediaEndpoint = "https://dbpedia.org/sparql"
|
||||
mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
|
||||
requestTimeoutMs = 5000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = api.ErrNotFound
|
||||
ErrNotImplemented = api.ErrNotImplemented
|
||||
|
||||
client = http.NewHttpService()
|
||||
)
|
||||
|
||||
// SPARQLResult struct for all possible fields
|
||||
// Only the needed field will be non-nil in each context
|
||||
// (Sitelink, Wiki, Comment, Img)
|
||||
type SPARQLResult struct {
|
||||
Results struct {
|
||||
Bindings []struct {
|
||||
Sitelink *struct{ Value string } `json:"sitelink,omitempty"`
|
||||
Wiki *struct{ Value string } `json:"wiki,omitempty"`
|
||||
Comment *struct{ Value string } `json:"comment,omitempty"`
|
||||
Img *struct{ Value string } `json:"img,omitempty"`
|
||||
} `json:"bindings"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// MediaWikiExtractResult is used to unmarshal MediaWiki API extract responses
|
||||
// (for getWikipediaExtract)
|
||||
type MediaWikiExtractResult struct {
|
||||
Query struct {
|
||||
Pages map[string]struct {
|
||||
PageID int `json:"pageid"`
|
||||
Ns int `json:"ns"`
|
||||
Title string `json:"title"`
|
||||
Extract string `json:"extract"`
|
||||
Missing bool `json:"missing"`
|
||||
} `json:"pages"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
// --- SPARQL Query Helper ---
|
||||
func sparqlQuery(ctx context.Context, client http.HttpService, endpoint, query string) (*SPARQLResult, error) {
|
||||
form := url.Values{}
|
||||
form.Set("query", query)
|
||||
|
||||
req := &http.HttpRequest{
|
||||
Url: endpoint,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/sparql-results+json",
|
||||
"Content-Type": "application/x-www-form-urlencoded", // Required by SPARQL endpoints
|
||||
"User-Agent": "NavidromeWikimediaPlugin/0.1",
|
||||
},
|
||||
Body: []byte(form.Encode()), // Send encoded form data
|
||||
TimeoutMs: requestTimeoutMs,
|
||||
}
|
||||
log.Printf("[Wikimedia Query] Attempting SPARQL query to %s (query length: %d):\n%s", endpoint, len(query), query)
|
||||
resp, err := client.Post(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SPARQL request error: %w", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
log.Printf("[Wikimedia Query] SPARQL HTTP error %d for query to %s. Body: %s", resp.Status, endpoint, string(resp.Body))
|
||||
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status)
|
||||
}
|
||||
var result SPARQLResult
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SPARQL response: %w", err)
|
||||
}
|
||||
if len(result.Results.Bindings) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// --- MediaWiki API Helper ---
|
||||
func mediawikiQuery(ctx context.Context, client http.HttpService, params url.Values) ([]byte, error) {
|
||||
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
|
||||
req := &http.HttpRequest{
|
||||
Url: apiURL,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "NavidromeWikimediaPlugin/0.1",
|
||||
},
|
||||
TimeoutMs: requestTimeoutMs,
|
||||
}
|
||||
resp, err := client.Get(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MediaWiki request error: %w", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
return nil, fmt.Errorf("MediaWiki HTTP error: status %d, body: %s", resp.Status, string(resp.Body))
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// --- Wikidata Fetch Functions ---
|
||||
func getWikidataWikipediaURL(ctx context.Context, client http.HttpService, mbid, name string) (string, error) {
|
||||
var q string
|
||||
if mbid != "" {
|
||||
// Using property chain: ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>.
|
||||
q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist wdt:P434 "%s". ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>. } LIMIT 1`, mbid)
|
||||
} else if name != "" {
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
// Using property chain: ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>.
|
||||
q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist rdfs:label "%s"@en. ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>. } LIMIT 1`, escapedName)
|
||||
} else {
|
||||
return "", errors.New("MBID or Name required for Wikidata URL lookup")
|
||||
}
|
||||
|
||||
result, err := sparqlQuery(ctx, client, wikidataEndpoint, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Wikidata SPARQL query failed: %w", err)
|
||||
}
|
||||
if result.Results.Bindings[0].Sitelink != nil {
|
||||
return result.Results.Bindings[0].Sitelink.Value, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// --- DBpedia Fetch Functions ---
|
||||
func getDBpediaWikipediaURL(ctx context.Context, client http.HttpService, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
q := fmt.Sprintf(`SELECT ?wiki WHERE { ?artist foaf:name "%s"@en; foaf:isPrimaryTopicOf ?wiki. FILTER regex(str(?wiki), "^https://en.wikipedia.org/") } LIMIT 1`, escapedName)
|
||||
result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DBpedia SPARQL query failed: %w", err)
|
||||
}
|
||||
if result.Results.Bindings[0].Wiki != nil {
|
||||
return result.Results.Bindings[0].Wiki.Value, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func getDBpediaComment(ctx context.Context, client http.HttpService, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
q := fmt.Sprintf(`SELECT ?comment WHERE { ?artist foaf:name "%s"@en; rdfs:comment ?comment. FILTER (lang(?comment) = 'en') } LIMIT 1`, escapedName)
|
||||
result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DBpedia comment SPARQL query failed: %w", err)
|
||||
}
|
||||
if result.Results.Bindings[0].Comment != nil {
|
||||
return result.Results.Bindings[0].Comment.Value, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// --- Wikipedia API Fetch Function ---
|
||||
func getWikipediaExtract(ctx context.Context, client http.HttpService, pageTitle string) (string, error) {
|
||||
if pageTitle == "" {
|
||||
return "", errors.New("page title required for Wikipedia API lookup")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("action", "query")
|
||||
params.Set("format", "json")
|
||||
params.Set("prop", "extracts")
|
||||
params.Set("exintro", "true") // Intro section only
|
||||
params.Set("explaintext", "true") // Plain text
|
||||
params.Set("titles", pageTitle)
|
||||
params.Set("redirects", "1") // Follow redirects
|
||||
|
||||
body, err := mediawikiQuery(ctx, client, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MediaWiki query failed: %w", err)
|
||||
}
|
||||
|
||||
var result MediaWikiExtractResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse MediaWiki response: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through the pages map (usually only one page)
|
||||
for _, page := range result.Query.Pages {
|
||||
if page.Missing {
|
||||
continue // Skip missing pages
|
||||
}
|
||||
if page.Extract != "" {
|
||||
return strings.TrimSpace(page.Extract), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// --- Helper to get Wikipedia Page Title from URL ---
|
||||
func extractPageTitleFromURL(wikiURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(wikiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsedURL.Host != "en.wikipedia.org" {
|
||||
return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host)
|
||||
}
|
||||
pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/")
|
||||
if len(pathParts) < 2 || pathParts[0] != "wiki" {
|
||||
return "", fmt.Errorf("URL path does not match /wiki/<title> format: %s", parsedURL.Path)
|
||||
}
|
||||
title := pathParts[1]
|
||||
if title == "" {
|
||||
return "", errors.New("extracted title is empty")
|
||||
}
|
||||
decodedTitle, err := url.PathUnescape(title)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
|
||||
}
|
||||
return decodedTitle, nil
|
||||
}
|
||||
|
||||
// --- Agent Implementation ---
|
||||
type WikimediaAgent struct{}
|
||||
|
||||
// GetArtistURL fetches the Wikipedia URL.
|
||||
// Order: Wikidata(MBID/Name) -> DBpedia(Name) -> Search URL
|
||||
func (WikimediaAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) {
|
||||
var wikiURL string
|
||||
var err error
|
||||
|
||||
// 1. Try Wikidata (MBID first, then name)
|
||||
wikiURL, err = getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name)
|
||||
if err == nil && wikiURL != "" {
|
||||
return &api.ArtistURLResponse{Url: wikiURL}, nil
|
||||
}
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia] Error fetching Wikidata URL: %v\n", err)
|
||||
// Don't stop, try DBpedia
|
||||
}
|
||||
|
||||
// 2. Try DBpedia (Name only)
|
||||
if req.Name != "" {
|
||||
wikiURL, err = getDBpediaWikipediaURL(ctx, client, req.Name)
|
||||
if err == nil && wikiURL != "" {
|
||||
return &api.ArtistURLResponse{Url: wikiURL}, nil
|
||||
}
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia] Error fetching DBpedia URL: %v\n", err)
|
||||
// Don't stop, generate search URL
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to search URL
|
||||
if req.Name != "" {
|
||||
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(req.Name))
|
||||
log.Printf("[Wikimedia] URL not found, falling back to search URL: %s\n", searchURL)
|
||||
return &api.ArtistURLResponse{Url: searchURL}, nil
|
||||
}
|
||||
|
||||
log.Printf("[Wikimedia] Could not determine Wikipedia URL for: %s (%s)\n", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistBiography fetches the long biography.
|
||||
// Order: Wikipedia API (via Wikidata/DBpedia URL) -> DBpedia Comment (Name)
|
||||
func (WikimediaAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) {
|
||||
var bio string
|
||||
var err error
|
||||
|
||||
log.Printf("[Wikimedia Bio] Fetching for Name: %s, MBID: %s", req.Name, req.Mbid)
|
||||
|
||||
// 1. Get Wikipedia URL (using the logic from GetArtistURL)
|
||||
wikiURL := ""
|
||||
// Try Wikidata first
|
||||
tempURL, wdErr := getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name)
|
||||
if wdErr == nil && tempURL != "" {
|
||||
log.Printf("[Wikimedia Bio] Found Wikidata URL: %s", tempURL)
|
||||
wikiURL = tempURL
|
||||
} else if req.Name != "" {
|
||||
// Try DBpedia if Wikidata failed or returned not found
|
||||
log.Printf("[Wikimedia Bio] Wikidata URL failed (%v), trying DBpedia URL", wdErr)
|
||||
tempURL, dbErr := getDBpediaWikipediaURL(ctx, client, req.Name)
|
||||
if dbErr == nil && tempURL != "" {
|
||||
log.Printf("[Wikimedia Bio] Found DBpedia URL: %s", tempURL)
|
||||
wikiURL = tempURL
|
||||
} else {
|
||||
log.Printf("[Wikimedia Bio] DBpedia URL failed (%v)", dbErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If Wikipedia URL found, try MediaWiki API
|
||||
if wikiURL != "" {
|
||||
pageTitle, err := extractPageTitleFromURL(wikiURL)
|
||||
if err == nil {
|
||||
log.Printf("[Wikimedia Bio] Extracted page title: %s", pageTitle)
|
||||
bio, err = getWikipediaExtract(ctx, client, pageTitle)
|
||||
if err == nil && bio != "" {
|
||||
log.Printf("[Wikimedia Bio] Found Wikipedia extract.")
|
||||
return &api.ArtistBiographyResponse{Biography: bio}, nil
|
||||
}
|
||||
log.Printf("[Wikimedia Bio] Wikipedia extract failed: %v", err)
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia Bio] Error fetching Wikipedia extract for '%s': %v", pageTitle, err)
|
||||
// Don't stop, try DBpedia comment
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Wikimedia Bio] Error extracting page title from URL '%s': %v", wikiURL, err)
|
||||
// Don't stop, try DBpedia comment
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to DBpedia Comment (Name only)
|
||||
if req.Name != "" {
|
||||
log.Printf("[Wikimedia Bio] Falling back to DBpedia comment for name: %s", req.Name)
|
||||
bio, err = getDBpediaComment(ctx, client, req.Name)
|
||||
if err == nil && bio != "" {
|
||||
log.Printf("[Wikimedia Bio] Found DBpedia comment.")
|
||||
return &api.ArtistBiographyResponse{Biography: bio}, nil
|
||||
}
|
||||
log.Printf("[Wikimedia Bio] DBpedia comment failed: %v", err)
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia Bio] Error fetching DBpedia comment for '%s': %v", req.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[Wikimedia Bio] Final: Biography not found for: %s (%s)", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistImages fetches images (Wikidata only for now)
|
||||
func (WikimediaAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) {
|
||||
var q string
|
||||
if req.Mbid != "" {
|
||||
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, req.Mbid)
|
||||
} else if req.Name != "" {
|
||||
escapedName := strings.ReplaceAll(req.Name, "\"", "\\\"")
|
||||
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist rdfs:label "%s"@en; wdt:P18 ?img } LIMIT 1`, escapedName)
|
||||
} else {
|
||||
return nil, errors.New("MBID or Name required for Wikidata Image lookup")
|
||||
}
|
||||
|
||||
result, err := sparqlQuery(ctx, client, wikidataEndpoint, q)
|
||||
if err != nil {
|
||||
log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if result.Results.Bindings[0].Img != nil {
|
||||
return &api.ArtistImageResponse{Images: []*api.ExternalImage{{Url: result.Results.Bindings[0].Img.Value, Size: 0}}}, nil
|
||||
}
|
||||
log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// Not implemented methods
|
||||
func (WikimediaAgent) GetArtistMBID(context.Context, *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (WikimediaAgent) GetSimilarArtists(context.Context, *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (WikimediaAgent) GetArtistTopSongs(context.Context, *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (WikimediaAgent) GetAlbumInfo(context.Context, *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
api.RegisterMetadataAgent(WikimediaAgent{})
|
||||
}
|
||||
73
plugins/host/artwork/artwork.pb.go
Normal file
73
plugins/host/artwork/artwork.pb.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/artwork/artwork.proto
|
||||
|
||||
package artwork
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type GetArtworkUrlRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Optional, 0 means original size
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlRequest) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlRequest) GetSize() int32 {
|
||||
if x != nil {
|
||||
return x.Size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetArtworkUrlResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlResponse) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type ArtworkService interface {
|
||||
GetArtistUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
|
||||
GetAlbumUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
|
||||
GetTrackUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
|
||||
}
|
||||
21
plugins/host/artwork/artwork.proto
Normal file
21
plugins/host/artwork/artwork.proto
Normal file
@@ -0,0 +1,21 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package artwork;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/artwork;artwork";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service ArtworkService {
|
||||
rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
|
||||
rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
|
||||
rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
|
||||
}
|
||||
|
||||
message GetArtworkUrlRequest {
|
||||
string id = 1;
|
||||
int32 size = 2; // Optional, 0 means original size
|
||||
}
|
||||
|
||||
message GetArtworkUrlResponse {
|
||||
string url = 1;
|
||||
}
|
||||
130
plugins/host/artwork/artwork_host.pb.go
Normal file
130
plugins/host/artwork/artwork_host.pb.go
Normal file
@@ -0,0 +1,130 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/artwork/artwork.proto
|
||||
|
||||
package artwork
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _artworkService struct {
|
||||
ArtworkService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ArtworkService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _artworkService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetArtistUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_artist_url")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetAlbumUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_album_url")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetTrackUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_track_url")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _artworkService) _GetArtistUrl(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetArtworkUrlRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetArtistUrl(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _artworkService) _GetAlbumUrl(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetArtworkUrlRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetAlbumUrl(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _artworkService) _GetTrackUrl(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetArtworkUrlRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetTrackUrl(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
90
plugins/host/artwork/artwork_plugin.pb.go
Normal file
90
plugins/host/artwork/artwork_plugin.pb.go
Normal 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
|
||||
}
|
||||
7
plugins/host/artwork/artwork_plugin_dev.go
Normal file
7
plugins/host/artwork/artwork_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package artwork
|
||||
|
||||
func NewArtworkService() ArtworkService {
|
||||
panic("not implemented")
|
||||
}
|
||||
425
plugins/host/artwork/artwork_vtproto.pb.go
Normal file
425
plugins/host/artwork/artwork_vtproto.pb.go
Normal 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
420
plugins/host/cache/cache.pb.go
vendored
Normal 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
120
plugins/host/cache/cache.proto
vendored
Normal 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
374
plugins/host/cache/cache_host.pb.go
vendored
Normal 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
251
plugins/host/cache/cache_plugin.pb.go
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/cache/cache.proto
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type cacheService struct{}
|
||||
|
||||
func NewCacheService() CacheService {
|
||||
return cacheService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env set_string
|
||||
func _set_string(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetString(ctx context.Context, request *SetStringRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_string(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_string
|
||||
func _get_string(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetString(ctx context.Context, request *GetRequest) (*GetStringResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_string(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetStringResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env set_int
|
||||
func _set_int(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetInt(ctx context.Context, request *SetIntRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_int(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_int
|
||||
func _get_int(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetInt(ctx context.Context, request *GetRequest) (*GetIntResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_int(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetIntResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env set_float
|
||||
func _set_float(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetFloat(ctx context.Context, request *SetFloatRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_float(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_float
|
||||
func _get_float(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetFloat(ctx context.Context, request *GetRequest) (*GetFloatResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_float(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetFloatResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env set_bytes
|
||||
func _set_bytes(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetBytes(ctx context.Context, request *SetBytesRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_bytes(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_bytes
|
||||
func _get_bytes(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetBytes(ctx context.Context, request *GetRequest) (*GetBytesResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_bytes(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetBytesResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env remove
|
||||
func _remove(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) Remove(ctx context.Context, request *RemoveRequest) (*RemoveResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _remove(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(RemoveResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env has
|
||||
func _has(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) Has(ctx context.Context, request *HasRequest) (*HasResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _has(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HasResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/cache/cache_plugin_dev.go
vendored
Normal file
7
plugins/host/cache/cache_plugin_dev.go
vendored
Normal 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
2352
plugins/host/cache/cache_vtproto.pb.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
54
plugins/host/config/config.pb.go
Normal file
54
plugins/host/config/config.pb.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type GetPluginConfigRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *GetPluginConfigRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
type GetPluginConfigResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
}
|
||||
|
||||
func (x *GetPluginConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetPluginConfigResponse) GetConfig() map[string]string {
|
||||
if x != nil {
|
||||
return x.Config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type ConfigService interface {
|
||||
GetPluginConfig(context.Context, *GetPluginConfigRequest) (*GetPluginConfigResponse, error)
|
||||
}
|
||||
18
plugins/host/config/config.proto
Normal file
18
plugins/host/config/config.proto
Normal file
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package config;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/config;config";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service ConfigService {
|
||||
rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse);
|
||||
}
|
||||
|
||||
message GetPluginConfigRequest {
|
||||
// No fields needed; plugin name is inferred from context
|
||||
}
|
||||
|
||||
message GetPluginConfigResponse {
|
||||
map<string, string> config = 1;
|
||||
}
|
||||
66
plugins/host/config/config_host.pb.go
Normal file
66
plugins/host/config/config_host.pb.go
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _configService struct {
|
||||
ConfigService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ConfigService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _configService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetPluginConfig), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_plugin_config")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _configService) _GetPluginConfig(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetPluginConfigRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetPluginConfig(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
44
plugins/host/config/config_plugin.pb.go
Normal file
44
plugins/host/config/config_plugin.pb.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type configService struct{}
|
||||
|
||||
func NewConfigService() ConfigService {
|
||||
return configService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env get_plugin_config
|
||||
func _get_plugin_config(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h configService) GetPluginConfig(ctx context.Context, request *GetPluginConfigRequest) (*GetPluginConfigResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_plugin_config(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetPluginConfigResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/config/config_plugin_dev.go
Normal file
7
plugins/host/config/config_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package config
|
||||
|
||||
func NewConfigService() ConfigService {
|
||||
panic("not implemented")
|
||||
}
|
||||
466
plugins/host/config/config_vtproto.pb.go
Normal file
466
plugins/host/config/config_vtproto.pb.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *GetPluginConfigRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Config) > 0 {
|
||||
for k := range m.Config {
|
||||
v := m.Config[k]
|
||||
baseI := i
|
||||
i -= len(v)
|
||||
copy(dAtA[i:], v)
|
||||
i = encodeVarint(dAtA, i, uint64(len(v)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
i -= len(k)
|
||||
copy(dAtA[i:], k)
|
||||
i = encodeVarint(dAtA, i, uint64(len(k)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
i = encodeVarint(dAtA, i, uint64(baseI-i))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *GetPluginConfigRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
if len(m.Config) > 0 {
|
||||
for k, v := range m.Config {
|
||||
_ = k
|
||||
_ = v
|
||||
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
|
||||
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
|
||||
}
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *GetPluginConfigRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: GetPluginConfigRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: GetPluginConfigRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *GetPluginConfigResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: GetPluginConfigResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: GetPluginConfigResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType)
|
||||
}
|
||||
var msglen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
msglen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if msglen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + msglen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if m.Config == nil {
|
||||
m.Config = make(map[string]string)
|
||||
}
|
||||
var mapkey string
|
||||
var mapvalue string
|
||||
for iNdEx < postIndex {
|
||||
entryPreIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
if fieldNum == 1 {
|
||||
var stringLenmapkey uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapkey |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapkey := int(stringLenmapkey)
|
||||
if intStringLenmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapkey := iNdEx + intStringLenmapkey
|
||||
if postStringIndexmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapkey > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
|
||||
iNdEx = postStringIndexmapkey
|
||||
} else if fieldNum == 2 {
|
||||
var stringLenmapvalue uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapvalue |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapvalue := int(stringLenmapvalue)
|
||||
if intStringLenmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
|
||||
if postStringIndexmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapvalue > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
|
||||
iNdEx = postStringIndexmapvalue
|
||||
} else {
|
||||
iNdEx = entryPreIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > postIndex {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
m.Config[mapkey] = mapvalue
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
117
plugins/host/http/http.pb.go
Normal file
117
plugins/host/http/http.pb.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type HttpRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
TimeoutMs int32 `protobuf:"varint,3,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"`
|
||||
Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` // Ignored for GET/DELETE/HEAD/OPTIONS
|
||||
}
|
||||
|
||||
func (x *HttpRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetTimeoutMs() int32 {
|
||||
if x != nil {
|
||||
return x.TimeoutMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HttpResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if network/protocol error
|
||||
}
|
||||
|
||||
func (x *HttpResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetStatus() int32 {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type HttpService interface {
|
||||
Get(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Post(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Put(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Delete(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Patch(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Head(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Options(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
}
|
||||
30
plugins/host/http/http.proto
Normal file
30
plugins/host/http/http.proto
Normal file
@@ -0,0 +1,30 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package http;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/http;http";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service HttpService {
|
||||
rpc Get(HttpRequest) returns (HttpResponse);
|
||||
rpc Post(HttpRequest) returns (HttpResponse);
|
||||
rpc Put(HttpRequest) returns (HttpResponse);
|
||||
rpc Delete(HttpRequest) returns (HttpResponse);
|
||||
rpc Patch(HttpRequest) returns (HttpResponse);
|
||||
rpc Head(HttpRequest) returns (HttpResponse);
|
||||
rpc Options(HttpRequest) returns (HttpResponse);
|
||||
}
|
||||
|
||||
message HttpRequest {
|
||||
string url = 1;
|
||||
map<string, string> headers = 2;
|
||||
int32 timeout_ms = 3;
|
||||
bytes body = 4; // Ignored for GET/DELETE/HEAD/OPTIONS
|
||||
}
|
||||
|
||||
message HttpResponse {
|
||||
int32 status = 1;
|
||||
bytes body = 2;
|
||||
map<string, string> headers = 3;
|
||||
string error = 4; // Non-empty if network/protocol error
|
||||
}
|
||||
258
plugins/host/http/http_host.pb.go
Normal file
258
plugins/host/http/http_host.pb.go
Normal file
@@ -0,0 +1,258 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _httpService struct {
|
||||
HttpService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions HttpService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _httpService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Get), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Post), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("post")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Put), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("put")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Delete), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("delete")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Patch), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("patch")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Head), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("head")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Options), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("options")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _httpService) _Get(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Get(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Post(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Post(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Put(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Put(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Delete(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Delete(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Patch(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Patch(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Head(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Head(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Options(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Options(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
182
plugins/host/http/http_plugin.pb.go
Normal file
182
plugins/host/http/http_plugin.pb.go
Normal file
@@ -0,0 +1,182 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type httpService struct{}
|
||||
|
||||
func NewHttpService() HttpService {
|
||||
return httpService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env get
|
||||
func _get(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Get(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env post
|
||||
func _post(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Post(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _post(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env put
|
||||
func _put(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Put(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _put(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env delete
|
||||
func _delete(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Delete(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _delete(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env patch
|
||||
func _patch(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Patch(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _patch(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env head
|
||||
func _head(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Head(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _head(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env options
|
||||
func _options(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Options(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _options(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/http/http_plugin_dev.go
Normal file
7
plugins/host/http/http_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package http
|
||||
|
||||
func NewHttpService() HttpService {
|
||||
panic("not implemented")
|
||||
}
|
||||
850
plugins/host/http/http_vtproto.pb.go
Normal file
850
plugins/host/http/http_vtproto.pb.go
Normal file
@@ -0,0 +1,850 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *HttpRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *HttpRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *HttpRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Body) > 0 {
|
||||
i -= len(m.Body)
|
||||
copy(dAtA[i:], m.Body)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Body)))
|
||||
i--
|
||||
dAtA[i] = 0x22
|
||||
}
|
||||
if m.TimeoutMs != 0 {
|
||||
i = encodeVarint(dAtA, i, uint64(m.TimeoutMs))
|
||||
i--
|
||||
dAtA[i] = 0x18
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k := range m.Headers {
|
||||
v := m.Headers[k]
|
||||
baseI := i
|
||||
i -= len(v)
|
||||
copy(dAtA[i:], v)
|
||||
i = encodeVarint(dAtA, i, uint64(len(v)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
i -= len(k)
|
||||
copy(dAtA[i:], k)
|
||||
i = encodeVarint(dAtA, i, uint64(len(k)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
i = encodeVarint(dAtA, i, uint64(baseI-i))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
}
|
||||
}
|
||||
if len(m.Url) > 0 {
|
||||
i -= len(m.Url)
|
||||
copy(dAtA[i:], m.Url)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *HttpResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *HttpResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *HttpResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Error) > 0 {
|
||||
i -= len(m.Error)
|
||||
copy(dAtA[i:], m.Error)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Error)))
|
||||
i--
|
||||
dAtA[i] = 0x22
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k := range m.Headers {
|
||||
v := m.Headers[k]
|
||||
baseI := i
|
||||
i -= len(v)
|
||||
copy(dAtA[i:], v)
|
||||
i = encodeVarint(dAtA, i, uint64(len(v)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
i -= len(k)
|
||||
copy(dAtA[i:], k)
|
||||
i = encodeVarint(dAtA, i, uint64(len(k)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
i = encodeVarint(dAtA, i, uint64(baseI-i))
|
||||
i--
|
||||
dAtA[i] = 0x1a
|
||||
}
|
||||
}
|
||||
if len(m.Body) > 0 {
|
||||
i -= len(m.Body)
|
||||
copy(dAtA[i:], m.Body)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Body)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
}
|
||||
if m.Status != 0 {
|
||||
i = encodeVarint(dAtA, i, uint64(m.Status))
|
||||
i--
|
||||
dAtA[i] = 0x8
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *HttpRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Url)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k, v := range m.Headers {
|
||||
_ = k
|
||||
_ = v
|
||||
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
|
||||
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
|
||||
}
|
||||
}
|
||||
if m.TimeoutMs != 0 {
|
||||
n += 1 + sov(uint64(m.TimeoutMs))
|
||||
}
|
||||
l = len(m.Body)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *HttpResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
if m.Status != 0 {
|
||||
n += 1 + sov(uint64(m.Status))
|
||||
}
|
||||
l = len(m.Body)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k, v := range m.Headers {
|
||||
_ = k
|
||||
_ = v
|
||||
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
|
||||
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
|
||||
}
|
||||
}
|
||||
l = len(m.Error)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *HttpRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: HttpRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: HttpRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Url = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
case 2:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType)
|
||||
}
|
||||
var msglen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
msglen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if msglen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + msglen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if m.Headers == nil {
|
||||
m.Headers = make(map[string]string)
|
||||
}
|
||||
var mapkey string
|
||||
var mapvalue string
|
||||
for iNdEx < postIndex {
|
||||
entryPreIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
if fieldNum == 1 {
|
||||
var stringLenmapkey uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapkey |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapkey := int(stringLenmapkey)
|
||||
if intStringLenmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapkey := iNdEx + intStringLenmapkey
|
||||
if postStringIndexmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapkey > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
|
||||
iNdEx = postStringIndexmapkey
|
||||
} else if fieldNum == 2 {
|
||||
var stringLenmapvalue uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapvalue |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapvalue := int(stringLenmapvalue)
|
||||
if intStringLenmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
|
||||
if postStringIndexmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapvalue > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
|
||||
iNdEx = postStringIndexmapvalue
|
||||
} else {
|
||||
iNdEx = entryPreIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > postIndex {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
m.Headers[mapkey] = mapvalue
|
||||
iNdEx = postIndex
|
||||
case 3:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field TimeoutMs", wireType)
|
||||
}
|
||||
m.TimeoutMs = 0
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
m.TimeoutMs |= int32(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType)
|
||||
}
|
||||
var byteLen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
byteLen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if byteLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + byteLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...)
|
||||
if m.Body == nil {
|
||||
m.Body = []byte{}
|
||||
}
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *HttpResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: HttpResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: HttpResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Status", wireType)
|
||||
}
|
||||
m.Status = 0
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
m.Status |= int32(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType)
|
||||
}
|
||||
var byteLen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
byteLen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if byteLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + byteLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...)
|
||||
if m.Body == nil {
|
||||
m.Body = []byte{}
|
||||
}
|
||||
iNdEx = postIndex
|
||||
case 3:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType)
|
||||
}
|
||||
var msglen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
msglen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if msglen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + msglen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if m.Headers == nil {
|
||||
m.Headers = make(map[string]string)
|
||||
}
|
||||
var mapkey string
|
||||
var mapvalue string
|
||||
for iNdEx < postIndex {
|
||||
entryPreIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
if fieldNum == 1 {
|
||||
var stringLenmapkey uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapkey |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapkey := int(stringLenmapkey)
|
||||
if intStringLenmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapkey := iNdEx + intStringLenmapkey
|
||||
if postStringIndexmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapkey > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
|
||||
iNdEx = postStringIndexmapkey
|
||||
} else if fieldNum == 2 {
|
||||
var stringLenmapvalue uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapvalue |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapvalue := int(stringLenmapvalue)
|
||||
if intStringLenmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
|
||||
if postStringIndexmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapvalue > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
|
||||
iNdEx = postStringIndexmapvalue
|
||||
} else {
|
||||
iNdEx = entryPreIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > postIndex {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
m.Headers[mapkey] = mapvalue
|
||||
iNdEx = postIndex
|
||||
case 4:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Error = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
165
plugins/host/scheduler/scheduler.pb.go
Normal file
165
plugins/host/scheduler/scheduler.pb.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/scheduler/scheduler.proto
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ScheduleOneTimeRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
DelaySeconds int32 `protobuf:"varint,1,opt,name=delay_seconds,json=delaySeconds,proto3" json:"delay_seconds,omitempty"` // Delay in seconds
|
||||
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback
|
||||
ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) GetDelaySeconds() int32 {
|
||||
if x != nil {
|
||||
return x.DelaySeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) GetPayload() []byte {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ScheduleRecurringRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CronExpression string `protobuf:"bytes,1,opt,name=cron_expression,json=cronExpression,proto3" json:"cron_expression,omitempty"` // Cron expression (e.g. "0 0 * * *" for daily at midnight)
|
||||
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback
|
||||
ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) GetCronExpression() string {
|
||||
if x != nil {
|
||||
return x.CronExpression
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) GetPayload() []byte {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ScheduleResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID to reference this scheduled job
|
||||
}
|
||||
|
||||
func (x *ScheduleResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ScheduleResponse) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the schedule to cancel
|
||||
}
|
||||
|
||||
func (x *CancelRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CancelRequest) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether cancellation was successful
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Error message if cancellation failed
|
||||
}
|
||||
|
||||
func (x *CancelResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetSuccess() bool {
|
||||
if x != nil {
|
||||
return x.Success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type SchedulerService interface {
|
||||
// One-time event scheduling
|
||||
ScheduleOneTime(context.Context, *ScheduleOneTimeRequest) (*ScheduleResponse, error)
|
||||
// Recurring event scheduling
|
||||
ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error)
|
||||
// Cancel any scheduled job
|
||||
CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error)
|
||||
}
|
||||
42
plugins/host/scheduler/scheduler.proto
Normal file
42
plugins/host/scheduler/scheduler.proto
Normal file
@@ -0,0 +1,42 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package scheduler;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/scheduler;scheduler";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service SchedulerService {
|
||||
// One-time event scheduling
|
||||
rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse);
|
||||
|
||||
// Recurring event scheduling
|
||||
rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse);
|
||||
|
||||
// Cancel any scheduled job
|
||||
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
|
||||
}
|
||||
|
||||
message ScheduleOneTimeRequest {
|
||||
int32 delay_seconds = 1; // Delay in seconds
|
||||
bytes payload = 2; // Serialized data to pass to the callback
|
||||
string schedule_id = 3; // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
message ScheduleRecurringRequest {
|
||||
string cron_expression = 1; // Cron expression (e.g. "0 0 * * *" for daily at midnight)
|
||||
bytes payload = 2; // Serialized data to pass to the callback
|
||||
string schedule_id = 3; // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
message ScheduleResponse {
|
||||
string schedule_id = 1; // ID to reference this scheduled job
|
||||
}
|
||||
|
||||
message CancelRequest {
|
||||
string schedule_id = 1; // ID of the schedule to cancel
|
||||
}
|
||||
|
||||
message CancelResponse {
|
||||
bool success = 1; // Whether cancellation was successful
|
||||
string error = 2; // Error message if cancellation failed
|
||||
}
|
||||
136
plugins/host/scheduler/scheduler_host.pb.go
Normal file
136
plugins/host/scheduler/scheduler_host.pb.go
Normal 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
|
||||
}
|
||||
90
plugins/host/scheduler/scheduler_plugin.pb.go
Normal file
90
plugins/host/scheduler/scheduler_plugin.pb.go
Normal 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
|
||||
}
|
||||
7
plugins/host/scheduler/scheduler_plugin_dev.go
Normal file
7
plugins/host/scheduler/scheduler_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package scheduler
|
||||
|
||||
func NewSchedulerService() SchedulerService {
|
||||
panic("not implemented")
|
||||
}
|
||||
1002
plugins/host/scheduler/scheduler_vtproto.pb.go
Normal file
1002
plugins/host/scheduler/scheduler_vtproto.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
240
plugins/host/websocket/websocket.pb.go
Normal file
240
plugins/host/websocket/websocket.pb.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/websocket/websocket.proto
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ConnectRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
ConnectionId string `protobuf:"bytes,3,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ConnectResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ConnectResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ConnectResponse) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConnectResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendTextRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendTextRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendTextRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SendTextRequest) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendTextResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendTextResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendTextResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendBinaryRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendBinaryRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendBinaryRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SendBinaryRequest) GetData() []byte {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SendBinaryResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendBinaryResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendBinaryResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CloseRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"`
|
||||
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CloseRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CloseRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CloseRequest) GetCode() int32 {
|
||||
if x != nil {
|
||||
return x.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CloseRequest) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CloseResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CloseResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CloseResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type WebSocketService interface {
|
||||
// Connect to a WebSocket endpoint
|
||||
Connect(context.Context, *ConnectRequest) (*ConnectResponse, error)
|
||||
// Send a text message
|
||||
SendText(context.Context, *SendTextRequest) (*SendTextResponse, error)
|
||||
// Send binary data
|
||||
SendBinary(context.Context, *SendBinaryRequest) (*SendBinaryResponse, error)
|
||||
// Close a connection
|
||||
Close(context.Context, *CloseRequest) (*CloseResponse, error)
|
||||
}
|
||||
57
plugins/host/websocket/websocket.proto
Normal file
57
plugins/host/websocket/websocket.proto
Normal file
@@ -0,0 +1,57 @@
|
||||
syntax = "proto3";
|
||||
package websocket;
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/websocket";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service WebSocketService {
|
||||
// Connect to a WebSocket endpoint
|
||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||
|
||||
// Send a text message
|
||||
rpc SendText(SendTextRequest) returns (SendTextResponse);
|
||||
|
||||
// Send binary data
|
||||
rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse);
|
||||
|
||||
// Close a connection
|
||||
rpc Close(CloseRequest) returns (CloseResponse);
|
||||
}
|
||||
|
||||
message ConnectRequest {
|
||||
string url = 1;
|
||||
map<string, string> headers = 2;
|
||||
string connection_id = 3;
|
||||
}
|
||||
|
||||
message ConnectResponse {
|
||||
string connection_id = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message SendTextRequest {
|
||||
string connection_id = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message SendTextResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message SendBinaryRequest {
|
||||
string connection_id = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message SendBinaryResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message CloseRequest {
|
||||
string connection_id = 1;
|
||||
int32 code = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message CloseResponse {
|
||||
string error = 1;
|
||||
}
|
||||
170
plugins/host/websocket/websocket_host.pb.go
Normal file
170
plugins/host/websocket/websocket_host.pb.go
Normal 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
|
||||
}
|
||||
113
plugins/host/websocket/websocket_plugin.pb.go
Normal file
113
plugins/host/websocket/websocket_plugin.pb.go
Normal 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
|
||||
}
|
||||
7
plugins/host/websocket/websocket_plugin_dev.go
Normal file
7
plugins/host/websocket/websocket_plugin_dev.go
Normal 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
Reference in New Issue
Block a user