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
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}