feat(player-stats): local listening history tab with heatmap and summaries (#849)

* feat(player-stats): local listening history tab with heatmap and summaries

Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.

* test(player-stats): split play_session repo and expand test coverage

Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.

* docs(release): CHANGELOG and credits for player stats (PR #849)

* fix(player-stats): satisfy tsc and clippy CI gates

Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
cucadmuh
2026-05-22 14:07:38 +03:00
committed by GitHub
parent 7afddf7b84
commit 23f7ba02d6
49 changed files with 3059 additions and 19 deletions
@@ -0,0 +1,22 @@
-- Player listening history — see workdocs player-stats spec §3.1
CREATE TABLE play_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_sec REAL NOT NULL,
position_max_sec REAL NOT NULL,
completion TEXT NOT NULL,
end_reason TEXT NOT NULL,
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
CHECK (completion IN ('partial', 'full'))
);
CREATE INDEX idx_play_session_server_time
ON play_session(server_id, started_at_ms DESC);
CREATE INDEX idx_play_session_track
ON play_session(server_id, track_id, started_at_ms DESC);
CREATE INDEX idx_play_session_started
ON play_session(started_at_ms DESC);
@@ -20,12 +20,13 @@ use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PurgeReportDto, SyncJobDto, SyncStateDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
use crate::live_search;
use crate::payload::LibrarySyncProgressPayload;
use crate::repos::{SyncStateRepository, TrackRepository};
use crate::repos::{PlaySessionRepository, SyncStateRepository, TrackRepository};
use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession};
use crate::search::search_tracks;
use crate::store::LibraryStore;
@@ -955,6 +956,53 @@ pub fn library_put_fact(
crate::repos::FactRepository::new(&runtime.store).put(&server_id, &track_id, &fact, now_unix_ms())
}
#[tauri::command]
pub fn library_record_play_session(
runtime: State<'_, LibraryRuntime>,
input: PlaySessionInputDto,
) -> Result<(), String> {
PlaySessionRepository::new(&runtime.store).insert(&input)
}
#[tauri::command]
pub fn library_get_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
year: i32,
) -> Result<PlaySessionYearSummaryDto, String> {
PlaySessionRepository::new(&runtime.store).year_summary(year)
}
#[tauri::command]
pub fn library_get_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
year: i32,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
PlaySessionRepository::new(&runtime.store).heatmap(year)
}
#[tauri::command]
pub fn library_get_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
date_iso: String,
) -> Result<PlaySessionDayDetailDto, String> {
PlaySessionRepository::new(&runtime.store).day_detail(&date_iso)
}
#[tauri::command]
pub fn library_get_player_stats_year_bounds(
runtime: State<'_, LibraryRuntime>,
) -> Result<PlaySessionYearBoundsDto, String> {
PlaySessionRepository::new(&runtime.store).year_bounds()
}
#[tauri::command]
pub fn library_get_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30))
}
#[tauri::command]
pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>,
@@ -1013,6 +1061,10 @@ pub fn library_purge_server(
"DELETE FROM track_id_history WHERE server_id = ?1",
params![server_id],
)?;
tx.execute(
"DELETE FROM play_session WHERE server_id = ?1",
params![server_id],
)?;
tx.execute(
"DELETE FROM track WHERE server_id = ?1",
params![server_id],
@@ -281,6 +281,94 @@ fn default_confidence() -> f64 {
1.0
}
/// Input to `library_record_play_session`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionInputDto {
pub server_id: String,
pub track_id: String,
pub started_at_ms: i64,
pub listened_sec: f64,
pub position_max_sec: f64,
pub end_reason: String,
/// Player-known duration when `track.duration_sec` in the index is missing/zero.
#[serde(default)]
pub duration_sec_hint: Option<i64>,
}
/// Cross-server year summary for the Player stats tab.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionYearSummaryDto {
pub total_listened_sec: f64,
/// Listening sessions (plays clustered by idle gap).
pub session_count: u32,
/// Individual track plays (`COUNT(*)`).
pub track_play_count: u32,
/// Distinct tracks heard at least once in the year.
pub unique_track_count: u32,
/// Calendar days with at least one recorded play.
pub listening_day_count: u32,
pub full_count: u32,
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionHeatmapDayDto {
pub date: String,
pub track_play_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayTotalsDto {
pub total_listened_sec: f64,
pub session_count: u32,
pub track_play_count: u32,
pub full_count: u32,
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayTrackDto {
pub server_id: String,
pub track_id: String,
pub title: String,
pub artist: Option<String>,
pub listened_sec: f64,
pub completion: String,
pub started_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayDetailDto {
pub totals: PlaySessionDayTotalsDto,
pub tracks: Vec<PlaySessionDayTrackDto>,
}
/// Summary for one day in the recent-days list (no track rows).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionRecentDayDto {
pub date: String,
pub total_listened_sec: f64,
pub session_count: u32,
pub track_play_count: u32,
pub full_count: u32,
pub partial_count: u32,
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionYearBoundsDto {
pub min_year: Option<i32>,
pub max_year: Option<i32>,
}
/// `library_purge_server` outcome.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -1,11 +1,13 @@
pub mod artifact;
pub mod fact;
pub mod play_session;
pub mod sync_state;
pub mod track;
pub mod track_id_history;
pub use artifact::ArtifactRepository;
pub use fact::FactRepository;
pub use play_session::PlaySessionRepository;
pub use sync_state::SyncStateRepository;
pub use track::{RemapEntry, RemapStats, TrackRepository, TrackRow};
pub use track_id_history::TrackIdHistoryRepository;
@@ -0,0 +1,52 @@
/// Idle gap after which the next play starts a new listening session.
pub(crate) const LISTENING_SESSION_GAP_MS: i64 = 30 * 60 * 1000;
#[derive(Clone, Copy)]
pub(crate) struct PlaySpan {
pub started_at_ms: i64,
pub listened_sec: f64,
}
fn play_end_ms(span: PlaySpan) -> i64 {
span.started_at_ms + (span.listened_sec * 1000.0) as i64
}
pub(crate) fn count_listening_sessions(plays: &[PlaySpan]) -> u32 {
if plays.is_empty() {
return 0;
}
let mut sorted = plays.to_vec();
sorted.sort_by_key(|p| p.started_at_ms);
let mut sessions = 1u32;
let mut prev_end = play_end_ms(sorted[0]);
for span in sorted.iter().skip(1) {
if span.started_at_ms - prev_end > LISTENING_SESSION_GAP_MS {
sessions += 1;
}
prev_end = prev_end.max(play_end_ms(*span));
}
sessions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clusters_by_thirty_minute_gap() {
let plays = vec![
PlaySpan { started_at_ms: 0, listened_sec: 120.0 },
PlaySpan { started_at_ms: 5 * 60 * 1000, listened_sec: 120.0 },
PlaySpan {
started_at_ms: 45 * 60 * 1000,
listened_sec: 120.0,
},
];
assert_eq!(count_listening_sessions(&plays), 2);
}
#[test]
fn empty_plays_is_zero_sessions() {
assert_eq!(count_listening_sessions(&[]), 0);
}
}
@@ -0,0 +1,47 @@
pub(crate) const MIN_LISTENED_SEC: f64 = 10.0;
pub(crate) const FULL_COMPLETION_RATIO: f64 = 0.70;
pub(crate) fn effective_duration_sec(db_duration_sec: i64, hint: Option<i64>) -> i64 {
let hint = hint.filter(|d| *d > 0).unwrap_or(0);
if db_duration_sec > 0 && hint > 0 {
return db_duration_sec.max(hint);
}
if db_duration_sec > 0 {
return db_duration_sec;
}
hint
}
pub(crate) fn completion_from_position(position_max_sec: f64, duration_sec: i64) -> &'static str {
if duration_sec <= 0 {
return "partial";
}
if position_max_sec / duration_sec as f64 >= FULL_COMPLETION_RATIO {
"full"
} else {
"partial"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_threshold_at_seventy_percent() {
assert_eq!(completion_from_position(70.0, 100), "full");
assert_eq!(completion_from_position(69.0, 100), "partial");
}
#[test]
fn zero_duration_is_always_partial() {
assert_eq!(completion_from_position(100.0, 0), "partial");
}
#[test]
fn effective_duration_prefers_max_of_db_and_hint() {
assert_eq!(effective_duration_sec(1, Some(300)), 300);
assert_eq!(effective_duration_sec(200, Some(100)), 200);
assert_eq!(effective_duration_sec(0, Some(240)), 240);
}
}
@@ -0,0 +1,349 @@
//! Append-only player listening sessions (`play_session`).
mod cluster;
mod completion;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use rusqlite::{params, OptionalExtension};
use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto,
PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto,
PlaySessionYearBoundsDto, PlaySessionYearSummaryDto,
};
use crate::store::LibraryStore;
use cluster::{count_listening_sessions, PlaySpan};
use completion::{
completion_from_position, effective_duration_sec, MIN_LISTENED_SEC,
};
struct DayAgg {
total_listened_sec: f64,
track_play_count: u32,
full_count: u32,
partial_count: u32,
plays: Vec<PlaySpan>,
}
fn validate_date_iso(date_iso: &str) -> Result<(), String> {
if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' {
return Err("dateIso must be YYYY-MM-DD".into());
}
let year: i32 = date_iso[0..4]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let month: u32 = date_iso[5..7]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let day: u32 = date_iso[8..10]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("dateIso must be YYYY-MM-DD".into());
}
Ok(())
}
pub struct PlaySessionRepository<'a> {
store: &'a LibraryStore,
}
impl<'a> PlaySessionRepository<'a> {
pub fn new(store: &'a LibraryStore) -> Self {
Self { store }
}
/// Insert one finalized session. Rejects `listened_sec <= 10` and missing tracks.
pub fn insert(&self, input: &PlaySessionInputDto) -> Result<(), String> {
if !input.listened_sec.is_finite() || input.listened_sec <= MIN_LISTENED_SEC {
return Err(format!(
"listened_sec must be > {} (got {})",
MIN_LISTENED_SEC, input.listened_sec
));
}
if !input.position_max_sec.is_finite() || input.position_max_sec < 0.0 {
return Err("position_max_sec must be finite and >= 0".into());
}
if input.server_id.is_empty() || input.track_id.is_empty() {
return Err("server_id and track_id are required".into());
}
self.store
.with_conn("play_session.insert", |conn| {
let duration_sec: i64 = conn
.query_row(
"SELECT duration_sec FROM track \
WHERE server_id = ?1 AND id = ?2 AND deleted = 0",
params![input.server_id, input.track_id],
|row| row.get(0),
)
.optional()?
.ok_or(rusqlite::Error::QueryReturnedNoRows)?;
let duration_for_completion =
effective_duration_sec(duration_sec, input.duration_sec_hint);
let completion =
completion_from_position(input.position_max_sec, duration_for_completion);
conn.execute(
"INSERT INTO play_session \
(server_id, track_id, started_at_ms, listened_sec, position_max_sec, \
completion, end_reason) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
input.server_id,
input.track_id,
input.started_at_ms,
input.listened_sec,
input.position_max_sec,
completion,
input.end_reason,
],
)?;
Ok(())
})
.map_err(|e| e.to_string())
}
pub fn year_summary(&self, year: i32) -> Result<PlaySessionYearSummaryDto, String> {
let year_str = year.to_string();
self.store
.with_read_conn(|conn| {
let totals = conn.query_row(
"SELECT \
COALESCE(SUM(listened_sec), 0.0), \
COUNT(*), \
COUNT(DISTINCT server_id || ':' || track_id), \
COUNT(DISTINCT date(started_at_ms / 1000, 'unixepoch', 'localtime')), \
COALESCE(SUM(CASE WHEN completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session \
WHERE strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') = ?1",
params![year_str],
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
row.get::<_, i64>(5)? as u32,
))
},
)?;
let mut stmt = conn.prepare(
"SELECT started_at_ms, listened_sec \
FROM play_session \
WHERE strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \
ORDER BY started_at_ms ASC",
)?;
let plays = stmt
.query_map(params![year_str], |row| {
Ok(PlaySpan {
started_at_ms: row.get(0)?,
listened_sec: row.get(1)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (
total_listened_sec,
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
) = totals;
Ok(PlaySessionYearSummaryDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
})
})
.map_err(|e| e.to_string())
}
pub fn heatmap(&self, year: i32) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
let year_str = year.to_string();
self.store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT \
date(started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \
COUNT(*) AS n \
FROM play_session \
WHERE strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \
GROUP BY d \
ORDER BY d ASC",
)?;
let rows = stmt
.query_map(params![year_str], |row| {
Ok(PlaySessionHeatmapDayDto {
date: row.get(0)?,
track_play_count: row.get::<_, i64>(1)? as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
pub fn day_detail(&self, date_iso: &str) -> Result<PlaySessionDayDetailDto, String> {
validate_date_iso(date_iso)?;
self.store
.with_read_conn(|conn| {
let totals_row = conn.query_row(
"SELECT \
COALESCE(SUM(listened_sec), 0.0), \
COUNT(*), \
COALESCE(SUM(CASE WHEN completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session \
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = ?1",
params![date_iso],
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
))
},
)?;
let mut stmt = conn.prepare(
"SELECT ps.server_id, ps.track_id, t.title, t.artist, \
ps.listened_sec, ps.completion, ps.started_at_ms \
FROM play_session ps \
JOIN track t ON t.server_id = ps.server_id AND t.id = ps.track_id \
WHERE date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \
ORDER BY ps.started_at_ms DESC",
)?;
let tracks = stmt
.query_map(params![date_iso], |row| {
Ok(PlaySessionDayTrackDto {
server_id: row.get(0)?,
track_id: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
listened_sec: row.get(4)?,
completion: row.get(5)?,
started_at_ms: row.get(6)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<PlaySpan> = tracks
.iter()
.map(|t| PlaySpan {
started_at_ms: t.started_at_ms,
listened_sec: t.listened_sec,
})
.collect();
let (total_listened_sec, track_play_count, full_count, partial_count) = totals_row;
let totals = PlaySessionDayTotalsDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
full_count,
partial_count,
};
Ok(PlaySessionDayDetailDto { totals, tracks })
})
.map_err(|e| e.to_string())
}
/// Calendar years that contain at least one session (local TZ).
pub fn year_bounds(&self) -> Result<PlaySessionYearBoundsDto, String> {
self.store
.with_read_conn(|conn| {
conn.query_row(
"SELECT \
MIN(CAST(strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER)), \
MAX(CAST(strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER)) \
FROM play_session",
[],
|row| {
Ok(PlaySessionYearBoundsDto {
min_year: row.get::<_, Option<i64>>(0)?.map(|y| y as i32),
max_year: row.get::<_, Option<i64>>(1)?.map(|y| y as i32),
})
},
)
})
.map_err(|e| e.to_string())
}
/// Most recent calendar days with sessions (newest first).
pub fn recent_days(&self, limit: u32) -> Result<Vec<PlaySessionRecentDayDto>, String> {
let limit = limit.clamp(1, 90);
self.store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT \
date(started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \
started_at_ms, listened_sec, completion \
FROM play_session \
ORDER BY d DESC, started_at_ms ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut by_day: HashMap<String, DayAgg> = HashMap::new();
for row in rows {
let (date, started_at_ms, listened_sec, completion) = row?;
let agg = by_day.entry(date).or_insert_with(|| DayAgg {
total_listened_sec: 0.0,
track_play_count: 0,
full_count: 0,
partial_count: 0,
plays: Vec::new(),
});
agg.total_listened_sec += listened_sec;
agg.track_play_count += 1;
if completion == "full" {
agg.full_count += 1;
} else {
agg.partial_count += 1;
}
agg.plays.push(PlaySpan {
started_at_ms,
listened_sec,
});
}
let mut out: Vec<PlaySessionRecentDayDto> = by_day
.into_iter()
.map(|(date, agg)| PlaySessionRecentDayDto {
date,
total_listened_sec: agg.total_listened_sec,
session_count: count_listening_sessions(&agg.plays),
track_play_count: agg.track_play_count,
full_count: agg.full_count,
partial_count: agg.partial_count,
})
.collect();
out.sort_by(|a, b| b.date.cmp(&a.date));
out.truncate(limit as usize);
Ok(out)
})
.map_err(|e| e.to_string())
}
}
@@ -0,0 +1,426 @@
use super::*;
use crate::dto::PlaySessionInputDto;
use crate::repos::{TrackRepository, TrackRow};
fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, duration_sec: i64) {
TrackRepository::new(store)
.upsert_batch(&[TrackRow {
server_id: server_id.into(),
id: track_id.into(),
title: "Test".into(),
title_sort: None,
artist: Some("Artist".into()),
artist_id: None,
album: "Album".into(),
album_id: None,
album_artist: None,
duration_sec,
track_number: None,
disc_number: None,
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}])
.expect("seed track");
}
fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Title".into(),
title_sort: None,
artist: None,
artist_id: None,
album: "Album".into(),
album_id: None,
album_artist: None,
duration_sec: 200,
track_number: None,
disc_number: None,
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: if path.is_empty() {
None
} else {
Some(path.into())
},
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: if hash.is_empty() {
None
} else {
Some(hash.into())
},
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn sample_input(server_id: &str, track_id: &str) -> PlaySessionInputDto {
PlaySessionInputDto {
server_id: server_id.into(),
track_id: track_id.into(),
started_at_ms: 1_000,
listened_sec: 20.0,
position_max_sec: 15.0,
end_reason: "ended".into(),
duration_sec_hint: None,
}
}
fn purge_play_sessions_for_server(store: &LibraryStore, server_id: &str) {
store
.with_conn_mut("test.purge_play_session", |conn| {
conn.execute(
"DELETE FROM play_session WHERE server_id = ?1",
rusqlite::params![server_id],
)?;
Ok(())
})
.expect("purge play_session");
}
#[test]
fn insert_rejects_short_sessions() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 200);
let repo = PlaySessionRepository::new(&store);
let input = PlaySessionInputDto {
server_id: "s1".into(),
track_id: "t1".into(),
started_at_ms: 1_000,
listened_sec: 10.0,
position_max_sec: 50.0,
end_reason: "ended".into(),
duration_sec_hint: None,
};
assert!(repo.insert(&input).is_err());
}
#[test]
fn insert_fails_when_track_missing() {
let store = LibraryStore::open_in_memory();
let repo = PlaySessionRepository::new(&store);
assert!(repo.insert(&sample_input("s1", "missing")).is_err());
}
#[test]
fn insert_full_vs_partial_completion() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 100);
let repo = PlaySessionRepository::new(&store);
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: "t1".into(),
started_at_ms: 1_000,
listened_sec: 80.0,
position_max_sec: 75.0,
end_reason: "ended".into(),
duration_sec_hint: None,
})
.expect("insert full");
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: "t1".into(),
started_at_ms: 2_000,
listened_sec: 30.0,
position_max_sec: 40.0,
end_reason: "skip".into(),
duration_sec_hint: None,
})
.expect("insert partial");
let summary = repo.year_summary(1970).expect("summary");
assert_eq!(summary.track_play_count, 2);
assert_eq!(summary.session_count, 1);
assert_eq!(summary.unique_track_count, 1);
assert_eq!(summary.listening_day_count, 1);
assert_eq!(summary.full_count, 1);
assert_eq!(summary.partial_count, 1);
}
#[test]
fn listening_sessions_cluster_by_idle_gap() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 200);
seed_track(&store, "s1", "t2", 200);
seed_track(&store, "s1", "t3", 200);
let repo = PlaySessionRepository::new(&store);
let base = 1_700_000_000_000_i64;
let insert = |offset_ms: i64, track_id: &str| {
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: track_id.into(),
started_at_ms: base + offset_ms,
listened_sec: 120.0,
position_max_sec: 100.0,
end_reason: "ended".into(),
duration_sec_hint: None,
})
.expect("insert");
};
insert(0, "t1");
insert(5 * 60 * 1000, "t2");
insert(10 * 60 * 1000, "t3");
insert(45 * 60 * 1000, "t1");
let year = repo
.year_bounds()
.expect("bounds")
.max_year
.expect("year with data");
let summary = repo.year_summary(year).expect("summary");
assert_eq!(summary.track_play_count, 4);
assert_eq!(summary.session_count, 2);
assert_eq!(summary.unique_track_count, 3);
assert_eq!(summary.listening_day_count, 1);
let heat = repo.heatmap(year).expect("heatmap");
assert_eq!(heat.len(), 1);
assert_eq!(heat[0].track_play_count, 4);
let days = repo.recent_days(10).expect("recent");
assert_eq!(days[0].track_play_count, 4);
assert_eq!(days[0].session_count, 2);
}
#[test]
fn year_bounds_empty_and_populated() {
let store = LibraryStore::open_in_memory();
let repo = PlaySessionRepository::new(&store);
let empty = repo.year_bounds().expect("empty bounds");
assert_eq!(empty.min_year, None);
assert_eq!(empty.max_year, None);
seed_track(&store, "s1", "t1", 200);
seed_track(&store, "s1", "t2", 200);
let insert = |started_at_ms: i64, track_id: &str| {
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: track_id.into(),
started_at_ms,
listened_sec: 20.0,
position_max_sec: 15.0,
end_reason: "ended".into(),
duration_sec_hint: None,
})
.expect("insert");
};
insert(1_577_836_800_000, "t1");
insert(1_609_459_200_000, "t2");
let bounds = repo.year_bounds().expect("bounds");
assert_eq!(bounds.min_year, Some(2020));
assert_eq!(bounds.max_year, Some(2021));
}
#[test]
fn recent_days_newest_first_with_limit() {
let store = LibraryStore::open_in_memory();
let repo = PlaySessionRepository::new(&store);
seed_track(&store, "s1", "t1", 200);
seed_track(&store, "s1", "t2", 200);
let insert = |started_at_ms: i64, track_id: &str| {
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: track_id.into(),
started_at_ms,
listened_sec: 20.0,
position_max_sec: 15.0,
end_reason: "ended".into(),
duration_sec_hint: None,
})
.expect("insert");
};
insert(1_577_836_800_000, "t1");
insert(1_609_459_200_000, "t2");
let days = repo.recent_days(30).expect("recent");
assert_eq!(days.len(), 2);
assert_eq!(days[0].date, "2021-01-01");
assert_eq!(days[1].date, "2020-01-01");
assert_eq!(days[0].session_count, 1);
assert_eq!(days[0].track_play_count, 1);
}
#[test]
fn day_detail_rejects_invalid_date() {
let store = LibraryStore::open_in_memory();
let repo = PlaySessionRepository::new(&store);
assert!(repo.day_detail("2025-13-40").is_err());
assert!(repo.day_detail("not-a-date").is_err());
}
#[test]
fn zero_index_duration_uses_hint_and_stays_partial() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 0);
let repo = PlaySessionRepository::new(&store);
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: "t1".into(),
started_at_ms: 1_000,
listened_sec: 45.0,
position_max_sec: 40.0,
end_reason: "skip".into(),
duration_sec_hint: Some(300),
})
.expect("insert");
let detail = repo.day_detail("1970-01-01").expect("detail");
assert_eq!(detail.tracks[0].completion, "partial");
}
#[test]
fn zero_duration_without_hint_is_partial_not_full() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 0);
let repo = PlaySessionRepository::new(&store);
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: "t1".into(),
started_at_ms: 1_000,
listened_sec: 45.0,
position_max_sec: 40.0,
end_reason: "skip".into(),
duration_sec_hint: None,
})
.expect("insert");
let detail = repo.day_detail("1970-01-01").expect("detail");
assert_eq!(detail.tracks[0].completion, "partial");
}
#[test]
fn corrupt_short_db_duration_prefers_player_hint() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 1);
let repo = PlaySessionRepository::new(&store);
repo.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: "t1".into(),
started_at_ms: 1_000,
listened_sec: 45.0,
position_max_sec: 40.0,
end_reason: "skip".into(),
duration_sec_hint: Some(300),
})
.expect("insert");
let detail = repo.day_detail("1970-01-01").expect("detail");
assert_eq!(detail.tracks[0].completion, "partial");
}
#[test]
fn remap_updates_play_session_track_id() {
let store = LibraryStore::open_in_memory();
let track_repo = TrackRepository::new(&store);
track_repo
.upsert_batch(&[row_with_id_hash("s1", "tr_old", "deadbeef", "/music/a.flac")])
.expect("seed old");
let play_repo = PlaySessionRepository::new(&store);
play_repo
.insert(&PlaySessionInputDto {
server_id: "s1".into(),
track_id: "tr_old".into(),
started_at_ms: 1_000,
listened_sec: 30.0,
position_max_sec: 20.0,
end_reason: "ended".into(),
duration_sec_hint: None,
})
.expect("insert play");
let stats = track_repo
.upsert_batch_with_remap(
&[row_with_id_hash("s1", "tr_new", "deadbeef", "/music/a.flac")],
true,
)
.expect("remap");
assert_eq!(stats.remapped.len(), 1);
assert_eq!(stats.remapped[0].old_id, "tr_old");
assert_eq!(stats.remapped[0].new_id, "tr_new");
let track_id: String = store
.with_conn("test.read_play_session", |conn| {
conn.query_row(
"SELECT track_id FROM play_session WHERE server_id = ?1",
rusqlite::params!["s1"],
|row| row.get(0),
)
})
.expect("read play_session");
assert_eq!(track_id, "tr_new");
}
#[test]
fn purge_deletes_play_session_rows_for_server() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", 200);
seed_track(&store, "s2", "t2", 200);
let repo = PlaySessionRepository::new(&store);
repo.insert(&sample_input("s1", "t1")).expect("s1 play");
repo.insert(&sample_input("s2", "t2")).expect("s2 play");
purge_play_sessions_for_server(&store, "s1");
let s1_count: i64 = store
.with_conn("test.count_s1", |conn| {
conn.query_row(
"SELECT COUNT(*) FROM play_session WHERE server_id = ?1",
rusqlite::params!["s1"],
|row| row.get(0),
)
})
.expect("count s1");
let s2_count: i64 = store
.with_conn("test.count_s2", |conn| {
conn.query_row(
"SELECT COUNT(*) FROM play_session WHERE server_id = ?1",
rusqlite::params!["s2"],
|row| row.get(0),
)
})
.expect("count s2");
assert_eq!(s1_count, 0);
assert_eq!(s2_count, 1);
}
@@ -365,6 +365,7 @@ fn remap_existing_to_new(
"track_fact",
"track_artifact",
"track_canonical_link",
"play_session",
] {
tx.execute(
&format!(
@@ -8,7 +8,7 @@ use tauri::Manager;
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 5;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 6;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -29,6 +29,7 @@ const MIGRATION_004_TRACK_TITLE_INDEX: &str =
include_str!("../migrations/004_track_title_index.sql");
const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str =
include_str!("../migrations/005_track_genre_year_indexes.sql");
const MIGRATION_006_PLAY_SESSION: &str = include_str!("../migrations/006_play_session.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
@@ -38,6 +39,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
(3, MIGRATION_003_TRACK_REMAP_INDEXES),
(4, MIGRATION_004_TRACK_TITLE_INDEX),
(5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES),
(6, MIGRATION_006_PLAY_SESSION),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+6
View File
@@ -594,6 +594,12 @@ pub fn run() {
psysonic_library::commands::library_patch_track,
psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_fact,
psysonic_library::commands::library_record_play_session,
psysonic_library::commands::library_get_player_stats_year_summary,
psysonic_library::commands::library_get_player_stats_heatmap,
psysonic_library::commands::library_get_player_stats_day_detail,
psysonic_library::commands::library_get_player_stats_year_bounds,
psysonic_library::commands::library_get_player_stats_recent_days,
psysonic_library::commands::library_purge_server,
psysonic_library::commands::library_delete_server_data,
psysonic_syncfs::cache::offline::download_track_offline,