diff --git a/hydrogen/CHANGELOG.md b/hydrogen/CHANGELOG.md index 0f140f2..c22ac17 100644 --- a/hydrogen/CHANGELOG.md +++ b/hydrogen/CHANGELOG.md @@ -6,6 +6,9 @@ - Create the shared behavior to share logic between components and commands. - Create the loop command. +- Implement autoplay loop mode using YouTube Mix. +- Implement duplicate detection. +- Implement helper functions to get the YouTube video ID from a Track. - Implement `PlayerManager::shuffle` method. - Implement the shuffle component. diff --git a/hydrogen/README.md b/hydrogen/README.md index 3415c7f..e1998c6 100644 --- a/hydrogen/README.md +++ b/hydrogen/README.md @@ -22,6 +22,9 @@ the [dev/README.md](https://github.com/nashiradeer/hydrogen-bot/blob/main/hydrog ## Using +**Warning:** YouTube Source is not optional, it is required because the autoplay feature uses it to get the next song +from a YouTube mix. + Hydrogen is available on our [GitHub Container Registry](https://github.com/nashiradeer/hydrogen-bot/pkgs/container/hydrogen-bot), you can use it by running diff --git a/hydrogen/TODO.md b/hydrogen/TODO.md index 8f6b3af..d71d41f 100644 --- a/hydrogen/TODO.md +++ b/hydrogen/TODO.md @@ -8,7 +8,7 @@ - [ ] Create the about command. - [ ] Create the donate command. - [ ] Create a command to set Music Player language. -- [ ] Implement auto-play loop mode. +- [x] Implement auto-play loop mode. ## 0.0.1-alpha.15 diff --git a/hydrogen/dev/_application.yml b/hydrogen/dev/_application.yml index f308602..622773e 100644 --- a/hydrogen/dev/_application.yml +++ b/hydrogen/dev/_application.yml @@ -15,7 +15,9 @@ plugins: allowDirectPlaylistIds: true clients: - MUSIC + - ANDROID_VR - WEB + - WEBEMBEDDED - TVHTML5EMBEDDED - TV @@ -39,14 +41,15 @@ plugins: localFiles: false deezer: masterDecryptionKey: # Paste your master decryption key here + arl: # Paste your ARL token here formats: [ "MP3_128", "MP3_64" ] lavalink: plugins: - - dependency: "dev.lavalink.youtube:youtube-plugin:1.12.0" - repository: "https://maven.lavalink.dev/releases" - - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.3.0" - repository: "https://maven.lavalink.dev/releases" + - dependency: "dev.lavalink.youtube:youtube-plugin:1.13.2" + snapshot: false + - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.6.0" + snapshot: false server: password: # Paste your Lavalink password here diff --git a/hydrogen/src/commands/join.rs b/hydrogen/src/commands/join.rs index d238c2e..b116ad4 100644 --- a/hydrogen/src/commands/join.rs +++ b/hydrogen/src/commands/join.rs @@ -3,13 +3,14 @@ use beef::lean::Cow; use serenity::all::{CommandOptionType, CreateCommandOption}; use serenity::{all::CommandInteraction, builder::CreateCommand, client::Context}; -use tracing::{event, Level}; +use tracing::{Level, event}; use crate::i18n::{serenity_command_option_description, serenity_command_option_name, t, t_all}; use crate::music::PlayerTemplate; use crate::{ + LOADED_COMMANDS, PLAYER_MANAGER, i18n::{serenity_command_description, serenity_command_name, t_vars}, - utils, LOADED_COMMANDS, PLAYER_MANAGER, + utils, }; /// Executes the `/join` command. @@ -40,6 +41,7 @@ pub async fn execute<'a>(context: &Context, interaction: &CommandInteraction) -> Some("queue") => PlayerTemplate::Queue, Some("manual") => PlayerTemplate::Manual, Some("rpg") => PlayerTemplate::Rpg, + Some("autoplay") => PlayerTemplate::Autoplay, _ => PlayerTemplate::Default, }; @@ -83,6 +85,7 @@ pub async fn execute<'a>(context: &Context, interaction: &CommandInteraction) -> PlayerTemplate::Queue => t(&interaction.locale, "join.template_queue"), PlayerTemplate::Manual => t(&interaction.locale, "join.template_manual"), PlayerTemplate::Rpg => t(&interaction.locale, "join.template_rpg"), + PlayerTemplate::Autoplay => t(&interaction.locale, "join.template_autoplay"), }; let play_command = match LOADED_COMMANDS.get().and_then(|v| v.get("play")) { @@ -117,7 +120,12 @@ pub fn create_command() -> CreateCommand { .add_string_choice_localized("Music", "music", t_all("join.template_music")) .add_string_choice_localized("Queue", "queue", t_all("join.template_queue")) .add_string_choice_localized("Manual", "manual", t_all("join.template_manual")) - .add_string_choice_localized("RPG", "rpg", t_all("join.template_rpg")); + .add_string_choice_localized("RPG", "rpg", t_all("join.template_rpg")) + .add_string_choice_localized( + "Autoplay", + "autoplay", + t_all("join.template_autoplay"), + ); option = serenity_command_option_name("join.template_name", option); option = serenity_command_option_description("join.template_description", option); diff --git a/hydrogen/src/commands/loop_switch.rs b/hydrogen/src/commands/loop_switch.rs index 52b8c86..c1e0434 100644 --- a/hydrogen/src/commands/loop_switch.rs +++ b/hydrogen/src/commands/loop_switch.rs @@ -36,6 +36,7 @@ pub async fn execute<'a>(context: &Context, interaction: &CommandInteraction) -> Some("single") => LoopMode::Single, Some("all") => LoopMode::All, Some("auto_pause") => LoopMode::AutoPause, + Some("autoplay") => LoopMode::Autoplay, _ => LoopMode::None, }; @@ -57,6 +58,7 @@ pub async fn execute<'a>(context: &Context, interaction: &CommandInteraction) -> LoopMode::AutoPause => "loop.pause", LoopMode::Single => "loop.music", LoopMode::All => "loop.queue", + LoopMode::Autoplay => "loop.autoplay", }; Cow::borrowed(t(&interaction.locale, loop_type_translation_key)) @@ -86,17 +88,18 @@ pub fn create_command() -> CreateCommand { "The loop mode to set.", ) .required(true) - .add_string_choice_localized("Default", "default", t_all("join.mode_default")) - .add_string_choice_localized("Single", "single", t_all("join.mode_single")) - .add_string_choice_localized("All", "all", t_all("join.mode_all")) + .add_string_choice_localized("Default", "default", t_all("loop.mode_default")) + .add_string_choice_localized("Single", "single", t_all("loop.mode_single")) + .add_string_choice_localized("All", "all", t_all("loop.mode_all")) + .add_string_choice_localized("Auto Pause", "auto_pause", t_all("loop.mode_auto_pause")) .add_string_choice_localized( - "Auto Pause", - "auto_pause", - t_all("join.mode_auto_pause"), + "Autoplay", + "autoplay", + t_all("loop.mode_autoplay"), ); - option = serenity_command_option_name("join.mode_name", option); - option = serenity_command_option_description("join.mode_description", option); + option = serenity_command_option_name("loop.mode_name", option); + option = serenity_command_option_description("loop.mode_description", option); option }) diff --git a/hydrogen/src/components/loop_switch.rs b/hydrogen/src/components/loop_switch.rs index 855bf8d..e2225f0 100644 --- a/hydrogen/src/components/loop_switch.rs +++ b/hydrogen/src/components/loop_switch.rs @@ -43,6 +43,7 @@ pub async fn execute<'a>(context: &Context, interaction: &ComponentInteraction) LoopMode::AutoPause => "loop.pause", LoopMode::Single => "loop.music", LoopMode::All => "loop.queue", + LoopMode::Autoplay => "loop.autoplay", }; Cow::borrowed(t(&interaction.locale, loop_type_translation_key)) diff --git a/hydrogen/src/i18n/en_us.rs b/hydrogen/src/i18n/en_us.rs index de8358e..ba7218e 100644 --- a/hydrogen/src/i18n/en_us.rs +++ b/hydrogen/src/i18n/en_us.rs @@ -42,22 +42,25 @@ pub static TRANSLATIONS: Map<&'static str, &'static str> = phf_map! { "join.template_queue" => "Queue", "join.template_manual" => "Manual", "join.template_rpg" => "RPG", + "join.template_autoplay" => "Autoplay", "join.result" => "Created the player with the template **{0}**, now you can request any music using {1}.", "stop.name" => "stop", "stop.description" => "Stops the player.", "stop.stopped" => "I'm leaving the voice channel. Hope to see you soon.", "loop.name" => "loop", "loop.description" => "Changes the loop mode of the player.", - "join.mode_name" => "mode", - "join.mode_description" => "The loop mode to set.", - "join.mode_default" => "Default", - "join.mode_single" => "Single", - "join.mode_all" => "All", - "join.mode_auto_pause" => "Auto Pause", + "loop.mode_name" => "mode", + "loop.mode_description" => "The loop mode to set.", + "loop.mode_default" => "Default", + "loop.mode_single" => "Single", + "loop.mode_all" => "All", + "loop.mode_auto_pause" => "Auto Pause", + "loop.mode_autoplay" => "Autoplay", "loop.normal" => "Loop disabled, the player will start the next song automatically.", "loop.pause" => "Loop disabled, the player will wait for you to start the next song.", "loop.music" => "Looping the current song.", "loop.queue" => "Looping the entire queue.", + "loop.autoplay" => "Autoplay enabled, the player will automatically add songs to the queue.", "pause.name" => "pause", "pause.description" => "Pauses or resumes the player.", "pause.paused" => "You have paused the music player.", diff --git a/hydrogen/src/i18n/pt_br.rs b/hydrogen/src/i18n/pt_br.rs index 00b0033..601885e 100644 --- a/hydrogen/src/i18n/pt_br.rs +++ b/hydrogen/src/i18n/pt_br.rs @@ -42,22 +42,25 @@ pub static TRANSLATIONS: Map<&'static str, &'static str> = phf_map! { "join.template_queue" => "Fila", "join.template_manual" => "Manual", "join.template_rpg" => "RPG", + "join.template_autoplay" => "Reprodução Automática", "join.result" => "Criei o tocador de música com o template **{0}**, agora você pode pedir qualquer música usando {1}.", "stop.name" => "parar", "stop.description" => "Para o tocador de música.", "stop.stopped" => "Eu estou saindo do chat de voz. Espero te ver em breve.", "loop.name" => "loop", "loop.description" => "Altera o modo de loop do tocador de música.", - "join.mode_name" => "modo", - "join.mode_description" => "O modo de loop para definir.", - "join.mode_default" => "Padrão", - "join.mode_single" => "Único", - "join.mode_all" => "Todos", - "join.mode_auto_pause" => "Pausa Automática", + "loop.mode_name" => "modo", + "loop.mode_description" => "O modo de loop para definir.", + "loop.mode_default" => "Padrão", + "loop.mode_single" => "Único", + "loop.mode_all" => "Todos", + "loop.mode_auto_pause" => "Pausa Automática", + "loop.mode_autoplay" => "Reprodução Automática", "loop.normal" => "Loop desabilitado, o tocador de música começará a próxima música automaticamente.", "loop.pause" => "Loop desabilitado, o tocador de música esperará você para começar a próxima música.", "loop.music" => "Repetindo a música atual.", "loop.queue" => "Repetindo a fila inteira.", + "loop.autoplay" => "Reprodução automática ativada, o tocador de música irá adicionar músicas automaticamente na fila.", "pause.name" => "pausar", "pause.description" => "Pausa ou resume o tocador de música.", "pause.paused" => "Você pausou o tocador de música.", diff --git a/hydrogen/src/main.rs b/hydrogen/src/main.rs index 4bb551b..023cf5f 100644 --- a/hydrogen/src/main.rs +++ b/hydrogen/src/main.rs @@ -168,6 +168,7 @@ impl EventHandler for HydrogenHandler { Arc::new(Cluster::new(lavalink_nodes, &ready.user.id.to_string()).await), ctx.cache.clone(), ctx.http.clone(), + ready.user.id, ) .await, ) diff --git a/hydrogen/src/music/mod.rs b/hydrogen/src/music/mod.rs index 9270470..4bb42b1 100644 --- a/hydrogen/src/music/mod.rs +++ b/hydrogen/src/music/mod.rs @@ -4,23 +4,19 @@ mod lavalink; mod message; mod player; -use hydrolink::{LoadResult, Rest, UpdatePlayer, UpdatePlayerTrack, VoiceState, cluster::Cluster}; +use hydrolink::{ + LoadResult, Rest, Track as LavalinkTrack, UpdatePlayer, UpdatePlayerTrack, VoiceState, + cluster::Cluster, +}; use message::update_message; pub use player::*; use tokio::time::sleep; use tracing::{Level, event}; -use std::{ - error::Error as StdError, - fmt::{self, Display, Formatter}, - result::Result as StdResult, - sync::Arc, - time::Duration, -}; - use crate::utils::constants::{ HYDROGEN_EMPTY_CHAT_TIMEOUT, HYDROGEN_QUEUE_LIMIT, HYDROGEN_SEARCH_PREFIXES, }; +use beef::lean::Cow; use dashmap::DashMap; use lavalink::{handle_lavalink, reconnect_node}; use rand::prelude::SliceRandom; @@ -29,6 +25,13 @@ use serenity::all::{ VoiceState as SerenityVoiceState, }; use songbird::{Songbird, error::JoinError}; +use std::{ + error::Error as StdError, + fmt::{self, Display, Formatter}, + result::Result as StdResult, + sync::Arc, + time::Duration, +}; #[derive(Debug, Clone)] /// The player manager. @@ -49,6 +52,8 @@ pub struct PlayerManager { /// /// This [Arc] comes from outside the player manager. http: Arc, + /// The bot user ID. + user_id: UserId, } impl PlayerManager { @@ -58,6 +63,7 @@ impl PlayerManager { lavalink: Arc, cache: Arc, http: Arc, + user_id: UserId, ) -> Self { let players = Arc::new(DashMap::::new()); @@ -76,6 +82,7 @@ impl PlayerManager { lavalink, cache, http, + user_id, }; handle_lavalink(me.clone()); @@ -893,22 +900,32 @@ impl PlayerManager { }); } + /// Check if the player exists, is playing the last track, and is with + fn should_autoplay(&self, guild_id: GuildId) -> bool { + let Some(player) = self.players.get(&guild_id) else { + return false; + }; + + if player.current_track + 1 >= player.queue.len() { + matches!(player.loop_mode, LoopMode::Autoplay) + } else { + false + } + } + /// Uses the player's loop mode to determine the next track to play. pub async fn next_track(&self, guild_id: GuildId) -> Result<()> { + if self.should_autoplay(guild_id) && self.autoplay(guild_id).await? { + return Ok(()); + } + let Some(mut player) = self.players.get_mut(&guild_id) else { return Ok(()); }; let (new_index, should_pause, need_sync) = match player.loop_mode { - LoopMode::None => { - if player.current_track + 1 >= player.queue.len() { - (player.queue.len() - 1, false, false) - } else { - (player.current_track + 1, false, true) - } - } LoopMode::Single => (player.current_track, false, true), - LoopMode::All => (player.current_track + 1 % player.queue.len(), false, true), + LoopMode::All => ((player.current_track + 1) % player.queue.len(), false, true), LoopMode::AutoPause => { if player.current_track + 1 >= player.queue.len() { (player.queue.len() - 1, true, false) @@ -916,6 +933,13 @@ impl PlayerManager { (player.current_track + 1, true, false) } } + _ => { + if player.current_track + 1 >= player.queue.len() { + (player.queue.len() - 1, false, false) + } else { + (player.current_track + 1, false, true) + } + } }; player.current_track = new_index; @@ -932,6 +956,48 @@ impl PlayerManager { Ok(()) } + /// Autoplay the next track using YouTube Mix, returning `true` if it was successful. + async fn autoplay(&self, guild_id: GuildId) -> Result { + if self + .players + .view(&guild_id, |_, p| p.queue.len() >= HYDROGEN_QUEUE_LIMIT) + .unwrap_or(true) + { + return Ok(false); + } + + event!(Level::DEBUG, guild_id = ?guild_id, "autoplay has been triggered"); + + let Some(current_track) = self.players.view(&guild_id, |_, p| p.current_track) else { + return Ok(false); + }; + + let Some(track) = self + .get_track_from_youtube_mix(guild_id, current_track) + .await? + else { + return Ok(false); + }; + + let fetch_result = FetchResult { + selected: Some(0), + tracks: vec![track], + }; + + let add_queue_result = + self.add_queue(guild_id, fetch_result, self.user_id, AddQueueOperation::End)?; + + self.forced_update_sync( + guild_id, + add_queue_result + .selected + .unwrap_or(add_queue_result.first_track_index), + ) + .await?; + + Ok(true) + } + /// Update the player message. pub async fn update_message(&self, guild_id: GuildId) { let player_state = self.get_player_state(guild_id); @@ -972,6 +1038,229 @@ impl PlayerManager { Ok(()) } + + /// Convert the load result to the identifier of the first track. + fn get_identifier(&self, load_result: LoadResult) -> Option { + match load_result { + LoadResult::Track(track) => Some(track.info.identifier.clone()), + LoadResult::Playlist(playlist) => { + playlist.tracks.first().map(|t| t.info.identifier.clone()) + } + LoadResult::Search(tracks) => tracks.first().map(|t| t.info.identifier.clone()), + _ => None, + } + } + + /// Get the YouTube ID from a query. + async fn get_youtube_id_from_query(&self, query: &str) -> Result> { + let node_id = self + .lavalink + .search_connected_node() + .ok_or(Error::NoAvailableLavalink)?; + + let node = &self.lavalink.nodes()[node_id]; + + let result = node + .load_track(&format!("ytsearch:{}", query)) + .await + .map_err(Error::from)?; + + Ok(self.get_identifier(result)) + } + + /// Get the YouTube ID from an ISRC code. + async fn get_youtube_id_from_isrc(&self, isrc: &str) -> Result> { + let node_id = self + .lavalink + .search_connected_node() + .ok_or(Error::NoAvailableLavalink)?; + + let node = &self.lavalink.nodes()[node_id]; + + let result = node + .load_track(&format!("ytsearch:\"{}\"", isrc)) + .await + .map_err(Error::from)?; + + Ok(self.get_identifier(result)) + } + + /// Convert a track from queue to YouTube ID. + async fn get_youtube_id(&self, guild_id: GuildId, index: usize) -> Result> { + let youtube_id = self + .players + .view(&guild_id, |_, p| { + p.queue.get(index).map(|t| t.youtube_id.clone()) + }) + .ok_or(Error::PlayerNotFound)? + .flatten(); + + if youtube_id.is_none() { + if let Some(isrc) = self + .players + .view(&guild_id, |_, p| p.queue.get(index).map(|t| t.isrc.clone())) + .ok_or(Error::PlayerNotFound)? + .flatten() + { + return self + .get_youtube_id_from_isrc(&isrc) + .await + .inspect(|youtube_id| self.update_youtube_id(guild_id, index, youtube_id)); + } + + if let Some(track) = self + .players + .view(&guild_id, |_, p| { + p.queue.get(index).map(|t| t.track.clone()) + }) + .ok_or(Error::PlayerNotFound)? + { + return self + .get_youtube_id_from_query(&track) + .await + .inspect(|youtube_id| self.update_youtube_id(guild_id, index, youtube_id)); + } + } + + Ok(youtube_id) + } + + /// Update the YouTube ID for a track in the player's queue. + fn update_youtube_id(&self, guild_id: GuildId, index: usize, youtube_id: &Option) { + if let Some(youtube_id) = youtube_id { + self.players.alter(&guild_id, |_, mut p| { + if let Some(track) = p.queue.get_mut(index) { + if track.youtube_id.is_none() { + track.youtube_id = Some(youtube_id.clone()); + } + } + p + }); + } + } + + /// Get the YouTube ID from a YouTube mix. + async fn get_track_from_youtube_mix( + &self, + guild_id: GuildId, + index: usize, + ) -> Result> { + let Some(youtube_id) = self.get_youtube_id(guild_id, index).await? else { + return Ok(None); + }; + + event!(Level::DEBUG, guild_id = ?guild_id, youtube_id = youtube_id, "getting track from youtube mix"); + + let node_id = self + .lavalink + .search_connected_node() + .ok_or(Error::NoAvailableLavalink)?; + + let node = &self.lavalink.nodes()[node_id]; + + let result = node + .load_track(&format!( + "https://www.youtube.com/watch?v={0}&list=RD{0}", + youtube_id + )) + .await + .map_err(Error::from)?; + + let tracks = match result { + LoadResult::Playlist(playlist) => playlist.tracks.into_iter(), + LoadResult::Search(tracks) => tracks.into_iter(), + _ => return Ok(None), + }; + + let track = self + .get_non_duplicated_track(guild_id, tracks.skip(1)) + .await?; + + event!(Level::DEBUG, guild_id = ?guild_id, track = ?track, "got track from youtube mix"); + + Ok(track) + } + + /// Get the YouTube ID from a Lavalink track. + async fn get_youtube_id_from_lavalink_track<'a>( + &self, + track: &'a LavalinkTrack, + ) -> Option> { + if track + .info + .source_name + .as_ref() + .is_some_and(|s| s == "youtube") + { + return Some(Cow::borrowed(&track.info.identifier)); + } else { + if let Some(isrc) = track.info.isrc.as_ref() { + if let Ok(Some(youtube_id)) = self.get_youtube_id_from_isrc(isrc).await.inspect_err( + |e| event!(Level::WARN, error = ?e, "failed to get youtube id from isrc"), + ) { + return Some(Cow::owned(youtube_id)); + } + } + + if let Ok(Some(youtube_id)) = self + .get_youtube_id_from_query(&track.info.title) + .await + .inspect_err( + |e| event!(Level::WARN, error = ?e, "failed to get youtube id from query"), + ) + { + return Some(Cow::owned(youtube_id)); + } + }; + + None + } + + /// Get a non-duplicated track from the provided tracks. + async fn get_non_duplicated_track>( + &self, + guild_id: GuildId, + tracks: T, + ) -> Result> { + for track in tracks { + event!(Level::TRACE, guild_id = ?guild_id, track = ?track, "checking track for duplication"); + + let is_duplicated = match self.get_youtube_id_from_lavalink_track(&track).await { + Some(id) => self.contains_track_by_youtube_id(guild_id, &id).await, + None => false, + }; + + if !is_duplicated { + return Ok(Some(track)); + } + } + + Ok(None) + } + + /// Check if the player contains a track by its YouTube ID. + async fn contains_track_by_youtube_id(&self, guild_id: GuildId, youtube_id: &str) -> bool { + let track_count = self + .players + .view(&guild_id, |_, p| p.queue.len()) + .unwrap_or(0); + + for i in 0..track_count { + let track_youtube_id = self + .get_youtube_id(guild_id, i) + .await + .inspect_err(|e| event!(Level::WARN, error = ?e, "failed to get youtube id")) + .unwrap_or(None); + + if let Some(id) = track_youtube_id { + if id == youtube_id { + return true; + } + } + } + + false + } } impl CacheHttp for PlayerManager { diff --git a/hydrogen/src/music/player.rs b/hydrogen/src/music/player.rs index faa5287..6571621 100644 --- a/hydrogen/src/music/player.rs +++ b/hydrogen/src/music/player.rs @@ -121,6 +121,8 @@ pub enum LoopMode { All, /// Like [None], but automatically pauses after the track ends. AutoPause, + /// Autoplay the next track using YouTube Mix. + Autoplay, } impl LoopMode { @@ -129,7 +131,8 @@ impl LoopMode { match self { LoopMode::None => LoopMode::Single, LoopMode::Single => LoopMode::All, - LoopMode::All => LoopMode::AutoPause, + LoopMode::All => LoopMode::Autoplay, + LoopMode::Autoplay => LoopMode::AutoPause, LoopMode::AutoPause => LoopMode::None, } } @@ -142,6 +145,7 @@ impl Display for LoopMode { LoopMode::Single => write!(f, "🔂"), LoopMode::All => write!(f, "🔁"), LoopMode::AutoPause => write!(f, "↩️"), + LoopMode::Autoplay => write!(f, "🔄️"), } } } @@ -155,7 +159,7 @@ impl From for ReactionType { #[derive(Debug, Clone, PartialEq, Eq)] /// Track information. pub struct Track { - /// The track's identifier. + /// The track's encoded string. pub track: String, /// The track's author. pub author: String, @@ -169,33 +173,31 @@ pub struct Track { pub url: Option, /// The track's thumbnail. pub thumbnail: Option, + /// The track's identifier from the YouTube source. + pub youtube_id: Option, + /// The track's ISRC (International Standard Recording Code). + pub isrc: Option, } impl Track { /// Create a new track. pub fn from_track(track: LavalinkTrack, requester: UserId) -> Self { - Self { - track: track.encoded, - title: track.info.title, - author: track.info.author, - requester, - duration: track.info.length, - url: track.info.uri, - thumbnail: track.info.artwork_url, - } - } -} + let youtube_id = if track.info.source_name.as_deref() == Some("youtube") { + Some(track.info.identifier.clone()) + } else { + None + }; -impl From for Track { - fn from(track: LavalinkTrack) -> Self { Self { track: track.encoded, title: track.info.title, author: track.info.author, - requester: Default::default(), + requester, duration: track.info.length, url: track.info.uri, thumbnail: track.info.artwork_url, + isrc: track.info.isrc, + youtube_id, } } } @@ -247,6 +249,8 @@ pub enum PlayerTemplate { Manual, /// Player for RPG music with single loop and paused by default. Rpg, + /// Player with autoplay enabled. + Autoplay, } impl PlayerTemplate { @@ -261,6 +265,7 @@ impl PlayerTemplate { Self::Music | Self::Rpg => LoopMode::Single, Self::Queue => LoopMode::All, Self::Manual => LoopMode::AutoPause, + Self::Autoplay => LoopMode::Autoplay, _ => LoopMode::None, } } diff --git a/hydrolink/src/cluster.rs b/hydrolink/src/cluster.rs index 58474d3..a0e43e8 100644 --- a/hydrolink/src/cluster.rs +++ b/hydrolink/src/cluster.rs @@ -101,6 +101,11 @@ impl Cluster { &self.nodes } + /// Get the user ID. + pub fn user_id(&self) -> &str { + &self.user_id + } + /// Get the list of connected nodes. pub fn connected_nodes(&self) -> Vec { self.session_id.read().keys().copied().collect()