//! 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, pub last_delta_sync_at: Option, pub next_poll_at: Option, pub server_last_scan_iso: Option, pub indexes_last_modified_ms: Option, pub artists_last_modified_ms: Option, pub local_track_count: Option, pub server_track_count: Option, pub last_error: Option, /// `MAX(server_updated_at)` over local non-deleted tracks — the /// implicit "tracks watermark" the N1-delta uses. pub local_tracks_max_updated_ms: Option, /// 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, /// Cursor phase during initial sync (`ingest`, `artist_pass`, …). #[serde(default)] pub ingest_phase: Option, /// Tracks ingested so far per persisted cursor (informational during IS-3). #[serde(default)] pub cursor_ingested_count: Option, /// Server flagged after N1 deep-offset failure — prefers S1/S2 on next run. #[serde(default)] pub n1_bulk_unreliable: Option, } /// 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, // Hot columns pub title: String, pub title_sort: Option, pub artist: Option, pub artist_id: Option, pub album: String, pub album_id: Option, pub album_artist: Option, pub duration_sec: i64, pub track_number: Option, pub disc_number: Option, pub year: Option, pub genre: Option, pub suffix: Option, pub bit_rate: Option, pub size_bytes: Option, pub cover_art_id: Option, pub starred_at: Option, pub user_rating: Option, pub play_count: Option, pub played_at: Option, pub server_path: Option, pub library_id: Option, pub isrc: Option, pub mbid_recording: Option, pub bpm: Option, pub replay_gain_track_db: Option, pub replay_gain_album_db: Option, pub server_updated_at: Option, pub server_created_at: Option, 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, /// 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, 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, pub content_text: Option, pub content_bytes: i64, pub not_found: bool, pub content_hash: Option, pub fetched_at: i64, pub expires_at: Option, } /// `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, pub value_int: Option, pub value_text: Option, pub unit: Option, pub source_kind: String, pub source_id: String, pub confidence: f64, pub content_hash: Option, pub fetched_at: i64, pub expires_at: Option, } /// `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, 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, } /// 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, #[serde(default)] pub content_text: Option, #[serde(default)] pub content_blob: Option>, #[serde(default)] pub content_bytes: i64, #[serde(default)] pub not_found: bool, #[serde(default)] pub content_hash: Option, #[serde(default)] pub expires_at: Option, } /// 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, #[serde(default)] pub value_int: Option, #[serde(default)] pub value_text: Option, #[serde(default)] pub unit: Option, pub source_kind: String, pub source_id: String, #[serde(default = "default_confidence")] pub confidence: f64, #[serde(default)] pub content_hash: Option, #[serde(default)] pub expires_at: Option, } 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, } /// 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, 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, } /// 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, pub max_year: Option, } /// `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, pub artist_id: Option, pub song_count: Option, pub duration_sec: Option, pub year: Option, pub genre: Option, pub cover_art_id: Option, pub starred_at: Option, 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, 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, #[serde(default)] pub value_to: Option, } /// 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, #[serde(default)] pub query: Option, pub entity_types: Vec, #[serde(default)] pub filters: Vec, #[serde(default)] pub starred_only: Option, #[serde(default)] pub sort: Vec, 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, pub albums: Vec, pub tracks: Vec, pub totals: LibrarySearchTotals, /// Distinct registry field ids that were actually applied — UI chips / /// debug. Includes `starred` when `starredOnly` is set. pub applied_filters: Vec, /// 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, #[serde(default)] pub artist_limit: Option, #[serde(default)] pub album_limit: Option, #[serde(default)] pub song_limit: Option, #[serde(default)] pub request_epoch: Option, } /// `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, pub albums: Vec, pub tracks: Vec, 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, /// 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, /// The server ids that were actually searched (resolved from the /// request's `servers` or all `ready` servers). pub servers_searched: Vec, } /// 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, 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>(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 { 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 { 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()); } }