feat: add support for Spotify albums & playlists (#1)
feat: improve formatting output * duration is now in human readable format * list of artists is now capped at 140 chars max chore(docker): upgrade Docker image and lint it chore(spotify): refactor spotify wrapper & function names feat(spotify): add playlist support, add pre-commit checks fix: set right visibility in structs & fields - Cleanup old imports feat: add support for album links - perform little code refactor, create new module spotify Co-authored-by: Davide Polonio <poloniodavide@gmail.com> Reviewed-on: #1
This commit is contained in:
		
							parent
							
								
									34c294ef0f
								
							
						
					
					
						commit
						91e3c89896
					
				
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -9,4 +9,3 @@ Cargo.lock | ||||
| 
 | ||||
| # These are backup files generated by rustfmt | ||||
| **/*.rs.bk | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										11
									
								
								.idea/codeStyles/Project.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								.idea/codeStyles/Project.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| <component name="ProjectCodeStyleConfiguration"> | ||||
|   <code_scheme name="Project" version="173"> | ||||
|     <codeStyleSettings language="XML"> | ||||
|       <indentOptions> | ||||
|         <option name="INDENT_SIZE" value="2" /> | ||||
|         <option name="CONTINUATION_INDENT_SIZE" value="4" /> | ||||
|         <option name="TAB_SIZE" value="2" /> | ||||
|       </indentOptions> | ||||
|     </codeStyleSettings> | ||||
|   </code_scheme> | ||||
| </component> | ||||
							
								
								
									
										5
									
								
								.idea/codeStyles/codeStyleConfig.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.idea/codeStyles/codeStyleConfig.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| <component name="ProjectCodeStyleConfiguration"> | ||||
|   <state> | ||||
|     <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" /> | ||||
|   </state> | ||||
| </component> | ||||
							
								
								
									
										6
									
								
								.idea/google-java-format.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/google-java-format.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="GoogleJavaFormatSettings"> | ||||
|     <option name="enabled" value="true" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										3
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
								
							| @ -3,7 +3,4 @@ | ||||
|   <component name="ProjectRootManager"> | ||||
|     <output url="file://$PROJECT_DIR$/out" /> | ||||
|   </component> | ||||
|   <component name="ScalaSbtSettings"> | ||||
|     <option name="customVMPath" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										6
									
								
								.idea/sbt.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/sbt.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="ScalaSbtSettings"> | ||||
|     <option name="customVMPath" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										21
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| @ -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 | ||||
|  | ||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								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 | ||||
| ENTRYPOINT ["/usr/bin/songlify"] | ||||
|  | ||||
							
								
								
									
										115
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								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<String>, | ||||
|     duration: Duration, | ||||
| } | ||||
| use crate::spotify::SpotifyKind::{Album, Playlist}; | ||||
| use crate::utils::{human_readable_duration, truncate_with_dots}; | ||||
| 
 | ||||
| fn get_spotify_entry(url: &str) -> Option<SpotifyURL> { | ||||
|     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<Client>, id: &String) -> Option<TrackInfo> { | ||||
|     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<TrackInfo>, reply: String) -> String { | ||||
|     if !tracks.is_empty() { | ||||
|         let songs = tracks | ||||
|             .iter() | ||||
|             .map(|x| x.name.clone() + "\n") | ||||
|             .collect::<String>(); | ||||
|         reply | ||||
|             .clone() | ||||
|             .push_str(format!("\n🎶 {} Track(s): {}", tracks.len(), songs).as_str()) | ||||
|     } | ||||
|     reply | ||||
| } | ||||
|  | ||||
							
								
								
									
										145
									
								
								src/spotify/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								src/spotify/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<String>, | ||||
|     pub(crate) duration: Duration, | ||||
| } | ||||
| 
 | ||||
| pub struct AlbumInfo { | ||||
|     pub(crate) name: String, | ||||
|     pub(crate) artists: Vec<String>, | ||||
|     pub(crate) genres: Vec<String>, | ||||
|     pub(crate) tracks: Vec<TrackInfo>, | ||||
| } | ||||
| 
 | ||||
| pub struct PlaylistInfo { | ||||
|     pub(crate) name: String, | ||||
|     pub(crate) artists: Vec<String>, | ||||
|     pub(crate) tracks: Vec<TrackInfo>, | ||||
| } | ||||
| 
 | ||||
| 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<TrackSimplified>) -> Vec<String> { | ||||
|     tracks | ||||
|         .iter() | ||||
|         .flat_map(|t| t.artists.iter().map(|a| a.name.clone())) | ||||
|         .collect() | ||||
| } | ||||
| 
 | ||||
| pub fn get_entry_kind(url: &str) -> Option<SpotifyKind> { | ||||
|     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<Client> { | ||||
|     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<Client>, id: &String) -> Option<TrackInfo> { | ||||
|     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<Client>, id: &String) -> Option<AlbumInfo> { | ||||
|     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<Client>, id: &String) -> Option<PlaylistInfo> { | ||||
|     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, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										96
									
								
								src/utils/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/utils/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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); | ||||
|     } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user