diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8cc5ad8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-gnu] +rustflags = "-C link-args=-lssp" # Does does compile without this line \ No newline at end of file diff --git a/COMPILING.md b/COMPILING.md new file mode 100644 index 0000000..af32217 --- /dev/null +++ b/COMPILING.md @@ -0,0 +1,72 @@ +# Compiling from source +## Initial setup +Spoticord is built using [rust](https://www.rust-lang.org/), so you'll need to install that first. It is cross-platform, so it should work on Windows, Linux and MacOS. You can find more info about how to install rust [here](https://www.rust-lang.org/tools/install). + +### Rust formatter +Spoticord uses [rustfmt](https://github.com/rust-lang/rustfmt) to format the code, and we ask everyone that contributes to Spoticord to use it as well. You can install it by running the following command in your terminal: + +```sh +rustup component add rustfmt +``` + +If you are using VSCode, you can install the [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer) extension, which will automatically format your code when you save it (if you have `format on save` enabled). Although rust-analyzer is recommended anyway, as it provides a lot of useful features. + +## Build dependencies +On Windows you'll need to install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2019) to be able to compile executables in rust (this will also be explained during the rust installation). + +If you are on Linux, you can use your package manager to install the following dependencies: + +```sh +# Debian/Ubuntu +sudo apt install build-essential + +# Arch +sudo pacman -S base-devel + +# Fedora +sudo dnf install gcc +``` + +Additionally, you will need to install CMake and OpenSSL (Linux only). On Windows, you can download CMake [here](https://cmake.org/download/). On Linux, you can use your package manager to install them: + +```sh +# Debian/Ubuntu +sudo apt install cmake libssl-dev + +# Arch +sudo pacman -S cmake openssl + +# Fedora +sudo dnf install cmake openssl-devel +``` + +## Compiling +Now that you have all the dependencies installed, you can compile Spoticord. To do this, you'll first need to clone the repository: + +```sh +git clone https://github.com/SpoticordMusic/spoticord.git +``` + +After cloning the repo run the following command in the root of the repository: + +```sh +cargo build +``` + +Or if you want to build a release version: + +```sh +cargo build --release +``` + +This will compile the bot and place the executable in `target/release`. You can now run the bot by running the following command: + +```sh +./target/release/spoticord +``` + +If you are actively developing Spoticord, you can use the following command to build and run the bot (this is easier than building and running the bot manually): + +```sh +cargo run +``` diff --git a/Cargo.toml b/Cargo.toml index 81067b6..fe86ccb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ lto = true codegen-units = 1 strip = true opt-level = "z" +panic = "abort" [dependencies] chrono = "0.4.22" diff --git a/README.md b/README.md index b6adda8..460471c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ # Spoticord -Spoticord is a Discord music bot that allows you to control your music using the Spotify app. \ No newline at end of file +Spoticord is a Discord music bot that allows you to control your music using the Spotify app. +Spoticord is built on top of [librespot](https://github.com/librespot-org/librespot) (with tiny additional changes), to allow full control using the Spotify client, with [serenity](https://github.com/serenity-rs/serenity) and [songbird](https://github.com/serenity-rs/songbird) for Discord communication. +Being built on top of rust, Spoticord is relatively lightweight and can run on low-spec hardware. + +## How to use +### Official bot +Spoticord is being hosted as an official bot. You can find more info about how to use this bot over at [the Spoticord website](https://spoticord.com/). + +### Environment variables +Spoticord uses environment variables to configure itself. The following variables are required: +- `DISCORD_TOKEN`: The Discord bot token used for authenticating with Discord. +- `DATABASE_URL`: The base URL of the database API used for storing user data. This base URL must point to an instance of [the Spoticord Database API](https://github.com/SpoticordMusic/spoticord-database). +- `SPOTICORD_ACCOUNTS_URL`: The base URL of the accounts frontend used for authenticating with Spotify. This base URL must point to an instance of [the Spoticord Accounts frontend](https://github.com/SpoticordMusic/spoticord-accounts). + +Additionally you can configure the following variables: +- `GUILD_ID`: The ID of the Discord server where this bot will create commands for. This is used during testing to prevent the bot from creating slash commands in other servers, as well as getting the commands quicker. This variable is optional, and if not set, the bot will create commands in all servers it is in (this may take up to 15 minutes). +- `KV_URL`: The connection URL of a redis-server instance used for storing realtime data. While not required, not providing one will cause the bot to spit out quite a bit of errors. You might want to comment out those error lines in `main.rs`. + +#### Providing environment variables +You can provide environment variables in a `.env` file at the root of the working directory of Spoticord. +You can also provide environment variables the normal way, e.g. the command line, using `export` (or `set` for Windows) or using docker. +Environment variables set this way take precedence over those in the `.env` file (if one exists). + +# Compiling +For information about how to compile Spoticord from source, check out [COMPILING.md](COMPILING.md). + +# Contact +![Discord Shield](https://discordapp.com/api/guilds/779292533053456404/widget.png?style=shield) + +If you have any questions, feel free to join the [Spoticord Discord server](https://discord.gg/wRCyhVqBZ5)! + +# License +Spoticord is licensed under the [Apache License 2.0](LICENSE). \ No newline at end of file diff --git a/src/audio/backend.rs b/src/audio/backend.rs index 583edf6..236807d 100644 --- a/src/audio/backend.rs +++ b/src/audio/backend.rs @@ -1,125 +1,30 @@ use librespot::playback::audio_backend::{Sink, SinkAsBytes, SinkResult}; use librespot::playback::convert::Converter; use librespot::playback::decoder::AudioPacket; -use log::{error, trace}; use std::io::Write; -use std::sync::{Arc, Mutex}; -use std::thread::JoinHandle; -use std::time::Duration; use crate::ipc; use crate::ipc::packet::IpcPacket; pub struct StdoutSink { client: ipc::Client, - buffer: Arc>>, - is_stopped: Arc>, - handle: Option>, } -const BUFFER_SIZE: usize = 7680; - impl StdoutSink { - pub fn start_writer(&mut self) { - // With 48khz, 32-bit float, 2 channels, 1 second of audio is 384000 bytes - // 384000 / 50 = 7680 bytes per 20ms - - let buffer = self.buffer.clone(); - let is_stopped = self.is_stopped.clone(); - let client = self.client.clone(); - - let handle = std::thread::spawn(move || { - let mut output = std::io::stdout(); - let mut act_buffer = [0u8; BUFFER_SIZE]; - - // Use closure to make sure lock is released as fast as possible - let is_stopped = || { - let is_stopped = is_stopped.lock().unwrap(); - *is_stopped - }; - - // Start songbird's playback - client.send(IpcPacket::StartPlayback).unwrap(); - - loop { - if is_stopped() { - break; - } - - std::thread::sleep(Duration::from_millis(15)); - - let mut buffer = buffer.lock().unwrap(); - let to_drain: usize; - - if buffer.len() < BUFFER_SIZE { - // Copy the buffer into the action buffer - // Fill remaining length with zeroes - act_buffer[..buffer.len()].copy_from_slice(&buffer[..]); - act_buffer[buffer.len()..].fill(0); - - to_drain = buffer.len(); - } else { - act_buffer.copy_from_slice(&buffer[..BUFFER_SIZE]); - to_drain = BUFFER_SIZE; - } - - output.write_all(&act_buffer).unwrap_or(()); - buffer.drain(..to_drain); - } - }); - - self.handle = Some(handle); - } - - pub fn stop_writer(&mut self) -> std::thread::Result<()> { - // Use closure to avoid deadlocking the mutex - let set_stopped = |value| { - let mut is_stopped = self.is_stopped.lock().unwrap(); - *is_stopped = value; - }; - - // Notify thread to stop - set_stopped(true); - - // Wait for thread to stop - let result = match self.handle.take() { - Some(handle) => handle.join(), - None => Ok(()), - }; - - // Reset stopped value - set_stopped(false); - - result - } - pub fn new(client: ipc::Client) -> Self { - StdoutSink { - client, - is_stopped: Arc::new(Mutex::new(false)), - buffer: Arc::new(Mutex::new(Vec::new())), - handle: None, - } + StdoutSink { client } } } impl Sink for StdoutSink { fn start(&mut self) -> SinkResult<()> { - self.start_writer(); + // TODO: Handle error + self.client.send(IpcPacket::StartPlayback).unwrap(); Ok(()) } fn stop(&mut self) -> SinkResult<()> { - // Stop the writer thread - // This is done before pausing songbird, because else the writer thread - // might hang on writing to stdout - if let Err(why) = self.stop_writer() { - error!("Failed to stop stdout writer: {:?}", why); - } else { - trace!("Stopped stdout writer"); - } - // Stop songbird's playback self.client.send(IpcPacket::StopPlayback).unwrap(); @@ -140,7 +45,11 @@ impl Sink for StdoutSink { &samples_f32, ) .unwrap(); - self.write_bytes(resampled.as_bytes())?; + + let samples_i16 = + &converter.f64_to_s16(&resampled.iter().map(|v| *v as f64).collect::>()); + + self.write_bytes(samples_i16.as_bytes())?; } Ok(()) @@ -149,18 +58,7 @@ impl Sink for StdoutSink { impl SinkAsBytes for StdoutSink { fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { - let get_buffer_len = || { - let buffer = self.buffer.lock().unwrap(); - buffer.len() - }; - - while get_buffer_len() > BUFFER_SIZE * 2 { - std::thread::sleep(Duration::from_millis(15)); - } - - let mut buffer = self.buffer.lock().unwrap(); - - buffer.extend_from_slice(data); + std::io::stdout().write_all(data).unwrap(); Ok(()) } diff --git a/src/bot/commands/music/join.rs b/src/bot/commands/music/join.rs index 105ba99..593e4d5 100644 --- a/src/bot/commands/music/join.rs +++ b/src/bot/commands/music/join.rs @@ -1,3 +1,4 @@ +use log::trace; use serenity::{ builder::CreateApplicationCommand, model::prelude::interaction::application_command::ApplicationCommandInteraction, @@ -45,7 +46,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu let mut session_manager = data.get::().unwrap().clone(); // Check if another session is already active in this server - let session_opt = session_manager.get_session(guild.id).await; + let mut session_opt = session_manager.get_session(guild.id).await; if let Some(session) = &session_opt { if let Some(owner) = session.get_owner().await { @@ -94,6 +95,17 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu defer_message(&ctx, &command, false).await; + if let Some(session) = &session_opt { + trace!("{} != {}", session.get_channel_id(), channel_id); + if session.get_channel_id() != channel_id { + session.disconnect().await; + session_opt = None; + + // Give serenity/songbird some time to register the disconnect + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + if let Some(session) = &session_opt { if let Err(why) = session.update_owner(&ctx, command.user.id).await { // Need to link first @@ -131,7 +143,13 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu } else { // Create the session, and handle potential errors if let Err(why) = session_manager - .create_session(&ctx, guild.id, channel_id, command.user.id) + .create_session( + &ctx, + guild.id, + channel_id, + command.channel_id, + command.user.id, + ) .await { // Need to link first @@ -176,7 +194,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu .icon_url("https://spoticord.com/static/image/speaker.png") .description(format!("Come listen along in <#{}>", channel_id)) .footer("Spotify will automatically start playing on Spoticord") - .status(Status::Success) + .status(Status::Info) .build(), ) .await; diff --git a/src/bot/commands/music/playing.rs b/src/bot/commands/music/playing.rs index 8994a87..68e2d15 100644 --- a/src/bot/commands/music/playing.rs +++ b/src/bot/commands/music/playing.rs @@ -11,7 +11,10 @@ use serenity::{ use crate::{ bot::commands::{respond_message, CommandOutput}, session::manager::SessionManager, - utils::{embed::{EmbedBuilder, Status}, self}, + utils::{ + self, + embed::{EmbedBuilder, Status}, + }, }; pub const NAME: &str = "playing"; @@ -81,7 +84,11 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu }; // Create title - let title = format!("{} - {}", pbi.get_artists().unwrap(), pbi.get_name().unwrap()); + let title = format!( + "{} - {}", + pbi.get_artists().unwrap(), + pbi.get_name().unwrap() + ); // Create description let mut description = String::new(); @@ -100,7 +107,11 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu } description.push_str("\n:alarm_clock: "); - description.push_str(&format!("{} / {}", utils::time_to_str(position / 1000), utils::time_to_str(pbi.duration_ms / 1000))); + description.push_str(&format!( + "{} / {}", + utils::time_to_str(position / 1000), + utils::time_to_str(pbi.duration_ms / 1000) + )); // Get owner of session let owner = match utils::discord::get_user(&ctx, owner).await { @@ -116,7 +127,10 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu &command, EmbedBuilder::new() .title("[INTERNAL ERROR] Cannot get track info") - .description(format!("Could not find user with id {}\nThis is an issue with the bot!", owner)) + .description(format!( + "Could not find user with id {}\nThis is an issue with the bot!", + owner + )) .status(Status::Error) .build(), true, @@ -136,23 +150,20 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu .kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message - .embed(|embed| - embed - .author(|author| - author + .embed(|embed| embed + .author(|author| author .name("Currently Playing") .icon_url("https://www.freepnglogos.com/uploads/spotify-logo-png/file-spotify-logo-png-4.png") ) .title(title) .url(format!("https://open.spotify.com/{}/{}", audio_type, spotify_id.to_base62().unwrap())) .description(description) - .footer(|footer| - footer + .footer(|footer| footer .text(&owner.name) .icon_url(owner.face()) ) .thumbnail(&thumbnail) - .color(Status::Success as u64) + .color(Status::Info as u64) ) }) }) diff --git a/src/consts.rs b/src/consts.rs index 920f012..fe8da6a 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,3 +1,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const MOTD: &str = "OPEN BETA (v2)"; + +/// The time it takes for Spoticord to disconnect when no music is being played +pub const DISCONNECT_TIME: u64 = 5 * 60; + // pub const MOTD: &str = "some good 'ol music"; diff --git a/src/main.rs b/src/main.rs index aabe8a9..08f5b40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,14 +4,16 @@ use dotenv::dotenv; use log::*; use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client}; use songbird::SerenityInit; -use std::{env, process::exit}; -use tokio::signal::unix::SignalKind; +use std::{any::Any, env, process::exit}; use crate::{ bot::commands::CommandManager, database::Database, session::manager::SessionManager, stats::StatsManager, }; +#[cfg(unix)] +use tokio::signal::unix::SignalKind; + mod audio; mod bot; mod consts; @@ -39,14 +41,6 @@ async fn main() { env_logger::init(); - let orig_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - error!("Panic: {}", panic_info); - - orig_hook(panic_info); - std::process::exit(1); - })); - let args: Vec = env::args().collect(); if args.len() > 2 { @@ -74,7 +68,7 @@ async fn main() { warn!("No .env file found, expecting all necessary environment variables"); } - let token = env::var("TOKEN").expect("a token in the environment"); + let token = env::var("DISCORD_TOKEN").expect("a token in the environment"); let db_url = env::var("DATABASE_URL").expect("a database URL in the environment"); let kv_url = env::var("KV_URL").expect("a redis URL in the environment"); @@ -104,7 +98,12 @@ async fn main() { let cache = client.cache_and_http.cache.clone(); #[cfg(unix)] - let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate()).unwrap(); + let mut term: Option> = Some(Box::new( + tokio::signal::unix::signal(SignalKind::terminate()).unwrap(), + )); + + #[cfg(not(unix))] + let term: Option> = None; // Background tasks tokio::spawn(async move { @@ -134,7 +133,18 @@ async fn main() { break; } - _ = sigterm.recv() => { + _ = async { + #[cfg(unix)] + match term { + Some(ref mut term) => { + let term = term.downcast_mut::().unwrap(); + + term.recv().await + } + + _ => None + } + }, if term.is_some() => { info!("Received terminate signal, shutting down..."); shard_manager.lock().await.shutdown_all().await; diff --git a/src/player/mod.rs b/src/player.rs similarity index 98% rename from src/player/mod.rs rename to src/player.rs index 42fff9d..d85100d 100644 --- a/src/player/mod.rs +++ b/src/player.rs @@ -66,7 +66,10 @@ impl SpoticordPlayer { self.session = Some(session.clone()); // Volume mixer - let mixer = (mixer::find(Some("softvol")).unwrap())(MixerConfig::default()); + let mixer = (mixer::find(Some("softvol")).unwrap())(MixerConfig { + volume_ctrl: librespot::playback::config::VolumeCtrl::Linear, + ..MixerConfig::default() + }); let client = self.client.clone(); diff --git a/src/session/manager.rs b/src/session/manager.rs index dd489a1..4f7bac5 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -47,10 +47,12 @@ impl SessionManager { ctx: &Context, guild_id: GuildId, channel_id: ChannelId, + text_channel_id: ChannelId, owner_id: UserId, ) -> Result<(), SessionCreateError> { // Create session first to make sure locks are kept for as little time as possible - let session = SpoticordSession::new(ctx, guild_id, channel_id, owner_id).await?; + let session = + SpoticordSession::new(ctx, guild_id, channel_id, text_channel_id, owner_id).await?; let mut sessions = self.sessions.write().await; let mut owner_map = self.owner_map.write().await; diff --git a/src/session/mod.rs b/src/session/mod.rs index 4a175cd..915c931 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,20 +1,25 @@ -use self::manager::{SessionCreateError, SessionManager}; +use self::{ + manager::{SessionCreateError, SessionManager}, + pbi::PlaybackInfo, +}; use crate::{ + consts::DISCONNECT_TIME, database::{Database, DatabaseError}, ipc::{self, packet::IpcPacket, Client}, - utils::{self, spotify}, + utils::{embed::Status, spotify}, }; use ipc_channel::ipc::{IpcError, TryRecvError}; use librespot::core::spotify_id::{SpotifyAudioType, SpotifyId}; use log::*; use serenity::{ async_trait, + http::Http, model::prelude::{ChannelId, GuildId, UserId}, prelude::{Context, RwLock}, }; use songbird::{ create_player, - input::{children_to_reader, Input}, + input::{children_to_reader, Codec, Container, Input}, tracks::TrackHandle, Call, Event, EventContext, EventHandler, }; @@ -26,114 +31,16 @@ use std::{ use tokio::sync::Mutex; pub mod manager; - -#[derive(Clone)] -pub struct PlaybackInfo { - last_updated: u128, - position_ms: u32, - - pub track: Option, - pub episode: Option, - pub spotify_id: Option, - - pub duration_ms: u32, - pub is_playing: bool, -} - -impl PlaybackInfo { - fn new(duration_ms: u32, position_ms: u32, is_playing: bool) -> Self { - Self { - last_updated: utils::get_time_ms(), - track: None, - episode: None, - spotify_id: None, - duration_ms, - position_ms, - is_playing, - } - } - - // Update position, duration and playback state - async fn update_pos_dur(&mut self, position_ms: u32, duration_ms: u32, is_playing: bool) { - self.position_ms = position_ms; - self.duration_ms = duration_ms; - self.is_playing = is_playing; - - self.last_updated = utils::get_time_ms(); - } - - // Update spotify id, track and episode - fn update_track_episode( - &mut self, - spotify_id: SpotifyId, - track: Option, - episode: Option, - ) { - self.spotify_id = Some(spotify_id); - self.track = track; - self.episode = episode; - } - - pub fn get_position(&self) -> u32 { - if self.is_playing { - let now = utils::get_time_ms(); - let diff = now - self.last_updated; - - self.position_ms + diff as u32 - } else { - self.position_ms - } - } - - pub fn get_name(&self) -> Option { - if let Some(track) = &self.track { - Some(track.name.clone()) - } else if let Some(episode) = &self.episode { - Some(episode.name.clone()) - } else { - None - } - } - - pub fn get_artists(&self) -> Option { - if let Some(track) = &self.track { - Some( - track - .artists - .iter() - .map(|a| a.name.clone()) - .collect::>() - .join(", "), - ) - } else if let Some(episode) = &self.episode { - Some(episode.show.name.clone()) - } else { - None - } - } - - pub fn get_thumbnail_url(&self) -> Option { - if let Some(track) = &self.track { - let mut images = track.album.images.clone(); - images.sort_by(|a, b| b.width.cmp(&a.width)); - - Some(images.get(0).unwrap().url.clone()) - } else if let Some(episode) = &self.episode { - let mut images = episode.show.images.clone(); - images.sort_by(|a, b| b.width.cmp(&a.width)); - - Some(images.get(0).unwrap().url.clone()) - } else { - None - } - } -} +mod pbi; #[derive(Clone)] pub struct SpoticordSession { owner: Arc>>, guild_id: GuildId, channel_id: ChannelId, + text_channel_id: ChannelId, + + http: Arc, session_manager: SessionManager, @@ -142,6 +49,8 @@ pub struct SpoticordSession { playback_info: Arc>>, + disconnect_handle: Arc>>>, + client: Client, } @@ -150,6 +59,7 @@ impl SpoticordSession { ctx: &Context, guild_id: GuildId, channel_id: ChannelId, + text_channel_id: ChannelId, owner_id: UserId, ) -> Result { // Get the Spotify token of the owner @@ -227,7 +137,8 @@ impl SpoticordSession { let reader = children_to_reader::(vec![child]); // Create track (paused, fixes audio glitches) - let (mut track, track_handle) = create_player(Input::float_pcm(true, reader)); + let (mut track, track_handle) = + create_player(Input::new(true, reader, Codec::Pcm, Container::Raw, None)); track.pause(); // Set call audio to track @@ -237,10 +148,13 @@ impl SpoticordSession { owner: Arc::new(RwLock::new(Some(owner_id.clone()))), guild_id, channel_id, + text_channel_id, + http: ctx.http.clone(), session_manager: session_manager.clone(), call: call.clone(), track: track_handle.clone(), playback_info: Arc::new(RwLock::new(None)), + disconnect_handle: Arc::new(Mutex::new(None)), client: client.clone(), }; @@ -336,6 +250,8 @@ impl SpoticordSession { } IpcPacket::Paused(track, position_ms, duration_ms) => { + ipc_instance.start_disconnect_timer().await; + // Convert to SpotifyId let track_id = SpotifyId::from_uri(&track).unwrap(); @@ -527,10 +443,8 @@ impl SpoticordSession { *playback_info = None; } - // Disconnect from voice channel and remove session from manager - pub async fn disconnect(&self) { - info!("Disconnecting from voice channel {}", self.channel_id); - + /// Internal version of disconnect, which does not abort the disconnect timer + async fn disconnect_no_abort(&self) { self .session_manager .clone() @@ -547,6 +461,48 @@ impl SpoticordSession { } } + // Disconnect from voice channel and remove session from manager + pub async fn disconnect(&self) { + info!("Disconnecting from voice channel {}", self.channel_id); + + self.disconnect_no_abort().await; + + // Stop the disconnect timer, if one is running + let mut dc_handle = self.disconnect_handle.lock().await; + + if let Some(handle) = dc_handle.take() { + handle.abort(); + } + } + + /// Disconnect from voice channel with a message + pub async fn disconnect_with_message(&self, content: &str) { + self.disconnect_no_abort().await; + + if let Err(why) = self + .text_channel_id + .send_message(&self.http, |message| { + message.embed(|embed| { + embed.title("Disconnected from voice channel"); + embed.description(content); + embed.color(Status::Warning as u64); + + embed + }) + }) + .await + { + error!("Failed to send disconnect message: {:?}", why); + } + + // Stop the disconnect timer, if one is running + let mut dc_handle = self.disconnect_handle.lock().await; + + if let Some(handle) = dc_handle.take() { + handle.abort(); + } + } + // Update playback info (duration, position, playing state) async fn update_playback(&self, duration_ms: u32, position_ms: u32, playing: bool) -> bool { let is_none = { @@ -572,6 +528,54 @@ impl SpoticordSession { is_none } + /// Start the disconnect timer, which will disconnect the bot from the voice channel after a + /// certain amount of time + async fn start_disconnect_timer(&self) { + let pbi = self.playback_info.clone(); + let instance = self.clone(); + + let mut handle = self.disconnect_handle.lock().await; + + // Abort the previous timer, if one is running + if let Some(handle) = handle.take() { + handle.abort(); + } + + *handle = Some(tokio::spawn(async move { + let mut timer = tokio::time::interval(Duration::from_secs(DISCONNECT_TIME)); + + // Ignore first (immediate) tick + timer.tick().await; + + loop { + timer.tick().await; + + // Make sure this task has not been aborted, if it has this will automatically stop execution. + tokio::task::yield_now().await; + + let is_playing = { + let pbi = pbi.read().await; + + if let Some(pbi) = &*pbi { + pbi.is_playing + } else { + false + } + }; + + if !is_playing { + info!("Player is not playing, disconnecting"); + instance + .disconnect_with_message( + "The player has been inactive for too long, and has been disconnected.", + ) + .await; + break; + } + } + })); + } + // Get the playback info for the current track pub async fn get_playback_info(&self) -> Option { self.playback_info.read().await.clone() diff --git a/src/session/pbi.rs b/src/session/pbi.rs new file mode 100644 index 0000000..fea90d6 --- /dev/null +++ b/src/session/pbi.rs @@ -0,0 +1,110 @@ +use librespot::core::spotify_id::SpotifyId; + +use crate::utils::{self, spotify}; + +#[derive(Clone)] +pub struct PlaybackInfo { + last_updated: u128, + position_ms: u32, + + pub track: Option, + pub episode: Option, + pub spotify_id: Option, + + pub duration_ms: u32, + pub is_playing: bool, +} + +impl PlaybackInfo { + /// Create a new instance of PlaybackInfo + pub fn new(duration_ms: u32, position_ms: u32, is_playing: bool) -> Self { + Self { + last_updated: utils::get_time_ms(), + track: None, + episode: None, + spotify_id: None, + duration_ms, + position_ms, + is_playing, + } + } + + /// Update position, duration and playback state + pub async fn update_pos_dur(&mut self, position_ms: u32, duration_ms: u32, is_playing: bool) { + self.position_ms = position_ms; + self.duration_ms = duration_ms; + self.is_playing = is_playing; + + self.last_updated = utils::get_time_ms(); + } + + /// Update spotify id, track and episode + pub fn update_track_episode( + &mut self, + spotify_id: SpotifyId, + track: Option, + episode: Option, + ) { + self.spotify_id = Some(spotify_id); + self.track = track; + self.episode = episode; + } + + /// Get the current playback position + pub fn get_position(&self) -> u32 { + if self.is_playing { + let now = utils::get_time_ms(); + let diff = now - self.last_updated; + + self.position_ms + diff as u32 + } else { + self.position_ms + } + } + + /// Get the name of the track or episode + pub fn get_name(&self) -> Option { + if let Some(track) = &self.track { + Some(track.name.clone()) + } else if let Some(episode) = &self.episode { + Some(episode.name.clone()) + } else { + None + } + } + + /// Get the artist(s) or show name of the current track + pub fn get_artists(&self) -> Option { + if let Some(track) = &self.track { + Some( + track + .artists + .iter() + .map(|a| a.name.clone()) + .collect::>() + .join(", "), + ) + } else if let Some(episode) = &self.episode { + Some(episode.show.name.clone()) + } else { + None + } + } + + /// Get the album art url + pub fn get_thumbnail_url(&self) -> Option { + if let Some(track) = &self.track { + let mut images = track.album.images.clone(); + images.sort_by(|a, b| b.width.cmp(&a.width)); + + Some(images.get(0).unwrap().url.clone()) + } else if let Some(episode) = &self.episode { + let mut images = episode.show.images.clone(); + images.sort_by(|a, b| b.width.cmp(&a.width)); + + Some(images.get(0).unwrap().url.clone()) + } else { + None + } + } +} diff --git a/src/stats/mod.rs b/src/stats.rs similarity index 100% rename from src/stats/mod.rs rename to src/stats.rs diff --git a/src/utils/embed.rs b/src/utils/embed.rs index 83f8351..075baaa 100644 --- a/src/utils/embed.rs +++ b/src/utils/embed.rs @@ -1,6 +1,5 @@ use serenity::builder::CreateEmbed; -#[allow(dead_code)] pub enum Status { Info = 0x0773D6, Success = 0x3BD65D,