diff --git a/Cargo.lock b/Cargo.lock index 4c49dff..cbdead2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,6 +380,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "dashmap" version = "5.4.0" @@ -429,6 +464,7 @@ dependencies = [ "clap", "dotenv", "log", + "poise", "reqwest", "serenity", "simple_logger", @@ -814,6 +850,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -1216,6 +1258,35 @@ dependencies = [ "pnet_base", ] +[[package]] +name = "poise" +version = "0.4.1" +source = "git+https://github.com/serenity-rs/poise?branch=serenity-next#50ddbbb85a66068e3de33a9ba3c6ff72e62596b6" +dependencies = [ + "async-trait", + "derivative", + "futures-core", + "futures-util", + "log", + "once_cell", + "parking_lot", + "poise_macros", + "regex", + "serenity", + "tokio", +] + +[[package]] +name = "poise_macros" +version = "0.4.0" +source = "git+https://github.com/serenity-rs/poise?branch=serenity-next#50ddbbb85a66068e3de33a9ba3c6ff72e62596b6" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "poly1305" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 01f051a..8d94681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ features = [ "rustls-tls" ] version = "0.11" git = "https://github.com/serenity-rs/serenity" branch = "next" -features = [ "client", "standard_framework", "voice", "rustls_backend" ] +features = [ "client", "voice", "rustls_backend" ] [dependencies.songbird] version = "0.3" @@ -36,6 +36,11 @@ git = "https://github.com/serenity-rs/songbird" branch = "next" features = [ "builtin-queue" ] +[dependencies.poise] +version = "0.4" +git = "https://github.com/serenity-rs/poise" +branch = "serenity-next" + [dependencies.symphonia] version = "0.5" features = ["aac", "mp3"] diff --git a/default.nix b/default.nix index 90c5108..2b771c2 100644 --- a/default.nix +++ b/default.nix @@ -16,6 +16,7 @@ in rustPlatform.buildRustPackage { "sunk-0.1.2" = "sha256-gxDinyzKHxph2D5OZ9TevVEWtsEnnFpu4BfkkBnRMx4="; "serenity-0.11.5" = "sha256-10s0kflNYEMwUXAgrh6d1IUk3ZRSCkAilz9m1lVhXhA="; "songbird-0.3.2" = "sha256-8wzCcV9W6K0MHqZ8yhTIMjh165NV8OQ9zlgrRrIhlOI="; + "poise-0.4.1" = "sha256-MPfg3miu9tesGKChaR8vz8RZA45I5uJs60MtgxDJryw="; }; }; diff --git a/src/config.rs b/src/config.rs index 1110001..480938f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,17 +1,11 @@ -use crate::handles::SubsonicClientHandle; +use crate::discord; use anyhow::{Context as ErrContext, Result}; use clap::Parser; use log::LevelFilter; -use serenity::{ - client::Client as DiscordClient, framework::standard::StandardFramework, - prelude::GatewayIntents, -}; +use serenity::client::Client as DiscordClient; use simple_logger::SimpleLogger; -use songbird::SerenityInit; use sunk::Client as SubsonicClient; -use crate::discord::{after_hook, Handler, GENERAL_GROUP}; - #[derive(Parser, Clone)] pub struct Config { #[clap(long, env = "DISCONIC_SUBSONIC_URL")] @@ -20,6 +14,8 @@ pub struct Config { subsonic_user: String, #[clap(long, env = "DISCONIC_SUBSONIC_PASSWORD")] subsonic_password: String, + #[clap(long, env = "DISCONIC_DISCORD_GUILD")] + discord_guild: Option, #[clap(long, env = "DISCONIC_DISCORD_TOKEN")] discord_token: String, #[clap(long, env = "DISCONIC_LOG_LEVEL", default_value = "warn")] @@ -27,22 +23,8 @@ pub struct Config { } impl Config { - pub async fn discord(&self, ss: SubsonicClient) -> Result { - let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT; - let framework = StandardFramework::new() - .group(&GENERAL_GROUP) - .after(after_hook); - - framework.configure(|c| c.prefix("~")); - - let client = DiscordClient::builder(&self.discord_token, intents) - .event_handler(Handler) - .framework(framework) - .type_map_insert::(ss) - .register_songbird() - .await?; - - Ok(client) + pub async fn discord(&self, subsonic_client: SubsonicClient) -> Result { + discord::create_client(&self.discord_token, self.discord_guild, subsonic_client).await } pub async fn subsonic(&self) -> Result { diff --git a/src/discord.rs b/src/discord.rs deleted file mode 100644 index f0d2163..0000000 --- a/src/discord.rs +++ /dev/null @@ -1,409 +0,0 @@ -use anyhow::{anyhow, Context as ErrContext, Result}; -use serenity::{ - all::{ChannelId, GuildId}, - async_trait, - client::{Context, EventHandler}, - framework::standard::{ - macros::{command, group, hook}, - Args, CommandResult, - }, - model::{channel::Message, gateway::Ready}, - utils::MessageBuilder, -}; -use songbird::{ - input::HttpRequest, - tracks::{Track, TrackHandle}, - Songbird, -}; -use sunk::{ - search::{self, SearchPage}, - song::Song, - Streamable, -}; -use tokio::sync::Mutex; - -use std::sync::Arc; - -use crate::handles::{SubsonicClientHandle, SubsonicSongHandle}; - -#[group] -#[commands( - song, random, skip, stop, pause, resume, queue, nowplaying, remove, album, join, leave -)] -pub struct General; - -pub struct Handler; - -#[async_trait] -impl EventHandler for Handler { - async fn ready(&self, _: Context, ready: Ready) { - log::info!("{} is connected!", ready.user.name); - } -} - -#[hook] -pub async fn after_hook(ctx: &Context, msg: &Message, cmd_name: &str, error: CommandResult) { - if let Err(why) = error { - msg.reply(&ctx.http, format!("{:?}", why)).await.ok(); - eprintln!("{}: {:?}", cmd_name, why,); - } -} - -#[command] -#[only_in(guilds)] -async fn leave(ctx: &Context, msg: &Message) -> CommandResult { - let guild = get_guild(ctx, msg)?; - let manager = get_manager(ctx).await?; - manager.remove(guild).await?; - Ok(()) -} - -#[command] -#[only_in(guilds)] -async fn join(ctx: &Context, msg: &Message) -> CommandResult { - let guild = get_guild(ctx, msg)?; - let channel = get_channel(ctx, msg)?; - let manager = get_manager(ctx).await?; - let _handler = manager.join(guild, channel).await; - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases(s, p, play)] -/// Play a named song -async fn song(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let data = ctx.data.read().await; - let music_client = data - .get::() - .expect("Couldn't retrieve music client"); - - let search_size = SearchPage::new().with_size(1); - let ignore = search::NONE; - - let result = music_client - .search(args.rest(), ignore, ignore, search_size) - .await - .with_context(|| anyhow!("Could not search for song"))? - .songs; - - let song = result - .first() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("No song matching search found"))?; - - log::info!("Found song {song:?}"); - queue_song(ctx, msg, &song, music_client).await?; - - let message = format!( - "Added **{} - {}** to the queue", - song.title, - song.artist.as_deref().unwrap_or_default(), - ); - msg.reply(&ctx.http, &MessageBuilder::new().push(&message).build()) - .await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases(a, album)] -/// Play a named album -async fn album(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let data = ctx.data.read().await; - let music_client = data - .get::() - .expect("Couldn't retrieve music client"); - - let search_size = SearchPage::new().with_size(1); - let ignore = search::NONE; - - let result = music_client - .search(args.rest(), ignore, search_size, ignore) - .await? - .albums; - - let album = result - .first() - .ok_or_else(|| anyhow!("No albums matching search found"))?; - - let songs = album.songs(music_client).await?; - for song in songs.iter() { - queue_song(ctx, msg, &song, music_client).await?; - } - - let message = format!( - "Added album **{} - {}** ({} songs) to the queue", - album.name, - album.artist.as_deref().unwrap_or_default(), - songs.len(), - ); - msg.reply(&ctx.http, &MessageBuilder::new().push(&message).build()) - .await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases(r, rand)] -/// Play a random song -async fn random(ctx: &Context, msg: &Message) -> CommandResult { - let data = ctx.data.read().await; - let music_client = data - .get::() - .expect("Couldn't retrieve music client"); - - let result = Song::random(music_client, 1).await?; - - let song: Song = result - .first() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("No song matching search found"))?; - queue_song(ctx, msg, &song, music_client).await?; - - let message = format!( - "Added **{} - {}** to the queue", - song.title, - song.artist.as_deref().unwrap_or_default(), - ); - msg.reply(&ctx.http, &MessageBuilder::new().push(&message).build()) - .await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -/// Skip song(s) -async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - let n = args.rest().parse().unwrap_or(1); - - let queue = handler.queue(); - for _ in 0..(n - 1) { - queue.dequeue(1).ok_or_else(|| anyhow!("Song not found"))?; - } - queue.skip()?; - - msg.reply(&ctx.http, &format!("{n} song(s) skipped")) - .await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -/// Clear queue and stop playing -async fn stop(ctx: &Context, msg: &Message) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - - let queue = handler.queue(); - queue.stop(); - - msg.reply(&ctx.http, "Stopped playing").await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -/// Pause playing current song -async fn pause(ctx: &Context, msg: &Message) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - - let queue = handler.queue(); - let current = queue - .current() - .ok_or_else(|| anyhow!("Not currently playing"))?; - current.pause()?; - - msg.reply(&ctx.http, "Paused playing").await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases(resume)] -/// Resume playing current song -async fn resume(ctx: &Context, msg: &Message) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - - let queue = handler.queue(); - queue.resume()?; - - msg.reply(&ctx.http, "Resumed playing").await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases(nowplaying, now, np, playing)] -/// Show currently playing song -async fn nowplaying(ctx: &Context, msg: &Message) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - - let queue = handler.queue(); - let current = queue - .current() - .ok_or_else(|| anyhow!("Not currently playing"))?; - - let song = get_song(¤t).await?; - let text = format!( - "**Currently playing**: {} - {}", - song.title, - song.artist.as_deref().unwrap_or_default() - ); - msg.reply(&ctx.http, text).await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases(q)] -/// Show song queue -async fn queue(ctx: &Context, msg: &Message) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - - let current_queue = handler.queue().current_queue(); - - let text = if current_queue.is_empty() { - "No songs queued".into() - } else { - let mut text = String::new(); - let mut queue = current_queue.iter().enumerate(); - - let (_, track) = queue.next().unwrap(); - let song = get_song(track).await?; - text.push_str(&format!( - "**Currently playing**: {} - {}\n\n", - song.title, - song.artist.as_deref().unwrap_or_default() - )); - - text.push_str("**Next songs in queue**:\n"); - for (i, track) in queue { - let song = get_song(track).await?; - let song_text = format!( - "**{}.** {} - {}", - i, - song.title, - song.artist.as_deref().unwrap_or_default() - ); - text.push_str(&song_text); - text.push('\n'); - } - text - }; - - msg.reply(&ctx.http, text).await?; - Ok(()) -} - -#[command] -#[only_in(guilds)] -/// Remove song from queue, given id -async fn remove(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let call = get_call(ctx, msg).await?; - let handler = call.lock().await; - - let index: usize = args.single()?; - - let queue = handler.queue(); - let track = queue - .dequeue(index - 1) - .ok_or_else(|| anyhow!("Song not found"))?; - let song = get_song(&track).await?; - let text = format!("Removed track: {}", song.title); - msg.reply(&ctx.http, text).await?; - Ok(()) -} - -// ========================== -// ========================== -// ========================== - -async fn queue_song( - ctx: &Context, - msg: &Message, - song: &Song, - client: &sunk::Client, -) -> Result<()> { - let call = get_call(ctx, msg).await?; - let mut handler = call.lock().await; - - let track = load_song(song, client).await?; - let track_handle = handler.enqueue(track).await; - let mut type_map = track_handle.typemap().write().await; - type_map.insert::(song.clone()); - - Ok(()) -} - -fn get_guild(ctx: &Context, msg: &Message) -> Result { - let guild = msg.guild(&ctx.cache).unwrap(); - Ok(guild.id) -} - -fn get_channel(ctx: &Context, msg: &Message) -> Result { - let guild = msg.guild(&ctx.cache).unwrap(); - let channel_id = guild - .voice_states - .get(&msg.author.id) - .and_then(|voice_state| voice_state.channel_id) - .ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?; - - Ok(channel_id) -} - -async fn get_call(ctx: &Context, msg: &Message) -> Result>> { - let manager = get_manager(ctx).await?; - let guild = get_guild(ctx, msg)?; - let call = manager.get(guild); - - if let Some(c) = call { - Ok(c) - } else { - let channel = get_channel(ctx, msg)?; - log::warn!("Not in a voice channel, trying to join"); - let _handler = manager.join(guild, channel).await; - manager - .get(guild) - .ok_or_else(|| anyhow!("Not in a voice channel, try running 'join'")) - } -} - -async fn get_song(track: &TrackHandle) -> Result { - let song = track - .typemap() - .read() - .await - .get::() - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("Sound information not found"))?; - Ok(song) -} - -async fn get_manager(ctx: &Context) -> Result> { - let manager = songbird::get(ctx) - .await - .ok_or_else(|| anyhow!("Couldn't start manager"))?; - Ok(manager) -} - -async fn load_song(song: &Song, client: &sunk::Client) -> Result { - log::info!("Loading song {song:?}"); - let url = song.stream_url(client)?; - let track: Track = HttpRequest::new(client.reqclient.clone(), url).into(); - Ok(track) -} diff --git a/src/discord/client.rs b/src/discord/client.rs new file mode 100644 index 0000000..b7b139e --- /dev/null +++ b/src/discord/client.rs @@ -0,0 +1,75 @@ +use crate::discord::{commands, Data}; +use anyhow::Result; +use poise::samples::create_application_commands; +use serenity::{all::GuildId, client::Client as DiscordClient, prelude::GatewayIntents}; +use songbird::SerenityInit; +use sunk::Client as SubsonicClient; + +async fn on_error(error: poise::FrameworkError<'_, Data, anyhow::Error>) { + let context = error.ctx(); + let message = match error { + poise::FrameworkError::Command { error, ctx } => { + format!( + "Error while running `{}`: **{:?}**", + ctx.command().name, + error + ) + } + poise::FrameworkError::Listener { error, event, .. } => { + format!( + "Error on event `{:?}`: **{:?}**", + event.snake_case_name(), + error + ) + } + error => { + if let Err(e) = poise::builtins::on_error(error).await { + format!("Error: **{}**", e) + } else { + format!("Error: **unknown**") + } + } + }; + log::error!("{message}"); + if let Some(ctx) = context { + ctx.say(message).await.ok(); + } +} + +pub async fn create_client( + token: &str, + guild_id: Option, + subsonic_client: SubsonicClient, +) -> Result { + let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT; + let commands = commands::commands(); + let data = Data { subsonic_client }; + + let create_commands = create_application_commands(&commands); + let options = poise::FrameworkOptions { + commands, + on_error: |e| Box::pin(on_error(e)), + ..Default::default() + }; + let framework = poise::Framework::new(options, move |ctx, _ready, _framework| { + Box::pin(async move { + if let Some(id) = guild_id { + let guild = GuildId::new(id); + guild + .set_commands(&ctx.http, create_commands) + .await + .expect("Failed to register command"); + } else { + log::warn!("Guild ID not configured. You'll have to run 'register' to register the slash commands.") + } + log::info!("Registered commands"); + Ok(data) + }) + }); + + let client = DiscordClient::builder(token, intents) + .framework(framework) + .register_songbird() + .await?; + Ok(client) +} diff --git a/src/discord/commands/album.rs b/src/discord/commands/album.rs new file mode 100644 index 0000000..5ea59e4 --- /dev/null +++ b/src/discord/commands/album.rs @@ -0,0 +1,41 @@ +use anyhow::{anyhow, Result}; +use poise::command; +use sunk::search; + +use crate::discord::{common::queue_song, Context}; + +/// Search for an album, and queue all its songs +#[command(slash_command, prefix_command)] +pub async fn album( + ctx: Context<'_>, + #[description = "What to search for"] query: String, +) -> Result<()> { + let music_client = &ctx.data().subsonic_client; + + let search_size = search::SearchPage::new().with_size(1); + let ignore = search::NONE; + + let result = music_client + .search(&query, ignore, search_size, ignore) + .await? + .albums; + + let album = result + .first() + .ok_or_else(|| anyhow!("No albums matching search found"))?; + + let songs = album.songs(&music_client).await?; + for song in songs.iter() { + queue_song(ctx, &song, &music_client).await?; + } + + let message = format!( + "Added album **{} - {}** ({} songs) to the queue", + album.name, + album.artist.as_deref().unwrap_or_default(), + songs.len(), + ); + ctx.say(message).await?; + + Ok(()) +} diff --git a/src/discord/commands/join.rs b/src/discord/commands/join.rs new file mode 100644 index 0000000..1b2f5ef --- /dev/null +++ b/src/discord/commands/join.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use poise::command; + +use crate::discord::{ + common::{get_channel, get_guild, get_manager}, + Context, +}; + +/// Join the voice chat where the caller is +#[command(slash_command, prefix_command)] +pub async fn join(ctx: Context<'_>) -> Result<()> { + let guild = get_guild(ctx); + let channel = get_channel(ctx)?; + let manager = get_manager(ctx).await?; + log::info!("Got manager: {manager:?}"); + let call = manager.join(guild.id, channel).await?; + log::info!("Got call: {call:?}"); + ctx.say("Hi! Try '/song' or '/album' to start").await?; + Ok(()) +} diff --git a/src/discord/commands/leave.rs b/src/discord/commands/leave.rs new file mode 100644 index 0000000..8d40ae9 --- /dev/null +++ b/src/discord/commands/leave.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use poise::command; + +use crate::discord::{ + common::{get_guild, get_manager}, + Context, +}; + +/// Leave the voice chat +#[command(slash_command, prefix_command)] +pub async fn leave(ctx: Context<'_>) -> Result<()> { + let guild = get_guild(ctx); + let manager = get_manager(ctx).await?; + manager.remove(guild.id).await?; + ctx.say("Bye!").await?; + Ok(()) +} diff --git a/src/discord/commands/mod.rs b/src/discord/commands/mod.rs new file mode 100644 index 0000000..b269217 --- /dev/null +++ b/src/discord/commands/mod.rs @@ -0,0 +1,36 @@ +pub mod album; +pub mod join; +pub mod leave; +pub mod nowplaying; +pub mod pause; +pub mod queue; +pub mod random; +pub mod remove; +pub mod resume; +pub mod skip; +pub mod song; +pub mod stop; + +#[poise::command(prefix_command)] +pub async fn register(ctx: crate::discord::Context<'_>) -> Result<(), anyhow::Error> { + poise::builtins::register_application_commands_buttons(ctx).await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![ + register(), + album::album(), + join::join(), + leave::leave(), + nowplaying::nowplaying(), + pause::pause(), + queue::queue(), + random::random(), + remove::remove(), + resume::resume(), + skip::skip(), + song::song(), + stop::stop(), + ] +} diff --git a/src/discord/commands/nowplaying.rs b/src/discord/commands/nowplaying.rs new file mode 100644 index 0000000..c42b921 --- /dev/null +++ b/src/discord/commands/nowplaying.rs @@ -0,0 +1,29 @@ +use anyhow::{anyhow, Result}; +use poise::command; + +use crate::discord::{ + common::{get_call, get_song}, + Context, +}; + +/// Show what's currently playing +#[command(slash_command, prefix_command)] +pub async fn nowplaying(ctx: Context<'_>) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + + let queue = handler.queue(); + let current = queue + .current() + .ok_or_else(|| anyhow!("Not currently playing"))?; + + let song = get_song(¤t).await?; + let text = format!( + "**Currently playing**: {} - {}", + song.title, + song.artist.as_deref().unwrap_or_default() + ); + ctx.say(text).await?; + + Ok(()) +} diff --git a/src/discord/commands/pause.rs b/src/discord/commands/pause.rs new file mode 100644 index 0000000..9284678 --- /dev/null +++ b/src/discord/commands/pause.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use poise::command; + +use crate::discord::{common::get_call, Context}; + +/// Pause playback +#[command(slash_command, prefix_command)] +pub async fn pause(ctx: Context<'_>) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + + let queue = handler.queue(); + let current = queue.current(); + if let Some(track) = current { + track.pause()?; + ctx.say("Paused playback").await?; + } else { + ctx.say("Not currently playing").await?; + } + + Ok(()) +} diff --git a/src/discord/commands/queue.rs b/src/discord/commands/queue.rs new file mode 100644 index 0000000..074ff30 --- /dev/null +++ b/src/discord/commands/queue.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use poise::command; + +use crate::discord::{ + common::{get_call, get_song}, + Context, +}; + +/// Get play queue list +#[command(slash_command, prefix_command)] +pub async fn queue(ctx: Context<'_>) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + + let current_queue = handler.queue().current_queue(); + + let text = if current_queue.is_empty() { + "No songs queued".into() + } else { + let mut text = String::new(); + let mut queue = current_queue.iter().enumerate(); + + let (_, track) = queue.next().unwrap(); + let song = get_song(track).await?; + text.push_str(&format!( + "**Currently playing**: {} - {}\n\n", + song.title, + song.artist.as_deref().unwrap_or_default() + )); + + text.push_str("**Next songs in queue**:\n"); + for (i, track) in queue { + let song = get_song(track).await?; + let song_text = format!( + "**{}.** {} - {}", + i, + song.title, + song.artist.as_deref().unwrap_or_default() + ); + text.push_str(&song_text); + text.push('\n'); + } + text + }; + + ctx.say(text).await?; + Ok(()) +} diff --git a/src/discord/commands/random.rs b/src/discord/commands/random.rs new file mode 100644 index 0000000..bb049af --- /dev/null +++ b/src/discord/commands/random.rs @@ -0,0 +1,29 @@ +use anyhow::{anyhow, Result}; +use poise::command; +use serenity::utils::MessageBuilder; +use sunk::song::Song; + +use crate::discord::{common::queue_song, Context}; + +/// Play a random song +#[command(slash_command, prefix_command)] +pub async fn random(ctx: Context<'_>) -> Result<()> { + let music_client = &ctx.data().subsonic_client; + + let result = Song::random(music_client, 1).await?; + let song: Song = result + .first() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("No song matching search found"))?; + queue_song(ctx, &song, music_client).await?; + + let message = format!( + "Added **{} - {}** to the queue", + song.title, + song.artist.as_deref().unwrap_or_default(), + ); + ctx.say(&MessageBuilder::new().push(&message).build()) + .await?; + + Ok(()) +} diff --git a/src/discord/commands/remove.rs b/src/discord/commands/remove.rs new file mode 100644 index 0000000..17ca7e6 --- /dev/null +++ b/src/discord/commands/remove.rs @@ -0,0 +1,26 @@ +use anyhow::{anyhow, Result}; +use poise::command; + +use crate::discord::{ + common::{get_call, get_song}, + Context, +}; + +/// Remove song from queue +#[command(slash_command, prefix_command)] +pub async fn remove( + ctx: Context<'_>, + #[description = "Song position on the queue"] index: usize, +) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + + let queue = handler.queue(); + let track = queue + .dequeue(index - 1) + .ok_or_else(|| anyhow!("Song not found"))?; + let song = get_song(&track).await?; + let text = format!("Removed track: {}", song.title); + ctx.say(text).await?; + Ok(()) +} diff --git a/src/discord/commands/resume.rs b/src/discord/commands/resume.rs new file mode 100644 index 0000000..11024b0 --- /dev/null +++ b/src/discord/commands/resume.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use poise::command; + +use crate::discord::{common::get_call, Context}; + +/// Resume playing current song +#[command(slash_command, prefix_command)] +pub async fn resume(ctx: Context<'_>) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + + let queue = handler.queue(); + queue.resume()?; + + ctx.say("Resumed playing").await?; + + Ok(()) +} diff --git a/src/discord/commands/skip.rs b/src/discord/commands/skip.rs new file mode 100644 index 0000000..24f4db5 --- /dev/null +++ b/src/discord/commands/skip.rs @@ -0,0 +1,25 @@ +use anyhow::{anyhow, Result}; +use poise::command; + +use crate::discord::{common::get_call, Context}; + +/// Skip song(s) +#[command(slash_command, prefix_command)] +pub async fn skip( + ctx: Context<'_>, + #[description = "Number of songs to skip"] n: Option, +) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + let n = n.unwrap_or(1); + + let queue = handler.queue(); + for _ in 0..(n - 1) { + queue.dequeue(1).ok_or_else(|| anyhow!("Song not found"))?; + } + queue.skip()?; + + ctx.say(&format!("{n} song(s) skipped")).await?; + + Ok(()) +} diff --git a/src/discord/commands/song.rs b/src/discord/commands/song.rs new file mode 100644 index 0000000..18c88c3 --- /dev/null +++ b/src/discord/commands/song.rs @@ -0,0 +1,40 @@ +use anyhow::{anyhow, Context as ErrContext, Result}; +use poise::command; +use sunk::search::{self, SearchPage}; + +use crate::discord::{common::queue_song, Context}; + +/// Search for a song, and queue it +#[command(slash_command, prefix_command)] +pub async fn song( + ctx: Context<'_>, + #[description = "What to search for"] query: String, +) -> Result<()> { + let music_client = &ctx.data().subsonic_client; + + let search_size = SearchPage::new().with_size(1); + let ignore = search::NONE; + + let result = music_client + .search(&query, ignore, ignore, search_size) + .await + .with_context(|| anyhow!("Could not search for song"))? + .songs; + + let song = result + .first() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("No song matching search found"))?; + + log::info!("Found song {song:?}"); + queue_song(ctx, &song, music_client).await?; + + let message = format!( + "Added **{} - {}** to the queue", + song.title, + song.artist.as_deref().unwrap_or_default(), + ); + ctx.say(message).await?; + + Ok(()) +} diff --git a/src/discord/commands/stop.rs b/src/discord/commands/stop.rs new file mode 100644 index 0000000..36e82bf --- /dev/null +++ b/src/discord/commands/stop.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use poise::command; + +use crate::discord::{common::get_call, Context}; + +/// Stop playing and clear queue +#[command(slash_command, prefix_command)] +pub async fn stop(ctx: Context<'_>) -> Result<()> { + let call = get_call(ctx).await?; + let handler = call.lock().await; + + let queue = handler.queue(); + queue.stop(); + + ctx.say("Stopped playing").await?; + + Ok(()) +} diff --git a/src/discord/common.rs b/src/discord/common.rs new file mode 100644 index 0000000..22132b9 --- /dev/null +++ b/src/discord/common.rs @@ -0,0 +1,92 @@ +use anyhow::{anyhow, Result}; +use serenity::all::{ChannelId, Guild}; +use songbird::{ + input::HttpRequest, + tracks::{Track, TrackHandle}, + Songbird, +}; +use sunk::{song::Song, Streamable}; +use tokio::sync::Mutex; + +use std::sync::Arc; + +use crate::handles::SubsonicSongHandle; + +pub struct Data { + pub subsonic_client: sunk::Client, +} +pub type Context<'a> = poise::Context<'a, Data, anyhow::Error>; + +pub async fn queue_song(ctx: Context<'_>, song: &Song, client: &sunk::Client) -> Result<()> { + let call = get_call(ctx).await?; + let mut handler = call.lock().await; + + let track = load_song(song, client).await?; + let track_handle = handler.enqueue(track).await; + let mut type_map = track_handle.typemap().write().await; + type_map.insert::(song.clone()); + + Ok(()) +} + +pub fn get_guild(ctx: Context<'_>) -> Guild { + let guild = ctx.guild().expect("No guild!").clone(); + log::info!("Got guild: {guild:?}"); + guild +} + +pub fn get_channel(ctx: Context<'_>) -> Result { + let guild = get_guild(ctx); + let voice_state = guild + .voice_states + .get(&ctx.author().id) + .ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?; + let channel = voice_state + .channel_id + .ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?; + + Ok(channel) +} + +pub async fn get_call(ctx: Context<'_>) -> Result>> { + let manager = get_manager(ctx).await?; + let guild = get_guild(ctx); + let call = manager.get(guild.id); + + if let Some(c) = call { + Ok(c) + } else { + let channel = get_channel(ctx)?; + log::warn!("Not in a voice channel, trying to join {channel}"); + let _handler = manager.join(guild.id, channel).await; + manager + .get(guild.id) + .ok_or_else(|| anyhow!("Not in a voice channel, try running 'join'")) + } +} + +pub async fn get_song(track: &TrackHandle) -> Result { + let song = track + .typemap() + .read() + .await + .get::() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("Sound information not found"))?; + Ok(song) +} + +pub async fn get_manager(ctx: Context<'_>) -> Result> { + let manager = songbird::get(ctx.discord()) + .await + .ok_or_else(|| anyhow!("Couldn't start manager"))?; + log::info!("Got manager: {manager:?}"); + Ok(manager) +} + +pub async fn load_song(song: &Song, client: &sunk::Client) -> Result { + log::info!("Loading song {song:?}"); + let url = song.stream_url(client)?; + let track: Track = HttpRequest::new(client.reqclient.clone(), url).into(); + Ok(track) +} diff --git a/src/discord/handler.rs b/src/discord/handler.rs new file mode 100644 index 0000000..c5aac67 --- /dev/null +++ b/src/discord/handler.rs @@ -0,0 +1,14 @@ +use serenity::{ + all::Ready, + async_trait, + prelude::{Context, EventHandler}, +}; + +pub struct Handler; + +#[async_trait] +impl EventHandler for Handler { + async fn ready(&self, _: Context, ready: Ready) { + log::info!("{} is connected!", ready.user.name); + } +} diff --git a/src/discord/mod.rs b/src/discord/mod.rs new file mode 100644 index 0000000..c3ab224 --- /dev/null +++ b/src/discord/mod.rs @@ -0,0 +1,8 @@ +pub mod client; +pub mod commands; +pub mod common; +pub mod handler; + +pub use client::create_client; +pub use common::{Context, Data}; +pub use handler::Handler;