feat: add youtube search for tracks. Still WIP

* missing tests, docs and other stuff
* missing playlist porting and other content too (maybe)
pull/8/head
Davide Polonio 2022-06-23 18:33:16 +02:00
parent ba2b689e77
commit ff86b4c26d
7 changed files with 249 additions and 137 deletions

View File

@ -14,6 +14,7 @@ rspotify = { version = "0.11.5", features = ["default"]}
sentry = "0.26.0" sentry = "0.26.0"
invidious = "0.2.1" invidious = "0.2.1"
chrono = "0.4.19" chrono = "0.4.19"
itertools = "0.10.3"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4.2" tokio-test = "0.4.2"

View File

@ -1,20 +1,20 @@
use log::LevelFilter; use log::{info, log, LevelFilter};
use search::spotify;
use sentry::ClientInitGuard; use sentry::ClientInitGuard;
use std::env; use std::env;
use std::sync::Arc;
use teloxide::prelude::*; use teloxide::prelude::*;
use crate::spotify::{PlayableKind, TrackInfo}; use search::spotify::ContentKind::Track;
use spotify::ContentKind::Track; use search::spotify::{PlayableKind, TrackInfo};
use crate::spotify::ContentKind::{Album, Episode, Playlist, Podcast}; use crate::search::get_spotify_kind;
use crate::utils::{human_readable_duration, truncate_with_dots}; use crate::spotify::ContentKind;
use search::spotify::ContentKind::{Album, Episode, Playlist, Podcast};
use tgformatter::utils::{human_readable_duration, truncate_with_dots};
mod engine; mod search;
mod spotify; mod tgformatter;
mod utils;
mod youtube;
static MAX_ARTISTS_CHARS: usize = 140;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@ -48,94 +48,39 @@ async fn main() {
let bot = Bot::from_env().auto_send(); let bot = Bot::from_env().auto_send();
teloxide::repl(bot, |message: Message, bot: AutoSend<Bot>| async move { teloxide::repl(bot, |message: Message, bot: AutoSend<Bot>| async move {
let text = message.text().and_then(spotify::get_entry_kind); let music_engine = search::Engine::new().await;
match text { let opt_text_message = message.text();
Some(spotify) => { if opt_text_message.is_none() {
let spotify_client = spotify::Client::new().await; return respond(());
match spotify { }
Track(id) => { let text_message = opt_text_message.unwrap();
log::debug!("Parsing spotify song: {}", id); let content_kind = opt_text_message.and_then(|x| get_spotify_kind(x));
let track_info = spotify_client.get_track(&id).await; let option_reply = match content_kind {
match track_info { None => return respond(()),
Some(info) => { Some(content) => match content {
let reply = format!( Track(id) => {
"Track information:\n\ info!("Processing song with spotify id: {}", id);
🎵 Track name: {}\n\ let track_item = music_engine.get_song_from_spotify_id(text_message).await;
🧑🎤 Artist(s): {}\n\ tgformatter::format_track_message(track_item)
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
}
} }
} Album(id) => {
None => None, 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; .await;

View File

@ -1,15 +1,25 @@
use crate::spotify::ContentKind; use crate::spotify::{get_entry_kind, AlbumInfo};
use crate::youtube::Video; use crate::TrackInfo;
use crate::{spotify, youtube, 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 { pub(crate) struct TrackItem {
spotify_track: Option<TrackInfo>, pub(crate) spotify_track: Option<TrackInfo>,
youtube_track: Option<Vec<Video>>, 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 // 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 { pub(crate) enum ServiceIdKind {
Spotify(String), Spotify(String),
Youtube(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 // This struct will allow us in the future to search, cache and store data and metadata regarding
// tracks, albums and playlists // tracks, albums and playlists
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct MusicEngine { pub(crate) struct Engine {
spotify: spotify::Client, spotify: spotify::Client,
youtube: youtube::Client, youtube: youtube::Client,
} }
impl MusicEngine { impl Engine {
pub(crate) async fn new() -> Self { pub(crate) async fn new() -> Self {
MusicEngine { Engine {
spotify: spotify::Client::new().await, spotify: spotify::Client::new().await,
youtube: youtube::Client::new().await, youtube: youtube::Client::new().await,
} }
@ -36,18 +46,18 @@ impl MusicEngine {
spotify_client: spotify::Client, spotify_client: spotify::Client,
youtube_client: youtube::Client, youtube_client: youtube::Client,
) -> Self { ) -> Self {
MusicEngine { Engine {
spotify: spotify_client, spotify: spotify_client,
youtube: youtube_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") 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> { pub(crate) async fn get_song_from_spotify_id(&self, message: &str) -> TrackItem {
let entry_kind = spotify::get_entry_kind(id); let entry_kind = spotify::get_entry_kind(message);
let track_info = match entry_kind { let track_info = match entry_kind {
Some(entry) => match entry { Some(entry) => match entry {
ContentKind::Track(id) => self.spotify.get_track(id.as_str()).await, ContentKind::Track(id) => self.spotify.get_track(id.as_str()).await,
@ -62,10 +72,10 @@ impl MusicEngine {
.youtube .youtube
.search_video( .search_video(
format!( format!(
"{} {}", "{}{}",
ti.artists ti.artists
.get(0) .get(0)
.map(|artist| format!("{} -", artist)) .map(|artist| format!("{} - ", artist))
.unwrap_or("".to_string()), .unwrap_or("".to_string()),
ti.name ti.name
) )
@ -78,13 +88,36 @@ impl MusicEngine {
Ok(search) => Some(search), Ok(search) => Some(search),
}; };
return Some(TrackItem { return TrackItem {
spotify_track: Some(ti), spotify_track: Some(ti),
youtube_track: youtube_search.map(|search| search.items), 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)] #[cfg(test)]

View File

@ -6,7 +6,7 @@ use std::any::Any;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ContentKind { pub enum ContentKind {
Track(String), Track(String),
Album(String), Album(String),
@ -15,20 +15,20 @@ pub enum ContentKind {
Episode(String), Episode(String),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PlayableKind { pub enum PlayableKind {
Track(TrackInfo), Track(TrackInfo),
Podcast(EpisodeInfo), Podcast(EpisodeInfo),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TrackInfo { pub struct TrackInfo {
pub(crate) name: String, pub(crate) name: String,
pub(crate) artists: Vec<String>, pub(crate) artists: Vec<String>,
pub(crate) duration: Duration, pub(crate) duration: Duration,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EpisodeInfo { pub struct EpisodeInfo {
pub(crate) name: String, pub(crate) name: String,
pub(crate) show: String, pub(crate) show: String,
@ -38,7 +38,7 @@ pub struct EpisodeInfo {
pub(crate) release_date: String, pub(crate) release_date: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AlbumInfo { pub struct AlbumInfo {
pub(crate) name: String, pub(crate) name: String,
pub(crate) artists: Vec<String>, pub(crate) artists: Vec<String>,
@ -46,7 +46,7 @@ pub struct AlbumInfo {
pub(crate) tracks: Vec<TrackInfo>, pub(crate) tracks: Vec<TrackInfo>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PlaylistInfo { pub struct PlaylistInfo {
pub(crate) name: String, pub(crate) name: String,
pub(crate) artists: Vec<String>, pub(crate) artists: Vec<String>,

View File

@ -1,26 +1,27 @@
use std::error::Error; use std::error::Error;
use std::sync::Arc; use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct VideoSearch { pub(crate) struct VideoSearch {
pub(crate) items: Vec<Video>, pub(crate) items: Vec<Video>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct Video { pub(crate) struct Video {
title: String, pub(crate) title: String,
video_id: String, pub(crate) video_id: String,
author: String, pub(crate) author: String,
author_id: String, pub(crate) author_id: String,
author_url: String, pub(crate) author_url: String,
length_seconds: u64, pub(crate) length_seconds: u64,
description: String, pub(crate) description: String,
description_html: String, pub(crate) description_html: String,
view_count: u64, pub(crate) view_count: u64,
published: u64, pub(crate) published: u64,
published_text: String, pub(crate) published_text: String,
live_now: bool, pub(crate) live_now: bool,
paid: bool, pub(crate) paid: bool,
premium: bool, pub(crate) premium: bool,
} }
type SearchSortBy = str; type SearchSortBy = str;

132
src/tgformatter/mod.rs Normal file
View 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())
}
}