use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, params};
use crate::db::connection::DbError;
use super::TrackRow;
pub const SOURCE_LOCAL: &str = "local";
pub const SOURCE_SUBSONIC: &str = "subsonic";
pub fn record_play_at(
conn: &Connection,
track_id: i64,
played_at: i64,
listened_ms: Option<i64>,
source: &str,
) -> Result<i64, DbError> {
conn.execute(
"INSERT INTO play_history (track_id, played_at, duration_ms, source)
VALUES (?1, ?2, ?3, ?4)",
params![track_id, played_at, listened_ms, source],
)?;
Ok(conn.last_insert_rowid())
}
pub fn record_play(
conn: &Connection,
track_id: i64,
listened_ms: Option<i64>,
) -> Result<i64, DbError> {
record_play_at(conn, track_id, now_secs(), listened_ms, SOURCE_LOCAL)
}
pub fn set_listened_ms(
conn: &Connection,
id: i64,
track_id: i64,
listened_ms: i64,
) -> Result<(), DbError> {
conn.execute(
"UPDATE play_history SET duration_ms = ?1 WHERE id = ?2 AND track_id = ?3",
params![listened_ms, id, track_id],
)?;
Ok(())
}
pub fn delete_plays(conn: &Connection, ids: &[i64]) -> Result<usize, DbError> {
let tx = conn.unchecked_transaction()?;
let mut removed = 0;
{
let mut stmt = tx.prepare("DELETE FROM play_history WHERE id = ?1")?;
for id in ids {
removed += stmt.execute(params![id])?;
}
}
tx.commit()?;
Ok(removed)
}
pub fn last_played_at(conn: &Connection, track_id: i64) -> Result<Option<i64>, DbError> {
let result = conn.query_row(
"SELECT MAX(played_at) FROM play_history WHERE track_id = ?1",
params![track_id],
|row| row.get::<_, Option<i64>>(0),
)?;
Ok(result)
}
pub fn recent_track_ids(conn: &Connection, limit: usize) -> Result<Vec<i64>, DbError> {
let mut stmt = conn.prepare(
"SELECT DISTINCT track_id FROM play_history
ORDER BY played_at DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], |row| row.get(0))?
.collect::<Result<Vec<i64>, _>>()?;
Ok(rows)
}
pub fn play_count(conn: &Connection, track_id: i64) -> Result<i64, DbError> {
let count = conn.query_row(
"SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
params![track_id],
|row| row.get(0),
)?;
Ok(count)
}
#[derive(Debug, Clone)]
pub struct PlayHistoryEntry {
pub track_id: i64,
pub played_at: i64,
pub duration_ms: Option<i64>,
}
pub fn get_play_history(
conn: &Connection,
limit: u32,
offset: u32,
) -> Result<Vec<PlayHistoryEntry>, DbError> {
let mut stmt = conn.prepare(
"SELECT track_id, played_at, duration_ms FROM play_history
ORDER BY played_at DESC
LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt
.query_map(params![limit as i64, offset as i64], |row| {
Ok(PlayHistoryEntry {
track_id: row.get(0)?,
played_at: row.get(1)?,
duration_ms: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
#[derive(Debug, Clone)]
pub struct PlayHistoryRow {
pub id: i64,
pub track: TrackRow,
pub played_at: i64,
pub listened_ms: Option<i64>,
pub source: String,
}
pub fn play_history_with_tracks(
conn: &Connection,
limit: u32,
offset: u32,
) -> Result<Vec<PlayHistoryRow>, DbError> {
let mut stmt = conn.prepare(
"SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
t.disc, t.track_number, t.title, t.duration_ms, t.path,
t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
t.genre, t.source, t.remote_id, t.cached_path,
h.id, h.played_at, h.duration_ms, COALESCE(h.source, 'local')
FROM play_history h
JOIN tracks t ON t.id = h.track_id
LEFT JOIN artists a ON t.artist_id = a.id
LEFT JOIN albums al ON t.album_id = al.id
LEFT JOIN artists aa ON al.artist_id = aa.id
ORDER BY h.played_at DESC, h.id DESC
LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt
.query_map(params![limit as i64, offset as i64], |row| {
Ok(PlayHistoryRow {
track: super::row_to_track_row(row)?,
id: row.get(20)?,
played_at: row.get(21)?,
listened_ms: row.get(22)?,
source: row.get(23)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn clear_play_history(conn: &Connection) -> Result<usize, DbError> {
Ok(conn.execute("DELETE FROM play_history", [])?)
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::connection::Database;
use crate::db::queries::{sample_meta, upsert_track};
fn test_db() -> Database {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "on").unwrap();
crate::db::schema::create_tables(&conn).unwrap();
Database { conn }
}
fn seed_track(db: &Database, title: &str) -> i64 {
let mut meta = sample_meta(title, "Artist1", "Album1");
meta.path = Some(format!("/music/{title}.flac"));
upsert_track(&db.conn, &meta).unwrap();
db.conn
.query_row(
"SELECT id FROM tracks WHERE title = ?1",
params![title],
|row| row.get(0),
)
.unwrap()
}
#[test]
fn test_record_and_query_play_history() {
let db = test_db();
let track_id = seed_track(&db, "Track1");
assert_eq!(play_count(&db.conn, track_id).unwrap(), 0);
assert!(last_played_at(&db.conn, track_id).unwrap().is_none());
assert!(recent_track_ids(&db.conn, 10).unwrap().is_empty());
record_play(&db.conn, track_id, Some(240_000)).unwrap();
assert_eq!(play_count(&db.conn, track_id).unwrap(), 1);
assert!(last_played_at(&db.conn, track_id).unwrap().is_some());
let recent = recent_track_ids(&db.conn, 10).unwrap();
assert_eq!(recent.len(), 1);
assert_eq!(recent[0], track_id);
record_play(&db.conn, track_id, Some(240_000)).unwrap();
assert_eq!(play_count(&db.conn, track_id).unwrap(), 2);
assert_eq!(recent_track_ids(&db.conn, 10).unwrap().len(), 1);
}
#[test]
fn history_is_a_list_of_events_not_of_tracks() {
let db = test_db();
let a = seed_track(&db, "A");
let b = seed_track(&db, "B");
record_play_at(&db.conn, a, 100, Some(1000), SOURCE_LOCAL).unwrap();
record_play_at(&db.conn, b, 200, None, SOURCE_SUBSONIC).unwrap();
record_play_at(&db.conn, a, 300, Some(2000), SOURCE_LOCAL).unwrap();
let rows = play_history_with_tracks(&db.conn, 10, 0).unwrap();
assert_eq!(
rows.iter()
.map(|r| r.track.title.as_str())
.collect::<Vec<_>>(),
["A", "B", "A"],
"most recent first, and the same track appears once per play"
);
assert_eq!(rows[0].played_at, 300);
assert_eq!(rows[0].listened_ms, Some(2000));
assert_eq!(rows[1].source, SOURCE_SUBSONIC);
assert_eq!(rows[1].listened_ms, None);
assert_eq!(rows[0].track.artist_name, "Artist1");
}
#[test]
fn history_paginates() {
let db = test_db();
let id = seed_track(&db, "A");
for at in 0..5 {
record_play_at(&db.conn, id, at, None, SOURCE_LOCAL).unwrap();
}
assert_eq!(play_history_with_tracks(&db.conn, 2, 0).unwrap().len(), 2);
assert_eq!(play_history_with_tracks(&db.conn, 2, 4).unwrap().len(), 1);
assert_eq!(play_history_with_tracks(&db.conn, 10, 5).unwrap().len(), 0);
}
#[test]
fn plays_within_the_same_second_keep_their_order() {
let db = test_db();
let a = seed_track(&db, "A");
let b = seed_track(&db, "B");
record_play_at(&db.conn, a, 42, None, SOURCE_LOCAL).unwrap();
record_play_at(&db.conn, b, 42, None, SOURCE_LOCAL).unwrap();
let rows = play_history_with_tracks(&db.conn, 10, 0).unwrap();
assert_eq!(
rows.iter()
.map(|r| r.track.title.as_str())
.collect::<Vec<_>>(),
["B", "A"]
);
}
#[test]
fn deleting_a_track_takes_its_history_with_it() {
let db = test_db();
let id = seed_track(&db, "A");
record_play(&db.conn, id, None).unwrap();
db.conn
.execute("DELETE FROM tracks WHERE id = ?1", params![id])
.expect("a track with play history must still be deletable");
assert_eq!(play_count(&db.conn, id).unwrap(), 0);
assert!(
play_history_with_tracks(&db.conn, 10, 0)
.unwrap()
.is_empty()
);
}
#[test]
fn listening_time_lands_on_the_entry_it_belongs_to() {
let db = test_db();
let a = seed_track(&db, "A");
let b = seed_track(&db, "B");
let first = record_play(&db.conn, a, None).unwrap();
let second = record_play(&db.conn, b, None).unwrap();
set_listened_ms(&db.conn, second, b, 4_200).unwrap();
set_listened_ms(&db.conn, first, b, 9_999).unwrap();
let rows = play_history_with_tracks(&db.conn, 10, 0).unwrap();
let by_id: Vec<_> = rows.iter().map(|r| (r.id, r.listened_ms)).collect();
assert!(by_id.contains(&(second, Some(4_200))));
assert!(
by_id.contains(&(first, None)),
"the mismatched update was refused"
);
}
#[test]
fn plays_can_be_forgotten_individually() {
let db = test_db();
let id = seed_track(&db, "A");
let first = record_play(&db.conn, id, None).unwrap();
let second = record_play(&db.conn, id, None).unwrap();
let third = record_play(&db.conn, id, None).unwrap();
assert_eq!(delete_plays(&db.conn, &[first, third]).unwrap(), 2);
let left = play_history_with_tracks(&db.conn, 10, 0).unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0].id, second);
assert_eq!(
play_count(&db.conn, id).unwrap(),
1,
"and the play count follows"
);
}
#[test]
fn forgetting_an_entry_that_is_already_gone_is_not_an_error() {
let db = test_db();
assert_eq!(delete_plays(&db.conn, &[404]).unwrap(), 0);
assert_eq!(delete_plays(&db.conn, &[]).unwrap(), 0);
}
#[test]
fn clearing_removes_everything() {
let db = test_db();
let id = seed_track(&db, "A");
record_play(&db.conn, id, None).unwrap();
record_play(&db.conn, id, None).unwrap();
assert_eq!(clear_play_history(&db.conn).unwrap(), 2);
assert_eq!(play_count(&db.conn, id).unwrap(), 0);
}
}