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