[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

wip Migrate library data

Kasper (Jun 9, 2026, 6:24 AM +0200) 603b0539 63ec0262

+392 -22
+18 -17
Cargo.lock
··· 155 155 "futures-lite", 156 156 "parking", 157 157 "polling", 158 - "rustix 1.1.2", 158 + "rustix 1.1.4", 159 159 "slab", 160 160 "windows-sys 0.61.0", 161 161 ] ··· 186 186 "cfg-if", 187 187 "event-listener 5.4.1", 188 188 "futures-lite", 189 - "rustix 1.1.2", 189 + "rustix 1.1.4", 190 190 ] 191 191 192 192 [[package]] ··· 212 212 "cfg-if", 213 213 "futures-core", 214 214 "futures-io", 215 - "rustix 1.1.2", 215 + "rustix 1.1.4", 216 216 "signal-hook-registry", 217 217 "slab", 218 218 "windows-sys 0.61.0", ··· 1226 1226 "simd-json", 1227 1227 "specta", 1228 1228 "sqlx", 1229 + "tempfile", 1229 1230 "time", 1230 1231 "tokio", 1231 1232 "trash", ··· 1582 1583 source = "registry+https://github.com/rust-lang/crates.io-index" 1583 1584 checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" 1584 1585 dependencies = [ 1585 - "rustix 1.1.2", 1586 + "rustix 1.1.4", 1586 1587 "windows-targets 0.52.6", 1587 1588 ] 1588 1589 ··· 2387 2388 2388 2389 [[package]] 2389 2390 name = "libc" 2390 - version = "0.2.175" 2391 + version = "0.2.186" 2391 2392 source = "registry+https://github.com/rust-lang/crates.io-index" 2392 - checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 2393 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 2393 2394 2394 2395 [[package]] 2395 2396 name = "libloading" ··· 2466 2467 2467 2468 [[package]] 2468 2469 name = "linux-raw-sys" 2469 - version = "0.11.0" 2470 + version = "0.12.1" 2470 2471 source = "registry+https://github.com/rust-lang/crates.io-index" 2471 - checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 2472 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 2472 2473 2473 2474 [[package]] 2474 2475 name = "litemap" ··· 3519 3520 "concurrent-queue", 3520 3521 "hermit-abi", 3521 3522 "pin-project-lite", 3522 - "rustix 1.1.2", 3523 + "rustix 1.1.4", 3523 3524 "windows-sys 0.61.0", 3524 3525 ] 3525 3526 ··· 3980 3981 3981 3982 [[package]] 3982 3983 name = "rustix" 3983 - version = "1.1.2" 3984 + version = "1.1.4" 3984 3985 source = "registry+https://github.com/rust-lang/crates.io-index" 3985 - checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 3986 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 3986 3987 dependencies = [ 3987 3988 "bitflags 2.9.4", 3988 3989 "errno", 3989 3990 "libc", 3990 - "linux-raw-sys 0.11.0", 3991 + "linux-raw-sys 0.12.1", 3991 3992 "windows-sys 0.61.0", 3992 3993 ] 3993 3994 ··· 5273 5274 5274 5275 [[package]] 5275 5276 name = "tempfile" 5276 - version = "3.22.0" 5277 + version = "3.27.0" 5277 5278 source = "registry+https://github.com/rust-lang/crates.io-index" 5278 - checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" 5279 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 5279 5280 dependencies = [ 5280 5281 "fastrand", 5281 - "getrandom 0.3.3", 5282 + "getrandom 0.4.2", 5282 5283 "once_cell", 5283 - "rustix 1.1.2", 5284 + "rustix 1.1.4", 5284 5285 "windows-sys 0.61.0", 5285 5286 ] 5286 5287 ··· 6938 6939 "hex", 6939 6940 "libc", 6940 6941 "ordered-stream", 6941 - "rustix 1.1.2", 6942 + "rustix 1.1.4", 6942 6943 "serde", 6943 6944 "serde_repr", 6944 6945 "tracing",
+2
Cargo.toml
··· 59 59 mimalloc = "0.1.48" 60 60 specta = { version = "=2.0.0-rc.22", features = ["derive"] } 61 61 sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } 62 + tempfile = "3.27.0" 63 + rusqlite = "0.40.1" 62 64 63 65 [target.'cfg(not(target_os = "android"))'.dependencies] 64 66 trash = "5.1"
+7
build.rs
··· 1 + use std::{env, path::PathBuf}; 1 2 extern crate napi_build; 2 3 3 4 fn main() { 5 + // Path to SQLite file relative to the crate root 6 + let db_path = PathBuf::from("src-native/appdata/Library/Library-typegen.sqlite"); 7 + let abs_path = env::current_dir().unwrap().join(&db_path); 8 + let database_url = format!("sqlite:///{}", abs_path.display()); 9 + println!("cargo:rustc-env=DATABASE_URL={}", database_url); 10 + 4 11 #[cfg(not(target_os = "android"))] 5 12 napi_build::setup(); 6 13 }
+365 -5
src-native/migrate.rs
··· 1 1 use crate::{ 2 2 library::{Paths, load_old_library_json}, 3 - library_types::Library, 3 + library_types::{Library, TrackList, TrackLists}, 4 4 }; 5 5 use anyhow::{Context, Result}; 6 6 use sqlx::{ 7 7 ConnectOptions, Connection, Sqlite, migrate::MigrateDatabase, sqlite::SqliteConnectOptions, 8 8 }; 9 + use tempfile::TempDir; 9 10 10 11 pub async fn migrate_to_sqlite(paths: &Paths) -> Result<()> { 11 12 let old_library = match load_old_library_json(&paths.library_json)? { ··· 15 16 Some(old_library) => old_library, 16 17 }; 17 18 18 - Sqlite::create_database(&paths.library_sqlite) 19 + let tmp_dir = TempDir::new().context("failed to create temp dir")?; 20 + let tmp_db = tmp_dir.path().join("Library.sqlite"); 21 + 22 + Sqlite::create_database(&tmp_db.to_str().unwrap()) 19 23 .await 20 24 .context("Could not create library database")?; 21 - 22 25 let mut connection = SqliteConnectOptions::new() 23 - .filename(&paths.library_sqlite) 26 + .filename(&tmp_db) 24 27 .connect() 25 28 .await 26 29 .context("Error connecting to created library database")?; ··· 30 33 .await 31 34 .context("Could not run database migrations")?; 32 35 33 - todo!("Save library to database"); 36 + insert_library_into_db(&old_library, &mut connection) 37 + .await 38 + .context("could not insert library into database")?; 34 39 35 40 connection 36 41 .close() 37 42 .await 38 43 .context("Could not save/close database")?; 44 + 45 + std::fs::rename(&tmp_db, &paths.library_sqlite) 46 + .context("Failed to finalize sqlite database")?; 39 47 40 48 Ok(()) 41 49 } 50 + 51 + async fn insert_library_into_db( 52 + library: &Library, 53 + conn: &mut sqlx::SqliteConnection, 54 + ) -> anyhow::Result<()> { 55 + let mut tx = conn.begin().await?; 56 + 57 + // --- tracks --- 58 + for (id, track) in library.get_tracks() { 59 + conn.execute( 60 + "INSERT INTO tracks ( 61 + 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 62 + ) VALUES ( 63 + :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 64 + )", 65 + named_params! { 66 + ":id": track.id, 67 + ":filesize": track.filesize, 68 + ":duration_s": track.duration_s, 69 + ":bitrate": track.bitrate, 70 + ":sample_rate": track.sample_rate, 71 + ":file": track.file, 72 + ":modified_at": track.modified_at, 73 + ":added_at": track.added_at, 74 + ":name": track.name, 75 + ":artist": track.artist, 76 + ":imported_from": track.imported_from, 77 + ":original_id": track.original_id, 78 + ":composer": track.composer, 79 + ":sort_name": track.sort_name, 80 + ":sort_artist": track.sort_artist, 81 + ":sort_composer": track.sort_composer, 82 + ":genre": track.genre, 83 + ":rating_pct": track.rating_pct, 84 + ":year": track.year, 85 + ":bpm": track.bpm, 86 + ":comments": track.comments, 87 + ":grouping": track.grouping, 88 + ":liked": track.liked, 89 + ":disliked": track.disliked, 90 + ":disabled": track.disabled, 91 + ":compilation": track.compilation, 92 + ":album_name": track.album_name, 93 + ":album_artist": track.album_artist, 94 + ":sort_album_name": track.sort_album_name, 95 + ":sort_album_artist": track.sort_album_artist, 96 + ":track_num": track.track_num, 97 + ":track_count": track.track_count, 98 + ":disc_num": track.disc_num, 99 + ":disc_count": track.disc_count, 100 + ":imported_at": track.imported_at, 101 + ":play_count": track.play_count, 102 + ":skip_count": track.skip_count, 103 + ":volume": track.volume, 104 + 105 + }, 106 + )?; 107 + sqlx::query!( 108 + r#" 109 + INSERT INTO tracks ( 110 + id 111 + filesize 112 + duration_s 113 + bitrate 114 + sample_rate 115 + file 116 + modified_at 117 + added_at 118 + name 119 + artist 120 + imported_from 121 + original_id 122 + composer 123 + sort_name 124 + sort_artist 125 + sort_composer 126 + genre 127 + rating_pct 128 + year 129 + bpm 130 + comments 131 + grouping 132 + liked 133 + disliked 134 + disabled 135 + compilation 136 + album_name 137 + album_artist 138 + sort_album_name 139 + sort_album_artist 140 + track_num 141 + track_count 142 + disc_num 143 + disc_count 144 + imported_at 145 + play_count 146 + skip_count 147 + volume 148 + ) VALUES ( 149 + ?1, ?2, ?3, ?4, ?5, ?6, 150 + ?7, ?8, ?9, ?10, 151 + ?11, ?12, ?13, 152 + ?14, ?15, ?16, 153 + ?17, ?18, ?19, ?20, ?21, ?22, 154 + ?23, ?24, ?25, ?26, 155 + ?27, ?28, ?29, ?30, 156 + ?31, ?32, ?33, ?34, 157 + ?35, ?36, ?37, ?38 158 + ) 159 + "#, 160 + id, 161 + track.size, 162 + track.duration, 163 + track.bitrate, 164 + track.sampleRate, 165 + track.file, 166 + track.dateModified, 167 + track.dateAdded, 168 + track.name, 169 + track.artist, 170 + track.importedFrom, 171 + track.originalId, 172 + track.composer, 173 + track.sortName, 174 + track.sortArtist, 175 + track.sortComposer, 176 + track.genre, 177 + track.rating, 178 + track.year, 179 + track.bpm, 180 + track.comments, 181 + track.grouping, 182 + track.liked, 183 + track.disliked, 184 + track.disabled, 185 + track.compilation, 186 + track.albumName, 187 + track.albumArtist, 188 + track.sortAlbumName, 189 + track.sortAlbumArtist, 190 + track.trackNum, 191 + track.trackCount, 192 + track.discNum, 193 + track.discCount, 194 + track.dateImported, 195 + track.playCount, 196 + track.skipCount, 197 + track.volume, 198 + ) 199 + .execute(&mut *tx) 200 + .await 201 + .with_context(|| format!("Failed to insert track {id}"))?; 202 + 203 + // // Individual play timestamps (known exact times) 204 + // if let Some(plays) = &track.plays { 205 + // for &date in plays { 206 + // sqlx::query!( 207 + // "INSERT INTO plays (track_id, date, date_range_to) VALUES (?1, ?2, NULL)", 208 + // id, 209 + // date, 210 + // ) 211 + // .execute(&mut *tx) 212 + // .await?; 213 + // } 214 + // } 215 + 216 + // // Imported play ranges (CountObject = fromDate..toDate with a count, 217 + // // but the schema only stores one row per range — store as a range row 218 + // // and ignore count since the schema has no count column) 219 + // if let Some(imported) = &track.playsImported { 220 + // for co in imported { 221 + // sqlx::query!( 222 + // "INSERT INTO plays (track_id, date, date_range_to) VALUES (?1, ?2, ?3)", 223 + // id, 224 + // co.fromDate, 225 + // co.toDate, 226 + // ) 227 + // .execute(&mut *tx) 228 + // .await?; 229 + // } 230 + // } 231 + 232 + // // Individual skip timestamps 233 + // if let Some(skips) = &track.skips { 234 + // for &date in skips { 235 + // sqlx::query!( 236 + // "INSERT INTO skips (track_id, date, date_range_to) VALUES (?1, ?2, NULL)", 237 + // id, 238 + // date, 239 + // ) 240 + // .execute(&mut *tx) 241 + // .await?; 242 + // } 243 + // } 244 + 245 + // // Imported skip ranges 246 + // if let Some(imported) = &track.skipsImported { 247 + // for co in imported { 248 + // sqlx::query!( 249 + // "INSERT INTO skips (track_id, date, date_range_to) VALUES (?1, ?2, ?3)", 250 + // id, 251 + // co.fromDate, 252 + // co.toDate, 253 + // ) 254 + // .execute(&mut *tx) 255 + // .await?; 256 + // } 257 + // } 258 + // } 259 + 260 + // // --- track_lists (folders, playlists, specials) --- 261 + // // We need parent_id, which requires a first pass to build the parent map. 262 + // let parent_map = build_parent_map(&library.trackLists); 263 + 264 + // for (list_id, tracklist) in &library.trackLists { 265 + // let parent_id = parent_map.get(list_id.as_str()).map(|s| s.as_str()); 266 + // let position = get_position_in_parent(list_id, &library.trackLists); 267 + 268 + // match tracklist { 269 + // TrackList::Special(s) => { 270 + // let name = s.name.to_string(); 271 + // sqlx::query!( 272 + // r#"INSERT INTO track_lists 273 + // (id, type, parent_id, position, name, description, liked, disliked, 274 + // imported_from, original_id, imported_at, created_at) 275 + // VALUES (?1,'special',?2,?3,?4,'',0,0,NULL,NULL,NULL,?5)"#, 276 + // s.id, 277 + // parent_id, 278 + // position, 279 + // name, 280 + // s.dateCreated, 281 + // ) 282 + // .execute(&mut *tx) 283 + // .await?; 284 + // } 285 + // TrackList::Folder(f) => { 286 + // sqlx::query!( 287 + // r#"INSERT INTO track_lists 288 + // (id, type, parent_id, position, name, description, liked, disliked, 289 + // imported_from, original_id, imported_at, created_at) 290 + // VALUES (?1,'folder',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"#, 291 + // f.id, 292 + // parent_id, 293 + // position, 294 + // f.name, 295 + // f.description, 296 + // f.liked, 297 + // f.disliked, 298 + // f.importedFrom, 299 + // f.originalId, 300 + // f.dateImported, 301 + // f.dateCreated, 302 + // ) 303 + // .execute(&mut *tx) 304 + // .await?; 305 + // } 306 + // TrackList::Playlist(p) => { 307 + // sqlx::query!( 308 + // r#"INSERT INTO track_lists 309 + // (id, type, parent_id, position, name, description, liked, disliked, 310 + // imported_from, original_id, imported_at, created_at) 311 + // VALUES (?1,'playlist',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"#, 312 + // p.id, 313 + // parent_id, 314 + // position, 315 + // p.name, 316 + // p.description, 317 + // p.liked, 318 + // p.disliked, 319 + // p.importedFrom, 320 + // p.originalId, 321 + // p.dateImported, 322 + // p.dateCreated, 323 + // ) 324 + // .execute(&mut *tx) 325 + // .await?; 326 + 327 + // // playlist_tracks rows 328 + // for (pos, track_id) in p.get_track_ids().iter().enumerate() { 329 + // let pos = pos as i64; 330 + // sqlx::query!( 331 + // "INSERT INTO playlist_tracks (track_list_id, track_id, position) 332 + // VALUES (?1, ?2, ?3)", 333 + // p.id, 334 + // track_id, 335 + // pos, 336 + // ) 337 + // .execute(&mut *tx) 338 + // .await?; 339 + // } 340 + // } 341 + // } 342 + } 343 + 344 + // // --- play_times (v1 and v2) --- 345 + // for (track_id, started_at, duration) in &library.v1PlayTime { 346 + // sqlx::query!( 347 + // "INSERT INTO play_times (track_id, started_at, duration, is_v1) VALUES (?1,?2,?3,1)", 348 + // track_id, 349 + // started_at, 350 + // duration, 351 + // ) 352 + // .execute(&mut *tx) 353 + // .await?; 354 + // } 355 + // for (track_id, started_at, duration) in &library.playTime { 356 + // sqlx::query!( 357 + // "INSERT INTO play_times (track_id, started_at, duration, is_v1) VALUES (?1,?2,?3,0)", 358 + // track_id, 359 + // started_at, 360 + // duration, 361 + // ) 362 + // .execute(&mut *tx) 363 + // .await?; 364 + // } 365 + 366 + tx.commit() 367 + .await 368 + .context("Failed to commit migration transaction")?; 369 + Ok(()) 370 + } 371 + 372 + // /// Returns a map of child_id -> parent_id for every tracklist entry. 373 + // fn build_parent_map(track_lists: &TrackLists) -> std::collections::HashMap<String, String> { 374 + // let mut map = std::collections::HashMap::new(); 375 + // for (parent_id, tl) in track_lists { 376 + // let children = match tl { 377 + // TrackList::Folder(f) => &f.children, 378 + // TrackList::Special(s) => &s.children, 379 + // TrackList::Playlist(_) => continue, 380 + // }; 381 + // for child_id in children { 382 + // map.insert(child_id.clone(), parent_id.clone()); 383 + // } 384 + // } 385 + // map 386 + // } 387 + 388 + // /// Returns the 0-based position of `id` in its parent's children list, or None. 389 + // fn get_position_in_parent(id: &str, track_lists: &TrackLists) -> Option<i64> { 390 + // for tl in track_lists.values() { 391 + // let children = match tl { 392 + // TrackList::Folder(f) => &f.children, 393 + // TrackList::Special(s) => &s.children, 394 + // TrackList::Playlist(_) => continue, 395 + // }; 396 + // if let Some(pos) = children.iter().position(|c| c == id) { 397 + // return Some(pos as i64); 398 + // } 399 + // } 400 + // None 401 + // }