diff --git a/src/db.rs b/src/db.rs index 4a77d8d..c4220b8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -64,6 +64,8 @@ pub struct NewScrobble { pub track_duration_secs: Option, pub played_duration_secs: i64, pub scrobbled_at: String, + /// The source that produced this scrobble (e.g. "MPD", "Qobuz"). + pub source: String, } /// Aggregate overview statistics for a given time period. @@ -119,6 +121,15 @@ pub struct TopGenre { pub listen_time_secs: i64, } +/// A row in the "top sources" breakdown, showing which player or source +/// contributed most scrobbles (e.g. "MPD", "Qobuz"). +#[derive(Debug, Clone, Serialize)] +pub struct TopSource { + pub source: String, + pub scrobbles: i64, + pub listen_time_secs: i64, +} + // --------------------------------------------------------------------------- // Database initialization // --------------------------------------------------------------------------- @@ -186,8 +197,15 @@ fn init_schema(conn: &Connection) -> Result<()> { scrobbled_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_scrobbled_at ON scrobbles(scrobbled_at); - CREATE INDEX IF NOT EXISTS idx_artist ON scrobbles(artist); - + CREATE INDEX IF NOT EXISTS idx_artist ON scrobbles(artist);", + )?; + // Add the source column to existing databases. SQLite returns an error if + // the column already exists, which we silently ignore for idempotency. + conn.execute( + "ALTER TABLE scrobbles ADD COLUMN source TEXT", + [], + ).ok(); + conn.execute_batch(" -- Cache table for album metadata (cover art, genres) from MusicBrainz. -- Populated by the 'enrich' command or automatically when generating -- HTML reports. Rows with cover_url = NULL are re-tried on next run. @@ -212,8 +230,8 @@ fn init_schema(conn: &Connection) -> Result<()> { /// Insert a new scrobble record and return its auto-generated row ID. pub fn insert_scrobble(conn: &Connection, s: &NewScrobble) -> Result { conn.execute( - "INSERT INTO scrobbles (artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO scrobbles (artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at, source) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ s.artist, s.album, @@ -221,6 +239,7 @@ pub fn insert_scrobble(conn: &Connection, s: &NewScrobble) -> Result { s.track_duration_secs, s.played_duration_secs, s.scrobbled_at, + s.source, ], )?; Ok(conn.last_insert_rowid()) @@ -581,6 +600,40 @@ pub fn top_genres(conn: &Connection, period: &str, limit: i64) -> Result Result> { + let (whr, p) = where_clause(period); + // Extend the WHERE clause to also filter out NULL sources. + let source_filter = if whr.is_empty() { + " WHERE source IS NOT NULL".to_string() + } else { + format!("{} AND source IS NOT NULL", whr) + }; + let sql = format!( + "SELECT source, COUNT(*) as scrobbles, COALESCE(SUM(played_duration_secs), 0) as listen_time + FROM scrobbles{} + GROUP BY source + ORDER BY scrobbles DESC, listen_time DESC, source ASC", + source_filter + ); + + let mut stmt = conn.prepare(&sql)?; + let mapper = |row: &rusqlite::Row| { + Ok(TopSource { + source: row.get(0)?, + scrobbles: row.get(1)?, + listen_time_secs: row.get(2)?, + }) + }; + if p.is_empty() { + stmt.query_map([], mapper)?.collect() + } else { + stmt.query_map(params![p[0], p[1]], mapper)?.collect() + } +} + /// Get the most recent scrobbles for the given period, ordered by /// timestamp descending (newest first), limited to `limit` entries. pub fn recent_scrobbles(conn: &Connection, period: &str, limit: i64) -> Result> { @@ -919,6 +972,7 @@ mod tests { track_duration_secs: Some(186), played_duration_secs: 186, scrobbled_at: "2026-03-19T10:00:00".to_string(), + source: "test".into(), }, NewScrobble { artist: "††† (Crosses)".to_string(), @@ -927,6 +981,7 @@ mod tests { track_duration_secs: Some(215), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:05:00".to_string(), + source: "test".into(), }, NewScrobble { artist: "Deftones".to_string(), @@ -935,6 +990,7 @@ mod tests { track_duration_secs: Some(291), played_duration_secs: 291, scrobbled_at: "2026-03-19T10:10:00".to_string(), + source: "test".into(), }, NewScrobble { artist: "Deftones".to_string(), @@ -943,6 +999,7 @@ mod tests { track_duration_secs: Some(290), played_duration_secs: 250, scrobbled_at: "2026-03-18T14:00:00".to_string(), + source: "test".into(), }, NewScrobble { artist: "††† (Crosses)".to_string(), @@ -951,6 +1008,7 @@ mod tests { track_duration_secs: Some(186), played_duration_secs: 180, scrobbled_at: "2026-03-12T09:00:00".to_string(), + source: "test".into(), }, ]; for s in &scrobbles { @@ -976,6 +1034,7 @@ mod tests { track_duration_secs: Some(186), played_duration_secs: 186, scrobbled_at: "2026-03-19T10:00:00".to_string(), + source: "test".into(), }; // First insert should get row ID 1. let id = insert_scrobble(&conn, &s).unwrap(); @@ -1078,6 +1137,7 @@ mod tests { track_duration_secs: Some(200), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:00:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1090,6 +1150,7 @@ mod tests { track_duration_secs: Some(180), played_duration_secs: 180, scrobbled_at: "2026-03-19T10:01:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1143,6 +1204,7 @@ mod tests { track_duration_secs: Some(120), played_duration_secs: 120, scrobbled_at: "2026-03-19T11:00:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1155,6 +1217,7 @@ mod tests { track_duration_secs: Some(320), played_duration_secs: 320, scrobbled_at: "2026-03-19T11:05:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1181,6 +1244,7 @@ mod tests { track_duration_secs: Some(150), played_duration_secs: 150, scrobbled_at: "2026-03-19T12:00:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1193,6 +1257,7 @@ mod tests { track_duration_secs: Some(240), played_duration_secs: 240, scrobbled_at: "2026-03-19T12:05:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1229,6 +1294,7 @@ mod tests { track_duration_secs: Some(300), played_duration_secs: 300, scrobbled_at: (*ts).to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1281,6 +1347,7 @@ mod tests { track_duration_secs: Some(100), played_duration_secs: 100, scrobbled_at: "2026-03-19T13:00:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1293,6 +1360,7 @@ mod tests { track_duration_secs: Some(360), played_duration_secs: 360, scrobbled_at: "2026-03-19T13:05:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1338,6 +1406,7 @@ mod tests { track_duration_secs: None, played_duration_secs: 240, scrobbled_at: "2026-03-19T12:00:00".to_string(), + source: "test".into(), }; insert_scrobble(&conn, &s).unwrap(); let recent = recent_scrobbles(&conn, "all", 1).unwrap(); @@ -1359,6 +1428,7 @@ mod tests { track_duration_secs: Some(100), played_duration_secs: 100, scrobbled_at: "2026-03-19T10:00:00".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1371,6 +1441,7 @@ mod tests { track_duration_secs: Some(120), played_duration_secs: 120, scrobbled_at: "2026-03-19T12:34:56".to_string(), + source: "test".into(), }, ) .unwrap(); @@ -1404,6 +1475,7 @@ mod tests { track_duration_secs: Some(180), played_duration_secs: 180, scrobbled_at: old_day.clone(), + source: "test".into(), }, ) .unwrap(); @@ -1419,6 +1491,7 @@ mod tests { track_duration_secs: Some(200), played_duration_secs: 200, scrobbled_at: recent, + source: "test".into(), }, ) .unwrap(); diff --git a/src/enrich.rs b/src/enrich.rs index 37aefc0..b9e234e 100644 --- a/src/enrich.rs +++ b/src/enrich.rs @@ -1178,6 +1178,7 @@ mod tests { track_duration_secs: Some(291), played_duration_secs: 291, scrobbled_at: "2026-03-19T10:00:00".into(), + source: "test".into(), }, ) .unwrap(); @@ -1190,6 +1191,7 @@ mod tests { track_duration_secs: Some(215), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:05:00".into(), + source: "test".into(), }, ) .unwrap(); @@ -1294,6 +1296,7 @@ mod tests { track_duration_secs: Some(180), played_duration_secs: 160, scrobbled_at: "2026-03-19T10:00:00".into(), + source: "test".into(), }, ) .unwrap(); diff --git a/src/main.rs b/src/main.rs index 1a5a906..bbc3f47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -405,7 +405,8 @@ fn run_watch(player: &str, use_mpris: bool, mpd_config: Option, // Receives events from both reader threads and the Ctrl+C handler, // and feeds them into the MPRIS ScrobbleTracker state machine. // When MPRIS is disabled, this loop exits immediately on shutdown. - let mut tracker = watcher::create_db_tracker(conn); + let source = watcher::normalise_player_name(player); + let mut tracker = watcher::create_db_tracker(conn, source); let mut eof_count = 0; let mut flushed = false; diff --git a/src/mpd.rs b/src/mpd.rs index c0770aa..a7076c7 100644 --- a/src/mpd.rs +++ b/src/mpd.rs @@ -603,7 +603,7 @@ pub fn run_mpd_watch(config: &MpdConfig, conn: Arc>, running: // Keep a clone of `conn` for inline cover extraction. The tracker closure // owns the original; both share the same underlying Mutex. let conn_for_covers = conn.clone(); - let mut tracker = watcher::create_db_tracker(conn); + let mut tracker = watcher::create_db_tracker(conn, "MPD".to_string()); // Outer loop: reconnect on failure until shutdown is requested. while running.load(Ordering::SeqCst) { diff --git a/src/report.rs b/src/report.rs index 4fd7bff..4f401b0 100644 --- a/src/report.rs +++ b/src/report.rs @@ -210,6 +210,8 @@ pub struct ReportData { pub top_tracks: Vec, /// Top genres derived from cached album metadata. pub top_genres: Vec, + /// Breakdown of scrobbles by source (player/service). + pub top_sources: Vec, /// Most recent scrobbles (newest first), limited to 20. pub recent_scrobbles: Vec, } @@ -529,6 +531,7 @@ pub fn gather_report( top_albums: db::top_albums(conn, period, limit)?, top_tracks: db::top_tracks(conn, period, limit)?, top_genres: db::top_genres(conn, period, limit)?, + top_sources: db::top_sources(conn, period)?, // Recent scrobbles are always limited to 20, regardless of the --limit flag. recent_scrobbles: db::recent_scrobbles(conn, period, 20)?, }) @@ -584,6 +587,28 @@ pub fn print_terminal_report(data: &ReportData) { println!(" Mood: {}", mood.join(" · ")); } + // --- Source Breakdown --- + if !data.top_sources.is_empty() { + println!("\n Sources"); + let rows: Vec> = data + .top_sources + .iter() + .map(|s| { + vec![ + s.source.clone(), + s.scrobbles.to_string(), + format_duration(s.listen_time_secs), + ] + }) + .collect(); + print_box_table( + &["Source", "Scrobbles", "Time"], + &rows, + None, + &[0], + ); + } + // --- Top Artists --- if !data.top_artists.is_empty() { println!("\n Top Artists"); @@ -881,6 +906,30 @@ pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) -> write_kpi(&mut h, "Tracks", &data.overview.unique_tracks.to_string()); h.close(""); + // Source breakdown — simple bar table showing scrobbles per player/service. + // Only rendered when there is more than one source, or when the single + // source differs from the default expectation, so it doesn't clutter + // reports for users with a single player. + if !data.top_sources.is_empty() { + let source_rows: Vec = data + .top_sources + .iter() + .enumerate() + .map(|(i, s)| BarRow { + cells: vec![(i + 1).to_string(), s.source.clone()], + value: s.scrobbles, + suffix: format_duration(s.listen_time_secs), + cover: None, + }) + .collect(); + write_bar_table( + &mut h, + "Sources", + &["#", "Source", "Scrobbles", ""], + &source_rows, + ); + } + // Album cover grid — shown first after KPIs to visually illustrate // the period's listening at a glance. // @@ -1633,6 +1682,7 @@ mod tests { track_duration_secs: Some(186), played_duration_secs: 186, scrobbled_at: "2026-03-19T10:00:00".into(), + source: "test".into(), }, db::NewScrobble { artist: "††† (Crosses)".into(), @@ -1641,6 +1691,7 @@ mod tests { track_duration_secs: Some(215), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:05:00".into(), + source: "test".into(), }, db::NewScrobble { artist: "Deftones".into(), @@ -1649,6 +1700,7 @@ mod tests { track_duration_secs: Some(291), played_duration_secs: 291, scrobbled_at: "2026-03-19T10:10:00".into(), + source: "test".into(), }, ]; for s in &scrobbles { @@ -1687,6 +1739,7 @@ mod tests { track_duration_secs: Some(180), played_duration_secs: 180, scrobbled_at: "2026-03-19T10:00:00".into(), + source: "test".into(), }, ) .unwrap(); @@ -1709,6 +1762,7 @@ mod tests { track_duration_secs: Some(180), played_duration_secs: 170, scrobbled_at: "2026-03-19T10:00:00".into(), + source: "test".into(), }, ) .unwrap(); diff --git a/src/watcher.rs b/src/watcher.rs index d85c706..0860a17 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -131,16 +131,19 @@ pub struct ScrobbleTracker { playing_since: Option, accumulated_secs: f64, scrobble_fn: F, + /// The source label recorded alongside each scrobble (e.g. `"MPD"`). + source: String, } impl ScrobbleTracker { - pub fn new(scrobble_fn: F) -> Self { + pub fn new(scrobble_fn: F, source: String) -> Self { Self { current_track: None, is_playing: false, playing_since: None, accumulated_secs: 0.0, scrobble_fn, + source, } } @@ -265,6 +268,7 @@ impl ScrobbleTracker { track_duration_secs: track_dur, played_duration_secs: self.accumulated_secs.round() as i64, scrobbled_at: now, + source: self.source.clone(), }; (self.scrobble_fn)(scrobble); } @@ -329,6 +333,35 @@ pub fn parse_status_line(line: &str) -> Option { } } +// --------------------------------------------------------------------------- +// Player name normalisation +// --------------------------------------------------------------------------- + +/// Normalise a D-Bus player name (e.g. `"com.blitzfc.qbz"`) into a +/// human-readable source label (e.g. `"Qobuz"`). +/// +/// Logic: +/// 1. Take the last `.`-separated segment of the D-Bus name. +/// 2. Apply known mappings (matched case-insensitively): +/// - `"qbz"` or `"qbz2"` → `"Qobuz"` +/// 3. Otherwise: capitalise the first character of the segment. +pub fn normalise_player_name(player: &str) -> String { + let segment = player.rsplit('.').next().unwrap_or(player); + match segment.to_lowercase().as_str() { + "qbz" | "qbz2" => "Qobuz".to_string(), + _ => { + let mut chars = segment.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + let upper: String = first.to_uppercase().collect(); + upper + chars.as_str() + } + } + } + } +} + // --------------------------------------------------------------------------- // Factory for production use // --------------------------------------------------------------------------- @@ -339,23 +372,28 @@ pub fn parse_status_line(line: &str) -> Option { /// to stderr. The `Arc>` is shared with the main thread /// but only accessed from the main event loop (single-threaded), so contention /// is minimal. +/// +/// The `source` parameter names the player or service that produced these +/// scrobbles (e.g. `"MPD"`, `"Qobuz"`). It is stored alongside each scrobble +/// and shown in source-breakdown reports. pub fn create_db_tracker( conn: std::sync::Arc>, + source: String, ) -> ScrobbleTracker { ScrobbleTracker::new(move |scrobble: NewScrobble| { let conn = conn.lock().unwrap(); match db::insert_scrobble(&conn, &scrobble) { Ok(_) => { eprintln!( - "[scrobbled] {} - {} ({}s)", - scrobble.artist, scrobble.title, scrobble.played_duration_secs + "[scrobbled] {}: {} - {} ({}s)", + scrobble.source, scrobble.artist, scrobble.title, scrobble.played_duration_secs ); } Err(e) => { eprintln!("[error] Failed to insert scrobble: {}", e); } } - }) + }, source) } // =========================================================================== @@ -380,6 +418,8 @@ pub struct TestableTracker { pub scrobbled: Vec, /// The current simulated time, in seconds since the start of the test. clock_secs: f64, + /// Source label attached to every scrobble produced during the test. + source: String, } #[cfg(test)] @@ -392,6 +432,7 @@ impl TestableTracker { accumulated_secs: 0.0, scrobbled: Vec::new(), clock_secs: 0.0, + source: "test".to_string(), } } @@ -426,6 +467,7 @@ impl TestableTracker { track_duration_secs: track_dur, played_duration_secs: self.accumulated_secs.round() as i64, scrobbled_at: format!("test-time-{}", self.clock_secs), + source: self.source.clone(), }; self.scrobbled.push(scrobble); }