Skip to main content

wavyzon_plugin/
plugin.rs

1//! The [`Plugin`] trait and the [`Registry`] plugins fill at startup.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use axum::Router;
9use serde::Serialize;
10
11use crate::{Event, FilterFactory, Source};
12
13/// Version of this API. The node refuses plugins built for another major.
14pub const API_VERSION: u32 = 1;
15
16/// Static description of a plugin, shown in `GET /v1/info` and `GET /v1/plugins`.
17#[derive(Debug, Clone, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct PluginManifest {
20    /// Lowercase, URL-safe name (`"hello"`). Also the route prefix
21    /// `/v1/plugins/<name>/…` and the env prefix `WAVYZON_PLUGIN_<NAME>_`.
22    pub name: &'static str,
23    pub version: &'static str,
24    pub description: &'static str,
25    /// Must equal [`API_VERSION`].
26    pub api_version: u32,
27}
28
29/// Receives every player event of every session, including Wavyzon-specific
30/// events a Lavalink v4 client did not opt into. Runs on a dedicated task;
31/// slow listeners only delay themselves.
32#[async_trait]
33pub trait EventListener: Send + Sync + 'static {
34    async fn on_event(&self, session_id: &str, event: &Event);
35}
36
37/// What a plugin is given at startup.
38#[derive(Debug, Clone)]
39pub struct PluginContext {
40    /// `WAVYZON_PLUGIN_<NAME>_*` variables, keyed by the suffix in lowercase
41    /// (`WAVYZON_PLUGIN_HELLO_GREETING=hi` → `"greeting" => "hi"`).
42    pub settings: HashMap<String, String>,
43    /// A private, writable directory (`$WAVYZON_DATA_DIR/plugins/<name>`). The
44    /// node creates it before calling [`Plugin::register`].
45    pub data_dir: PathBuf,
46    /// The node's version string.
47    pub node_version: &'static str,
48}
49
50impl PluginContext {
51    pub fn setting(&self, key: &str) -> Option<&str> {
52        self.settings.get(&key.to_ascii_lowercase()).map(String::as_str)
53    }
54
55    /// Build the context for `name` from the process environment.
56    pub fn from_env(name: &str, data_root: &std::path::Path, node_version: &'static str) -> Self {
57        let prefix = format!("WAVYZON_PLUGIN_{}_", name.to_ascii_uppercase().replace('-', "_"));
58        let settings = std::env::vars()
59            .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|s| (s.to_ascii_lowercase(), v)))
60            .collect();
61        Self {
62            settings,
63            data_dir: data_root.join("plugins").join(name),
64            node_version,
65        }
66    }
67}
68
69/// Everything plugins contribute.
70#[derive(Default)]
71pub struct Registry {
72    pub(crate) sources: Vec<Arc<dyn Source>>,
73    pub(crate) filters: Vec<Arc<dyn FilterFactory>>,
74    pub(crate) listeners: Vec<Arc<dyn EventListener>>,
75    pub(crate) routes: Vec<(String, Router)>,
76    current: Option<String>,
77}
78
79impl Registry {
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Add a source. It is consulted before the built-in ones for links it
85    /// matches, and its search prefix becomes usable in `loadtracks`.
86    pub fn source(&mut self, source: Arc<dyn Source>) -> &mut Self {
87        self.sources.push(source);
88        self
89    }
90
91    /// Add a filter usable under `filters.pluginFilters.<name>`.
92    pub fn filter(&mut self, factory: Arc<dyn FilterFactory>) -> &mut Self {
93        self.filters.push(factory);
94        self
95    }
96
97    /// Receive every player event.
98    pub fn listener(&mut self, listener: Arc<dyn EventListener>) -> &mut Self {
99        self.listeners.push(listener);
100        self
101    }
102
103    /// Mount `router` at `/v1/plugins/<plugin name>`. Requests are
104    /// authenticated by the node before they reach it.
105    pub fn routes(&mut self, router: Router) -> &mut Self {
106        let name = self.current.clone().unwrap_or_else(|| "unnamed".into());
107        self.routes.push((name, router));
108        self
109    }
110
111    #[doc(hidden)]
112    pub fn begin(&mut self, plugin: &str) {
113        self.current = Some(plugin.to_string());
114    }
115
116    pub fn sources(&self) -> &[Arc<dyn Source>] {
117        &self.sources
118    }
119    pub fn filters(&self) -> &[Arc<dyn FilterFactory>] {
120        &self.filters
121    }
122    pub fn listeners(&self) -> &[Arc<dyn EventListener>] {
123        &self.listeners
124    }
125    /// Take the routers out (the server mounts them once).
126    pub fn take_routes(&mut self) -> Vec<(String, Router)> {
127        std::mem::take(&mut self.routes)
128    }
129}
130
131/// A Wavyzon plugin.
132///
133/// ```no_run
134/// use std::sync::Arc;
135/// use wavyzon_plugin::*;
136///
137/// struct Hello;
138///
139/// #[async_trait]
140/// impl Plugin for Hello {
141///     fn manifest(&self) -> PluginManifest {
142///         PluginManifest { name: "hello", version: "1.0.0", description: "Says hi", api_version: API_VERSION }
143///     }
144///     async fn register(&self, _ctx: &PluginContext, reg: &mut Registry) -> Result<(), PluginError> {
145///         reg.routes(axum::Router::new().route("/", axum::routing::get(|| async { "hi" })));
146///         Ok(())
147///     }
148/// }
149/// ```
150#[async_trait]
151pub trait Plugin: Send + Sync + 'static {
152    fn manifest(&self) -> PluginManifest;
153
154    /// Register sources, filters, listeners and routes. Called once, in
155    /// plugin order, before the server starts listening.
156    async fn register(&self, ctx: &PluginContext, registry: &mut Registry) -> Result<(), PluginError>;
157
158    /// Called once on graceful shutdown.
159    async fn shutdown(&self) {}
160}
161
162#[derive(Debug, thiserror::Error)]
163pub enum PluginError {
164    #[error("plugin {plugin} targets API v{found}, this node speaks v{expected}")]
165    ApiVersion { plugin: String, found: u32, expected: u32 },
166    #[error("plugin configuration: {0}")]
167    Config(String),
168    #[error("{0}")]
169    Other(String),
170}