Now we also record the scrobble source

This commit is contained in:
2026-03-22 09:39:16 +00:00
parent 10483822ee
commit b9dbd17e24
6 changed files with 183 additions and 10 deletions

View File

@@ -64,6 +64,8 @@ pub struct NewScrobble {
pub track_duration_secs: Option<i64>,
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<i64> {
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<i64> {
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<Vec<Top
Ok(out)
}
/// Get the top sources (players) by scrobble count for the given period,
/// ordered by scrobble count descending. Rows with a NULL source are excluded
/// — these are historical records written before the `source` column existed.
pub fn top_sources(conn: &Connection, period: &str) -> Result<Vec<TopSource>> {
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<Vec<Scrobble>> {
@@ -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();

View File

@@ -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();

View File

@@ -405,7 +405,8 @@ fn run_watch(player: &str, use_mpris: bool, mpd_config: Option<mpd::MpdConfig>,
// 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;

View File

@@ -603,7 +603,7 @@ pub fn run_mpd_watch(config: &MpdConfig, conn: Arc<Mutex<Connection>>, running:
// Keep a clone of `conn` for inline cover extraction. The tracker closure
// owns the original; both share the same underlying Mutex<Connection>.
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) {

View File

@@ -210,6 +210,8 @@ pub struct ReportData {
pub top_tracks: Vec<db::TopTrack>,
/// Top genres derived from cached album metadata.
pub top_genres: Vec<db::TopGenre>,
/// Breakdown of scrobbles by source (player/service).
pub top_sources: Vec<db::TopSource>,
/// Most recent scrobbles (newest first), limited to 20.
pub recent_scrobbles: Vec<db::Scrobble>,
}
@@ -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<Vec<String>> = 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("</div>");
// 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<BarRow> = 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();

View File

@@ -131,16 +131,19 @@ pub struct ScrobbleTracker<F: FnMut(NewScrobble)> {
playing_since: Option<Instant>,
accumulated_secs: f64,
scrobble_fn: F,
/// The source label recorded alongside each scrobble (e.g. `"MPD"`).
source: String,
}
impl<F: FnMut(NewScrobble)> ScrobbleTracker<F> {
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<F: FnMut(NewScrobble)> ScrobbleTracker<F> {
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<Event> {
}
}
// ---------------------------------------------------------------------------
// 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<Event> {
/// to stderr. The `Arc<Mutex<Connection>>` 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<std::sync::Mutex<Connection>>,
source: String,
) -> ScrobbleTracker<impl FnMut(NewScrobble)> {
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<NewScrobble>,
/// 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);
}