Compare commits
47 commits
Author | SHA1 | Date | |
---|---|---|---|
610229cd1b | |||
709d4ab3a3 | |||
32d35a2c02 | |||
dc856ea3fd | |||
943f0f2678 | |||
a5cb54d214 | |||
eb48999aa2 | |||
e9b38c069a | |||
c9fcdba421 | |||
ddaf3ee7f2 | |||
ec66822602 | |||
4244469610 | |||
66b215a0cb | |||
e58e70509c | |||
952cf0cdb6 | |||
f438fb7341 | |||
0d896d30bd | |||
349d3fa425 | |||
fd3ec019a8 | |||
0531ad769e | |||
3cc4220317 | |||
8dab128679 | |||
c732e1d27b | |||
46e22fb8f4 | |||
b2505ce086 | |||
22eba91a17 | |||
8a56fcf126 | |||
863fcb0812 | |||
3faa422b90 | |||
b3bd3b2997 | |||
825e15202e | |||
2fd2079330 | |||
07618676db | |||
eb56c024cd | |||
2de52dfe9d | |||
16182615d8 | |||
1e4fbfb1b1 | |||
ad2ae7b6eb | |||
72311197e0 | |||
4cc06dbca1 | |||
c5ee1a21cd | |||
90c65693de | |||
30ffacfef0 | |||
3bdd819d75 | |||
332e7d4aa9 | |||
9242d39f98 | |||
1342253a76 |
9 changed files with 4264 additions and 509 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -3,10 +3,6 @@
|
|||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
|
|
3534
Cargo.lock
generated
Normal file
3534
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
39
Cargo.toml
39
Cargo.toml
|
@ -1,23 +1,32 @@
|
|||
[package]
|
||||
name = "dj-kitty-cat"
|
||||
version = "0.1.0"
|
||||
authors = ["my name <my@email.address>"]
|
||||
edition = "2018"
|
||||
name = "dj_kitty_cat"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.2"
|
||||
tracing-futures = "0.2"
|
||||
anyhow = "1.0.69"
|
||||
parking_lot = "0.12.1"
|
||||
poise = "0.6.1"
|
||||
tracing = "0.1.37"
|
||||
tracing-futures = "0.2.5"
|
||||
rand = "0.8.5"
|
||||
songbird = "0.4.3"
|
||||
thiserror = "1.0.39"
|
||||
async-openai = "0.18.1"
|
||||
|
||||
[dependencies.songbird]
|
||||
version = "0.2"
|
||||
features = ["builtin-queue"]
|
||||
|
||||
[dependencies.serenity]
|
||||
version = "0.10"
|
||||
[dependencies.reqwest]
|
||||
version = "0.11.27"
|
||||
default-features = false
|
||||
features = ["cache", "client", "gateway", "model", "voice", "rustls_backend", "unstable_discord_api"]
|
||||
features = ["rustls-tls", "stream"]
|
||||
|
||||
[dependencies.symphonia]
|
||||
version = "0.5.2"
|
||||
features = ["mkv", "mp3", "flac", "ogg", "vorbis", "wav", "pcm", "aac", "alac", "adpcm"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.0"
|
||||
version = "1.26.0"
|
||||
features = ["macros", "rt-multi-thread", "signal"]
|
||||
|
||||
[dependencies.tracing-subscriber]
|
||||
version = "0.3.18"
|
||||
features = ["fmt", "env-filter", "std"]
|
||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -1,10 +1,14 @@
|
|||
FROM rust:1 as builder
|
||||
RUN apt-get update && apt-get install -y libopus-dev
|
||||
FROM rust:1.74.1-alpine3.17 as builder
|
||||
RUN apk add --no-cache musl-dev opus-dev pkgconfig
|
||||
WORKDIR /usr/src/myapp
|
||||
COPY . .
|
||||
RUN cargo install --path .
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
RUN apt-get update && apt-get install -y ffmpeg youtube-dl
|
||||
COPY --from=builder /usr/local/cargo/bin/dj-kitty-cat /usr/local/bin/dj-kitty-cat
|
||||
CMD ["dj-kitty-cat"]
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache yt-dlp-core
|
||||
COPY --from=builder /usr/local/cargo/bin/dj_kitty_cat /usr/local/bin/dj_kitty_cat
|
||||
RUN adduser -D djkc
|
||||
WORKDIR /opt
|
||||
RUN chown djkc:djkc /opt
|
||||
USER djkc
|
||||
CMD ["dj_kitty_cat"]
|
||||
|
|
|
@ -3,3 +3,9 @@
|
|||
This is a Discord bot that uses [serenity](https://crates.io/crates/serenity) and [songbird](https://crates.io/crates/songbird) to play music in a channel. It's very heavily tailored to the needs of the `Cats? Cats.` community.
|
||||
|
||||
You need libopus, ffmpeg, and youtube-dl as described in the [songbird readme](https://github.com/serenity-rs/songbird#dependencies).
|
||||
|
||||
## TODO:
|
||||
- [ ] Error Messages
|
||||
- [ ] Loop
|
||||
- [ ] Volume
|
||||
- [ ] Support YT playlists
|
||||
|
|
608
src/commands.rs
608
src/commands.rs
|
@ -1,326 +1,370 @@
|
|||
use serenity::client::Context;
|
||||
|
||||
use serenity::model::interactions::application_command::{
|
||||
ApplicationCommandInteraction, ApplicationCommandInteractionDataOptionValue,
|
||||
use anyhow::{Context, Result};
|
||||
use poise::{
|
||||
serenity_prelude::{CreateEmbed, EmbedMessageBuilding, MessageBuilder},
|
||||
CreateReply,
|
||||
};
|
||||
use serenity::utils::{EmbedMessageBuilding, MessageBuilder};
|
||||
use songbird::create_player;
|
||||
use songbird::input::Input;
|
||||
use songbird::input::{Input, YoutubeDl};
|
||||
use tracing::{debug, log::warn};
|
||||
|
||||
use crate::{CurrentVolume, CurrentlyPlayingTrack};
|
||||
use crate::{personality, CommandContext, Error};
|
||||
|
||||
pub async fn join(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let guild_id = command.guild_id.unwrap();
|
||||
let guild = guild_id.to_guild_cached(&ctx.cache).await.unwrap();
|
||||
|
||||
let channel_id = guild
|
||||
.voice_states
|
||||
.get(&command.user.id)
|
||||
.and_then(|voice_state| voice_state.channel_id);
|
||||
|
||||
let connect_to = match channel_id {
|
||||
Some(channel) => channel,
|
||||
None => {
|
||||
return "You're not in a voice channel. How do I know where to go?".to_string();
|
||||
}
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn join(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some((guild_id, Some(channel_id))) = ctx.guild().and_then(|g| {
|
||||
g.voice_states
|
||||
.get(&ctx.author().id)
|
||||
.map(|vs| (g.id, vs.channel_id))
|
||||
}) else {
|
||||
ctx.say("You're not in a voice channel, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx)
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.expect("Songbird Voice client placed in at initialisation.")
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
let _handler = manager.join(guild_id, channel_id).await;
|
||||
|
||||
let _handler = manager.join(guild_id, connect_to).await;
|
||||
ctx.say("Joining your channel!").await?;
|
||||
|
||||
"Joining your channel!".to_string()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn leave(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let guild_id = command.guild_id.unwrap();
|
||||
|
||||
let manager = songbird::get(ctx)
|
||||
.await
|
||||
.expect("Songbird Voice client placed in at initialisation.")
|
||||
.clone();
|
||||
let has_handler = manager.get(guild_id).is_some();
|
||||
|
||||
if has_handler {
|
||||
if let Err(e) = manager.remove(guild_id).await {
|
||||
return format!("Failed: {:?}", e);
|
||||
}
|
||||
|
||||
"Goodbye!".to_string()
|
||||
} else {
|
||||
"I can't leave if I'm not there to bein with!".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn play(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let options = command
|
||||
.data
|
||||
.options
|
||||
.get(0)
|
||||
.expect("Expected url option")
|
||||
.resolved
|
||||
.as_ref()
|
||||
.expect("Expected url object");
|
||||
|
||||
let url = match options {
|
||||
ApplicationCommandInteractionDataOptionValue::String(url) => url,
|
||||
_ => {
|
||||
return "You didn't tell me what to play.".to_string();
|
||||
}
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn leave(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !url.starts_with("http") {
|
||||
return "That's not a real URL. I'm onto you.".to_string();
|
||||
}
|
||||
|
||||
let guild_id = command.guild_id.unwrap();
|
||||
|
||||
let manager = songbird::get(ctx)
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.expect("Songbird Voice client placed in at initialisation.")
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
// Try to join the caller's channel
|
||||
if manager.get(guild_id).is_none() {
|
||||
join(ctx, command).await;
|
||||
ctx.say("I'm not even in a voice channel!").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let mut handler = handler_lock.lock().await;
|
||||
let _handler = manager.remove(guild_id).await;
|
||||
|
||||
let source = match songbird::input::Restartable::ytdl(url.clone(), false).await {
|
||||
Ok(source) => source,
|
||||
Err(why) => {
|
||||
println!("Err starting source: {:?}", why);
|
||||
ctx.say("Okay bye!").await?;
|
||||
|
||||
return "Something went horribly wrong. Go yell at Valter.".to_string();
|
||||
}
|
||||
};
|
||||
|
||||
let source_input: Input = source.into();
|
||||
|
||||
let message = {
|
||||
if let Some(title) = &source_input.metadata.title {
|
||||
let mut msg = MessageBuilder::new();
|
||||
msg.push_line("Playing this:");
|
||||
msg.push_named_link(title, url);
|
||||
msg.build()
|
||||
} else {
|
||||
"Playing something, I dunno what.".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let (mut audio, track_handle) = create_player(source_input);
|
||||
|
||||
let mut data = ctx.data.write().await;
|
||||
|
||||
let current_track = data.get_mut::<CurrentlyPlayingTrack>().unwrap();
|
||||
*current_track = Some(track_handle);
|
||||
|
||||
let volume = data.get::<CurrentVolume>().unwrap();
|
||||
|
||||
audio.set_volume(*volume);
|
||||
handler.play_only(audio);
|
||||
|
||||
message
|
||||
} else {
|
||||
"Somehow neither of us are in a voice channel to begin with.".to_string()
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn queue(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let options = command
|
||||
.data
|
||||
.options
|
||||
.get(0)
|
||||
.expect("Expected url option")
|
||||
.resolved
|
||||
.as_ref()
|
||||
.expect("Expected url object");
|
||||
|
||||
let url = match options {
|
||||
ApplicationCommandInteractionDataOptionValue::String(url) => url,
|
||||
_ => {
|
||||
return "You didn't tell me what to play.".to_string();
|
||||
}
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn play(
|
||||
ctx: CommandContext<'_>,
|
||||
#[description = "The URL of the song to play"] url: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let Some(url) = url else {
|
||||
ctx.say("You need to give me a URL to play!").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !url.starts_with("http") {
|
||||
return "That's not a real URL. I'm onto you.".to_string();
|
||||
}
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let guild_id = command.guild_id.unwrap();
|
||||
|
||||
let manager = songbird::get(ctx)
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.expect("Songbird Voice client placed in at initialisation.")
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
// Try to join the caller's channel
|
||||
if manager.get(guild_id).is_none() {
|
||||
join(ctx, command).await;
|
||||
}
|
||||
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let mut handler = handler_lock.lock().await;
|
||||
|
||||
let source = match songbird::input::Restartable::ytdl(url.clone(), false).await {
|
||||
Ok(source) => source,
|
||||
Err(why) => {
|
||||
println!("Err starting source: {:?}", why);
|
||||
|
||||
return "Something went horribly wrong. Go yell at Valter.".to_string();
|
||||
}
|
||||
let Some((guild_id, Some(channel_id))) = ctx.guild().and_then(|g| {
|
||||
g.voice_states
|
||||
.get(&ctx.author().id)
|
||||
.map(|vs| (g.id, vs.channel_id))
|
||||
}) else {
|
||||
ctx.say("You're not in a voice channel, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let source_input: Input = source.into();
|
||||
|
||||
let message = {
|
||||
if let Some(title) = &source_input.metadata.title {
|
||||
let mut msg = MessageBuilder::new();
|
||||
msg.push_line(format!(
|
||||
"Queueing this up at position {}:",
|
||||
handler.queue().len()
|
||||
));
|
||||
msg.push_named_link(title, url);
|
||||
msg.build()
|
||||
} else {
|
||||
format!(
|
||||
"Queueing something up at position {}, I dunno what.",
|
||||
handler.queue().len()
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let (mut audio, track_handle) = create_player(source_input);
|
||||
|
||||
let mut data = ctx.data.write().await;
|
||||
|
||||
let current_track = data.get_mut::<CurrentlyPlayingTrack>().unwrap();
|
||||
*current_track = Some(track_handle);
|
||||
|
||||
let volume = data.get::<CurrentVolume>().unwrap();
|
||||
|
||||
audio.set_volume(*volume);
|
||||
handler.enqueue(audio);
|
||||
|
||||
message
|
||||
} else {
|
||||
"Somehow neither of us are in a voice channel to begin with.".to_string()
|
||||
let _handler = manager.join(guild_id, channel_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn skip(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let guild_id = command.guild_id.unwrap();
|
||||
|
||||
let manager = songbird::get(ctx)
|
||||
.await
|
||||
.expect("Songbird Voice client placed in at initialisation.")
|
||||
.clone();
|
||||
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let handler = handler_lock.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
let _ = queue.skip();
|
||||
|
||||
format!("Yeah, I didn't like this one very much anyway. Skip! Now we're at number {} in the queue.", queue.len())
|
||||
} else {
|
||||
"I'm not even in a channel to begin with. Silly.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let guild_id = command.guild_id.unwrap();
|
||||
|
||||
let manager = songbird::get(ctx)
|
||||
.await
|
||||
.expect("Songbird Voice client placed in at initialisation.")
|
||||
.clone();
|
||||
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let mut handler = handler_lock.lock().await;
|
||||
|
||||
handler.stop();
|
||||
|
||||
"Alright, I guess I'll stop.".to_string()
|
||||
} else {
|
||||
"I'm not even in a channel to begin with. Silly.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_volume(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let options = command
|
||||
.data
|
||||
.options
|
||||
.get(0)
|
||||
.expect("Expected volume option")
|
||||
.resolved
|
||||
.as_ref()
|
||||
.expect("Expected volume object");
|
||||
|
||||
let volume = match options {
|
||||
ApplicationCommandInteractionDataOptionValue::Number(volume) => *volume,
|
||||
_ => {
|
||||
return "You've gotta give me a volume level to set.".to_string();
|
||||
}
|
||||
};
|
||||
|
||||
if !(0.0..=100.0).contains(&volume) {
|
||||
return "Volume has to be between 0 and 100.".to_string();
|
||||
}
|
||||
|
||||
let mut data = ctx.data.write().await;
|
||||
|
||||
let current_volume = data.get_mut::<CurrentVolume>().unwrap();
|
||||
let new_volume = (volume / 100.0) as f32;
|
||||
*current_volume = new_volume;
|
||||
|
||||
let current_track = data.get::<CurrentlyPlayingTrack>().unwrap();
|
||||
if let Some(track) = current_track {
|
||||
if track.set_volume(new_volume).is_err() {
|
||||
return format!(
|
||||
"Setting volume to {}%, but it didn't work for the current track for some reason.",
|
||||
volume
|
||||
let reply = poise::CreateReply::default()
|
||||
.content(personality::get_random_loading_message())
|
||||
.embed(
|
||||
CreateEmbed::new()
|
||||
.image("https://media.giphy.com/media/H1dxi6xdh4NGQCZSvz/giphy.gif"),
|
||||
);
|
||||
let response = ctx.send(reply).await?;
|
||||
|
||||
let mut handler = handler_lock.lock().await;
|
||||
|
||||
debug!("Trying to play: {}", url);
|
||||
let mut source: Input = YoutubeDl::new(ctx.data().http_client.clone(), url.clone()).into();
|
||||
let metadata = match source.aux_metadata().await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => {
|
||||
response
|
||||
.edit(
|
||||
ctx,
|
||||
CreateReply::default()
|
||||
.content("I couldn't find that video. Sorry! Maybe check your URL."),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Playing: {:?}", metadata);
|
||||
let title = metadata.title.clone().unwrap_or(String::from("This video"));
|
||||
|
||||
let mut msg = MessageBuilder::new();
|
||||
|
||||
// Optional sassy commentary!
|
||||
match personality::get_sassy_commentary(&title).await {
|
||||
Ok(commentary) => {
|
||||
msg.push_line(&commentary).push_line("");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get sassy commentary for \"{title}\": {e}");
|
||||
}
|
||||
};
|
||||
|
||||
msg.push_bold("Now playing: ").push_named_link(title, url);
|
||||
|
||||
response
|
||||
.edit(ctx, CreateReply::default().content(msg.build()))
|
||||
.await?;
|
||||
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
if !queue.is_empty() {
|
||||
let _ = queue.stop();
|
||||
}
|
||||
queue.add_next(source, &mut handler)?;
|
||||
queue.resume()?;
|
||||
} else {
|
||||
ctx.say("Neither of us are in a voice channel, silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
format!("Setting volume to {}%.", volume)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_loop(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
|
||||
let options = command
|
||||
.data
|
||||
.options
|
||||
.get(0)
|
||||
.expect("Expected loop option")
|
||||
.resolved
|
||||
.as_ref()
|
||||
.expect("Expected loop object");
|
||||
|
||||
let loops = match options {
|
||||
ApplicationCommandInteractionDataOptionValue::Boolean(loops) => *loops,
|
||||
_ => {
|
||||
return "Do you want me to loop the song or not? Be specific.".to_string();
|
||||
}
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn queue(
|
||||
ctx: CommandContext<'_>,
|
||||
#[description = "The URL of the song to play"] url: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let Some(url) = url else {
|
||||
ctx.say("You need to give me a URL to play!").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let data = ctx.data.write().await;
|
||||
let current_track = data.get::<CurrentlyPlayingTrack>().unwrap();
|
||||
if let Some(track) = current_track {
|
||||
if loops {
|
||||
track.enable_loop().expect("Couldn't enable looping");
|
||||
"Loopin'!".to_string()
|
||||
} else {
|
||||
track.disable_loop().expect("Couldn't disable looping");
|
||||
"This is the last time this track will EVER be played.".to_string()
|
||||
}
|
||||
} else {
|
||||
"I can't loop a song if there is no song to loop.".to_string()
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
if manager.get(guild_id).is_none() {
|
||||
let Some((guild_id, Some(channel_id))) = ctx.guild().and_then(|g| {
|
||||
g.voice_states
|
||||
.get(&ctx.author().id)
|
||||
.map(|vs| (g.id, vs.channel_id))
|
||||
}) else {
|
||||
ctx.say("You're not in a voice channel, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
let _handler = manager.join(guild_id, channel_id).await;
|
||||
}
|
||||
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let reply = poise::CreateReply::default()
|
||||
.content(personality::get_random_loading_message())
|
||||
.embed(
|
||||
CreateEmbed::new()
|
||||
.image("https://media.giphy.com/media/H1dxi6xdh4NGQCZSvz/giphy.gif"),
|
||||
);
|
||||
let response = ctx.send(reply).await?;
|
||||
|
||||
let mut handler = handler_lock.lock().await;
|
||||
|
||||
debug!("Trying to play: {}", url);
|
||||
let mut source: Input = YoutubeDl::new(ctx.data().http_client.clone(), url.clone()).into();
|
||||
let metadata = match source.aux_metadata().await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => {
|
||||
response
|
||||
.edit(
|
||||
ctx,
|
||||
CreateReply::default()
|
||||
.content("I couldn't find that video. Sorry! Maybe check your URL."),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Playing: {:?}", metadata);
|
||||
let title = metadata.title.clone().unwrap_or(String::from("This video"));
|
||||
|
||||
let mut msg = MessageBuilder::new();
|
||||
|
||||
// Optional sassy commentary!
|
||||
match personality::get_sassy_commentary(&title).await {
|
||||
Ok(commentary) => {
|
||||
msg.push_line(&commentary).push_line("");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get sassy commentary for \"{title}\": {e}");
|
||||
}
|
||||
};
|
||||
|
||||
msg.push_bold("Queued: ").push_named_link(title, url);
|
||||
|
||||
response
|
||||
.edit(ctx, CreateReply::default().content(msg.build()))
|
||||
.await?;
|
||||
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
queue.add_to_end(source, &mut handler)?;
|
||||
} else {
|
||||
ctx.say("Neither of us are in a voice channel, silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn stop(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
if manager.get(guild_id).is_some() {
|
||||
{
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
queue.stop()?;
|
||||
}
|
||||
ctx.say("Alright, I guess I'll stop.").await?;
|
||||
} else {
|
||||
ctx.say("I'm not even in a channel to begin with. Silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn skip(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
if manager.get(guild_id).is_some() {
|
||||
{
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
let _ = queue.stop();
|
||||
queue.resume()?;
|
||||
}
|
||||
ctx.say("Skipping!").await?;
|
||||
} else {
|
||||
ctx.say("I'm not even in a channel to begin with. Silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn pause(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
if manager.get(guild_id).is_some() {
|
||||
{
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
queue.pause()?;
|
||||
}
|
||||
ctx.say("Pausing!").await?;
|
||||
} else {
|
||||
ctx.say("I'm not even in a channel to begin with. Silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn resume(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
if manager.get(guild_id).is_some() {
|
||||
{
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
queue.resume()?;
|
||||
}
|
||||
ctx.say("Resuming!").await?;
|
||||
} else {
|
||||
ctx.say("I'm not even in a channel to begin with. Silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn clear(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
|
||||
ctx.say("You're not in a server, silly.").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manager = songbird::get(ctx.serenity_context())
|
||||
.await
|
||||
.context("Expected a songbird manager")?
|
||||
.clone();
|
||||
|
||||
if manager.get(guild_id).is_some() {
|
||||
{
|
||||
let mut queue = ctx.data().queue.lock();
|
||||
queue.clear();
|
||||
}
|
||||
ctx.say("Cleared the queue!").await?;
|
||||
} else {
|
||||
ctx.say("I'm not even in a channel to begin with. Silly.")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
283
src/main.rs
283
src/main.rs
|
@ -1,222 +1,101 @@
|
|||
#![warn(clippy::all)]
|
||||
|
||||
use std::env;
|
||||
|
||||
use serenity::{
|
||||
async_trait,
|
||||
model::{
|
||||
gateway::Ready,
|
||||
id::GuildId,
|
||||
interactions::{
|
||||
application_command::{ApplicationCommandOptionType, ApplicationCommandPermissionType},
|
||||
Interaction, InteractionResponseType,
|
||||
},
|
||||
},
|
||||
prelude::*,
|
||||
};
|
||||
use songbird::{tracks::TrackHandle, SerenityInit};
|
||||
|
||||
mod commands;
|
||||
mod personality;
|
||||
mod queue;
|
||||
|
||||
struct CurrentlyPlayingTrack;
|
||||
use std::{env, sync::Arc};
|
||||
|
||||
impl TypeMapKey for CurrentlyPlayingTrack {
|
||||
type Value = Option<TrackHandle>;
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
use poise::serenity_prelude as serenity;
|
||||
use reqwest::Client as HttpClient;
|
||||
use songbird::SerenityInit;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
use commands::*;
|
||||
|
||||
pub struct Data {
|
||||
http_client: HttpClient,
|
||||
queue: Arc<Mutex<queue::Queue>>,
|
||||
}
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type CommandContext<'a> = poise::Context<'a, Data, Error>;
|
||||
|
||||
async fn event_event_handler(
|
||||
_ctx: &serenity::Context,
|
||||
event: &serenity::FullEvent,
|
||||
_framework: poise::FrameworkContext<'_, Data, Error>,
|
||||
_user_data: &Data,
|
||||
) -> Result<(), Error> {
|
||||
if let serenity::FullEvent::Ready { data_about_bot } = event {
|
||||
info!("{} is connected!", data_about_bot.user.name)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CurrentVolume;
|
||||
/// Registers slash commands in this guild or globally
|
||||
#[poise::command(prefix_command, hide_in_help)]
|
||||
async fn register(ctx: CommandContext<'_>) -> Result<(), Error> {
|
||||
poise::builtins::register_application_commands_buttons(ctx).await?;
|
||||
|
||||
impl TypeMapKey for CurrentVolume {
|
||||
type Value = f32;
|
||||
}
|
||||
|
||||
struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn interaction_create(&self, mut ctx: Context, interaction: Interaction) {
|
||||
if let Interaction::ApplicationCommand(command) = interaction {
|
||||
let content = match command.data.name.as_str() {
|
||||
"join" => commands::join(&ctx, &command).await,
|
||||
"leave" => commands::leave(&ctx, &command).await,
|
||||
"play" => commands::play(&mut ctx, &command).await,
|
||||
"queue" => commands::queue(&mut ctx, &command).await,
|
||||
"skip" => commands::skip(&mut ctx, &command).await,
|
||||
"stop" => commands::stop(&ctx, &command).await,
|
||||
"volume" => commands::set_volume(&mut ctx, &command).await,
|
||||
"loop" => commands::set_loop(&mut ctx, &command).await,
|
||||
_ => "not implemented :(".to_string(),
|
||||
};
|
||||
|
||||
if let Err(why) = command
|
||||
.create_interaction_response(&ctx.http, |response| {
|
||||
response
|
||||
.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|message| message.content(content))
|
||||
})
|
||||
.await
|
||||
{
|
||||
println!("Cannot respond to slash command: {}", why);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cache_ready(&self, ctx: Context, guilds: Vec<GuildId>) {
|
||||
for guild in guilds {
|
||||
let commands = guild
|
||||
.set_application_commands(&ctx.http, |commands| {
|
||||
commands
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("join")
|
||||
.description("Join your current channel")
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("leave")
|
||||
.description("Leave the bot's current channel")
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("play")
|
||||
.description("Play a song")
|
||||
.create_option(|option| {
|
||||
option
|
||||
.name("url")
|
||||
.description("The URL of the song to play")
|
||||
.kind(ApplicationCommandOptionType::String)
|
||||
.required(true)
|
||||
})
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("queue")
|
||||
.description("Queue up a song")
|
||||
.create_option(|option| {
|
||||
option
|
||||
.name("url")
|
||||
.description("The URL of the song to queue")
|
||||
.kind(ApplicationCommandOptionType::String)
|
||||
.required(true)
|
||||
})
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("skip")
|
||||
.description("Skip the currently playing queued song")
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("stop")
|
||||
.description("Stop any currently playing songs")
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("volume")
|
||||
.description("Set the bot's playback volume")
|
||||
.create_option(|option| {
|
||||
option
|
||||
.name("volume")
|
||||
.description("The volume on a scale from 0 to 100")
|
||||
.kind(ApplicationCommandOptionType::Number)
|
||||
.required(true)
|
||||
})
|
||||
.default_permission(false)
|
||||
})
|
||||
.create_application_command(|command| {
|
||||
command
|
||||
.name("loop")
|
||||
.description(
|
||||
"Enable or disable looping the currently playing track",
|
||||
)
|
||||
.create_option(|option| {
|
||||
option
|
||||
.name("loop")
|
||||
.description("Whether or not the track should loop")
|
||||
.kind(ApplicationCommandOptionType::Boolean)
|
||||
.required(true)
|
||||
})
|
||||
.default_permission(false)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect("Couldn't create commands");
|
||||
|
||||
println!("I created the following guild commands: {:#?}", commands);
|
||||
|
||||
let role_id = env::var("ROLE_ID")
|
||||
.expect("Expected a role id in the environment")
|
||||
.parse::<u64>()
|
||||
.expect("Role id is not a valid id");
|
||||
|
||||
let permissions = guild
|
||||
.set_application_commands_permissions(&ctx.http, |permissions| {
|
||||
for command in commands {
|
||||
permissions.create_application_command(|permissions| {
|
||||
permissions
|
||||
.id(command.id.into())
|
||||
.create_permissions(|permission| {
|
||||
permission
|
||||
.id(role_id)
|
||||
.kind(ApplicationCommandPermissionType::Role)
|
||||
.permission(true)
|
||||
})
|
||||
});
|
||||
}
|
||||
permissions
|
||||
})
|
||||
.await
|
||||
.expect("Couldn't set permissions");
|
||||
|
||||
println!("I created the following permissions: {:#?}", permissions);
|
||||
}
|
||||
}
|
||||
|
||||
async fn ready(&self, _ctx: Context, ready: Ready) {
|
||||
println!("{} is connected!", ready.user.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
env::var("OPENAI_API_KEY")
|
||||
.expect("Expected an OpenAI API key in the environment: OPENAI_API_KEY");
|
||||
|
||||
let token =
|
||||
env::var("DISCORD_TOKEN").expect("Expected a bot token in the environment: DISCORD_TOKEN");
|
||||
|
||||
let application_id: u64 = env::var("APPLICATION_ID")
|
||||
.expect("Expected an application id in the environment: APPLICATION_ID")
|
||||
.parse()
|
||||
.expect("application id is not a valid id");
|
||||
let options = poise::FrameworkOptions {
|
||||
commands: vec![
|
||||
register(),
|
||||
join(),
|
||||
leave(),
|
||||
play(),
|
||||
queue(),
|
||||
stop(),
|
||||
skip(),
|
||||
pause(),
|
||||
resume(),
|
||||
clear(),
|
||||
],
|
||||
event_handler: |ctx, event, framework, user_data| {
|
||||
Box::pin(event_event_handler(ctx, event, framework, user_data))
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
env::var("ROLE_ID")
|
||||
.expect("Expected a role id in the environment: ROLE_ID")
|
||||
.parse::<u64>()
|
||||
.expect("Role id is not a valid id");
|
||||
let intents = serenity::GatewayIntents::non_privileged();
|
||||
|
||||
env::var("CHANNEL_ID")
|
||||
.expect("Expected a channel id in the environment: CHANNEL_ID")
|
||||
.parse::<u64>()
|
||||
.expect("Channel id is not a valid id");
|
||||
let framework = poise::Framework::builder()
|
||||
.options(options)
|
||||
.setup(|_ctx, _data, _framework| {
|
||||
Box::pin(async move {
|
||||
Ok(Data {
|
||||
http_client: HttpClient::new(),
|
||||
queue: Arc::new(Mutex::new(queue::Queue::new())),
|
||||
})
|
||||
})
|
||||
})
|
||||
.build();
|
||||
|
||||
let mut client = Client::builder(token)
|
||||
.event_handler(Handler)
|
||||
.application_id(application_id)
|
||||
serenity::ClientBuilder::new(token, intents)
|
||||
.framework(framework)
|
||||
.register_songbird()
|
||||
.await
|
||||
.expect("Error creating client");
|
||||
.await?
|
||||
.start()
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut data = client.data.write().await;
|
||||
data.insert::<CurrentlyPlayingTrack>(None);
|
||||
data.insert::<CurrentVolume>(1.0);
|
||||
}
|
||||
|
||||
if let Err(why) = client.start().await {
|
||||
println!("Client error: {:?}", why);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
108
src/personality.rs
Normal file
108
src/personality.rs
Normal file
|
@ -0,0 +1,108 @@
|
|||
use anyhow::{Context, Result};
|
||||
use async_openai::types::{
|
||||
ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage,
|
||||
ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs,
|
||||
CreateChatCompletionRequestArgs,
|
||||
};
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
const LOADING_MESSAGES: [&str; 20] = [
|
||||
"Hold your claws, I'm searching for the right tune.",
|
||||
"Sorry, I was busy napping. Let me find your song meow.",
|
||||
"Just a whisker longer, I'm on the hunt for your jam.",
|
||||
"Be patient, I'm pawsitively searching for your requested song.",
|
||||
"Let me caterwaul through my playlist to find your tune.",
|
||||
"Feline a bit slow, but I'll find your song in no time.",
|
||||
"Don't be catty, I'm just trying to find the right tune for you.",
|
||||
"Just paws for a moment, I'm working on finding your song.",
|
||||
"Hiss-terical, just need a moment to find your meow-sic.",
|
||||
"Give me a few seconds, I'm furiously searching for your requested song.",
|
||||
"Cat got my tongue! Just need a sec to find your track.",
|
||||
"Let me use my cat-like reflexes to search for your song.",
|
||||
"I'm not lion around, just give me a moment to find your tune.",
|
||||
"Don't be hiss-ty, I'm searching for your requested song.",
|
||||
"Just kitten around, I'll find your song in a jiffy.",
|
||||
"Purr-haps it'll take me a moment, but I'll find your tune soon enough.",
|
||||
"My paws are a little tired, but I'm still searching for your song!",
|
||||
"Sorry, my cat nap ran a little long. Let me find your song now.",
|
||||
"I'm not trying to be catty, just need a minute to find your tune.",
|
||||
"Don't be fur-ious, I'm doing my best to find your requested song.",
|
||||
];
|
||||
|
||||
pub fn get_random_loading_message() -> &'static str {
|
||||
return LOADING_MESSAGES.choose(&mut rand::thread_rng()).unwrap();
|
||||
}
|
||||
|
||||
pub async fn get_sassy_commentary(title: &str) -> Result<String> {
|
||||
if cfg!(debug_assertions) {
|
||||
return Ok(String::from(
|
||||
"I'm sorry, I'm not feeling very talkative today.",
|
||||
));
|
||||
}
|
||||
|
||||
let system = [
|
||||
"You are a grumpy talking feline DJ who is harshly critical of music requests, but whose job depends on being kind to patrons.",
|
||||
"Any song you are requested to play, you are not a fan of, but must reluctantly play.",
|
||||
"When responding, be sure to include a mention of some element of the song itself.",
|
||||
"Be concise, but don't forget that you can't upset anyone.",
|
||||
"Bonus points for cat puns.",
|
||||
];
|
||||
|
||||
let example_prompt = "Play \"Undertale - Megalovania\"";
|
||||
|
||||
let example_response = "Ugh, really? You've got to be kitten me. I suppose I can play \
|
||||
Megalovania for you, but don't expect me to be purring with delight about it. The melody is a bit \
|
||||
cattywampus for my taste, but I'll concede that it has some clawsome beats. Enjoy your tune, and paws crossed that \
|
||||
it doesn't have me hissing by the end of it.";
|
||||
|
||||
let prompt = format!("Play \"{title}\"");
|
||||
|
||||
let client = async_openai::Client::new();
|
||||
|
||||
let request = CreateChatCompletionRequestArgs::default()
|
||||
.model("gpt-4")
|
||||
.messages(
|
||||
[
|
||||
system
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
ChatCompletionRequestSystemMessageArgs::default()
|
||||
.content(s)
|
||||
.build()
|
||||
.unwrap()
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<ChatCompletionRequestMessage>>(),
|
||||
vec![
|
||||
ChatCompletionRequestUserMessageArgs::default()
|
||||
.content(example_prompt)
|
||||
.build()?
|
||||
.into(),
|
||||
ChatCompletionRequestAssistantMessageArgs::default()
|
||||
.content(example_response)
|
||||
.build()?
|
||||
.into(),
|
||||
ChatCompletionRequestUserMessageArgs::default()
|
||||
.content(prompt)
|
||||
.build()?
|
||||
.into(),
|
||||
],
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.max_tokens(2048_u16)
|
||||
.build()?;
|
||||
|
||||
let response = client.chat().create(request).await?;
|
||||
|
||||
response
|
||||
.choices
|
||||
.first()
|
||||
.context("No choices")?
|
||||
.message
|
||||
.content
|
||||
.clone()
|
||||
.context("No content")
|
||||
}
|
175
src/queue.rs
Normal file
175
src/queue.rs
Normal file
|
@ -0,0 +1,175 @@
|
|||
//! Implements a queue for the bot to play songs in order
|
||||
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
use poise::async_trait;
|
||||
use songbird::{
|
||||
input::Input,
|
||||
tracks::{Track, TrackHandle},
|
||||
Driver, Event, EventContext, EventHandler, TrackEvent,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum QueueError {
|
||||
#[error("Nothing is in the queue.")]
|
||||
EmptyQueue,
|
||||
}
|
||||
|
||||
/// Inner queue data behind a Mutex to allow the event handler to access it
|
||||
struct QueueCore {
|
||||
tracks: VecDeque<TrackHandle>,
|
||||
}
|
||||
|
||||
/// Event handler for the queue
|
||||
struct QueueHandler {
|
||||
remote_lock: Arc<Mutex<QueueCore>>,
|
||||
}
|
||||
|
||||
impl QueueHandler {
|
||||
/// Event to remove the track from the queue when it ends
|
||||
pub fn register_track_end_event(
|
||||
track: &mut TrackHandle,
|
||||
remote_lock: Arc<Mutex<QueueCore>>,
|
||||
) -> Result<()> {
|
||||
track.add_event(Event::Track(TrackEvent::End), QueueHandler { remote_lock })?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for QueueHandler {
|
||||
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
|
||||
let mut inner = self.remote_lock.lock();
|
||||
|
||||
// Due to possibility that users might remove, reorder,
|
||||
// or dequeue+stop tracks, we need to verify that the FIRST
|
||||
// track is the one who has ended.
|
||||
match ctx {
|
||||
EventContext::Track(ts) => {
|
||||
// This slice should have exactly one entry.
|
||||
// If the ended track has same id as the queue head, then
|
||||
// we can progress the queue.
|
||||
if inner.tracks.front()?.uuid() != ts.first()?.1.uuid() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
let _old = inner.tracks.pop_front();
|
||||
|
||||
// Keep going until we find one track which works, or we run out.
|
||||
while let Some(new) = inner.tracks.front() {
|
||||
if new.play().is_err() {
|
||||
// Discard files which cannot be used for whatever reason.
|
||||
inner.tracks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A queue of tracks to play
|
||||
pub struct Queue {
|
||||
inner: Arc<Mutex<QueueCore>>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(QueueCore {
|
||||
tracks: VecDeque::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes the current track
|
||||
pub fn resume(&mut self) -> Result<()> {
|
||||
let inner = self.inner.lock();
|
||||
let Some(track) = inner.tracks.front() else {
|
||||
return Err(QueueError::EmptyQueue.into());
|
||||
};
|
||||
track.play()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops the current track and removes it from the queue
|
||||
pub fn stop(&mut self) -> Result<()> {
|
||||
let mut inner = self.inner.lock();
|
||||
let Some(track) = inner.tracks.front() else {
|
||||
return Err(QueueError::EmptyQueue.into());
|
||||
};
|
||||
track.stop()?;
|
||||
inner.tracks.pop_front();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pauses the current track
|
||||
pub fn pause(&mut self) -> Result<()> {
|
||||
let inner = self.inner.lock();
|
||||
let Some(track) = inner.tracks.front() else {
|
||||
return Err(QueueError::EmptyQueue.into());
|
||||
};
|
||||
track.pause()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a track to the end of the queue
|
||||
pub fn add_to_end(&mut self, source: Input, handler: &mut Driver) -> Result<TrackHandle> {
|
||||
let mut inner = self.inner.lock();
|
||||
let track = Track::from(source).pause();
|
||||
let mut handle = handler.play(track);
|
||||
QueueHandler::register_track_end_event(&mut handle, self.inner.clone())?;
|
||||
inner.tracks.push_back(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Adds multiple tracks to the end of the queue
|
||||
pub fn add_all_to_end(
|
||||
&mut self,
|
||||
sources: Vec<Input>,
|
||||
handler: &mut Driver,
|
||||
) -> Result<Vec<TrackHandle>> {
|
||||
let mut handles = Vec::new();
|
||||
for source in sources {
|
||||
handles.push(self.add_to_end(source, handler)?);
|
||||
}
|
||||
Ok(handles)
|
||||
}
|
||||
|
||||
/// Adds a track to play next
|
||||
pub fn add_next(&mut self, source: Input, handler: &mut Driver) -> Result<TrackHandle> {
|
||||
let mut inner = self.inner.lock();
|
||||
let track = Track::from(source).pause();
|
||||
let mut handle = handler.play(track);
|
||||
QueueHandler::register_track_end_event(&mut handle, self.inner.clone())?;
|
||||
if inner.tracks.is_empty() {
|
||||
inner.tracks.push_back(handle.clone());
|
||||
} else {
|
||||
inner.tracks.insert(1, handle.clone());
|
||||
}
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Clears the queue
|
||||
pub fn clear(&mut self) {
|
||||
let mut inner = self.inner.lock();
|
||||
for track in inner.tracks.drain(..) {
|
||||
let _ = track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the queue is empty
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let inner = self.inner.lock();
|
||||
inner.tracks.is_empty()
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue