diff --git a/Cargo.lock b/Cargo.lock index f95bfe7..3188c8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix 1.1.4", "slab", "windows-sys 0.61.0", ] @@ -186,7 +186,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -212,7 +212,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.0", @@ -1226,6 +1226,7 @@ dependencies = [ "simd-json", "specta", "sqlx", + "tempfile", "time", "tokio", "trash", @@ -1582,7 +1583,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "windows-targets 0.52.6", ] @@ -2387,9 +2388,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2466,9 +2467,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3519,7 +3520,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.0", ] @@ -3980,14 +3981,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.0", ] @@ -5273,14 +5274,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.0", ] @@ -6938,7 +6939,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.2", + "rustix 1.1.4", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index e1f1fbc..f14b04a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,8 @@ simd-json = "0.16.0" mimalloc = "0.1.48" specta = { version = "=2.0.0-rc.22", features = ["derive"] } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } +tempfile = "3.27.0" +rusqlite = "0.40.1" [target.'cfg(not(target_os = "android"))'.dependencies] trash = "5.1" diff --git a/build.rs b/build.rs index 3ed61cd..c64803f 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,13 @@ +use std::{env, path::PathBuf}; extern crate napi_build; fn main() { + // Path to SQLite file relative to the crate root + let db_path = PathBuf::from("src-native/appdata/Library/Library-typegen.sqlite"); + let abs_path = env::current_dir().unwrap().join(&db_path); + let database_url = format!("sqlite:///{}", abs_path.display()); + println!("cargo:rustc-env=DATABASE_URL={}", database_url); + #[cfg(not(target_os = "android"))] napi_build::setup(); } diff --git a/src-native/migrate.rs b/src-native/migrate.rs index 738bbf4..bbe5c56 100644 --- a/src-native/migrate.rs +++ b/src-native/migrate.rs @@ -1,11 +1,12 @@ use crate::{ library::{Paths, load_old_library_json}, - library_types::Library, + library_types::{Library, TrackList, TrackLists}, }; use anyhow::{Context, Result}; use sqlx::{ ConnectOptions, Connection, Sqlite, migrate::MigrateDatabase, sqlite::SqliteConnectOptions, }; +use tempfile::TempDir; pub async fn migrate_to_sqlite(paths: &Paths) -> Result<()> { let old_library = match load_old_library_json(&paths.library_json)? { @@ -15,12 +16,14 @@ pub async fn migrate_to_sqlite(paths: &Paths) -> Result<()> { Some(old_library) => old_library, }; - Sqlite::create_database(&paths.library_sqlite) + let tmp_dir = TempDir::new().context("failed to create temp dir")?; + let tmp_db = tmp_dir.path().join("Library.sqlite"); + + Sqlite::create_database(&tmp_db.to_str().unwrap()) .await .context("Could not create library database")?; - let mut connection = SqliteConnectOptions::new() - .filename(&paths.library_sqlite) + .filename(&tmp_db) .connect() .await .context("Error connecting to created library database")?; @@ -30,12 +33,369 @@ pub async fn migrate_to_sqlite(paths: &Paths) -> Result<()> { .await .context("Could not run database migrations")?; - todo!("Save library to database"); + insert_library_into_db(&old_library, &mut connection) + .await + .context("could not insert library into database")?; connection .close() .await .context("Could not save/close database")?; + std::fs::rename(&tmp_db, &paths.library_sqlite) + .context("Failed to finalize sqlite database")?; + Ok(()) } + +async fn insert_library_into_db( + library: &Library, + conn: &mut sqlx::SqliteConnection, +) -> anyhow::Result<()> { + let mut tx = conn.begin().await?; + + // --- tracks --- + for (id, track) in library.get_tracks() { + conn.execute( + "INSERT INTO tracks ( + id, filesize, duration_s, bitrate, sample_rate, file, modified_at, added_at, name, artist, imported_from, original_id, composer, sort_name, sort_artist, sort_composer, genre, rating_pct, year, bpm, comments, grouping, liked, disliked, disabled, compilation, album_name, album_artist, sort_album_name, sort_album_artist, track_num, track_count, disc_num, disc_count, imported_at, play_count, skip_count, volume + ) VALUES ( + :id,:filesize,:duration_s,:bitrate,:sample_rate,:file,:modified_at,:added_at,:name,:artist,:imported_from,:original_id,:composer,:sort_name,:sort_artist,:sort_composer,:genre,:rating_pct,:year,:bpm,:comments,:grouping,:liked,:disliked,:disabled,:compilation,:album_name,:album_artist,:sort_album_name,:sort_album_artist,:track_num,:track_count,:disc_num,:disc_count,:imported_at,:play_count,:skip_count,:volume + )", + named_params! { + ":id": track.id, + ":filesize": track.filesize, + ":duration_s": track.duration_s, + ":bitrate": track.bitrate, + ":sample_rate": track.sample_rate, + ":file": track.file, + ":modified_at": track.modified_at, + ":added_at": track.added_at, + ":name": track.name, + ":artist": track.artist, + ":imported_from": track.imported_from, + ":original_id": track.original_id, + ":composer": track.composer, + ":sort_name": track.sort_name, + ":sort_artist": track.sort_artist, + ":sort_composer": track.sort_composer, + ":genre": track.genre, + ":rating_pct": track.rating_pct, + ":year": track.year, + ":bpm": track.bpm, + ":comments": track.comments, + ":grouping": track.grouping, + ":liked": track.liked, + ":disliked": track.disliked, + ":disabled": track.disabled, + ":compilation": track.compilation, + ":album_name": track.album_name, + ":album_artist": track.album_artist, + ":sort_album_name": track.sort_album_name, + ":sort_album_artist": track.sort_album_artist, + ":track_num": track.track_num, + ":track_count": track.track_count, + ":disc_num": track.disc_num, + ":disc_count": track.disc_count, + ":imported_at": track.imported_at, + ":play_count": track.play_count, + ":skip_count": track.skip_count, + ":volume": track.volume, + + }, + )?; + sqlx::query!( + r#" + INSERT INTO tracks ( + id + filesize + duration_s + bitrate + sample_rate + file + modified_at + added_at + name + artist + imported_from + original_id + composer + sort_name + sort_artist + sort_composer + genre + rating_pct + year + bpm + comments + grouping + liked + disliked + disabled + compilation + album_name + album_artist + sort_album_name + sort_album_artist + track_num + track_count + disc_num + disc_count + imported_at + play_count + skip_count + volume + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, + ?7, ?8, ?9, ?10, + ?11, ?12, ?13, + ?14, ?15, ?16, + ?17, ?18, ?19, ?20, ?21, ?22, + ?23, ?24, ?25, ?26, + ?27, ?28, ?29, ?30, + ?31, ?32, ?33, ?34, + ?35, ?36, ?37, ?38 + ) + "#, + id, + track.size, + track.duration, + track.bitrate, + track.sampleRate, + track.file, + track.dateModified, + track.dateAdded, + track.name, + track.artist, + track.importedFrom, + track.originalId, + track.composer, + track.sortName, + track.sortArtist, + track.sortComposer, + track.genre, + track.rating, + track.year, + track.bpm, + track.comments, + track.grouping, + track.liked, + track.disliked, + track.disabled, + track.compilation, + track.albumName, + track.albumArtist, + track.sortAlbumName, + track.sortAlbumArtist, + track.trackNum, + track.trackCount, + track.discNum, + track.discCount, + track.dateImported, + track.playCount, + track.skipCount, + track.volume, + ) + .execute(&mut *tx) + .await + .with_context(|| format!("Failed to insert track {id}"))?; + + // // Individual play timestamps (known exact times) + // if let Some(plays) = &track.plays { + // for &date in plays { + // sqlx::query!( + // "INSERT INTO plays (track_id, date, date_range_to) VALUES (?1, ?2, NULL)", + // id, + // date, + // ) + // .execute(&mut *tx) + // .await?; + // } + // } + + // // Imported play ranges (CountObject = fromDate..toDate with a count, + // // but the schema only stores one row per range — store as a range row + // // and ignore count since the schema has no count column) + // if let Some(imported) = &track.playsImported { + // for co in imported { + // sqlx::query!( + // "INSERT INTO plays (track_id, date, date_range_to) VALUES (?1, ?2, ?3)", + // id, + // co.fromDate, + // co.toDate, + // ) + // .execute(&mut *tx) + // .await?; + // } + // } + + // // Individual skip timestamps + // if let Some(skips) = &track.skips { + // for &date in skips { + // sqlx::query!( + // "INSERT INTO skips (track_id, date, date_range_to) VALUES (?1, ?2, NULL)", + // id, + // date, + // ) + // .execute(&mut *tx) + // .await?; + // } + // } + + // // Imported skip ranges + // if let Some(imported) = &track.skipsImported { + // for co in imported { + // sqlx::query!( + // "INSERT INTO skips (track_id, date, date_range_to) VALUES (?1, ?2, ?3)", + // id, + // co.fromDate, + // co.toDate, + // ) + // .execute(&mut *tx) + // .await?; + // } + // } + // } + + // // --- track_lists (folders, playlists, specials) --- + // // We need parent_id, which requires a first pass to build the parent map. + // let parent_map = build_parent_map(&library.trackLists); + + // for (list_id, tracklist) in &library.trackLists { + // let parent_id = parent_map.get(list_id.as_str()).map(|s| s.as_str()); + // let position = get_position_in_parent(list_id, &library.trackLists); + + // match tracklist { + // TrackList::Special(s) => { + // let name = s.name.to_string(); + // sqlx::query!( + // r#"INSERT INTO track_lists + // (id, type, parent_id, position, name, description, liked, disliked, + // imported_from, original_id, imported_at, created_at) + // VALUES (?1,'special',?2,?3,?4,'',0,0,NULL,NULL,NULL,?5)"#, + // s.id, + // parent_id, + // position, + // name, + // s.dateCreated, + // ) + // .execute(&mut *tx) + // .await?; + // } + // TrackList::Folder(f) => { + // sqlx::query!( + // r#"INSERT INTO track_lists + // (id, type, parent_id, position, name, description, liked, disliked, + // imported_from, original_id, imported_at, created_at) + // VALUES (?1,'folder',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"#, + // f.id, + // parent_id, + // position, + // f.name, + // f.description, + // f.liked, + // f.disliked, + // f.importedFrom, + // f.originalId, + // f.dateImported, + // f.dateCreated, + // ) + // .execute(&mut *tx) + // .await?; + // } + // TrackList::Playlist(p) => { + // sqlx::query!( + // r#"INSERT INTO track_lists + // (id, type, parent_id, position, name, description, liked, disliked, + // imported_from, original_id, imported_at, created_at) + // VALUES (?1,'playlist',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"#, + // p.id, + // parent_id, + // position, + // p.name, + // p.description, + // p.liked, + // p.disliked, + // p.importedFrom, + // p.originalId, + // p.dateImported, + // p.dateCreated, + // ) + // .execute(&mut *tx) + // .await?; + + // // playlist_tracks rows + // for (pos, track_id) in p.get_track_ids().iter().enumerate() { + // let pos = pos as i64; + // sqlx::query!( + // "INSERT INTO playlist_tracks (track_list_id, track_id, position) + // VALUES (?1, ?2, ?3)", + // p.id, + // track_id, + // pos, + // ) + // .execute(&mut *tx) + // .await?; + // } + // } + // } + } + + // // --- play_times (v1 and v2) --- + // for (track_id, started_at, duration) in &library.v1PlayTime { + // sqlx::query!( + // "INSERT INTO play_times (track_id, started_at, duration, is_v1) VALUES (?1,?2,?3,1)", + // track_id, + // started_at, + // duration, + // ) + // .execute(&mut *tx) + // .await?; + // } + // for (track_id, started_at, duration) in &library.playTime { + // sqlx::query!( + // "INSERT INTO play_times (track_id, started_at, duration, is_v1) VALUES (?1,?2,?3,0)", + // track_id, + // started_at, + // duration, + // ) + // .execute(&mut *tx) + // .await?; + // } + + tx.commit() + .await + .context("Failed to commit migration transaction")?; + Ok(()) +} + +// /// Returns a map of child_id -> parent_id for every tracklist entry. +// fn build_parent_map(track_lists: &TrackLists) -> std::collections::HashMap { +// let mut map = std::collections::HashMap::new(); +// for (parent_id, tl) in track_lists { +// let children = match tl { +// TrackList::Folder(f) => &f.children, +// TrackList::Special(s) => &s.children, +// TrackList::Playlist(_) => continue, +// }; +// for child_id in children { +// map.insert(child_id.clone(), parent_id.clone()); +// } +// } +// map +// } + +// /// Returns the 0-based position of `id` in its parent's children list, or None. +// fn get_position_in_parent(id: &str, track_lists: &TrackLists) -> Option { +// for tl in track_lists.values() { +// let children = match tl { +// TrackList::Folder(f) => &f.children, +// TrackList::Special(s) => &s.children, +// TrackList::Playlist(_) => continue, +// }; +// if let Some(pos) = children.iter().position(|c| c == id) { +// return Some(pos as i64); +// } +// } +// None +// }