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);
+ }
+}