From 47e9b874caee8fd1c78ef984a2f1b6c0fc2c7ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Toma=C5=9Bko?= Date: Sun, 29 Sep 2024 03:36:53 +0200 Subject: [PATCH] #73 improve artist summary page --- Cargo.lock | 36 +++--- endsong_ui/Cargo.lock | 20 +-- endsong_ui/src/lib.rs | 60 +++++++++ endsong_ui/src/main.rs | 4 +- endsong_ui/src/plot.rs | 61 +-------- endsong_ui/src/summarize.rs | 160 ++++++++++++++++++++++-- endsong_ui/templates/artist.html | 62 +++++++-- endsong_ui/templates/base_style.css | 9 ++ endsong_ui/templates/tailwind_style.css | 56 +++++++-- src/lib.rs | 2 +- 10 files changed, 351 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c50784..ed2cb67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,9 +40,9 @@ checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "bumpalo" @@ -64,9 +64,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.1.18" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "shlex", ] @@ -120,18 +120,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.17" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac" +checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.17" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73" +checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" dependencies = [ "anstyle", "clap_lex", @@ -266,9 +266,9 @@ checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -333,9 +333,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.158" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "log" @@ -565,9 +565,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "syn" -version = "2.0.77" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -576,18 +576,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", diff --git a/endsong_ui/Cargo.lock b/endsong_ui/Cargo.lock index 21b24df..6260dfa 100644 --- a/endsong_ui/Cargo.lock +++ b/endsong_ui/Cargo.lock @@ -53,9 +53,9 @@ checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "base64" @@ -98,9 +98,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.1.21" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "shlex", ] @@ -582,9 +582,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libm" @@ -925,9 +925,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags", ] @@ -1209,9 +1209,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.77" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", diff --git a/endsong_ui/src/lib.rs b/endsong_ui/src/lib.rs index 165e5ac..4fc0ef8 100644 --- a/endsong_ui/src/lib.rs +++ b/endsong_ui/src/lib.rs @@ -40,6 +40,66 @@ pub const fn spaces(num: usize) -> &'static str { endsong_macros::generate_spaces_match!(100) } +/// Replaces Windows forbidden symbols in path with an '_' +/// +/// Also removes whitespace and replaces empty +/// strings with '_' +#[must_use] +pub fn normalize_path(path: &str) -> String { + // https://stackoverflow.com/a/31976060 + // Array > HashSet bc of overhead + let forbidden_characters = [' ', '<', '>', ':', '"', '/', '\\', '|', '?', '*']; + let mut new_path = String::with_capacity(path.len()); + + for ch in path.chars() { + if forbidden_characters.contains(&ch) { + // replace a forbidden symbol with an underscore (for now...) + new_path.push('_'); + } else { + new_path.push(ch); + } + } + + // https://stackoverflow.com/a/1976050 + if new_path.is_empty() || new_path == "." { + new_path = "_".into(); + } + + new_path +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_paths() { + // should change the forbidden symbols to '_' in these + assert_eq!(normalize_path("A|B"), "A_B"); + assert_eq!(normalize_path("A>BB? "), "_A_B__B__"); + + // shouldn't change anything about these + assert_eq!(normalize_path("A_B"), "A_B"); + assert_eq!(normalize_path("AB"), "AB"); + } +} + /// Prelude containing all the modules, /// a function for parsing dates, some structs used for printing, /// and a trait to add a [pretty display method][print::DurationUtils::display] diff --git a/endsong_ui/src/main.rs b/endsong_ui/src/main.rs index 5252e4a..98da0a9 100644 --- a/endsong_ui/src/main.rs +++ b/endsong_ui/src/main.rs @@ -51,9 +51,7 @@ fn main() { // test_two(&entries); // test_plot(&entries); - summarize::artist(&entries, &Artist::new("Sabaton")); - - // ui::start(&entries); + ui::start(&entries); } /// tests various [`print`][crate::print] and [`endsong::gather`] functions diff --git a/endsong_ui/src/plot.rs b/endsong_ui/src/plot.rs index 2b998ba..fb6719a 100644 --- a/endsong_ui/src/plot.rs +++ b/endsong_ui/src/plot.rs @@ -57,7 +57,7 @@ fn write_and_open_plot(plot: &Plot, title: &str) { // creates plots/ folder std::fs::create_dir_all("plots").unwrap(); - let title = normalize_path(title); + let title = crate::normalize_path(title); // opens the plot in the browser match std::env::consts::OS { @@ -109,62 +109,3 @@ fn write_and_open_plot(plot: &Plot, title: &str) { } }; } - -/// Replaces Windows forbidden symbols in path with an '_' -/// -/// Also removes whitespace and replaces empty -/// strings with '_' -fn normalize_path(path: &str) -> String { - // https://stackoverflow.com/a/31976060 - // Array > HashSet bc of overhead - let forbidden_characters = [' ', '<', '>', ':', '"', '/', '\\', '|', '?', '*']; - let mut new_path = String::with_capacity(path.len()); - - for ch in path.chars() { - if forbidden_characters.contains(&ch) { - // replace a forbidden symbol with an underscore (for now...) - new_path.push('_'); - } else { - new_path.push(ch); - } - } - - // https://stackoverflow.com/a/1976050 - if new_path.is_empty() || new_path == "." { - new_path = "_".into(); - } - - new_path -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_paths() { - // should change the forbidden symbols to '_' in these - assert_eq!(normalize_path("A|B"), "A_B"); - assert_eq!(normalize_path("A>BB? "), "_A_B__B__"); - - // shouldn't change anything about these - assert_eq!(normalize_path("A_B"), "A_B"); - assert_eq!(normalize_path("AB"), "AB"); - } -} diff --git a/endsong_ui/src/summarize.rs b/endsong_ui/src/summarize.rs index eaf7197..22368fd 100644 --- a/endsong_ui/src/summarize.rs +++ b/endsong_ui/src/summarize.rs @@ -11,16 +11,18 @@ use rinja::Template; #[derive(Template)] #[template(path = "artist.html", print = "none")] struct ArtistSummary { - /// artist name + /// Artist name name: Rc, - /// number of top songs/albums to be displayed + /// Number of top songs/albums to be displayed top: usize, - /// array of top song names with their playcount + /// Array of top song names with their playcount songs: Vec<(Rc, usize)>, - /// array of top album names with their playcount + /// Array of top album names with their playcount albums: Vec<(Rc, usize)>, - /// number of this artist's plays + /// Count of this artist's plays plays: usize, + /// Total time listend to this artist + time_played: TimeDelta, /// % of total plays percentage_of_plays: String, /// Date of first listen @@ -31,6 +33,10 @@ struct ArtistSummary { now: DateTime, /// Names of the files used for the [`SongEntries`] filenames: Vec, + /// First `top` listens of that artist + first_listens: Vec, + /// Last `top` listens of that artist + last_listens: Vec, } /// Generates an HTML summary page of an [`Artist`] @@ -48,6 +54,12 @@ pub fn artist(entries: &SongEntries, artist: &Artist) { let plays = gather::plays(entries, artist); let percentage_of_plays = format!("{:.2}", (plays as f64 / entries.len() as f64) * 100.0); + let time_played = entries + .iter() + .filter(|e| artist.is_entry(e)) + .map(|e| e.time_played) + .sum(); + let first_listen = entries .iter() .find(|entry| artist.is_entry(entry)) @@ -68,6 +80,20 @@ pub fn artist(entries: &SongEntries, artist: &Artist) { .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .collect(); + let first_listens = entries + .iter() + .filter(|e| artist.is_entry(e)) + .take(top) + .cloned() + .collect(); + let last_listens = entries + .iter() + .rev() + .filter(|e| artist.is_entry(e)) + .take(top) + .cloned() + .collect(); + let page = ArtistSummary { name: std::rc::Rc::clone(&artist.name), top, @@ -75,18 +101,74 @@ pub fn artist(entries: &SongEntries, artist: &Artist) { albums, plays, percentage_of_plays, + time_played, first_listen, last_listen, now, filenames, + first_listens, + last_listens, }; - std::fs::create_dir_all("summaries").unwrap(); let path = format!("summaries/{} summary.html", artist.name); std::fs::write(&path, page.render().unwrap()).unwrap(); - // std::process::Command::new("open") - // .arg(&path) - // .output() - // .unwrap(); + write_and_open_summary(page, &artist.name); +} + +/// Creates the summary .html in the plots/ folder and opens it in the browser +/// +/// Compare with [`crate::plot::write_and_open_plot`] +fn write_and_open_summary(summary: S, name: &str) { + std::fs::create_dir_all("summaries").unwrap(); + + let title = crate::normalize_path(&format!("{name}_summary.html")); + + match std::env::consts::OS { + // see https://github.com/plotly/plotly.rs/issues/132#issuecomment-1488920563 + "windows" => { + let path = format!( + "{}\\summaries\\{}.html", + std::env::current_dir().unwrap().display(), + title + ); + std::fs::write(&path, summary.render().unwrap()).unwrap(); + std::process::Command::new("explorer") + .arg(&path) + .output() + .unwrap(); + } + "macos" => { + let path = format!( + "{}/summaries/{}.html", + std::env::current_dir().unwrap().display(), + title + ); + std::fs::write(&path, summary.render().unwrap()).unwrap(); + std::process::Command::new("open") + .arg(&path) + .output() + .unwrap(); + } + _ => { + let path = format!( + "{}/summaries/{}.html", + std::env::current_dir().unwrap().display(), + title + ); + std::fs::write(&path, summary.render().unwrap()).unwrap(); + + match std::env::var("BROWSER") { + Ok(browser) => { + std::process::Command::new(browser) + .arg(&path) + .output() + .unwrap(); + } + Err(_) => { + eprintln!("Your BROWSER environmental variable is not set!"); + } + } + } + }; } /// Makes a list of aspects with their total playcount sorted by their @@ -103,3 +185,61 @@ fn get_sorted_playcount_list( .map(|(asp, plays)| (asp.name(), plays)) .collect() } + +/// Custom [`rinja`] filters used by templates here +/// +/// See +mod filters { + use endsong::prelude::*; + + /// Pretty formats a [`DateTime`] + #[expect(clippy::unnecessary_wraps, reason = "rinja required output type")] + pub fn pretty_date(date: &DateTime) -> rinja::Result { + Ok(format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + date.year(), + date.month(), + date.day(), + date.hour(), + date.minute(), + )) + } + + /// Pretty formats a [`TimeDelta`] in a reasonable way + /// + /// ```"_d _h _m"``` + #[expect(clippy::unnecessary_wraps, reason = "rinja required output type")] + pub fn pretty_duration(duration: &TimeDelta) -> rinja::Result { + let days = duration.num_days(); + let hours = duration.num_hours(); + let minutes = duration.num_minutes(); + + if minutes == 0 || minutes == 1 { + return Ok(format!("{}s", duration.num_seconds())); + } + + if days > 0 { + let remaining_hours = hours % (24 * days); + let remaining_minutes = minutes % (60 * hours); + return Ok(format!("{days}d {remaining_hours}h {remaining_minutes}m")); + } + + if hours > 0 { + let remaining_minutes = minutes % (60 * hours); + return Ok(format!("{hours}h {remaining_minutes}m")); + } + + Ok(format!("{minutes}m")) + } + + /// Formats a [`TimeDelta`] in a machine-readble way + /// for the `dateime` `