Files
psysonic/src-tauri/crates/psysonic-library/src/dto.rs
T
cucadmuh 23f7ba02d6 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.
2026-05-22 14:07:38 +03:00

779 lines
26 KiB
Rust

//! Public DTOs the Tauri command surface returns. camelCase wire shape
//! per `src-tauri/CLAUDE.md`. PR-5a only defines what the read-only
//! commands need; PR-5b adds sync-progress / cancel-ack shapes.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::filter::{EntityKind, FilterOp};
use crate::repos::TrackRow;
use crate::store::LibraryStore;
/// `library_get_status` payload — mirrors the `sync_state` row plus a
/// few derived counters from `track`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SyncStateDto {
pub server_id: String,
pub library_scope: String,
#[serde(default)]
pub sync_phase: String,
#[serde(default)]
pub capability_flags: u32,
#[serde(default)]
pub library_tier: String,
pub last_full_sync_at: Option<i64>,
pub last_delta_sync_at: Option<i64>,
pub next_poll_at: Option<i64>,
pub server_last_scan_iso: Option<String>,
pub indexes_last_modified_ms: Option<i64>,
pub artists_last_modified_ms: Option<i64>,
pub local_track_count: Option<i64>,
pub server_track_count: Option<i64>,
pub last_error: Option<String>,
/// `MAX(server_updated_at)` over local non-deleted tracks — the
/// implicit "tracks watermark" the N1-delta uses.
pub local_tracks_max_updated_ms: Option<i64>,
/// Cheap `EXISTS` over `track` — avoids a full `COUNT(*)` on every status read.
#[serde(default)]
pub has_local_tracks: bool,
/// Active/resumed initial-sync ingest strategy (`n1` / `s1` / `s2`), if any.
#[serde(default)]
pub ingest_strategy: Option<String>,
/// Cursor phase during initial sync (`ingest`, `artist_pass`, …).
#[serde(default)]
pub ingest_phase: Option<String>,
/// Tracks ingested so far per persisted cursor (informational during IS-3).
#[serde(default)]
pub cursor_ingested_count: Option<u32>,
/// Server flagged after N1 deep-offset failure — prefers S1/S2 on next run.
#[serde(default)]
pub n1_bulk_unreliable: Option<bool>,
}
/// E3 readiness summary attached to a single-track `library_get_track` read.
/// Per-server, read-only, best-effort — never blocks on the network. Omitted
/// from list/batch reads (would be one analysis probe per row).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct TrackEnrichmentDto {
pub waveform_ready: bool,
pub loudness_ready: bool,
pub lyrics_cached: bool,
}
/// `library_get_track` / `library_search` row shape — flat projection
/// over `track`'s hot columns plus the raw JSON sub-tree. Frontend
/// re-assembles its own `LibraryTrack` shape from this.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryTrackDto {
// Ref
pub server_id: String,
pub id: String,
pub content_hash: Option<String>,
// Hot columns
pub title: String,
pub title_sort: Option<String>,
pub artist: Option<String>,
pub artist_id: Option<String>,
pub album: String,
pub album_id: Option<String>,
pub album_artist: Option<String>,
pub duration_sec: i64,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub year: Option<i64>,
pub genre: Option<String>,
pub suffix: Option<String>,
pub bit_rate: Option<i64>,
pub size_bytes: Option<i64>,
pub cover_art_id: Option<String>,
pub starred_at: Option<i64>,
pub user_rating: Option<i64>,
pub play_count: Option<i64>,
pub played_at: Option<i64>,
pub server_path: Option<String>,
pub library_id: Option<String>,
pub isrc: Option<String>,
pub mbid_recording: Option<String>,
pub bpm: Option<i64>,
pub replay_gain_track_db: Option<f64>,
pub replay_gain_album_db: Option<f64>,
pub server_updated_at: Option<i64>,
pub server_created_at: Option<i64>,
pub synced_at: i64,
/// E3 readiness summary. Only populated by `library_get_track`; `None`
/// (omitted on the wire) for list/batch projections via `from_row`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enrichment: Option<TrackEnrichmentDto>,
/// Original Subsonic / Navidrome song JSON the sync engine stored.
/// Frontend parses this lazily when it needs OpenSubsonic extras
/// (contributors, replayGain detail, …).
pub raw_json: Value,
}
impl LibraryTrackDto {
pub fn from_row(row: &TrackRow) -> Self {
let raw_json: Value = serde_json::from_str(&row.raw_json).unwrap_or(Value::Null);
Self {
server_id: row.server_id.clone(),
id: row.id.clone(),
content_hash: row.content_hash.clone(),
title: row.title.clone(),
title_sort: row.title_sort.clone(),
artist: row.artist.clone(),
artist_id: row.artist_id.clone(),
album: row.album.clone(),
album_id: row.album_id.clone(),
album_artist: row.album_artist.clone(),
duration_sec: row.duration_sec,
track_number: row.track_number,
disc_number: row.disc_number,
year: row.year,
genre: row.genre.clone(),
suffix: row.suffix.clone(),
bit_rate: row.bit_rate,
size_bytes: row.size_bytes,
cover_art_id: row.cover_art_id.clone(),
starred_at: row.starred_at,
user_rating: row.user_rating,
play_count: row.play_count,
played_at: row.played_at,
server_path: row.server_path.clone(),
library_id: row.library_id.clone(),
isrc: row.isrc.clone(),
mbid_recording: row.mbid_recording.clone(),
bpm: row.bpm,
replay_gain_track_db: row.replay_gain_track_db,
replay_gain_album_db: row.replay_gain_album_db,
server_updated_at: row.server_updated_at,
server_created_at: row.server_created_at,
synced_at: row.synced_at,
enrichment: None,
raw_json,
}
}
}
/// `library_get_tracks_batch` / `library_search` envelope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryTracksEnvelope {
pub tracks: Vec<LibraryTrackDto>,
pub total: u32,
}
/// `library_get_artifact` payload — one row of `track_artifact`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TrackArtifactDto {
pub server_id: String,
pub track_id: String,
pub artifact_kind: String,
pub format: String,
pub source_kind: String,
pub source_id: String,
pub language: Option<String>,
pub content_text: Option<String>,
pub content_bytes: i64,
pub not_found: bool,
pub content_hash: Option<String>,
pub fetched_at: i64,
pub expires_at: Option<i64>,
}
/// `library_get_facts` row.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TrackFactDto {
pub server_id: String,
pub track_id: String,
pub fact_kind: String,
pub value_real: Option<f64>,
pub value_int: Option<i64>,
pub value_text: Option<String>,
pub unit: Option<String>,
pub source_kind: String,
pub source_id: String,
pub confidence: f64,
pub content_hash: Option<String>,
pub fetched_at: i64,
pub expires_at: Option<i64>,
}
/// `library_get_offline_path` outcome — either a path string or a
/// `missing` flag so the frontend can show a hint without polling.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct OfflinePathDto {
pub server_id: String,
pub track_id: String,
pub local_path: Option<String>,
pub missing: bool,
}
/// Compact track reference used as input by `library_get_tracks_batch`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
pub struct TrackRefDto {
pub server_id: String,
pub track_id: String,
#[serde(default)]
pub content_hash: Option<String>,
}
/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto`
/// minus the server-supplied `server_id` / `track_id` (provided as
/// command args) and `fetched_at` (stamped server-side from `now`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactInputDto {
pub artifact_kind: String,
pub format: String,
pub source_kind: String,
pub source_id: String,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub content_text: Option<String>,
#[serde(default)]
pub content_blob: Option<Vec<u8>>,
#[serde(default)]
pub content_bytes: i64,
#[serde(default)]
pub not_found: bool,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub expires_at: Option<i64>,
}
/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the
/// indices.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FactInputDto {
pub fact_kind: String,
#[serde(default)]
pub value_real: Option<f64>,
#[serde(default)]
pub value_int: Option<i64>,
#[serde(default)]
pub value_text: Option<String>,
#[serde(default)]
pub unit: Option<String>,
pub source_kind: String,
pub source_id: String,
#[serde(default = "default_confidence")]
pub confidence: f64,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub expires_at: Option<i64>,
}
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")]
pub struct PurgeReportDto {
pub tracks_deleted: u32,
pub albums_deleted: u32,
pub artists_deleted: u32,
pub offline_rows_deleted: u32,
/// Total bytes freed across the purged scopes (best-effort).
pub bytes_freed: i64,
}
/// `library_sync_start` ack.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SyncJobDto {
pub job_id: String,
pub server_id: String,
/// `"initial_sync"` or `"delta_sync"`.
pub kind: String,
}
// ──────────────────────────────────────────────────────────────────────
// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B)
// ──────────────────────────────────────────────────────────────────────
/// `library_advanced_search` row shape for an album. Flat projection over
/// the `album` hot columns plus the raw JSON sub-tree (mirrors
/// `LibraryTrackDto`'s lazy-parse contract).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumDto {
pub server_id: String,
pub id: String,
pub name: String,
pub artist: Option<String>,
pub artist_id: Option<String>,
pub song_count: Option<i64>,
pub duration_sec: Option<i64>,
pub year: Option<i64>,
pub genre: Option<String>,
pub cover_art_id: Option<String>,
pub starred_at: Option<i64>,
pub synced_at: i64,
pub raw_json: Value,
}
/// `library_advanced_search` row shape for an artist.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryArtistDto {
pub server_id: String,
pub id: String,
pub name: String,
pub album_count: Option<i64>,
pub synced_at: i64,
pub raw_json: Value,
}
/// One filter predicate. `field` is a `FilterFieldRegistry` id (§5.13.3),
/// `op` the comparison, `value` / `valueTo` the operands. `between` uses
/// both bounds (inclusive); scalar ops use `value` only; `isTrue` ignores
/// the value side.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryFilterClause {
pub field: String,
pub op: FilterOp,
#[serde(default)]
pub value: Option<Value>,
#[serde(default)]
pub value_to: Option<Value>,
}
/// One sort key. `field` is a registry id; `dir` is `asc` / `desc`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibrarySortClause {
pub field: String,
pub dir: SortDir,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SortDir {
Asc,
Desc,
}
/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an
/// `fts` clause on the text fields; `entityTypes` controls which of the
/// three queries run; `filters` are combined with AND.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAdvancedSearchRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
#[serde(default)]
pub filters: Vec<LibraryFilterClause>,
#[serde(default)]
pub starred_only: Option<bool>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
pub limit: u32,
#[serde(default)]
pub offset: u32,
/// When true, skip per-entity COUNT queries (Live Search / small pages).
#[serde(default)]
pub skip_totals: bool,
}
/// Per-entity result counts (full match count, not page size).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LibrarySearchTotals {
pub artists: u32,
pub albums: u32,
pub tracks: u32,
}
/// `library_advanced_search` response (§5.13.2).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAdvancedSearchResponse {
pub artists: Vec<LibraryArtistDto>,
pub albums: Vec<LibraryAlbumDto>,
pub tracks: Vec<LibraryTrackDto>,
pub totals: LibrarySearchTotals,
/// Distinct registry field ids that were actually applied — UI chips /
/// debug. Includes `starred` when `starredOnly` is set.
pub applied_filters: Vec<String>,
/// Always `"local"` from this command (it queries the local index); the
/// frontend's fallback decides local vs network (§5.13.6).
pub source: String,
}
/// `library_live_search` response — lean FTS dropdown (§5.9 / P24).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryLiveSearchRequest {
pub server_id: String,
pub query: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub artist_limit: Option<u32>,
#[serde(default)]
pub album_limit: Option<u32>,
#[serde(default)]
pub song_limit: Option<u32>,
#[serde(default)]
pub request_epoch: Option<u64>,
}
/// `library_live_search` response — lean FTS dropdown (§5.9 / P24).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryLiveSearchResponse {
pub artists: Vec<LibraryArtistDto>,
pub albums: Vec<LibraryAlbumDto>,
pub tracks: Vec<LibraryTrackDto>,
pub source: String,
}
/// `library_search_cross_server` response (§5.5B / §5.9).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryCrossServerSearchResponse {
/// Primary FTS-union hits, deduped by canonical id where a link exists.
pub hits: Vec<LibraryTrackDto>,
/// Fuzzy fallback (§5.9 / H3): per-server `title LIKE` matches that the
/// exact FTS pass missed (diacritics, partial words). Excludes anything
/// already in `hits` and dedupes by canonical id against them.
pub fuzzy: Vec<LibraryTrackDto>,
/// The server ids that were actually searched (resolved from the
/// request's `servers` or all `ready` servers).
pub servers_searched: Vec<String>,
}
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
/// Settings without a separate column.
pub fn local_tracks_max_updated_ms(
store: &LibraryStore,
server_id: &str,
) -> Result<Option<i64>, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT MAX(server_updated_at) FROM track \
WHERE server_id = ?1 AND deleted = 0",
rusqlite::params![server_id],
|row| row.get::<_, Option<i64>>(0),
)
})
.map_err(|e| e.to_string())
}
/// Cheap `EXISTS` — true when at least one non-deleted track is indexed.
pub fn track_index_nonempty(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT EXISTS(SELECT 1 FROM track WHERE server_id = ?1 AND deleted = 0 LIMIT 1)",
rusqlite::params![server_id],
|row| row.get(0),
)
})
.map_err(|e| e.to_string())
}
/// Live non-deleted track count for a server (used when the sync_state
/// snapshot is missing or stale).
pub fn count_local_tracks(store: &LibraryStore, server_id: &str) -> Result<i64, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0",
rusqlite::params![server_id],
|row| row.get(0),
)
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::TrackRepository;
fn sample_row() -> TrackRow {
TrackRow {
server_id: "s1".into(),
id: "tr_1".into(),
title: "Hello".into(),
title_sort: None,
artist: Some("World".into()),
artist_id: Some("ar_1".into()),
album: "An Album".into(),
album_id: Some("al_1".into()),
album_artist: Some("World".into()),
duration_sec: 240,
track_number: Some(3),
disc_number: Some(1),
year: Some(2024),
genre: Some("Ambient".into()),
suffix: Some("flac".into()),
bit_rate: Some(1000),
size_bytes: Some(32_000_000),
cover_art_id: Some("cv_1".into()),
starred_at: None,
user_rating: None,
play_count: Some(0),
played_at: None,
server_path: Some("/path/x.flac".into()),
library_id: Some("1".into()),
isrc: Some("USRC17607839".into()),
mbid_recording: Some("mb-1".into()),
bpm: Some(120),
replay_gain_track_db: Some(-1.2),
replay_gain_album_db: Some(-0.8),
content_hash: Some("deadbeef".into()),
server_updated_at: Some(1_700_000_000),
server_created_at: Some(1_699_000_000),
deleted: false,
synced_at: 1_700_000_500,
raw_json: r#"{"replayGain":{"trackGain":-1.2}}"#.into(),
}
}
#[test]
fn library_track_dto_serializes_field_names_camel_case() {
let dto = LibraryTrackDto::from_row(&sample_row());
let json = serde_json::to_value(&dto).unwrap();
// Spot-check critical wire keys — IPC contract.
for key in [
"serverId",
"contentHash",
"albumArtist",
"durationSec",
"trackNumber",
"discNumber",
"coverArtId",
"userRating",
"playCount",
"playedAt",
"serverPath",
"libraryId",
"mbidRecording",
"replayGainTrackDb",
"replayGainAlbumDb",
"serverUpdatedAt",
"syncedAt",
"rawJson",
] {
assert!(
json.get(key).is_some(),
"expected camelCase key `{key}` in serialized DTO, got {json}"
);
}
}
#[test]
fn library_track_dto_parses_raw_json_into_value() {
let dto = LibraryTrackDto::from_row(&sample_row());
let rg = dto
.raw_json
.get("replayGain")
.and_then(|v| v.get("trackGain"))
.and_then(|v| v.as_f64())
.unwrap();
assert!((rg - -1.2).abs() < 0.001);
}
#[test]
fn library_track_dto_falls_back_to_null_on_invalid_raw_json() {
let mut row = sample_row();
row.raw_json = "{not valid json}".into();
let dto = LibraryTrackDto::from_row(&row);
assert!(dto.raw_json.is_null(), "invalid JSON must surface as Value::Null");
}
#[test]
fn local_tracks_max_updated_returns_max_over_non_deleted_rows() {
let store = LibraryStore::open_in_memory();
let repo = TrackRepository::new(&store);
let mut r1 = sample_row();
r1.server_updated_at = Some(1_000);
let mut r2 = sample_row();
r2.id = "tr_2".into();
r2.server_updated_at = Some(3_000);
let mut r3 = sample_row();
r3.id = "tr_3".into();
r3.server_updated_at = Some(5_000);
r3.deleted = true;
repo.upsert_batch(&[r1, r2, r3]).unwrap();
assert_eq!(
local_tracks_max_updated_ms(&store, "s1").unwrap(),
Some(3_000),
"deleted rows must be excluded"
);
}
#[test]
fn count_local_tracks_matches_non_deleted_rows() {
let store = LibraryStore::open_in_memory();
let repo = TrackRepository::new(&store);
let mut deleted = sample_row();
deleted.id = "tr_del".into();
deleted.deleted = true;
repo.upsert_batch(&[sample_row(), deleted]).unwrap();
assert_eq!(count_local_tracks(&store, "s1").unwrap(), 1);
}
#[test]
fn track_ref_dto_roundtrips_through_json() {
let r = TrackRefDto {
server_id: "s1".into(),
track_id: "tr_1".into(),
content_hash: Some("h".into()),
};
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json.get("serverId").and_then(|v| v.as_str()), Some("s1"));
let back: TrackRefDto = serde_json::from_value(json).unwrap();
assert_eq!(back, r);
}
#[test]
fn sync_state_dto_omits_null_optionals_cleanly() {
let dto = SyncStateDto {
server_id: "s1".into(),
library_scope: "".into(),
sync_phase: "idle".into(),
capability_flags: 0,
library_tier: "unknown".into(),
last_full_sync_at: None,
last_delta_sync_at: None,
next_poll_at: None,
server_last_scan_iso: None,
indexes_last_modified_ms: None,
artists_last_modified_ms: None,
local_track_count: None,
server_track_count: None,
last_error: None,
local_tracks_max_updated_ms: None,
has_local_tracks: false,
ingest_strategy: None,
ingest_phase: None,
cursor_ingested_count: None,
n1_bulk_unreliable: None,
};
let json = serde_json::to_value(dto).unwrap();
assert_eq!(
json.get("syncPhase").and_then(|v| v.as_str()),
Some("idle")
);
// `null` survives as JSON null, not omitted — explicit shape
// for the WebView so it can distinguish "missing" from
// "unset".
assert!(json.get("lastFullSyncAt").unwrap().is_null());
}
}