Listen source colouring
This commit is contained in:
26
src/db.rs
26
src/db.rs
@@ -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() {
|
||||||
|
|||||||
100
src/report.rs
100
src/report.rs
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user