Listen source colouring

This commit is contained in:
2026-03-22 12:59:37 +00:00
parent aaa1580c49
commit 2668f7408d
2 changed files with 111 additions and 15 deletions

View File

@@ -97,6 +97,9 @@ pub struct TopAlbum {
pub album: String, pub album: String,
pub plays: i64, pub plays: i64,
pub listen_time_secs: i64, pub listen_time_secs: i64,
/// The source (player) responsible for the most scrobbles of this album
/// in the queried period. `None` for historical rows without a source.
pub dominant_source: Option<String>,
} }
/// A row in the "top tracks" ranking, grouped by (artist, title). /// A row in the "top tracks" ranking, grouped by (artist, title).
@@ -443,6 +446,15 @@ pub fn top_albums(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Top
// //
// The displayed artist is similarly resolved: the enriched (cached) // The displayed artist is similarly resolved: the enriched (cached)
// artist takes priority so that album_cache_meta() can find the cover. // artist takes priority so that album_cache_meta() can find the cover.
// The outer query uses `whr` which starts with " WHERE ...".
// The dominant_source subquery already has its own WHERE, so we need an
// AND-form of the same filter to avoid double WHERE.
let subquery_period_filter = if whr.is_empty() {
String::new()
} else {
" AND s2.scrobbled_at BETWEEN ?1 AND ?2".to_string()
};
let sql = format!( let sql = format!(
"SELECT "SELECT
CASE WHEN c.artist IS NOT NULL THEN s.artist CASE WHEN c.artist IS NOT NULL THEN s.artist
@@ -455,7 +467,16 @@ pub fn top_albums(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Top
END AS artist, END AS artist,
s.album AS album, s.album AS album,
COUNT(*) AS plays, COUNT(*) AS plays,
COALESCE(SUM(s.played_duration_secs), 0) AS listen_time COALESCE(SUM(s.played_duration_secs), 0) AS listen_time,
-- Dominant source: the player responsible for the most scrobbles
-- of this album in the queried period. Used to colour-code cards.
(SELECT s2.source
FROM scrobbles s2
WHERE s2.album = s.album
AND s2.source IS NOT NULL{}
GROUP BY s2.source
ORDER BY COUNT(*) DESC
LIMIT 1) AS dominant_source
FROM scrobbles s FROM scrobbles s
LEFT JOIN album_cache c ON c.artist = s.artist AND c.album = s.album{} LEFT JOIN album_cache c ON c.artist = s.artist AND c.album = s.album{}
GROUP BY s.album, COALESCE( GROUP BY s.album, COALESCE(
@@ -467,7 +488,7 @@ pub fn top_albums(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Top
) )
ORDER BY plays DESC, listen_time DESC, artist ASC, album ASC ORDER BY plays DESC, listen_time DESC, artist ASC, album ASC
LIMIT {}", LIMIT {}",
whr, limit subquery_period_filter, whr, limit
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
@@ -477,6 +498,7 @@ pub fn top_albums(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Top
album: row.get(1)?, album: row.get(1)?,
plays: row.get(2)?, plays: row.get(2)?,
listen_time_secs: row.get(3)?, listen_time_secs: row.get(3)?,
dominant_source: row.get(4)?,
}) })
}; };
if p.is_empty() { if p.is_empty() {

View File

@@ -782,6 +782,15 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
.filter_map(|(p, l)| gather_report(conn, p, *l).ok()) .filter_map(|(p, l)| gather_report(conn, p, *l).ok())
.collect(); .collect();
// Build a stable source → colour index from all-time play counts so that
// the same source always gets the same colour regardless of which period
// is being rendered. Most-played source gets palette slot 0, and so on.
let ordered_sources: Vec<String> = db::top_sources(conn, "all")
.unwrap_or_default()
.into_iter()
.map(|s| s.source)
.collect();
let mut cover_files: Vec<std::path::PathBuf> = Vec::new(); let mut cover_files: Vec<std::path::PathBuf> = Vec::new();
let mut h = HtmlWriter::new(); let mut h = HtmlWriter::new();
@@ -915,11 +924,28 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
.top_sources .top_sources
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, s)| BarRow { .map(|(i, s)| {
cells: vec![(i + 1).to_string(), s.source.clone()], // Prefix the source name with a colour dot matching the
value: s.scrobbles, // palette slot assigned to it in the all-time ranking.
suffix: format_duration(s.listen_time_secs), let label = if let Some((bg, _)) =
cover: None, source_colours(&s.source, &ordered_sources)
{
format!(
"<span style=\"display:inline-block;width:9px;height:9px;\
border-radius:50%;background:{};margin-right:5px;\
vertical-align:middle\"></span>{}",
bg, html_escape(&s.source)
)
} else {
html_escape(&s.source)
};
BarRow {
cells: vec![(i + 1).to_string(), label],
value: s.scrobbles,
suffix: format_duration(s.listen_time_secs),
cover: None,
raw_cells: true,
}
}) })
.collect(); .collect();
write_bar_table( write_bar_table(
@@ -954,7 +980,19 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
&a.album, &a.album,
meta.as_ref().and_then(|m| m.mbid.as_deref()), meta.as_ref().and_then(|m| m.mbid.as_deref()),
); );
h.open("<article class=\"album\">"); // Tint the card background with the dominant source's colour.
let card_style = a
.dominant_source
.as_deref()
.and_then(|src| source_colours(src, &ordered_sources))
.map(|(bg, border)| {
format!(
" style=\"background:{};border-color:{}\"",
bg, border
)
})
.unwrap_or_default();
h.linef(format_args!("<article class=\"album\"{}>", card_style));
let cover_rel = resolve_cover( let cover_rel = resolve_cover(
meta.as_ref().and_then(|m| m.cover_url.clone()), meta.as_ref().and_then(|m| m.cover_url.clone()),
@@ -1029,6 +1067,7 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
value: a.plays, value: a.plays,
suffix: format_duration(a.listen_time_secs), suffix: format_duration(a.listen_time_secs),
cover, cover,
raw_cells: false,
} }
}) })
.collect(); .collect();
@@ -1056,6 +1095,7 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
value: a.plays, value: a.plays,
suffix: format_duration(a.listen_time_secs), suffix: format_duration(a.listen_time_secs),
cover, cover,
raw_cells: false,
} }
}) })
.collect(); .collect();
@@ -1076,6 +1116,7 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
value: g.plays, value: g.plays,
suffix: format_duration(g.listen_time_secs), suffix: format_duration(g.listen_time_secs),
cover: None, cover: None,
raw_cells: false,
}) })
.collect(); .collect();
write_bar_table( write_bar_table(
@@ -1102,6 +1143,7 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) ->
value: t.plays, value: t.plays,
suffix: format_duration(t.listen_time_secs), suffix: format_duration(t.listen_time_secs),
cover, cover,
raw_cells: false,
} }
}) })
.collect(); .collect();
@@ -1272,6 +1314,32 @@ tr:last-child td { border-bottom: none; }
} }
"; ";
/// Source colour palette — muted tints that work as card backgrounds on the
/// dark theme. Assigned in order to sources ranked by all-time scrobble count,
/// so the most-listened source always gets the first colour.
///
/// Format: (background tint, border accent)
const SOURCE_PALETTE: &[(&str, &str)] = &[
("rgba(240,192,106,0.10)", "rgba(240,192,106,0.30)"), // amber (warm)
("rgba(100,160,240,0.10)", "rgba(100,160,240,0.30)"), // blue (cool)
("rgba(140,210,130,0.10)", "rgba(140,210,130,0.30)"), // green
("rgba(200,130,220,0.10)", "rgba(200,130,220,0.30)"), // purple
("rgba(220,130,130,0.10)", "rgba(220,130,130,0.30)"), // red
];
/// Look up the card background and border colours for a source name, given
/// the ordered list of all-time sources (most played first).
fn source_colours<'a>(
source: &str,
ordered_sources: &[String],
) -> Option<(&'a str, &'a str)> {
ordered_sources
.iter()
.position(|s| s == source)
.and_then(|i| SOURCE_PALETTE.get(i % SOURCE_PALETTE.len()))
.copied()
}
/// Desktop column count for the album cover grid. /// Desktop column count for the album cover grid.
const ALBUM_GRID_COLUMNS: i64 = 6; const ALBUM_GRID_COLUMNS: i64 = 6;
@@ -1403,6 +1471,8 @@ struct BarRow {
suffix: String, suffix: String,
/// Relative path to a cover image (e.g., "covers/UUID.jpg"), or None. /// Relative path to a cover image (e.g., "covers/UUID.jpg"), or None.
cover: Option<String>, cover: Option<String>,
/// When true, `cells` contain pre-escaped HTML and must not be escaped again.
raw_cells: bool,
} }
/// Render a table where the last column is a horizontal bar chart. /// Render a table where the last column is a horizontal bar chart.
@@ -1451,7 +1521,11 @@ fn write_bar_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[Ba
} }
} }
for cell in &row.cells { for cell in &row.cells {
h.linef(format_args!("<td>{}</td>", html_escape(cell))); if row.raw_cells {
h.linef(format_args!("<td>{}</td>", cell));
} else {
h.linef(format_args!("<td>{}</td>", html_escape(cell)));
}
} }
let pct = (row.value as f64 / max_val as f64 * 100.0).round() as u32; let pct = (row.value as f64 / max_val as f64 * 100.0).round() as u32;
h.linef(format_args!("<td>{}</td>", row.value)); h.linef(format_args!("<td>{}</td>", row.value));
@@ -1691,7 +1765,7 @@ mod tests {
title: "This Is a Trick".into(), title: "This Is a Trick".into(),
track_duration_secs: Some(186), track_duration_secs: Some(186),
played_duration_secs: 186, played_duration_secs: 186,
scrobbled_at: "2026-03-19T10:00:00".into(), scrobbled_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
source: "test".into(), source: "test".into(),
}, },
db::NewScrobble { db::NewScrobble {
@@ -1700,7 +1774,7 @@ mod tests {
title: "Telepathy".into(), title: "Telepathy".into(),
track_duration_secs: Some(215), track_duration_secs: Some(215),
played_duration_secs: 200, played_duration_secs: 200,
scrobbled_at: "2026-03-19T10:05:00".into(), scrobbled_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
source: "test".into(), source: "test".into(),
}, },
db::NewScrobble { db::NewScrobble {
@@ -1709,7 +1783,7 @@ mod tests {
title: "Digital Bath".into(), title: "Digital Bath".into(),
track_duration_secs: Some(291), track_duration_secs: Some(291),
played_duration_secs: 291, played_duration_secs: 291,
scrobbled_at: "2026-03-19T10:10:00".into(), scrobbled_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
source: "test".into(), source: "test".into(),
}, },
]; ];
@@ -1748,7 +1822,7 @@ mod tests {
title: "Song".into(), title: "Song".into(),
track_duration_secs: Some(180), track_duration_secs: Some(180),
played_duration_secs: 180, played_duration_secs: 180,
scrobbled_at: "2026-03-19T10:00:00".into(), scrobbled_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
source: "test".into(), source: "test".into(),
}, },
) )
@@ -1771,7 +1845,7 @@ mod tests {
title: "Test Song".into(), title: "Test Song".into(),
track_duration_secs: Some(180), track_duration_secs: Some(180),
played_duration_secs: 170, played_duration_secs: 170,
scrobbled_at: "2026-03-19T10:00:00".into(), scrobbled_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
source: "test".into(), source: "test".into(),
}, },
) )
@@ -1784,7 +1858,7 @@ mod tests {
musicbrainz_id: None, musicbrainz_id: None,
cover_url: None, cover_url: None,
genre: Some("trip hop".into()), genre: Some("trip hop".into()),
fetched_at: "2026-03-19T10:10:00".into(), fetched_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
}, },
) )
.unwrap(); .unwrap();