db/lib.rs
1//! Kopuz persistence layer (issue #347).
2//!
3//! Owns the SQLite schema and all persistence behind a single async [`Storage`]
4//! trait. Native targets implement it with sqlx; wasm (not a shipped target)
5//! gets a thin in-memory stub so the build stays green. Everything above this
6//! crate (reactive hooks, UI) is driver-agnostic.
7//!
8//! Dependency direction: `db` sits ABOVE `config`/`reader` (it persists their
9//! types), so those crates stay pure model definitions and all save/load lives
10//! here.
11
12use std::sync::Arc;
13
14mod backend;
15
16/// What a one-shot legacy-JSON import did. `ran == false` means it was skipped
17/// (already migrated, or no legacy JSON present); the counts are then all zero.
18#[derive(Debug, Default, Clone)]
19pub struct ImportReport {
20 pub ran: bool,
21 pub tracks: usize,
22 pub albums: usize,
23 pub playlists: usize,
24 pub favorites: usize,
25 pub servers: usize,
26}
27
28// `Source` is defined in `config` (the active source lives there) and is the
29// single type-safe representation of "which source"; re-exported here since the
30// DB layer is its main consumer (`WHERE source = ?`).
31pub use config::Source;
32
33/// A window into a list query (for virtual-scrolled big lists).
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct Page {
36 pub offset: u32,
37 pub limit: u32,
38}
39
40/// The queue/progress snapshot, reconstructed from the `queue_state` row. The
41/// in-memory `PersistedQueueState` (in the app crate) maps directly from this.
42#[derive(Clone, Debug, Default)]
43pub struct QueueSnapshot {
44 pub version: u8,
45 pub queue: Vec<reader::Track>,
46 pub current_queue_index: usize,
47 pub progress_secs: u64,
48 pub shuffle_order: Vec<usize>,
49 pub shuffle_enabled: bool,
50}
51
52/// Sort order for a track listing — maps to an indexed `ORDER BY`.
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
54pub enum TrackSort {
55 /// Artist → album → disc → track (the natural library order).
56 #[default]
57 ArtistAlbum,
58 Title,
59 Artist,
60 Album,
61 /// Most-recently-added first (insertion order).
62 DateAdded,
63 /// Most-played first (`listen_counts` join), ties by title.
64 PlayCount,
65}
66
67/// What a windowed track listing selects: which source, how it's sorted, and
68/// an optional case-insensitive search across title/artist/album. Drives
69/// `WHERE`/`ORDER BY` so only the needed rows are materialized. Narrower
70/// listings (one album, one artist, one genre, a folder) have dedicated
71/// `Storage` methods instead of filter fields — there is deliberately no way
72/// to pull a whole source and filter it in memory.
73#[derive(Clone, Debug, Default, PartialEq, Eq)]
74pub struct TrackFilter {
75 pub source: Source,
76 pub sort: TrackSort,
77 pub search: String,
78}
79
80impl TrackFilter {
81 pub fn new(source: Source) -> Self {
82 Self {
83 source,
84 ..Default::default()
85 }
86 }
87}
88
89/// Errors surfaced by the storage layer. String-wrapped so the type is identical
90/// on native and wasm (sqlx isn't compiled for wasm).
91#[derive(Debug, Clone)]
92pub enum DbError {
93 Backend(String),
94 Serde(String),
95 Io(String),
96}
97
98impl std::fmt::Display for DbError {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 DbError::Backend(e) => write!(f, "db backend: {e}"),
102 DbError::Serde(e) => write!(f, "db serde: {e}"),
103 DbError::Io(e) => write!(f, "db io: {e}"),
104 }
105 }
106}
107
108impl std::error::Error for DbError {}
109
110impl From<serde_json::Error> for DbError {
111 fn from(e: serde_json::Error) -> Self {
112 DbError::Serde(e.to_string())
113 }
114}
115
116impl From<sqlx::Error> for DbError {
117 fn from(e: sqlx::Error) -> Self {
118 DbError::Backend(e.to_string())
119 }
120}
121
122impl From<sqlx::migrate::MigrateError> for DbError {
123 fn from(e: sqlx::migrate::MigrateError) -> Self {
124 DbError::Backend(e.to_string())
125 }
126}
127
128/// Per-artist images, source-agnostic: `(overrides, photos)`. `overrides` are
129/// user-set custom photos (always a local path, highest priority); `photos` are
130/// the synced photo per artist as a uniform [`reader::ArtistImageRef`] (a server
131/// URL or a local path — server wins when both exist), resolved by the cover
132/// seam so callers never branch on origin. Both keyed by normalized artist name.
133pub type ArtistImages = (
134 std::collections::HashMap<String, std::path::PathBuf>,
135 std::collections::HashMap<String, reader::ArtistImageRef>,
136);
137
138/// The read side of the persistence API — every query, no mutation. Carried as
139/// a supertrait of [`Storage`], so any `dyn Storage` is also a `dyn ReadStore`.
140#[async_trait::async_trait]
141pub trait ReadStore: Send + Sync {
142 /// Load the persisted `AppConfig` (the single-row JSON blob), or `None` if
143 /// the app has never been configured.
144 async fn load_config(&self) -> Result<Option<config::AppConfig>, DbError>;
145
146 /// One window of a track listing (sorted + filtered in SQL — only this slice
147 /// is materialized).
148 async fn tracks_page(
149 &self,
150 filter: &TrackFilter,
151 page: Page,
152 ) -> Result<Vec<reader::Track>, DbError>;
153
154 /// Total rows a `tracks_page` filter matches (for the scroll spacer).
155 async fn tracks_count(&self, filter: &TrackFilter) -> Result<u32, DbError>;
156
157 /// One album's tracks, disc/track-ordered.
158 async fn album_tracks(
159 &self,
160 source: &Source,
161 album_id: &str,
162 ) -> Result<Vec<reader::Track>, DbError>;
163
164 /// One artist's tracks, album/disc/track-ordered.
165 async fn artist_tracks(
166 &self,
167 source: &Source,
168 artist: &str,
169 ) -> Result<Vec<reader::Track>, DbError>;
170
171 /// Tracks whose album has this genre, artist/album-ordered.
172 async fn genre_tracks(
173 &self,
174 source: &Source,
175 genre: &str,
176 ) -> Result<Vec<reader::Track>, DbError>;
177
178 /// Local tracks under a directory (path-prefix match), path-ordered.
179 async fn folder_tracks(&self, prefix: &str) -> Result<Vec<reader::Track>, DbError>;
180
181 /// This source's recently-played track keys, newest first (capped).
182 async fn recently_played(&self, source: &Source, limit: u32) -> Result<Vec<String>, DbError>;
183
184 /// One representative (first-inserted) track per artist, artist A→Z — for
185 /// artist tiles that need a cover without pulling the whole source.
186 async fn artist_sample_tracks(
187 &self,
188 source: &Source,
189 limit: u32,
190 ) -> Result<Vec<reader::Track>, DbError>;
191
192 /// The genre with the highest summed play count for a source, if any.
193 async fn top_genre(&self, source: &Source) -> Result<Option<String>, DbError>;
194
195 /// Every track of a source — ONLY for full-text search, which needs the
196 /// corpus because its Unicode-aware matching can't be expressed as SQLite
197 /// `LIKE` (ASCII-only case folding). Runs on demand when a query is typed,
198 /// never on page mount. Nothing else may pull a whole source.
199 async fn search_corpus(&self, source: &Source) -> Result<Vec<reader::Track>, DbError>;
200
201 /// Resolve tracks by `track_key`, preserving the input order (recents,
202 /// playlist membership). Missing keys are skipped.
203 async fn tracks_by_keys(
204 &self,
205 source: &Source,
206 keys: &[String],
207 ) -> Result<Vec<reader::Track>, DbError>;
208
209 /// Distinct artists for a source with their track counts, A→Z.
210 async fn artists(&self, source: &Source) -> Result<Vec<(String, u32)>, DbError>;
211
212 /// Distinct non-empty album genres for a source, A→Z.
213 async fn genres(&self, source: &Source) -> Result<Vec<String>, DbError>;
214
215 /// One album by id.
216 async fn album(
217 &self,
218 source: &Source,
219 album_id: &str,
220 ) -> Result<Option<reader::Album>, DbError>;
221
222 /// Per-artist images: `(overrides, photos)` — see [`ArtistImages`].
223 async fn artist_images(&self) -> Result<ArtistImages, DbError>;
224
225 /// All albums for a source, ordered by artist then title.
226 async fn albums(&self, source: &Source) -> Result<Vec<reader::Album>, DbError>;
227
228 /// Reconstruct the queue/progress snapshot from the `queue_state` row.
229 async fn load_queue(&self) -> Result<QueueSnapshot, DbError>;
230
231 /// The `PlaylistStore` (the active source's playlists + folders) — the read
232 /// side of the playlists UI (`use_playlists`). Writes go through the
233 /// playlist-scoped ops, never a whole-store save. Scoped to `source`, the
234 /// caller's in-memory active source.
235 async fn load_playlists(&self, source: &Source) -> Result<reader::PlaylistStore, DbError>;
236
237 /// Hydrate one server row (creds included) into the in-memory shape — used
238 /// by server switching so stored creds are reused instead of re-prompting.
239 async fn load_server(&self, id: &str) -> Result<Option<config::MusicServer>, DbError>;
240
241 /// Generic metadata-cache read (`metadata_cache` table): the `payload` for
242 /// `(cache_key, kind)`, if cached.
243 async fn meta_get(&self, cache_key: &str, kind: &str) -> Result<Option<String>, DbError>;
244
245 /// The favorite refs (`track_key`s) for a server (`"local"` for filesystem).
246 async fn favorites(&self, server_id: &str) -> Result<Vec<String>, DbError>;
247
248 /// Whether `ref_` is favorited under `server_id`.
249 async fn is_favorite(&self, server_id: &str, ref_: &str) -> Result<bool, DbError>;
250
251 /// Pending-like refs (`dirty=1`) not yet pushed to the server.
252 async fn dirty_favorites(&self, server_id: &str) -> Result<Vec<String>, DbError>;
253
254 /// Pending-unlike tombstones (`dirty=2`) not yet pushed to the server.
255 async fn dirty_unlikes(&self, server_id: &str) -> Result<Vec<String>, DbError>;
256}
257
258/// The persistence API: every mutation plus admin/dev ops, layered on top of the
259/// read-only [`ReadStore`]. One impl per target (sqlx native / in-mem stub).
260#[async_trait::async_trait]
261pub trait Storage: ReadStore {
262 /// Persist the whole `AppConfig` as the single-row JSON blob.
263 async fn save_config(&self, cfg: &config::AppConfig) -> Result<(), DbError>;
264
265 /// One-shot import of the legacy `*.json` store at `config_dir` into the DB,
266 /// then rename each imported file to `*.json.bak` and drop a sentinel. No-op
267 /// if the DB already holds data or the sentinel exists. Idempotent; safe to
268 /// call on every launch. (Native only; the wasm stub no-ops.)
269 async fn import_legacy_json(
270 &self,
271 config_dir: &std::path::Path,
272 ) -> Result<ImportReport, DbError>;
273
274 /// Point of no return: rename each imported `X.json` → `X.json.bak` (kept for
275 /// downgrade). Call only once every domain reads from the DB. Idempotent;
276 /// no-op until a real import has happened. Returns how many files moved.
277 async fn finalize_migration(&self, config_dir: &std::path::Path) -> Result<usize, DbError>;
278
279 /// Delete tracks by key for a source. Returns rows removed.
280 async fn delete_tracks(&self, source: &Source, keys: &[String]) -> Result<u64, DbError>;
281
282 /// Delete an album AND its tracks (matches the legacy `Library::remove_album`).
283 async fn delete_album(&self, source: &Source, album_id: &str) -> Result<(), DbError>;
284
285 /// After a full sync: drop this source's tracks/albums that were NOT in the
286 /// sync (`keep_*` = the synced identities). The sync-side replacement for
287 /// the old clear-and-repopulate.
288 async fn prune_source(
289 &self,
290 source: &Source,
291 keep_track_keys: &[String],
292 keep_album_ids: &[String],
293 ) -> Result<(), DbError>;
294
295 /// Set (`Some`) or remove (`None`) one artist image. `kind` is
296 /// `"server" | "local" | "custom"`.
297 async fn set_artist_image(
298 &self,
299 artist_norm: &str,
300 kind: &str,
301 image_ref: Option<&str>,
302 ) -> Result<(), DbError>;
303
304 /// Set/clear an album's cover (manual covers survive non-manual updates).
305 async fn update_album_cover(
306 &self,
307 source: &Source,
308 album_id: &str,
309 cover_path: Option<&str>,
310 manual: bool,
311 ) -> Result<(), DbError>;
312
313 /// Upsert one playlist's metadata (name/cover/image_tag), keeping membership.
314 async fn upsert_playlist_meta(
315 &self,
316 source: &Source,
317 pl_id: &str,
318 name: &str,
319 cover_path: Option<&str>,
320 image_tag: Option<&str>,
321 ) -> Result<(), DbError>;
322
323 /// Delete one playlist (membership cascades).
324 async fn delete_playlist(&self, source: &Source, pl_id: &str) -> Result<(), DbError>;
325
326 /// Replace ONE playlist's membership (creates the playlist row if absent).
327 /// For reorders and full rebuilds; prefer the incremental variants below for
328 /// single add/remove so a big playlist isn't rewritten wholesale.
329 async fn set_playlist_tracks(
330 &self,
331 source: &Source,
332 pl_id: &str,
333 refs: &[String],
334 ) -> Result<(), DbError>;
335
336 /// Append refs to one playlist (creating it if absent), skipping any already
337 /// present so a track is never duplicated.
338 async fn add_playlist_tracks(
339 &self,
340 source: &Source,
341 pl_id: &str,
342 refs: &[String],
343 ) -> Result<(), DbError>;
344
345 /// Remove every occurrence of each ref from one playlist. No-op for absent
346 /// playlists/refs.
347 async fn remove_playlist_tracks(
348 &self,
349 source: &Source,
350 pl_id: &str,
351 refs: &[String],
352 ) -> Result<(), DbError>;
353
354 /// Streaming upsert of one page of a playlist's entries (creating the playlist
355 /// row if absent): each ref is written at `start_position + i` and stamped with
356 /// the current walk's `epoch`. On position conflict the ref and epoch are
357 /// overwritten — so re-walking in order applies adds, reorders, and (with the
358 /// trailing sweep) removals, all without rewriting the whole list up front.
359 async fn upsert_playlist_tracks_page(
360 &self,
361 source: &Source,
362 pl_id: &str,
363 refs: &[String],
364 start_position: i64,
365 epoch: i64,
366 ) -> Result<(), DbError>;
367
368 /// End-of-walk sweep: drop one playlist's rows NOT re-stamped with `epoch` —
369 /// entries removed remotely, plus the stale tail when the playlist shrank.
370 async fn sweep_playlist_tracks(
371 &self,
372 source: &Source,
373 pl_id: &str,
374 epoch: i64,
375 ) -> Result<(), DbError>;
376
377 /// Create one (local) playlist folder.
378 async fn create_folder(&self, id: &str, name: &str) -> Result<(), DbError>;
379
380 /// Rename one folder.
381 async fn rename_folder(&self, id: &str, name: &str) -> Result<(), DbError>;
382
383 /// Delete one folder; its playlist memberships cascade away.
384 async fn delete_folder(&self, id: &str) -> Result<(), DbError>;
385
386 /// Move one playlist into `folder_id`, or out of every folder when `None`.
387 /// Folder membership is single-folder per playlist.
388 async fn set_playlist_folder(
389 &self,
390 playlist_ref: &str,
391 folder_id: Option<&str>,
392 ) -> Result<(), DbError>;
393
394 /// Increment one track's play count (single-row upsert; key = `TrackId::uid()`).
395 async fn bump_listen_count(&self, track_uid: &str) -> Result<(), DbError>;
396
397 /// Record a play for this source's recently-played history (caps + trims).
398 async fn push_recent(&self, source: &Source, track_key: &str) -> Result<(), DbError>;
399
400 /// Register/unregister one offline download in the config blob (single
401 /// `json_set`/`json_remove` — the downloads hot path must not rewrite the
402 /// whole config per finished song).
403 async fn set_offline_track(&self, id: &str, path: Option<&str>) -> Result<(), DbError>;
404
405 /// Persist the queue/progress snapshot to the single `queue_state` row.
406 async fn save_queue(&self, snap: &QueueSnapshot) -> Result<(), DbError>;
407
408 /// Generic metadata-cache write (upsert of `payload` for `(cache_key, kind)`).
409 async fn meta_put(&self, cache_key: &str, kind: &str, payload: &str) -> Result<(), DbError>;
410
411 // --- Debug-panel operations (dev tooling; no-ops on the wasm stub) -----
412
413 /// Delete the database files at `db_path`, re-init an empty schema there,
414 /// and hot-swap the live pool onto it.
415 async fn debug_reset(&self, db_path: &std::path::Path) -> Result<(), DbError>;
416
417 /// Copy the release database over `db_path` (running any pending
418 /// migrations on the copy) and hot-swap the live pool onto it.
419 async fn debug_load_release(
420 &self,
421 release_path: &std::path::Path,
422 db_path: &std::path::Path,
423 ) -> Result<(), DbError>;
424
425 /// Insert `n` synthetic local tracks (perf testing the windowed queries).
426 async fn debug_seed_synthetic(&self, n: u32) -> Result<(), DbError>;
427
428 /// Human-readable DB info: applied migrations + row counts.
429 async fn debug_info(&self) -> Result<String, DbError>;
430
431 /// VACUUM.
432 async fn debug_vacuum(&self) -> Result<(), DbError>;
433
434 /// Toggle a favorite locally, optimistically. `on` upserts the row as a
435 /// pending-like (`dirty=1`). `!on` deletes a never-pushed like outright and
436 /// turns a synced row into a pending-unlike tombstone (`dirty=2`) so the
437 /// removal can be pushed later. Works while unauthenticated — the reconciler
438 /// flushes pending rows once a server is active.
439 async fn set_favorite(&self, server_id: &str, ref_: &str, on: bool) -> Result<(), DbError>;
440
441 /// Resolve a ref after a successful remote push: a pending-like becomes
442 /// clean, a pending-unlike tombstone is deleted.
443 async fn clear_favorite_dirty(&self, server_id: &str, ref_: &str) -> Result<(), DbError>;
444
445 /// Replace a server's favorites with the remote set (a sync pull): rows not in
446 /// `refs` and not `dirty` are dropped, rows in `refs` are added clean. Dirty
447 /// local rows are preserved (push-before-pull hasn't flushed them yet).
448 async fn replace_favorites_clean(
449 &self,
450 server_id: &str,
451 refs: &[String],
452 ) -> Result<(), DbError>;
453
454 /// Upsert one page of a streaming favorites sync: refs become clean rows at
455 /// `start_rank + offset` (remote order), stamped with `epoch`. Existing rows
456 /// update in place; dirty rows keep their flag. Pair with
457 /// [`sweep_favorites`](Self::sweep_favorites) at stream end. Lets the list
458 /// grow live during the walk.
459 async fn upsert_favorites_page(
460 &self,
461 server_id: &str,
462 refs: &[String],
463 start_rank: i64,
464 epoch: i64,
465 ) -> Result<(), DbError>;
466
467 /// End-of-stream sweep for [`upsert_favorites_page`](Self::upsert_favorites_page):
468 /// drop clean rows not stamped with the current `epoch` (unliked remotely).
469 /// Dirty rows survive.
470 async fn sweep_favorites(&self, server_id: &str, epoch: i64) -> Result<(), DbError>;
471
472 /// Batch upsert tracks for a source (one transaction). Identity is
473 /// `(source, track_key)`; an existing row is updated in place. Used by the
474 /// streaming scan/sync so a batch lands atomically.
475 async fn upsert_tracks(&self, source: &Source, tracks: &[reader::Track])
476 -> Result<(), DbError>;
477
478 /// Batch upsert albums for a source (one transaction).
479 async fn upsert_albums(&self, source: &Source, albums: &[reader::Album])
480 -> Result<(), DbError>;
481}
482
483/// Cheap-`Clone` handle to the active storage backend, shared via Dioxus context.
484#[derive(Clone)]
485pub struct Db(Arc<dyn Storage>);
486
487impl std::ops::Deref for Db {
488 type Target = dyn Storage;
489 fn deref(&self) -> &Self::Target {
490 &*self.0
491 }
492}
493
494/// Read-only view of the storage backend — the surface the UI gets, so it
495/// cannot reach a write method (those live on `Storage`, not `ReadStore`).
496#[derive(Clone)]
497pub struct ReadDb(std::sync::Arc<dyn ReadStore>);
498
499impl std::ops::Deref for ReadDb {
500 type Target = dyn ReadStore;
501 fn deref(&self) -> &Self::Target {
502 &*self.0
503 }
504}
505
506impl Db {
507 /// A read-only view of the same backend (cheap Arc upcast).
508 pub fn reads(&self) -> ReadDb {
509 ReadDb(self.0.clone())
510 }
511}
512
513/// Open the database and apply migrations. Native callers should `block_on`
514/// this in `main()` before mounting.
515pub async fn init(db_path: &std::path::Path) -> Result<Db, DbError> {
516 let native = backend::Native::open(db_path).await?;
517 Ok(Db(Arc::new(native)))
518}
519
520/// The on-disk database path: `KOPUZ_DB_PATH` override, else `<config_dir>/kopuz.db`
521/// (release) or `kopuz-debug.db` (debug builds, so `dx run` never touches real data).
522pub fn default_db_path() -> std::path::PathBuf {
523 if let Ok(p) = std::env::var("KOPUZ_DB_PATH") {
524 return std::path::PathBuf::from(p);
525 }
526 let name = if cfg!(debug_assertions) {
527 "kopuz-debug.db"
528 } else {
529 "kopuz.db"
530 };
531 config_dir().join(name)
532}
533
534/// Blocking pre-boot read of the config blob — for the few values needed before
535/// the app (and its async runtime/log subscriber) exists: the tracing toggle and
536/// the titlebar mode. Opens the DB read-only without running migrations; `None`
537/// if the DB or blob doesn't exist yet (first launch). Server/creds fields are
538/// NOT hydrated — blob fields only.
539pub fn peek_config(db_path: &std::path::Path) -> Option<config::AppConfig> {
540 if !db_path.exists() {
541 return None;
542 }
543 let rt = tokio::runtime::Builder::new_current_thread()
544 .enable_all()
545 .build()
546 .ok()?;
547 rt.block_on(async {
548 let opts = sqlx::sqlite::SqliteConnectOptions::new()
549 .filename(db_path)
550 .create_if_missing(false)
551 .read_only(true);
552 use sqlx::ConnectOptions;
553 let mut conn = opts.connect().await.ok()?;
554 let json: Option<String> = sqlx::query_scalar!("SELECT json FROM app_config WHERE id = 1")
555 .fetch_optional(&mut conn)
556 .await
557 .ok()
558 .flatten();
559 json.and_then(|j| serde_json::from_str(&j).ok())
560 })
561}
562
563/// The RELEASE database path (`kopuz.db`), independent of build profile — the
564/// debug panel's "load release DB" source.
565pub fn release_db_path() -> std::path::PathBuf {
566 config_dir().join("kopuz.db")
567}
568
569/// `<config_dir>` for kopuz (matches the legacy JSON store location).
570pub fn config_dir() -> std::path::PathBuf {
571 directories::ProjectDirs::from("com", "temidaradev", "kopuz")
572 .map(|d| d.config_dir().to_path_buf())
573 .unwrap_or_else(|| std::path::PathBuf::from("./config"))
574}