diff --git a/examples/lavalink-basic-bot.rs b/examples/lavalink-basic-bot.rs index ccc27bb1e25..6ebb864b46f 100644 --- a/examples/lavalink-basic-bot.rs +++ b/examples/lavalink-basic-bot.rs @@ -9,6 +9,10 @@ use twilight_gateway::{ Event, EventTypeFlags, Intents, MessageSender, Shard, ShardId, StreamExt as _, }; use twilight_http::Client as HttpClient; +use twilight_lavalink::{ + http::LoadResultData::{Playlist, Search, Track}, + model::{Equalizer, EqualizerBand}, +}; use twilight_lavalink::{ http::LoadedTracks, model::{Destroy, Pause, Play, Seek, Stop, Volume}, @@ -53,6 +57,7 @@ async fn main() -> anyhow::Result<()> { let http = HttpClient::new(token.clone()); let user_id = http.current_user().await?.model().await?.id; + // The client is [`Lavalink`](crate::client::Lavalink) that forwards the required events from Discord. let lavalink = Lavalink::new(user_id, shard_count); lavalink.add(lavalink_host, lavalink_auth).await?; @@ -81,6 +86,10 @@ async fn main() -> anyhow::Result<()> { }; state.standby.process(&event); + + // We read the [Voice State and Voice Server Updates](https://discord.com/developers/docs/topics/gateway-events#voice) from discord to format the data to send to a Lavalink `VoiceUpdate` Event. + // There is a lower level [node](crate::node) that processes this for you. It isn't recommended to use this but rather the lavalink struct with the players. If you don't find functionality please open up and issue to expose what you need. + // See the command functions for where we use the players. state.lavalink.process(&event).await?; if let Event::MessageCreate(msg) = event { @@ -96,6 +105,7 @@ async fn main() -> anyhow::Result<()> { Some("!seek") => spawn(seek(msg.0, Arc::clone(&state))), Some("!stop") => spawn(stop(msg.0, Arc::clone(&state))), Some("!volume") => spawn(volume(msg.0, Arc::clone(&state))), + Some("!equalize") => spawn(equalize(msg.0, Arc::clone(&state))), _ => continue, } } @@ -194,13 +204,21 @@ async fn play(msg: Message, state: State) -> anyhow::Result<()> { let loaded = serde_json::from_slice::(&response_bytes)?; - if let Some(track) = loaded.tracks.first() { - player.send(Play::from((guild_id, &track.track)))?; + let track = match loaded.data { + Track(track) => Some(track), + Playlist(top_track) => top_track.tracks.first().cloned(), + Search(result) => result.first().cloned(), + _ => None, + }; + + if let Some(track) = track { + player.send(Play::from((guild_id, &track.encoded)))?; let content = format!( "Playing **{:?}** by **{:?}**", track.info.title, track.info.author ); + state .http .create_message(msg.channel_id) @@ -274,6 +292,57 @@ async fn seek(msg: Message, state: State) -> anyhow::Result<()> { Ok(()) } +async fn equalize(msg: Message, state: State) -> anyhow::Result<()> { + tracing::debug!( + "equalize command in channel {} by {}", + msg.channel_id, + msg.author.name + ); + state + .http + .create_message(msg.channel_id) + .content("What band do you want to equalize (0-14)?") + .await?; + + let author_id = msg.author.id; + let band_msg = state + .standby + .wait_for_message(msg.channel_id, move |new_msg: &MessageCreate| { + new_msg.author.id == author_id + }) + .await?; + let guild_id = msg.guild_id.unwrap(); + let band = band_msg.content.parse::()?; + + state + .http + .create_message(msg.channel_id) + .content("What gain do you want to equalize (-0.25 to 1.0)?") + .await?; + + let gain_msg = state + .standby + .wait_for_message(msg.channel_id, move |new_msg: &MessageCreate| { + new_msg.author.id == author_id + }) + .await?; + let gain = gain_msg.content.parse::()?; + + let player = state.lavalink.player(guild_id).await.unwrap(); + player.send(Equalizer::from(( + guild_id, + vec![EqualizerBand::new(band, gain)], + )))?; + + state + .http + .create_message(msg.channel_id) + .content(&format!("Changed gain level to {gain} on band {band}.")) + .await?; + + Ok(()) +} + async fn stop(msg: Message, state: State) -> anyhow::Result<()> { tracing::debug!( "stop command in channel {} by {}", diff --git a/twilight-lavalink/Cargo.toml b/twilight-lavalink/Cargo.toml index 9bab2dfb32a..4eb087f0fd6 100644 --- a/twilight-lavalink/Cargo.toml +++ b/twilight-lavalink/Cargo.toml @@ -17,15 +17,16 @@ version = "0.16.0-rc.1" dashmap = { default-features = false, version = "5.3" } futures-util = { default-features = false, features = ["bilock", "sink", "std", "unstable"], version = "0.3" } http = { default-features = false, version = "1" } +hyper = { default-features = false, features = ["client", "http1"], version = "1" } +hyper-util = { default-features = false, features = ["client-legacy", "client", "http1", "tokio"], version = "0.1" } +http-body-util = "0.1" serde = { default-features = false, features = ["derive", "std"], version = "1" } serde_json = { default-features = false, features = ["std"], version = "1" } tokio = { default-features = false, features = ["macros", "net", "rt", "sync", "time"], version = "1.0" } tokio-websockets = { default-features = false, features = ["client", "fastrand", "sha1_smol", "simd"], version = "0.7" } tracing = { default-features = false, features = ["std", "attributes"], version = "0.1" } twilight-model = { default-features = false, path = "../twilight-model", version = "0.16.0-rc.1" } - -# Optional dependencies. -percent-encoding = { default-features = false, optional = true, version = "2" } +percent-encoding = { default-features = false, version = "2" } [dev-dependencies] anyhow = { default-features = false, features = ["std"], version = "1" } @@ -37,8 +38,8 @@ twilight-gateway = { default-features = false, features = ["rustls-native-roots" twilight-http = { default-features = false, features = ["rustls-native-roots"], path = "../twilight-http", version = "0.16.0-rc.1" } [features] -default = ["http-support", "rustls-native-roots"] -http-support = ["dep:percent-encoding"] +default = ["http2", "rustls-native-roots"] +http2 = ["hyper/http2", "hyper-util/http2"] native-tls = ["tokio-websockets/native-tls", "tokio-websockets/openssl"] rustls-native-roots = ["tokio-websockets/ring", "tokio-websockets/rustls-native-roots"] rustls-webpki-roots = ["tokio-websockets/ring", "tokio-websockets/rustls-webpki-roots"] diff --git a/twilight-lavalink/README.md b/twilight-lavalink/README.md index 3800e077ba9..6f39edf0c97 100644 --- a/twilight-lavalink/README.md +++ b/twilight-lavalink/README.md @@ -13,12 +13,19 @@ handle sending voice channel updates to Lavalink by processing events via the [client's `process` method][`Lavalink::process`], which you must call with every Voice State Update and Voice Server Update you receive. +Currently some [Filters](crate::model::outgoing::Filters) are not yet supported. +Some endpoints such as [Lavalink Info] and [Update Session] have also not yet +been implemented. Please reach out and open an issue for any missing feature you +would like to use. The Lavalink V4 port did not add support for any new features +not previously found in V3. + ## Features -### `http-support` +### `http2` -The `http-support` feature adds support for the `http` module to return -request types from the [`http`] crate. This is enabled by default. +The `http2` feature enables support for communicating with the Lavalink server +over HTTP/2. You will also need to enable http2 support in your Lavalink server +configuration as it is disabled by default. ### TLS @@ -104,6 +111,8 @@ There is also an example of a basic bot located in the [root of the `twilight` repository][github examples link]. [Lavalink]: https://github.com/freyacodes/Lavalink +[Lavalink Info]: https://lavalink.dev/api/rest.html#get-lavalink-version +[Update Session]: https://lavalink.dev/api/rest#update-session [`http`]: https://crates.io/crates/http [`rustls`]: https://crates.io/crates/rustls [`rustls-native-certs`]: https://crates.io/crates/rustls-native-certs diff --git a/twilight-lavalink/src/client.rs b/twilight-lavalink/src/client.rs index 5b98e9e0845..d221993e78c 100644 --- a/twilight-lavalink/src/client.rs +++ b/twilight-lavalink/src/client.rs @@ -103,7 +103,7 @@ pub struct Lavalink { shard_count: u32, user_id: Id, server_updates: DashMap, VoiceServerUpdate>, - sessions: DashMap, Box>, + discord_sessions: DashMap, Box>, } impl Lavalink { @@ -145,7 +145,7 @@ impl Lavalink { shard_count, user_id, server_updates: DashMap::new(), - sessions: DashMap::new(), + discord_sessions: DashMap::new(), } } @@ -199,10 +199,10 @@ impl Lavalink { } if e.channel_id.is_none() { - self.sessions.remove(&guild_id); + self.discord_sessions.remove(&guild_id); self.server_updates.remove(&guild_id); } else { - self.sessions + self.discord_sessions .insert(guild_id, e.session_id.clone().into_boxed_str()); } guild_id @@ -218,7 +218,7 @@ impl Lavalink { let update = { let server = self.server_updates.get(&guild_id); - let session = self.sessions.get(&guild_id); + let session = self.discord_sessions.get(&guild_id); match (server, session) { (Some(server), Some(session)) => { let server = server.value(); @@ -386,7 +386,7 @@ impl Lavalink { self.server_updates .retain(|k, _| (k.get() >> 22) % shard_count != u64::from(shard_id)); - self.sessions + self.discord_sessions .retain(|k, _| (k.get() >> 22) % shard_count != u64::from(shard_id)); } } diff --git a/twilight-lavalink/src/http.rs b/twilight-lavalink/src/http.rs index 76dee1b5264..f7d66fe3849 100644 --- a/twilight-lavalink/src/http.rs +++ b/twilight-lavalink/src/http.rs @@ -1,6 +1,7 @@ //! Models to deserialize responses into and functions to create `http` crate //! requests. +use crate::model::incoming::{Exception, Track}; use http::{ header::{HeaderValue, AUTHORIZATION}, Error as HttpError, Request, @@ -9,66 +10,13 @@ use percent_encoding::NON_ALPHANUMERIC; use serde::{Deserialize, Deserializer, Serialize}; use std::net::{IpAddr, SocketAddr}; -/// The type of search result given. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[non_exhaustive] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum LoadType { - /// Loading the results failed. - LoadFailed, - /// There were no matches. - NoMatches, - /// A playlist was found. - PlaylistLoaded, - /// Some results were found. - SearchResult, - /// A single track was found. - TrackLoaded, -} - -/// A track within a search result. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[non_exhaustive] -#[serde(rename_all = "camelCase")] -pub struct Track { - /// Details about a track, such as the author and title. - pub info: TrackInfo, - /// The base64 track string that you use in the [`Play`] event. - /// - /// [`Play`]: crate::model::outgoing::Play - pub track: String, -} - -/// Additional information about a track, such as the author. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[non_exhaustive] -#[serde(rename_all = "camelCase")] -pub struct TrackInfo { - /// The name of the author, if provided. - pub author: Option, - /// The identifier of the source of the track. - pub identifier: String, - /// Whether the source is seekable. - pub is_seekable: bool, - /// Whether the source is a stream. - pub is_stream: bool, - /// The length of the audio in milliseconds. - pub length: u64, - /// The position of the audio. - pub position: u64, - /// The title, if provided. - pub title: Option, - /// The source URI of the track. - pub uri: String, -} - /// Information about a playlist from a search result. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct PlaylistInfo { - /// The name of the playlist, if available. - pub name: Option, + /// The name of the playlist + pub name: String, /// The selected track within the playlist, if available. #[serde(default, deserialize_with = "deserialize_selected_track")] pub selected_track: Option, @@ -85,17 +33,60 @@ where .and_then(|selected| u64::try_from(selected).ok())) } +/// The type of search result given. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub enum LoadResultName { + /// There has been no matches for your identifier. + Empty, + /// Loading has failed with an error. + Error, + /// A playlist has been loaded. + Playlist, + /// A search result has been loaded. + Search, + /// A track has been loaded. + Track, +} + +/// The type of search result given. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(untagged)] +pub enum LoadResultData { + /// Empty data response. + Empty, + /// The exception that was thrown when searching. + Error(Exception), + /// The playlist results with the play list info and tracks in the playlist. + Playlist(PlaylistResult), + /// The list of tracks based on the search. + Search(Vec), + /// Track result with the track info. + Track(Track), +} + +/// The playlist with the provided tracks. Currently plugin info isn't supported +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct PlaylistResult { + /// The info of the playlist. + pub info: PlaylistInfo, + /// The tracks of the playlist. + pub tracks: Vec, +} + /// Possible track results for a query. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct LoadedTracks { + /// The data of the result. + pub data: LoadResultData, /// The type of search result, such as a list of tracks or a playlist. - pub load_type: LoadType, - /// Information about the playlist, if provided. - pub playlist_info: PlaylistInfo, - /// The list of tracks returned for the search query. - pub tracks: Vec, + pub load_type: LoadResultName, } /// A failing IP address within the planner. @@ -258,7 +249,32 @@ pub fn load_track( ) -> Result, HttpError> { let identifier = percent_encoding::percent_encode(identifier.as_ref().as_bytes(), NON_ALPHANUMERIC); - let url = format!("http://{address}/loadtracks?identifier={identifier}"); + let url = format!("http://{address}/v4/loadtracks?identifier={identifier}"); + + let mut req = Request::get(url); + + let auth_value = HeaderValue::from_str(authorization.as_ref())?; + req = req.header(AUTHORIZATION, auth_value); + + req.body(b"") +} + +/// Decode a single track into its info +/// +/// The response will include a body which can be deserialized into a +/// [`Track`]. +/// +/// # Errors +/// +/// See the documentation for [`http::Error`]. +pub fn decode_track( + address: SocketAddr, + encoded: impl AsRef, + authorization: impl AsRef, +) -> Result, HttpError> { + let identifier = + percent_encoding::percent_encode(encoded.as_ref().as_bytes(), NON_ALPHANUMERIC); + let url = format!("http://{address}/v4/decodetrack?encodedTrack={identifier}"); let mut req = Request::get(url); @@ -280,7 +296,7 @@ pub fn get_route_planner( address: SocketAddr, authorization: impl AsRef, ) -> Result, HttpError> { - let mut req = Request::get(format!("{address}/routeplanner/status")); + let mut req = Request::get(format!("{address}/v4/routeplanner/status")); let auth_value = HeaderValue::from_str(authorization.as_ref())?; req = req.header(AUTHORIZATION, auth_value); @@ -301,7 +317,7 @@ pub fn unmark_failed_address( authorization: impl AsRef, route_address: impl Into, ) -> Result>, HttpError> { - let mut req = Request::post(format!("{}/routeplanner/status", node_address.into())); + let mut req = Request::post(format!("{}/v4/routeplanner/status", node_address.into())); let auth_value = HeaderValue::from_str(authorization.as_ref())?; req = req.header(AUTHORIZATION, auth_value); @@ -317,11 +333,11 @@ pub fn unmark_failed_address( #[cfg(test)] mod tests { use super::{ - FailingAddress, IpBlock, IpBlockType, LoadType, LoadedTracks, NanoIpDetails, - NanoIpRoutePlanner, PlaylistInfo, RotatingIpDetails, RotatingIpRoutePlanner, - RotatingNanoIpDetails, RotatingNanoIpRoutePlanner, RoutePlanner, RoutePlannerType, Track, - TrackInfo, + FailingAddress, IpBlock, IpBlockType, LoadedTracks, NanoIpDetails, NanoIpRoutePlanner, + PlaylistInfo, RotatingIpDetails, RotatingIpRoutePlanner, RotatingNanoIpDetails, + RotatingNanoIpRoutePlanner, RoutePlanner, RoutePlannerType, Track, }; + use crate::model::incoming::TrackInfo; use serde::{Deserialize, Serialize}; use serde_test::Token; use static_assertions::{assert_fields, assert_impl_all}; @@ -359,17 +375,6 @@ mod tests { Serialize, Sync, ); - assert_impl_all!( - LoadType: Clone, - Debug, - Deserialize<'static>, - Eq, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(LoadedTracks: load_type, playlist_info, tracks); assert_impl_all!( LoadedTracks: Clone, Debug, @@ -512,7 +517,7 @@ mod tests { Serialize, Sync ); - assert_fields!(Track: info, track); + assert_fields!(Track: encoded, info); assert_impl_all!( Track: Clone, Debug, @@ -527,7 +532,7 @@ mod tests { #[test] pub fn test_deserialize_playlist_info_negative_selected_track() { let value = PlaylistInfo { - name: Some("Test Playlist".to_owned()), + name: "Test Playlist".to_owned(), selected_track: None, }; @@ -539,7 +544,6 @@ mod tests { len: 13, }, Token::Str("name"), - Token::Some, Token::Str("Test Playlist"), Token::Str("selectedTrack"), Token::I64(-1), diff --git a/twilight-lavalink/src/lib.rs b/twilight-lavalink/src/lib.rs index 367a4bf3ce8..8d7db101d90 100644 --- a/twilight-lavalink/src/lib.rs +++ b/twilight-lavalink/src/lib.rs @@ -13,11 +13,12 @@ )] pub mod client; +pub mod http; pub mod model; pub mod node; pub mod player; -#[cfg(feature = "http-support")] -pub mod http; - pub use self::{client::Lavalink, node::Node, player::PlayerManager}; + +/// Lavalink API version used by this crate. +pub const API_VERSION: u8 = 4; diff --git a/twilight-lavalink/src/model.rs b/twilight-lavalink/src/model.rs index 1b94d3fdec7..dac9296c348 100644 --- a/twilight-lavalink/src/model.rs +++ b/twilight-lavalink/src/model.rs @@ -1,926 +1,24 @@ //! Models to (de)serialize incoming/outgoing websocket events and HTTP //! responses. -use serde::{Deserialize, Serialize}; - -/// The type of event that something is. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[non_exhaustive] -#[serde(rename_all = "camelCase")] -pub enum Opcode { - /// Destroy a player from a node. - Destroy, - /// Equalize a player. - Equalizer, - /// Meta information about a track starting or ending. - Event, - /// Pause a player. - Pause, - /// Play a track. - Play, - /// An update about a player's current track. - PlayerUpdate, - /// Seek a player's active track to a new position. - Seek, - /// Updated statistics about a node. - Stats, - /// Stop a player. - Stop, - /// A combined voice server and voice state update. - VoiceUpdate, - /// Set the volume of a player. - Volume, -} - -pub mod outgoing { - //! Events that clients send to Lavalink. - - use super::Opcode; - use serde::{Deserialize, Serialize}; - use twilight_model::{ - gateway::payload::incoming::VoiceServerUpdate, - id::{marker::GuildMarker, Id}, - }; - - /// An outgoing event to send to Lavalink. - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(untagged)] - pub enum OutgoingEvent { - /// Destroy a player for a guild. - Destroy(Destroy), - /// Equalize a player. - Equalizer(Equalizer), - /// Pause or unpause a player. - Pause(Pause), - /// Play a track. - Play(Play), - /// Seek a player's active track to a new position. - Seek(Seek), - /// Stop a player. - Stop(Stop), - /// A combined voice server and voice state update. - VoiceUpdate(VoiceUpdate), - /// Set the volume of a player. - Volume(Volume), - } - - impl From for OutgoingEvent { - fn from(event: Destroy) -> OutgoingEvent { - Self::Destroy(event) - } - } - - impl From for OutgoingEvent { - fn from(event: Equalizer) -> OutgoingEvent { - Self::Equalizer(event) - } - } - - impl From for OutgoingEvent { - fn from(event: Pause) -> OutgoingEvent { - Self::Pause(event) - } - } - - impl From for OutgoingEvent { - fn from(event: Play) -> OutgoingEvent { - Self::Play(event) - } - } - - impl From for OutgoingEvent { - fn from(event: Seek) -> OutgoingEvent { - Self::Seek(event) - } - } - - impl From for OutgoingEvent { - fn from(event: Stop) -> OutgoingEvent { - Self::Stop(event) - } - } - - impl From for OutgoingEvent { - fn from(event: VoiceUpdate) -> OutgoingEvent { - Self::VoiceUpdate(event) - } - } - - impl From for OutgoingEvent { - fn from(event: Volume) -> OutgoingEvent { - Self::Volume(event) - } - } - - /// Destroy a player from a node. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Destroy { - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - } - - impl Destroy { - /// Create a new destroy event. - pub const fn new(guild_id: Id) -> Self { - Self { - guild_id, - op: Opcode::Destroy, - } - } - } - - impl From> for Destroy { - fn from(guild_id: Id) -> Self { - Self { - guild_id, - op: Opcode::Destroy, - } - } - } - - /// Equalize a player. - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Equalizer { - /// The bands to use as part of the equalizer. - pub bands: Vec, - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - } - - impl Equalizer { - /// Create a new equalizer event. - pub fn new(guild_id: Id, bands: Vec) -> Self { - Self::from((guild_id, bands)) - } - } - - impl From<(Id, Vec)> for Equalizer { - fn from((guild_id, bands): (Id, Vec)) -> Self { - Self { - bands, - guild_id, - op: Opcode::Equalizer, - } - } - } - - /// A band of the equalizer event. - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct EqualizerBand { - /// The band. - pub band: i64, - /// The gain. - pub gain: f64, - } - - impl EqualizerBand { - /// Create a new equalizer band. - pub fn new(band: i64, gain: f64) -> Self { - Self::from((band, gain)) - } - } - - impl From<(i64, f64)> for EqualizerBand { - fn from((band, gain): (i64, f64)) -> Self { - Self { band, gain } - } - } - - /// Pause or unpause a player. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Pause { - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - /// Whether to pause the player. - /// - /// Set to `true` to pause or `false` to resume. - pub pause: bool, - } - - impl Pause { - /// Create a new pause event. - /// - /// Set to `true` to pause the player or `false` to resume it. - pub fn new(guild_id: Id, pause: bool) -> Self { - Self::from((guild_id, pause)) - } - } - - impl From<(Id, bool)> for Pause { - fn from((guild_id, pause): (Id, bool)) -> Self { - Self { - guild_id, - op: Opcode::Pause, - pause, - } - } - } - - /// Play a track, optionally specifying to not skip the current track. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Play { - /// The position in milliseconds to end the track. - /// - /// This currently [does nothing] as of this writing. - /// - /// [does nothing]: https://github.com/freyacodes/Lavalink/issues/179 - #[serde(skip_serializing_if = "Option::is_none")] - pub end_time: Option, - /// The guild ID of the player. - pub guild_id: Id, - /// Whether or not to replace the currently playing track with this new - /// track. - /// - /// Set to `true` to keep playing the current playing track, or `false` - /// to replace the current playing track with a new one. - pub no_replace: bool, - /// The opcode of the event. - pub op: Opcode, - /// The position in milliseconds to start the track from. - /// - /// For example, set to 5000 to start the track 5 seconds in. - #[serde(skip_serializing_if = "Option::is_none")] - pub start_time: Option, - /// The base64 track information. - pub track: String, - } - - impl Play { - /// Create a new play event. - pub fn new( - guild_id: Id, - track: impl Into, - start_time: impl Into>, - end_time: impl Into>, - no_replace: bool, - ) -> Self { - Self::from((guild_id, track, start_time, end_time, no_replace)) - } - } - - impl> From<(Id, T)> for Play { - fn from((guild_id, track): (Id, T)) -> Self { - Self::from((guild_id, track, None, None, true)) - } - } - - impl, S: Into>> From<(Id, T, S)> for Play { - fn from((guild_id, track, start_time): (Id, T, S)) -> Self { - Self::from((guild_id, track, start_time, None, true)) - } - } - - impl, S: Into>, E: Into>> - From<(Id, T, S, E)> for Play - { - fn from((guild_id, track, start_time, end_time): (Id, T, S, E)) -> Self { - Self::from((guild_id, track, start_time, end_time, true)) - } - } - - impl, S: Into>, E: Into>> - From<(Id, T, S, E, bool)> for Play - { - fn from( - (guild_id, track, start_time, end_time, no_replace): (Id, T, S, E, bool), - ) -> Self { - Self { - end_time: end_time.into(), - guild_id, - no_replace, - op: Opcode::Play, - start_time: start_time.into(), - track: track.into(), - } - } - } - - /// Seek a player's active track to a new position. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Seek { - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - /// The position in milliseconds to seek to. - pub position: i64, - } - - impl Seek { - /// Create a new seek event. - pub fn new(guild_id: Id, position: i64) -> Self { - Self::from((guild_id, position)) - } - } - - impl From<(Id, i64)> for Seek { - fn from((guild_id, position): (Id, i64)) -> Self { - Self { - guild_id, - op: Opcode::Seek, - position, - } - } - } - - /// Stop a player. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Stop { - /// The opcode of the event. - pub op: Opcode, - /// The guild ID of the player. - pub guild_id: Id, - } - - impl Stop { - /// Create a new stop event. - pub fn new(guild_id: Id) -> Self { - Self::from(guild_id) - } - } - - impl From> for Stop { - fn from(guild_id: Id) -> Self { - Self { - guild_id, - op: Opcode::Stop, - } - } - } - - /// A combined voice server and voice state update. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct VoiceUpdate { - /// The inner event being forwarded to a node. - pub event: VoiceServerUpdate, - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - /// The session ID of the voice channel. - pub session_id: String, - } - - impl VoiceUpdate { - /// Create a new voice update event. - pub fn new( - guild_id: Id, - session_id: impl Into, - event: VoiceServerUpdate, - ) -> Self { - Self::from((guild_id, session_id, event)) - } - } - - impl> From<(Id, T, VoiceServerUpdate)> for VoiceUpdate { - fn from((guild_id, session_id, event): (Id, T, VoiceServerUpdate)) -> Self { - Self { - event, - guild_id, - op: Opcode::VoiceUpdate, - session_id: session_id.into(), - } - } - } - - /// Set the volume of a player. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Volume { - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - /// The volume of the player from 0 to 1000. 100 is the default. - pub volume: i64, - } - - impl Volume { - /// Create a new volume event. - pub fn new(guild_id: Id, volume: i64) -> Self { - Self::from((guild_id, volume)) - } - } - - impl From<(Id, i64)> for Volume { - fn from((guild_id, volume): (Id, i64)) -> Self { - Self { - guild_id, - op: Opcode::Volume, - volume, - } - } - } -} - -pub mod incoming { - //! Events that Lavalink sends to clients. - - use super::Opcode; - use serde::{Deserialize, Serialize}; - use twilight_model::id::{marker::GuildMarker, Id}; - - /// An incoming event from a Lavalink node. - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(untagged)] - pub enum IncomingEvent { - /// An update about the information of a player. - PlayerUpdate(PlayerUpdate), - /// New statistics about a node and its host. - Stats(Stats), - /// A track ended. - TrackEnd(TrackEnd), - /// A track started. - TrackStart(TrackStart), - /// The voice websocket connection was closed. - WeboscketClosed(WebsocketClosed), - } - - impl From for IncomingEvent { - fn from(event: PlayerUpdate) -> IncomingEvent { - Self::PlayerUpdate(event) - } - } - - impl From for IncomingEvent { - fn from(event: Stats) -> IncomingEvent { - Self::Stats(event) - } - } - - /// An update about the information of a player. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct PlayerUpdate { - /// The guild ID of the player. - pub guild_id: Id, - /// The opcode of the event. - pub op: Opcode, - /// The new state of the player. - pub state: PlayerUpdateState, - } - - /// New statistics about a node and its host. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct PlayerUpdateState { - /// True when the player is connected to the voice gateway. - pub connected: bool, - /// Unix timestamp of the player in milliseconds. - pub time: i64, - /// Track position in milliseconds. None if not playing anything. - pub position: Option, - } - - /// Statistics about a node and its host. - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct Stats { - /// CPU information about the node's host. - pub cpu: StatsCpu, - /// Statistics about audio frames. - #[serde(rename = "frameStats", skip_serializing_if = "Option::is_none")] - pub frames: Option, - /// Memory information about the node's host. - pub memory: StatsMemory, - /// The current number of total players (active and not active) within - /// the node. - pub players: u64, - /// The current number of active players within the node. - pub playing_players: u64, - /// The opcode of the event. - pub op: Opcode, - /// The uptime of the Lavalink server in seconds. - pub uptime: u64, - } - - /// CPU information about a node and its host. - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct StatsCpu { - /// The number of CPU cores. - pub cores: usize, - /// The load of the Lavalink server. - pub lavalink_load: f64, - /// The load of the system as a whole. - pub system_load: f64, - } - - /// CPU information about a node and its host. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct StatsFrames { - /// The number of CPU cores. - pub sent: u64, - /// The load of the Lavalink server. - pub nulled: u64, - /// The load of the system as a whole. - pub deficit: u64, - } - - /// Memory information about a node and its host. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct StatsMemory { - /// The number of bytes allocated. - pub allocated: u64, - /// The number of bytes free. - pub free: u64, - /// The number of bytes reservable. - pub reservable: u64, - /// The number of bytes used. - pub used: u64, - } - - /// The type of track event that was received. - #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - pub enum TrackEventType { - /// A track for a player ended. - #[serde(rename = "TrackEndEvent")] - End, - /// A track for a player started. - #[serde(rename = "TrackStartEvent")] - Start, - /// The voice websocket connection to Discord has been closed. - #[serde(rename = "WebSocketClosedEvent")] - WebsocketClosed, - } - - /// A track ended. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct TrackEnd { - /// The guild ID of the player. - pub guild_id: Id, - /// The type of track event. - #[serde(rename = "type")] - pub kind: TrackEventType, - /// The opcode of the event. - pub op: Opcode, - /// The reason that the track ended. - /// - /// For example, this may be `"FINISHED"`. - pub reason: String, - /// The base64 track that was affected. - pub track: String, - } - - /// A track started. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct TrackStart { - /// The guild ID of the player. - pub guild_id: Id, - /// The type of track event. - #[serde(rename = "type")] - pub kind: TrackEventType, - /// The opcode of the event. - pub op: Opcode, - /// The base64 track that was affected. - pub track: String, - } - - /// The voice websocket connection to Discord has been closed. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[non_exhaustive] - #[serde(rename_all = "camelCase")] - pub struct WebsocketClosed { - /// Guild ID of the associated player. - pub guild_id: Id, - /// Type of track event. - #[serde(rename = "type")] - pub kind: TrackEventType, - /// Lavalink websocket opcode of the event. - pub op: Opcode, - /// Discord websocket opcode that closed the connection. - pub code: u64, - /// True if Discord closed the connection, false if Lavalink closed it. - pub by_remote: bool, - /// Reason the connection was closed. - pub reason: String, - } -} +pub mod incoming; +pub mod outgoing; pub use self::{ incoming::{ - IncomingEvent, PlayerUpdate, PlayerUpdateState, Stats, StatsCpu, StatsFrames, StatsMemory, - TrackEnd, TrackEventType, TrackStart, WebsocketClosed, + Exception, IncomingEvent, PlayerUpdate, PlayerUpdateState, Stats, StatsCpu, StatsFrame, + StatsMemory, Track, TrackEnd, TrackException, TrackStart, TrackStuck, WebSocketClosed, }, outgoing::{ - Destroy, Equalizer, EqualizerBand, OutgoingEvent, Pause, Play, Seek, Stop, VoiceUpdate, - Volume, + Destroy, Equalizer, EqualizerBand, OutgoingEvent, Pause, Play, Seek, Stop, + UpdatePlayerTrack, VoiceUpdate, Volume, }, }; #[cfg(test)] -mod tests { - use super::{ - incoming::{ - IncomingEvent, PlayerUpdate, PlayerUpdateState, Stats, StatsCpu, StatsFrames, - StatsMemory, TrackEnd, TrackEventType, TrackStart, WebsocketClosed, - }, - outgoing::{ - Destroy, Equalizer, EqualizerBand, OutgoingEvent, Pause, Play, Seek, Stop, VoiceUpdate, - Volume, - }, - Opcode, - }; - use serde::{Deserialize, Serialize}; +mod lavalink_struct_tests { + use super::incoming::{Stats, StatsCpu, StatsMemory}; use serde_test::Token; - use static_assertions::{assert_fields, assert_impl_all}; - use std::fmt::Debug; - use twilight_model::{ - gateway::payload::incoming::VoiceServerUpdate, - id::{marker::GuildMarker, Id}, - }; - - assert_fields!(Destroy: guild_id, op); - assert_impl_all!( - Destroy: Clone, - Debug, - Deserialize<'static>, - Eq, - From>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(EqualizerBand: band, gain); - assert_impl_all!( - EqualizerBand: Clone, - Debug, - Deserialize<'static>, - From<(i64, f64)>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(Equalizer: bands, guild_id, op); - assert_impl_all!( - Equalizer: Clone, - Debug, - Deserialize<'static>, - From<(Id, Vec)>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_impl_all!( - IncomingEvent: Clone, - Debug, - Deserialize<'static>, - From, - From, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_impl_all!( - OutgoingEvent: Clone, - Debug, - Deserialize<'static>, - From, - From, - From, - From, - From, - From, - From, - From, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(Pause: guild_id, op, pause); - assert_impl_all!( - Pause: Clone, - Debug, - Deserialize<'static>, - Eq, - From<(Id, bool)>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(PlayerUpdateState: position, time); - assert_impl_all!( - PlayerUpdateState: Clone, - Debug, - Deserialize<'static>, - Eq, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(PlayerUpdate: guild_id, op, state); - assert_impl_all!( - PlayerUpdate: Clone, - Debug, - Deserialize<'static>, - Eq, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(Play: end_time, guild_id, no_replace, op, start_time, track); - assert_impl_all!( - Play: Clone, - Debug, - Deserialize<'static>, - Eq, - From<(Id, String)>, - From<(Id, String, Option)>, - From<(Id, String, u64)>, - From<(Id, String, Option, Option)>, - From<(Id, String, Option, u64)>, - From<(Id, String, u64, Option)>, - From<(Id, String, u64, u64)>, - From<(Id, String, Option, Option, bool)>, - From<(Id, String, Option, u64, bool)>, - From<(Id, String, u64, Option, bool)>, - From<(Id, String, u64, u64, bool)>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(Seek: guild_id, op, position); - assert_impl_all!( - Seek: Clone, - Debug, - Deserialize<'static>, - Eq, - From<(Id, i64)>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!( - Stats: cpu, - frames, - memory, - players, - playing_players, - op, - uptime - ); - assert_impl_all!( - Stats: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(StatsCpu: cores, lavalink_load, system_load); - assert_impl_all!( - StatsCpu: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(StatsFrames: deficit, nulled, sent); - assert_impl_all!( - StatsFrames: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(StatsMemory: allocated, free, reservable, used); - assert_impl_all!( - StatsMemory: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(Stop: guild_id, op); - assert_impl_all!( - Stop: Clone, - Debug, - Deserialize<'static>, - Eq, - From>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(TrackEnd: guild_id, kind, op, reason, track); - assert_impl_all!( - TrackEnd: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_impl_all!( - TrackEventType: Clone, - Copy, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(TrackStart: guild_id, kind, op, track); - assert_impl_all!( - TrackStart: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(WebsocketClosed: guild_id, kind, op, code, reason, by_remote); - assert_impl_all!( - WebsocketClosed: Clone, - Debug, - Deserialize<'static>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(VoiceUpdate: event, guild_id, op, session_id); - assert_impl_all!( - VoiceUpdate: Clone, - Debug, - Deserialize<'static>, - Eq, - From<(Id, String, VoiceServerUpdate)>, - PartialEq, - Send, - Serialize, - Sync, - ); - assert_fields!(Volume: guild_id, op, volume); - assert_impl_all!( - Volume: Clone, - Debug, - Deserialize<'static>, - Eq, - PartialEq, - Send, - Serialize, - Sync, - ); #[test] fn stats_frames_not_provided() { @@ -932,12 +30,13 @@ mod tests { const SYSTEM_LOAD: f64 = 0.195_380_536_378_835_9; let expected = Stats { + op: crate::model::incoming::Opcode::Stats, cpu: StatsCpu { cores: 4, lavalink_load: LAVALINK_LOAD, system_load: SYSTEM_LOAD, }, - frames: None, + frame_stats: None, memory: StatsMemory { allocated: MEM_ALLOCATED, free: MEM_FREE, @@ -946,7 +45,6 @@ mod tests { }, players: 0, playing_players: 0, - op: Opcode::Stats, uptime: 18589, }; @@ -999,3 +97,391 @@ mod tests { ); } } + +#[cfg(test)] +mod lavalink_incoming_model_tests { + use crate::model::{TrackEnd, TrackException, TrackStart, TrackStuck}; + use twilight_model::id::{marker::GuildMarker, Id}; + + use super::{ + incoming::{ + Event, EventData, EventType, Exception, Opcode, PlayerUpdate, PlayerUpdateState, Ready, + Severity, Stats, StatsCpu, StatsFrame, StatsMemory, Track, TrackEndReason, TrackInfo, + }, + WebSocketClosed, + }; + + // These are incoming so we only need to check that the input json can deserialize into the struct. + fn compare_json_payload< + T: std::fmt::Debug + for<'a> serde::Deserialize<'a> + std::cmp::PartialEq, + >( + data_struct: &T, + json_payload: &str, + ) { + // Deserialize + let deserialized: T = serde_json::from_str(json_payload).unwrap(); + assert_eq!(deserialized, *data_struct); + } + + #[test] + fn should_deserialize_a_ready_response() { + let ready = Ready { + op: Opcode::Ready, + resumed: false, + session_id: "la3kfsdf5eafe848".to_string(), + }; + compare_json_payload( + &ready, + r#"{"op":"ready","resumed":false,"sessionId":"la3kfsdf5eafe848"}"#, + ); + } + + #[test] + fn should_deserialize_a_player_update_response() { + let update = PlayerUpdate { + op: Opcode::PlayerUpdate, + guild_id: Id::::new(987_654_321), + state: PlayerUpdateState { + time: 1_710_214_147_839, + position: 534, + connected: true, + ping: 0, + }, + }; + compare_json_payload( + &update, + r#"{"op":"playerUpdate","guildId":"987654321","state":{"time":1710214147839,"position":534,"connected":true,"ping":0}}"#, + ); + } + + #[test] + fn should_deserialize_stat_event() { + let stat_event = Stats { + op: Opcode::Stats, + players: 0, + playing_players: 0, + uptime: 1_139_738, + cpu: StatsCpu { + cores: 16, + lavalink_load: 3.497_090_420_769_919E-5, + system_load: 0.055_979_978_347_863_06, + }, + frame_stats: None, + memory: StatsMemory { + allocated: 331_350_016, + free: 228_139_904, + reservable: 8_396_996_608, + used: 103_210_112, + }, + }; + compare_json_payload( + &stat_event.clone(), + r#"{"op":"stats","frameStats":null,"players":0,"playingPlayers":0,"uptime":1139738,"memory":{"free":228139904,"used":103210112,"allocated":331350016,"reservable":8396996608},"cpu":{"cores":16,"systemLoad":0.05597997834786306,"lavalinkLoad":3.497090420769919E-5}}"#, + ); + } + + #[test] + fn should_deserialize_stat_event_with_frame_stat() { + let stat_event = Stats { + op: Opcode::Stats, + players: 0, + playing_players: 0, + uptime: 1_139_738, + cpu: StatsCpu { + cores: 16, + lavalink_load: 3.497_090_420_769_919E-5, + system_load: 0.055_979_978_347_863_06, + }, + frame_stats: Some(StatsFrame { + sent: 6000, + nulled: 10, + deficit: -3010, + }), + memory: StatsMemory { + allocated: 331_350_016, + free: 228_139_904, + reservable: 8_396_996_608, + used: 103_210_112, + }, + }; + compare_json_payload( + &stat_event.clone(), + r#"{"op":"stats","frameStats":{"sent":6000,"nulled":10,"deficit":-3010},"players":0,"playingPlayers":0,"uptime":1139738,"memory":{"free":228139904,"used":103210112,"allocated":331350016,"reservable":8396996608},"cpu":{"cores":16,"systemLoad":0.05597997834786306,"lavalinkLoad":3.497090420769919E-5}}"#, + ); + } + + #[test] + fn should_deserialize_track_start_event() { + let track_start_event = Event { + op: Opcode::Event, + r#type: EventType::TrackStartEvent, + guild_id: Id::::new(987_654_321).to_string(), + data: EventData::TrackStartEvent( + TrackStart { + track: Track { + encoded: "QAAAzgMAMUJsZWVkIEl0IE91dCBbT2ZmaWNpYWwgTXVzaWMgVmlkZW9dIC0gTGlua2luIFBhcmsAC0xpbmtpbiBQYXJrAAAAAAAClCgAC09udXVZY3FoekNFAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9T251dVljcWh6Q0UBADRodHRwczovL2kueXRpbWcuY29tL3ZpL09udXVZY3FoekNFL21heHJlc2RlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA".to_string(), + info: TrackInfo { + identifier: "OnuuYcqhzCE".to_string(), + is_seekable: true, + author: "Linkin Park".to_string(), + length: 169_000, + is_stream: false, + position: 0, + title: "Bleed It Out [Official Music Video] - Linkin Park".to_string(), + uri:Some("https://www.youtube.com/watch?v=OnuuYcqhzCE".to_string()), + source_name:"youtube".to_string(), + artwork_url:Some("https://i.ytimg.com/vi/OnuuYcqhzCE/maxresdefault.jpg".to_string()), + isrc: None + } + } + } + ) + + }; + compare_json_payload( + &track_start_event.clone(), + r#"{"op":"event","guildId":"987654321","type":"TrackStartEvent","track":{"encoded":"QAAAzgMAMUJsZWVkIEl0IE91dCBbT2ZmaWNpYWwgTXVzaWMgVmlkZW9dIC0gTGlua2luIFBhcmsAC0xpbmtpbiBQYXJrAAAAAAAClCgAC09udXVZY3FoekNFAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9T251dVljcWh6Q0UBADRodHRwczovL2kueXRpbWcuY29tL3ZpL09udXVZY3FoekNFL21heHJlc2RlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA","info":{"identifier":"OnuuYcqhzCE","isSeekable":true,"author":"Linkin Park","length":169000,"isStream":false,"position":0,"title":"Bleed It Out [Official Music Video] - Linkin Park","uri":"https://www.youtube.com/watch?v=OnuuYcqhzCE","artworkUrl":"https://i.ytimg.com/vi/OnuuYcqhzCE/maxresdefault.jpg","isrc":null,"sourceName":"youtube"},"pluginInfo":{},"userData":{}}}"#, + ); + } + + #[test] + fn should_deserialize_track_exception_event() { + let track_exception_event = Event { + op: Opcode::Event, + r#type: EventType::TrackExceptionEvent, + guild_id: Id::::new(987_654_321).to_string(), + data: EventData::TrackExceptionEvent( + TrackException { + track: Track { + encoded: "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==".to_string(), + info: TrackInfo { + identifier: "dQw4w9WgXcQ".to_string(), + is_seekable: true, + author: "RickAstleyVEVO".to_string(), + length: 212_000, + is_stream: false, + position: 0, + title: "Rick Astley - Never Gonna Give You Up".to_string(), + uri:Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string()), + source_name:"youtube".to_string(), + artwork_url:Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg".to_string()), + isrc: None + } + }, + exception: Exception { + message: Some(String::new()), + severity: Severity::Common, + cause: "No video found.".to_string(), + } + + } + ) + + }; + compare_json_payload( + &track_exception_event.clone(), + r#"{"op":"event","type":"TrackExceptionEvent","guildId":"987654321","track":{"encoded":"QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==","info":{"identifier":"dQw4w9WgXcQ","isSeekable":true,"author":"RickAstleyVEVO","length":212000,"isStream":false,"position":0,"title":"Rick Astley - Never Gonna Give You Up","uri":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","artworkUrl":"https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg","isrc":null,"sourceName":"youtube"},"pluginInfo":{}},"exception":{"message":"","severity":"common","cause":"No video found."}}"#, + ); + } + + #[test] + fn should_deserialize_track_stuck_event() { + let track_stuck_event = Event { + op: Opcode::Event, + r#type: EventType::TrackStuckEvent, + guild_id: Id::::new(987_654_321).to_string(), + data: EventData::TrackStuckEvent( + TrackStuck { + track: Track { + encoded: "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==".to_string(), + info: TrackInfo { + identifier: "dQw4w9WgXcQ".to_string(), + is_seekable: true, + author: "RickAstleyVEVO".to_string(), + length: 212_000, + is_stream: false, + position: 0, + title: "Rick Astley - Never Gonna Give You Up".to_string(), + uri:Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string()), + source_name:"youtube".to_string(), + artwork_url:Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg".to_string()), + isrc: None + } + }, + threshold_ms: 123_456_789, + + } + ) + + }; + compare_json_payload( + &track_stuck_event.clone(), + r#"{"op":"event","type":"TrackStuckEvent","guildId":"987654321","track":{"encoded":"QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==","info":{"identifier":"dQw4w9WgXcQ","isSeekable":true,"author":"RickAstleyVEVO","length":212000,"isStream":false,"position":0,"title":"Rick Astley - Never Gonna Give You Up","uri":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","artworkUrl":"https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg","isrc":null,"sourceName":"youtube"},"pluginInfo":{}},"thresholdMs":123456789}"#, + ); + } + + #[test] + fn should_deserialize_track_end_event() { + let track_stuck_event = Event { + op: Opcode::Event, + r#type: EventType::TrackEndEvent, + guild_id: Id::::new(987_654_321).to_string(), + data: EventData::TrackEndEvent( + TrackEnd { + track: Track { + encoded: "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==".to_string(), + info: TrackInfo { + identifier: "dQw4w9WgXcQ".to_string(), + is_seekable: true, + author: "RickAstleyVEVO".to_string(), + length: 212_000, + is_stream: false, + position: 0, + title: "Rick Astley - Never Gonna Give You Up".to_string(), + uri:Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string()), + source_name:"youtube".to_string(), + artwork_url:Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg".to_string()), + isrc: None + } + }, + reason: TrackEndReason::Finished, + } + ) + + }; + compare_json_payload( + &track_stuck_event.clone(), + r#"{"op":"event","type":"TrackEndEvent","guildId":"987654321","track":{"encoded":"QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==","info":{"identifier":"dQw4w9WgXcQ","isSeekable":true,"author":"RickAstleyVEVO","length":212000,"isStream":false,"position":0,"title":"Rick Astley - Never Gonna Give You Up","uri":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","artworkUrl":"https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg","isrc":null,"sourceName":"youtube"},"pluginInfo":{}},"reason":"finished"}"#, + ); + } + + #[test] + fn should_deserialize_websocketclosed_event() { + let websocket_closed_event = Event { + op: Opcode::Event, + r#type: EventType::WebSocketClosedEvent, + guild_id: Id::::new(987_654_321).to_string(), + data: EventData::WebSocketClosedEvent(WebSocketClosed { + code: 1000, + reason: String::new(), + by_remote: false, + }), + }; + compare_json_payload( + &websocket_closed_event.clone(), + r#"{"op":"event","type":"WebSocketClosedEvent","guildId":"987654321","code":1000,"reason":"","byRemote":false}"#, + ); + } +} + +#[cfg(test)] +mod lavalink_outgoing_model_tests { + use crate::model::outgoing::TrackOption; + use crate::model::{Destroy, Equalizer, Pause, Play, Seek, Stop, Volume}; + + use twilight_model::id::{marker::GuildMarker, Id}; + + use super::outgoing::{OutgoingEvent, UpdatePlayerTrack, Voice, VoiceUpdate}; + use super::EqualizerBand; + + // For some of the outgoing we have fields that don't get deserialized. We only need + // to check weather the serialization is working. + fn compare_json_payload( + data_struct: &T, + json_payload: &str, + ) { + let serialized = serde_json::to_string(&data_struct).unwrap(); + let expected_serialized = json_payload; + assert_eq!(serialized, expected_serialized); + } + + #[test] + fn should_serialize_an_outgoing_voice_update() { + let voice = VoiceUpdate { + guild_id: Id::::new(987_654_321), + voice: Voice { + token: String::from("863ea8ef2ads8ef2"), + endpoint: String::from("eu-centra654863.discord.media:443"), + session_id: String::from("asdf5w1efa65feaf315e8a8effsa1e5f"), + }, + }; + compare_json_payload( + &voice, + r#"{"voice":{"endpoint":"eu-centra654863.discord.media:443","sessionId":"asdf5w1efa65feaf315e8a8effsa1e5f","token":"863ea8ef2ads8ef2"}}"#, + ); + } + + #[test] + fn should_serialize_an_outgoing_play() { + let play = OutgoingEvent::Play(Play{ + track: UpdatePlayerTrack { + track_string: TrackOption::Encoded(Some("QAAAzgMAMUJsZWVkIEl0IE91dCBbT2ZmaWNpYWwgTXVzaWMgVmlkZW9dIC0gTGlua2luIFBhcmsAC0xpbmtpbiBQYXJrAAAAAAAClCgAC09udXVZY3FoekNFAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9T251dVljcWh6Q0UBADRodHRwczovL2kueXRpbWcuY29tL3ZpL09udXVZY3FoekNFL21heHJlc2RlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA".to_string())), + }, + position: None, + end_time: Some(None), + volume: None, + paused: None, + guild_id: Id::::new(987_654_321), + no_replace: true, + }); + compare_json_payload( + &play, + r#"{"endTime":null,"track":{"encoded":"QAAAzgMAMUJsZWVkIEl0IE91dCBbT2ZmaWNpYWwgTXVzaWMgVmlkZW9dIC0gTGlua2luIFBhcmsAC0xpbmtpbiBQYXJrAAAAAAAClCgAC09udXVZY3FoekNFAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9T251dVljcWh6Q0UBADRodHRwczovL2kueXRpbWcuY29tL3ZpL09udXVZY3FoekNFL21heHJlc2RlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA"}}"#, + ); + } + + #[test] + fn should_serialize_an_outgoing_stop() { + let stop = OutgoingEvent::Stop(Stop { + track: UpdatePlayerTrack { + track_string: TrackOption::Encoded(None), + }, + guild_id: Id::::new(987_654_321), + }); + compare_json_payload(&stop, r#"{"track":{"encoded":null}}"#); + } + + #[test] + fn should_serialize_an_outgoing_pause() { + let pause = OutgoingEvent::Pause(Pause { + paused: true, + guild_id: Id::::new(987_654_321), + }); + compare_json_payload(&pause, r#"{"guildId":"987654321","paused":true}"#); + } + + #[test] + fn should_serialize_an_outgoing_seek() { + let seek = OutgoingEvent::Seek(Seek { + position: 66000, + guild_id: Id::::new(987_654_321), + }); + compare_json_payload(&seek, r#"{"position":66000}"#); + } + + #[test] + fn should_serialize_an_outgoing_volume() { + let volume = OutgoingEvent::Volume(Volume { + volume: 50, + guild_id: Id::::new(987_654_321), + }); + compare_json_payload(&volume, r#"{"volume":50}"#); + } + + #[test] + fn should_serialize_an_outgoing_destroy_aka_leave() { + let destroy = OutgoingEvent::Destroy(Destroy { + guild_id: Id::::new(987_654_321), + }); + compare_json_payload(&destroy, r#"{"guildId":"987654321"}"#); + } + + #[test] + fn should_serialize_an_outgoing_equalize() { + let equalize = OutgoingEvent::Equalizer(Equalizer { + equalizer: vec![EqualizerBand::new(5, -0.15)], + guild_id: Id::::new(987_654_321), + }); + compare_json_payload(&equalize, r#"{"equalizer":[{"band":5,"gain":-0.15}]}"#); + } +} diff --git a/twilight-lavalink/src/model/incoming.rs b/twilight-lavalink/src/model/incoming.rs new file mode 100644 index 00000000000..f280ef8cf21 --- /dev/null +++ b/twilight-lavalink/src/model/incoming.rs @@ -0,0 +1,374 @@ +//! Events that Lavalink sends to clients. + +/// The type of event that something is. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub enum Opcode { + /// Meta information about a track starting or ending. + Event, + /// An update about a player's current track. + PlayerUpdate, + /// Lavalink is connected and ready. + Ready, + /// Updated statistics about a node. + Stats, +} + +use serde::{Deserialize, Serialize}; +use twilight_model::id::{marker::GuildMarker, Id}; + +/// The levels of severity that an exception can have. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub enum Severity { + /// The cause is known and expected, indicates that there is nothing wrong + /// with the library itself. + Common, + /// The probable cause is an issue with the library or there is no way to + /// tell what the cause might be. This is the default level and other + /// levels are used in cases where the thrower has more in-depth knowledge + /// about the error. + Fault, + /// The cause might not be exactly known, but is possibly caused by outside + /// factors. For example when an outside service responds in a format that + /// we do not expect. + Suspicious, +} + +/// The exception with the details attached on what happened when making a query +/// to lavalink. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Exception { + /// The cause of the exception. + pub cause: String, + /// The message of the exception. + pub message: Option, + /// The severity of the exception. + pub severity: Severity, +} + +/// An incoming event from a Lavalink node. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(untagged)] +pub enum IncomingEvent { + /// Dispatched when player or voice events occur. + Event(Event), + /// Dispatched when you successfully connect to the Lavalink node. + Ready(Ready), + /// New statistics about a node and its host. + Stats(Stats), + /// An update about the information of a player. + PlayerUpdate(PlayerUpdate), +} + +impl From for IncomingEvent { + fn from(event: Ready) -> IncomingEvent { + Self::Ready(event) + } +} + +impl From for IncomingEvent { + fn from(event: Event) -> IncomingEvent { + Self::Event(event) + } +} + +impl From for IncomingEvent { + fn from(event: PlayerUpdate) -> IncomingEvent { + Self::PlayerUpdate(event) + } +} + +impl From for IncomingEvent { + fn from(event: Stats) -> IncomingEvent { + Self::Stats(event) + } +} + +/// The discord voice information that lavalink uses for connection and sending +/// information. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct VoiceState { + /// The Discord voice endpoint to connect to. + pub endpoint: String, + /// The Discord voice session id to authenticate with. Note this is separate + /// from the lavalink session id. + pub session_id: String, + /// The Discord voice token to authenticate with. + pub token: String, +} + +/// An update of a player's status and state. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct PlayerUpdate { + /// The guild ID of the player. + pub guild_id: Id, + /// Op code for this websocket event. + pub op: Opcode, + /// The new state of the player. + pub state: PlayerUpdateState, +} + +/// New statistics about a node and its host. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct PlayerUpdateState { + /// True when the player is connected to the voice gateway. + pub connected: bool, + /// The ping of the node to the Discord voice server in milliseconds (-1 if not connected). + pub ping: i64, + /// Track position in milliseconds. None if not playing anything. + pub position: i64, + /// Unix timestamp of the player in milliseconds. + pub time: i64, +} + +/// Dispatched by Lavalink upon successful connection and authorization. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Ready { + /// Op code for this websocket event. + pub op: Opcode, + /// Whether this session was resumed. + pub resumed: bool, + /// The Lavalink session id of this connection. Not to be confused with a + /// Discord voice session id. + pub session_id: String, +} + +/// Statistics about a node and its host. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Stats { + /// CPU information about the node's host. + pub cpu: StatsCpu, + /// Statistics about audio frames. + #[serde(skip_serializing_if = "Option::is_none")] + pub frame_stats: Option, + /// Memory information about the node's host. + pub memory: StatsMemory, + /// Op code for this websocket event. + pub op: Opcode, + /// The current number of total players (active and not active) within + /// the node. + pub players: u64, + /// The current number of active players within the node. + pub playing_players: u64, + /// The uptime of the Lavalink server in seconds. + pub uptime: u64, +} + +/// CPU information about a node and its host. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct StatsCpu { + /// The number of CPU cores. + pub cores: usize, + /// The load of the Lavalink server. + pub lavalink_load: f64, + /// The load of the system as a whole. + pub system_load: f64, +} + +/// CPU information about a node and its host. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct StatsFrame { + /// The load of the system as a whole. + pub deficit: i64, + /// The load of the Lavalink server. + pub nulled: i64, + /// The number of CPU cores. + pub sent: i64, +} + +/// Memory information about a node and its host. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct StatsMemory { + /// The number of bytes allocated. + pub allocated: u64, + /// The number of bytes free. + pub free: u64, + /// The number of bytes reservable. + pub reservable: u64, + /// The number of bytes used. + pub used: u64, +} + +/// Information about the track returned or playing on lavalink. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct TrackInfo { + /// The track artwork url. + pub artwork_url: Option, + /// The track author. + pub author: String, + /// The track [ISRC](https://en.wikipedia.org/wiki/International_Standard_Recording_Code). + pub isrc: Option, + /// The track identifier. + pub identifier: String, + /// Whether the track is seekable. + pub is_seekable: bool, + /// Whether the track is a stream. + pub is_stream: bool, + /// The track length in milliseconds. + pub length: u64, + /// The track position in milliseconds. + pub position: u64, + /// The track source name. + pub source_name: String, + /// The track title. + pub title: String, + /// The track uri. + pub uri: Option, +} + +/// A track object for lavalink to consume and read. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Track { + /// The base64 encoded track to play + pub encoded: String, + /// Info about the track + pub info: TrackInfo, +} + +/// Server dispatched an event. See the Event Types section for more information. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Event { + /// The data of the event type. + #[serde(flatten)] + pub data: EventData, + /// The guild id that this was received from. + pub guild_id: String, + /// Op code for this websocket event. + pub op: Opcode, + /// The type of event. + pub r#type: EventType, +} + +/// Server dispatched an event. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +pub enum EventType { + /// Dispatched when a track starts playing. + TrackStartEvent, + /// Dispatched when a track ends. + TrackEndEvent, + /// Dispatched when a track throws an exception. + TrackExceptionEvent, + /// Dispatched when a track gets stuck while playing. + TrackStuckEvent, + /// Dispatched when the websocket connection to Discord voice servers is closed. + WebSocketClosedEvent, +} + +/// Server dispatched an event. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(untagged)] +pub enum EventData { + /// Dispatched when a track ends. + TrackEndEvent(TrackEnd), + /// Dispatched when a track throws an exception. + TrackExceptionEvent(TrackException), + /// Dispatched when a track gets stuck while playing. + TrackStuckEvent(TrackStuck), + /// Dispatched when a track starts playing. + TrackStartEvent(TrackStart), + /// Dispatched when the websocket connection to Discord voice servers is closed. + WebSocketClosedEvent(WebSocketClosed), +} + +/// The reason for the track ending. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub enum TrackEndReason { + /// The track was cleaned up. + Cleanup, + /// The track finished playing. + Finished, + /// The track failed to load. + LoadFailed, + /// The track was replaced + Replaced, + /// The track was stopped. + Stopped, +} + +/// A track ended event from lavalink. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct TrackEnd { + /// The reason that the track ended. + pub reason: TrackEndReason, + /// The track that ended playing. + pub track: Track, +} + +/// A track started. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct TrackStart { + /// The track that started playing. + pub track: Track, +} + +/// Dispatched when a track throws an exception. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct TrackException { + /// The occurred exception. + pub exception: Exception, + /// The track that threw the exception. + pub track: Track, +} + +/// Dispatched when a track gets stuck while playing. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct TrackStuck { + /// The threshold in milliseconds that was exceeded. + pub threshold_ms: u64, + /// The track that got stuck. + pub track: Track, +} + +/// The voice websocket connection to Discord has been closed. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct WebSocketClosed { + /// True if Discord closed the connection, false if Lavalink closed it. + pub by_remote: bool, + /// [Discord websocket opcode](https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes) + /// that closed the connection. + pub code: u64, + /// Reason the connection was closed. + pub reason: String, +} diff --git a/twilight-lavalink/src/model/outgoing.rs b/twilight-lavalink/src/model/outgoing.rs new file mode 100644 index 00000000000..86c8687a9ba --- /dev/null +++ b/twilight-lavalink/src/model/outgoing.rs @@ -0,0 +1,453 @@ +//! Events that clients send to Lavalink. +use serde::{Deserialize, Serialize}; +use twilight_model::{ + gateway::payload::incoming::VoiceServerUpdate, + id::{marker::GuildMarker, Id}, +}; + +/// The track on the player. The encoded and identifier are mutually exclusive. +/// We don't support userData field currently. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct UpdatePlayerTrack { + /// The string of the track to play. + #[serde(flatten)] + pub track_string: TrackOption, +} + +/// Used to play a specific track. These are mutually exclusive. +/// When identifier is used, Lavalink will try to resolve the identifier as a +/// single track. An HTTP 400 error is returned when resolving a playlist, +/// search result, or no tracks. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TrackOption { + /// The base64 encoded track to play. null stops the current track. + Encoded(Option), + /// The identifier of the track to play. + Identifier(String), +} + +/// An outgoing event to send to Lavalink. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(untagged)] +pub enum OutgoingEvent { + /// Destroy a player for a guild. + Destroy(Destroy), + /// Equalize a player. + Equalizer(Equalizer), + /// Pause or unpause a player. + Pause(Pause), + /// Play a track. + Play(Play), + /// Seek a player's active track to a new position. + Seek(Seek), + /// Stop a player. + Stop(Stop), + /// A combined voice server and voice state update. + VoiceUpdate(VoiceUpdate), + /// Set the volume of a player. + Volume(Volume), +} + +impl From for OutgoingEvent { + fn from(event: Destroy) -> OutgoingEvent { + Self::Destroy(event) + } +} + +impl From for OutgoingEvent { + fn from(event: Equalizer) -> OutgoingEvent { + Self::Equalizer(event) + } +} + +impl From for OutgoingEvent { + fn from(event: Pause) -> OutgoingEvent { + Self::Pause(event) + } +} + +impl From for OutgoingEvent { + fn from(event: Play) -> OutgoingEvent { + Self::Play(event) + } +} + +impl From for OutgoingEvent { + fn from(event: Seek) -> OutgoingEvent { + Self::Seek(event) + } +} + +impl From for OutgoingEvent { + fn from(event: Stop) -> OutgoingEvent { + Self::Stop(event) + } +} + +impl From for OutgoingEvent { + fn from(event: VoiceUpdate) -> OutgoingEvent { + Self::VoiceUpdate(event) + } +} + +impl From for OutgoingEvent { + fn from(event: Volume) -> OutgoingEvent { + Self::Volume(event) + } +} + +impl OutgoingEvent { + /// ID of the destination guild of this event. + pub const fn guild_id(&self) -> Id { + match self { + Self::VoiceUpdate(voice_update) => voice_update.guild_id, + Self::Play(play) => play.guild_id, + Self::Destroy(destroy) => destroy.guild_id, + Self::Equalizer(equalize) => equalize.guild_id, + Self::Pause(pause) => pause.guild_id, + Self::Seek(seek) => seek.guild_id, + Self::Stop(stop) => stop.guild_id, + Self::Volume(volume) => volume.guild_id, + } + } + + /// Whether this event replaces the currently playing track. + pub(crate) const fn no_replace(&self) -> bool { + match self { + Self::Play(play) => play.no_replace, + Self::Stop(_) => false, + _ => true, + } + } +} + +/// Destroy a player from a node. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Destroy { + /// The guild ID of the player. + pub guild_id: Id, +} + +impl Destroy { + /// Create a new destroy event. + pub const fn new(guild_id: Id) -> Self { + Self { guild_id } + } +} + +impl From> for Destroy { + fn from(guild_id: Id) -> Self { + Self { guild_id } + } +} + +/// Filters to pass to the update player endpoint. Currently only Equalizer is supported. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub enum Filters { + /// Adjusts 15 different bands + Equalizer(Equalizer), +} + +/// Equalize a player. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Equalizer { + /// The bands to use as part of the equalizer. + pub equalizer: Vec, + /// The guild ID of the player. + #[serde(skip_serializing)] + pub guild_id: Id, +} + +impl Equalizer { + /// Create a new equalizer event. + pub fn new(guild_id: Id, bands: Vec) -> Self { + Self::from((guild_id, bands)) + } +} + +impl From<(Id, Vec)> for Equalizer { + fn from((guild_id, bands): (Id, Vec)) -> Self { + Self { + equalizer: bands, + guild_id, + } + } +} + +/// A band of the equalizer event. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct EqualizerBand { + /// The band. + pub band: i64, + /// The gain. + pub gain: f64, +} + +impl EqualizerBand { + /// Create a new equalizer band. + pub fn new(band: i64, gain: f64) -> Self { + Self::from((band, gain)) + } +} + +impl From<(i64, f64)> for EqualizerBand { + fn from((band, gain): (i64, f64)) -> Self { + Self { band, gain } + } +} + +/// Pause or unpause a player. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Pause { + /// The guild ID of the player. + pub guild_id: Id, + /// Whether to pause the player. + /// + /// Set to `true` to pause or `false` to resume. + pub paused: bool, +} + +impl Pause { + /// Create a new pause event. + /// + /// Set to `true` to pause the player or `false` to resume it. + pub fn new(guild_id: Id, pause: bool) -> Self { + Self::from((guild_id, pause)) + } +} + +impl From<(Id, bool)> for Pause { + fn from((guild_id, pause): (Id, bool)) -> Self { + Self { + guild_id, + paused: pause, + } + } +} + +// TODO: Might need to fix this struct to abstract the guild_id to another struct pending on what the server sends back with it included. +/// Play a track, optionally specifying to not skip the current track. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Play { + /// The position in milliseconds to end the track. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option>, + /// The guild ID of the player. + #[serde(skip_serializing)] + pub guild_id: Id, + /// Whether or not to replace the currently playing track with this new + /// track. + /// + /// Set to `true` to keep playing the current playing track, or `false` + /// to replace the current playing track with a new one. + #[serde(skip_serializing)] + pub no_replace: bool, + /// The position in milliseconds to start the track from. + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, + /// Whether the player is paused + #[serde(skip_serializing_if = "Option::is_none")] + pub paused: Option, + /// Information about the track to play. + pub track: UpdatePlayerTrack, + /// The player volume, in percentage, from 0 to 1000 + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, +} + +impl Play { + /// Create a new play event. + pub fn new( + guild_id: Id, + track: impl Into, + start_time: impl Into>, + end_time: impl Into>, + no_replace: bool, + ) -> Self { + Self::from((guild_id, track, start_time, end_time, no_replace)) + } +} + +impl> From<(Id, T)> for Play { + fn from((guild_id, track): (Id, T)) -> Self { + Self::from((guild_id, track, None, None, true)) + } +} + +impl, S: Into>> From<(Id, T, S)> for Play { + fn from((guild_id, track, start_time): (Id, T, S)) -> Self { + Self::from((guild_id, track, start_time, None, true)) + } +} + +impl, S: Into>, E: Into>> From<(Id, T, S, E)> + for Play +{ + fn from((guild_id, track, start_time, end_time): (Id, T, S, E)) -> Self { + Self::from((guild_id, track, start_time, end_time, true)) + } +} + +impl, S: Into>, E: Into>> + From<(Id, T, S, E, bool)> for Play +{ + fn from( + (guild_id, track, start_time, end_time, no_replace): (Id, T, S, E, bool), + ) -> Self { + Self { + guild_id, + no_replace, + position: start_time.into(), + end_time: Some(end_time.into()), + volume: None, + paused: None, + track: UpdatePlayerTrack { + track_string: TrackOption::Encoded(Some(track.into())), + }, + } + } +} + +/// Seek a player's active track to a new position. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Seek { + /// The guild ID of the player. + #[serde(skip_serializing)] + pub guild_id: Id, + /// The position in milliseconds to seek to. + pub position: i64, +} + +impl Seek { + /// Create a new seek event. + pub fn new(guild_id: Id, position: i64) -> Self { + Self::from((guild_id, position)) + } +} + +impl From<(Id, i64)> for Seek { + fn from((guild_id, position): (Id, i64)) -> Self { + Self { guild_id, position } + } +} + +/// Stop a player. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Stop { + /// The guild ID of the player. + #[serde(skip_serializing)] + pub guild_id: Id, + /// The track object to pass set to null + pub track: UpdatePlayerTrack, +} + +impl Stop { + /// Create a new stop event. + pub fn new(guild_id: Id) -> Self { + Self::from(guild_id) + } +} + +impl From> for Stop { + fn from(guild_id: Id) -> Self { + Self { + guild_id, + track: UpdatePlayerTrack { + track_string: TrackOption::Encoded(None), + }, + } + } +} +/// The voice payload for the combined server and state to send to lavalink. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Voice { + /// The Discord voice endpoint to connect to. + pub endpoint: String, + /// The Discord voice session id to authenticate with. This is separate from the session id of lavalink. + pub session_id: String, + /// The Discord voice token to authenticate with. + pub token: String, +} + +/// A combined voice server and voice state update. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct VoiceUpdate { + /// The guild ID of the player. + #[serde(skip_serializing)] + pub guild_id: Id, + /// The voice payload for the combined server and state to send to lavalink. + pub voice: Voice, +} + +impl VoiceUpdate { + /// Create a new voice update event. + pub fn new( + guild_id: Id, + session_id: impl Into, + event: VoiceServerUpdate, + ) -> Self { + Self::from((guild_id, session_id, event)) + } +} + +impl> From<(Id, T, VoiceServerUpdate)> for VoiceUpdate { + fn from((guild_id, session_id, event): (Id, T, VoiceServerUpdate)) -> Self { + Self { + guild_id, + voice: Voice { + token: event.token, + endpoint: event.endpoint.unwrap_or("NO_ENDPOINT_RETURNED".to_string()), + session_id: session_id.into(), + }, + } + } +} + +/// Set the volume of a player. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "camelCase")] +pub struct Volume { + /// The guild ID of the player. + #[serde(skip_serializing)] + pub guild_id: Id, + /// The volume of the player from 0 to 1000. 100 is the default. + pub volume: i64, +} + +impl Volume { + /// Create a new volume event. + pub fn new(guild_id: Id, volume: i64) -> Self { + Self::from((guild_id, volume)) + } +} + +impl From<(Id, i64)> for Volume { + fn from((guild_id, volume): (Id, i64)) -> Self { + Self { guild_id, volume } + } +} diff --git a/twilight-lavalink/src/node.rs b/twilight-lavalink/src/node.rs index 154ed7bb296..6016833e102 100644 --- a/twilight-lavalink/src/node.rs +++ b/twilight-lavalink/src/node.rs @@ -18,7 +18,7 @@ //! [`Lavalink`]: crate::client::Lavalink use crate::{ - model::{IncomingEvent, Opcode, OutgoingEvent, PlayerUpdate, Stats, StatsCpu, StatsMemory}, + model::{incoming, IncomingEvent, OutgoingEvent, PlayerUpdate, Stats, StatsCpu, StatsMemory}, player::PlayerManager, }; use futures_util::{ @@ -26,8 +26,15 @@ use futures_util::{ sink::SinkExt, stream::{Stream, StreamExt}, }; -use http::header::{HeaderName, AUTHORIZATION}; +use http::header::{HeaderName, HeaderValue, AUTHORIZATION}; +use http_body_util::Full; +use hyper::{body::Bytes, header, Method, Request, Uri}; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client as HyperClient}, + rt::TokioExecutor, +}; use std::{ + borrow::Borrow, error::Error, fmt::{Debug, Display, Formatter, Result as FmtResult}, net::SocketAddr, @@ -79,7 +86,13 @@ impl Display for NodeError { NodeErrorType::BuildingConnectionRequest { .. } => { f.write_str("failed to build connection request") } + NodeErrorType::HttpRequestFailed { .. } => { + f.write_str("failed to send http request to lavalink server") + } NodeErrorType::Connecting { .. } => f.write_str("Failed to connect to the node"), + NodeErrorType::OutgoingEventHasNoSession { .. } => { + f.write_str("No session id found for connection to lavalink api.") + } NodeErrorType::SerializingMessage { .. } => { f.write_str("failed to serialize outgoing message as json") } @@ -107,8 +120,14 @@ impl Error for NodeError { pub enum NodeErrorType { /// Building the HTTP request to initialize a connection failed. BuildingConnectionRequest, + /// The request to send to lavalink has failed, + HttpRequestFailed, /// Connecting to the Lavalink server failed after several backoff attempts. Connecting, + /// You can potentially have no valid session before trying to send outgoing + /// events. The session id is obtained in the startup sequence of the node. + /// If you attempt to send events before connecting you will error out. + OutgoingEventHasNoSession, /// Serializing a JSON message to be sent to a Lavalink node failed. SerializingMessage { /// The message that couldn't be serialized. @@ -361,12 +380,13 @@ impl Node { players: PlayerManager, ) -> Result<(Self, IncomingEvents), NodeError> { let (bilock_left, bilock_right) = BiLock::new(Stats { + op: incoming::Opcode::Stats, cpu: StatsCpu { cores: 0, lavalink_load: 0f64, system_load: 0f64, }, - frames: None, + frame_stats: None, memory: StatsMemory { allocated: 0, free: 0, @@ -375,7 +395,6 @@ impl Node { }, players: 0, playing_players: 0, - op: Opcode::Stats, uptime: 0, }); @@ -447,13 +466,13 @@ impl Node { let cpu = 1.05f64.powf(100f64 * stats.cpu.system_load) * 10f64 - 10f64; let (deficit_frame, null_frame) = ( - 1.03f64 - .powf(500f64 * (stats.frames.as_ref().map_or(0, |f| f.deficit) as f64 / 3000f64)) - * 300f64 + 1.03f64.powf( + 500f64 * (stats.frame_stats.as_ref().map_or(0, |f| f.deficit) as f64 / 3000f64), + ) * 300f64 - 300f64, - (1.03f64 - .powf(500f64 * (stats.frames.as_ref().map_or(0, |f| f.nulled) as f64 / 3000f64)) - * 300f64 + (1.03f64.powf( + 500f64 * (stats.frame_stats.as_ref().map_or(0, |f| f.nulled) as f64 / 3000f64), + ) * 300f64 - 300f64) * 2f64, ); @@ -465,10 +484,12 @@ impl Node { struct Connection { config: NodeConfig, stream: WebSocketStream>, + lavalink_http: HyperClient>, node_from: UnboundedReceiver, node_to: UnboundedSender, players: PlayerManager, stats: BiLock, + lavalink_session_id: Option>, } impl Connection { @@ -488,15 +509,18 @@ impl Connection { let (to_node, from_lavalink) = mpsc::unbounded_channel(); let (to_lavalink, from_node) = mpsc::unbounded_channel(); + let lavalink_http = HyperClient::builder(TokioExecutor::new()).build_http(); Ok(( Self { config, stream, + lavalink_http, node_from: from_node, node_to: to_node, players, stats, + lavalink_session_id: None, }, to_lavalink, from_lavalink, @@ -516,20 +540,9 @@ impl Connection { } outgoing = self.node_from.recv() => { if let Some(outgoing) = outgoing { - tracing::debug!( - "forwarding event to {}: {outgoing:?}", - self.config.address, - ); - - let payload = serde_json::to_string(&outgoing).map_err(|source| NodeError { - kind: NodeErrorType::SerializingMessage { message: outgoing }, - source: Some(Box::new(source)), - })?; - let msg = Message::text(payload); - self.stream.send(msg).await.unwrap(); + self.outgoing(outgoing).await?; } else { tracing::debug!("node {} closed, ending connection", self.config.address); - break; } } @@ -539,6 +552,71 @@ impl Connection { Ok(()) } + fn get_outgoing_endpoint_based_on_event( + &mut self, + outgoing: &OutgoingEvent, + ) -> Result<(Method, hyper::Uri), NodeError> { + let address = self.config.address; + tracing::debug!("forwarding event to {address}: {outgoing:?}"); + + let guild_id = outgoing.guild_id(); + let no_replace = outgoing.no_replace(); + + if let Some(session) = &self.lavalink_session_id { + let mut path = format!("/v4/sessions/{session}/players/{guild_id}"); + if !matches!(outgoing, OutgoingEvent::Destroy(_)) { + path.push_str(&format!("?noReplace={no_replace}")); + } + let uri = Uri::builder() + .scheme("http") + .authority(address.to_string()) + .path_and_query(path) + .build() + .expect("uri is valid"); + return if matches!(outgoing, OutgoingEvent::Destroy(_)) { + Ok((Method::DELETE, uri)) + } else { + Ok((Method::PATCH, uri)) + }; + } + + tracing::error!("no session id is found. Session id should have been provided from the websocket connection already."); + + Err(NodeError { + kind: NodeErrorType::OutgoingEventHasNoSession, + source: None, + }) + } + + async fn outgoing(&mut self, outgoing: OutgoingEvent) -> Result<(), NodeError> { + let (method, url) = self.get_outgoing_endpoint_based_on_event(&outgoing)?; + let payload = serde_json::to_string(&outgoing).expect("serialization cannot fail"); + + let authority = url.authority().expect("Authority comes from endpoint"); + + let req = Request::builder() + .uri(url.borrow()) + .method(method) + .header(header::HOST, authority.as_str()) + .header(header::AUTHORIZATION, self.config.authorization.as_str()) + .header(header::CONTENT_TYPE, "application/json") + .body(Full::from(payload)) + .map_err(|source| NodeError { + kind: NodeErrorType::BuildingConnectionRequest, + source: Some(Box::new(source)), + })?; + + self.lavalink_http + .request(req) + .await + .map_err(|source| NodeError { + kind: NodeErrorType::HttpRequestFailed, + source: Some(Box::new(source)), + })?; + + Ok(()) + } + async fn incoming(&mut self, incoming: Message) -> Result { tracing::debug!( "received message from {}: {incoming:?}", @@ -565,8 +643,11 @@ impl Connection { match &event { IncomingEvent::PlayerUpdate(update) => self.player_update(update)?, + IncomingEvent::Ready(ready) => { + self.lavalink_session_id = Some(ready.session_id.clone().into_boxed_str()); + } IncomingEvent::Stats(stats) => self.stats(stats).await?, - _ => {} + &IncomingEvent::Event(_) => {} } // It's fine if the rx end dropped, often users don't need to care about @@ -588,7 +669,7 @@ impl Connection { return Ok(()); }; - player.set_position(update.state.position.unwrap_or(0)); + player.set_position(update.state.position); player.set_time(update.state.time); Ok(()) @@ -610,23 +691,42 @@ impl Drop for Connection { } } +const TWILIGHT_CLIENT_NAME: &str = concat!("twilight-lavalink/", env!("CARGO_PKG_VERSION")); + fn connect_request(state: &NodeConfig) -> Result { let mut builder = ClientBuilder::new() - .uri(&format!("ws://{}", state.address)) + .uri(&format!("ws://{}/v4/websocket", state.address)) .map_err(|source| NodeError { kind: NodeErrorType::BuildingConnectionRequest, source: Some(Box::new(source)), })? - .add_header(AUTHORIZATION, state.authorization.parse().unwrap()) + .add_header( + AUTHORIZATION, + state.authorization.parse().map_err(|source| NodeError { + kind: NodeErrorType::BuildingConnectionRequest, + source: Some(Box::new(source)), + })?, + ) .add_header( HeaderName::from_static("user-id"), state.user_id.get().into(), + ) + .add_header( + HeaderName::from_static("client-name"), + HeaderValue::from_static(TWILIGHT_CLIENT_NAME), ); if state.resume.is_some() { builder = builder.add_header( HeaderName::from_static("resume-key"), - state.address.to_string().parse().unwrap(), + state + .address + .to_string() + .parse() + .map_err(|source| NodeError { + kind: NodeErrorType::BuildingConnectionRequest, + source: Some(Box::new(source)), + })?, ); } @@ -652,9 +752,14 @@ async fn reconnect( "key": config.address, "timeout": resume.timeout, }); - let msg = Message::text(serde_json::to_string(&payload).unwrap()); + let msg = Message::text( + serde_json::to_string(&payload).expect("Serialize can't panic here."), + ); - stream.send(msg).await.unwrap(); + stream.send(msg).await.map_err(|source| NodeError { + kind: NodeErrorType::Connecting, + source: Some(Box::new(source)), + })?; } else { tracing::debug!("session to {} resumed", config.address); } diff --git a/twilight-lavalink/src/player.rs b/twilight-lavalink/src/player.rs index 40e29d50ff2..a67644e3eef 100644 --- a/twilight-lavalink/src/player.rs +++ b/twilight-lavalink/src/player.rs @@ -149,7 +149,7 @@ impl Player { tracing::debug!("sending event on guild player {}: {event:?}", self.guild_id); match &event { - OutgoingEvent::Pause(event) => self.paused.store(event.pause, Ordering::Release), + OutgoingEvent::Pause(event) => self.paused.store(event.paused, Ordering::Release), OutgoingEvent::Volume(event) => { #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] self.volume.store(event.volume, Ordering::Release);