mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
4ac373a65b
* feat(artists): scoped live search badge replaces page filter Move Artists browse text search into the header Live Search with a page scope badge (Users icon), field-local undo, and double-click/backspace to clear scope. Block the live-search dropdown while scoped so results only filter the Artists grid; mobile overlay follows the same rules. * fix(artists): plain grid for scoped search fixes broken card layout Route the browse grid through VirtualCardGrid, switch to non-virtual CSS grid when live search filters the catalog, reset scroll on filter changes, and skip content-visibility on plain tiles to avoid blank/black cards. * fix(search): scope badge double-Backspace and single clear control Require two Backspaces on an empty scoped field after prior text input; one Backspace still clears the badge when the field was never filled. Move live-search clear/advanced controls inside the field pill, drop the extra outer clear button, and use type=text to avoid native search clears. * fix(search): drop duplicate outer live-search clear button Keep the native in-field clear on type=search and the original pill layout; remove only the extra × control outside the search border. Reset dropdown state when the query is cleared via the native control. * refactor(search): generic scoped browse query helper, drop dead code Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an expectedScope argument; wire Artists via useScopedBrowseSearchQuery. Remove unused liveSearchScoped dropdown helper (scoped mode blocks it). * feat(search): ghost scope badge and single-click badge remove After clearing the artists scope on /artists, show a faded ghost chip to restore page-only search while keeping the global search placeholder. Active badge removes on one click; tooltips and styles updated. * feat(search): scoped live search for All Albums and New Releases Wire albums and newReleases scope badges with debounced album title search (local index title-only FTS + filtered search3). Plain grid, scroll reset, and session query restore on album grid browse pages. * fix(browse): preserve scroll restore after album/artist detail back Only reset in-page scroll when filter resetKey changes, not when isScrollRestorePending clears after session restore. * feat(search): scoped live search for Tracks browse Wire /tracks to header live search with wide title/artist/album FTS, hide hero and discovery rails while search is active, and remove the inline search field from the browse list. * fix(search): clear header query when leaving scoped browse pages Prevent global live search from firing on album/detail routes after a scoped browse query; browse session stashes still restore on back. * fix(tracks): restore scroll after back from detail during scoped search Hold stashed song results across fetchSongPage churn, defer leave-stash teardown past AppShell scroll reset, restore tracks scroll after the list is ready, and save scroll snapshot when opening artist from song context menu. * fix(tracks): hide discovery headings during scoped search Hide the page subtitle and "Browse all tracks" section title when tracks search is active, matching hero/rails chrome behavior. * feat(search): scoped live search for Composers browse Wire /composers to header live search with composers scope badge, session stash, scroll restore, and plain grid/list during text filter. Remove the in-page filter input; add i18n and navigation helpers. * docs: CHANGELOG and credits for scoped browse live search (PR #938)
885 lines
30 KiB
Rust
885 lines
30 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>,
|
|
/// `'analysis'` | `'tag'` — only on Advanced Search rows with BPM dual-storage projection.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub bpm_source: Option<String>,
|
|
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,
|
|
bpm_source: None,
|
|
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>,
|
|
}
|
|
|
|
/// Min/max `year` from indexed tracks for a server (Albums year filter UI).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CatalogYearBoundsDto {
|
|
pub min_year: Option<i32>,
|
|
pub max_year: Option<i32>,
|
|
}
|
|
|
|
/// Per-genre album/track totals from the local track catalog (Genres cloud + browse).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GenreAlbumCountDto {
|
|
pub value: String,
|
|
pub album_count: u32,
|
|
pub song_count: u32,
|
|
}
|
|
|
|
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LibraryGenreAlbumsRequest {
|
|
pub server_id: String,
|
|
pub genre: String,
|
|
#[serde(default)]
|
|
pub library_scope: Option<String>,
|
|
#[serde(default)]
|
|
pub sort: Vec<LibrarySortClause>,
|
|
#[serde(default = "default_genre_album_limit")]
|
|
pub limit: u32,
|
|
#[serde(default)]
|
|
pub offset: u32,
|
|
#[serde(default)]
|
|
pub include_total: bool,
|
|
}
|
|
|
|
fn default_genre_album_limit() -> u32 {
|
|
50
|
|
}
|
|
|
|
/// `library_list_albums_by_genre` response.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LibraryGenreAlbumsResponse {
|
|
pub albums: Vec<LibraryAlbumDto>,
|
|
pub has_more: bool,
|
|
pub total: Option<u32>,
|
|
pub source: String,
|
|
}
|
|
|
|
/// `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>,
|
|
/// When set, album browse is limited to these ids (e.g. server `getStarred2`
|
|
/// intersected with local lossless / genre filters). Not combined with
|
|
/// `starred_only` — use one or the other.
|
|
#[serde(default)]
|
|
pub restrict_album_ids: Option<Vec<String>>,
|
|
/// When true, album text search matches title/name only (not album artist).
|
|
#[serde(default)]
|
|
pub query_album_title_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_list_lossless_albums` request — paginated lossless browse (local index).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LibraryLosslessAlbumsRequest {
|
|
pub server_id: String,
|
|
#[serde(default)]
|
|
pub library_scope: Option<String>,
|
|
#[serde(default = "default_lossless_limit")]
|
|
pub limit: u32,
|
|
#[serde(default)]
|
|
pub offset: u32,
|
|
}
|
|
|
|
fn default_lossless_limit() -> u32 {
|
|
30
|
|
}
|
|
|
|
/// `library_list_lossless_albums` response.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LibraryLosslessAlbumsResponse {
|
|
pub albums: Vec<LibraryAlbumDto>,
|
|
pub has_more: bool,
|
|
pub source: String,
|
|
}
|
|
|
|
/// `library_get_artist_lossless_browse` request.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LibraryArtistLosslessBrowseRequest {
|
|
pub server_id: String,
|
|
pub artist_id: String,
|
|
#[serde(default)]
|
|
pub library_scope: Option<String>,
|
|
}
|
|
|
|
/// Lossless albums + tracks for one artist (local index).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LibraryArtistLosslessBrowseResponse {
|
|
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());
|
|
}
|
|
}
|