//! Report module — generates listening statistics in three output formats. //! //! This module reads scrobble data from the database (via `db` module queries) //! and presents it in one of three formats: //! //! 1. **Terminal tables** — box-drawing character tables with bar charts, //! showing overview stats, top artists/albums/tracks, and recent scrobbles //! for a single period. //! //! 2. **JSON** — the same single-period data serialized as pretty-printed JSON, //! suitable for piping into other tools (e.g., `jq`). //! //! 3. **HTML** — a standalone, multi-period report with a dark theme. The HTML //! output includes all periods (Today, This Week, This Month, All Time) in //! one page, with: //! - KPI cards for each period (scrobbles, listen time, unique counts) //! - Horizontal bar charts for top artists, top albums, and top tracks //! - Album cover art grids (fetched via the `enrich` module) //! - Genre badges from MusicBrainz //! - A recent scrobbles table (All Time section only) //! //! Terminal and JSON reports operate on a single `--period` flag. //! HTML reports ignore `--period` and always render all periods together. //! //! ## Output structure for `--html --output ` //! //! ```text //! / //! ├── index.html ← properly indented, human-readable HTML //! └── covers/ //! ├── .jpg ← cover images copied from the enrichment cache //! └── .jpg //! ``` //! //! Cover images are referenced via relative `covers/` paths so the //! directory is self-contained and can be moved or shared as-is. use crate::db; use rusqlite::Connection; use serde::Serialize; use std::fmt::Write as _; /// Maximum bar width used in terminal tables. const MAX_BAR_WIDTH: usize = 20; /// Minimum bar width used in terminal tables when terminal is narrow. const MIN_BAR_WIDTH: usize = 8; // --------------------------------------------------------------------------- // Duration formatting // --------------------------------------------------------------------------- /// Format a duration in seconds into a human-readable string. /// /// Examples: /// - 45 seconds → "45s" /// - 120 seconds → "2m" /// - 3660 seconds → "1h 01m" /// - 0 seconds → "0s" pub fn format_duration(secs: i64) -> String { if secs < 60 { format!("{}s", secs) } else if secs < 3600 { format!("{}m", secs / 60) } else { let hours = secs / 3600; let mins = (secs % 3600) / 60; if mins == 0 { format!("{}h", hours) } else { format!("{}h {:02}m", hours, mins) } } } /// Format a play count with correct singular/plural grammar. fn format_play_count(plays: i64) -> String { if plays == 1 { "1 play".to_string() } else { format!("{} plays", plays) } } /// Build a short "mood" label from top genres. /// /// Selection rule: /// - take the top genre first /// - then keep scanning in rank order and only take the next genre if its /// listen time differs from the previously selected genre /// /// This avoids showing multiple mood tags with identical play-time weight. fn mood_labels(top_genres: &[db::TopGenre], max_labels: usize) -> Vec { let mut out: Vec = Vec::new(); let mut last_selected_time: Option = None; for g in top_genres { if out.len() >= max_labels { break; } if let Some(prev_time) = last_selected_time && g.listen_time_secs == prev_time { continue; } out.push(g.genre.clone()); last_selected_time = Some(g.listen_time_secs); } out } /// Convert an ISO 8601 timestamp into a short, privacy-friendly relative /// time label. Avoids exposing exact listening times. Labels are kept compact /// so they fit well in both terminal tables and HTML columns. /// /// Examples: /// - 10 minutes ago → "Moments ago" /// - 50 minutes ago → "Within hour" /// - 2 hours ago → "A while ago" /// - 8 hours ago → "Today" /// - yesterday → "Yesterday" /// - 3 days ago → "Few days" /// - 2 weeks ago → "About week" /// - 5 weeks ago → "About month" /// - 3 months ago → "Few months" /// - 14 months ago → "Over year" fn fuzzy_time(iso_timestamp: &str) -> String { let Ok(then) = chrono::NaiveDateTime::parse_from_str(iso_timestamp, "%Y-%m-%dT%H:%M:%S") else { return "Some time".to_string(); }; let now = chrono::Local::now().naive_local(); let delta = now.signed_duration_since(then); let hours = delta.num_hours(); let days = delta.num_days(); // Use calendar-date comparison for "Today" and "Yesterday" so that a track // scrobbled before midnight is never labelled "Today" after the date rolls over. let today = now.date(); let then_date = then.date(); match () { _ if delta.num_minutes() < 20 => "Moments ago".to_string(), _ if delta.num_minutes() < 60 => "Within hour".to_string(), _ if hours < 3 => "A while ago".to_string(), _ if then_date == today => "Today".to_string(), _ if then_date == today - chrono::Duration::days(1) => "Yesterday".to_string(), _ if days < 5 => "Few days".to_string(), _ if days < 14 => "About week".to_string(), _ if days < 21 => "2 weeks".to_string(), _ if days < 45 => "About month".to_string(), _ if days < 90 => "Few months".to_string(), _ if days < 365 => "Months ago".to_string(), _ => "Over year".to_string(), } } /// Terse relative time label for terminal tables. /// /// Uses minimal-width labels like "2m", "1h", "3d" to avoid column bloat. /// Privacy is still maintained — no exact timestamps are shown. /// /// Examples: /// - 2 minutes ago → "2m" /// - 45 minutes ago → "45m" /// - 2 hours ago → "2h" /// - yesterday → "1d" /// - 10 days ago → "10d" /// - 5 weeks ago → "5w" /// - 3 months ago → "3mo" /// - 2 years ago → "2y" fn terse_time(iso_timestamp: &str) -> String { let Ok(then) = chrono::NaiveDateTime::parse_from_str(iso_timestamp, "%Y-%m-%dT%H:%M:%S") else { return "?".to_string(); }; let now = chrono::Local::now().naive_local(); let delta = now.signed_duration_since(then); let mins = delta.num_minutes(); let hours = delta.num_hours(); let days = delta.num_days(); match () { _ if mins < 1 => "<1m".to_string(), _ if mins < 60 => format!("{}m", mins), _ if hours < 24 => format!("{}h", hours), _ if days < 14 => format!("{}d", days), _ if days < 90 => format!("{}w", days / 7), _ if days < 730 => format!("{}mo", days / 30), _ => format!("{}y", days / 365), } } // --------------------------------------------------------------------------- // Report data structures // --------------------------------------------------------------------------- /// The complete report data, containing all sections. This struct is used /// both for terminal rendering and JSON serialization, so all fields /// implement `Serialize`. #[derive(Debug, Serialize)] pub struct ReportData { /// Which time period this report covers. pub period: PeriodInfo, /// Aggregate statistics (total scrobbles, listen time, unique counts). pub overview: db::Overview, /// Top artists ranked by play count. pub top_artists: Vec, /// Top albums ranked by play count. pub top_albums: Vec, /// Top tracks ranked by play count. pub top_tracks: Vec, /// Top genres derived from cached album metadata. pub top_genres: Vec, /// Most recent scrobbles (newest first), limited to 20. pub recent_scrobbles: Vec, } /// Metadata about the time period the report covers. /// For "all time" reports, `from` and `to` will be `None`. #[derive(Debug, Serialize)] pub struct PeriodInfo { /// The period name as provided by the user (e.g., "week", "month", "all"). pub name: String, /// ISO 8601 start of the period, or None for "all". pub from: Option, /// ISO 8601 end of the period (current time), or None for "all". pub to: Option, } // --------------------------------------------------------------------------- // Terminal box-drawing table renderer // // Uses Unicode box-drawing characters (─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) and block // elements (█ ░) to produce tables with horizontal bar charts directly in // the terminal. No external crate needed. // --------------------------------------------------------------------------- /// Render a table with box-drawing characters. /// `headers` are the column names, `rows` are the cell values. /// The last column in each row is treated as a numeric value for the bar chart /// if `bar_max` is Some (the maximum value for scaling bars). fn print_box_table( headers: &[&str], rows: &[Vec], bar_max: Option, shrinkable_cols: &[usize], ) { if rows.is_empty() { return; } // Calculate column widths (max of header and all cell widths). let ncols = headers.len(); let mut widths: Vec = headers.iter().map(|h| visible_width(h)).collect(); for row in rows { for (i, cell) in row.iter().enumerate() { if i < ncols { widths[i] = widths[i].max(visible_width(cell)); } } } // Fit table to terminal width (best-effort): reduce bar width first, then // shrink only selected text columns. let terminal_width = terminal_columns(); let mut bar_width = if bar_max.is_some() { MAX_BAR_WIDTH } else { 0 }; while table_width(&widths, bar_width) > terminal_width && bar_width > MIN_BAR_WIDTH { bar_width -= 1; } while table_width(&widths, bar_width) > terminal_width { let mut changed = false; let mut widest: Option = None; for &idx in shrinkable_cols { if idx >= widths.len() { continue; } let min_w = 6usize; if widths[idx] <= min_w { continue; } if widest.is_none_or(|w| widths[idx] > widths[w]) { widest = Some(idx); } } if let Some(i) = widest { widths[i] -= 1; changed = true; } if !changed { break; } } // If we have a bar chart, add space for it after the last column. let bar_col = if bar_max.is_some() { bar_width + 2 } else { 0 }; let header_cells: Vec = headers .iter() .enumerate() .map(|(i, h)| truncate_cell(h, widths[i])) .collect(); // Top border: ┌──────┬──────┐ print!(" ┌"); for (i, w) in widths.iter().enumerate() { print!("{}", "─".repeat(w + 2)); if bar_max.is_some() && i == ncols - 1 { print!("┬{}", "─".repeat(bar_col)); } if i < ncols - 1 { print!("┬"); } } println!("┐"); // Header row: │ Header │ Header │ print!(" │"); for (i, h) in header_cells.iter().enumerate() { print!(" {: usize { // Try ioctl first — this is the only reliable method. #[cfg(unix)] { use std::mem::MaybeUninit; #[repr(C)] struct Winsize { ws_row: libc::c_ushort, ws_col: libc::c_ushort, ws_xpixel: libc::c_ushort, ws_ypixel: libc::c_ushort, } let mut ws = MaybeUninit::::uninit(); let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) }; if ret == 0 { let ws = unsafe { ws.assume_init() }; if ws.ws_col >= 40 { return ws.ws_col as usize; } } } if let Ok(cols) = std::env::var("COLUMNS") && let Ok(parsed) = cols.parse::() && parsed >= 40 { return parsed; } 80 } /// Compute rendered table width for current settings. fn table_width(widths: &[usize], bar_width: usize) -> usize { let ncols = widths.len(); let mut total = 2 + 1 + 1; // indent + left border + right border for (i, w) in widths.iter().enumerate() { total += w + 2; if i < ncols - 1 { total += 1; } } if bar_width > 0 { total += 1 + (bar_width + 2); } total } /// Visible character width (simple char-count approximation). fn visible_width(s: &str) -> usize { s.chars().count() } /// Truncate a cell to a target width using ASCII ellipsis. fn truncate_cell(s: &str, width: usize) -> String { let len = visible_width(s); if len <= width { return s.to_string(); } if width <= 3 { return s.chars().take(width).collect(); } let mut out: String = s.chars().take(width - 3).collect(); out.push_str("..."); out } // --------------------------------------------------------------------------- // Report generation // --------------------------------------------------------------------------- /// Query the database and assemble all sections of the report. /// /// This is the main entry point for report generation. It runs all the /// necessary SQL queries for the given period and limit, and returns /// a `ReportData` struct ready for rendering. /// /// # Arguments /// - `conn` — SQLite database connection /// - `period` — time period filter ("today", "week", "month", "year", "all") /// Return the set of `(artist, album)` pairs that will appear anywhere in the /// HTML report at the given limits. Used to focus automatic enrichment on /// exactly what the report needs rather than the entire scrobble library. pub fn albums_needed_for_report( conn: &Connection, limit: i64, all_time_limit: i64, ) -> std::collections::HashSet<(String, String)> { let mut pairs = std::collections::HashSet::new(); let periods: &[(&str, i64)] = &[ ("today", limit), ("week", limit), ("month", limit), ("all", all_time_limit), ]; for (period, lim) in periods { let cover_lim = album_cover_grid_limit(*lim); if let Ok(albums) = db::top_albums(conn, period, cover_lim) { for a in albums { pairs.insert((a.artist, a.album)); } } if let Ok(tracks) = db::top_tracks(conn, period, *lim) { for t in tracks { pairs.insert((t.artist, t.album)); } } // Include the most-played album per top artist so artist_cover() // has something to work with even when that album is outside top_albums. if let Ok(artists) = db::top_artists(conn, period, *lim) { for a in artists { if let Some(album) = db::artist_top_album(conn, &a.artist) { pairs.insert((a.artist, album)); } } } } pairs } /// - `limit` — maximum number of entries in each top-N list pub fn gather_report( conn: &Connection, period: &str, limit: i64, ) -> Result { // Compute the date range for the header/JSON metadata. let range = db::period_range(period); let period_info = PeriodInfo { name: period.to_string(), from: range.as_ref().map(|(f, _)| f.clone()), to: range.as_ref().map(|(_, t)| t.clone()), }; // Run all queries. Each query internally applies the same period filter. Ok(ReportData { period: period_info, overview: db::overview(conn, period)?, top_artists: db::top_artists(conn, period, limit)?, top_albums: db::top_albums(conn, period, limit)?, top_tracks: db::top_tracks(conn, period, limit)?, top_genres: db::top_genres(conn, period, limit)?, // Recent scrobbles are always limited to 20, regardless of the --limit flag. recent_scrobbles: db::recent_scrobbles(conn, period, 20)?, }) } // --------------------------------------------------------------------------- // Terminal output // --------------------------------------------------------------------------- /// Render the report as box-drawing tables with bar charts to stdout. /// /// Output sections (each printed only if non-empty): /// 1. Header with period name and date range /// 2. Overview KPIs /// 3. Top Artists with bar chart /// 4. Top Albums with bar chart /// 5. Top Genres with bar chart /// 6. Top Tracks with bar chart /// 7. Recent Scrobbles pub fn print_terminal_report(data: &ReportData) { // --- Header --- let period_label = match data.period.name.as_str() { "today" => "Today", "week" => "This Week", "month" => "This Month", "year" => "This Year", _ => "All Time", }; let date_range = match (&data.period.from, &data.period.to) { (Some(from), Some(_)) if data.period.name == "today" => format!(" {}", &from[..10]), (Some(from), Some(to)) => format!(" {} → {}", &from[..10], &to[..10]), _ => String::new(), }; println!(); println!( " ┌─ Listening Report ─── {} ──{}─┐", period_label, date_range ); println!(); // --- Overview KPIs --- println!( " Scrobbles: {} Listen time: {} Artists: {} Albums: {} Tracks: {}", data.overview.total_scrobbles, format_duration(data.overview.total_listen_time_secs), data.overview.unique_artists, data.overview.unique_albums, data.overview.unique_tracks, ); let mood = mood_labels(&data.top_genres, 6); if !mood.is_empty() && data.period.name != "all" { println!(" Mood: {}", mood.join(" · ")); } // --- Top Artists --- if !data.top_artists.is_empty() { println!("\n Top Artists"); let max_plays = data.top_artists.first().map(|a| a.plays).unwrap_or(1); let rows: Vec> = data .top_artists .iter() .enumerate() .map(|(i, a)| { vec![ format!("{:>2}", i + 1), a.artist.clone(), format_duration(a.listen_time_secs), a.plays.to_string(), ] }) .collect(); print_box_table( &["#", "Artist", "Time", "Plays"], &rows, Some(max_plays), &[1], ); } // --- Top Albums --- if !data.top_albums.is_empty() { println!("\n Top Albums"); let max_plays = data.top_albums.first().map(|a| a.plays).unwrap_or(1); let rows: Vec> = data .top_albums .iter() .enumerate() .map(|(i, a)| { vec![ format!("{:>2}", i + 1), a.artist.clone(), a.album.clone(), format_duration(a.listen_time_secs), a.plays.to_string(), ] }) .collect(); print_box_table( &["#", "Artist", "Album", "Time", "Plays"], &rows, Some(max_plays), &[1, 2], ); } // --- Top Genres --- if !data.top_genres.is_empty() { println!("\n Top Genres"); let max_plays = data.top_genres.first().map(|g| g.plays).unwrap_or(1); let rows: Vec> = data .top_genres .iter() .enumerate() .map(|(i, g)| { vec![ format!("{:>2}", i + 1), g.genre.clone(), format_duration(g.listen_time_secs), g.plays.to_string(), ] }) .collect(); print_box_table( &["#", "Genre", "Time", "Plays"], &rows, Some(max_plays), &[1], ); } // --- Top Tracks --- if !data.top_tracks.is_empty() { println!("\n Top Tracks"); let max_plays = data.top_tracks.first().map(|t| t.plays).unwrap_or(1); let rows: Vec> = data .top_tracks .iter() .enumerate() .map(|(i, t)| { vec![ format!("{:>2}", i + 1), t.artist.clone(), t.title.clone(), format_duration(t.listen_time_secs), t.plays.to_string(), ] }) .collect(); print_box_table( &["#", "Artist", "Title", "Time", "Plays"], &rows, Some(max_plays), &[1, 2], ); } // --- Recent Scrobbles --- if !data.recent_scrobbles.is_empty() { println!("\n Recent Scrobbles"); let rows: Vec> = data .recent_scrobbles .iter() .map(|s| { vec![ terse_time(&s.scrobbled_at), s.artist.clone(), s.title.clone(), s.album.clone(), ] }) .collect(); print_box_table( &["Ago", "Artist", "Title", "Album"], &rows, None, &[1, 2, 3], ); } println!(); } // --------------------------------------------------------------------------- // JSON output // --------------------------------------------------------------------------- /// Serialize the report as pretty-printed JSON and write it to stdout. /// This is the output format used with `--json`. pub fn print_json_report(data: &ReportData) { println!( "{}", serde_json::to_string_pretty(data).expect("Failed to serialize report") ); } /// Result of rendering an HTML report. Contains the HTML string and a list /// of cover image files that should be copied into a `covers/` subdirectory /// next to the HTML file. pub struct HtmlReport { /// The generated HTML document. pub html: String, /// Absolute paths to cover image files that the HTML references. /// Each should be copied to `/covers/`. pub cover_files: Vec, } /// Render a multi-period HTML report. /// /// The report contains sections for Today, This Week, This Month, and All Time, /// each with their own KPIs, top artists/albums/tracks with bar charts, and /// album cover art cards. /// /// Cover images are referenced via relative `covers/` paths. pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) -> HtmlReport { // Gather data for each period. All-time uses its own (larger) limit; // shorter periods use the base limit as-is. let periods: &[(&str, i64)] = &[ ("today", limit), ("week", limit), ("month", limit), ("all", all_time_limit), ]; let reports: Vec = periods .iter() .filter_map(|(p, l)| gather_report(conn, p, *l).ok()) .collect(); let mut cover_files: Vec = Vec::new(); let mut h = HtmlWriter::new(); // --- HTML head + CSS --- h.line(""); h.line(""); h.line(""); h.indent(); h.line(""); h.line(""); h.line("Listening Report"); h.line(""); h.line(""); h.line(""); h.line(""); h.dedent(); h.line(""); h.line(""); h.open("
"); // --- Hero header --- let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); h.open("
"); h.line("

Listening Report

"); h.linef(format_args!( "
Generated by mpris-scrobbler on {}
", html_escape(&generated_at) )); h.close("
"); // --- Mobile-friendly jump menu --- // Show links only for periods that will actually render. let nav_periods: Vec<(&str, &str)> = reports .iter() .filter(|d| d.overview.total_scrobbles > 0) .map(|d| match d.period.name.as_str() { "today" => ("Today", "period-today"), "week" => ("This Week", "period-week"), "month" => ("This Month", "period-month"), _ => ("All Time", "period-all"), }) .collect(); if !nav_periods.is_empty() { h.open(""); } // --- Period sections (Today → Week → Month → All Time) --- for data in &reports { let (label, is_all_time, section_id) = match data.period.name.as_str() { "today" => ("Today", false, "period-today"), "week" => ("This Week", false, "period-week"), "month" => ("This Month", false, "period-month"), _ => ("All Time", true, "period-all"), }; let range_str = match (&data.period.from, &data.period.to) { (Some(from), Some(_)) if data.period.name == "today" => from[..10].to_string(), (Some(from), Some(to)) => format!("{} → {}", &from[..10], &to[..10]), _ => String::new(), }; // Skip empty periods. if data.overview.total_scrobbles == 0 { continue; } h.blank(); h.open(&format!( "
", section_id )); h.linef(format_args!( "
{}
", html_escape(label) )); if !range_str.is_empty() { h.linef(format_args!( "
{}
", html_escape(&range_str) )); } let mood = mood_labels(&data.top_genres, 6); if !mood.is_empty() && !is_all_time { h.open("
"); h.linef(format_args!( "Mood of {}:", html_escape(label) )); for genre in mood { h.linef(format_args!( "{}", html_escape(&genre) )); } h.close("
"); } // KPIs h.open("
"); write_kpi( &mut h, "Scrobbles", &data.overview.total_scrobbles.to_string(), ); write_kpi( &mut h, "Listen time", &format_duration(data.overview.total_listen_time_secs), ); write_kpi(&mut h, "Artists", &data.overview.unique_artists.to_string()); write_kpi(&mut h, "Albums", &data.overview.unique_albums.to_string()); write_kpi(&mut h, "Tracks", &data.overview.unique_tracks.to_string()); h.close("
"); // Album cover grid — shown first after KPIs to visually illustrate // the period's listening at a glance. // // We intentionally round the cover count up to a full desktop row so // the grid doesn't end with a partially filled row when the user picks // a limit like 20 and desktop layout has 6 columns. let cover_limit = album_cover_grid_limit(limit); let cover_albums = db::top_albums(conn, &data.period.name, cover_limit) .unwrap_or_else(|_| data.top_albums.clone()); if !cover_albums.is_empty() { h.blank(); h.open("
"); h.line("

Top Album Covers

"); h.open("
"); for a in &cover_albums { let meta = db::album_cache_meta(conn, &a.artist, &a.album) .ok() .flatten(); let genre = meta.as_ref().and_then(|m| m.genre.as_ref()); let cmd = pin_album_cmd( &a.artist, &a.album, meta.as_ref().and_then(|m| m.mbid.as_deref()), ); h.open("
"); let cover_rel = resolve_cover( meta.as_ref().and_then(|m| m.cover_url.clone()), &mut cover_files, ); if let Some(ref rel) = cover_rel { h.linef(format_args!( "\"{}\"", html_attr_escape(rel), html_attr_escape(&format!("{} - {}", a.artist, a.album)) )); } else { h.line("
No cover
"); } h.linef(format_args!( "", html_attr_escape(&cmd) )); h.open("
"); h.linef(format_args!( "
{}
", html_escape(&a.album) )); h.linef(format_args!( "
{}
", html_escape(&a.artist) )); h.linef(format_args!( "
{} · {}
", html_escape(&format_play_count(a.plays)), html_escape(&format_duration(a.listen_time_secs)) )); if let Some(g) = genre { let labels = top_genre_labels(g, 3); if !labels.is_empty() { h.open("
"); for label in labels { h.linef(format_args!( "{}", html_escape(&label) )); } h.close("
"); } } h.close("
"); // .meta h.close("
"); } h.close("
"); // .grid h.close("
"); } // Top Albums as a bar table, matching the visual language used by // Top Artists and Top Tracks. let album_rows: Vec = data .top_albums .iter() .enumerate() .map(|(i, a)| { let cover_url = db::album_cache_meta(conn, &a.artist, &a.album) .ok() .flatten() .and_then(|m| m.cover_url); let cover = resolve_cover(cover_url, &mut cover_files); BarRow { cells: vec![(i + 1).to_string(), a.artist.clone(), a.album.clone()], value: a.plays, suffix: format_duration(a.listen_time_secs), cover, } }) .collect(); write_bar_table( &mut h, "Top Albums", &["#", "Artist", "Album", "Plays", ""], &album_rows, ); // Top Artists with bar chart and mini cover. // Each artist's cover is taken from their most-played album in this // specific period. let artist_rows: Vec = data .top_artists .iter() .enumerate() .map(|(i, a)| { let cover = resolve_cover( db::artist_cover(conn, &a.artist, &data.period.name), &mut cover_files, ); BarRow { cells: vec![(i + 1).to_string(), a.artist.clone()], value: a.plays, suffix: format_duration(a.listen_time_secs), cover, } }) .collect(); write_bar_table( &mut h, "Top Artists", &["#", "Artist", "Plays", ""], &artist_rows, ); // Top Genres with bar chart. let genre_rows: Vec = data .top_genres .iter() .enumerate() .map(|(i, g)| BarRow { cells: vec![(i + 1).to_string(), g.genre.clone()], value: g.plays, suffix: format_duration(g.listen_time_secs), cover: None, }) .collect(); write_bar_table( &mut h, "Top Genres", &["#", "Genre", "Plays", ""], &genre_rows, ); // Top Tracks with bar chart and mini cover. // Each track's cover comes from its album. let track_rows: Vec = data .top_tracks .iter() .enumerate() .map(|(i, t)| { let cover_url = db::album_cache_meta(conn, &t.artist, &t.album) .ok() .flatten() .and_then(|m| m.cover_url); let cover = resolve_cover(cover_url, &mut cover_files); BarRow { cells: vec![(i + 1).to_string(), t.artist.clone(), t.title.clone()], value: t.plays, suffix: format_duration(t.listen_time_secs), cover, } }) .collect(); write_bar_table( &mut h, "Top Tracks", &["#", "Artist", "Title", "Plays", ""], &track_rows, ); // Recent scrobbles (only for All Time section to avoid repetition) if is_all_time && !data.recent_scrobbles.is_empty() { write_plain_table( &mut h, "Recent Scrobbles", &["When", "Artist", "Title", "Album"], &data .recent_scrobbles .iter() .map(|s| { vec![ fuzzy_time(&s.scrobbled_at), s.artist.clone(), s.title.clone(), s.album.clone(), ] }) .collect::>(), ); } h.close("
"); // .period-block } h.close("
"); // .wrap h.line(""); h.line(""); // Deduplicate cover files — the same album may appear in multiple // period sections, but we only need to copy each image once. cover_files.sort(); cover_files.dedup(); HtmlReport { html: h.finish(), cover_files, } } // --------------------------------------------------------------------------- // CSS (kept minified since it's a style block, not meant to be read in HTML) // --------------------------------------------------------------------------- const CSS: &str = "\ :root { --bg: #11100d; --panel: #1b1813; --panel2: #241f18; --text: #f1eadf; --muted: #b8a892; --line: #3b3228; --accent: #f0c06a; --accent2: #d89a3d; --bar: #3a2a16; --bar-fill: #d89a3d; } * { box-sizing: border-box; } body { margin: 0; color: var(--text); font-family: \"JetBrains Mono\", \"Fira Code\", \"IBM Plex Mono\", \"Cascadia Code\", \"SFMono-Regular\", Menlo, Consolas, monospace; background: radial-gradient(1200px 420px at 10% -10%, #2a231a, transparent), radial-gradient(1000px 380px at 100% 0%, #31230f, transparent), var(--bg); } .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 18px 56px; } h1, h2, h3 { margin: 0; } .hero { padding: 22px 24px; border: 1px solid var(--line); background: linear-gradient(145deg, var(--panel), #15120d); border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.25); } .sub { margin-top: 6px; color: var(--muted); font-size: 14px; } .jump-nav { position: sticky; top: 0; z-index: 20; margin-top: 12px; padding: 8px; display: flex; gap: 8px; overflow-x: auto; background: rgba(17, 16, 13, 0.84); border: 1px solid var(--line); border-radius: 10px; backdrop-filter: blur(8px); } .jump-nav a { white-space: nowrap; text-decoration: none; color: var(--text); font-size: 12px; font-weight: 600; padding: 7px 10px; border-radius: 8px; border: 1px solid #5b4631; background: linear-gradient(140deg, #2b2218, #34281c); } .jump-nav a:hover { border-color: #a87937; } .period-block { margin-top: 32px; padding: 20px 24px; border: 1px solid var(--line); background: var(--panel); border-radius: 14px; } .period-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; } .period-range { font-size: 13px; color: var(--muted); margin-bottom: 14px; } .mood { margin-bottom: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } .mood-title { font-size: 11px; color: var(--muted); } .mood-pill { font-size: 9px; line-height: 1; letter-spacing: 0.01em; color: #e9c98f; padding: 2px 5px; border: 1px solid #7e5e2f; border-radius: 999px; } .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; } .kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px; } .kpi .k { font-size: 11px; color: var(--muted); } .kpi .v { margin-top: 4px; font-size: 22px; font-weight: 700; } section { margin-top: 18px; } .section-title { font-size: 16px; margin-bottom: 8px; } .card { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--line); } th { font-size: 11px; color: var(--muted); letter-spacing: .02em; text-transform: uppercase; } tr:last-child td { border-bottom: none; } .mono { font-family: \"JetBrains Mono\", \"Fira Code\", \"IBM Plex Mono\", \"Cascadia Code\", \"SFMono-Regular\", Menlo, Consolas, monospace; } .bar-cell { width: 40%; } .bar-wrap { display: flex; align-items: center; gap: 8px; } .bar { height: 18px; border-radius: 4px; background: var(--bar-fill); min-width: 2px; } .bar-label { font-size: 12px; color: var(--muted); white-space: nowrap; } .grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; } .album { position: relative; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } .cover { width: 100%; aspect-ratio: 1/1; object-fit: cover; background: #0d1116; } .ph { width: 100%; aspect-ratio: 1/1; display: grid; place-items: center; place-content: center; color: #a99984; background: linear-gradient(135deg, #17130d, #2c2116); font-size: 12px; } .pin-dot { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.45); border: none; cursor: pointer; font-size: 12px; line-height: 22px; text-align: center; opacity: 0; transition: opacity 0.15s; z-index: 1; } .album:hover .pin-dot { opacity: 1; } .pin-dot:hover { background: rgba(0,0,0,0.75); } .meta { padding: 8px; } .t { font-size: 13px; font-weight: 700; line-height: 1.2; } .a { margin-top: 3px; font-size: 11px; color: var(--muted); } .genre { margin-top: 5px; display: flex; gap: 5px; flex-wrap: wrap; } .pill { display: inline-block; font-size: 9px; line-height: 1; letter-spacing: 0.01em; color: #e9c98f; padding: 2px 5px; border: 1px solid #7e5e2f; border-radius: 999px; } .mini-cover { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; vertical-align: middle; } .mini-ph { display: inline-block; width: 36px; height: 36px; border-radius: 4px; background: linear-gradient(135deg, #17130d, #2c2116); vertical-align: middle; } .nowrap { white-space: nowrap; } .note { margin-top: 18px; color: var(--muted); font-size: 12px; } @media (max-width: 980px) { .grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 700px) { .wrap { padding: 16px 12px 36px; } .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .jump-nav { margin-top: 10px; padding: 7px; } } "; /// Desktop column count for the album cover grid. const ALBUM_GRID_COLUMNS: i64 = 6; /// Round a user-provided limit up to the nearest full grid row. /// /// Examples with 6-column rows: /// - limit 20 -> 24 /// - limit 18 -> 18 /// - limit 1 -> 6 fn album_cover_grid_limit(limit: i64) -> i64 { let columns = ALBUM_GRID_COLUMNS; let safe = limit.max(1); ((safe + columns - 1) / columns) * columns } // --------------------------------------------------------------------------- // HtmlWriter — helper for producing indented HTML output // --------------------------------------------------------------------------- /// A simple helper that tracks indentation depth so the generated HTML is /// human-readable. Each `open()` increases indent, each `close()` decreases. struct HtmlWriter { buf: String, depth: usize, } impl HtmlWriter { fn new() -> Self { Self { buf: String::with_capacity(128 * 1024), depth: 0, } } /// Write a self-contained line at the current indent level. fn line(&mut self, s: &str) { self.write_indent(); self.buf.push_str(s); self.buf.push('\n'); } /// Write a formatted line at the current indent level. fn linef(&mut self, args: std::fmt::Arguments<'_>) { self.write_indent(); let _ = write!(self.buf, "{}", args); self.buf.push('\n'); } /// Write raw text (no indent, no newline added — the text provides its own). fn raw(&mut self, s: &str) { self.buf.push_str(s); } /// Write an opening tag and increase indent for children. fn open(&mut self, tag: &str) { self.line(tag); self.indent(); } /// Decrease indent and write a closing tag. fn close(&mut self, tag: &str) { self.dedent(); self.line(tag); } /// Write a blank line for visual separation. fn blank(&mut self) { self.buf.push('\n'); } fn indent(&mut self) { self.depth += 1; } fn dedent(&mut self) { self.depth = self.depth.saturating_sub(1); } fn write_indent(&mut self) { for _ in 0..self.depth { self.buf.push_str(" "); } } /// Consume the writer and return the finished HTML string. fn finish(self) -> String { self.buf } } // --------------------------------------------------------------------------- // HTML content helpers // --------------------------------------------------------------------------- /// Convert an absolute cover file path (from the DB) into a relative path /// for use in HTML (`covers/`), and track the source file for /// copying into the output directory. Returns `None` if the path is missing /// or the file doesn't exist on disk. fn resolve_cover( abs_path: Option, cover_files: &mut Vec, ) -> Option { let src = abs_path?; let src_path = std::path::Path::new(&src); let filename = src_path.file_name()?; if src_path.exists() { cover_files.push(src_path.to_path_buf()); } Some(format!("covers/{}", filename.to_string_lossy())) } fn write_kpi(h: &mut HtmlWriter, key: &str, val: &str) { h.open("
"); h.linef(format_args!("
{}
", html_escape(key))); h.linef(format_args!( "
{}
", html_escape(val) )); h.close("
"); } /// A row in a bar-chart table. `cells` are the text columns, `value` is /// the numeric value used for the bar width, `suffix` is shown after the /// bar (e.g., listen time), and `cover` is an optional relative path to /// a mini cover image shown at the start of the row. struct BarRow { cells: Vec, value: i64, suffix: String, /// Relative path to a cover image (e.g., "covers/UUID.jpg"), or None. cover: Option, } /// Render a table where the last column is a horizontal bar chart. fn write_bar_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[BarRow]) { if rows.is_empty() { return; } let max_val = rows.iter().map(|r| r.value).max().unwrap_or(1).max(1); h.blank(); h.open("
"); h.linef(format_args!( "

{}

", html_escape(title) )); h.open("
"); h.open(""); // Check if any row has a cover image — if so, add a cover column. let has_covers = rows.iter().any(|r| r.cover.is_some()); // Header h.open(""); h.open(""); if has_covers { h.line(""); // empty header for the cover column } for hdr in headers { h.linef(format_args!("", html_escape(hdr))); } h.close(""); h.close(""); // Body h.open(""); for row in rows { h.open(""); if has_covers { if let Some(ref src) = row.cover { h.linef(format_args!( "", html_attr_escape(src) )); } else { h.line(""); } } for cell in &row.cells { h.linef(format_args!("", html_escape(cell))); } let pct = (row.value as f64 / max_val as f64 * 100.0).round() as u32; h.linef(format_args!("", row.value)); h.linef(format_args!( "", s = " ".repeat(h.depth), pct = pct, suffix = html_escape(&row.suffix) )); h.close(""); } h.close(""); h.close("
{}
\"\"{}{}\n\ {s}
\n\ {s}
\n\ {s} {suffix}\n\ {s}
\n\ {s}
"); h.close("
"); // .card h.close("
"); } /// Render a plain table (no bar chart). fn write_plain_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[Vec]) { if rows.is_empty() { return; } h.blank(); h.open("
"); h.linef(format_args!( "

{}

", html_escape(title) )); h.open("
"); h.open(""); h.open(""); h.open(""); for hdr in headers { h.linef(format_args!("", html_escape(hdr))); } h.close(""); h.close(""); h.open(""); for row in rows { h.open(""); for (i, col) in row.iter().enumerate() { if i == 0 { h.linef(format_args!( "", html_escape(col) )); } else { h.linef(format_args!("", html_escape(col))); } } h.close(""); } h.close(""); h.close("
{}
{}{}
"); h.close("
"); h.close("
"); } /// Build the `pin-album` shell command for the given artist and album. /// Uses the known MBID when available, otherwise substitutes `MBID_HERE`. /// Double-quotes in string values are escaped so the command is safe to paste. fn pin_album_cmd(artist: &str, album: &str, mbid: Option<&str>) -> String { let a = artist.replace('"', "\\\""); let b = album.replace('"', "\\\""); let m = mbid.unwrap_or("MBID_HERE"); format!( "mpris-scrobbler pin-album --artist \"{a}\" --album \"{b}\" --mbid \"{m}\"" ) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") } fn html_attr_escape(s: &str) -> String { html_escape(s).replace('"', """) } /// Split a comma-separated genre list and keep only the first `max_labels` /// cleaned labels. fn top_genre_labels(genre_csv: &str, max_labels: usize) -> Vec { genre_csv .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .take(max_labels) .map(|s| s.to_string()) .collect() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // ======================================================================= // Duration formatting // ======================================================================= #[test] fn test_format_duration_seconds() { assert_eq!(format_duration(45), "45s"); } #[test] fn test_format_duration_minutes() { assert_eq!(format_duration(120), "2m"); assert_eq!(format_duration(300), "5m"); } #[test] fn test_format_duration_hours() { assert_eq!(format_duration(3600), "1h"); assert_eq!(format_duration(3660), "1h 01m"); assert_eq!(format_duration(7380), "2h 03m"); } #[test] fn test_format_duration_zero() { assert_eq!(format_duration(0), "0s"); } #[test] fn test_format_play_count() { assert_eq!(format_play_count(0), "0 plays"); assert_eq!(format_play_count(1), "1 play"); assert_eq!(format_play_count(2), "2 plays"); } #[test] fn test_truncate_cell() { assert_eq!(truncate_cell("abcdef", 6), "abcdef"); assert_eq!(truncate_cell("abcdef", 5), "ab..."); assert_eq!(truncate_cell("abcdef", 3), "abc"); } #[test] fn test_album_cover_grid_limit_rounds_to_full_rows() { assert_eq!(album_cover_grid_limit(20), 24); assert_eq!(album_cover_grid_limit(18), 18); assert_eq!(album_cover_grid_limit(1), 6); assert_eq!(album_cover_grid_limit(0), 6); } #[test] fn test_top_genre_labels_caps_to_three() { let labels = top_genre_labels("alternative rock, electronic, industrial, darkwave", 3); assert_eq!( labels, vec![ "alternative rock".to_string(), "electronic".to_string(), "industrial".to_string(), ] ); } #[test] fn test_mood_labels_caps_to_three() { let genres = vec![ db::TopGenre { genre: "trip hop".to_string(), plays: 5, listen_time_secs: 1000, }, db::TopGenre { genre: "electronic".to_string(), plays: 4, listen_time_secs: 900, }, db::TopGenre { genre: "ambient".to_string(), plays: 3, listen_time_secs: 800, }, db::TopGenre { genre: "downtempo".to_string(), plays: 2, listen_time_secs: 700, }, ]; assert_eq!( mood_labels(&genres, 3), vec![ "trip hop".to_string(), "electronic".to_string(), "ambient".to_string(), ] ); } // ======================================================================= // Report gathering // ======================================================================= #[test] fn test_gather_report_empty_db() { // An empty database should produce a valid report with all zeros. let conn = db::open_memory_db().unwrap(); let report = gather_report(&conn, "all", 10).unwrap(); assert_eq!(report.overview.total_scrobbles, 0); assert!(report.top_artists.is_empty()); assert!(report.top_genres.is_empty()); assert!(report.recent_scrobbles.is_empty()); } #[test] fn test_gather_report_with_data() { // Populate a test database and verify the report contains expected data. let conn = db::open_memory_db().unwrap(); let scrobbles = vec![ db::NewScrobble { artist: "††† (Crosses)".into(), album: "††† (Crosses)".into(), title: "This Is a Trick".into(), track_duration_secs: Some(186), played_duration_secs: 186, scrobbled_at: "2026-03-19T10:00:00".into(), }, db::NewScrobble { artist: "††† (Crosses)".into(), album: "††† (Crosses)".into(), title: "Telepathy".into(), track_duration_secs: Some(215), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:05:00".into(), }, db::NewScrobble { artist: "Deftones".into(), album: "White Pony".into(), title: "Digital Bath".into(), track_duration_secs: Some(291), played_duration_secs: 291, scrobbled_at: "2026-03-19T10:10:00".into(), }, ]; for s in &scrobbles { db::insert_scrobble(&conn, s).unwrap(); } let report = gather_report(&conn, "all", 10).unwrap(); // Overview should reflect all 3 scrobbles. assert_eq!(report.overview.total_scrobbles, 3); // Two distinct artists: ††† (Crosses) and Deftones. assert_eq!(report.top_artists.len(), 2); // ††† (Crosses) has 2 plays, so it should be ranked first. assert_eq!(report.top_artists[0].artist, "††† (Crosses)"); // All 3 tracks are unique. assert_eq!(report.top_tracks.len(), 3); // No cached genres yet in this test fixture. assert!(report.top_genres.is_empty()); // Recent scrobbles should contain all 3. assert_eq!(report.recent_scrobbles.len(), 3); // Period should be "all" with no date range. assert_eq!(report.period.name, "all"); } #[test] fn test_json_serialization() { // Verify that the report can be serialized to JSON and contains // expected fields. let conn = db::open_memory_db().unwrap(); db::insert_scrobble( &conn, &db::NewScrobble { artist: "Test".into(), album: "Album".into(), title: "Song".into(), track_duration_secs: Some(180), played_duration_secs: 180, scrobbled_at: "2026-03-19T10:00:00".into(), }, ) .unwrap(); let report = gather_report(&conn, "all", 10).unwrap(); let json = serde_json::to_string(&report).unwrap(); assert!(json.contains("\"total_scrobbles\":1")); assert!(json.contains("\"artist\":\"Test\"")); } #[test] fn test_html_render_contains_sections() { let conn = db::open_memory_db().unwrap(); db::insert_scrobble( &conn, &db::NewScrobble { artist: "Test Artist".into(), album: "Test Album".into(), title: "Test Song".into(), track_duration_secs: Some(180), played_duration_secs: 170, scrobbled_at: "2026-03-19T10:00:00".into(), }, ) .unwrap(); db::upsert_album_cache( &conn, &db::AlbumCacheEntry { artist: "Test Artist".into(), album: "Test Album".into(), musicbrainz_id: None, cover_url: None, genre: Some("trip hop".into()), fetched_at: "2026-03-19T10:10:00".into(), }, ) .unwrap(); let report = render_html_report(&conn, 10, 25); assert!(report.html.contains("