use serde::Deserialize;
use crate::fetcher::lastfm::common::{format_count, plays_word};
use crate::fetcher::thumbnails;
use crate::payload::{
BadgeData, Bar, BarsData, Body, EntriesData, Entry, ImageLinkedItem, ImageLinkedListData,
LinkedLine, LinkedTextBlockData, MarkdownTextBlockData, NumberSeriesData, RatioData, Status,
TextBlockData, TextData,
};
#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
pub enum Period {
#[serde(rename = "7day")]
#[default]
SevenDay,
#[serde(rename = "1month")]
OneMonth,
#[serde(rename = "3month")]
ThreeMonth,
#[serde(rename = "6month")]
SixMonth,
#[serde(rename = "12month")]
TwelveMonth,
#[serde(rename = "overall")]
Overall,
}
impl Period {
pub fn as_param(self) -> &'static str {
match self {
Self::SevenDay => "7day",
Self::OneMonth => "1month",
Self::ThreeMonth => "3month",
Self::SixMonth => "6month",
Self::TwelveMonth => "12month",
Self::Overall => "overall",
}
}
pub fn label(self) -> &'static str {
match self {
Self::SevenDay => "last 7d",
Self::OneMonth => "last 1m",
Self::ThreeMonth => "last 3m",
Self::SixMonth => "last 6m",
Self::TwelveMonth => "last 12m",
Self::Overall => "overall",
}
}
}
#[derive(Debug, Clone)]
pub struct TopRow {
pub rank: usize,
pub primary: String,
pub secondary: Option<String>,
pub playcount: u64,
pub url: String,
pub image_url: Option<String>,
}
impl TopRow {
pub fn display_title(&self) -> String {
match &self.secondary {
Some(s) if !s.is_empty() => format!("{} โ {}", self.primary, s),
_ => self.primary.clone(),
}
}
pub fn count_label(&self) -> String {
format!(
"{} {}",
format_count(self.playcount),
plays_word(self.playcount)
)
}
}
pub fn headline(rows: &[TopRow], empty_label: &str) -> String {
match rows.first() {
Some(top) => format!(
"#{} {} ({})",
top.rank,
top.display_title(),
top.count_label()
),
None => empty_label.into(),
}
}
pub fn text_body(rows: &[TopRow], empty_label: &str) -> Body {
Body::Text(TextData {
value: headline(rows, empty_label),
})
}
pub fn text_block_body(rows: &[TopRow], empty_label: &str) -> Body {
Body::TextBlock(TextBlockData {
lines: lines(rows, empty_label),
})
}
fn lines(rows: &[TopRow], empty_label: &str) -> Vec<String> {
if rows.is_empty() {
return vec![empty_label.into()];
}
rows.iter()
.map(|r| format!("#{} {} {}", r.rank, r.display_title(), r.count_label()))
.collect()
}
pub fn markdown_body(rows: &[TopRow], empty_label: &str) -> Body {
let value = if rows.is_empty() {
format!("_{empty_label}_")
} else {
rows.iter()
.map(|r| {
format!(
"- **#{} {}** โ {}",
r.rank,
r.display_title(),
r.count_label()
)
})
.collect::<Vec<_>>()
.join("\n")
};
Body::MarkdownTextBlock(MarkdownTextBlockData { value })
}
pub fn linked_text_body(rows: &[TopRow], empty_label: &str) -> Body {
let items: Vec<LinkedLine> = if rows.is_empty() {
vec![LinkedLine {
text: empty_label.into(),
url: None,
}]
} else {
rows.iter()
.map(|r| LinkedLine {
text: format!("#{} {} {}", r.rank, r.display_title(), r.count_label()),
url: Some(r.url.clone()),
})
.collect()
};
Body::LinkedTextBlock(LinkedTextBlockData { items })
}
pub async fn image_linked_body(rows: &[TopRow]) -> Body {
let urls: Vec<Option<String>> = rows.iter().map(|r| r.image_url.clone()).collect();
let paths = thumbnails::download_many(&urls).await;
let items = rows
.iter()
.zip(paths)
.map(|(r, path)| ImageLinkedItem {
title: format!("#{} {}", r.rank, r.primary),
url: Some(r.url.clone()),
thumbnail_path: path.map(|p| p.to_string_lossy().into_owned()),
subtitle: subtitle(r),
})
.collect();
Body::ImageLinkedList(ImageLinkedListData { items })
}
fn subtitle(row: &TopRow) -> Option<String> {
let count = row.count_label();
match &row.secondary {
Some(s) if !s.is_empty() => Some(format!("{s} ยท {count}")),
_ => Some(count),
}
}
pub fn entries_body(rows: &[TopRow]) -> Body {
let items: Vec<Entry> = if rows.is_empty() {
vec![Entry {
key: "โ".into(),
value: Some("no scrobbles yet".into()),
status: None,
}]
} else {
rows.iter()
.map(|r| Entry {
key: format!("#{} {}", r.rank, r.display_title()),
value: Some(r.count_label()),
status: None,
})
.collect()
};
Body::Entries(EntriesData { items })
}
pub fn ratio_body(rows: &[TopRow]) -> Body {
let total: u64 = rows.iter().map(|r| r.playcount).sum();
let (value, label, denominator) = match rows.first() {
Some(top) if total > 0 => (
top.playcount as f64 / total as f64,
Some(top.display_title()),
Some(total),
),
_ => (0.0, None, None),
};
Body::Ratio(RatioData {
value,
label,
denominator,
})
}
pub fn number_series_body(rows: &[TopRow]) -> Body {
Body::NumberSeries(NumberSeriesData {
values: rows.iter().map(|r| r.playcount).collect(),
})
}
pub fn bars_body(rows: &[TopRow]) -> Body {
Body::Bars(BarsData {
bars: rows
.iter()
.map(|r| Bar {
label: r.display_title(),
value: r.playcount,
value_label: Some(r.count_label()),
})
.collect(),
})
}
pub fn badge_body(rows: &[TopRow], period: Period) -> Body {
let (status, label) = match rows.first() {
Some(top) => (Status::Ok, format!("#1 {}", top.primary)),
None => (Status::Warn, format!("quiet ({})", period.label())),
};
Body::Badge(BadgeData { status, label })
}
#[cfg(test)]
mod tests {
use super::*;
fn row(rank: usize, primary: &str, secondary: Option<&str>, plays: u64) -> TopRow {
TopRow {
rank,
primary: primary.into(),
secondary: secondary.map(String::from),
playcount: plays,
url: format!("https://www.last.fm/x/{primary}"),
image_url: None,
}
}
#[test]
fn period_as_param_covers_all_variants() {
for p in [
Period::SevenDay,
Period::OneMonth,
Period::ThreeMonth,
Period::SixMonth,
Period::TwelveMonth,
Period::Overall,
] {
assert!(!p.as_param().is_empty());
assert!(!p.label().is_empty());
}
}
#[test]
fn period_default_is_seven_day() {
assert_eq!(Period::default(), Period::SevenDay);
}
#[test]
fn period_deserialises_from_seven_day_param_string() {
let raw: toml::Value = toml::from_str(r#"period = "7day""#).unwrap();
#[derive(Deserialize)]
struct Wrap {
period: Period,
}
let parsed: Wrap = raw.try_into().unwrap();
assert_eq!(parsed.period, Period::SevenDay);
}
#[test]
fn display_title_includes_secondary_when_present() {
let r = row(1, "Track", Some("Artist"), 42);
assert_eq!(r.display_title(), "Track โ Artist");
}
#[test]
fn display_title_falls_back_to_primary_alone() {
let r = row(1, "Artist", None, 42);
assert_eq!(r.display_title(), "Artist");
}
#[test]
fn display_title_treats_empty_secondary_as_absent() {
let r = row(1, "Track", Some(""), 42);
assert_eq!(r.display_title(), "Track");
}
#[test]
fn count_label_pluralises_only_for_non_singular() {
assert_eq!(row(1, "x", None, 0).count_label(), "0 plays");
assert_eq!(row(1, "x", None, 1).count_label(), "1 play");
assert_eq!(row(1, "x", None, 5).count_label(), "5 plays");
assert_eq!(row(1, "x", None, 1500).count_label(), "1.5k plays");
}
#[test]
fn headline_reports_top_entity_with_rank_prefix() {
let rows = vec![row(1, "Artist", None, 100), row(2, "Other", None, 50)];
assert_eq!(headline(&rows, "empty"), "#1 Artist (100 plays)");
}
#[test]
fn headline_falls_back_to_empty_label() {
assert_eq!(headline(&[], "quiet window"), "quiet window");
}
#[test]
fn linked_text_body_carries_one_row_per_entry_with_url() {
let rows = vec![row(1, "Artist", None, 100)];
let Body::LinkedTextBlock(b) = linked_text_body(&rows, "empty") else {
panic!("expected linked_text_block");
};
assert_eq!(b.items.len(), 1);
assert!(b.items[0].url.is_some());
assert!(b.items[0].text.starts_with("#1 Artist"));
}
#[test]
fn linked_text_body_emits_single_unlinked_row_on_empty() {
let Body::LinkedTextBlock(b) = linked_text_body(&[], "empty") else {
panic!("expected linked_text_block");
};
assert_eq!(b.items.len(), 1);
assert_eq!(b.items[0].text, "empty");
assert!(b.items[0].url.is_none());
}
#[test]
fn markdown_body_italicises_empty_label() {
let Body::MarkdownTextBlock(m) = markdown_body(&[], "quiet") else {
panic!("expected markdown");
};
assert_eq!(m.value, "_quiet_");
}
#[test]
fn ratio_body_is_zero_when_rows_are_empty() {
let Body::Ratio(r) = ratio_body(&[]) else {
panic!("expected ratio");
};
assert_eq!(r.value, 0.0);
assert!(r.label.is_none());
assert!(r.denominator.is_none());
}
#[test]
fn ratio_body_carries_top_share_and_total_as_denominator() {
let rows = vec![
row(1, "A", None, 60),
row(2, "B", None, 30),
row(3, "C", None, 10),
];
let Body::Ratio(r) = ratio_body(&rows) else {
panic!("expected ratio");
};
assert!((r.value - 0.6).abs() < 1e-9);
assert_eq!(r.label.as_deref(), Some("A"));
assert_eq!(r.denominator, Some(100));
}
#[test]
fn ratio_body_returns_zero_when_total_is_zero() {
let rows = vec![row(1, "A", None, 0), row(2, "B", None, 0)];
let Body::Ratio(r) = ratio_body(&rows) else {
panic!("expected ratio");
};
assert_eq!(r.value, 0.0);
assert!(r.label.is_none());
}
#[test]
fn number_series_body_lifts_playcounts_in_order() {
let rows = vec![row(1, "A", None, 30), row(2, "B", None, 20)];
let Body::NumberSeries(s) = number_series_body(&rows) else {
panic!("expected number_series");
};
assert_eq!(s.values, vec![30, 20]);
}
#[test]
fn bars_body_uses_display_title_as_label_and_playcount_as_value() {
let rows = vec![row(1, "Track", Some("Artist"), 42)];
let Body::Bars(b) = bars_body(&rows) else {
panic!("expected bars");
};
assert_eq!(b.bars.len(), 1);
assert_eq!(b.bars[0].label, "Track โ Artist");
assert_eq!(b.bars[0].value, 42);
}
#[test]
fn badge_body_promotes_top_entity_and_warns_on_empty() {
let rows = vec![row(1, "Artist", None, 100)];
let Body::Badge(b) = badge_body(&rows, Period::SevenDay) else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Ok);
assert!(b.label.contains("Artist"));
let Body::Badge(empty) = badge_body(&[], Period::SevenDay) else {
panic!("expected badge");
};
assert_eq!(empty.status, Status::Warn);
assert!(empty.label.contains("7d"));
}
#[test]
fn entries_body_appends_count_value_per_row() {
let rows = vec![row(1, "Track", Some("Artist"), 42)];
let Body::Entries(e) = entries_body(&rows) else {
panic!("expected entries");
};
assert_eq!(e.items[0].key, "#1 Track โ Artist");
assert_eq!(e.items[0].value.as_deref(), Some("42 plays"));
}
#[test]
fn subtitle_falls_back_to_count_when_secondary_missing() {
let r = row(1, "Artist", None, 42);
assert_eq!(subtitle(&r), Some("42 plays".into()));
}
#[test]
fn subtitle_includes_secondary_alongside_count() {
let r = row(1, "Track", Some("Artist"), 42);
let s = subtitle(&r).unwrap();
assert!(s.contains("Artist"));
assert!(s.contains("42 plays"));
}
#[test]
fn period_deserialises_every_renamed_param_string() {
#[derive(Deserialize)]
struct Wrap {
period: Period,
}
for (param, expected) in [
("1month", Period::OneMonth),
("3month", Period::ThreeMonth),
("6month", Period::SixMonth),
("12month", Period::TwelveMonth),
("overall", Period::Overall),
] {
let raw: toml::Value = toml::from_str(&format!("period = \"{param}\"")).unwrap();
let parsed: Wrap = raw.try_into().unwrap();
assert_eq!(parsed.period, expected);
}
}
#[test]
fn text_body_wraps_headline_in_text_shape() {
let rows = vec![row(1, "Artist", None, 100)];
let Body::Text(t) = text_body(&rows, "empty") else {
panic!("expected text");
};
assert_eq!(t.value, "#1 Artist (100 plays)");
let Body::Text(empty) = text_body(&[], "quiet window") else {
panic!("expected text");
};
assert_eq!(empty.value, "quiet window");
}
#[test]
fn text_block_body_emits_one_line_per_row() {
let rows = vec![row(1, "A", None, 100), row(2, "B", None, 50)];
let Body::TextBlock(b) = text_block_body(&rows, "empty") else {
panic!("expected text_block");
};
assert_eq!(b.lines.len(), 2);
assert!(b.lines[0].starts_with("#1 A"));
assert!(b.lines[1].contains("50 plays"));
}
#[test]
fn text_block_body_falls_back_to_single_empty_line() {
let Body::TextBlock(b) = text_block_body(&[], "quiet") else {
panic!("expected text_block");
};
assert_eq!(b.lines, vec!["quiet".to_string()]);
}
#[test]
fn markdown_body_renders_a_bullet_per_row() {
let rows = vec![row(1, "Track", Some("Artist"), 42)];
let Body::MarkdownTextBlock(m) = markdown_body(&rows, "quiet") else {
panic!("expected markdown");
};
assert_eq!(m.value, "- **#1 Track โ Artist** โ 42 plays");
}
#[test]
fn entries_body_emits_placeholder_row_when_empty() {
let Body::Entries(e) = entries_body(&[]) else {
panic!("expected entries");
};
assert_eq!(e.items.len(), 1);
assert_eq!(e.items[0].key, "โ");
assert_eq!(e.items[0].value.as_deref(), Some("no scrobbles yet"));
}
#[tokio::test]
async fn image_linked_body_maps_rows_to_items_without_thumbnails() {
let rows = vec![row(1, "Track", Some("Artist"), 42), row(2, "Solo", None, 7)];
let Body::ImageLinkedList(d) = image_linked_body(&rows).await else {
panic!("expected image_linked_list");
};
assert_eq!(d.items.len(), 2);
assert_eq!(d.items[0].title, "#1 Track");
assert!(d.items[0].thumbnail_path.is_none());
assert_eq!(d.items[0].subtitle.as_deref(), Some("Artist ยท 42 plays"));
assert_eq!(d.items[1].subtitle.as_deref(), Some("7 plays"));
assert!(d.items[1].url.is_some());
}
}