Simplified enqueue_track to parse track type (playlist or single track)

This commit is contained in:
2024-12-18 19:05:13 -05:00
parent c82fee0dd3
commit aa7bad945a
5 changed files with 144 additions and 116 deletions

View File

@@ -56,14 +56,8 @@ pub async fn leave_voice_channel(manager: &Arc<Songbird>, guild_id: &GuildId) ->
* 1st tuple value is if the URL is valid. * 1st tuple value is if the URL is valid.
* 2nd tuple value is if the URL is a playlist. * 2nd tuple value is if the URL is a playlist.
*/ */
fn is_valid_url(url: &str) -> (bool, bool) { fn is_valid_url(url: &str) -> bool {
Url::parse(url).ok().map_or((false, false), |valid_url| { Url::parse(url).ok().map_or(false, |valid_url| true)
let is_playlist: bool = valid_url
.query_pairs()
.find(|(key, _)| key == "list")
.map_or(false, |_| true);
(true, is_playlist)
})
} }
/** /**

View File

@@ -9,7 +9,7 @@ use songbird::{Event, EventHandler, Songbird, TrackEvent};
use crate::bot::commands::audio::leave_voice_channel; use crate::bot::commands::audio::leave_voice_channel;
use crate::data::guilds::GuildCache; use crate::data::guilds::GuildCache;
use crate::bot::ytdlp::{PlaylistItem, YtDlp}; use crate::bot::ytdlp::{YtDlp, YtDlpItem};
use crate::error::{SirenResult, Error as SirenError}; use crate::error::{SirenResult, Error as SirenError};
use crate::HttpKey; use crate::HttpKey;
@@ -57,12 +57,12 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
log::debug!("<{guild_id}> Play command executed on {channel_id} with track: {track_url:?}"); log::debug!("<{guild_id}> Play command executed on {channel_id} with track: {track_url:?}");
// Handle the track url // Handle the track url
match enqueue_track(ctx, manager, guild_id.to_owned(), track_url).await { match enqueue_track(ctx, manager, guild_id.to_owned(), track_url).await {
Ok(count) => { Ok(items) => {
let mut message = format!("Playing {} tracks", count); let mut message = format!("Added {} tracks", items.len());
if count == 0 { if items.len() == 0 {
message = "No tracks were played".to_string(); message = "No tracks were played".to_string();
} else if count == 1 { } else if items.len() == 1 {
message = "Playing 1 track".to_string(); message = format!("Added **{}**", items[0].get_title());
} }
edit_response(&ctx, &command, message).await; edit_response(&ctx, &command, message).await;
} }
@@ -84,42 +84,32 @@ pub async fn enqueue_track(
manager: Arc<Songbird>, manager: Arc<Songbird>,
guild_id: GuildId, guild_id: GuildId,
track_url: &str, track_url: &str,
) -> SirenResult<i32> { ) -> SirenResult<Vec<YtDlpItem>> {
let mut track_count = 0; let mut playlist_items: Vec<YtDlpItem> = Vec::new();
if let Some(handler_lock) = manager.get(guild_id) { if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await; let mut handler = handler_lock.lock().await;
let guild = GuildCache::get_by_id(guild_id.get() as i64).await?.unwrap(); let guild = GuildCache::get_by_id(guild_id.get() as i64).await?.unwrap();
let valid = is_valid_url(&track_url); let valid = is_valid_url(&track_url);
// Check if the URL is valid // Check if the URL is valid
if !valid.0 { if !valid {
log::warn!("<{guild_id}> Invalid track url: {}", track_url); log::warn!("<{guild_id}> Invalid track url: {}", track_url);
return Err(SirenError::new( return Err(SirenError::new(
422, 422,
format!("Invalid track url: {}", track_url), format!("Invalid track url: {}", track_url),
)); ));
} }
let mut playlist_items: Vec<PlaylistItem> = Vec::new();
// Check if the URL is a playlist or a single track playlist_items = match get_ytdlp_items(&track_url) {
if valid.1 { Ok(items) => items,
playlist_items = match get_playlist_urls(&track_url) { Err(err) => {
Ok(items) => items, log::warn!("<{guild_id}> Failed to get playlist urls: {}", err);
Err(err) => { return Err(SirenError::new(422, err.to_string()));
log::warn!("<{guild_id}> Failed to get playlist urls: {}", err); }
return Err(SirenError::new(422, err.to_string())); };
}
};
} else {
let playlist_item = PlaylistItem {
id: "".to_string(),
url: track_url.to_string(),
title: "".to_string(),
duration: 0,
playlist_index: 0,
};
playlist_items.push(playlist_item);
}
// Add each track to the queue // Add each track to the queue
for item in playlist_items { for item in &playlist_items {
let volume = guild.volume as f32 / 100.0; let volume = guild.volume as f32 / 100.0;
let http_client = { let http_client = {
let data = ctx.data.read().await; let data = ctx.data.read().await;
@@ -128,21 +118,17 @@ pub async fn enqueue_track(
.cloned() .cloned()
.expect("Guaranteed to exist in the typemap.") .expect("Guaranteed to exist in the typemap.")
}; };
let source = YoutubeDl::new(http_client, item.url.to_owned());
let mut input: Input = source.into(); let source = YoutubeDl::new(http_client, item.get_url().to_owned());
let metadata = match input.aux_metadata().await { let input: Input = source.into();
Ok(metadata) => metadata, let track_title = item.get_title().to_owned();
Err(err) => {
log::warn!("<{guild_id}> Failed to get metadata for track: {err}");
let _ = leave_voice_channel(&manager, &guild_id).await;
return Err(SirenError::new(422, err.to_string()));
}
};
let track_handle: TrackHandle; let track_handle: TrackHandle;
track_handle = handler.enqueue_input(input).await; track_handle = handler.enqueue_input(input).await;
// Set the volume // Set the volume
let _ = track_handle.set_volume(volume); let _ = track_handle.set_volume(volume);
let track_title = metadata.title.unwrap();
log::debug!("<{guild_id}> Added track: {}", track_title); log::debug!("<{guild_id}> Added track: {}", track_title);
handler.remove_all_global_events(); handler.remove_all_global_events();
handler.add_global_event( handler.add_global_event(
@@ -152,29 +138,28 @@ pub async fn enqueue_track(
call: manager.clone(), call: manager.clone(),
}, },
); );
track_count += 1;
} }
if handler.queue().is_empty() { if handler.queue().is_empty() {
let _ = handler.queue().resume(); let _ = handler.queue().resume();
} }
} }
Ok(track_count) Ok(playlist_items)
} }
pub fn get_playlist_urls(url: &str) -> SirenResult<Vec<PlaylistItem>> { pub fn get_ytdlp_items(url: &str) -> SirenResult<Vec<YtDlpItem>> {
let output = YtDlp::new() let output = YtDlp::new()
.arg("--flat-playlist") .arg("--flat-playlist")
.arg("--dump-json") .arg("--dump-json")
.arg(url) .arg(url)
.execute()?; .execute()?;
let items: Vec<PlaylistItem> = String::from_utf8(output.stdout)? let items: Vec<YtDlpItem> = String::from_utf8(output.stdout)?
.split('\n') .split('\n')
.filter_map(|line| { .filter_map(|line| {
if line.is_empty() { if line.is_empty() {
None None
} else { } else {
Some( Some(
serde_json::from_slice::<PlaylistItem>(line.as_bytes()) serde_json::from_slice::<YtDlpItem>(line.as_bytes())
.map_err(|err| SirenError::new(500, err.to_string())), .map_err(|err| SirenError::new(500, err.to_string())),
) )
} }

View File

@@ -1,32 +1,45 @@
use rand::Rng; use rand::Rng;
use serenity::all::{ use serenity::all::{
CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, Mentionable, UserId CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, Mentionable,
UserId,
}; };
use crate::bot::commands::{create_response, edit_response, user_id_dm}; use crate::bot::commands::{create_response, edit_response, user_id_dm};
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Check if the roll result is private // Check if the roll result is private
let private = command.data.options.iter().find(|opt| opt.name == "private") let private = command
.and_then(|o| o.value.as_bool()) .data
.unwrap_or(true); .options
.iter()
.find(|opt| opt.name == "private")
.and_then(|o| o.value.as_bool())
.unwrap_or(true);
// Retrieve the DM's name or ID from the options (optional) // Retrieve the DM's name or ID from the options (optional)
let user = command.data.options.iter().find(|opt| opt.name == "user") let user = command
.data
.options
.iter()
.find(|opt| opt.name == "user")
.and_then(|o| o.value.as_mentionable()); .and_then(|o| o.value.as_mentionable());
create_response(&ctx, &command, format!("Rolling..."), private).await; create_response(&ctx, &command, format!("Rolling..."), private).await;
let dice_string = match command.data.options.get(0) let dice_string = match command
.data
.options
.get(0)
.and_then(|o| o.value.as_str()) .and_then(|o| o.value.as_str())
.map(|s| s.split_whitespace().collect::<String>()) { .map(|s| s.split_whitespace().collect::<String>())
Some(dice_value) => dice_value, {
None => { Some(dice_value) => dice_value,
log::warn!("Missing or invalid dice option"); None => {
let _ = edit_response(&ctx, &command, "Dice option is missing".to_string()).await; log::warn!("Missing or invalid dice option");
return; let _ = edit_response(&ctx, &command, "Dice option is missing".to_string()).await;
} return;
}; }
};
let dice = parse_dice(dice_string.as_str()); let dice = parse_dice(dice_string.as_str());
match dice { match dice {
@@ -51,14 +64,24 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
"".to_string() "".to_string()
} }
); );
match user { match user {
Some(id) => { Some(id) => {
let user_id = UserId::new(id.get()); let user_id = UserId::new(id.get());
user_id_dm(&ctx, &user_id, format!("Dice roll from {}: {}", &command.user.mention(), response)).await; user_id_dm(
edit_response(&ctx, command, format!("Sending dice roll results to {}", &user_id.mention())).await; &ctx,
}, &user_id,
None => edit_response(&ctx, &command, response).await format!("Dice roll from {}: {}", &command.user.mention(), response),
)
.await;
edit_response(
&ctx,
command,
format!("Sending dice roll results to {}", &user_id.mention()),
)
.await;
}
None => edit_response(&ctx, &command, response).await,
}; };
} }
Err(why) => { Err(why) => {
@@ -77,7 +100,7 @@ fn parse_dice(dice: &str) -> Result<(u32, u32, i32), String> {
let dice = if dice.starts_with("d") { let dice = if dice.starts_with("d") {
format!("1{}", dice) // Prepend "1" format!("1{}", dice) // Prepend "1"
} else { } else {
dice.to_string() dice.to_string()
}; };
let mut parts = dice.split(['d', '+', '-'].as_ref()); let mut parts = dice.split(['d', '+', '-'].as_ref());
@@ -87,49 +110,53 @@ fn parse_dice(dice: &str) -> Result<(u32, u32, i32), String> {
let count = match parts.next() { let count = match parts.next() {
Some("") => 1, // Handle cases like "d6", assume 1 dice Some("") => 1, // Handle cases like "d6", assume 1 dice
Some(c) => match c.parse::<u32>() { Some(c) => match c.parse::<u32>() {
Ok(n) => n, Ok(n) => n,
Err(_) => return Err(format!("Invalid dice count: {}", c)), Err(_) => return Err(format!("Invalid dice count: {}", c)),
}, },
None => return Err(format!("Invalid dice string: {}", dice)), None => return Err(format!("Invalid dice string: {}", dice)),
}; };
// Parse the number of sides // Parse the number of sides
let sides_part = parts.next().ok_or_else(|| format!("Invalid dice string: {}", dice))?; let sides_part = parts
.next()
.ok_or_else(|| format!("Invalid dice string: {}", dice))?;
let sides = match sides_part.parse::<u32>() { let sides = match sides_part.parse::<u32>() {
Ok(n) => { Ok(n) => {
if [4, 6, 8, 10, 12, 20, 100].contains(&n) { if [4, 6, 8, 10, 12, 20, 100].contains(&n) {
n n
} else { } else {
return Err(format!( return Err(format!(
"Expected one of d4, d6, d8, d10, d12, d20, d100 but received d{}",
n
));
}
}
Err(_) => return Err(format!(
"Expected one of d4, d6, d8, d10, d12, d20, d100 but received d{}", "Expected one of d4, d6, d8, d10, d12, d20, d100 but received d{}",
sides_part n
)), ));
}
}
Err(_) => {
return Err(format!(
"Expected one of d4, d6, d8, d10, d12, d20, d100 but received d{}",
sides_part
))
}
}; };
// Determine if there's a modifier (+ or -) // Determine if there's a modifier (+ or -)
if dice.contains('+') { if dice.contains('+') {
positive_modifier = true; positive_modifier = true;
} else if dice.contains('-') { } else if dice.contains('-') {
positive_modifier = false; positive_modifier = false;
} }
// Parse the modifier, if present // Parse the modifier, if present
let modifier = match parts.next() { let modifier = match parts.next() {
Some(m) => match m.parse::<i32>() { Some(m) => match m.parse::<i32>() {
Ok(n) => { Ok(n) => {
if positive_modifier { if positive_modifier {
n n
} else { } else {
-n -n
}
} }
Err(_) => return Err(format!("Invalid dice modifier: {}", m)), }
Err(_) => return Err(format!("Invalid dice modifier: {}", m)),
}, },
None => 0, // No modifier found None => 0, // No modifier found
}; };
@@ -152,7 +179,11 @@ pub fn register() -> CreateCommand {
.required(false), .required(false),
) )
.add_option( .add_option(
CreateCommandOption::new(CommandOptionType::Mentionable, "user", "User to receive the roll results") CreateCommandOption::new(
CommandOptionType::Mentionable,
"user",
"User to receive the roll results",
)
.required(false), .required(false),
) )
} }

View File

@@ -1,6 +1,7 @@
use serenity::prelude::*; use serenity::prelude::*;
use serenity::all::{ use serenity::all::{
CommandInteraction, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, EditInteractionResponse, InteractionResponseFlags, Message, User, UserId CommandInteraction, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
EditInteractionResponse, InteractionResponseFlags, Message, User, UserId,
}; };
pub mod audio; pub mod audio;
@@ -13,11 +14,7 @@ pub async fn process_message(ctx: &Context, command: &CommandInteraction, privat
create_response(&ctx, &command, format!("Processing..."), private).await; create_response(&ctx, &command, format!("Processing..."), private).await;
} }
pub async fn user_id_dm( pub async fn user_id_dm(ctx: &Context, user_id: &UserId, content: String) -> Option<Message> {
ctx: &Context,
user_id: &UserId,
content: String,
) -> Option<Message> {
let data = CreateMessage::new().content(content.to_owned()); let data = CreateMessage::new().content(content.to_owned());
return match user_id.dm(ctx, data).await { return match user_id.dm(ctx, data).await {
Ok(message) => Some(message), Ok(message) => Some(message),
@@ -28,11 +25,7 @@ pub async fn user_id_dm(
}; };
} }
pub async fn user_dm( pub async fn user_dm(ctx: &Context, user: &User, content: String) -> Option<Message> {
ctx: &Context,
user: &User,
content: String,
) -> Option<Message> {
let data = CreateMessage::new().content(content.to_owned()); let data = CreateMessage::new().content(content.to_owned());
return match user.direct_message(ctx, data).await { return match user.direct_message(ctx, data).await {
Ok(message) => Some(message), Ok(message) => Some(message),

View File

@@ -1,10 +1,35 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct PlaylistItem { #[serde(untagged)]
pub id: String, pub enum YtDlpItem {
pub url: String, PlaylistItem {
pub title: String, id: String,
pub duration: i32, url: String,
pub playlist_index: i32, title: String,
duration: i32,
playlist_index: i32,
},
VideoItem {
id: String,
webpage_url: String,
title: String,
duration: i32,
},
}
impl YtDlpItem {
pub fn get_title(&self) -> &str {
match self {
YtDlpItem::PlaylistItem { title, .. } => title,
YtDlpItem::VideoItem { title, .. } => title,
}
}
pub fn get_url(&self) -> &str {
match self {
YtDlpItem::PlaylistItem { url, .. } => url,
YtDlpItem::VideoItem { webpage_url, .. } => webpage_url,
}
}
} }