diff --git a/.dockerignore b/.dockerignore index 07827cc..96ef862 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,2 @@ target/ -.idea/ \ No newline at end of file +.idea/ diff --git a/.gitignore b/.gitignore index 62bd1a4..70b1c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk - diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..37c5bdd --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..1a0176a --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + diff --git a/.idea/google-java-format.xml b/.idea/google-java-format.xml new file mode 100644 index 0000000..7c69eed --- /dev/null +++ b/.idea/google-java-format.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml index bfecbb1..9360735 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,4 @@ - - - \ No newline at end of file + diff --git a/.idea/modules.xml b/.idea/modules.xml index 68e6503..c56df5b 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -5,4 +5,4 @@ - \ No newline at end of file + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml index 797acea..a1fe99e 100644 --- a/.idea/runConfigurations.xml +++ b/.idea/runConfigurations.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/.idea/sbt.xml b/.idea/sbt.xml new file mode 100644 index 0000000..a6f46a0 --- /dev/null +++ b/.idea/sbt.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..dcb6b8c 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c0be7d5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: check-merge-conflict + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +- repo: https://github.com/doublify/pre-commit-rust + rev: v1.0 + hooks: + - id: fmt + args: ['--verbose', '--'] + - id: cargo-check +- repo: https://github.com/IamTheFij/docker-pre-commit + rev: v2.0.1 + hooks: + - id: hadolint diff --git a/Cargo.toml b/Cargo.toml index 37f1f2b..673ed0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "songlify" -version = "0.1.0" +version = "0.2.2" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -10,4 +10,4 @@ teloxide = { version = "0.5", features = ["auto-send", "macros"] } log = "0.4" pretty_env_logger = "0.4.0" tokio = { version = "1.8", features = ["rt-multi-thread", "macros"] } -aspotify = "0.7.0" \ No newline at end of file +aspotify = "0.7.0" diff --git a/Dockerfile b/Dockerfile index 0dbde9c..aff8dea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,18 @@ -FROM rust:1.55.0-bullseye as builder +FROM rust:1.56.1-slim-bullseye as builder -# ENV RUSTFLAGS="-C target-feature=+crt-static" WORKDIR /build -RUN apt-get update && apt-get install -y \ - libssl-dev +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl-dev=1.1.1k-1+deb11u1 \ + pkg-config=0.29.2-1 COPY ./ /build RUN cargo build --release -FROM gcr.io/distroless/base +FROM gcr.io/distroless/base:latest-amd64 COPY --from=builder /build/target/release/songlify /usr/bin/songlify COPY --from=builder /lib/x86_64-linux-gnu/libgcc_s.so.1 /lib/x86_64-linux-gnu/ COPY --from=builder /usr/lib/x86_64-linux-gnu/libstdc++.so.6 /usr/lib/x86_64-linux-gnu/ -ENTRYPOINT /usr/bin/songlify \ No newline at end of file +ENTRYPOINT ["/usr/bin/songlify"] diff --git a/README.md b/README.md index b4b27b7..17eeeed 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Songlify -A Telegram bot that gives you information about the song other people share with you in chat. \ No newline at end of file +A Telegram bot that gives you information about the song other people share with you in chat. diff --git a/songlify.iml b/songlify.iml index 2fecef3..601d455 100644 --- a/songlify.iml +++ b/songlify.iml @@ -9,4 +9,4 @@ - \ No newline at end of file + diff --git a/src/main.rs b/src/main.rs index 492b240..8cb3183 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,15 @@ -use crate::SpotifyURL::Track; -use aspotify::{Client, ClientCredentials}; -use std::time::Duration; use teloxide::prelude::*; -enum SpotifyURL { - Track(String), -} +use crate::spotify::TrackInfo; +use spotify::SpotifyKind::Track; -struct TrackInfo { - name: String, - artist: Vec, - duration: Duration, -} +use crate::spotify::SpotifyKind::{Album, Playlist}; +use crate::utils::{human_readable_duration, truncate_with_dots}; -fn get_spotify_entry(url: &str) -> Option { - if url.contains("https://open.spotify.com/track/") { - let track_id = url.rsplit('/').next().and_then(|x| x.split('?').next()); - return match track_id { - Some(id) => Some(SpotifyURL::Track(id.to_string())), - None => None, - }; - } - return None; -} +mod spotify; +mod utils; -async fn get_spotify_track(spotify: Box, id: &String) -> Option { - match spotify.tracks().get_track(id.as_str(), None).await { - Ok(track) => Some(TrackInfo { - name: track.data.name, - artist: track.data.artists.iter().map(|x| x.name.clone()).collect(), - duration: track.data.duration, - }), - Err(_e) => None, - } -} +static MAX_ARTISTS_CHARS: usize = 140; #[tokio::main] async fn main() { @@ -42,32 +18,80 @@ async fn main() { let bot = Bot::from_env().auto_send(); teloxide::repl(bot, |message| async move { - let text = message.update.text().and_then(get_spotify_entry); + let text = message.update.text().and_then(spotify::get_entry_kind); match text { Some(spotify) => { - let spotify_creds = - ClientCredentials::from_env().expect("CLIENT_ID and CLIENT_SECRET not found."); - let spotify_client = Box::new(Client::new(spotify_creds)); + let spotify_client = spotify::get_client(); match spotify { Track(id) => { log::debug!("Parsing spotify song: {}", id); - let track_info = get_spotify_track(spotify_client, &id).await; + 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: {} second(s)", + ā³ Duration: {}", info.name, - info.artist.join(", "), - info.duration.as_secs() + truncate_with_dots(info.artists.join(", "), MAX_ARTISTS_CHARS), + human_readable_duration(info.duration) ); Some(message.reply_to(reply).await?) } 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(), + ); + } + Some( + message + .reply_to(add_track_section(info.tracks, reply)) + .await?, + ) + } + 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) + ); + Some( + message + .reply_to(add_track_section(info.tracks, reply)) + .await?, + ) + } + None => None, + } + } } } None => None, @@ -78,3 +102,16 @@ async fn main() { log::info!("Exiting..."); } + +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/spotify/mod.rs b/src/spotify/mod.rs new file mode 100644 index 0000000..0606aa9 --- /dev/null +++ b/src/spotify/mod.rs @@ -0,0 +1,145 @@ +use std::time::Duration; + +use aspotify::PlaylistItemType::{Episode, Track}; +use aspotify::{Client, ClientCredentials, TrackSimplified}; + +pub enum SpotifyKind { + Track(String), + Album(String), + Playlist(String), +} + +pub struct TrackInfo { + pub(crate) name: String, + pub(crate) artists: Vec, + pub(crate) duration: Duration, +} + +pub struct AlbumInfo { + pub(crate) name: String, + pub(crate) artists: Vec, + pub(crate) genres: Vec, + pub(crate) tracks: Vec, +} + +pub struct PlaylistInfo { + pub(crate) name: String, + pub(crate) artists: Vec, + pub(crate) tracks: Vec, +} + +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 extract_artists_from_tracks(tracks: Vec) -> Vec { + tracks + .iter() + .flat_map(|t| t.artists.iter().map(|a| a.name.clone())) + .collect() +} + +pub fn get_entry_kind(url: &str) -> Option { + if url.contains("https://open.spotify.com/track/") { + let track_id = get_id_in_url(url); + return match track_id { + Some(id) => Some(SpotifyKind::Track(id.to_string())), + None => None, + }; + } + if url.contains("https://open.spotify.com/album/") { + let album_id = get_id_in_url(url); + return match album_id { + Some(id) => Some(SpotifyKind::Album(id.to_string())), + None => None, + }; + } + if url.contains("https://open.spotify.com/playlist/") { + let playlist_id = get_id_in_url(url); + return match playlist_id { + Some(id) => Some(SpotifyKind::Playlist(id.to_string())), + None => None, + }; + } + return None; +} + +pub fn get_client() -> Box { + let spotify_creds = + ClientCredentials::from_env().expect("CLIENT_ID and CLIENT_SECRET not found."); + let spotify_client = Box::new(Client::new(spotify_creds)); + spotify_client +} + +pub async fn get_track(spotify: Box, id: &String) -> Option { + match spotify.tracks().get_track(id.as_str(), None).await { + Ok(track) => Some(TrackInfo { + name: track.data.name, + artists: track.data.artists.iter().map(|x| x.name.clone()).collect(), + duration: track.data.duration, + }), + Err(_e) => None, + } +} + +pub async fn get_album(spotify: Box, id: &String) -> Option { + match spotify.albums().get_album(id.as_str(), None).await { + Ok(album) => Some(AlbumInfo { + name: album.data.name, + artists: album.data.artists.iter().map(|x| x.name.clone()).collect(), + genres: album.data.genres, + tracks: album + .data + .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, + } +} + +pub async fn get_playlist(spotify: Box, id: &String) -> Option { + match spotify.playlists().get_playlist(id.as_str(), None).await { + Ok(playlist) => Some(PlaylistInfo { + name: playlist.data.name, + artists: playlist + .data + .tracks + .items + .iter() + .flat_map(|p| { + p.item.as_ref().map_or(Vec::new(), |x| match x { + Track(t) => extract_artists_from_tracks(vec![t.clone().simplify()]), + Episode(_e) => Vec::new(), + }) + }) + .collect(), + tracks: playlist + .data + .tracks + .items + .iter() + .flat_map(|p| { + p.item.as_ref().map_or(None, |x| match x { + Track(t) => Some(TrackInfo { + name: t.name.clone(), + artists: extract_artists_from_tracks(vec![t.clone().simplify()]), + duration: t.duration, + }), + Episode(_e) => None, + }) + }) + .collect(), + }), + Err(_e) => None, + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..3b4c338 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,96 @@ +use std::ops::Add; +use std::time::Duration; + +pub(crate) fn truncate_with_dots(to_truncate: String, new_size: usize) -> String { + if to_truncate.len() < new_size { + return to_truncate; + } + + let mut new_string = to_truncate.clone(); + let dots = "..."; + if new_size as isize - 3 > 0 { + new_string.truncate(new_size - 3); + new_string.add(dots) + } else { + let mut dots_to_ret = String::new(); + for _i in 0..new_size { + dots_to_ret.push('.'); + } + return dots_to_ret; + } +} + +pub(crate) fn human_readable_duration(duration: Duration) -> String { + let total_duration = duration.as_secs(); + let mut minutes = total_duration / 60; + let seconds = total_duration % 60; + + if minutes >= 60 { + let hours = minutes / 60; + minutes = minutes % 60; + return format!( + "{} hour(s), {} minute(s) and {} second(s)", + hours, minutes, seconds + ); + } + format!("{} minute(s) and {} second(s)", minutes, seconds) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn should_truncate_short_string_as_expected() { + let example_string = "example"; + let expected_string = ".."; + + assert_eq!( + expected_string, + truncate_with_dots(example_string.to_string(), 2) + ) + } + + #[test] + fn should_truncate_long_string_as_expected() { + let example_string = "this is a very long string"; + let expected_string = "this i..."; + + assert_eq!( + expected_string, + truncate_with_dots(example_string.to_string(), 9) + ) + } + + #[test] + fn should_not_truncate_if_string_is_not_long_enough() { + let example_string = "short string"; + let expected_string = "short string"; + + assert_eq!( + expected_string, + truncate_with_dots(example_string.to_string(), 1000) + ); + } + + #[test] + fn should_print_correct_duration_into_human_readable_format() { + let duration: Duration = Duration::new(124, 0); + let got = human_readable_duration(duration); + + assert_eq!("2 minute(s) and 4 second(s)", got) + } + + #[test] + fn should_handle_duration_in_hours() { + let duration1 = Duration::new(3621, 0); + let got1 = human_readable_duration(duration1); + + assert_eq!("1 hour(s), 0 minute(s) and 21 second(s)", got1); + + let duration2 = Duration::new(5021, 0); + let got2 = human_readable_duration(duration2); + + assert_eq!("1 hour(s), 23 minute(s) and 41 second(s)", got2); + } +}