Now we also record the scrobble source
This commit is contained in:
81
src/db.rs
81
src/db.rs
@@ -64,6 +64,8 @@ pub struct NewScrobble {
|
|||||||
pub track_duration_secs: Option<i64>,
|
pub track_duration_secs: Option<i64>,
|
||||||
pub played_duration_secs: i64,
|
pub played_duration_secs: i64,
|
||||||
pub scrobbled_at: String,
|
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.
|
/// Aggregate overview statistics for a given time period.
|
||||||
@@ -119,6 +121,15 @@ pub struct TopGenre {
|
|||||||
pub listen_time_secs: i64,
|
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
|
// Database initialization
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -186,8 +197,15 @@ fn init_schema(conn: &Connection) -> Result<()> {
|
|||||||
scrobbled_at TEXT NOT NULL
|
scrobbled_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_scrobbled_at ON scrobbles(scrobbled_at);
|
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.
|
-- Cache table for album metadata (cover art, genres) from MusicBrainz.
|
||||||
-- Populated by the 'enrich' command or automatically when generating
|
-- Populated by the 'enrich' command or automatically when generating
|
||||||
-- HTML reports. Rows with cover_url = NULL are re-tried on next run.
|
-- 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.
|
/// Insert a new scrobble record and return its auto-generated row ID.
|
||||||
pub fn insert_scrobble(conn: &Connection, s: &NewScrobble) -> Result<i64> {
|
pub fn insert_scrobble(conn: &Connection, s: &NewScrobble) -> Result<i64> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO scrobbles (artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at)
|
"INSERT INTO scrobbles (artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at, source)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![
|
params![
|
||||||
s.artist,
|
s.artist,
|
||||||
s.album,
|
s.album,
|
||||||
@@ -221,6 +239,7 @@ pub fn insert_scrobble(conn: &Connection, s: &NewScrobble) -> Result<i64> {
|
|||||||
s.track_duration_secs,
|
s.track_duration_secs,
|
||||||
s.played_duration_secs,
|
s.played_duration_secs,
|
||||||
s.scrobbled_at,
|
s.scrobbled_at,
|
||||||
|
s.source,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
@@ -581,6 +600,40 @@ pub fn top_genres(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Top
|
|||||||
Ok(out)
|
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
|
/// Get the most recent scrobbles for the given period, ordered by
|
||||||
/// timestamp descending (newest first), limited to `limit` entries.
|
/// timestamp descending (newest first), limited to `limit` entries.
|
||||||
pub fn recent_scrobbles(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Scrobble>> {
|
pub fn recent_scrobbles(conn: &Connection, period: &str, limit: i64) -> Result<Vec<Scrobble>> {
|
||||||
@@ -919,6 +972,7 @@ mod tests {
|
|||||||
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".to_string(),
|
scrobbled_at: "2026-03-19T10:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
NewScrobble {
|
NewScrobble {
|
||||||
artist: "††† (Crosses)".to_string(),
|
artist: "††† (Crosses)".to_string(),
|
||||||
@@ -927,6 +981,7 @@ mod tests {
|
|||||||
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".to_string(),
|
scrobbled_at: "2026-03-19T10:05:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
NewScrobble {
|
NewScrobble {
|
||||||
artist: "Deftones".to_string(),
|
artist: "Deftones".to_string(),
|
||||||
@@ -935,6 +990,7 @@ mod tests {
|
|||||||
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".to_string(),
|
scrobbled_at: "2026-03-19T10:10:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
NewScrobble {
|
NewScrobble {
|
||||||
artist: "Deftones".to_string(),
|
artist: "Deftones".to_string(),
|
||||||
@@ -943,6 +999,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(290),
|
track_duration_secs: Some(290),
|
||||||
played_duration_secs: 250,
|
played_duration_secs: 250,
|
||||||
scrobbled_at: "2026-03-18T14:00:00".to_string(),
|
scrobbled_at: "2026-03-18T14:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
NewScrobble {
|
NewScrobble {
|
||||||
artist: "††† (Crosses)".to_string(),
|
artist: "††† (Crosses)".to_string(),
|
||||||
@@ -951,6 +1008,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(186),
|
track_duration_secs: Some(186),
|
||||||
played_duration_secs: 180,
|
played_duration_secs: 180,
|
||||||
scrobbled_at: "2026-03-12T09:00:00".to_string(),
|
scrobbled_at: "2026-03-12T09:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
for s in &scrobbles {
|
for s in &scrobbles {
|
||||||
@@ -976,6 +1034,7 @@ mod tests {
|
|||||||
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".to_string(),
|
scrobbled_at: "2026-03-19T10:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
};
|
};
|
||||||
// First insert should get row ID 1.
|
// First insert should get row ID 1.
|
||||||
let id = insert_scrobble(&conn, &s).unwrap();
|
let id = insert_scrobble(&conn, &s).unwrap();
|
||||||
@@ -1078,6 +1137,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(200),
|
track_duration_secs: Some(200),
|
||||||
played_duration_secs: 200,
|
played_duration_secs: 200,
|
||||||
scrobbled_at: "2026-03-19T10:00:00".to_string(),
|
scrobbled_at: "2026-03-19T10:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1090,6 +1150,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(180),
|
track_duration_secs: Some(180),
|
||||||
played_duration_secs: 180,
|
played_duration_secs: 180,
|
||||||
scrobbled_at: "2026-03-19T10:01:00".to_string(),
|
scrobbled_at: "2026-03-19T10:01:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1143,6 +1204,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(120),
|
track_duration_secs: Some(120),
|
||||||
played_duration_secs: 120,
|
played_duration_secs: 120,
|
||||||
scrobbled_at: "2026-03-19T11:00:00".to_string(),
|
scrobbled_at: "2026-03-19T11:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1155,6 +1217,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(320),
|
track_duration_secs: Some(320),
|
||||||
played_duration_secs: 320,
|
played_duration_secs: 320,
|
||||||
scrobbled_at: "2026-03-19T11:05:00".to_string(),
|
scrobbled_at: "2026-03-19T11:05:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1181,6 +1244,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(150),
|
track_duration_secs: Some(150),
|
||||||
played_duration_secs: 150,
|
played_duration_secs: 150,
|
||||||
scrobbled_at: "2026-03-19T12:00:00".to_string(),
|
scrobbled_at: "2026-03-19T12:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1193,6 +1257,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(240),
|
track_duration_secs: Some(240),
|
||||||
played_duration_secs: 240,
|
played_duration_secs: 240,
|
||||||
scrobbled_at: "2026-03-19T12:05:00".to_string(),
|
scrobbled_at: "2026-03-19T12:05:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1229,6 +1294,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(300),
|
track_duration_secs: Some(300),
|
||||||
played_duration_secs: 300,
|
played_duration_secs: 300,
|
||||||
scrobbled_at: (*ts).to_string(),
|
scrobbled_at: (*ts).to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1281,6 +1347,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(100),
|
track_duration_secs: Some(100),
|
||||||
played_duration_secs: 100,
|
played_duration_secs: 100,
|
||||||
scrobbled_at: "2026-03-19T13:00:00".to_string(),
|
scrobbled_at: "2026-03-19T13:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1293,6 +1360,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(360),
|
track_duration_secs: Some(360),
|
||||||
played_duration_secs: 360,
|
played_duration_secs: 360,
|
||||||
scrobbled_at: "2026-03-19T13:05:00".to_string(),
|
scrobbled_at: "2026-03-19T13:05:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1338,6 +1406,7 @@ mod tests {
|
|||||||
track_duration_secs: None,
|
track_duration_secs: None,
|
||||||
played_duration_secs: 240,
|
played_duration_secs: 240,
|
||||||
scrobbled_at: "2026-03-19T12:00:00".to_string(),
|
scrobbled_at: "2026-03-19T12:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
};
|
};
|
||||||
insert_scrobble(&conn, &s).unwrap();
|
insert_scrobble(&conn, &s).unwrap();
|
||||||
let recent = recent_scrobbles(&conn, "all", 1).unwrap();
|
let recent = recent_scrobbles(&conn, "all", 1).unwrap();
|
||||||
@@ -1359,6 +1428,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(100),
|
track_duration_secs: Some(100),
|
||||||
played_duration_secs: 100,
|
played_duration_secs: 100,
|
||||||
scrobbled_at: "2026-03-19T10:00:00".to_string(),
|
scrobbled_at: "2026-03-19T10:00:00".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1371,6 +1441,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(120),
|
track_duration_secs: Some(120),
|
||||||
played_duration_secs: 120,
|
played_duration_secs: 120,
|
||||||
scrobbled_at: "2026-03-19T12:34:56".to_string(),
|
scrobbled_at: "2026-03-19T12:34:56".to_string(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1404,6 +1475,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(180),
|
track_duration_secs: Some(180),
|
||||||
played_duration_secs: 180,
|
played_duration_secs: 180,
|
||||||
scrobbled_at: old_day.clone(),
|
scrobbled_at: old_day.clone(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1419,6 +1491,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(200),
|
track_duration_secs: Some(200),
|
||||||
played_duration_secs: 200,
|
played_duration_secs: 200,
|
||||||
scrobbled_at: recent,
|
scrobbled_at: recent,
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1178,6 +1178,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(291),
|
track_duration_secs: Some(291),
|
||||||
played_duration_secs: 291,
|
played_duration_secs: 291,
|
||||||
scrobbled_at: "2026-03-19T10:00:00".into(),
|
scrobbled_at: "2026-03-19T10:00:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1190,6 +1191,7 @@ mod tests {
|
|||||||
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: "2026-03-19T10:05:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1294,6 +1296,7 @@ mod tests {
|
|||||||
track_duration_secs: Some(180),
|
track_duration_secs: Some(180),
|
||||||
played_duration_secs: 160,
|
played_duration_secs: 160,
|
||||||
scrobbled_at: "2026-03-19T10:00:00".into(),
|
scrobbled_at: "2026-03-19T10:00:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -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,
|
// Receives events from both reader threads and the Ctrl+C handler,
|
||||||
// and feeds them into the MPRIS ScrobbleTracker state machine.
|
// and feeds them into the MPRIS ScrobbleTracker state machine.
|
||||||
// When MPRIS is disabled, this loop exits immediately on shutdown.
|
// 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 eof_count = 0;
|
||||||
let mut flushed = false;
|
let mut flushed = false;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Keep a clone of `conn` for inline cover extraction. The tracker closure
|
||||||
// owns the original; both share the same underlying Mutex<Connection>.
|
// owns the original; both share the same underlying Mutex<Connection>.
|
||||||
let conn_for_covers = conn.clone();
|
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.
|
// Outer loop: reconnect on failure until shutdown is requested.
|
||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ pub struct ReportData {
|
|||||||
pub top_tracks: Vec<db::TopTrack>,
|
pub top_tracks: Vec<db::TopTrack>,
|
||||||
/// Top genres derived from cached album metadata.
|
/// Top genres derived from cached album metadata.
|
||||||
pub top_genres: Vec<db::TopGenre>,
|
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.
|
/// Most recent scrobbles (newest first), limited to 20.
|
||||||
pub recent_scrobbles: Vec<db::Scrobble>,
|
pub recent_scrobbles: Vec<db::Scrobble>,
|
||||||
}
|
}
|
||||||
@@ -529,6 +531,7 @@ pub fn gather_report(
|
|||||||
top_albums: db::top_albums(conn, period, limit)?,
|
top_albums: db::top_albums(conn, period, limit)?,
|
||||||
top_tracks: db::top_tracks(conn, period, limit)?,
|
top_tracks: db::top_tracks(conn, period, limit)?,
|
||||||
top_genres: db::top_genres(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 are always limited to 20, regardless of the --limit flag.
|
||||||
recent_scrobbles: db::recent_scrobbles(conn, period, 20)?,
|
recent_scrobbles: db::recent_scrobbles(conn, period, 20)?,
|
||||||
})
|
})
|
||||||
@@ -584,6 +587,28 @@ pub fn print_terminal_report(data: &ReportData) {
|
|||||||
println!(" Mood: {}", mood.join(" · "));
|
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 ---
|
// --- Top Artists ---
|
||||||
if !data.top_artists.is_empty() {
|
if !data.top_artists.is_empty() {
|
||||||
println!("\n Top Artists");
|
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());
|
write_kpi(&mut h, "Tracks", &data.overview.unique_tracks.to_string());
|
||||||
h.close("</div>");
|
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
|
// Album cover grid — shown first after KPIs to visually illustrate
|
||||||
// the period's listening at a glance.
|
// the period's listening at a glance.
|
||||||
//
|
//
|
||||||
@@ -1633,6 +1682,7 @@ mod tests {
|
|||||||
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: "2026-03-19T10:00:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
db::NewScrobble {
|
db::NewScrobble {
|
||||||
artist: "††† (Crosses)".into(),
|
artist: "††† (Crosses)".into(),
|
||||||
@@ -1641,6 +1691,7 @@ mod tests {
|
|||||||
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: "2026-03-19T10:05:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
db::NewScrobble {
|
db::NewScrobble {
|
||||||
artist: "Deftones".into(),
|
artist: "Deftones".into(),
|
||||||
@@ -1649,6 +1700,7 @@ mod tests {
|
|||||||
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: "2026-03-19T10:10:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
for s in &scrobbles {
|
for s in &scrobbles {
|
||||||
@@ -1687,6 +1739,7 @@ mod tests {
|
|||||||
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: "2026-03-19T10:00:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1709,6 +1762,7 @@ mod tests {
|
|||||||
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: "2026-03-19T10:00:00".into(),
|
||||||
|
source: "test".into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -131,16 +131,19 @@ pub struct ScrobbleTracker<F: FnMut(NewScrobble)> {
|
|||||||
playing_since: Option<Instant>,
|
playing_since: Option<Instant>,
|
||||||
accumulated_secs: f64,
|
accumulated_secs: f64,
|
||||||
scrobble_fn: F,
|
scrobble_fn: F,
|
||||||
|
/// The source label recorded alongside each scrobble (e.g. `"MPD"`).
|
||||||
|
source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<F: FnMut(NewScrobble)> ScrobbleTracker<F> {
|
impl<F: FnMut(NewScrobble)> ScrobbleTracker<F> {
|
||||||
pub fn new(scrobble_fn: F) -> Self {
|
pub fn new(scrobble_fn: F, source: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
current_track: None,
|
current_track: None,
|
||||||
is_playing: false,
|
is_playing: false,
|
||||||
playing_since: None,
|
playing_since: None,
|
||||||
accumulated_secs: 0.0,
|
accumulated_secs: 0.0,
|
||||||
scrobble_fn,
|
scrobble_fn,
|
||||||
|
source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +268,7 @@ impl<F: FnMut(NewScrobble)> ScrobbleTracker<F> {
|
|||||||
track_duration_secs: track_dur,
|
track_duration_secs: track_dur,
|
||||||
played_duration_secs: self.accumulated_secs.round() as i64,
|
played_duration_secs: self.accumulated_secs.round() as i64,
|
||||||
scrobbled_at: now,
|
scrobbled_at: now,
|
||||||
|
source: self.source.clone(),
|
||||||
};
|
};
|
||||||
(self.scrobble_fn)(scrobble);
|
(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
|
// 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
|
/// to stderr. The `Arc<Mutex<Connection>>` is shared with the main thread
|
||||||
/// but only accessed from the main event loop (single-threaded), so contention
|
/// but only accessed from the main event loop (single-threaded), so contention
|
||||||
/// is minimal.
|
/// 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(
|
pub fn create_db_tracker(
|
||||||
conn: std::sync::Arc<std::sync::Mutex<Connection>>,
|
conn: std::sync::Arc<std::sync::Mutex<Connection>>,
|
||||||
|
source: String,
|
||||||
) -> ScrobbleTracker<impl FnMut(NewScrobble)> {
|
) -> ScrobbleTracker<impl FnMut(NewScrobble)> {
|
||||||
ScrobbleTracker::new(move |scrobble: NewScrobble| {
|
ScrobbleTracker::new(move |scrobble: NewScrobble| {
|
||||||
let conn = conn.lock().unwrap();
|
let conn = conn.lock().unwrap();
|
||||||
match db::insert_scrobble(&conn, &scrobble) {
|
match db::insert_scrobble(&conn, &scrobble) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[scrobbled] {} - {} ({}s)",
|
"[scrobbled] {}: {} - {} ({}s)",
|
||||||
scrobble.artist, scrobble.title, scrobble.played_duration_secs
|
scrobble.source, scrobble.artist, scrobble.title, scrobble.played_duration_secs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[error] Failed to insert scrobble: {}", e);
|
eprintln!("[error] Failed to insert scrobble: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -380,6 +418,8 @@ pub struct TestableTracker {
|
|||||||
pub scrobbled: Vec<NewScrobble>,
|
pub scrobbled: Vec<NewScrobble>,
|
||||||
/// The current simulated time, in seconds since the start of the test.
|
/// The current simulated time, in seconds since the start of the test.
|
||||||
clock_secs: f64,
|
clock_secs: f64,
|
||||||
|
/// Source label attached to every scrobble produced during the test.
|
||||||
|
source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -392,6 +432,7 @@ impl TestableTracker {
|
|||||||
accumulated_secs: 0.0,
|
accumulated_secs: 0.0,
|
||||||
scrobbled: Vec::new(),
|
scrobbled: Vec::new(),
|
||||||
clock_secs: 0.0,
|
clock_secs: 0.0,
|
||||||
|
source: "test".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +467,7 @@ impl TestableTracker {
|
|||||||
track_duration_secs: track_dur,
|
track_duration_secs: track_dur,
|
||||||
played_duration_secs: self.accumulated_secs.round() as i64,
|
played_duration_secs: self.accumulated_secs.round() as i64,
|
||||||
scrobbled_at: format!("test-time-{}", self.clock_secs),
|
scrobbled_at: format!("test-time-{}", self.clock_secs),
|
||||||
|
source: self.source.clone(),
|
||||||
};
|
};
|
||||||
self.scrobbled.push(scrobble);
|
self.scrobbled.push(scrobble);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user