use std::sync::Arc; use serenity::all::{CommandInteraction, CommandOptionType, CreateCommand, CreateCommandOption}; use serenity::model::prelude::GuildId; use serenity::{prelude::*, async_trait}; use songbird::input::{Input, YoutubeDl}; use songbird::tracks::TrackHandle; use songbird::{Event, EventHandler, Songbird, TrackEvent}; use crate::bot::commands::audio::leave_voice_channel; use crate::data::guilds::GuildCache; use crate::bot::ytdlp::{PlaylistItem, YtDlp}; use crate::error::{SirenResult, Error as SirenError}; use crate::HttpKey; use super::{ create_response, edit_response, get_songbird, is_valid_url, join_voice_channel, process_message, }; pub async fn run(ctx: &Context, command: &CommandInteraction) { // Process the command options let track_url = match command.data.options.first() { Some(o) => o.value.as_str().unwrap(), None => { log::warn!( "{} attempted to play a track without a track option", command.user.id.get() ); create_response(&ctx, &command, format!("Track option is missing")).await; return; } }; // Create the initial response process_message(&ctx, &command).await; // Get the songbird manager let manager = get_songbird(ctx).await; // Extract the guild ID let guild_id = match &command.guild_id { Some(guild_id) => guild_id, None => { edit_response( &ctx, &command, "Unable to find the current server ID".to_string(), ) .await; return; } }; // Join the user's voice channel match join_voice_channel(&ctx.cache, &manager, guild_id, &command.user).await { Ok(channel_id) => { log::debug!("<{guild_id}> Play command executed on {channel_id} with track: {track_url:?}"); // Handle the track url match enqueue_track(ctx, manager, guild_id.to_owned(), track_url, true).await { Ok(count) => { let mut message = format!("Playing {} tracks", count); if count == 0 { message = "No tracks were played".to_string(); } else if count == 1 { message = "Playing 1 track".to_string(); } edit_response(&ctx, &command, message).await; } Err(err) => { log::warn!("Failed to play track: {}", err); edit_response(&ctx, &command, format!("Failed to play track: {}", err)).await; } }; } Err(err) => { log::warn!("<{guild_id}> Failed to join voice channel: {}", err); edit_response(&ctx, &command, format!("{}", err)).await; } } } pub async fn enqueue_track( ctx: &Context, manager: Arc, guild_id: GuildId, track_url: &str, play_now: bool, ) -> SirenResult { let mut track_count = 0; if let Some(handler_lock) = manager.get(guild_id) { let mut handler = handler_lock.lock().await; let guild = GuildCache::get_by_id(guild_id.get() as i64).await?.unwrap(); let valid = is_valid_url(&track_url); // Check if the URL is valid if !valid.0 { log::warn!("<{guild_id}> Invalid track url: {}", track_url); return Err(SirenError::new( 422, format!("Invalid track url: {}", track_url), )); } let mut playlist_items: Vec = Vec::new(); // Check if the URL is a playlist or a single track if valid.1 { playlist_items = match get_playlist_urls(&track_url) { Ok(items) => items, Err(err) => { 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 for item in playlist_items { let volume = guild.volume as f32 / 100.0; let http_client = { let data = ctx.data.read().await; data .get::() .cloned() .expect("Guaranteed to exist in the typemap.") }; let source = YoutubeDl::new(http_client, item.url.to_owned()); let mut input: Input = source.into(); let metadata = match input.aux_metadata().await { Ok(metadata) => metadata, 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 is_queue_empty = handler.queue().is_empty(); track_handle = handler.enqueue_input(input).await; // Set the volume let _ = track_handle.set_volume(volume); let track_title = metadata.title.unwrap(); log::debug!("<{guild_id}> Added track: {}", track_title); handler.remove_all_global_events(); handler.add_global_event( Event::Track(TrackEvent::End), TrackEndNotifier { guild_id, call: manager.clone(), }, ); track_count += 1; } if play_now && !handler.queue().is_empty() { handler.queue().resume(); } } Ok(track_count) } pub fn get_playlist_urls(url: &str) -> SirenResult> { let output = YtDlp::new() .arg("--flat-playlist") .arg("--dump-json") .arg(url) .execute()?; let items: Vec = String::from_utf8(output.stdout)? .split('\n') .filter_map(|line| { if line.is_empty() { None } else { Some( serde_json::from_slice::(line.as_bytes()) .map_err(|err| SirenError::new(500, err.to_string())), ) } }) .filter_map(|parsed| match parsed { Ok(item) => Some(item), Err(err) => { log::warn!("Failed to parse playlist item: {}", err); None } }) .collect(); Ok(items) } pub fn register() -> CreateCommand { CreateCommand::new("play") .description("Plays the given track") .add_option( CreateCommandOption::new(CommandOptionType::String, "track", "The track to be played") .required(true), ) } struct TrackEndNotifier { pub call: Arc, pub guild_id: GuildId, } #[async_trait] impl EventHandler for TrackEndNotifier { async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option { if let songbird::EventContext::Track(_track_list) = ctx { if let Some(call) = self.call.get(self.guild_id) { let mut handler = call.lock().await; if handler.queue().is_empty() { log::debug!("Queue is empty, leaving voice channel"); handler.leave().await.unwrap(); } } } None } }