//! Database module for the MPRIS scrobbler. //! //! This module handles all SQLite interactions: schema creation, inserting //! scrobble records, and querying data for reports. The database is stored //! as a single SQLite file (by default at `~/.local/share/mpris-scrobbler/scrobbles.db`). //! //! ## Tables //! //! - **`scrobbles`** — one row per scrobbled track. Stores artist, album, title, //! the track's full duration, the actual time spent listening (paused time //! excluded), and an ISO 8601 timestamp. This is the core data that the //! `watch` command writes and the `report` command reads. //! //! - **`album_cache`** — one row per unique (artist, album) pair. Populated by //! the `enrich` command (or automatically when generating an HTML report). //! Stores the MusicBrainz release ID, a local file path to the downloaded //! cover art image, and genre/tag strings from MusicBrainz. Albums with //! `cover_url = NULL` or `genre = NULL` are considered incomplete and can be //! re-tried on enrichment runs after a cooldown period. //! //! ## Query patterns //! //! All report queries accept a period string (`"today"`, `"week"`, `"month"`, //! `"year"`, `"all"`). The `period_range()` function converts this to an //! ISO 8601 date range, and `where_clause()` generates the corresponding SQL //! `WHERE` fragment. This keeps period logic in one place rather than //! duplicating it across every query function. use chrono::{Datelike, NaiveDateTime}; use rusqlite::{Connection, Result, params}; use serde::Serialize; use std::collections::HashMap; // --------------------------------------------------------------------------- // Data structures // --------------------------------------------------------------------------- /// A scrobble record as stored in the database. /// This is the "full" version with the auto-generated `id`, used when reading /// data back from the DB (e.g., for reports or JSON export). #[derive(Debug, Clone, Serialize)] pub struct Scrobble { pub id: i64, pub artist: String, pub album: String, pub title: String, /// The track's full duration in seconds, as reported by MPRIS. /// May be `None` if the player didn't provide duration info. pub track_duration_secs: Option, /// How long the user actually listened (in seconds). Only time spent /// in the "Playing" state counts — paused time is excluded. pub played_duration_secs: i64, /// ISO 8601 timestamp of when the scrobble was recorded. pub scrobbled_at: String, } /// Data required to insert a new scrobble. Same fields as `Scrobble` but /// without the `id`, which is auto-assigned by SQLite. #[derive(Debug, Clone)] pub struct NewScrobble { pub artist: String, pub album: String, pub title: String, pub track_duration_secs: Option, pub played_duration_secs: i64, pub scrobbled_at: String, } /// Aggregate overview statistics for a given time period. #[derive(Debug, Clone, Serialize)] pub struct Overview { pub total_scrobbles: i64, /// Sum of `played_duration_secs` across all scrobbles in the period. pub total_listen_time_secs: i64, pub unique_artists: i64, pub unique_albums: i64, /// Unique tracks, identified by the (artist, title) pair. pub unique_tracks: i64, } /// A row in the "top artists" ranking. #[derive(Debug, Clone, Serialize)] pub struct TopArtist { pub artist: String, /// Number of scrobbles for this artist. pub plays: i64, /// Total seconds spent listening to this artist. pub listen_time_secs: i64, } /// A row in the "top albums" ranking, grouped by (artist, album). #[derive(Debug, Clone, Serialize)] pub struct TopAlbum { pub artist: String, pub album: String, pub plays: i64, pub listen_time_secs: i64, } /// A row in the "top tracks" ranking, grouped by (artist, title). /// Includes the album name so we can look up the cover image. #[derive(Debug, Clone, Serialize)] pub struct TopTrack { pub artist: String, pub album: String, pub title: String, pub plays: i64, pub listen_time_secs: i64, } /// A row in the "top genres" ranking. /// /// Note: a single scrobble may contribute to multiple genres if its album has /// multiple comma-separated genres in `album_cache.genre`. #[derive(Debug, Clone, Serialize)] pub struct TopGenre { pub genre: String, pub plays: i64, pub listen_time_secs: i64, } // --------------------------------------------------------------------------- // Database initialization // --------------------------------------------------------------------------- /// Open (or create) the database at the given file path and ensure the /// schema exists. This is safe to call on an already-initialized DB because /// all CREATE statements use `IF NOT EXISTS`. pub fn open_db(path: &str) -> Result { let conn = Connection::open(path)?; init_schema(&conn)?; Ok(conn) } /// Open an in-memory SQLite database with the schema applied. /// Used exclusively by unit tests to avoid touching the filesystem. #[cfg(test)] pub fn open_memory_db() -> Result { let conn = Connection::open_in_memory()?; init_schema(&conn)?; Ok(conn) } /// Create all tables and indexes if they don't already exist. /// /// ## Tables /// /// ### `scrobbles` — one row per scrobbled track /// - `id` — auto-incrementing primary key /// - `artist` — artist name (required) /// - `album` — album name (defaults to empty string) /// - `title` — track title (required) /// - `track_duration_secs` — full track length in seconds (nullable) /// - `played_duration_secs` — actual listening time in seconds (required) /// - `scrobbled_at` — ISO 8601 timestamp string (required) /// /// ### `album_cache` — cached metadata for album art and genres /// /// Populated by the `enrich` command (or automatically when `report --html` /// is used). Stores MusicBrainz IDs, local cover art file paths, and genre /// info to avoid repeated API lookups. Keyed by the (artist, album) pair. /// Albums with `cover_url = NULL` are considered incomplete and will be /// re-tried on the next enrichment run. /// /// - `id` — auto-incrementing primary key /// - `artist` — artist name (matches scrobbles.artist) /// - `album` — album name (matches scrobbles.album) /// - `musicbrainz_id` — MusicBrainz release MBID (UUID string, nullable) /// - `cover_url` — Cover Art Archive URL or local file path (nullable) /// - `genre` — comma-separated genre tags from MusicBrainz (nullable) /// - `fetched_at` — ISO 8601 timestamp of when the lookup was performed /// - UNIQUE(artist, album) — prevents duplicate cache entries /// /// ## Indexes /// - `idx_scrobbled_at` — for efficient time-range queries in reports /// - `idx_artist` — for efficient GROUP BY artist queries fn init_schema(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS scrobbles ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist TEXT NOT NULL, album TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, track_duration_secs INTEGER, played_duration_secs INTEGER 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_artist ON scrobbles(artist); -- 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. CREATE TABLE IF NOT EXISTS album_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist TEXT NOT NULL, album TEXT NOT NULL, musicbrainz_id TEXT, cover_url TEXT, genre TEXT, fetched_at TEXT, UNIQUE(artist, album) );", )?; Ok(()) } // --------------------------------------------------------------------------- // Insert // --------------------------------------------------------------------------- /// 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)", params![ s.artist, s.album, s.title, s.track_duration_secs, s.played_duration_secs, s.scrobbled_at, ], )?; Ok(conn.last_insert_rowid()) } /// Return the newest scrobble timestamp (`scrobbled_at`) in the database. /// /// Returns `Ok(None)` when there are no scrobbles yet. pub fn latest_scrobble_at(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT scrobbled_at FROM scrobbles ORDER BY scrobbled_at DESC LIMIT 1")?; let mut rows = stmt.query([])?; if let Some(row) = rows.next()? { let ts: String = row.get(0)?; Ok(Some(ts)) } else { Ok(None) } } // --------------------------------------------------------------------------- // Period helpers // --------------------------------------------------------------------------- /// Compute the ISO 8601 date range `(from, to)` for a named period. /// /// Supported periods: /// - `"today"` — from midnight today to now /// - `"week"` — from Monday 00:00 of the current week to now /// - `"month"` — from the 1st of the current month to now /// - `"year"` — from January 1st of the current year to now /// - `"all"` — returns `None` (no filtering) /// /// The `from` and `to` strings are formatted as `YYYY-MM-DDTHH:MM:SS` so /// they can be compared directly against the `scrobbled_at` column using /// SQLite's string comparison (ISO 8601 sorts lexicographically). pub fn period_range(period: &str) -> Option<(String, String)> { let now = chrono::Local::now().naive_local(); let today_start = now.date().and_hms_opt(0, 0, 0).unwrap(); let from = match period { "today" => today_start, "week" => { // Calculate how many days back from today to reach Monday (0 = Mon, 6 = Sun). let weekday = now.date().weekday().num_days_from_monday(); today_start - chrono::Duration::days(weekday as i64) } "month" => { // First day of the current month at midnight. let d = now.date(); NaiveDateTime::new( chrono::NaiveDate::from_ymd_opt(d.year(), d.month(), 1).unwrap(), chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(), ) } "year" => { // January 1st of the current year at midnight. let d = now.date(); NaiveDateTime::new( chrono::NaiveDate::from_ymd_opt(d.year(), 1, 1).unwrap(), chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(), ) } // "all": no date filtering. "all" => return None, // Any unrecognized period: no date filtering (validated earlier in CLI). _ => return None, }; let from_str = from.format("%Y-%m-%dT%H:%M:%S").to_string(); let to_str = now.format("%Y-%m-%dT%H:%M:%S").to_string(); Some((from_str, to_str)) } /// Build a SQL WHERE clause fragment for time-period filtering. /// /// Returns a tuple of: /// - The clause string (empty if no filtering, or " WHERE scrobbled_at BETWEEN ?1 AND ?2") /// - A vector of bind parameter values (empty if no filtering, or [from, to]) /// /// This is used by all query functions below to optionally restrict results /// to a specific time period. fn where_clause(period: &str) -> (String, Vec) { match period_range(period) { Some((from, to)) => ( " WHERE scrobbled_at BETWEEN ?1 AND ?2".to_string(), vec![from, to], ), None => (String::new(), vec![]), } } // --------------------------------------------------------------------------- // Query functions // // Each function takes a period string and constructs a SQL query with an // optional WHERE clause for time filtering. The `mapper` closure is defined // once and passed to both branches of the if/else (with/without params) to // avoid Rust's "each closure has a unique type" issue. // --------------------------------------------------------------------------- /// Get aggregate overview statistics for the given period. /// /// Unique tracks are counted by concatenating artist + null byte + title, /// so that "Artist A - Song X" and "Artist B - Song X" are counted separately. pub fn overview(conn: &Connection, period: &str) -> Result { let (whr, p) = where_clause(period); let sql = format!( "SELECT COUNT(*), COALESCE(SUM(played_duration_secs), 0), COUNT(DISTINCT artist), COUNT(DISTINCT album), COUNT(DISTINCT artist || '\\0' || title) FROM scrobbles{}", whr ); let mut stmt = conn.prepare(&sql)?; let row = if p.is_empty() { stmt.query_row([], |row| { Ok(Overview { total_scrobbles: row.get(0)?, total_listen_time_secs: row.get(1)?, unique_artists: row.get(2)?, unique_albums: row.get(3)?, unique_tracks: row.get(4)?, }) }) } else { stmt.query_row(params![p[0], p[1]], |row| { Ok(Overview { total_scrobbles: row.get(0)?, total_listen_time_secs: row.get(1)?, unique_artists: row.get(2)?, unique_albums: row.get(3)?, unique_tracks: row.get(4)?, }) }) }?; Ok(row) } /// Get the top N artists by play count for the given period, /// ordered by number of scrobbles descending. /// /// Ties are broken by total listened time (descending), then artist name for /// stable output ordering. pub fn top_artists(conn: &Connection, period: &str, limit: i64) -> Result> { let (whr, p) = where_clause(period); let sql = format!( "SELECT artist, COUNT(*) as plays, COALESCE(SUM(played_duration_secs), 0) as listen_time FROM scrobbles{} GROUP BY artist ORDER BY plays DESC, listen_time DESC, artist ASC LIMIT {}", whr, limit ); let mut stmt = conn.prepare(&sql)?; let mapper = |row: &rusqlite::Row| { Ok(TopArtist { artist: row.get(0)?, plays: 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 top N albums by play count for the given period, /// grouped by MusicBrainz release ID when known (falling back to the /// (artist, album) pair) and ordered by scrobble count descending. /// /// Using the MBID as the group key means tracks from the same physical /// album that were scrobbled under different artist tags (common in /// classical recordings where individual tracks credit a soloist or /// ensemble while others credit the full orchestra or choir) are /// correctly counted as a single album. The artist returned is the one /// that has an album_cache entry (i.e. the one that was enriched), so /// cover art lookups in the report continue to work. /// /// Ties are broken by total listened time (descending), then artist + album /// names for stable output ordering. pub fn top_albums(conn: &Connection, period: &str, limit: i64) -> Result> { let (whr, p) = where_clause(period); // Group by (album, MBID) rather than (artist, album) so that scrobbles // tagged with different artist names for the same physical release are // counted together. // // For each row the group key resolves as: // 1. The MBID from the direct (artist, album) album_cache entry, or // 2. The MBID from any album_cache entry sharing the same album name // (catches variants tagged with a performer vs. composer), or // 3. The composite "artist::album" string (no MBID found — treated as // a unique album, same as before). // // The displayed artist is similarly resolved: the enriched (cached) // artist takes priority so that album_cache_meta() can find the cover. let sql = format!( "SELECT CASE WHEN c.artist IS NOT NULL THEN s.artist ELSE COALESCE( (SELECT ac.artist FROM album_cache ac WHERE ac.album = s.album AND ac.musicbrainz_id IS NOT NULL ORDER BY ac.artist LIMIT 1), s.artist ) END AS artist, s.album AS album, COUNT(*) AS plays, COALESCE(SUM(s.played_duration_secs), 0) AS listen_time FROM scrobbles s LEFT JOIN album_cache c ON c.artist = s.artist AND c.album = s.album{} GROUP BY s.album, COALESCE( c.musicbrainz_id, (SELECT ac.musicbrainz_id FROM album_cache ac WHERE ac.album = s.album AND ac.musicbrainz_id IS NOT NULL ORDER BY ac.musicbrainz_id LIMIT 1), s.artist || '::' || s.album ) ORDER BY plays DESC, listen_time DESC, artist ASC, album ASC LIMIT {}", whr, limit ); let mut stmt = conn.prepare(&sql)?; let mapper = |row: &rusqlite::Row| { Ok(TopAlbum { artist: row.get(0)?, album: row.get(1)?, plays: row.get(2)?, listen_time_secs: row.get(3)?, }) }; if p.is_empty() { stmt.query_map([], mapper)?.collect() } else { stmt.query_map(params![p[0], p[1]], mapper)?.collect() } } /// Get the top N tracks by play count for the given period, /// grouped by (artist, title) and ordered by scrobble count descending. /// The album field is the most frequently scrobbled album for that track /// (used for cover art lookup). /// /// Ties are broken by total listened time (descending), then artist + title /// names for stable output ordering. pub fn top_tracks(conn: &Connection, period: &str, limit: i64) -> Result> { let (whr, p) = where_clause(period); // Use a subquery to pick the most common album for each (artist, title). // In SQLite, the non-aggregated `album` column in a GROUP BY returns an // arbitrary row's value, but wrapping it in a subquery with its own // GROUP BY and ORDER BY ensures we get the most frequent album. let sql = format!( "SELECT artist, album, title, COUNT(*) as plays, COALESCE(SUM(played_duration_secs), 0) as listen_time FROM scrobbles{} GROUP BY artist, title ORDER BY plays DESC, listen_time DESC, artist ASC, title ASC LIMIT {}", whr, limit ); let mut stmt = conn.prepare(&sql)?; let mapper = |row: &rusqlite::Row| { Ok(TopTrack { artist: row.get(0)?, album: row.get(1)?, title: row.get(2)?, plays: row.get(3)?, listen_time_secs: row.get(4)?, }) }; if p.is_empty() { stmt.query_map([], mapper)?.collect() } else { stmt.query_map(params![p[0], p[1]], mapper)?.collect() } } /// Get the top N genres for the given period. /// /// Genre values come from `album_cache.genre` (comma-separated) and are /// associated with scrobbles via `(artist, album)` joins. /// /// Ties are broken by total listened time (descending), then genre name. pub fn top_genres(conn: &Connection, period: &str, limit: i64) -> Result> { let rows: Vec<(String, i64)> = if let Some((from, to)) = period_range(period) { let mut stmt = conn.prepare( "SELECT c.genre, s.played_duration_secs FROM scrobbles s JOIN album_cache c ON s.artist = c.artist AND s.album = c.album WHERE c.genre IS NOT NULL AND c.genre != '' AND s.scrobbled_at BETWEEN ?1 AND ?2", )?; let mapped = stmt.query_map(params![from, to], |row| { let genre: String = row.get(0)?; let played_duration_secs: i64 = row.get(1)?; Ok((genre, played_duration_secs)) })?; mapped.collect::>>()? } else { let mut stmt = conn.prepare( "SELECT c.genre, s.played_duration_secs FROM scrobbles s JOIN album_cache c ON s.artist = c.artist AND s.album = c.album WHERE c.genre IS NOT NULL AND c.genre != ''", )?; let mapped = stmt.query_map([], |row| { let genre: String = row.get(0)?; let played_duration_secs: i64 = row.get(1)?; Ok((genre, played_duration_secs)) })?; mapped.collect::>>()? }; // key -> (display_label, plays, listen_secs) // The key normalises hyphen/space variants so labels like "post-rock" and // "post rock" are grouped together. let mut agg: HashMap = HashMap::new(); for (genre_csv, played_secs) in rows { for genre in split_genre_list(&genre_csv) { let key = canonical_genre_key(&genre); if key.is_empty() { continue; } let entry = agg.entry(key).or_insert_with(|| (genre.clone(), 0, 0)); if prefer_display_genre(&entry.0, &genre) { entry.0 = genre; } entry.1 += 1; entry.2 += played_secs; } } let mut out: Vec = agg .into_iter() .map(|(_, (genre, plays, listen_time_secs))| TopGenre { genre: genre.trim().to_string(), plays, listen_time_secs, }) .collect(); out.sort_by(|a, b| { b.plays .cmp(&a.plays) .then_with(|| b.listen_time_secs.cmp(&a.listen_time_secs)) .then_with(|| a.genre.cmp(&b.genre)) }); out.truncate(limit.max(0) as usize); Ok(out) } /// 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> { let (whr, p) = where_clause(period); let sql = format!( "SELECT id, artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at FROM scrobbles{} ORDER BY scrobbled_at DESC LIMIT {}", whr, limit ); let mut stmt = conn.prepare(&sql)?; let mapper = |row: &rusqlite::Row| { Ok(Scrobble { id: row.get(0)?, artist: row.get(1)?, album: row.get(2)?, title: row.get(3)?, track_duration_secs: row.get(4)?, played_duration_secs: row.get(5)?, scrobbled_at: row.get(6)?, }) }; if p.is_empty() { stmt.query_map([], mapper)?.collect() } else { stmt.query_map(params![p[0], p[1]], mapper)?.collect() } } // --------------------------------------------------------------------------- // Album cache queries (for the `enrich` command) // --------------------------------------------------------------------------- /// An (artist, album) pair that has scrobbles but no entry in `album_cache`. /// These are the albums that need enrichment (MusicBrainz lookup + cover fetch). #[derive(Debug, Clone)] pub struct UncachedAlbum { pub artist: String, pub album: String, } /// Find all unique (artist, album) pairs in `scrobbles` that either: /// - don't have a corresponding row in `album_cache` at all, or /// - have a cached row but with `cover_url` still NULL, or /// - have a cached row but with `genre` still NULL. /// /// This ensures albums are re-tried if a previous run failed to find cover art /// or genre metadata, but only after a cooldown to avoid hitting MusicBrainz /// on every single report generation. pub fn uncached_albums(conn: &Connection) -> Result> { // Retry incomplete cache entries at most once per 7 days. let retry_before = (chrono::Local::now().naive_local() - chrono::Duration::days(7)) .format("%Y-%m-%dT%H:%M:%S") .to_string(); let mut stmt = conn.prepare( "SELECT DISTINCT s.artist, s.album FROM scrobbles s LEFT JOIN album_cache c ON s.artist = c.artist AND s.album = c.album WHERE s.album != '' AND ( c.id IS NULL OR ( (c.cover_url IS NULL OR c.genre IS NULL) AND (c.fetched_at IS NULL OR c.fetched_at < ?1) ) ) ORDER BY s.artist, s.album", )?; let rows = stmt.query_map(params![retry_before], |row| { Ok(UncachedAlbum { artist: row.get(0)?, album: row.get(1)?, }) })?; rows.collect() } /// Split a comma-separated genre string into cleaned labels. fn split_genre_list(csv: &str) -> Vec { csv.split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect() } /// Canonical key for grouping genre labels. /// /// Rules: /// - case-insensitive /// - `-` and `_` are treated as spaces /// - repeated spaces are collapsed fn canonical_genre_key(genre: &str) -> String { let lowered = genre .chars() .map(|c| match c { '-' | '_' => ' ', _ => c.to_ascii_lowercase(), }) .collect::(); lowered.split_whitespace().collect::>().join(" ") } /// Decide whether `candidate` should replace `existing` as the display label. /// /// Preference: if we already have a hyphenated label and we later see a spaced /// variant for the same canonical key, prefer the spaced variant. fn prefer_display_genre(existing: &str, candidate: &str) -> bool { existing.contains('-') && candidate.contains(' ') && !candidate.contains('-') } /// Data for inserting a new album_cache entry. #[derive(Debug, Clone)] pub struct AlbumCacheEntry { pub artist: String, pub album: String, pub musicbrainz_id: Option, pub cover_url: Option, pub genre: Option, pub fetched_at: String, } /// Insert or update an album_cache entry. Uses INSERT OR REPLACE so that /// re-running `enrich` on an already-cached album updates the data. pub fn upsert_album_cache(conn: &Connection, entry: &AlbumCacheEntry) -> Result<()> { conn.execute( "INSERT OR REPLACE INTO album_cache (artist, album, musicbrainz_id, cover_url, genre, fetched_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ entry.artist, entry.album, entry.musicbrainz_id, entry.cover_url, entry.genre, entry.fetched_at, ], )?; Ok(()) } /// Cached album metadata returned by [`album_cache_meta`]. #[derive(Debug, Clone)] pub struct AlbumCacheMeta { pub cover_url: Option, pub genre: Option, pub mbid: Option, } /// Fetch cached metadata (cover URL/path and genre string) for a specific /// `(artist, album)` pair. pub fn album_cache_meta( conn: &Connection, artist: &str, album: &str, ) -> Result> { let mut stmt = conn.prepare( "SELECT cover_url, genre, musicbrainz_id FROM album_cache WHERE artist = ?1 AND album = ?2 LIMIT 1", )?; let mut rows = stmt.query(params![artist, album])?; if let Some(row) = rows.next()? { Ok(Some(AlbumCacheMeta { cover_url: row.get(0)?, genre: row.get(1)?, mbid: row.get(2)?, })) } else { Ok(None) } } /// Find a cover image for a given artist for the requested period. /// /// The selected image is the cover of the artist's most-played album in that /// period (ties break by listen time, then album name). This keeps mini covers /// in top-artist rows aligned with what the listener actually played most. /// /// Falls back to an album-name-only cache lookup when the direct /// (artist, album) join finds no cover, so artists whose scrobbles carry /// a performer tag (e.g. a soloist on a classical recording) still get /// the cover that was pinned under a different artist tag for the same album. pub fn artist_cover(conn: &Connection, artist: &str, period: &str) -> Option { // The cover subquery first tries the direct (artist, album) cache entry; // if that has no cover it falls back to any cache entry for the same // album name that does have one (same MBID-merging logic as top_albums). let cover_expr = "COALESCE( c.cover_url, (SELECT ac.cover_url FROM album_cache ac WHERE ac.album = s.album AND ac.cover_url IS NOT NULL ORDER BY ac.artist LIMIT 1) )"; let time_filter = match period_range(period) { Some((from, to)) => format!("AND s.scrobbled_at BETWEEN '{}' AND '{}'", from, to), None => String::new(), }; let sql = format!( "SELECT {cover_expr} AS cover_url FROM scrobbles s LEFT JOIN album_cache c ON c.artist = s.artist AND c.album = s.album WHERE s.artist = ?1 {time_filter} AND {cover_expr} IS NOT NULL GROUP BY s.album ORDER BY COUNT(*) DESC, COALESCE(SUM(s.played_duration_secs), 0) DESC, s.album ASC LIMIT 1" ); let mut stmt = conn.prepare(&sql).ok()?; let mut rows = stmt.query(params![artist]).ok()?; rows.next().ok()?.and_then(|row| row.get(0).ok()) } /// Return the name of the most-played album for `artist` (any period). /// Used to ensure the artist's cover album is included in targeted enrichment. pub fn artist_top_album(conn: &Connection, artist: &str) -> Option { let mut stmt = conn .prepare( "SELECT album FROM scrobbles WHERE artist = ?1 AND album != '' GROUP BY album ORDER BY COUNT(*) DESC LIMIT 1", ) .ok()?; let mut rows = stmt.query(params![artist]).ok()?; rows.next().ok()?.and_then(|row| row.get(0).ok()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// Populate an in-memory database with a mix of test data: /// - 3 scrobbles for ††† (Crosses) across two days /// - 2 scrobbles for Deftones /// /// This gives us enough data to test rankings, listen time sums, etc. fn seed_db(conn: &Connection) { let scrobbles = vec![ NewScrobble { artist: "††† (Crosses)".to_string(), album: "††† (Crosses)".to_string(), title: "This Is a Trick".to_string(), track_duration_secs: Some(186), played_duration_secs: 186, scrobbled_at: "2026-03-19T10:00:00".to_string(), }, NewScrobble { artist: "††† (Crosses)".to_string(), album: "††† (Crosses)".to_string(), title: "Telepathy".to_string(), track_duration_secs: Some(215), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:05:00".to_string(), }, NewScrobble { artist: "Deftones".to_string(), album: "White Pony".to_string(), title: "Digital Bath".to_string(), track_duration_secs: Some(291), played_duration_secs: 291, scrobbled_at: "2026-03-19T10:10:00".to_string(), }, NewScrobble { artist: "Deftones".to_string(), album: "White Pony".to_string(), title: "Knife Prty".to_string(), track_duration_secs: Some(290), played_duration_secs: 250, scrobbled_at: "2026-03-18T14:00:00".to_string(), }, NewScrobble { artist: "††† (Crosses)".to_string(), album: "††† (Crosses)".to_string(), title: "This Is a Trick".to_string(), track_duration_secs: Some(186), played_duration_secs: 180, scrobbled_at: "2026-03-12T09:00:00".to_string(), }, ]; for s in &scrobbles { insert_scrobble(conn, s).unwrap(); } } #[test] fn test_schema_creation() { let conn = open_memory_db().unwrap(); // A freshly created DB should have zero scrobbles. let ov = overview(&conn, "all").unwrap(); assert_eq!(ov.total_scrobbles, 0); } #[test] fn test_insert_and_query() { let conn = open_memory_db().unwrap(); let s = NewScrobble { artist: "††† (Crosses)".to_string(), album: "††† (Crosses)".to_string(), title: "This Is a Trick".to_string(), track_duration_secs: Some(186), played_duration_secs: 186, scrobbled_at: "2026-03-19T10:00:00".to_string(), }; // First insert should get row ID 1. let id = insert_scrobble(&conn, &s).unwrap(); assert_eq!(id, 1); // Verify the overview reflects the single scrobble. let ov = overview(&conn, "all").unwrap(); assert_eq!(ov.total_scrobbles, 1); assert_eq!(ov.total_listen_time_secs, 186); assert_eq!(ov.unique_artists, 1); } #[test] fn test_top_artists() { let conn = open_memory_db().unwrap(); seed_db(&conn); let artists = top_artists(&conn, "all", 10).unwrap(); assert_eq!(artists.len(), 2); // ††† (Crosses) has 3 plays, Deftones has 2 — sorted by play count. assert_eq!(artists[0].artist, "††† (Crosses)"); assert_eq!(artists[0].plays, 3); assert_eq!(artists[1].artist, "Deftones"); assert_eq!(artists[1].plays, 2); } #[test] fn test_top_albums() { let conn = open_memory_db().unwrap(); seed_db(&conn); let albums = top_albums(&conn, "all", 10).unwrap(); assert_eq!(albums.len(), 2); // ††† (Crosses) album has 3 plays, White Pony has 2. assert_eq!(albums[0].album, "††† (Crosses)"); assert_eq!(albums[0].plays, 3); } #[test] fn test_top_tracks() { let conn = open_memory_db().unwrap(); seed_db(&conn); let tracks = top_tracks(&conn, "all", 10).unwrap(); // "This Is a Trick" appears twice (two scrobbles), others appear once each. assert_eq!(tracks[0].title, "This Is a Trick"); assert_eq!(tracks[0].plays, 2); } #[test] fn test_top_genres() { let conn = open_memory_db().unwrap(); seed_db(&conn); // Map seeded albums to genres. upsert_album_cache( &conn, &AlbumCacheEntry { artist: "††† (Crosses)".to_string(), album: "††† (Crosses)".to_string(), musicbrainz_id: None, cover_url: None, genre: Some("darkwave, electronic".to_string()), fetched_at: "2026-03-19T12:00:00".to_string(), }, ) .unwrap(); upsert_album_cache( &conn, &AlbumCacheEntry { artist: "Deftones".to_string(), album: "White Pony".to_string(), musicbrainz_id: None, cover_url: None, genre: Some("alternative metal".to_string()), fetched_at: "2026-03-19T12:00:00".to_string(), }, ) .unwrap(); let genres = top_genres(&conn, "all", 10).unwrap(); assert_eq!(genres[0].genre, "darkwave"); assert_eq!(genres[0].plays, 3); assert_eq!(genres[1].genre, "electronic"); assert_eq!(genres[1].plays, 3); assert_eq!(genres[2].genre, "alternative metal"); assert_eq!(genres[2].plays, 2); } #[test] fn test_top_genres_merges_hyphen_and_space_variants() { let conn = open_memory_db().unwrap(); insert_scrobble( &conn, &NewScrobble { artist: "A".to_string(), album: "H".to_string(), title: "T1".to_string(), track_duration_secs: Some(200), played_duration_secs: 200, scrobbled_at: "2026-03-19T10:00:00".to_string(), }, ) .unwrap(); insert_scrobble( &conn, &NewScrobble { artist: "A".to_string(), album: "S".to_string(), title: "T2".to_string(), track_duration_secs: Some(180), played_duration_secs: 180, scrobbled_at: "2026-03-19T10:01:00".to_string(), }, ) .unwrap(); // Insert hyphenated first, spaced second. We should group both and // prefer the spaced display label. upsert_album_cache( &conn, &AlbumCacheEntry { artist: "A".to_string(), album: "H".to_string(), musicbrainz_id: None, cover_url: None, genre: Some("post-rock".to_string()), fetched_at: "2026-03-19T12:00:00".to_string(), }, ) .unwrap(); upsert_album_cache( &conn, &AlbumCacheEntry { artist: "A".to_string(), album: "S".to_string(), musicbrainz_id: None, cover_url: None, genre: Some("post rock".to_string()), fetched_at: "2026-03-19T12:00:01".to_string(), }, ) .unwrap(); let genres = top_genres(&conn, "all", 10).unwrap(); assert_eq!(genres.len(), 1); assert_eq!(genres[0].genre, "post rock"); assert_eq!(genres[0].plays, 2); assert_eq!(genres[0].listen_time_secs, 380); } #[test] fn test_top_artists_tie_breaks_by_listen_time() { let conn = open_memory_db().unwrap(); // Both artists have 1 play; SlowCore should rank above FastPop because // it has higher total listened time. insert_scrobble( &conn, &NewScrobble { artist: "FastPop".to_string(), album: "A".to_string(), title: "Short".to_string(), track_duration_secs: Some(120), played_duration_secs: 120, scrobbled_at: "2026-03-19T11:00:00".to_string(), }, ) .unwrap(); insert_scrobble( &conn, &NewScrobble { artist: "SlowCore".to_string(), album: "B".to_string(), title: "Long".to_string(), track_duration_secs: Some(320), played_duration_secs: 320, scrobbled_at: "2026-03-19T11:05:00".to_string(), }, ) .unwrap(); let artists = top_artists(&conn, "all", 10).unwrap(); assert_eq!(artists.len(), 2); assert_eq!(artists[0].artist, "SlowCore"); assert_eq!(artists[0].plays, 1); assert_eq!(artists[1].artist, "FastPop"); } #[test] fn test_top_albums_tie_breaks_by_listen_time() { let conn = open_memory_db().unwrap(); // Both albums have 1 play; Album B should rank above Album A because // listened time is greater. insert_scrobble( &conn, &NewScrobble { artist: "Artist".to_string(), album: "Album A".to_string(), title: "One".to_string(), track_duration_secs: Some(150), played_duration_secs: 150, scrobbled_at: "2026-03-19T12:00:00".to_string(), }, ) .unwrap(); insert_scrobble( &conn, &NewScrobble { artist: "Artist".to_string(), album: "Album B".to_string(), title: "Two".to_string(), track_duration_secs: Some(240), played_duration_secs: 240, scrobbled_at: "2026-03-19T12:05:00".to_string(), }, ) .unwrap(); let albums = top_albums(&conn, "all", 10).unwrap(); assert_eq!(albums.len(), 2); assert_eq!(albums[0].album, "Album B"); assert_eq!(albums[0].plays, 1); assert_eq!(albums[1].album, "Album A"); } #[test] fn test_top_albums_merges_multi_artist_same_mbid() { // Classical albums often have different MPRIS artist tags per track // (soloist on one, ensemble on another). Verify that top_albums groups // them under one entry when album_cache maps one of the artist variants // to a MBID. let conn = open_memory_db().unwrap(); let album = "Vivaldi: Gloria; Nisi Dominus"; // Three tracks: two with artist A, one with artist B. Same album name. for (artist, ts) in &[ ("Choir", "2026-03-19T10:00:00"), ("Choir", "2026-03-19T10:05:00"), ("Soloist", "2026-03-19T10:10:00"), ] { insert_scrobble( &conn, &NewScrobble { artist: (*artist).to_string(), album: album.to_string(), title: "Track".to_string(), track_duration_secs: Some(300), played_duration_secs: 300, scrobbled_at: (*ts).to_string(), }, ) .unwrap(); } // Without any album_cache entry, both (artist, album) pairs are // separate groups — pre-fix behaviour reproduced here. let albums_no_cache = top_albums(&conn, "all", 10).unwrap(); assert_eq!(albums_no_cache.len(), 2, "without cache: two separate groups"); // Pin the album via album_cache for "Choir". The MBID lookup in // top_albums will now resolve "Soloist"'s rows to the same MBID. upsert_album_cache( &conn, &AlbumCacheEntry { artist: "Choir".to_string(), album: album.to_string(), musicbrainz_id: Some("test-mbid-vivaldi".to_string()), cover_url: Some("covers/test.jpg".to_string()), genre: Some("classical".to_string()), fetched_at: "2026-03-19T12:00:00".to_string(), }, ) .unwrap(); let albums = top_albums(&conn, "all", 10).unwrap(); assert_eq!(albums.len(), 1, "with cache: merged into one group"); assert_eq!(albums[0].plays, 3); assert_eq!(albums[0].album, album); // The returned artist should be the cached one so cover lookup works. assert_eq!(albums[0].artist, "Choir"); } #[test] fn test_top_tracks_tie_breaks_by_listen_time() { let conn = open_memory_db().unwrap(); // Both tracks have 1 play; "Epic" should rank above "Brief" because // listened time is greater. insert_scrobble( &conn, &NewScrobble { artist: "Band".to_string(), album: "LP".to_string(), title: "Brief".to_string(), track_duration_secs: Some(100), played_duration_secs: 100, scrobbled_at: "2026-03-19T13:00:00".to_string(), }, ) .unwrap(); insert_scrobble( &conn, &NewScrobble { artist: "Band".to_string(), album: "LP".to_string(), title: "Epic".to_string(), track_duration_secs: Some(360), played_duration_secs: 360, scrobbled_at: "2026-03-19T13:05:00".to_string(), }, ) .unwrap(); let tracks = top_tracks(&conn, "all", 10).unwrap(); assert_eq!(tracks.len(), 2); assert_eq!(tracks[0].title, "Epic"); assert_eq!(tracks[0].plays, 1); assert_eq!(tracks[1].title, "Brief"); } #[test] fn test_recent_scrobbles() { let conn = open_memory_db().unwrap(); seed_db(&conn); let recent = recent_scrobbles(&conn, "all", 3).unwrap(); assert_eq!(recent.len(), 3); // Results are ordered by scrobbled_at DESC — most recent first. assert_eq!(recent[0].scrobbled_at, "2026-03-19T10:10:00"); } #[test] fn test_overview_listen_time() { let conn = open_memory_db().unwrap(); seed_db(&conn); let ov = overview(&conn, "all").unwrap(); assert_eq!(ov.total_scrobbles, 5); // Total listen time: 186 + 200 + 291 + 250 + 180 = 1107 seconds. assert_eq!(ov.total_listen_time_secs, 1107); assert_eq!(ov.unique_artists, 2); } #[test] fn test_insert_missing_duration() { // Verify that tracks with unknown duration can be stored and retrieved. let conn = open_memory_db().unwrap(); let s = NewScrobble { artist: "Unknown".to_string(), album: "".to_string(), title: "Mystery".to_string(), track_duration_secs: None, played_duration_secs: 240, scrobbled_at: "2026-03-19T12:00:00".to_string(), }; insert_scrobble(&conn, &s).unwrap(); let recent = recent_scrobbles(&conn, "all", 1).unwrap(); assert!(recent[0].track_duration_secs.is_none()); assert_eq!(recent[0].played_duration_secs, 240); } #[test] fn test_latest_scrobble_at() { let conn = open_memory_db().unwrap(); assert!(latest_scrobble_at(&conn).unwrap().is_none()); insert_scrobble( &conn, &NewScrobble { artist: "A".to_string(), album: "X".to_string(), title: "T1".to_string(), track_duration_secs: Some(100), played_duration_secs: 100, scrobbled_at: "2026-03-19T10:00:00".to_string(), }, ) .unwrap(); insert_scrobble( &conn, &NewScrobble { artist: "A".to_string(), album: "Y".to_string(), title: "T2".to_string(), track_duration_secs: Some(120), played_duration_secs: 120, scrobbled_at: "2026-03-19T12:34:56".to_string(), }, ) .unwrap(); assert_eq!( latest_scrobble_at(&conn).unwrap().as_deref(), Some("2026-03-19T12:34:56") ); } #[test] fn test_artist_cover_uses_most_played_album_and_period() { let conn = open_memory_db().unwrap(); let now = chrono::Local::now().naive_local(); let old_day = (now - chrono::Duration::days(10)) .format("%Y-%m-%dT%H:%M:%S") .to_string(); let recent = (now - chrono::Duration::minutes(10)) .format("%Y-%m-%dT%H:%M:%S") .to_string(); // Older period: two plays on "Old Album". for i in 0..2 { insert_scrobble( &conn, &NewScrobble { artist: "Artist X".to_string(), album: "Old Album".to_string(), title: format!("Old Song {}", i), track_duration_secs: Some(180), played_duration_secs: 180, scrobbled_at: old_day.clone(), }, ) .unwrap(); } // Today: one play on "New Album". insert_scrobble( &conn, &NewScrobble { artist: "Artist X".to_string(), album: "New Album".to_string(), title: "New Song".to_string(), track_duration_secs: Some(200), played_duration_secs: 200, scrobbled_at: recent, }, ) .unwrap(); upsert_album_cache( &conn, &AlbumCacheEntry { artist: "Artist X".to_string(), album: "Old Album".to_string(), musicbrainz_id: None, cover_url: Some("covers/old.jpg".to_string()), genre: None, fetched_at: now.format("%Y-%m-%dT%H:%M:%S").to_string(), }, ) .unwrap(); upsert_album_cache( &conn, &AlbumCacheEntry { artist: "Artist X".to_string(), album: "New Album".to_string(), musicbrainz_id: None, cover_url: Some("covers/new.jpg".to_string()), genre: None, fetched_at: now.format("%Y-%m-%dT%H:%M:%S").to_string(), }, ) .unwrap(); // Across all time, Old Album wins with more plays. assert_eq!( artist_cover(&conn, "Artist X", "all").as_deref(), Some("covers/old.jpg") ); // For today, only New Album is present. assert_eq!( artist_cover(&conn, "Artist X", "today").as_deref(), Some("covers/new.jpg") ); } }