├── src ├── models │ ├── mod.rs │ └── pagination.rs ├── persistence │ ├── entities │ │ ├── mod.rs │ │ └── user.rs │ └── mod.rs ├── events │ ├── mod.rs │ ├── track_error_notifier.rs │ └── track_queue_event.rs ├── commands │ ├── mod.rs │ ├── ping.rs │ ├── fmlogin.rs │ ├── clear.rs │ ├── seek.rs │ ├── pause.rs │ ├── now.rs │ ├── repeat.rs │ ├── skip.rs │ ├── remove.rs │ ├── yt.rs │ ├── utils.rs │ ├── play.rs │ └── music.rs ├── state │ └── mod.rs ├── spotify │ └── mod.rs ├── scrobbler │ └── mod.rs ├── queue │ └── mod.rs └── main.rs ├── .gitignore ├── .env.example ├── migrations └── 20241224102046_user.sql ├── .envrc ├── .sqlx ├── query-0a34d97893d306f87cd2bb00fc865cef00c13270c488699e45e0940b94333d71.json └── query-ab421b14ff3820296208e04fb2e1de127cb7f58a065e8ce63bf5a4061b77a947.json ├── Cargo.toml ├── LICENSE ├── flake.nix ├── flake.lock ├── README.md └── Cargo.lock /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod pagination; 2 | -------------------------------------------------------------------------------- /src/persistence/entities/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod user; 2 | -------------------------------------------------------------------------------- /src/events/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod track_error_notifier; 2 | pub mod track_queue_event; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.sqlite* 3 | .env 4 | # Nix & direnv 5 | .direnv 6 | result 7 | result-* -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=sqlite:db.sqlite 2 | LASTFM_API_SECRET= 3 | LASTFM_API_KEY= 4 | DISCORD_TOKEN= 5 | SPOTIFY_CLIENT_ID= 6 | SPOTIFY_CLIENT_SECRET= -------------------------------------------------------------------------------- /migrations/20241224102046_user.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | CREATE TABLE user ( 3 | id INTEGER NOT NULL, 4 | token TEXT NOT NULL 5 | ) -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | 3 | # Load .env file if it exists 4 | if [ -e .env ]; then 5 | dotenv .env 6 | fi 7 | 8 | # Automatically rebuild the project when Cargo.toml changes 9 | watch_file Cargo.toml -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clear; 2 | pub mod fmlogin; 3 | pub mod music; 4 | pub mod now; 5 | pub mod pause; 6 | pub mod ping; 7 | pub mod play; 8 | pub mod remove; 9 | pub mod repeat; 10 | pub mod seek; 11 | pub mod skip; 12 | pub mod utils; 13 | pub mod yt; 14 | -------------------------------------------------------------------------------- /.sqlx/query-0a34d97893d306f87cd2bb00fc865cef00c13270c488699e45e0940b94333d71.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "INSERT INTO user (id, token) VALUES (?, ?)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "0a34d97893d306f87cd2bb00fc865cef00c13270c488699e45e0940b94333d71" 12 | } 13 | -------------------------------------------------------------------------------- /src/commands/ping.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | 4 | use crate::commands::utils::Error; 5 | 6 | use super::utils::Context; 7 | 8 | #[poise::command(prefix_command)] 9 | pub async fn ping(ctx: Context<'_>) -> Result<(), Error> { 10 | let embed = CreateEmbed::new() 11 | .title("Pong!") 12 | .color(Colour::from_rgb(0, 255, 0)); 13 | ctx.send(CreateReply { 14 | embeds: vec![embed], 15 | ..Default::default() 16 | }) 17 | .await?; 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /src/persistence/entities/user.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Error, Ok}; 2 | use sqlx::SqlitePool; 3 | 4 | #[derive(sqlx::FromRow)] 5 | pub struct User { 6 | pub id: i64, 7 | pub token: String, 8 | } 9 | 10 | impl User { 11 | pub fn new(id: i64, token: String) -> Self { 12 | Self { id, token } 13 | } 14 | 15 | pub async fn save(&self, sql_conn: &SqlitePool) -> Result<(), Error> { 16 | sqlx::query!( 17 | "INSERT INTO user (id, token) VALUES (?, ?)", 18 | self.id, 19 | self.token 20 | ) 21 | .execute(sql_conn) 22 | .await?; 23 | Ok(()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.sqlx/query-ab421b14ff3820296208e04fb2e1de127cb7f58a065e8ce63bf5a4061b77a947.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "SELECT * FROM user where id=(?) LIMIT 1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "token", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 1 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "ab421b14ff3820296208e04fb2e1de127cb7f58a065e8ce63bf5a4061b77a947" 26 | } 27 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mb" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | poise = "0.6.1" 8 | reqwest = { version = "0.11", features = ["json", "stream"] } 9 | serenity = "0.12.2" 10 | songbird = { version = "0.4.3", features = ["builtin-queue"] } 11 | tokio = { version = "1.41.1", features = ["full"] } 12 | symphonia = { version = "0.5.2", features = [ 13 | 'pcm', 14 | 'mp3', 15 | 'wav', 16 | 'isomp4', 17 | 'aac', 18 | 'alac', 19 | ] } 20 | futures = "0.3.31" 21 | tracing = "0.1.41" 22 | tracing-subscriber = "0.3.19" 23 | async-trait = "0.1.83" 24 | dotenv = "0.15.0" 25 | anyhow = "1.0.95" 26 | sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio"] } 27 | rustfm-scrobble = "1.1.1" 28 | spotify-rs = "0.3.14" 29 | regex = "1.11.1" 30 | -------------------------------------------------------------------------------- /src/persistence/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | 3 | use entities::user::User; 4 | use sqlx::SqlitePool; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct SqlConn { 8 | pub sql_conn: SqlitePool, 9 | } 10 | 11 | impl SqlConn { 12 | pub async fn new(sql_url: String) -> Self { 13 | Self { 14 | sql_conn: SqlitePool::connect(&sql_url) 15 | .await 16 | .expect("Failed to connect to db"), 17 | } 18 | } 19 | 20 | pub async fn get_user(&self, id: i64) -> Option { 21 | Some( 22 | sqlx::query_as!(User, "SELECT * FROM user where id=(?) LIMIT 1", id) 23 | .fetch_optional(&self.sql_conn) 24 | .await 25 | .expect("User not found")?, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/fmlogin.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::utils::Error; 2 | use crate::persistence::entities::user::User; 3 | use crate::scrobbler::Scrobbler; 4 | 5 | use super::utils::Context; 6 | 7 | #[poise::command(prefix_command, aliases("login"), dm_only)] 8 | pub async fn fmlogin(ctx: Context<'_>, username: String, password: String) -> Result<(), Error> { 9 | let api_key = std::env::var("LASTFM_API_KEY").expect("missing LASTFM_API_KEY"); 10 | let api_secret = std::env::var("LASTFM_API_SECRET").expect("missing LASTFM_API_SECRET"); 11 | let token = Scrobbler::new(api_key, api_secret) 12 | .get_user_token(&username, &password) 13 | .await?; 14 | let user = User::new(ctx.author().id.get() as i64, token); 15 | user.save(&ctx.data().sql_conn.sql_conn).await?; 16 | 17 | ctx.reply("Saved LastFM Token").await?; 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /src/state/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use reqwest::Client as HttpClient; 4 | use tokio::sync::RwLock; 5 | 6 | use crate::{persistence::SqlConn, queue::EventfulQueue}; 7 | 8 | #[derive(Debug, Clone, Default)] 9 | pub struct Track { 10 | pub name: String, 11 | pub handle_uuid: String, 12 | pub artist: String, 13 | pub duration: String, 14 | pub thumbnail: String, 15 | pub album: String, 16 | pub can_scrobble: bool, 17 | pub from_playlist: bool, 18 | } 19 | 20 | #[derive(Debug)] 21 | pub struct Data { 22 | pub hc: HttpClient, 23 | pub queue: Arc>>, 24 | pub sql_conn: SqlConn, 25 | } 26 | 27 | // pub struct Track { 28 | // pub name: String, 29 | // pub is_playing: bool 30 | // } 31 | // impl Track { 32 | // pub fn new_from_name(name: impl Into) -> Self { 33 | // Self { name: name.into(), is_playing: false } 34 | // } 35 | // pub fn new(name: impl Into, is_playing: bool) -> Self { 36 | // Self { name: name.into(), is_playing } 37 | // } 38 | // } 39 | -------------------------------------------------------------------------------- /src/events/track_error_notifier.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use ::serenity::async_trait; 4 | use serenity::all::{ChannelId, GuildId}; 5 | use songbird::{ 6 | events::{Event, EventContext, EventHandler as VoiceEventHandler}, 7 | tracks::PlayMode, 8 | }; 9 | use tokio::sync::RwLock; 10 | 11 | use crate::{ 12 | queue::{EventfulQueue, EventfulQueueKey}, 13 | state::Track, 14 | }; 15 | pub struct TrackErrorNotifier { 16 | pub queues: Arc>>, 17 | pub channel_id: ChannelId, 18 | pub guild_id: GuildId, 19 | } 20 | 21 | #[async_trait] 22 | impl VoiceEventHandler for TrackErrorNotifier { 23 | async fn act(&self, ctx: &EventContext<'_>) -> Option { 24 | let k = EventfulQueueKey { 25 | guild_id: self.guild_id, 26 | channel_id: self.channel_id, 27 | }; 28 | if let EventContext::Track(track_list) = ctx { 29 | let state = track_list.first(); 30 | if let None = state { 31 | return None; 32 | } 33 | let (state, handle) = state.unwrap(); 34 | 35 | let track = { self.queues.read().await.front(&k).await.cloned() }; 36 | if let Some(track) = track { 37 | if track.handle_uuid == handle.uuid().to_string() { 38 | if state.playing == PlayMode::End || state.playing == PlayMode::Stop { 39 | self.queues.write().await.pop(&k).await; 40 | } 41 | } 42 | } 43 | } 44 | None 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, GamyingOnline 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/spotify/mod.rs: -------------------------------------------------------------------------------- 1 | use spotify_rs::{ 2 | model::{ 3 | album::Album, 4 | playlist::Playlist, 5 | search::{Item, SearchResults}, 6 | }, 7 | ClientCredsClient, ClientCredsFlow, Error, 8 | }; 9 | 10 | #[derive(Debug)] 11 | pub struct SpotifyClient { 12 | client_id: String, 13 | client_secret: String, 14 | } 15 | 16 | impl SpotifyClient { 17 | pub fn new(client_id: String, client_secret: String) -> Self { 18 | Self { 19 | client_id, 20 | client_secret, 21 | } 22 | } 23 | 24 | pub async fn get_track(&mut self, query: String) -> Result { 25 | let mut client = ClientCredsClient::authenticate(ClientCredsFlow::new( 26 | self.client_id.clone(), 27 | self.client_secret.clone(), 28 | )) 29 | .await?; 30 | client.search(query, &[Item::Track]).limit(10).get().await 31 | } 32 | 33 | pub async fn get_playlist(&mut self, id: String) -> Result { 34 | let mut client = ClientCredsClient::authenticate(ClientCredsFlow::new( 35 | self.client_id.clone(), 36 | self.client_secret.clone(), 37 | )) 38 | .await?; 39 | client.playlist(id).get().await 40 | } 41 | 42 | pub async fn get_album(&mut self, id: String) -> Result { 43 | let mut client = ClientCredsClient::authenticate(ClientCredsFlow::new( 44 | self.client_id.clone(), 45 | self.client_secret.clone(), 46 | )) 47 | .await?; 48 | client.album(id).get().await 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/clear.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | 4 | use crate::{commands::utils::Error, queue::EventfulQueueKey}; 5 | 6 | use super::utils::Context; 7 | 8 | /// Clears the whole queue 9 | #[poise::command(prefix_command)] 10 | pub async fn clear(ctx: Context<'_>) -> Result<(), Error> { 11 | let (guild_id, channel_id) = { 12 | let guild = ctx.guild().expect("Guild only message"); 13 | let channel_id = guild 14 | .voice_states 15 | .get(&ctx.author().id) 16 | .and_then(|voice_state| voice_state.channel_id); 17 | 18 | (guild.id, channel_id) 19 | }; 20 | 21 | if let None = channel_id { 22 | let embed = CreateEmbed::new() 23 | .title("❌ Not in a voice chat.") 24 | .color(Colour::from_rgb(255, 0, 0)); 25 | ctx.send(CreateReply { 26 | embeds: vec![embed], 27 | ..Default::default() 28 | }) 29 | .await?; 30 | return Ok(()); 31 | } 32 | 33 | let channel_id = channel_id.unwrap(); 34 | let manager = songbird::get(&ctx.serenity_context()) 35 | .await 36 | .expect("Songbird Voice client placed in at initialisation.") 37 | .clone(); 38 | 39 | if let Ok(handler_lock) = manager.join(guild_id, channel_id).await { 40 | let handler = handler_lock.lock().await; 41 | let queue = handler.queue(); 42 | ctx.data() 43 | .queue 44 | .write() 45 | .await 46 | .clear(EventfulQueueKey { 47 | guild_id, 48 | channel_id, 49 | }) 50 | .await; 51 | queue.stop(); 52 | } 53 | Ok(()) 54 | } 55 | -------------------------------------------------------------------------------- /src/scrobbler/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use rustfm_scrobble::{Scrobble, Scrobbler as RustFmScrobbler}; 3 | 4 | use crate::persistence::entities::user::User; 5 | use std::fmt::Debug; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct Scrobbler { 9 | api_key: String, 10 | api_secret: String, 11 | } 12 | 13 | impl Scrobbler { 14 | pub fn new(api_key: String, api_secret: String) -> Self { 15 | Scrobbler { 16 | api_key, 17 | api_secret, 18 | } 19 | } 20 | 21 | pub async fn track_to_scrobble( 22 | &self, 23 | artist: &String, 24 | track: &String, 25 | album: &String, 26 | ) -> Scrobble { 27 | Scrobble::new(artist, track, album) 28 | } 29 | 30 | pub async fn scrobble(&mut self, song: &Scrobble, user: User) { 31 | let mut scrobbler = RustFmScrobbler::new(&self.api_key, &self.api_secret); 32 | scrobbler.authenticate_with_session_key(&user.token); 33 | scrobbler.scrobble(song).expect("Scrobble failed"); 34 | } 35 | 36 | pub async fn now_playing(&mut self, song: &Scrobble, user: User) { 37 | let mut scrobbler = RustFmScrobbler::new(&self.api_key, &self.api_secret); 38 | scrobbler.authenticate_with_session_key(&user.token); 39 | scrobbler.now_playing(song).expect("Now Playing failed"); 40 | } 41 | 42 | pub async fn get_user_token( 43 | &mut self, 44 | username: &String, 45 | password: &String, 46 | ) -> Result { 47 | let mut scrobbler = RustFmScrobbler::new(&self.api_key, &self.api_secret); 48 | let res = scrobbler 49 | .authenticate_with_password(username, password) 50 | .expect("Invalid creds"); 51 | Ok(res.key) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/models/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use crate::state::Track; 4 | 5 | pub struct PaginatedQueue<'a> { 6 | queue: &'a VecDeque, 7 | page: usize, 8 | limit: usize, 9 | total: usize, 10 | } 11 | 12 | impl<'a> PaginatedQueue<'a> { 13 | pub fn new(queue: &'a VecDeque, total: usize, page: usize) -> Self { 14 | Self { 15 | queue, 16 | page, 17 | limit: 10, 18 | total, 19 | } 20 | } 21 | 22 | fn start_idx(&self) -> usize { 23 | (self.page - 1) * self.limit 24 | } 25 | 26 | fn end_idx(&self) -> usize { 27 | (self.start_idx() + self.limit).min(self.total) 28 | } 29 | 30 | pub fn total_pages(&self) -> usize { 31 | (self.total + self.limit - 1) / self.limit 32 | } 33 | 34 | pub fn get_fields(&self) -> impl IntoIterator + '_ { 35 | let start = self.start_idx(); 36 | let end = self.end_idx(); 37 | self.queue 38 | .iter() 39 | .skip(start) 40 | .take(end - start) 41 | .enumerate() 42 | .map(move |(index, song)| { 43 | if index == 0 { 44 | ( 45 | format!( 46 | "{}. {} - {}[{}] ⬅️", 47 | index + 1 + start, 48 | song.artist, 49 | song.name, 50 | song.duration 51 | ), 52 | "".to_string(), 53 | false, 54 | ) 55 | } else { 56 | ( 57 | format!( 58 | "{}. {} - {}[{}]", 59 | index + 1 + start, 60 | song.artist, 61 | song.name, 62 | song.duration 63 | ), 64 | "".to_string(), 65 | false, 66 | ) 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "yt-yapper: A Rust-based Discord bot"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: 11 | flake-utils.lib.eachDefaultSystem (system: 12 | let 13 | overlays = [ (import rust-overlay) ]; 14 | pkgs = import nixpkgs { 15 | inherit system overlays; 16 | }; 17 | 18 | rustToolchain = pkgs.rust-bin.stable.latest.default.override { 19 | extensions = [ "rust-src" "rust-analyzer" ]; 20 | targets = [ ]; 21 | }; 22 | 23 | nativeBuildInputs = with pkgs; [ 24 | rustToolchain 25 | pkg-config 26 | cmake 27 | ]; 28 | 29 | # Common dependencies for all platforms 30 | commonBuildInputs = with pkgs; [ 31 | openssl 32 | sqlite 33 | ffmpeg 34 | yt-dlp 35 | 36 | # Development tools 37 | sqlx-cli 38 | ]; 39 | 40 | # Platform-specific dependencies 41 | platformBuildInputs = if pkgs.stdenv.isDarwin then 42 | # macOS dependencies 43 | with pkgs; [ 44 | darwin.apple_sdk.frameworks.AudioToolbox 45 | darwin.apple_sdk.frameworks.CoreAudio 46 | darwin.apple_sdk.frameworks.CoreServices 47 | ] 48 | else 49 | # Linux dependencies 50 | with pkgs; [ 51 | alsa-lib 52 | ]; 53 | 54 | # Combine common and platform-specific dependencies 55 | buildInputs = commonBuildInputs ++ platformBuildInputs; 56 | 57 | in 58 | { 59 | devShells.default = pkgs.mkShell { 60 | inherit nativeBuildInputs buildInputs; 61 | 62 | RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; 63 | SQLX_OFFLINE = "true"; 64 | 65 | shellHook = '' 66 | echo "yt-yapper dev environment" 67 | ''; 68 | }; 69 | } 70 | ); 71 | } -------------------------------------------------------------------------------- /src/commands/seek.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | 4 | use crate::{commands::utils::time_to_duration, queue::EventfulQueueKey}; 5 | 6 | use super::utils::{Context, Error}; 7 | 8 | #[poise::command(prefix_command)] 9 | pub async fn seek(ctx: Context<'_>, time: String) -> Result<(), Error> { 10 | let duration = time_to_duration(&time); 11 | 12 | let (guild_id, channel_id) = { 13 | let guild = ctx.guild().expect("Guild only message"); 14 | let channel_id = guild 15 | .voice_states 16 | .get(&ctx.author().id) 17 | .and_then(|voice_state| voice_state.channel_id); 18 | 19 | (guild.id, channel_id) 20 | }; 21 | if let None = channel_id { 22 | let embed = CreateEmbed::new() 23 | .title("❌ Not in a voice chat.") 24 | .color(Colour::from_rgb(255, 0, 0)); 25 | ctx.send(CreateReply { 26 | embeds: vec![embed], 27 | ..Default::default() 28 | }) 29 | .await?; 30 | return Ok(()); 31 | } 32 | 33 | let channel_id = channel_id.unwrap(); 34 | let manager = songbird::get(&ctx.serenity_context()) 35 | .await 36 | .expect("Songbird Voice client placed in at initialisation.") 37 | .clone(); 38 | let handler = manager.get(guild_id).unwrap(); 39 | let handler_lock = handler.lock().await; 40 | if let None = handler_lock.queue().current() { 41 | let embed = CreateEmbed::new() 42 | .title("❌ Nothing is playing.") 43 | .color(Colour::from_rgb(255, 0, 0)); 44 | ctx.send(CreateReply { 45 | embeds: vec![embed], 46 | ..Default::default() 47 | }) 48 | .await?; 49 | return Ok(()); 50 | } 51 | let k = EventfulQueueKey { 52 | guild_id, 53 | channel_id, 54 | }; 55 | let track = { ctx.data().queue.read().await.front(&k).await.cloned() }; 56 | let track_duration = time_to_duration(&track.unwrap().duration); 57 | 58 | if track_duration < duration { 59 | let embed = CreateEmbed::new() 60 | .title("❌ Seek value cannot be greater than duration.") 61 | .color(Colour::from_rgb(255, 0, 0)); 62 | ctx.send(CreateReply { 63 | embeds: vec![embed], 64 | ..Default::default() 65 | }) 66 | .await?; 67 | return Ok(()); 68 | } 69 | 70 | let _ = { handler_lock.queue().current().unwrap().seek(duration) }; 71 | 72 | Ok(()) 73 | } 74 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1748370509, 24 | "narHash": "sha256-QlL8slIgc16W5UaI3w7xHQEP+Qmv/6vSNTpoZrrSlbk=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "4faa5f5321320e49a78ae7848582f684d64783e9", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1744536153, 40 | "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "NixOS", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": "nixpkgs_2" 63 | }, 64 | "locked": { 65 | "lastModified": 1748486227, 66 | "narHash": "sha256-veMuFa9cq/XgUXp1S57oC8K0TIw3XyZWL2jIyGWlW0c=", 67 | "owner": "oxalica", 68 | "repo": "rust-overlay", 69 | "rev": "4bf1892eb81113e868efe67982b64f1da15c8c5a", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "oxalica", 74 | "repo": "rust-overlay", 75 | "type": "github" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /src/commands/pause.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | use songbird::tracks::PlayMode; 4 | 5 | use crate::queue::EventfulQueueKey; 6 | 7 | use super::utils::{Context, Error}; 8 | 9 | #[poise::command(prefix_command, aliases("resume"))] 10 | pub async fn pause(ctx: Context<'_>) -> Result<(), Error> { 11 | let (guild_id, channel_id) = { 12 | let guild = ctx.guild().expect("Guild only message"); 13 | let channel_id = guild 14 | .voice_states 15 | .get(&ctx.author().id) 16 | .and_then(|voice_state| voice_state.channel_id); 17 | 18 | (guild.id, channel_id) 19 | }; 20 | if let None = channel_id { 21 | let embed = CreateEmbed::new() 22 | .title("❌ Not in a voice chat.") 23 | .color(Colour::from_rgb(255, 0, 0)); 24 | ctx.send(CreateReply { 25 | embeds: vec![embed], 26 | ..Default::default() 27 | }) 28 | .await?; 29 | return Ok(()); 30 | } 31 | 32 | let channel_id = channel_id.unwrap(); 33 | 34 | let manager = songbird::get(&ctx.serenity_context()) 35 | .await 36 | .expect("Songbird Voice client placed in at initialisation.") 37 | .clone(); 38 | let handler = manager.get(guild_id).unwrap(); 39 | let handler_lock = handler.lock().await; 40 | if let None = handler_lock.queue().current() { 41 | let embed = CreateEmbed::new() 42 | .title("❌ Nothing is playing.") 43 | .color(Colour::from_rgb(255, 0, 0)); 44 | ctx.send(CreateReply { 45 | embeds: vec![embed], 46 | ..Default::default() 47 | }) 48 | .await?; 49 | return Ok(()); 50 | } 51 | let k = EventfulQueueKey { 52 | guild_id, 53 | channel_id, 54 | }; 55 | let track = { ctx.data().queue.read().await.front(&k).await.cloned() }; 56 | if handler_lock 57 | .queue() 58 | .current() 59 | .unwrap() 60 | .get_info() 61 | .await 62 | .unwrap() 63 | .playing 64 | == PlayMode::Play 65 | { 66 | handler_lock.queue().current().unwrap().pause().unwrap(); 67 | let embed = CreateEmbed::new() 68 | .title("⏸️ Paused.") 69 | .title(track.unwrap().name) 70 | .color(Colour::from_rgb(255, 0, 0)); 71 | ctx.send(CreateReply { 72 | embeds: vec![embed], 73 | ..Default::default() 74 | }) 75 | .await?; 76 | return Ok(()); 77 | } else { 78 | handler_lock.queue().current().unwrap().play().unwrap(); 79 | let embed = CreateEmbed::new() 80 | .title("▶️ Resumed.") 81 | .title(track.unwrap().name) 82 | .color(Colour::from_rgb(0, 255, 0)); 83 | ctx.send(CreateReply { 84 | embeds: vec![embed], 85 | ..Default::default() 86 | }) 87 | .await?; 88 | Ok(()) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/commands/now.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed, CreateEmbedFooter}; 3 | 4 | use crate::{commands::utils::Error, models::pagination::PaginatedQueue, queue::EventfulQueueKey}; 5 | 6 | use super::utils::Context; 7 | 8 | // TODO: make a button to change pages instead of entering page number 9 | #[poise::command(prefix_command, aliases("queue"))] 10 | pub async fn now(ctx: Context<'_>, n: Option) -> Result<(), Error> { 11 | let n = n.unwrap_or(1); 12 | let (guild_id, channel_id) = { 13 | let guild = ctx.guild().expect("Guild only command"); 14 | let channel_id = guild 15 | .voice_states 16 | .get(&ctx.author().id) 17 | .and_then(|voice_state| voice_state.channel_id); 18 | 19 | (guild.id, channel_id) 20 | }; 21 | 22 | if let None = channel_id { 23 | let embed = CreateEmbed::new() 24 | .title("❌ Not in a voice chat.") 25 | .color(Colour::from_rgb(255, 0, 0)); 26 | ctx.send(CreateReply { 27 | embeds: vec![embed], 28 | ..Default::default() 29 | }) 30 | .await?; 31 | return Ok(()); 32 | } 33 | let lock = ctx.data().queue.read().await; 34 | let k = EventfulQueueKey { 35 | guild_id, 36 | channel_id: channel_id.unwrap(), 37 | }; 38 | let queue = lock.get_queue(&k).await; 39 | 40 | if let None = queue { 41 | let embed = CreateEmbed::new() 42 | .title("❌ No music is playing.") 43 | .color(Colour::from_rgb(255, 0, 0)); 44 | ctx.send(CreateReply { 45 | embeds: vec![embed], 46 | ..Default::default() 47 | }) 48 | .await?; 49 | return Ok(()); 50 | } 51 | let queue = queue.unwrap(); 52 | 53 | let len = queue.len(); 54 | if len == 0 { 55 | let embed = CreateEmbed::new() 56 | .title("❌ Queue is currently empty.") 57 | .color(Colour::from_rgb(255, 0, 0)); 58 | ctx.send(CreateReply { 59 | embeds: vec![embed], 60 | ..Default::default() 61 | }) 62 | .await?; 63 | return Ok(()); 64 | } 65 | let paginated_queue = PaginatedQueue::new(queue, len, n); 66 | let pages = paginated_queue.total_pages(); 67 | 68 | if n > pages { 69 | let embed = CreateEmbed::new() 70 | .title(format!( 71 | "❌ Number cannot be larger than total pages({})", 72 | pages 73 | )) 74 | .color(Colour::from_rgb(255, 0, 0)); 75 | ctx.send(CreateReply { 76 | embeds: vec![embed], 77 | ..Default::default() 78 | }) 79 | .await?; 80 | return Ok(()); 81 | } 82 | let embed = CreateEmbed::new() 83 | .title("📋 **Currently Playing**") 84 | .title("".to_string()) 85 | .fields(paginated_queue.get_fields()) 86 | .footer(CreateEmbedFooter::new(format!("Total Pages: {}", pages))) 87 | .color(Colour::from_rgb(0, 236, 255)); 88 | ctx.send(CreateReply { 89 | embeds: vec![embed], 90 | ..Default::default() 91 | }) 92 | .await?; 93 | 94 | Ok(()) 95 | } 96 | -------------------------------------------------------------------------------- /src/commands/repeat.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | use songbird::tracks::LoopState; 4 | 5 | use crate::queue::EventfulQueueKey; 6 | 7 | use super::utils::{Context, Error}; 8 | 9 | #[poise::command(prefix_command, rename = "loop")] 10 | pub async fn repeat(ctx: Context<'_>) -> Result<(), Error> { 11 | let (guild_id, channel_id) = { 12 | let guild = ctx.guild().expect("Guild only message"); 13 | let channel_id = guild 14 | .voice_states 15 | .get(&ctx.author().id) 16 | .and_then(|voice_state| voice_state.channel_id); 17 | 18 | (guild.id, channel_id) 19 | }; 20 | if let None = channel_id { 21 | let embed = CreateEmbed::new() 22 | .title("❌ Not in a voice chat.") 23 | .color(Colour::from_rgb(255, 0, 0)); 24 | ctx.send(CreateReply { 25 | embeds: vec![embed], 26 | ..Default::default() 27 | }) 28 | .await?; 29 | return Ok(()); 30 | } 31 | 32 | let channel_id = channel_id.unwrap(); 33 | 34 | let manager = songbird::get(&ctx.serenity_context()) 35 | .await 36 | .expect("Songbird Voice client placed in at initialisation.") 37 | .clone(); 38 | let handler = manager.get(guild_id).unwrap(); 39 | let handler_lock = handler.lock().await; 40 | if let None = handler_lock.queue().current() { 41 | let embed = CreateEmbed::new() 42 | .title("❌ Nothing is playing.") 43 | .color(Colour::from_rgb(255, 0, 0)); 44 | ctx.send(CreateReply { 45 | embeds: vec![embed], 46 | ..Default::default() 47 | }) 48 | .await?; 49 | return Ok(()); 50 | } 51 | let k = EventfulQueueKey { 52 | guild_id, 53 | channel_id, 54 | }; 55 | let track = { ctx.data().queue.read().await.front(&k).await.cloned() }; 56 | if handler_lock 57 | .queue() 58 | .current() 59 | .unwrap() 60 | .get_info() 61 | .await 62 | .unwrap() 63 | .loops 64 | == LoopState::Finite(0) 65 | { 66 | handler_lock 67 | .queue() 68 | .current() 69 | .unwrap() 70 | .enable_loop() 71 | .unwrap(); 72 | let embed = CreateEmbed::new() 73 | .title("♾️ Looping this track") 74 | .description(track.unwrap().name) 75 | .color(Colour::from_rgb(0, 255, 0)); 76 | ctx.send(CreateReply { 77 | embeds: vec![embed], 78 | ..Default::default() 79 | }) 80 | .await?; 81 | return Ok(()); 82 | } 83 | handler_lock 84 | .queue() 85 | .current() 86 | .unwrap() 87 | .disable_loop() 88 | .unwrap(); 89 | let embed = CreateEmbed::new() 90 | .title("♾️ Disabled loop.") 91 | .description(track.unwrap().name) 92 | .color(Colour::from_rgb(255, 0, 0)); 93 | ctx.send(CreateReply { 94 | embeds: vec![embed], 95 | ..Default::default() 96 | }) 97 | .await?; 98 | Ok(()) 99 | } 100 | -------------------------------------------------------------------------------- /src/commands/skip.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | 4 | use crate::{commands::utils::Error, queue::EventfulQueueKey}; 5 | 6 | use super::utils::Context; 7 | 8 | #[poise::command(prefix_command, guild_only)] 9 | pub async fn skip(ctx: Context<'_>, n: Option) -> Result<(), Error> { 10 | let (guild_id, channel_id) = { 11 | let guild = ctx.guild().expect("Guild only message"); 12 | let channel_id = guild 13 | .voice_states 14 | .get(&ctx.author().id) 15 | .and_then(|voice_state| voice_state.channel_id); 16 | 17 | (guild.id, channel_id) 18 | }; 19 | 20 | if let None = channel_id { 21 | let embed = CreateEmbed::new() 22 | .title("❌ Not in a voice chat.") 23 | .color(Colour::from_rgb(255, 0, 0)); 24 | ctx.send(CreateReply { 25 | embeds: vec![embed], 26 | ..Default::default() 27 | }) 28 | .await?; 29 | return Ok(()); 30 | } 31 | 32 | let channel_id = channel_id.unwrap(); 33 | let manager = songbird::get(&ctx.serenity_context()) 34 | .await 35 | .expect("Songbird Voice client placed in at initialisation.") 36 | .clone(); 37 | if let Ok(handler_lock) = manager.join(guild_id, channel_id).await { 38 | let handler = handler_lock.lock().await; 39 | let queue = handler.queue(); 40 | if queue.len() == 0 { 41 | let embed = CreateEmbed::new() 42 | .title("❌ Nothing to skip.") 43 | .color(Colour::from_rgb(255, 0, 0)); 44 | ctx.send(CreateReply { 45 | embeds: vec![embed], 46 | ..Default::default() 47 | }) 48 | .await?; 49 | return Ok(()); 50 | } 51 | let n_times = if n.unwrap_or(1) >= queue.len() { 52 | queue.len() 53 | } else { 54 | n.unwrap_or(1) 55 | }; 56 | let k = EventfulQueueKey { 57 | guild_id, 58 | channel_id, 59 | }; 60 | for _ in 0..n_times { 61 | queue.skip()?; 62 | let pop = { ctx.data().queue.write().await.pop(&k).await }; 63 | if let None = pop { 64 | let embed = CreateEmbed::new() 65 | .title(format!( 66 | "⏩ Skipped {} {}", 67 | n_times, 68 | if n_times > 1 { "tracks" } else { "track" } 69 | )) 70 | .color(Colour::from_rgb(0, 255, 0)); 71 | ctx.send(CreateReply { 72 | embeds: vec![embed], 73 | ..Default::default() 74 | }) 75 | .await?; 76 | return Ok(()); 77 | } 78 | } 79 | let embed = CreateEmbed::new() 80 | .title(format!( 81 | "⏩ Skipped {} {}", 82 | n_times, 83 | if n_times > 1 { "tracks" } else { "track" } 84 | )) 85 | .color(Colour::from_rgb(0, 255, 0)); 86 | ctx.send(CreateReply { 87 | embeds: vec![embed], 88 | ..Default::default() 89 | }) 90 | .await?; 91 | } 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/remove.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | 4 | use crate::queue::EventfulQueueKey; 5 | 6 | use super::utils::{Context, Error}; 7 | 8 | #[poise::command(prefix_command)] 9 | pub async fn remove(ctx: Context<'_>, n: u64) -> Result<(), Error> { 10 | if n <= 1 { 11 | let embed = CreateEmbed::new() 12 | .title("❌ Number must be greater than 1.") 13 | .color(Colour::from_rgb(255, 0, 0)); 14 | ctx.send(CreateReply { 15 | embeds: vec![embed], 16 | ..Default::default() 17 | }) 18 | .await?; 19 | return Ok(()); 20 | } 21 | let (guild_id, channel_id) = { 22 | let guild = ctx.guild().expect("Guild only message"); 23 | let channel_id = guild 24 | .voice_states 25 | .get(&ctx.author().id) 26 | .and_then(|voice_state| voice_state.channel_id); 27 | 28 | (guild.id, channel_id) 29 | }; 30 | if let None = channel_id { 31 | let embed = CreateEmbed::new() 32 | .title("❌ Not in a voice chat.") 33 | .color(Colour::from_rgb(255, 0, 0)); 34 | ctx.send(CreateReply { 35 | embeds: vec![embed], 36 | ..Default::default() 37 | }) 38 | .await?; 39 | return Ok(()); 40 | } 41 | 42 | let channel_id = channel_id.unwrap(); 43 | 44 | let manager = songbird::get(&ctx.serenity_context()) 45 | .await 46 | .expect("Songbird Voice client placed in at initialisation.") 47 | .clone(); 48 | let handler = manager.get(guild_id).unwrap(); 49 | let handler_lock = handler.lock().await; 50 | if let None = handler_lock.queue().current() { 51 | let embed = CreateEmbed::new() 52 | .title("❌ Nothing is playing.") 53 | .color(Colour::from_rgb(255, 0, 0)); 54 | ctx.send(CreateReply { 55 | embeds: vec![embed], 56 | ..Default::default() 57 | }) 58 | .await?; 59 | return Ok(()); 60 | } 61 | let k = EventfulQueueKey { 62 | guild_id, 63 | channel_id, 64 | }; 65 | if handler_lock.queue().len() < n.try_into().unwrap() { 66 | let embed = CreateEmbed::new() 67 | .title("❌ Number cannot be larger than the queue size.") 68 | .color(Colour::from_rgb(255, 0, 0)); 69 | ctx.send(CreateReply { 70 | embeds: vec![embed], 71 | ..Default::default() 72 | }) 73 | .await?; 74 | return Ok(()); 75 | } 76 | 77 | let track = 78 | { ctx.data().queue.read().await.get_queue(&k).await.unwrap()[(n - 1) as usize].clone() }; 79 | 80 | handler_lock.queue().modify_queue(|queue| { 81 | queue.remove((n - 1) as usize); 82 | }); 83 | { 84 | ctx.data() 85 | .queue 86 | .write() 87 | .await 88 | .remove(k, (n - 1) as usize) 89 | .await; 90 | } 91 | let embed = CreateEmbed::new() 92 | .title("✅ Removed Track") 93 | .field(format!("{} - {}", track.artist, track.name), "", false) 94 | .color(Colour::from_rgb(0, 255, 0)); 95 | ctx.send(CreateReply { 96 | embeds: vec![embed], 97 | ..Default::default() 98 | }) 99 | .await?; 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /src/queue/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, VecDeque}; 2 | 3 | use async_trait::async_trait; 4 | use serenity::all::{ChannelId, GuildId}; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] 7 | pub struct EventfulQueueKey { 8 | pub guild_id: GuildId, 9 | pub channel_id: ChannelId, 10 | } 11 | 12 | #[derive(Debug)] 13 | #[non_exhaustive] 14 | pub enum QueueEvents<'a, T> { 15 | TrackPushed(EventfulQueueKey, &'a VecDeque), 16 | TrackPopped(EventfulQueueKey, &'a VecDeque, &'a T), 17 | QueueCreated(EventfulQueueKey), 18 | QueueCleared(EventfulQueueKey), 19 | } 20 | 21 | #[async_trait] 22 | pub trait QueueEventHandler: std::fmt::Debug 23 | where 24 | T: Send + Sync, 25 | { 26 | async fn on_event(&self, event: &QueueEvents); 27 | } 28 | 29 | #[derive(Debug, Default)] 30 | pub struct EventfulQueue { 31 | data: HashMap>, 32 | handlers: HashMap + Send + Sync>>, 33 | } 34 | 35 | impl EventfulQueue { 36 | pub fn add_handler(&mut self, handler: H, key: &EventfulQueueKey) 37 | where 38 | H: QueueEventHandler + Send + Sync + 'static, 39 | { 40 | self.handlers.insert(key.clone(), Box::new(handler)); 41 | } 42 | 43 | pub async fn add_queue(&mut self, key: EventfulQueueKey) { 44 | let queue = self.data.get(&key); 45 | if queue.is_some() { 46 | return; 47 | } 48 | self.data.insert(key.clone(), VecDeque::new()); 49 | self.handlers 50 | .get(&key) 51 | .unwrap() 52 | .on_event(&QueueEvents::QueueCreated(key)) 53 | .await; 54 | } 55 | 56 | pub async fn push(&mut self, key: &EventfulQueueKey, val: T) { 57 | let queue = self.data.entry(key.clone()).or_insert_with(VecDeque::new); 58 | 59 | queue.push_back(val); 60 | 61 | if let Some(_) = queue.back() { 62 | self.handlers 63 | .get(&key) 64 | .unwrap() 65 | .on_event(&QueueEvents::TrackPushed( 66 | key.clone(), 67 | self.data.get(&key).unwrap(), 68 | )) 69 | .await; 70 | } 71 | } 72 | 73 | pub async fn get_queue(&self, key: &EventfulQueueKey) -> Option<&VecDeque> { 74 | self.data.get(key) 75 | } 76 | 77 | pub async fn clear(&mut self, key: EventfulQueueKey) { 78 | self.data.get_mut(&key).unwrap().clear(); 79 | self.handlers 80 | .get(&key) 81 | .unwrap() 82 | .on_event(&QueueEvents::QueueCleared(key)) 83 | .await; 84 | } 85 | 86 | pub async fn pop(&mut self, key: &EventfulQueueKey) -> Option { 87 | let track = self.data.get_mut(&key)?.pop_front(); 88 | 89 | if let Some(ref v) = track { 90 | self.handlers 91 | .get(&key) 92 | .unwrap() 93 | .on_event(&QueueEvents::TrackPopped( 94 | key.clone(), 95 | self.data.get(&key).unwrap(), 96 | v, 97 | )) 98 | .await; 99 | } 100 | track 101 | } 102 | 103 | pub async fn remove(&mut self, key: EventfulQueueKey, idx: usize) -> Option { 104 | self.data.get_mut(&key)?.remove(idx) 105 | } 106 | 107 | pub async fn front(&self, key: &EventfulQueueKey) -> Option<&T> { 108 | self.data.get(&key)?.front() 109 | } 110 | 111 | pub async fn key_exists(&mut self, key: &EventfulQueueKey) -> bool { 112 | self.data.contains_key(key) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/commands/yt.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | use serenity::all::{Colour, CreateEmbed}; 3 | use songbird::{ 4 | input::{Compose, YoutubeDl}, 5 | TrackEvent, 6 | }; 7 | 8 | use crate::{ 9 | commands::utils::{duration_to_time, Error}, 10 | events::{track_error_notifier::TrackErrorNotifier, track_queue_event::QueueEvent}, 11 | queue::EventfulQueueKey, 12 | state::Track, 13 | }; 14 | 15 | use super::utils::Context; 16 | 17 | /// Plays music - pass the name of song. 18 | #[poise::command(prefix_command, aliases("youtube"))] 19 | pub async fn yt(ctx: Context<'_>, song_name: Vec) -> Result<(), Error> { 20 | let (guild_id, channel_id) = { 21 | let guild = ctx.guild().expect("Guild only command"); 22 | let channel_id = guild 23 | .voice_states 24 | .get(&ctx.author().id) 25 | .and_then(|voice_state| voice_state.channel_id); 26 | 27 | (guild.id, channel_id) 28 | }; 29 | 30 | if let None = channel_id { 31 | let embed = CreateEmbed::new() 32 | .title("❌ Not in a voice chat.") 33 | .color(Colour::from_rgb(255, 0, 0)); 34 | ctx.send(CreateReply { 35 | embeds: vec![embed], 36 | ..Default::default() 37 | }) 38 | .await?; 39 | return Ok(()); 40 | } 41 | 42 | let channel_id = channel_id.unwrap(); 43 | let manager = songbird::get(&ctx.serenity_context()) 44 | .await 45 | .expect("Songbird Voice client placed in at initialisation.") 46 | .clone(); 47 | let http_client = ctx.data().hc.clone(); 48 | let mut src = match song_name[0].starts_with("http") { 49 | true => YoutubeDl::new(http_client, song_name.join(" ")), 50 | false => YoutubeDl::new_search(http_client, song_name.join(" ")), 51 | }; 52 | let queues = &ctx.data().queue; 53 | let k = EventfulQueueKey { 54 | guild_id, 55 | channel_id, 56 | }; 57 | { 58 | let mut lock = queues.write().await; 59 | let queue = lock.key_exists(&k).await; 60 | if !queue { 61 | lock.add_handler( 62 | QueueEvent { 63 | channel_id, 64 | guild_id, 65 | text_channel_id: ctx.channel_id(), 66 | context: ctx.serenity_context().clone(), 67 | sql_conn: ctx.data().sql_conn.clone(), 68 | }, 69 | &k, 70 | ); 71 | lock.add_queue(k).await; 72 | } 73 | } 74 | let track_metadata = src.aux_metadata().await?; 75 | if let Ok(handler_lock) = manager.join(guild_id, channel_id).await { 76 | let mut handler = handler_lock.lock().await; 77 | handler.add_global_event( 78 | TrackEvent::End.into(), 79 | TrackErrorNotifier { 80 | channel_id, 81 | guild_id, 82 | queues: ctx.data().queue.clone(), 83 | }, 84 | ); 85 | let track_handle = handler.enqueue_input(src.into()).await; 86 | 87 | let track = Track { 88 | name: track_metadata.title.unwrap_or_default(), 89 | handle_uuid: track_handle.uuid().to_string(), 90 | artist: track_metadata.artist.unwrap_or_default(), 91 | duration: duration_to_time(track_metadata.duration.unwrap_or_default()), 92 | thumbnail: track_metadata.thumbnail.unwrap_or_default(), 93 | album: track_metadata.album.unwrap_or_default(), 94 | can_scrobble: false, 95 | from_playlist: false, 96 | }; 97 | queues.write().await.push(&k, track).await; 98 | } 99 | 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use commands::fmlogin::fmlogin; 4 | use commands::music::music; 5 | use commands::now::now; 6 | use commands::pause::pause; 7 | use commands::ping::ping; 8 | use commands::play::playlist; 9 | use commands::remove::remove; 10 | use commands::seek::seek; 11 | use commands::skip::skip; 12 | use commands::yt::yt; 13 | use commands::{clear::clear, repeat::repeat}; 14 | 15 | use dotenv::dotenv; 16 | use persistence::SqlConn; 17 | use poise::{serenity_prelude as serenity, PrefixFrameworkOptions}; 18 | use reqwest::Client as HttpClient; 19 | use songbird::SerenityInit; 20 | use state::Data; 21 | 22 | mod commands; 23 | mod events; 24 | mod models; 25 | mod persistence; 26 | mod queue; 27 | mod scrobbler; 28 | mod spotify; 29 | mod state; 30 | 31 | #[tokio::main] 32 | async fn main() -> Result<(), Box> { 33 | let _ = dotenv(); 34 | tracing_subscriber::fmt().init(); 35 | let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); 36 | let sql_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL"); 37 | let intents = 38 | serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; 39 | 40 | let framework = poise::Framework::builder() 41 | .options(poise::FrameworkOptions { 42 | commands: vec![ 43 | playlist(), 44 | music(), 45 | ping(), 46 | skip(), 47 | clear(), 48 | now(), 49 | repeat(), 50 | pause(), 51 | seek(), 52 | remove(), 53 | fmlogin(), 54 | yt(), 55 | ], 56 | event_handler: |ctx, event, _, _| match event { 57 | serenity::FullEvent::VoiceStateUpdate { new, .. } => Box::pin(async move { 58 | if new.user_id.to_string() == ctx.http.application_id().unwrap().to_string() { 59 | let manager = songbird::get(&ctx) 60 | .await 61 | .expect("Songbird Voice client placed in at initialisation.") 62 | .clone(); 63 | let handler = manager.get(new.guild_id.unwrap()).unwrap(); 64 | let handler_lock = handler.lock().await; 65 | if handler_lock.queue().current().is_none() { 66 | return Ok(()); 67 | } 68 | match new.mute { 69 | true => { 70 | handler_lock.queue().current().unwrap().pause().unwrap(); 71 | } 72 | false => { 73 | handler_lock.queue().current().unwrap().play().unwrap(); 74 | } 75 | } 76 | } 77 | Ok(()) 78 | }), 79 | _ => Box::pin(async move { Ok(()) }), 80 | }, 81 | prefix_options: PrefixFrameworkOptions { 82 | prefix: Some(";".to_string()), 83 | ..Default::default() 84 | }, 85 | ..Default::default() 86 | }) 87 | .setup(|ctx, _ready, framework| { 88 | Box::pin(async move { 89 | poise::builtins::register_globally(ctx, &framework.options().commands).await?; 90 | Ok(Data { 91 | hc: HttpClient::new(), 92 | queue: Default::default(), 93 | sql_conn: SqlConn::new(sql_url).await, 94 | }) 95 | }) 96 | }) 97 | .build(); 98 | 99 | let mut client = serenity::ClientBuilder::new(token, intents) 100 | .framework(framework) 101 | .register_songbird() 102 | .await?; 103 | 104 | client.start().await?; 105 | 106 | Ok(()) 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YT-Yapper 2 | 3 | YT-Yapper is a feature-rich Discord music bot written in Rust. It provides seamless music playback and advanced controls while integrating Last.fm scrobbling and Spotify track data for a complete music experience. 4 | 5 | ## Features 6 | 7 | - Music playback with search and URL support 8 | - Comprehensive playback controls: 9 | - Play/Pause 10 | - Skip tracks 11 | - Seek within tracks 12 | - Clear queue 13 | - Remove specific tracks 14 | - Single track repeat mode with toggle 15 | - Now playing information with rich embeds and thumbnails 16 | - Automatic track progression 17 | - Spotify playlist support 18 | - Spotify album support 19 | - Server-specific features: 20 | - Per-server music queues 21 | - Server mute synchronization 22 | - Automatic voice channel state handling 23 | - Last.fm Integration: 24 | - Automatic song scrobbling on track completion 25 | - Real-time now playing updates 26 | - Voice channel member tracking 27 | - Secure DM-based authentication 28 | - Multi-user support with session persistence 29 | - SQLite persistence for Last.fm user tokens 30 | - Spotify Integration: Fetches detailed track metadata using the spotify-rs crate. 31 | 32 | ## Installation 33 | 34 | ### 1. Prerequisites: 35 | 36 | - Ensure [Rust](https://rust-lang.org) is installed on your system. 37 | - Obtain a Discord bot token by creating a new application in the [Discord Developer Portal](https://discord.com/developers/applications). 38 | - Set up your [Last.fm API](https://www.last.fm/api/account/create) credentials for scrobbling. 39 | - Set up [Spotify API credentials](https://developer.spotify.com/dashboard) for enhanced track metadata. 40 | 41 | ### 2. Clone the Repository: 42 | 43 | ```bash 44 | git clone https://github.com/gamyingonline/yt-yapper.git 45 | cd yt-yapper 46 | ``` 47 | 48 | ### 3. Configuration: 49 | 50 | - Rename .env.example to .env and fill in the required details: 51 | 52 | ``` 53 | DATABASE_URL=database_url 54 | DISCORD_TOKEN=your_discord_token 55 | LASTFM_API_KEY=your_lastfm_api_key 56 | LASTFM_SECRET=your_lastfm_secret 57 | SPOTIFY_CLIENT_ID=your_spotify_client_id 58 | SPOTIFY_CLIENT_SECRET=your_spotify_client_secret 59 | ``` 60 | 61 | ### 4. Build and Run: 62 | 63 | ```bash 64 | cargo install sqlx-cli 65 | cargo sqlx prepare 66 | cargo build --release 67 | cargo run --release 68 | ``` 69 | 70 | ## Dependencies 71 | 72 | - [poise](https://crates.io/crates/poise) v0.6.1 - Discord bot framework 73 | - [songbird](https://crates.io/crates/songbird) v0.4.3 - Voice and audio handling 74 | - [tokio](https://crates.io/crates/tokio) v1.41.1 - Async runtime 75 | - [serenity](https://crates.io/crates/serenity) v0.12.2 - Discord API wrapper 76 | - [spotify-rs](https://crates.io/crates/spotify-rs) v0.3.14 - Spotify integration 77 | - [symphonia](https://crates.io/crates/symphonia) v0.5.2 - Audio decoding 78 | - [rustfm-scrobble](https://crates.io/crates/rustfm-scrobble) v1.1.1 - Last.fm integration 79 | - [sqlx](https://crates.io/crates/sqlx) v0.8.2 - SQLite database integration 80 | - [dotenv](https://crates.io/crates/dotenv) v0.15.0 - Environment configuration 81 | 82 | ## Usage 83 | 84 | Once the bot is running, invite it to your Discord server using the OAuth2 URL generated in the Discord Developer Portal. Use the following commands with the prefix `;` 85 | 86 | - `;play `: Play a track from a search query (searches from spotify) or a spotify playlist, album. (alias: `;music`) 87 | - `;yt `: Play a track from a url or search query (searches from yt). (alias: `;youtube`) 88 | - `;pause`: Pause the current track. 89 | - `;resume`: Resume playback. 90 | - `;skip []`: Skip to the next track in the queue. 91 | - `;queue`: Display the current queue. 92 | - `;clear`: Clear the queue. 93 | - `;now`: Show information about the currently playing track. 94 | - `;seek