feat: add youtube search #8
| @ -14,6 +14,7 @@ rspotify = { version = "0.11.5", features = ["default"]} | ||||
| sentry = "0.26.0" | ||||
| invidious = "0.2.1" | ||||
| chrono = "0.4.19" | ||||
| itertools = "0.10.3" | ||||
| 
 | ||||
| [dev-dependencies] | ||||
| tokio-test = "0.4.2" | ||||
|  | ||||
							
								
								
									
										139
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										139
									
								
								src/main.rs
									
									
									
									
									
								
							| @ -1,20 +1,20 @@ | ||||
| use log::LevelFilter; | ||||
| use log::{info, log, LevelFilter}; | ||||
| use search::spotify; | ||||
| use sentry::ClientInitGuard; | ||||
| use std::env; | ||||
| use std::sync::Arc; | ||||
| use teloxide::prelude::*; | ||||
| 
 | ||||
| use crate::spotify::{PlayableKind, TrackInfo}; | ||||
| use spotify::ContentKind::Track; | ||||
| use search::spotify::ContentKind::Track; | ||||
| use search::spotify::{PlayableKind, TrackInfo}; | ||||
| 
 | ||||
| use crate::spotify::ContentKind::{Album, Episode, Playlist, Podcast}; | ||||
| use crate::utils::{human_readable_duration, truncate_with_dots}; | ||||
| use crate::search::get_spotify_kind; | ||||
| use crate::spotify::ContentKind; | ||||
| use search::spotify::ContentKind::{Album, Episode, Playlist, Podcast}; | ||||
| use tgformatter::utils::{human_readable_duration, truncate_with_dots}; | ||||
| 
 | ||||
| mod engine; | ||||
| mod spotify; | ||||
| mod utils; | ||||
| mod youtube; | ||||
| 
 | ||||
| static MAX_ARTISTS_CHARS: usize = 140; | ||||
| mod search; | ||||
| mod tgformatter; | ||||
| 
 | ||||
| #[tokio::main] | ||||
| async fn main() { | ||||
| @ -48,94 +48,39 @@ async fn main() { | ||||
| 
 | ||||
|     let bot = Bot::from_env().auto_send(); | ||||
|     teloxide::repl(bot, |message: Message, bot: AutoSend<Bot>| async move { | ||||
|         let text = message.text().and_then(spotify::get_entry_kind); | ||||
|         match text { | ||||
|             Some(spotify) => { | ||||
|                 let spotify_client = spotify::Client::new().await; | ||||
|                 match spotify { | ||||
|                     Track(id) => { | ||||
|                         log::debug!("Parsing spotify song: {}", id); | ||||
|                         let track_info = spotify_client.get_track(&id).await; | ||||
|                         match track_info { | ||||
|                             Some(info) => { | ||||
|                                 let reply = format!( | ||||
|                                     "Track information:\n\ | ||||
|                                 🎵 Track name: {}\n\ | ||||
|                                 🧑🎤 Artist(s): {}\n\ | ||||
|                                 ⏳ Duration: {}",
 | ||||
|                                     info.name, | ||||
|                                     truncate_with_dots(info.artists.join(", "), MAX_ARTISTS_CHARS), | ||||
|                                     human_readable_duration(info.duration) | ||||
|                                 ); | ||||
|                                 bot.send_message(message.chat.id, reply) | ||||
|                                     .reply_to_message_id(message.id) | ||||
|                                     .await?; | ||||
|                                 Some(respond(())) | ||||
|                             } | ||||
|                             None => None, | ||||
|                         } | ||||
|                     } | ||||
|                     Album(id) => { | ||||
|                         log::debug!("Parsing spotify album: {}", id); | ||||
|                         let album_info = spotify_client.get_album(&id).await; | ||||
|                         match album_info { | ||||
|                             Some(info) => { | ||||
|                                 let mut reply = format!( | ||||
|                                     "Album information:\n\ | ||||
|                                     🎵 Album name: {}\n\ | ||||
|                                     🧑🎤 {} artist(s): {}",
 | ||||
|                                     info.name, | ||||
|                                     info.artists.len(), | ||||
|                                     truncate_with_dots(info.artists.join(", "), MAX_ARTISTS_CHARS) | ||||
|                                 ); | ||||
|                                 if !info.genres.is_empty() { | ||||
|                                     reply.push_str( | ||||
|                                         format!("\n💿 Genre(s): {}", info.genres.join(", ")) | ||||
|                                             .as_str(), | ||||
|                                     ); | ||||
|                                 } | ||||
|                                 bot.send_message(message.chat.id, reply) | ||||
|                                     .reply_to_message_id(message.id) | ||||
|                                     .await?; | ||||
|                                 Some(respond(())) | ||||
|                             } | ||||
|                             None => None, | ||||
|                         } | ||||
|                     } | ||||
|                     Playlist(id) => { | ||||
|                         log::debug!("Parsing spotify playlist: {}", id); | ||||
|                         let playlist_info = spotify_client.get_playlist(&id).await; | ||||
|                         match playlist_info { | ||||
|                             Some(info) => { | ||||
|                                 let reply = format!( | ||||
|                                     "Playlist information:\n\ | ||||
|                                     ✒️ Playlist name: {}\n\ | ||||
|                                     🧑🎤 {} artist(s): {}",
 | ||||
|                                     info.name, | ||||
|                                     info.artists.len(), | ||||
|                                     truncate_with_dots(info.artists.join(", "), MAX_ARTISTS_CHARS) | ||||
|                                 ); | ||||
|                                 bot.send_message(message.chat.id, reply) | ||||
|                                     .reply_to_message_id(message.id) | ||||
|                                     .await?; | ||||
|                                 Some(respond(())) | ||||
|                             } | ||||
|                             None => None, | ||||
|                         } | ||||
|                     } | ||||
|                     Episode(id) => { | ||||
|                         log::warn!("Support for episodes ({}) has not be implemented yet!", id); | ||||
|                         None | ||||
|                     } | ||||
|                     Podcast(id) => { | ||||
|                         log::warn!("Support for podcasts ({}) has not be implemented yet!", id); | ||||
|                         None | ||||
|                     } | ||||
|         let music_engine = search::Engine::new().await; | ||||
|         let opt_text_message = message.text(); | ||||
|         if opt_text_message.is_none() { | ||||
|             return respond(()); | ||||
|         } | ||||
|         let text_message = opt_text_message.unwrap(); | ||||
|         let content_kind = opt_text_message.and_then(|x| get_spotify_kind(x)); | ||||
|         let option_reply = match content_kind { | ||||
|             None => return respond(()), | ||||
|             Some(content) => match content { | ||||
|                 Track(id) => { | ||||
|                     info!("Processing song with spotify id: {}", id); | ||||
|                     let track_item = music_engine.get_song_from_spotify_id(text_message).await; | ||||
|                     tgformatter::format_track_message(track_item) | ||||
|                 } | ||||
|             } | ||||
|             None => None, | ||||
|                 Album(id) => { | ||||
|                     info!("Processing album with spotify id: {}", id); | ||||
|                     let album_item = music_engine.get_album_by_spotify_id(text_message).await; | ||||
|                     tgformatter::format_album_message(album_item) | ||||
|                 } | ||||
|                 _ => None, | ||||
|             }, | ||||
|         }; | ||||
|         respond(()) | ||||
| 
 | ||||
|         if option_reply.is_some() { | ||||
|             info!("Got value to send back"); | ||||
|             let reply = option_reply.unwrap(); | ||||
|             bot.send_message(message.chat.id, reply) | ||||
|                 .reply_to_message_id(message.id) | ||||
|                 .await?; | ||||
|         } | ||||
| 
 | ||||
|         return respond(()); | ||||
|     }) | ||||
|     .await; | ||||
| 
 | ||||
|  | ||||
| @ -1,15 +1,25 @@ | ||||
| use crate::spotify::ContentKind; | ||||
| use crate::youtube::Video; | ||||
| use crate::{spotify, youtube, TrackInfo}; | ||||
| use crate::spotify::{get_entry_kind, AlbumInfo}; | ||||
| use crate::TrackInfo; | ||||
| use log::info; | ||||
| use spotify::ContentKind; | ||||
| use youtube::Video; | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| pub mod spotify; | ||||
| mod youtube; | ||||
| 
 | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub(crate) struct TrackItem { | ||||
|     spotify_track: Option<TrackInfo>, | ||||
|     youtube_track: Option<Vec<Video>>, | ||||
|     pub(crate) spotify_track: Option<TrackInfo>, | ||||
|     pub(crate) youtube_track: Option<Vec<Video>>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub(crate) struct AlbumItem { | ||||
|     pub(crate) spotify_track: Option<AlbumInfo>, | ||||
| } | ||||
| 
 | ||||
| // The enum holds all the currently supported type of Id which the engine can search for
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub(crate) enum ServiceIdKind { | ||||
|     Spotify(String), | ||||
|     Youtube(String), | ||||
| @ -19,14 +29,14 @@ pub(crate) enum ServiceIdKind { | ||||
| // This struct will allow us in the future to search, cache and store data and metadata regarding
 | ||||
| // tracks, albums and playlists
 | ||||
| #[derive(Debug, Clone)] | ||||
| pub(crate) struct MusicEngine { | ||||
| pub(crate) struct Engine { | ||||
|     spotify: spotify::Client, | ||||
|     youtube: youtube::Client, | ||||
| } | ||||
| 
 | ||||
| impl MusicEngine { | ||||
| impl Engine { | ||||
|     pub(crate) async fn new() -> Self { | ||||
|         MusicEngine { | ||||
|         Engine { | ||||
|             spotify: spotify::Client::new().await, | ||||
|             youtube: youtube::Client::new().await, | ||||
|         } | ||||
| @ -36,18 +46,18 @@ impl MusicEngine { | ||||
|         spotify_client: spotify::Client, | ||||
|         youtube_client: youtube::Client, | ||||
|     ) -> Self { | ||||
|         MusicEngine { | ||||
|         Engine { | ||||
|             spotify: spotify_client, | ||||
|             youtube: youtube_client, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) async fn search_song_by_name(&self, name: &str) { | ||||
|     pub(crate) async fn get_song_by_name(&self, name: &str) -> TrackItem { | ||||
|         todo!("In the future it would be possible to search for all metadata on a record from this call") | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) async fn search_song_by_id(&self, id: &str) -> Option<TrackItem> { | ||||
|         let entry_kind = spotify::get_entry_kind(id); | ||||
|     pub(crate) async fn get_song_from_spotify_id(&self, message: &str) -> TrackItem { | ||||
|         let entry_kind = spotify::get_entry_kind(message); | ||||
|         let track_info = match entry_kind { | ||||
|             Some(entry) => match entry { | ||||
|                 ContentKind::Track(id) => self.spotify.get_track(id.as_str()).await, | ||||
| @ -62,10 +72,10 @@ impl MusicEngine { | ||||
|                 .youtube | ||||
|                 .search_video( | ||||
|                     format!( | ||||
|                         "{} {}", | ||||
|                         "{}{}", | ||||
|                         ti.artists | ||||
|                             .get(0) | ||||
|                             .map(|artist| format!("{} -", artist)) | ||||
|                             .map(|artist| format!("{} - ", artist)) | ||||
|                             .unwrap_or("".to_string()), | ||||
|                         ti.name | ||||
|                     ) | ||||
| @ -78,13 +88,36 @@ impl MusicEngine { | ||||
|                 Ok(search) => Some(search), | ||||
|             }; | ||||
| 
 | ||||
|             return Some(TrackItem { | ||||
|             return TrackItem { | ||||
|                 spotify_track: Some(ti), | ||||
|                 youtube_track: youtube_search.map(|search| search.items), | ||||
|             }); | ||||
|             }; | ||||
|         } | ||||
|         return None; | ||||
|         return TrackItem { | ||||
|             spotify_track: None, | ||||
|             youtube_track: None, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) async fn get_album_by_spotify_id(&self, message: &str) -> AlbumItem { | ||||
|         let entry_kind = spotify::get_entry_kind(message); | ||||
| 
 | ||||
|         let album_info = match entry_kind { | ||||
|             Some(entry) => match entry { | ||||
|                 ContentKind::Album(id) => self.spotify.get_album(id.as_str()).await, | ||||
|                 _ => None, | ||||
|             }, | ||||
|             None => None, | ||||
|         }; | ||||
| 
 | ||||
|         AlbumItem { | ||||
|             spotify_track: album_info, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub(crate) fn get_spotify_kind(spotify_id: &str) -> Option<ContentKind> { | ||||
|     get_entry_kind(spotify_id) | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| @ -6,7 +6,7 @@ use std::any::Any; | ||||
| use std::sync::Arc; | ||||
| use std::time::Duration; | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub enum ContentKind { | ||||
|     Track(String), | ||||
|     Album(String), | ||||
| @ -15,20 +15,20 @@ pub enum ContentKind { | ||||
|     Episode(String), | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub enum PlayableKind { | ||||
|     Track(TrackInfo), | ||||
|     Podcast(EpisodeInfo), | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub struct TrackInfo { | ||||
|     pub(crate) name: String, | ||||
|     pub(crate) artists: Vec<String>, | ||||
|     pub(crate) duration: Duration, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub struct EpisodeInfo { | ||||
|     pub(crate) name: String, | ||||
|     pub(crate) show: String, | ||||
| @ -38,7 +38,7 @@ pub struct EpisodeInfo { | ||||
|     pub(crate) release_date: String, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub struct AlbumInfo { | ||||
|     pub(crate) name: String, | ||||
|     pub(crate) artists: Vec<String>, | ||||
| @ -46,7 +46,7 @@ pub struct AlbumInfo { | ||||
|     pub(crate) tracks: Vec<TrackInfo>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub struct PlaylistInfo { | ||||
|     pub(crate) name: String, | ||||
|     pub(crate) artists: Vec<String>, | ||||
| @ -1,26 +1,27 @@ | ||||
| use std::error::Error; | ||||
| use std::sync::Arc; | ||||
| 
 | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub(crate) struct VideoSearch { | ||||
|     pub(crate) items: Vec<Video>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] | ||||
| pub(crate) struct Video { | ||||
|     title: String, | ||||
|     video_id: String, | ||||
|     author: String, | ||||
|     author_id: String, | ||||
|     author_url: String, | ||||
|     length_seconds: u64, | ||||
|     description: String, | ||||
|     description_html: String, | ||||
|     view_count: u64, | ||||
|     published: u64, | ||||
|     published_text: String, | ||||
|     live_now: bool, | ||||
|     paid: bool, | ||||
|     premium: bool, | ||||
|     pub(crate) title: String, | ||||
|     pub(crate) video_id: String, | ||||
|     pub(crate) author: String, | ||||
|     pub(crate) author_id: String, | ||||
|     pub(crate) author_url: String, | ||||
|     pub(crate) length_seconds: u64, | ||||
|     pub(crate) description: String, | ||||
|     pub(crate) description_html: String, | ||||
|     pub(crate) view_count: u64, | ||||
|     pub(crate) published: u64, | ||||
|     pub(crate) published_text: String, | ||||
|     pub(crate) live_now: bool, | ||||
|     pub(crate) paid: bool, | ||||
|     pub(crate) premium: bool, | ||||
| } | ||||
| 
 | ||||
| type SearchSortBy = str; | ||||
							
								
								
									
										132
									
								
								src/tgformatter/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/tgformatter/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,132 @@ | ||||
| use crate::search::{AlbumItem, TrackItem}; | ||||
| use crate::TrackInfo; | ||||
| use log::{info, log}; | ||||
| use std::borrow::Borrow; | ||||
| use std::collections::HashSet; | ||||
| use std::fmt::format; | ||||
| use std::time::Duration; | ||||
| use teloxide::repl; | ||||
| 
 | ||||
| pub mod utils; | ||||
| 
 | ||||
| static MAX_ARTISTS_CHARS: usize = 140; | ||||
| 
 | ||||
| pub(crate) fn format_track_message(track_info: TrackItem) -> Option<String> { | ||||
|     // Let's avoid copying all the structure...we place it in the heap and pass the pointer to all the other functions
 | ||||
|     let boxed_info = Box::new(track_info); | ||||
|     if boxed_info.spotify_track.is_none() && boxed_info.youtube_track.is_none() { | ||||
|         return None; | ||||
|     } | ||||
| 
 | ||||
|     let mut reply = format!( | ||||
|         "Track information:\n\ | ||||
|                                 🎵 Track name: {}\n\ | ||||
|                                 🧑🎤 Artist(s): {}\n\ | ||||
|                                 ⏳ Duration: {}",
 | ||||
|         get_track_name(boxed_info.clone()), | ||||
|         get_track_artists(boxed_info.clone()), | ||||
|         get_track_duration(boxed_info.clone()) | ||||
|     ); | ||||
| 
 | ||||
|     let possible_first_video_link = boxed_info | ||||
|         .youtube_track | ||||
|         .and_then(|x| x.get(0).map(|x| format!("https://youtu.be/{}", x.video_id))); | ||||
| 
 | ||||
|     if possible_first_video_link.is_some() { | ||||
|         reply.push_str( | ||||
|             format!("\n\n🔗Youtube link: {}", possible_first_video_link.unwrap()).as_str(), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     Some(reply) | ||||
| } | ||||
| 
 | ||||
| pub(crate) fn format_album_message(album_info: AlbumItem) -> Option<String> { | ||||
|     let boxed_info = Box::new(album_info); | ||||
|     if boxed_info.spotify_track.is_none() { | ||||
|         return None; | ||||
|     } | ||||
| 
 | ||||
|     let mut artists = get_album_artists(boxed_info.clone()); | ||||
|     let mut artists_names = "Unknown artist list".to_string(); | ||||
| 
 | ||||
|     if artists.len() > 0 { | ||||
|         artists_names = itertools::join(&artists, ", "); | ||||
|     } | ||||
| 
 | ||||
|     let mut reply = format!( | ||||
|         "Album information:\n\ | ||||
|                                     🎵 Album name: {}\n\ | ||||
|                                     🧑🎤 {} artist(s): {}",
 | ||||
|         get_album_name(boxed_info.clone()), | ||||
|         artists.len(), | ||||
|         artists_names | ||||
|     ); | ||||
| 
 | ||||
|     let mut album_genres = get_album_genres(boxed_info.clone()); | ||||
|     if album_genres.len() > 0 { | ||||
|         reply.push_str(format!("\n💿 Genre(s): {}", itertools::join(&artists, ", ")).as_str()); | ||||
|     } | ||||
| 
 | ||||
|     return Some(reply); | ||||
| } | ||||
| 
 | ||||
| fn get_album_name(ai: Box<AlbumItem>) -> String { | ||||
|     ai.spotify_track | ||||
|         .map(|s| s.name) | ||||
|         .unwrap_or("Unknown album name".to_string()) | ||||
| } | ||||
| 
 | ||||
| fn get_album_genres(ai: Box<AlbumItem>) -> HashSet<String> { | ||||
|     if ai.spotify_track.is_some() { | ||||
|         return ai.spotify_track.unwrap().genres.into_iter().collect(); | ||||
|     } | ||||
| 
 | ||||
|     return HashSet::new(); | ||||
| } | ||||
| 
 | ||||
| fn get_album_artists(ai: Box<AlbumItem>) -> HashSet<String> { | ||||
|     if ai.spotify_track.is_some() { | ||||
|         return ai.spotify_track.unwrap().artists.into_iter().collect(); | ||||
|     } | ||||
| 
 | ||||
|     return HashSet::new(); | ||||
| } | ||||
| 
 | ||||
| fn get_track_name(ti: Box<TrackItem>) -> String { | ||||
|     if ti.spotify_track.is_some() { | ||||
|         ti.spotify_track.unwrap().name | ||||
|     } else { | ||||
|         ti.youtube_track | ||||
|             .and_then(|youtube_tracks| youtube_tracks.get(0).map(|t| t.title.clone())) | ||||
|             .unwrap_or("Unknown title".to_string()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn get_track_artists(ti: Box<TrackItem>) -> String { | ||||
|     if ti.spotify_track.is_some() { | ||||
|         utils::truncate_with_dots( | ||||
|             ti.spotify_track.unwrap().artists.join(", "), | ||||
|             MAX_ARTISTS_CHARS, | ||||
|         ) | ||||
|     } else { | ||||
|         ti.youtube_track | ||||
|             .and_then(|youtube_tracks| youtube_tracks.get(0).map(|t| t.author.clone())) | ||||
|             .unwrap_or("Unknown artist".to_string()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn get_track_duration(ti: Box<TrackItem>) -> String { | ||||
|     if ti.spotify_track.is_some() { | ||||
|         utils::human_readable_duration(ti.spotify_track.unwrap().duration) | ||||
|     } else { | ||||
|         ti.youtube_track | ||||
|             .map(|youtube_tracks| { | ||||
|                 let seconds_in_string = | ||||
|                     youtube_tracks.get(0).map(|t| t.length_seconds).unwrap_or(0); | ||||
| 
 | ||||
|                 utils::human_readable_duration(Duration::from_secs(seconds_in_string)) | ||||
|             }) | ||||
|             .unwrap_or("Unknown duration".to_string()) | ||||
|     } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user