Skip to main content

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}