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