diff --git a/.idea/runConfigurations/Run_all_tests.xml b/.idea/runConfigurations/Run_all_tests.xml new file mode 100644 index 0000000..abc81eb --- /dev/null +++ b/.idea/runConfigurations/Run_all_tests.xml @@ -0,0 +1,19 @@ + + + + diff --git a/Cargo.toml b/Cargo.toml index 8bfef7d..20c965f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,22 @@ [package] name = "songlify" -version = "0.3.3" +version = "0.3.4" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -teloxide = { version = "0.9.1", features = ["auto-send", "macros"] } +teloxide = { version = "0.9.2", features = ["auto-send", "macros"] } log = "0.4.17" pretty_env_logger = "0.4.0" -tokio = { version = "1.18.2", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1.20.0", features = ["rt-multi-thread", "macros"] } rspotify = { version = "0.11.5", features = ["default"]} -sentry = "0.26.0" +sentry = "0.27.0" +invidious = "0.2.1" +chrono = "0.4.19" +itertools = "0.10.3" +async-trait = "0.1.56" + +[dev-dependencies] +tokio-test = "0.4.2" +mockall = "0.11.1" diff --git a/Dockerfile b/Dockerfile index b372676..aa4c59a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM rust:1.59.0-slim-bullseye as builder +FROM rust:1.62.0-slim-bullseye as builder WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl-dev=1.1.1k-1+deb11u2 \ + libssl-dev=1.1.1n-0+deb11u3 \ pkg-config=0.29.2-1 COPY ./ /build diff --git a/src/main.rs b/src/main.rs index 1a8ff7c..7505725 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,17 @@ -use log::LevelFilter; +use log::{debug, info, LevelFilter}; +use search::spotify; use sentry::ClientInitGuard; use std::env; use teloxide::prelude::*; -use crate::spotify::{PlayableKind, TrackInfo}; -use spotify::SpotifyKind::Track; +use search::spotify::ContentKind::Track; +use search::spotify::TrackInfo; -use crate::spotify::SpotifyKind::{Album, Episode, Playlist, Podcast}; -use crate::utils::{human_readable_duration, truncate_with_dots}; +use crate::search::get_spotify_kind; +use search::spotify::ContentKind::{Album, Playlist}; -mod spotify; -mod utils; - -static MAX_ARTISTS_CHARS: usize = 140; +mod search; +mod tgformatter; #[tokio::main] async fn main() { @@ -46,125 +45,51 @@ async fn main() { let bot = Bot::from_env().auto_send(); teloxide::repl(bot, |message: Message, bot: AutoSend| async move { - let text = message.text().and_then(spotify::get_entry_kind); - match text { - Some(spotify) => { - let spotify_client = spotify::get_client().await; - match spotify { - Track(id) => { - log::debug!("Parsing spotify song: {}", id); - let track_info = spotify::get_track(spotify_client, &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::get_album(spotify_client, &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::get_playlist(spotify_client, &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_from_spotify_id(text_message).await; + tgformatter::format_album_message(album_item) + } + Playlist(id) => { + info!("Processing playlist with spotify id: {}", id); + let playlist_item = music_engine + .get_playlist_from_spotify_id(text_message) + .await; + tgformatter::format_playlist_message(playlist_item) + } + _ => { + log::warn!("This kind of media has been not supported yet"); + None + } + }, }; - respond(()) + + if option_reply.is_some() { + debug!("Got reply 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; log::info!("Exiting..."); } - -fn add_track_section_for_playlist(tracks: Vec, reply: String) -> String { - if !tracks.is_empty() { - let songs = tracks - .iter() - .map(|x| match x { - PlayableKind::Track(t) => t.name.clone(), - PlayableKind::Podcast(e) => e.name.clone() - } + "\n") - .collect::(); - reply - .clone() - .push_str(format!("\n🎶 {} Track(s): {}", tracks.len(), songs).as_str()) - } - reply -} - -fn add_track_section(tracks: Vec, reply: String) -> String { - if !tracks.is_empty() { - let songs = tracks - .iter() - .map(|x| x.name.clone() + "\n") - .collect::(); - reply - .clone() - .push_str(format!("\n🎶 {} Track(s): {}", tracks.len(), songs).as_str()) - } - reply -} diff --git a/src/search/mod.rs b/src/search/mod.rs new file mode 100644 index 0000000..a631ab9 --- /dev/null +++ b/src/search/mod.rs @@ -0,0 +1,204 @@ +use crate::spotify::{get_entry_kind, AlbumInfo, PlaylistInfo}; +use crate::TrackInfo; +use spotify::ContentKind; +use std::collections::HashSet; +use youtube::Video; + +pub mod spotify; +mod youtube; + +#[cfg(test)] +mod tests; + +pub(crate) trait ArtistComposed { + fn get_artists_name(&self) -> HashSet; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct TrackItem { + pub(crate) spotify_track: Option, + pub(crate) youtube_track: Option>, +} + +impl ArtistComposed for TrackItem { + fn get_artists_name(&self) -> HashSet { + if self.spotify_track.is_some() { + return self + .spotify_track + .clone() + .unwrap() + .artists + .into_iter() + .collect(); + } else { + self.youtube_track + .clone() + .and_then(|youtube_tracks| { + youtube_tracks.get(0).map(|t| { + let mut hash = HashSet::new(); + hash.insert(t.author.clone()); + return hash; + }) + }) + .unwrap_or_else(|| { + let mut hash = HashSet::new(); + hash.insert("Unknown artist".to_string()); + return hash; + }) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct AlbumItem { + pub(crate) spotify_album: Option, +} + +impl ArtistComposed for AlbumItem { + fn get_artists_name(&self) -> HashSet { + if self.spotify_album.is_some() { + return self + .spotify_album + .clone() + .unwrap() + .artists + .into_iter() + .collect(); + } + + return HashSet::new(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PlaylistItem { + pub(crate) spotify_playlist: Option, +} + +impl ArtistComposed for PlaylistItem { + fn get_artists_name(&self) -> HashSet { + if self.spotify_playlist.is_some() { + return self + .spotify_playlist + .clone() + .unwrap() + .artists + .into_iter() + .collect(); + } + + return HashSet::new(); + } +} + +// This struct will allow us in the future to search, cache and store data and metadata regarding +// tracks, albums and playlists +pub(crate) struct Engine { + spotify: Box, + youtube: Box, +} + +impl Engine { + pub(crate) async fn new() -> Self { + Engine { + spotify: Box::new(spotify::Client::new().await), + youtube: Box::new(youtube::Client::new().await), + } + } + + #[allow(dead_code)] + #[allow(unused_variables)] + #[cfg(test)] + pub(crate) fn new_with_dependencies( + spotify_client: Box, + youtube_client: Box, + ) -> Self { + Engine { + spotify: spotify_client, + youtube: youtube_client, + } + } + + 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 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, + _ => None, + }, + None => None, + }; + + if track_info.is_some() { + let ti = track_info.unwrap(); + let youtube_search = match self + .youtube + .search_video( + format!( + "{}{}", + ti.artists + .get(0) + .map(|artist| format!("{} - ", artist)) + .unwrap_or("".to_string()), + ti.name + ) + .as_str(), + None, + ) + .await + { + Err(_) => None, + Ok(search) => Some(search), + }; + + return TrackItem { + spotify_track: Some(ti), + youtube_track: youtube_search.map(|search| search.items), + }; + } + return TrackItem { + spotify_track: None, + youtube_track: None, + }; + } + + pub(crate) async fn get_album_from_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_album: album_info, + } + } + + pub(crate) async fn get_playlist_from_spotify_id(&self, message: &str) -> PlaylistItem { + let entry_kind = spotify::get_entry_kind(message); + + let playlist_info = match entry_kind { + Some(entry) => match entry { + ContentKind::Playlist(id) => self.spotify.get_playlist(id.as_str()).await, + _ => None, + }, + None => None, + }; + + PlaylistItem { + spotify_playlist: playlist_info, + } + } +} + +pub(crate) fn get_spotify_kind(spotify_id: &str) -> Option { + get_entry_kind(spotify_id) +} diff --git a/src/search/spotify/mod.rs b/src/search/spotify/mod.rs new file mode 100644 index 0000000..36e86a3 --- /dev/null +++ b/src/search/spotify/mod.rs @@ -0,0 +1,278 @@ +use async_trait::async_trait; +#[cfg(test)] +use mockall::{automock, mock, predicate::*}; +use rspotify::model::PlayableItem::{Episode, Track}; +use rspotify::model::{AlbumId, PlaylistId, TrackId}; +use rspotify::prelude::*; +use rspotify::{ClientCredsSpotify, Credentials}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg_attr(test, automock)] +#[async_trait] +pub(crate) trait SearchableClient { + async fn get_track(&self, id: &str) -> Option; + async fn get_album(&self, id: &str) -> Option; + async fn get_playlist(&self, id: &str) -> Option; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ContentKind { + Track(String), + Album(String), + Playlist(String), + Podcast(String), + Episode(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlayableKind { + Track(TrackInfo), + Podcast(EpisodeInfo), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TrackInfo { + pub(crate) name: String, + pub(crate) artists: Vec, + pub(crate) duration: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EpisodeInfo { + pub(crate) name: String, + pub(crate) show: String, + pub(crate) duration: Duration, + pub(crate) description: String, + pub(crate) languages: Vec, + pub(crate) release_date: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AlbumInfo { + pub(crate) name: String, + pub(crate) artists: Vec, + pub(crate) genres: Vec, + pub(crate) tracks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PlaylistInfo { + pub(crate) name: String, + pub(crate) artists: Vec, + pub(crate) tracks: Vec, + pub(crate) owner: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct Client { + client: Arc, +} + +impl Client { + pub(crate) async fn new() -> Self { + let spotify_creds = Credentials::from_env() + .expect("RSPOTIFY_CLIENT_ID and RSPOTIFY_CLIENT_SECRET not found."); + let mut spotify = ClientCredsSpotify::new(spotify_creds); + spotify.request_token().await.unwrap(); + Client { + client: Arc::new(spotify), + } + } + + #[allow(dead_code)] + #[allow(unused_variables)] + #[cfg(test)] + pub(crate) fn new_with_dependencies(client: ClientCredsSpotify) -> Self { + Client { + client: Arc::new(client), + } + } +} + +#[async_trait] +impl SearchableClient for Client { + async fn get_track(&self, id: &str) -> Option { + // FIXME should we really return Option here? We're hiding a possible error or a entry not found + let track_id = match TrackId::from_id(id) { + Ok(track) => track, + Err(_e) => return None, + }; + + match self.client.track(&track_id).await { + Ok(track) => Some(TrackInfo { + name: track.name, + artists: track.artists.iter().map(|x| x.name.clone()).collect(), + duration: track.duration, + }), + Err(_e) => return None, + } + } + + async fn get_album(&self, id: &str) -> Option { + let album_id = match AlbumId::from_id(id) { + Ok(album) => album, + Err(_e) => return None, + }; + + match self.client.album(&album_id).await { + Ok(album) => Some(AlbumInfo { + name: album.name, + artists: album.artists.iter().map(|x| x.name.clone()).collect(), + genres: album.genres, + tracks: album + .tracks + .items + .iter() + .map(|t| TrackInfo { + name: t.name.clone(), + artists: t.artists.iter().map(|x| x.name.clone()).collect(), + duration: t.duration, + }) + .collect(), + }), + Err(_e) => None, + } + } + + async fn get_playlist(&self, id: &str) -> Option { + let playlist_id = match PlaylistId::from_id(id) { + Ok(playlist) => playlist, + Err(_e) => return None, + }; + + match self.client.playlist(&playlist_id, None, None).await { + Ok(playlist) => Some(PlaylistInfo { + name: playlist.name, + artists: playlist + .tracks + .items + .iter() + .flat_map(|p| { + match &p.track { + Some(t) => match t { + Track(t) => t.artists.iter().map(|a| a.name.clone()).collect(), + Episode(e) => vec![e.show.publisher.clone()], + }, + None => Vec::new(), + } + .into_iter() + }) + .collect(), + tracks: playlist + .tracks + .items + .iter() + .map(|p| match &p.track { + Some(t) => match t { + Track(t) => Some(PlayableKind::Track(TrackInfo { + name: t.name.clone(), + artists: t.artists.iter().map(|a| a.name.clone()).collect(), + duration: t.duration, + })), + Episode(e) => Some(PlayableKind::Podcast(EpisodeInfo { + name: e.name.clone(), + show: e.show.name.clone(), + duration: e.duration, + description: e.description.clone(), + languages: e.languages.clone(), + release_date: e.release_date.clone(), + })), + }, + None => None, + }) + .filter(|i| i.is_some()) + .map(|i| i.unwrap()) + .collect(), + owner: playlist.owner.display_name, + }), + Err(_e) => None, + } + } +} + +fn get_id_in_url(url: &str) -> Option<&str> { + url.rsplit('/') + .next() + .and_then(|x| x.split(' ').next()) + .and_then(|x| x.split('?').next()) +} + +fn get_id_in_uri(uri: &str) -> Option<&str> { + uri.rsplit(':').next() +} + +pub fn get_entry_kind(uri: &str) -> Option { + // TODO WE SHOULD PROPERLY TEST THIS FUNCTION + if uri.contains("spotify:track:") { + let track_id = get_id_in_uri(uri); + return match track_id { + Some(id) => Some(ContentKind::Track(id.to_string())), + None => None, + }; + } + if uri.contains("https://open.spotify.com/track/") { + let track_id = get_id_in_url(uri); + return match track_id { + Some(id) => Some(ContentKind::Track(id.to_string())), + None => None, + }; + } + if uri.contains("spotify:album:") { + let track_id = get_id_in_uri(uri); + return match track_id { + Some(id) => Some(ContentKind::Album(id.to_string())), + None => None, + }; + } + if uri.contains("https://open.spotify.com/album/") { + let album_id = get_id_in_url(uri); + return match album_id { + Some(id) => Some(ContentKind::Album(id.to_string())), + None => None, + }; + } + if uri.contains("spotify:playlist:") { + let track_id = get_id_in_uri(uri); + return match track_id { + Some(id) => Some(ContentKind::Playlist(id.to_string())), + None => None, + }; + } + if uri.contains("https://open.spotify.com/playlist/") { + let playlist_id = get_id_in_url(uri); + return match playlist_id { + Some(id) => Some(ContentKind::Playlist(id.to_string())), + None => None, + }; + } + if uri.contains("spotify:show:") { + let track_id = get_id_in_uri(uri); + return match track_id { + Some(id) => Some(ContentKind::Album(id.to_string())), + None => None, + }; + } + if uri.contains("https://open.spotify.com/show/") { + let playlist_id = get_id_in_url(uri); + return match playlist_id { + Some(id) => Some(ContentKind::Podcast(id.to_string())), + None => None, + }; + } + if uri.contains("spotify:episode:") { + let track_id = get_id_in_uri(uri); + return match track_id { + Some(id) => Some(ContentKind::Album(id.to_string())), + None => None, + }; + } + if uri.contains("https://open.spotify.com/episode/") { + let playlist_id = get_id_in_url(uri); + return match playlist_id { + Some(id) => Some(ContentKind::Episode(id.to_string())), + None => None, + }; + } + return None; +} diff --git a/src/search/tests.rs b/src/search/tests.rs new file mode 100644 index 0000000..cc5c9fb --- /dev/null +++ b/src/search/tests.rs @@ -0,0 +1,147 @@ +use super::*; +use crate::search::youtube::VideoSearch; +use crate::spotify::PlayableKind; +use mockall::predicate; + +#[tokio::test] +async fn should_search_track_by_spotify_id() { + let spotify_id = "spotify:track:no-value-kek"; + let mut spotify_mock = spotify::MockSearchableClient::new(); + spotify_mock + .expect_get_track() + .with(predicate::eq("no-value-kek")) + .returning(|_id| { + Some(TrackInfo { + name: "A name".to_string(), + artists: vec!["Art1".to_string()], + duration: Default::default(), + }) + }); + let mut youtube_mock = youtube::MockSearchableClient::new(); + youtube_mock + .expect_search_video() + .returning(|_id, _sort_by| { + Ok(VideoSearch { + items: vec![Video { + title: "An example".to_string(), + video_id: "id123".to_string(), + author: "An Art".to_string(), + author_id: "artId123".to_string(), + author_url: "https://example.com".to_string(), + length_seconds: 42, + description: "A song".to_string(), + description_html: "A song 2".to_string(), + view_count: 0, + published: 0, + published_text: "".to_string(), + live_now: false, + paid: false, + premium: false, + }], + }) + }); + + let engine = Engine::new_with_dependencies(Box::new(spotify_mock), Box::new(youtube_mock)); + let got = engine.get_song_from_spotify_id(spotify_id).await; + + assert_eq!(true, got.spotify_track.is_some()); + let boxed_st = Box::new(got.spotify_track.unwrap()); + assert_eq!(1, boxed_st.artists.len()); + assert_eq!("Art1".to_string(), boxed_st.artists.get(0).unwrap().clone()); + assert_eq!("A name".to_string(), boxed_st.name); + + assert_eq!(true, got.youtube_track.is_some()); + let boxed_yt = Box::new(got.youtube_track.unwrap()); + assert_eq!(1, boxed_yt.len()); + let got_video = boxed_yt.get(0).unwrap(); + assert_eq!("An example".to_string(), got_video.title); +} + +#[tokio::test] +async fn should_search_album_by_spotify_id() { + let spotify_id = "spotify:album:no-value-kek"; + let mut spotify_mock = spotify::MockSearchableClient::new(); + spotify_mock + .expect_get_album() + .with(predicate::eq("no-value-kek")) + .returning(|_id| { + Some(AlbumInfo { + name: "An album".to_string(), + artists: vec!["Art1".to_string(), "Art2".to_string()], + genres: vec!["Rock".to_string(), "Hip-hop".to_string()], + tracks: vec![TrackInfo { + name: "Track info 1".to_string(), + artists: vec!["Art1".to_string()], + duration: Default::default(), + }], + }) + }); + let youtube_mock = youtube::MockSearchableClient::new(); + + let engine = Engine::new_with_dependencies(Box::new(spotify_mock), Box::new(youtube_mock)); + let got = engine.get_album_from_spotify_id(spotify_id).await; + + assert_eq!(true, got.spotify_album.is_some()); + let boxed_st = Box::new(got.spotify_album.unwrap()); + assert_eq!(2, boxed_st.artists.len()); + assert_eq!("Art1".to_string(), boxed_st.artists.get(0).unwrap().clone()); + assert_eq!("Art2".to_string(), boxed_st.artists.get(1).unwrap().clone()); + assert_eq!("An album".to_string(), boxed_st.name); + assert_eq!(2, boxed_st.genres.len()); + assert_eq!("Rock".to_string(), boxed_st.genres.get(0).unwrap().clone()); + assert_eq!( + "Hip-hop".to_string(), + boxed_st.genres.get(1).unwrap().clone() + ); + assert_eq!(1, boxed_st.tracks.len()); + assert_eq!( + TrackInfo { + name: "Track info 1".to_string(), + artists: vec!["Art1".to_string()], + duration: Default::default(), + }, + boxed_st.tracks.get(0).unwrap().clone() + ); +} + +#[tokio::test] +async fn should_search_playlist_by_spotify_id() { + let spotify_id = "spotify:playlist:no-value-kek"; + let mut spotify_mock = spotify::MockSearchableClient::new(); + spotify_mock + .expect_get_playlist() + .with(predicate::eq("no-value-kek")) + .returning(|_id| { + Some(PlaylistInfo { + name: "A playlist".to_string(), + artists: vec!["Art1".to_string(), "Art2".to_string()], + tracks: vec![PlayableKind::Track(TrackInfo { + name: "A track".to_string(), + artists: vec!["Art1".to_string()], + duration: Default::default(), + })], + owner: Some("Frodo".to_string()), + }) + }); + let youtube_mock = youtube::MockSearchableClient::new(); + + let engine = Engine::new_with_dependencies(Box::new(spotify_mock), Box::new(youtube_mock)); + let got = engine.get_playlist_from_spotify_id(spotify_id).await; + + assert_eq!(true, got.spotify_playlist.is_some()); + let boxed_st = Box::new(got.spotify_playlist.unwrap()); + assert_eq!(2, boxed_st.artists.len()); + assert_eq!("Art1".to_string(), boxed_st.artists.get(0).unwrap().clone()); + assert_eq!("Art2".to_string(), boxed_st.artists.get(1).unwrap().clone()); + assert_eq!("A playlist".to_string(), boxed_st.name); + assert_eq!(1, boxed_st.tracks.len()); + assert_eq!( + PlayableKind::Track(TrackInfo { + name: "A track".to_string(), + artists: vec!["Art1".to_string()], + duration: Default::default(), + }), + boxed_st.tracks.get(0).unwrap().clone() + ); + assert_eq!(Some("Frodo".to_string()), boxed_st.owner); +} diff --git a/src/search/youtube/mod.rs b/src/search/youtube/mod.rs new file mode 100644 index 0000000..e52bc35 --- /dev/null +++ b/src/search/youtube/mod.rs @@ -0,0 +1,161 @@ +use async_trait::async_trait; +#[cfg(test)] +use mockall::{automock, mock, predicate::*}; +use std::error::Error; +use std::sync::Arc; + +#[cfg_attr(test, automock)] +#[async_trait] +pub(crate) trait SearchableClient { + async fn search_video<'a>( + &self, + id: &str, + sort_by: Option<&'a SearchSortBy>, + ) -> Result>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct VideoSearch { + pub(crate) items: Vec