From ca1f9e7ecf7bb74bac01a00e972dff07cdd24193 Mon Sep 17 00:00:00 2001 From: trivernis Date: Thu, 8 Apr 2021 13:50:44 +0200 Subject: [PATCH] Add spotify url support for music Signed-off-by: trivernis --- Cargo.lock | 217 +++++++++++++++++++++++++++++++++ Cargo.toml | 6 +- src/commands/music/mod.rs | 121 +++++++++++++----- src/providers/music/mod.rs | 56 +++++---- src/providers/music/spotify.rs | 100 +++++++++++++++ src/utils/error.rs | 3 + src/utils/store.rs | 3 + 7 files changed, 448 insertions(+), 58 deletions(-) create mode 100644 src/providers/music/spotify.rs diff --git a/Cargo.lock b/Cargo.lock index ae573b9..f8f5d6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,27 @@ version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" +[[package]] +name = "aspotify" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f408f0fda4701d158664e1f25d36316808a7a2f920d7052593a28833cee649" +dependencies = [ + "base64 0.13.0", + "chrono", + "futures-util", + "isocountry", + "isolanguage-1", + "itertools", + "rand 0.8.3", + "reqwest", + "serde", + "serde_json", + "serde_millis", + "tokio", + "url", +] + [[package]] name = "async-trait" version = "0.1.48" @@ -213,6 +234,22 @@ dependencies = [ "syn", ] +[[package]] +name = "core-foundation" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a89e2ae426ea83155dccf10c0fa6b1463ef6d5fcb44cee0b224a408fa640a62" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" + [[package]] name = "cpuid-bool" version = "0.1.2" @@ -348,6 +385,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -622,6 +674,19 @@ dependencies = [ "webpki", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes 1.0.1", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "idna" version = "0.2.2" @@ -700,6 +765,25 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135" +[[package]] +name = "isocountry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea1dc4bf0fb4904ba83ffdb98af3d9c325274e92e6e295e4151e86c96363e04" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "isolanguage-1" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e2c8b6a22c1151c2e5ea3ac2da0fbe4f226bc046e43955395e19be4271b3d3" +dependencies = [ + "serde", +] + [[package]] name = "itertools" version = "0.10.0" @@ -880,6 +964,24 @@ dependencies = [ "getrandom 0.2.2", ] +[[package]] +name = "native-tls" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4" +dependencies = [ + "lazy_static", + "libc", + "log 0.4.14", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ntapi" version = "0.3.6" @@ -939,6 +1041,39 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl" +version = "0.10.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577" +dependencies = [ + "bitflags 1.2.1", + "cfg-if 1.0.0", + "foreign-types", + "libc", + "once_cell", + "openssl-sys", +] + +[[package]] +name = "openssl-probe" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" + +[[package]] +name = "openssl-sys" +version = "0.9.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.11.1" @@ -1187,6 +1322,15 @@ version = "0.6.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "reqwest" version = "0.11.2" @@ -1202,12 +1346,14 @@ dependencies = [ "http-body", "hyper", "hyper-rustls", + "hyper-tls", "ipnet", "js-sys", "lazy_static", "log 0.4.14", "mime", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "rustls", @@ -1215,6 +1361,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "tokio", + "tokio-native-tls", "tokio-rustls", "url", "wasm-bindgen", @@ -1295,6 +1442,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "schannel" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +dependencies = [ + "lazy_static", + "winapi 0.3.9", +] + [[package]] name = "scoped-tls" version = "1.0.0" @@ -1317,6 +1474,29 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3670b1d2fdf6084d192bc71ead7aabe6c06aa2ea3fbd9cc3ac111fa5c2b1bd84" +dependencies = [ + "bitflags 1.2.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3676258fd3cfe2c9a0ec99ce3038798d847ce3e4bb17746373eb9f0f1ac16339" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.125" @@ -1348,6 +1528,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_millis" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e2dc780ca5ee2c369d1d01d100270203c4ff923d2a4264812d723766434d00" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.6" @@ -1604,6 +1793,20 @@ dependencies = [ "unicode-xid 0.0.3", ] +[[package]] +name = "tempfile" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "rand 0.8.3", + "redox_syscall", + "remove_dir_all", + "winapi 0.3.9", +] + [[package]] name = "term" version = "0.4.6" @@ -1664,9 +1867,13 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" name = "tobi-rs" version = "0.1.0" dependencies = [ + "aspotify", "dotenv", + "futures", + "lazy_static", "minecraft-data-rs", "rand 0.8.3", + "regex", "rusqlite", "serde", "serde_derive", @@ -1708,6 +1915,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.22.0" diff --git a/Cargo.toml b/Cargo.toml index 0486fe0..8cfe376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,8 @@ thiserror = "1.0.24" minecraft-data-rs = "0.2.0" songbird = "0.1.5" serde_json = "1.0.64" -rand = "0.8.3" \ No newline at end of file +rand = "0.8.3" +regex = "1.4.5" +aspotify = "0.7.0" +lazy_static = "1.4.0" +futures = "0.3.13" \ No newline at end of file diff --git a/src/commands/music/mod.rs b/src/commands/music/mod.rs index f780adc..507166e 100644 --- a/src/commands/music/mod.rs +++ b/src/commands/music/mod.rs @@ -23,11 +23,15 @@ use shuffle::SHUFFLE_COMMAND; use skip::SKIP_COMMAND; use crate::providers::music::queue::{MusicQueue, Song}; +use crate::providers::music::responses::VideoInformation; use crate::providers::music::{ get_video_information, get_videos_for_playlist, search_video_information, }; use crate::utils::error::{BotError, BotResult}; use crate::utils::store::Store; +use futures::future::BoxFuture; +use futures::FutureExt; +use regex::Regex; mod clear; mod current; @@ -151,40 +155,97 @@ async fn play_next_in_queue( } /// Returns the list of songs for a given url -async fn get_songs_for_query(ctx: &&Context, msg: &Message, query: &str) -> BotResult> { - let mut songs: Vec = get_videos_for_playlist(query)? - .into_iter() - .map(Song::from) - .collect(); - if songs.len() == 0 { - let song: Song = if !query.starts_with("http") { - search_video_information(query)? - .ok_or(BotError::Msg(format!("Noting found for {}", query)))? - .into() - } else { - get_video_information(query)?.into() - }; - - msg.channel_id - .send_message(&ctx.http, |m| { - m.embed(|mut e| { - e = e.description(format!("Added [{}]({}) to the queue", song.title, song.url)); - if let Some(thumb) = &song.thumbnail { - e = e.thumbnail(thumb); - } +async fn get_songs_for_query(ctx: &Context, msg: &Message, query: &str) -> BotResult> { + lazy_static::lazy_static! { + static ref YOUTUBE_URL_REGEX: Regex = Regex::new(r"^(https?(://))?(www\.)?(youtube\.com/watch\?.*v=.*)|(/youtu.be/.*)$").unwrap(); + static ref SPOTIFY_PLAYLIST_REGEX: Regex = Regex::new(r"^(https?(://))?(www\.|open\.)?spotify\.com/playlist/.*").unwrap(); + static ref SPOTIFY_ALBUM_REGEX: Regex = Regex::new(r"^(https?(://))?(www\.|open\.)?spotify\.com/album/.*").unwrap(); + static ref SPOTIFY_SONG_REGEX: Regex = Regex::new(r"^(https?(://))?(www\.|open\.)?spotify\.com/track/.*").unwrap(); + } + let mut songs = Vec::new(); + let data = ctx.data.read().await; + let store = data.get::().unwrap(); - e - }) - }) - .await?; + if YOUTUBE_URL_REGEX.is_match(query) { + songs = get_videos_for_playlist(query) + .await? + .into_iter() + .map(Song::from) + .collect(); + + if songs.len() == 0 { + let song: Song = get_video_information(query).await?.into(); + added_one_msg(&ctx, msg, &song).await?; + songs.push(song); + } else { + added_multiple_msg(&ctx, msg, &mut songs).await?; + } + } else if SPOTIFY_PLAYLIST_REGEX.is_match(query) { + let song_names = store.spotify_api.get_songs_in_playlist(query).await?; + songs = parallel_search_youtube(song_names).await; + added_multiple_msg(&ctx, msg, &mut songs).await?; + } else if SPOTIFY_ALBUM_REGEX.is_match(query) { + let song_names = store.spotify_api.get_songs_in_album(query).await?; + songs = parallel_search_youtube(song_names).await; + added_multiple_msg(&ctx, msg, &mut songs).await?; + } else if SPOTIFY_SONG_REGEX.is_match(query) { + let name = store.spotify_api.get_song_name(query).await?; + let song: Song = search_video_information(name.clone()) + .await? + .ok_or(BotError::Msg(format!("Noting found for {}", name)))? + .into(); + added_one_msg(ctx, msg, &song).await?; songs.push(song); } else { - msg.channel_id - .send_message(&ctx.http, |m| { - m.embed(|e| e.description(format!("Added {} songs to the queue", songs.len()))) - }) - .await?; + let song: Song = search_video_information(query.to_string()) + .await? + .ok_or(BotError::Msg(format!("Noting found for {}", query)))? + .into(); + + added_one_msg(&ctx, msg, &song).await?; + songs.push(song); } Ok(songs) } + +/// Searches songs on youtube in parallel +async fn parallel_search_youtube(song_names: Vec) -> Vec { + let search_futures: Vec>>> = song_names + .into_iter() + .map(|s| search_video_information(s).boxed()) + .collect(); + let information: Vec>> = + futures::future::join_all(search_futures).await; + information + .into_iter() + .filter_map(|i| i.ok().and_then(|s| s).map(Song::from)) + .collect() +} + +/// Message when one song was added to the queue +async fn added_one_msg(ctx: &Context, msg: &Message, song: &Song) -> BotResult<()> { + msg.channel_id + .send_message(&ctx.http, |m| { + m.embed(|mut e| { + e = e.description(format!("Added [{}]({}) to the queue", song.title, song.url)); + if let Some(thumb) = &song.thumbnail { + e = e.thumbnail(thumb); + } + + e + }) + }) + .await?; + Ok(()) +} + +/// Message when multiple songs were added to the queue +async fn added_multiple_msg(ctx: &Context, msg: &Message, songs: &mut Vec) -> BotResult<()> { + msg.channel_id + .send_message(&ctx.http, |m| { + m.embed(|e| e.description(format!("Added {} songs to the queue", songs.len()))) + }) + .await?; + Ok(()) +} diff --git a/src/providers/music/mod.rs b/src/providers/music/mod.rs index d0ef667..c85a79c 100644 --- a/src/providers/music/mod.rs +++ b/src/providers/music/mod.rs @@ -1,21 +1,17 @@ -use std::io::Read; -use std::process::{Command, Stdio}; - use crate::providers::music::responses::{PlaylistEntry, VideoInformation}; use crate::utils::error::BotResult; +use std::process::Stdio; +use tokio::io::AsyncReadExt; +use tokio::process::Command; pub(crate) mod queue; pub(crate) mod responses; +pub(crate) mod spotify; /// Returns a list of youtube videos for a given url -pub(crate) fn get_videos_for_playlist(url: &str) -> BotResult> { - let ytdl = Command::new("youtube-dl") - .args(&["--no-warnings", "--flat-playlist", "--dump-json", "-i", url]) - .stdout(Stdio::piped()) - .spawn()?; - - let mut output = String::new(); - ytdl.stdout.unwrap().read_to_string(&mut output)?; +pub(crate) async fn get_videos_for_playlist(url: &str) -> BotResult> { + let output = + youtube_dl(&["--no-warnings", "--flat-playlist", "--dump-json", "-i", url]).await?; let videos = output .lines() @@ -26,30 +22,36 @@ pub(crate) fn get_videos_for_playlist(url: &str) -> BotResult } /// Returns information for a single video by using youtube-dl -pub(crate) fn get_video_information(url: &str) -> BotResult { - let ytdl = Command::new("youtube-dl") - .args(&["--no-warnings", "--dump-json", "-i", url]) - .stdout(Stdio::piped()) - .spawn()?; +pub(crate) async fn get_video_information(url: &str) -> BotResult { + let output = youtube_dl(&["--no-warnings", "--dump-json", "-i", url]).await?; - let information = serde_json::from_reader(ytdl.stdout.unwrap())?; + let information = serde_json::from_str(&*output)?; Ok(information) } /// Searches for a video -pub(crate) fn search_video_information(query: &str) -> BotResult> { +pub(crate) async fn search_video_information(query: String) -> BotResult> { + let output = youtube_dl(&[ + "--no-warnings", + "--dump-json", + "-i", + format!("ytsearch:\"{}\"", query).as_str(), + ]) + .await?; + let information = serde_json::from_str(&*output)?; + + Ok(information) +} + +/// Executes youtube-dl asynchronously +async fn youtube_dl(args: &[&str]) -> BotResult { let ytdl = Command::new("youtube-dl") - .args(&[ - "--no-warnings", - "--dump-json", - "-i", - format!("ytsearch:\"{}\"", query).as_str(), - ]) + .args(args) .stdout(Stdio::piped()) .spawn()?; + let mut output = String::new(); + ytdl.stdout.unwrap().read_to_string(&mut output).await?; - let information = serde_json::from_reader(ytdl.stdout.unwrap())?; - - Ok(information) + Ok(output) } diff --git a/src/providers/music/spotify.rs b/src/providers/music/spotify.rs new file mode 100644 index 0000000..b8cd51a --- /dev/null +++ b/src/providers/music/spotify.rs @@ -0,0 +1,100 @@ +use crate::utils::error::{BotError, BotResult}; +use aspotify::{Client, ClientCredentials, PlaylistItemType}; + +pub struct SpotifyApi { + client: Client, +} + +impl SpotifyApi { + /// Creates a new spotify api wrapper with the credentials stored + /// in the .env files + pub fn new() -> Self { + let credentials = ClientCredentials { + id: dotenv::var("SPOTIFY_CLIENT_ID").expect("Missing Spotify Credentials"), + secret: dotenv::var("SPOTIFY_CLIENT_SECRET").expect("Missing Spotify Credentials"), + }; + let client = Client::new(credentials); + + Self { client } + } + + /// Returns the song names for a playlist + pub async fn get_songs_in_playlist(&self, url: &str) -> BotResult> { + let id = self.get_id_for_url(url)?; + let playlist = self.client.playlists().get_playlist(&*id, None).await?.data; + let song_names = playlist + .tracks + .items + .into_iter() + .filter_map(|item| item.item) + .map(|t| match t { + PlaylistItemType::Track(t) => format!( + "{} - {}", + t.artists + .into_iter() + .map(|a| a.name) + .collect::>() + .join(","), + t.name + ), + PlaylistItemType::Episode(e) => e.name, + }) + .collect(); + + Ok(song_names) + } + + /// Returns all song names for a given album + pub async fn get_songs_in_album(&self, url: &str) -> BotResult> { + let id = self.get_id_for_url(url)?; + let album = self.client.albums().get_album(&*id, None).await?.data; + let song_names = album + .tracks + .items + .into_iter() + .map(|item| { + format!( + "{} - {}", + item.artists + .into_iter() + .map(|a| a.name) + .collect::>() + .join(","), + item.name + ) + }) + .collect(); + + Ok(song_names) + } + + /// Returns the name for a spotify song url + pub async fn get_song_name(&self, url: &str) -> BotResult { + let id = self.get_id_for_url(url)?; + let track = self.client.tracks().get_track(&*id, None).await?.data; + + Ok(format!( + "{} - {}", + track + .artists + .into_iter() + .map(|a| a.name) + .collect::>() + .join(","), + track.name + )) + } + + /// Returns the id for a given spotify URL + fn get_id_for_url(&self, url: &str) -> BotResult { + url.split('/') + .last() + .ok_or(BotError::from("Invalid Spotify URL")) + .and_then(|s| { + s.split('?') + .next() + .ok_or(BotError::from("Invalid Spotify URL")) + }) + .map(|s| s.to_string()) + } +} diff --git a/src/utils/error.rs b/src/utils/error.rs index c00b3ac..5fb3885 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -22,6 +22,9 @@ pub enum BotError { #[error("JSON Error: {0}")] JsonError(#[from] serde_json::Error), + #[error("Spotify API Error: {0}")] + SpotifyError(#[from] aspotify::Error), + #[error("{0}")] Msg(String), } diff --git a/src/utils/store.rs b/src/utils/store.rs index 876b415..ed6ab5b 100644 --- a/src/utils/store.rs +++ b/src/utils/store.rs @@ -7,6 +7,7 @@ use tokio::sync::Mutex; use crate::database::Database; use crate::providers::music::queue::MusicQueue; +use crate::providers::music::spotify::SpotifyApi; pub struct Store; @@ -14,6 +15,7 @@ pub struct StoreData { pub database: Arc>, pub minecraft_data_api: minecraft_data_rs::api::Api, pub music_queues: HashMap>>, + pub spotify_api: SpotifyApi, } impl StoreData { @@ -24,6 +26,7 @@ impl StoreData { minecraft_data_rs::api::versions::latest_stable().unwrap(), ), music_queues: HashMap::new(), + spotify_api: SpotifyApi::new(), } } }