diff --git a/ferrum-addon/addon.d.ts b/ferrum-addon/addon.d.ts index f735a2b..fb746d7 100644 --- a/ferrum-addon/addon.d.ts +++ b/ferrum-addon/addon.d.ts @@ -66,7 +66,7 @@ export declare function get_track_lists_details(): Record -export declare function get_tracks_page(options: TracksPageOptions): TracksPage +export declare function get_tracks_page(options: TracksPageOptions): Promise export declare function import_file(path: string, now: MsSinceUnixEpoch): void @@ -258,7 +258,7 @@ export interface TrackMd { export interface TracksPage { playlistKind: string playlistName: string - playlistDescription?: string + playlistDescription: string playlistLength: number itemIds: Array } diff --git a/src-native/db.rs b/src-native/db.rs new file mode 100644 index 0000000..955d95c --- /dev/null +++ b/src-native/db.rs @@ -0,0 +1,44 @@ +pub type TrackID = String; +pub type TrackListID = String; + +#[derive(Debug, Clone, Copy, PartialEq, sqlx::Type)] +#[sqlx(type_name = "TEXT")] +#[sqlx(rename_all = "lowercase")] +pub enum TrackListKind { + Playlist, + Folder, + Special, +} +impl ToString for TrackListKind { + fn to_string(&self) -> String { + match self { + TrackListKind::Playlist => "playlist".to_string(), + TrackListKind::Folder => "folder".to_string(), + TrackListKind::Special => "special".to_string(), + } + } +} + +// #[derive(sqlx::FromRow, Debug)] +// pub struct TrackList { +// id: String, +// kind: String, +// parent_id: Option, +// item_index: Option, +// name: String, +// description: String, +// liked: bool, +// disliked: bool, +// /// For example "itunes" +// imported_from: Option, +// /// For example iTunes Persistent ID +// original_id: Option, +// imported_at: Option, +// /// Nullable for imported playlists +// created_at: Option, +// } + +// let tracklist: db::TrackList = sqlx::query_as("SELECT * FROM track_lists WHERE id = ?") +// .bind(&options.playlist_id) +// .fetch_one(&mut data.db) +// .await?; diff --git a/src-native/lib.rs b/src-native/lib.rs index c4a1e11..f4c4f20 100644 --- a/src-native/lib.rs +++ b/src-native/lib.rs @@ -21,6 +21,7 @@ extern crate napi_derive; mod data; #[cfg(feature = "napi-rs")] mod data_js; +pub mod db; pub mod filter; #[cfg(feature = "napi-rs")] mod itunes_import; diff --git a/src-native/library_types.rs b/src-native/library_types.rs index 9145741..9d2e533 100644 --- a/src-native/library_types.rs +++ b/src-native/library_types.rs @@ -205,12 +205,12 @@ impl Library { pub fn get_track(&self, id: &TrackID) -> Result<&Track> { self.get_tracks() .get(id) - .context("Track with ID {} not found") + .with_context(|| format!("Track with ID {id} not found")) } pub fn get_track_mut(&mut self, id: &TrackID) -> Result<&mut Track> { self.tracks .get_mut(id) - .context("Track with ID {} not found") + .with_context(|| format!("Track with ID {id} not found")) } pub fn get_tracklist(&self, id: &str) -> Result<&TrackList> { self.trackLists.get(id).context("Playlist ID not found") diff --git a/src-native/migrations/1_start.sql b/src-native/migrations/1_start.sql index 0f4296a..a5498d7 100644 --- a/src-native/migrations/1_start.sql +++ b/src-native/migrations/1_start.sql @@ -39,6 +39,19 @@ create table tracks ( volume INTEGER NULL -- from -100 to 100 ); +CREATE VIRTUAL TABLE tracks_fts USING fts5( + title, + artist, + composer, + genre, + comments, + grouping, + album_title, + album_artist, + content='tracks', + content_rowid='rowid' +); + CREATE TABLE plays ( date INTEGER NOT NULL, track_id TEXT NOT NULL REFERENCES tracks(id), diff --git a/src-native/page.rs b/src-native/page.rs index be5315e..075602e 100644 --- a/src-native/page.rs +++ b/src-native/page.rs @@ -1,11 +1,15 @@ #[cfg(feature = "napi-rs")] use crate::data::Data; +use crate::db::TrackListKind; use crate::filter::filter; -use crate::library_types::{ItemId, Library, TrackList}; -use crate::sort::sort; +use crate::library_types::ItemId; +#[cfg(feature = "napi-rs")] +use crate::library_types::new_item_ids_from_track_ids; use anyhow::Result; use serde::{Deserialize, Serialize}; use specta::Type; +#[cfg(feature = "napi-rs")] +use sqlx::{Arguments, AssertSqlSafe, Connection}; #[cfg_attr(feature = "napi", napi(object))] #[derive(Deserialize, Clone, Type)] @@ -22,49 +26,152 @@ pub struct TracksPageOptions { pub struct TracksPage { pub playlist_kind: String, pub playlist_name: String, - pub playlist_description: Option, + pub playlist_description: String, pub playlist_length: u32, pub item_ids: Vec, } +#[derive(Debug, sqlx::FromRow)] +struct TrackListPage { + kind: TrackListKind, + name: String, + description: String, +} + +// returns (column_name, is_text) +fn to_sql_sort_key(sort_key: &str) -> (&'static str, bool) { + match sort_key { + "albumName" => ("album_title", true), + "albumArtist" => ("album_artist", true), + "artist" => ("artist", true), + "bpm" => ("bpm", false), + "comments" => ("comments", true), + "composer" => ("composer", true), + "dateAdded" => ("added_at", false), + "duration" => ("duration_s", false), + "genre" => ("genre", true), + "grouping" => ("grouping", true), + "name" => ("title", true), + "playCount" => ("play_count", false), + "skipCount" => ("skip_count", false), + "year" => ("year", false), + sort_key => panic!("Invalid sort key {sort_key}"), + } +} + #[cfg(feature = "napi-rs")] #[cfg_attr(feature = "napi", napi(js_name = "get_tracks_page"))] #[allow(dead_code)] -pub fn get_tracks_page(options: TracksPageOptions) -> Result { - let data = Data::get_blocking(); - Ok(get_tracks_page_from_library(options, &data.library)?) -} +pub async fn get_tracks_page(options: TracksPageOptions) -> Result { + let start_time = std::time::Instant::now(); + let mut data = Data::get_async().await; + let mut tx = data.db.begin().await?; + let track_list: TrackListPage = sqlx::query_as( + "SELECT kind, name, description + FROM track_lists + WHERE id = ?", + ) + .bind(&options.playlist_id) + .fetch_one(&mut *tx) + .await?; -pub fn get_tracks_page_from_library( - options: TracksPageOptions, - library: &Library, -) -> anyhow::Result { - let tracklist = library.get_tracklist(&options.playlist_id)?; - let item_ids = sort(options.clone(), &library)?; - let tracklist_length = item_ids.len(); - let item_ids = filter(item_ids, options.filter_query, &library); - let track_page = match tracklist { - TrackList::Playlist(playlist) => TracksPage { - playlist_kind: tracklist.kind().to_string(), - playlist_name: playlist.name.clone(), - playlist_description: playlist.description.clone(), - playlist_length: tracklist_length as u32, - item_ids, - }, - TrackList::Folder(folder) => TracksPage { - playlist_kind: tracklist.kind().to_string(), - playlist_name: folder.name.clone(), - playlist_description: folder.description.clone(), - playlist_length: tracklist_length as u32, - item_ids, - }, - TrackList::Special(special) => TracksPage { - playlist_kind: tracklist.kind().to_string(), - playlist_name: special.name.to_string(), - playlist_description: None, - playlist_length: tracklist_length as u32, - item_ids, + // todo: FTS match OR exact match on year || bpm || play_count || skip_count + + let mut where_clauses = Vec::new(); + let mut args = sqlx::sqlite::SqliteArguments::default(); + + println!("{track_list:?}"); + if track_list.kind == TrackListKind::Playlist { + where_clauses.push("playlist_tracks.track_list_id = ?"); + args.add(&options.playlist_id).unwrap(); + } + + if options.filter_query.trim() != "" { + where_clauses.push("tracks_fts MATCH ?"); + args.add(&options.filter_query).unwrap(); + } + + let direction = match options.sort_desc { + true => "DESC", + false => "ASC", + }; + let mut order_by_clauses = Vec::new(); + match options.sort_key.as_str() { + "index" => match track_list.kind { + TrackListKind::Playlist => { + order_by_clauses.push(format!("playlist_tracks.item_pos {direction}")) + } + TrackListKind::Folder => todo!(), + TrackListKind::Special => order_by_clauses.push(format!("tracks.added_at {direction}")), }, + sort_key => { + // TEXT columns should have empty values sorted last + let (sql_sort_key, is_text_col) = to_sql_sort_key(sort_key); + if is_text_col { + order_by_clauses.push(format!( + "CASE WHEN tracks.{sql_sort_key} IS NULL OR tracks.{sql_sort_key} = '' \ + THEN 1 ELSE 0 END ASC" + )); + } + match track_list.kind { + TrackListKind::Playlist => { + order_by_clauses.push(format!("tracks.{sql_sort_key} {direction}")); + order_by_clauses.push("playlist_tracks.item_pos ASC".to_string()); + } + TrackListKind::Folder => todo!(), + TrackListKind::Special => { + order_by_clauses.push(format!("tracks.{sql_sort_key} {direction}")); + order_by_clauses.push("tracks.added_at ASC".to_string()); + } + } + } + }; + let order_by = order_by_clauses.join(", "); + + let where_clause = match where_clauses.len() > 0 { + true => format!("WHERE {}", where_clauses.join(" AND ")), + false => "".to_string(), + }; + let track_ids_sql = match track_list.kind { + TrackListKind::Playlist => format!( + "SELECT tracks.id + FROM playlist_tracks + JOIN tracks ON tracks.id = playlist_tracks.track_id + JOIN tracks_fts ON tracks_fts.rowid = tracks.rowid + {where_clause} + ORDER BY {order_by}", + ), + TrackListKind::Folder => todo!(), + TrackListKind::Special => format!( + "SELECT tracks.id + FROM tracks + JOIN tracks_fts ON tracks_fts.rowid = tracks.rowid + {where_clause} + ORDER BY {order_by}", + ), + }; + println!("{track_ids_sql}"); + + let track_ids: Vec = sqlx::query_scalar_with(AssertSqlSafe(track_ids_sql), args) + .fetch_all(&mut *tx) + .await?; + println!("{track_ids:?}"); + + tx.commit().await?; + + // let track_ids = filter(track_ids, options.filter_query); + + println!("get_tracks_page took {:?}", start_time.elapsed()); + + // todo: temporary workaround + let item_ids = new_item_ids_from_track_ids(&track_ids); + + let tracks_page = TracksPage { + playlist_kind: track_list.kind.to_string(), + playlist_name: track_list.name, + playlist_description: track_list.description, + playlist_length: track_ids.len().try_into().unwrap(), + item_ids, }; - Ok(track_page) + Ok(tracks_page) } diff --git a/src/routes/playlist/[id]/+page.svelte b/src/routes/playlist/[id]/+page.svelte index d9c08fe..3a44bf5 100644 --- a/src/routes/playlist/[id]/+page.svelte +++ b/src/routes/playlist/[id]/+page.svelte @@ -67,11 +67,12 @@ groupAlbumTracks: $group_album_tracks, }) - let tracks_page = $derived.by(() => { + const tracks_page_promise = $derived.by(() => { refreshers.tracklist refreshers.tracks return get_tracks_page(page_options) }) + const tracks_page = $derived(await tracks_page_promise) $effect(() => { $tracks_page_item_ids = tracks_page.itemIds }) diff --git a/svelte.config.js b/svelte.config.js index dc4d299..54c30d2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -13,4 +13,9 @@ export default { $electron: 'src/electron', }, }, + compilerOptions: { + experimental: { + async: true, + }, + }, }