1381 lines
49 KiB
Rust
1381 lines
49 KiB
Rust
//! 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<i64>,
|
|
/// 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<i64>,
|
|
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<Connection> {
|
|
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<Connection> {
|
|
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<i64> {
|
|
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<Option<String>> {
|
|
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<String>) {
|
|
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<Overview> {
|
|
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<Vec<TopArtist>> {
|
|
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<Vec<TopAlbum>> {
|
|
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<Vec<TopTrack>> {
|
|
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<Vec<TopGenre>> {
|
|
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::<Result<Vec<_>>>()?
|
|
} 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::<Result<Vec<_>>>()?
|
|
};
|
|
|
|
// 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<String, (String, i64, i64)> = 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<TopGenre> = 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<Vec<Scrobble>> {
|
|
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<Vec<UncachedAlbum>> {
|
|
// 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<String> {
|
|
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::<String>();
|
|
lowered.split_whitespace().collect::<Vec<_>>().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<String>,
|
|
pub cover_url: Option<String>,
|
|
pub genre: Option<String>,
|
|
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<String>,
|
|
pub genre: Option<String>,
|
|
pub mbid: Option<String>,
|
|
}
|
|
|
|
/// 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<Option<AlbumCacheMeta>> {
|
|
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<String> {
|
|
// 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<String> {
|
|
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")
|
|
);
|
|
}
|
|
}
|