Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

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

Add optional FTS5 full-text search backend

Introduce crates/fts5 implementing the same search API shape as
Typesense but backed by SQLite FTS5. Add a migration to create and
maintain FTS5 tables/triggers and run it from create_connection_pool().
Gate Typesense imports/behavior behind a feature so consumers can opt
into the `fts5` cargo feature to switch backends.

+915 -205
+16
Cargo.lock
··· 9269 9269 "reqwest", 9270 9270 "rockbox-airplay", 9271 9271 "rockbox-chromecast", 9272 + "rockbox-fts5", 9272 9273 "rockbox-library", 9273 9274 "rockbox-playlists", 9274 9275 "rockbox-rocksky", ··· 9314 9315 ] 9315 9316 9316 9317 [[package]] 9318 + name = "rockbox-fts5" 9319 + version = "0.1.0" 9320 + dependencies = [ 9321 + "anyhow", 9322 + "rockbox-library", 9323 + "rockbox-typesense", 9324 + "sqlx", 9325 + "tokio", 9326 + "tracing", 9327 + ] 9328 + 9329 + [[package]] 9317 9330 name = "rockbox-graphql" 9318 9331 version = "0.1.0" 9319 9332 dependencies = [ ··· 9333 9346 "owo-colors 4.1.0", 9334 9347 "reqwest", 9335 9348 "rockbox-bluetooth", 9349 + "rockbox-fts5", 9336 9350 "rockbox-library", 9337 9351 "rockbox-playlists", 9338 9352 "rockbox-rocksky", ··· 9491 9505 "prost", 9492 9506 "reqwest", 9493 9507 "rockbox-bluetooth", 9508 + "rockbox-fts5", 9494 9509 "rockbox-graphql", 9495 9510 "rockbox-library", 9496 9511 "rockbox-playlists", ··· 9530 9545 "rockbox-bluetooth", 9531 9546 "rockbox-chromecast", 9532 9547 "rockbox-discovery", 9548 + "rockbox-fts5", 9533 9549 "rockbox-graphql", 9534 9550 "rockbox-library", 9535 9551 "rockbox-mpd",
+15
crates/cli/Cargo.toml
··· 18 18 rockbox-library = {path = "../library"} 19 19 rockbox-playlists = {path = "../playlists"} 20 20 rockbox-typesense = {path = "../typesense"} 21 + rockbox-fts5 = {path = "../fts5", optional = true} 21 22 rockbox-settings = {path = "../settings"} 22 23 rockbox-rocksky = {path = "../rocksky"} 23 24 tokio = {version = "1.36.0", features = ["full"]} ··· 26 27 libc.workspace = true 27 28 tracing = { workspace = true } 28 29 tracing-subscriber = { workspace = true } 30 + 31 + [features] 32 + default = [] 33 + # Use SQLite FTS5 for search instead of spawning a Typesense subprocess. 34 + # When enabled, the typesense binary is never downloaded or started and 35 + # the Typesense REST API is never called — search runs in-process against 36 + # FTS5 virtual tables maintained by triggers in `crates/library`. 37 + # 38 + # `rockbox-cli` and `rockbox-server` are linked into the same Zig binary 39 + # but built as separate staticlibs, so the feature must be passed to both 40 + # cargo invocations: 41 + # cargo build --release -p rockbox-cli --features fts5 42 + # cargo build --release -p rockbox-server --features fts5 43 + fts5 = ["dep:rockbox-fts5"]
+163 -107
crates/cli/src/lib.rs
··· 9 9 use rockbox_chromecast::_link_chromecast as _; 10 10 use rockbox_library::audio_scan::{save_audio_metadata, scan_audio_files}; 11 11 use rockbox_library::{create_connection_pool, repo}; 12 + #[cfg(not(feature = "fts5"))] 12 13 use rockbox_playlists::PlaylistStore; 13 14 #[allow(unused_imports)] 14 15 use rockbox_slim::_link_slim as _; 16 + #[cfg(not(feature = "fts5"))] 15 17 use rockbox_typesense::client::*; 18 + #[cfg(not(feature = "fts5"))] 16 19 use rockbox_typesense::types::*; 17 20 #[allow(unused_imports)] 18 21 use rockbox_upnp::_link_upnp as _; 22 + #[cfg(not(feature = "fts5"))] 19 23 use std::io::{BufRead, BufReader}; 24 + #[cfg(not(feature = "fts5"))] 20 25 use std::process::Stdio; 26 + #[cfg(not(feature = "fts5"))] 21 27 use std::sync::atomic::{AtomicI32, Ordering}; 22 28 use std::thread::sleep; 23 29 use std::time::Duration; 24 30 use std::{env, ffi::CStr}; 25 31 use std::{fs, thread}; 26 - use tracing::{error, info, warn}; 32 + #[cfg(not(feature = "fts5"))] 33 + use tracing::error; 34 + use tracing::{info, warn}; 27 35 28 36 /// PID of the spawned typesense-server child, or -1 if not yet started. 37 + #[cfg(not(feature = "fts5"))] 29 38 static TYPESENSE_PID: AtomicI32 = AtomicI32::new(-1); 30 39 31 40 /// Poll the Typesense health endpoint until it responds, giving the server 32 41 /// time to start before any collection or indexing calls are made. 42 + #[cfg(not(feature = "fts5"))] 33 43 async fn wait_for_typesense() { 34 44 let port = std::env::var("RB_TYPESENSE_PORT").unwrap_or_else(|_| "8109".to_string()); 35 45 let url = format!("http://localhost:{}/health", port); ··· 69 79 /// _exit is used because it is async-signal-safe (exit() is not). 70 80 #[cfg(unix)] 71 81 extern "C" fn handle_shutdown(_sig: libc::c_int) { 72 - let pid = TYPESENSE_PID.load(Ordering::SeqCst); 73 - if pid > 0 { 74 - unsafe { libc::kill(pid, libc::SIGTERM) }; 82 + #[cfg(not(feature = "fts5"))] 83 + { 84 + let pid = TYPESENSE_PID.load(Ordering::SeqCst); 85 + if pid > 0 { 86 + unsafe { libc::kill(pid, libc::SIGTERM) }; 87 + } 75 88 } 76 89 unsafe { libc::_exit(0) }; 77 90 } ··· 163 176 match fs::create_dir_all(format!("{}/Music", home)) { 164 177 Ok(_) => {} 165 178 Err(e) => { 166 - error!("Failed to create Music directory: {}", e); 179 + tracing::error!("Failed to create Music directory: {}", e); 167 180 } 168 181 } 169 182 ··· 178 191 }; 179 192 let path = rockbox_settings::get_music_dir().unwrap_or(format!("{}/Music", home)); 180 193 let rt = tokio::runtime::Runtime::new().unwrap(); 181 - rt.block_on(async { 182 - info!("Setting up Typesense search engine..."); 183 - rockbox_typesense::setup()?; 184 - 185 - // Wait for Typesense to accept connections before any HTTP calls. 186 - // The subprocess thread starts typesense-server concurrently, so it 187 - // may not be listening yet when we reach the first collection call. 188 - wait_for_typesense().await; 189 - 190 - info!("Connecting to library database..."); 191 - let pool = create_connection_pool().await?; 192 - let tracks = repo::track::all(pool.clone()).await?; 193 - if tracks.is_empty() || update_library { 194 - if tracks.is_empty() { 195 - info!( 196 - "Library is empty — starting first-time audio scan of: {}", 197 - path 198 - ); 199 - } else { 200 - info!( 201 - "ROCKBOX_UPDATE_LIBRARY set — rescanning audio library at: {}", 202 - path 203 - ); 204 - } 205 - match scan_audio_files(pool.clone(), path.into()).await { 206 - Ok(_) => info!("Audio scan complete"), 207 - Err(e) => error!("Failed to scan audio files: {}", e), 208 - } 209 - let tracks = repo::track::all(pool.clone()).await?; 210 - let albums = repo::album::all(pool.clone()).await?; 211 - let artists = repo::artist::all(pool.clone()).await?; 212 - 213 - info!( 214 - "Indexing {} tracks, {} albums, {} artists into Typesense...", 215 - tracks.len(), 216 - albums.len(), 217 - artists.len() 218 - ); 219 - 220 - info!("Creating Typesense collections..."); 221 - create_tracks_collection().await?; 222 - create_albums_collection().await?; 223 - create_artists_collection().await?; 224 - 225 - info!("Inserting {} tracks...", tracks.len()); 226 - insert_tracks(tracks.into_iter().map(Track::from).collect()).await?; 227 - info!("Inserting {} artists...", artists.len()); 228 - insert_artists(artists.into_iter().map(Artist::from).collect()).await?; 229 - info!("Inserting {} albums...", albums.len()); 230 - insert_albums(albums.into_iter().map(Album::from).collect()).await?; 231 - 232 - info!("Search index build complete."); 233 - } else { 234 - info!( 235 - "Library already indexed ({} tracks); skipping scan.", 236 - tracks.len() 237 - ); 238 - } 239 - 240 - info!("Setting up playlists collection..."); 241 - create_playlists_collection().await?; 242 - let playlist_store = PlaylistStore::new(pool.clone()); 243 - let saved = playlist_store.list().await.unwrap_or_default(); 244 - let smart = playlist_store 245 - .list_smart_playlists() 246 - .await 247 - .unwrap_or_default(); 248 - let ts_playlists: Vec<Playlist> = saved 249 - .into_iter() 250 - .map(|p| Playlist { 251 - id: p.id, 252 - name: p.name, 253 - description: p.description, 254 - image: p.image, 255 - is_smart: false, 256 - track_count: p.track_count, 257 - }) 258 - .chain(smart.into_iter().map(|p| Playlist { 259 - id: p.id, 260 - name: p.name, 261 - description: p.description, 262 - image: p.image, 263 - is_smart: true, 264 - track_count: 0, 265 - })) 266 - .collect(); 267 - if !ts_playlists.is_empty() { 268 - info!( 269 - "Indexing {} playlist(s) into Typesense...", 270 - ts_playlists.len() 271 - ); 272 - insert_playlists(ts_playlists).await?; 273 - info!("Playlist index complete."); 274 - } else { 275 - info!("No playlists to index."); 276 - } 277 - Ok::<(), Error>(()) 278 - }) 279 - .unwrap_or_else(|e| warn!("Library indexing failed: {}", e)); 194 + rt.block_on(async { run_indexing(path, update_library).await }) 195 + .unwrap_or_else(|e| warn!("Library indexing failed: {}", e)); 280 196 281 197 thread::spawn(move || { 282 198 sleep(Duration::from_secs(5)); ··· 319 235 info!("Rockbox Web UI is running on http://localhost:6062"); 320 236 }); 321 237 238 + spawn_typesense_subprocess(); 239 + return 0; 240 + } 241 + 242 + #[cfg(not(feature = "fts5"))] 243 + async fn run_indexing(path: String, update_library: bool) -> Result<(), Error> { 244 + info!("Setting up Typesense search engine..."); 245 + rockbox_typesense::setup()?; 246 + 247 + // Wait for Typesense to accept connections before any HTTP calls. 248 + // The subprocess thread starts typesense-server concurrently, so it 249 + // may not be listening yet when we reach the first collection call. 250 + wait_for_typesense().await; 251 + 252 + info!("Connecting to library database..."); 253 + let pool = create_connection_pool().await?; 254 + let tracks = repo::track::all(pool.clone()).await?; 255 + if tracks.is_empty() || update_library { 256 + if tracks.is_empty() { 257 + info!( 258 + "Library is empty — starting first-time audio scan of: {}", 259 + path 260 + ); 261 + } else { 262 + info!( 263 + "ROCKBOX_UPDATE_LIBRARY set — rescanning audio library at: {}", 264 + path 265 + ); 266 + } 267 + match scan_audio_files(pool.clone(), path.into()).await { 268 + Ok(_) => info!("Audio scan complete"), 269 + Err(e) => error!("Failed to scan audio files: {}", e), 270 + } 271 + let tracks = repo::track::all(pool.clone()).await?; 272 + let albums = repo::album::all(pool.clone()).await?; 273 + let artists = repo::artist::all(pool.clone()).await?; 274 + 275 + info!( 276 + "Indexing {} tracks, {} albums, {} artists into Typesense...", 277 + tracks.len(), 278 + albums.len(), 279 + artists.len() 280 + ); 281 + 282 + info!("Creating Typesense collections..."); 283 + create_tracks_collection().await?; 284 + create_albums_collection().await?; 285 + create_artists_collection().await?; 286 + 287 + info!("Inserting {} tracks...", tracks.len()); 288 + insert_tracks(tracks.into_iter().map(Track::from).collect()).await?; 289 + info!("Inserting {} artists...", artists.len()); 290 + insert_artists(artists.into_iter().map(Artist::from).collect()).await?; 291 + info!("Inserting {} albums...", albums.len()); 292 + insert_albums(albums.into_iter().map(Album::from).collect()).await?; 293 + 294 + info!("Search index build complete."); 295 + } else { 296 + info!( 297 + "Library already indexed ({} tracks); skipping scan.", 298 + tracks.len() 299 + ); 300 + } 301 + 302 + info!("Setting up playlists collection..."); 303 + create_playlists_collection().await?; 304 + let playlist_store = PlaylistStore::new(pool.clone()); 305 + let saved = playlist_store.list().await.unwrap_or_default(); 306 + let smart = playlist_store 307 + .list_smart_playlists() 308 + .await 309 + .unwrap_or_default(); 310 + let ts_playlists: Vec<Playlist> = saved 311 + .into_iter() 312 + .map(|p| Playlist { 313 + id: p.id, 314 + name: p.name, 315 + description: p.description, 316 + image: p.image, 317 + is_smart: false, 318 + track_count: p.track_count, 319 + }) 320 + .chain(smart.into_iter().map(|p| Playlist { 321 + id: p.id, 322 + name: p.name, 323 + description: p.description, 324 + image: p.image, 325 + is_smart: true, 326 + track_count: 0, 327 + })) 328 + .collect(); 329 + if !ts_playlists.is_empty() { 330 + info!( 331 + "Indexing {} playlist(s) into Typesense...", 332 + ts_playlists.len() 333 + ); 334 + insert_playlists(ts_playlists).await?; 335 + info!("Playlist index complete."); 336 + } else { 337 + info!("No playlists to index."); 338 + } 339 + Ok(()) 340 + } 341 + 342 + #[cfg(feature = "fts5")] 343 + async fn run_indexing(path: String, update_library: bool) -> Result<(), Error> { 344 + info!("Connecting to library database (FTS5 search backend)..."); 345 + let pool = create_connection_pool().await?; 346 + let tracks = repo::track::all(pool.clone()).await?; 347 + if tracks.is_empty() || update_library { 348 + if tracks.is_empty() { 349 + info!( 350 + "Library is empty — starting first-time audio scan of: {}", 351 + path 352 + ); 353 + } else { 354 + info!( 355 + "ROCKBOX_UPDATE_LIBRARY set — rescanning audio library at: {}", 356 + path 357 + ); 358 + } 359 + match scan_audio_files(pool.clone(), path.into()).await { 360 + Ok(_) => info!("Audio scan complete (FTS5 indexed via triggers)"), 361 + Err(e) => tracing::error!("Failed to scan audio files: {}", e), 362 + } 363 + } else { 364 + info!( 365 + "Library already indexed ({} tracks); FTS5 ready.", 366 + tracks.len() 367 + ); 368 + } 369 + Ok(()) 370 + } 371 + 372 + #[cfg(not(feature = "fts5"))] 373 + fn spawn_typesense_subprocess() { 322 374 thread::spawn(move || { 323 375 let api_key = uuid::Uuid::new_v4().to_string(); 324 376 let api_key = std::env::var("RB_TYPESENSE_API_KEY").unwrap_or(api_key); ··· 407 459 408 460 Ok::<(), Error>(()) 409 461 }); 410 - return 0; 462 + } 463 + 464 + #[cfg(feature = "fts5")] 465 + fn spawn_typesense_subprocess() { 466 + info!("FTS5 search backend enabled — skipping typesense-server spawn."); 411 467 } 412 468 413 469 #[no_mangle] ··· 429 485 let rt = match tokio::runtime::Runtime::new() { 430 486 Ok(rt) => rt, 431 487 Err(e) => { 432 - error!( 488 + tracing::error!( 433 489 "save_remote_track_metadata: failed to create runtime: {}", 434 490 e 435 491 ); ··· 443 499 }) { 444 500 Ok(()) => 0, 445 501 Err(e) => { 446 - error!("save_remote_track_metadata: {}", e); 502 + tracing::error!("save_remote_track_metadata: {}", e); 447 503 -1 448 504 } 449 505 }
+19
crates/fts5/Cargo.toml
··· 1 + [package] 2 + edition = "2021" 3 + name = "rockbox-fts5" 4 + version = "0.1.0" 5 + 6 + [dependencies] 7 + anyhow = "1.0.89" 8 + rockbox-library = { path = "../library" } 9 + rockbox-typesense = { path = "../typesense" } 10 + sqlx = { version = "0.8.2", features = [ 11 + "runtime-tokio", 12 + "tls-rustls", 13 + "sqlite", 14 + "chrono", 15 + "derive", 16 + "macros", 17 + ] } 18 + tokio = { version = "1.36.0", features = ["full"] } 19 + tracing = { workspace = true }
+273
crates/fts5/src/lib.rs
··· 1 + //! SQLite FTS5 backed search, exposing the same `search_*` API surface as 2 + //! `rockbox_typesense::client` so callers can swap implementations behind a 3 + //! single cargo feature. 4 + //! 5 + //! Each function returns `Option<*Result>` from `rockbox_typesense::types` so 6 + //! the result shape stays identical to the Typesense path. Numeric fields 7 + //! that don't exist in FTS5 (`text_match`, `search_time_ms`, etc.) are filled 8 + //! with sensible defaults — callers only read `hits[].document` today. 9 + 10 + use anyhow::Error; 11 + use rockbox_library::entity; 12 + use rockbox_typesense::types::{ 13 + Album, AlbumHit, AlbumResult, Artist, ArtistHit, ArtistResult, Playlist, PlaylistHit, 14 + PlaylistResult, Track, TrackHit, TrackResult, 15 + }; 16 + use sqlx::{Pool, Sqlite}; 17 + use tracing::warn; 18 + 19 + const RESULT_LIMIT: i64 = 50; 20 + 21 + /// Sanitize free-form user input into a safe FTS5 MATCH expression. 22 + /// 23 + /// FTS5 MATCH syntax interprets characters like `"`, `*`, `(`, `)`, `:`, `^`, 24 + /// `-`, `+`, `AND`, `OR`, `NOT` and `NEAR` specially; passing a raw user 25 + /// string would either error or match the wrong rows. We strip the dangerous 26 + /// punctuation, drop empty tokens, then quote each remaining token and append 27 + /// a `*` for prefix matching so partial typing (`"beyo"` → `"beyonce"`) works. 28 + fn build_match_expression(query: &str) -> Option<String> { 29 + let cleaned: String = query 30 + .chars() 31 + .map(|c| { 32 + if c.is_alphanumeric() || c.is_whitespace() { 33 + c 34 + } else { 35 + ' ' 36 + } 37 + }) 38 + .collect(); 39 + let tokens: Vec<String> = cleaned 40 + .split_whitespace() 41 + .filter(|t| !t.is_empty()) 42 + .map(|t| format!("\"{}\"*", t)) 43 + .collect(); 44 + if tokens.is_empty() { 45 + None 46 + } else { 47 + Some(tokens.join(" ")) 48 + } 49 + } 50 + 51 + pub async fn search_tracks(pool: Pool<Sqlite>, query: &str) -> Result<Option<TrackResult>, Error> { 52 + let expr = match build_match_expression(query) { 53 + Some(e) => e, 54 + None => return Ok(Some(TrackResult::default())), 55 + }; 56 + 57 + let rows: Vec<entity::track::Track> = match sqlx::query_as( 58 + r#" 59 + SELECT t.* 60 + FROM track t 61 + JOIN track_fts f ON f.id = t.id 62 + WHERE track_fts MATCH ? 63 + AND t.is_remote = 0 64 + ORDER BY rank 65 + LIMIT ? 66 + "#, 67 + ) 68 + .bind(&expr) 69 + .bind(RESULT_LIMIT) 70 + .fetch_all(&pool) 71 + .await 72 + { 73 + Ok(rows) => rows, 74 + Err(e) => { 75 + warn!("fts5 search_tracks failed: {}", e); 76 + return Ok(Some(TrackResult::default())); 77 + } 78 + }; 79 + 80 + let hits: Vec<TrackHit> = rows 81 + .into_iter() 82 + .map(|t| TrackHit { 83 + document: Track::from(t), 84 + ..Default::default() 85 + }) 86 + .collect(); 87 + let found = hits.len() as i64; 88 + 89 + Ok(Some(TrackResult { 90 + found, 91 + out_of: found, 92 + hits, 93 + ..Default::default() 94 + })) 95 + } 96 + 97 + pub async fn search_albums(pool: Pool<Sqlite>, query: &str) -> Result<Option<AlbumResult>, Error> { 98 + let expr = match build_match_expression(query) { 99 + Some(e) => e, 100 + None => return Ok(Some(AlbumResult::default())), 101 + }; 102 + 103 + let rows: Vec<entity::album::Album> = match sqlx::query_as( 104 + r#" 105 + SELECT a.* 106 + FROM album a 107 + JOIN album_fts f ON f.id = a.id 108 + WHERE album_fts MATCH ? 109 + ORDER BY rank 110 + LIMIT ? 111 + "#, 112 + ) 113 + .bind(&expr) 114 + .bind(RESULT_LIMIT) 115 + .fetch_all(&pool) 116 + .await 117 + { 118 + Ok(rows) => rows, 119 + Err(e) => { 120 + warn!("fts5 search_albums failed: {}", e); 121 + return Ok(Some(AlbumResult::default())); 122 + } 123 + }; 124 + 125 + let hits: Vec<AlbumHit> = rows 126 + .into_iter() 127 + .map(|a| AlbumHit { 128 + document: Album::from(a), 129 + ..Default::default() 130 + }) 131 + .collect(); 132 + let found = hits.len() as i64; 133 + 134 + Ok(Some(AlbumResult { 135 + found, 136 + out_of: found, 137 + hits, 138 + ..Default::default() 139 + })) 140 + } 141 + 142 + pub async fn search_artists( 143 + pool: Pool<Sqlite>, 144 + query: &str, 145 + ) -> Result<Option<ArtistResult>, Error> { 146 + let expr = match build_match_expression(query) { 147 + Some(e) => e, 148 + None => return Ok(Some(ArtistResult::default())), 149 + }; 150 + 151 + let rows: Vec<entity::artist::Artist> = match sqlx::query_as( 152 + r#" 153 + SELECT a.* 154 + FROM artist a 155 + JOIN artist_fts f ON f.id = a.id 156 + WHERE artist_fts MATCH ? 157 + ORDER BY rank 158 + LIMIT ? 159 + "#, 160 + ) 161 + .bind(&expr) 162 + .bind(RESULT_LIMIT) 163 + .fetch_all(&pool) 164 + .await 165 + { 166 + Ok(rows) => rows, 167 + Err(e) => { 168 + warn!("fts5 search_artists failed: {}", e); 169 + return Ok(Some(ArtistResult::default())); 170 + } 171 + }; 172 + 173 + let hits: Vec<ArtistHit> = rows 174 + .into_iter() 175 + .map(|a| ArtistHit { 176 + document: Artist::from(a), 177 + ..Default::default() 178 + }) 179 + .collect(); 180 + let found = hits.len() as i64; 181 + 182 + Ok(Some(ArtistResult { 183 + found, 184 + out_of: found, 185 + hits, 186 + ..Default::default() 187 + })) 188 + } 189 + 190 + #[derive(sqlx::FromRow)] 191 + struct PlaylistFtsHit { 192 + id: String, 193 + is_smart: i64, 194 + name: String, 195 + description: Option<String>, 196 + image: Option<String>, 197 + track_count: i64, 198 + } 199 + 200 + pub async fn search_playlists( 201 + pool: Pool<Sqlite>, 202 + query: &str, 203 + ) -> Result<Option<PlaylistResult>, Error> { 204 + let expr = match build_match_expression(query) { 205 + Some(e) => e, 206 + None => return Ok(Some(PlaylistResult::default())), 207 + }; 208 + 209 + // playlist_fts is the union index over saved_playlists + smart_playlists; 210 + // we re-join each side back to its source table to recover image / track 211 + // count, then UNION the two halves and re-sort by FTS rank. 212 + let rows: Vec<PlaylistFtsHit> = match sqlx::query_as( 213 + r#" 214 + SELECT p.id AS id, 215 + 0 AS is_smart, 216 + p.name AS name, 217 + p.description AS description, 218 + p.image AS image, 219 + COALESCE((SELECT COUNT(*) FROM saved_playlist_tracks t WHERE t.playlist_id = p.id), 0) AS track_count, 220 + f.rank AS rank 221 + FROM saved_playlists p 222 + JOIN playlist_fts f ON f.id = p.id AND f.is_smart = 0 223 + WHERE playlist_fts MATCH ?1 224 + UNION ALL 225 + SELECT p.id AS id, 226 + 1 AS is_smart, 227 + p.name AS name, 228 + p.description AS description, 229 + p.image AS image, 230 + 0 AS track_count, 231 + f.rank AS rank 232 + FROM smart_playlists p 233 + JOIN playlist_fts f ON f.id = p.id AND f.is_smart = 1 234 + WHERE playlist_fts MATCH ?1 235 + ORDER BY rank 236 + LIMIT ?2 237 + "#, 238 + ) 239 + .bind(&expr) 240 + .bind(RESULT_LIMIT) 241 + .fetch_all(&pool) 242 + .await 243 + { 244 + Ok(rows) => rows, 245 + Err(e) => { 246 + warn!("fts5 search_playlists failed: {}", e); 247 + return Ok(Some(PlaylistResult::default())); 248 + } 249 + }; 250 + 251 + let hits: Vec<PlaylistHit> = rows 252 + .into_iter() 253 + .map(|r| PlaylistHit { 254 + document: Playlist { 255 + id: r.id, 256 + name: r.name, 257 + description: r.description, 258 + image: r.image, 259 + is_smart: r.is_smart != 0, 260 + track_count: r.track_count, 261 + }, 262 + ..Default::default() 263 + }) 264 + .collect(); 265 + let found = hits.len() as i64; 266 + 267 + Ok(Some(PlaylistResult { 268 + found, 269 + out_of: found, 270 + hits, 271 + ..Default::default() 272 + })) 273 + }
+5
crates/graphql/Cargo.toml
··· 24 24 rockbox-playlists = {path = "../playlists"} 25 25 rockbox-rocksky = {path = "../rocksky"} 26 26 rockbox-typesense = {path = "../typesense"} 27 + rockbox-fts5 = {path = "../fts5", optional = true} 27 28 rockbox-sys = {path = "../sys"} 28 29 rockbox-types = {path = "../types"} 29 30 rockbox-webui = {path = "../../webui"} ··· 32 33 slab = "0.4.9" 33 34 sqlx = {version = "0.8.2", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "derive", "macros"]} 34 35 tokio = {version = "1.36.0", features = ["full"]} 36 + 37 + [features] 38 + default = [] 39 + fts5 = ["dep:rockbox-fts5"] 35 40 36 41 [target.'cfg(target_os = "linux")'.dependencies] 37 42 rockbox-bluetooth = {path = "../bluetooth"}
+38 -14
crates/graphql/src/schema/library.rs
··· 1 1 use async_graphql::*; 2 2 use rockbox_library::{entity::favourites::Favourites, repo}; 3 - use rockbox_typesense::client::{search_albums, search_artists, search_tracks}; 4 3 use sqlx::{Pool, Sqlite}; 5 4 6 5 use crate::{rockbox_url, schema::objects::track::Track}; ··· 74 73 Ok(results.into_iter().map(Into::into).collect()) 75 74 } 76 75 77 - async fn search(&self, _ctx: &Context<'_>, term: String) -> Result<SearchResults, Error> { 78 - let tracks = search_tracks(&term) 79 - .await? 80 - .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 81 - .unwrap_or_default(); 82 - let albums = search_albums(&term) 83 - .await? 84 - .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 85 - .unwrap_or_default(); 86 - let artists = search_artists(&term) 87 - .await? 88 - .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 89 - .unwrap_or_default(); 76 + async fn search(&self, ctx: &Context<'_>, term: String) -> Result<SearchResults, Error> { 77 + #[cfg(not(feature = "fts5"))] 78 + let (tracks, albums, artists) = { 79 + use rockbox_typesense::client::{search_albums, search_artists, search_tracks}; 80 + let _ = ctx; 81 + let tracks = search_tracks(&term) 82 + .await? 83 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 84 + .unwrap_or_default(); 85 + let albums = search_albums(&term) 86 + .await? 87 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 88 + .unwrap_or_default(); 89 + let artists = search_artists(&term) 90 + .await? 91 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 92 + .unwrap_or_default(); 93 + (tracks, albums, artists) 94 + }; 95 + 96 + #[cfg(feature = "fts5")] 97 + let (tracks, albums, artists) = { 98 + use rockbox_fts5::{search_albums, search_artists, search_tracks}; 99 + let pool = ctx.data::<Pool<Sqlite>>()?; 100 + let tracks = search_tracks(pool.clone(), &term) 101 + .await? 102 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 103 + .unwrap_or_default(); 104 + let albums = search_albums(pool.clone(), &term) 105 + .await? 106 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 107 + .unwrap_or_default(); 108 + let artists = search_artists(pool.clone(), &term) 109 + .await? 110 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 111 + .unwrap_or_default(); 112 + (tracks, albums, artists) 113 + }; 90 114 91 115 Ok(SearchResults { 92 116 tracks,
+185
crates/library/migrations/20260503000000_add_fts5_search.sql
··· 1 + -- FTS5 full-text search indexes mirroring the searchable columns of 2 + -- track / album / artist / saved_playlists / smart_playlists. 3 + -- 4 + -- These tables and triggers are always created so that toggling the 5 + -- `fts5` cargo feature on a consumer crate does not require a manual 6 + -- reindex. When the feature is off (default), Typesense is used and these 7 + -- indexes are simply maintained but never queried. 8 + -- 9 + -- Tokenizer: porter for stemming + unicode61 with diacritics removed, 10 + -- so "beyonce" matches "Beyoncé". 11 + 12 + CREATE VIRTUAL TABLE IF NOT EXISTS track_fts USING fts5( 13 + id UNINDEXED, 14 + title, 15 + artist, 16 + album, 17 + album_artist, 18 + composer, 19 + genre, 20 + path, 21 + tokenize = "porter unicode61 remove_diacritics 2" 22 + ); 23 + 24 + CREATE TRIGGER IF NOT EXISTS track_fts_ai AFTER INSERT ON track BEGIN 25 + INSERT INTO track_fts(id, title, artist, album, album_artist, composer, genre, path) 26 + VALUES ( 27 + new.id, 28 + COALESCE(new.title, ''), 29 + COALESCE(new.artist, ''), 30 + COALESCE(new.album, ''), 31 + COALESCE(new.album_artist, ''), 32 + COALESCE(new.composer, ''), 33 + COALESCE(new.genre, ''), 34 + COALESCE(new.path, '') 35 + ); 36 + END; 37 + 38 + CREATE TRIGGER IF NOT EXISTS track_fts_ad AFTER DELETE ON track BEGIN 39 + DELETE FROM track_fts WHERE id = old.id; 40 + END; 41 + 42 + CREATE TRIGGER IF NOT EXISTS track_fts_au AFTER UPDATE ON track BEGIN 43 + DELETE FROM track_fts WHERE id = old.id; 44 + INSERT INTO track_fts(id, title, artist, album, album_artist, composer, genre, path) 45 + VALUES ( 46 + new.id, 47 + COALESCE(new.title, ''), 48 + COALESCE(new.artist, ''), 49 + COALESCE(new.album, ''), 50 + COALESCE(new.album_artist, ''), 51 + COALESCE(new.composer, ''), 52 + COALESCE(new.genre, ''), 53 + COALESCE(new.path, '') 54 + ); 55 + END; 56 + 57 + -- Backfill any tracks already present (idempotent). 58 + INSERT INTO track_fts(id, title, artist, album, album_artist, composer, genre, path) 59 + SELECT 60 + t.id, 61 + COALESCE(t.title, ''), 62 + COALESCE(t.artist, ''), 63 + COALESCE(t.album, ''), 64 + COALESCE(t.album_artist, ''), 65 + COALESCE(t.composer, ''), 66 + COALESCE(t.genre, ''), 67 + COALESCE(t.path, '') 68 + FROM track t 69 + WHERE NOT EXISTS (SELECT 1 FROM track_fts f WHERE f.id = t.id); 70 + 71 + CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( 72 + id UNINDEXED, 73 + title, 74 + artist, 75 + label, 76 + tokenize = "porter unicode61 remove_diacritics 2" 77 + ); 78 + 79 + CREATE TRIGGER IF NOT EXISTS album_fts_ai AFTER INSERT ON album BEGIN 80 + INSERT INTO album_fts(id, title, artist, label) 81 + VALUES ( 82 + new.id, 83 + COALESCE(new.title, ''), 84 + COALESCE(new.artist, ''), 85 + COALESCE(new.label, '') 86 + ); 87 + END; 88 + 89 + CREATE TRIGGER IF NOT EXISTS album_fts_ad AFTER DELETE ON album BEGIN 90 + DELETE FROM album_fts WHERE id = old.id; 91 + END; 92 + 93 + CREATE TRIGGER IF NOT EXISTS album_fts_au AFTER UPDATE ON album BEGIN 94 + DELETE FROM album_fts WHERE id = old.id; 95 + INSERT INTO album_fts(id, title, artist, label) 96 + VALUES ( 97 + new.id, 98 + COALESCE(new.title, ''), 99 + COALESCE(new.artist, ''), 100 + COALESCE(new.label, '') 101 + ); 102 + END; 103 + 104 + INSERT INTO album_fts(id, title, artist, label) 105 + SELECT a.id, COALESCE(a.title, ''), COALESCE(a.artist, ''), COALESCE(a.label, '') 106 + FROM album a 107 + WHERE NOT EXISTS (SELECT 1 FROM album_fts f WHERE f.id = a.id); 108 + 109 + CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( 110 + id UNINDEXED, 111 + name, 112 + bio, 113 + tokenize = "porter unicode61 remove_diacritics 2" 114 + ); 115 + 116 + CREATE TRIGGER IF NOT EXISTS artist_fts_ai AFTER INSERT ON artist BEGIN 117 + INSERT INTO artist_fts(id, name, bio) 118 + VALUES (new.id, COALESCE(new.name, ''), COALESCE(new.bio, '')); 119 + END; 120 + 121 + CREATE TRIGGER IF NOT EXISTS artist_fts_ad AFTER DELETE ON artist BEGIN 122 + DELETE FROM artist_fts WHERE id = old.id; 123 + END; 124 + 125 + CREATE TRIGGER IF NOT EXISTS artist_fts_au AFTER UPDATE ON artist BEGIN 126 + DELETE FROM artist_fts WHERE id = old.id; 127 + INSERT INTO artist_fts(id, name, bio) 128 + VALUES (new.id, COALESCE(new.name, ''), COALESCE(new.bio, '')); 129 + END; 130 + 131 + INSERT INTO artist_fts(id, name, bio) 132 + SELECT a.id, COALESCE(a.name, ''), COALESCE(a.bio, '') 133 + FROM artist a 134 + WHERE NOT EXISTS (SELECT 1 FROM artist_fts f WHERE f.id = a.id); 135 + 136 + -- Single FTS table covers both saved_playlists and smart_playlists; the 137 + -- `is_smart` column tells the query layer which source table to pull 138 + -- the document from when reconstructing results. 139 + CREATE VIRTUAL TABLE IF NOT EXISTS playlist_fts USING fts5( 140 + id UNINDEXED, 141 + is_smart UNINDEXED, 142 + name, 143 + description, 144 + tokenize = "porter unicode61 remove_diacritics 2" 145 + ); 146 + 147 + CREATE TRIGGER IF NOT EXISTS saved_playlist_fts_ai AFTER INSERT ON saved_playlists BEGIN 148 + INSERT INTO playlist_fts(id, is_smart, name, description) 149 + VALUES (new.id, 0, COALESCE(new.name, ''), COALESCE(new.description, '')); 150 + END; 151 + 152 + CREATE TRIGGER IF NOT EXISTS saved_playlist_fts_ad AFTER DELETE ON saved_playlists BEGIN 153 + DELETE FROM playlist_fts WHERE id = old.id AND is_smart = 0; 154 + END; 155 + 156 + CREATE TRIGGER IF NOT EXISTS saved_playlist_fts_au AFTER UPDATE ON saved_playlists BEGIN 157 + DELETE FROM playlist_fts WHERE id = old.id AND is_smart = 0; 158 + INSERT INTO playlist_fts(id, is_smart, name, description) 159 + VALUES (new.id, 0, COALESCE(new.name, ''), COALESCE(new.description, '')); 160 + END; 161 + 162 + CREATE TRIGGER IF NOT EXISTS smart_playlist_fts_ai AFTER INSERT ON smart_playlists BEGIN 163 + INSERT INTO playlist_fts(id, is_smart, name, description) 164 + VALUES (new.id, 1, COALESCE(new.name, ''), COALESCE(new.description, '')); 165 + END; 166 + 167 + CREATE TRIGGER IF NOT EXISTS smart_playlist_fts_ad AFTER DELETE ON smart_playlists BEGIN 168 + DELETE FROM playlist_fts WHERE id = old.id AND is_smart = 1; 169 + END; 170 + 171 + CREATE TRIGGER IF NOT EXISTS smart_playlist_fts_au AFTER UPDATE ON smart_playlists BEGIN 172 + DELETE FROM playlist_fts WHERE id = old.id AND is_smart = 1; 173 + INSERT INTO playlist_fts(id, is_smart, name, description) 174 + VALUES (new.id, 1, COALESCE(new.name, ''), COALESCE(new.description, '')); 175 + END; 176 + 177 + INSERT INTO playlist_fts(id, is_smart, name, description) 178 + SELECT p.id, 0, COALESCE(p.name, ''), COALESCE(p.description, '') 179 + FROM saved_playlists p 180 + WHERE NOT EXISTS (SELECT 1 FROM playlist_fts f WHERE f.id = p.id AND f.is_smart = 0); 181 + 182 + INSERT INTO playlist_fts(id, is_smart, name, description) 183 + SELECT p.id, 1, COALESCE(p.name, ''), COALESCE(p.description, '') 184 + FROM smart_playlists p 185 + WHERE NOT EXISTS (SELECT 1 FROM playlist_fts f WHERE f.id = p.id AND f.is_smart = 1);
+10
crates/library/src/lib.rs
··· 102 102 )) 103 103 .await?; 104 104 105 + match pool 106 + .execute(include_str!( 107 + "../migrations/20260503000000_add_fts5_search.sql" 108 + )) 109 + .await 110 + { 111 + Ok(_) => {} 112 + Err(e) => warn!("fts5 migration: {}", e), 113 + } 114 + 105 115 sqlx::query("PRAGMA journal_mode=WAL") 106 116 .execute(&pool) 107 117 .await?;
+5
crates/rpc/Cargo.toml
··· 23 23 rockbox-playlists = { path = "../playlists" } # needed for Playlist/PlaylistFolder type deserialization 24 24 rockbox-rocksky = {path = "../rocksky"} 25 25 rockbox-typesense = { path = "../typesense" } 26 + rockbox-fts5 = { path = "../fts5", optional = true } 26 27 rockbox-sys = { path = "../sys" } 27 28 rockbox-types = { path = "../types" } 28 29 serde = { version = "1.0.210", features = ["derive"] } ··· 40 41 tonic = "0.12.3" 41 42 tonic-reflection = "0.12.2" 42 43 tonic-web = "0.12.3" 44 + 45 + [features] 46 + default = [] 47 + fts5 = ["dep:rockbox-fts5"] 43 48 44 49 [build-dependencies] 45 50 tonic-build = "0.12.3"
+77 -33
crates/rpc/src/library.rs
··· 2 2 3 3 use rockbox_graphql::{simplebroker::SimpleBroker, types::ScanCompleted}; 4 4 use rockbox_library::{entity::favourites::Favourites, repo}; 5 - use rockbox_typesense::client::{search_albums, search_artists, search_playlists, search_tracks}; 6 5 use sqlx::Sqlite; 7 6 use tokio_stream::{Stream, StreamExt}; 8 7 ··· 273 272 let request = request.into_inner(); 274 273 let term = request.term; 275 274 276 - let tracks = search_tracks(&term) 277 - .await 278 - .map_err(|e| tonic::Status::internal(e.to_string()))? 279 - .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 280 - .unwrap_or_default(); 281 - let albums = search_albums(&term) 282 - .await 283 - .map_err(|e| tonic::Status::internal(e.to_string()))? 284 - .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 285 - .unwrap_or_default(); 286 - let artists = search_artists(&term) 287 - .await 288 - .map_err(|e| tonic::Status::internal(e.to_string()))? 289 - .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 290 - .unwrap_or_default(); 291 - let playlists = search_playlists(&term) 292 - .await 293 - .unwrap_or_default() 294 - .map(|r| { 295 - r.hits 296 - .into_iter() 297 - .map(|h| SearchPlaylist { 298 - id: h.document.id, 299 - name: h.document.name, 300 - description: h.document.description, 301 - image: h.document.image, 302 - is_smart: h.document.is_smart, 303 - track_count: h.document.track_count, 304 - }) 305 - .collect() 306 - }) 307 - .unwrap_or_default(); 275 + #[cfg(not(feature = "fts5"))] 276 + let (tracks, albums, artists, playlists) = { 277 + use rockbox_typesense::client::{ 278 + search_albums, search_artists, search_playlists, search_tracks, 279 + }; 280 + let tracks = search_tracks(&term) 281 + .await 282 + .map_err(|e| tonic::Status::internal(e.to_string()))? 283 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 284 + .unwrap_or_default(); 285 + let albums = search_albums(&term) 286 + .await 287 + .map_err(|e| tonic::Status::internal(e.to_string()))? 288 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 289 + .unwrap_or_default(); 290 + let artists = search_artists(&term) 291 + .await 292 + .map_err(|e| tonic::Status::internal(e.to_string()))? 293 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 294 + .unwrap_or_default(); 295 + let playlists = search_playlists(&term) 296 + .await 297 + .unwrap_or_default() 298 + .map(|r| { 299 + r.hits 300 + .into_iter() 301 + .map(|h| SearchPlaylist { 302 + id: h.document.id, 303 + name: h.document.name, 304 + description: h.document.description, 305 + image: h.document.image, 306 + is_smart: h.document.is_smart, 307 + track_count: h.document.track_count, 308 + }) 309 + .collect() 310 + }) 311 + .unwrap_or_default(); 312 + (tracks, albums, artists, playlists) 313 + }; 314 + 315 + #[cfg(feature = "fts5")] 316 + let (tracks, albums, artists, playlists) = { 317 + use rockbox_fts5::{search_albums, search_artists, search_playlists, search_tracks}; 318 + let tracks = search_tracks(self.pool.clone(), &term) 319 + .await 320 + .map_err(|e| tonic::Status::internal(e.to_string()))? 321 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 322 + .unwrap_or_default(); 323 + let albums = search_albums(self.pool.clone(), &term) 324 + .await 325 + .map_err(|e| tonic::Status::internal(e.to_string()))? 326 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 327 + .unwrap_or_default(); 328 + let artists = search_artists(self.pool.clone(), &term) 329 + .await 330 + .map_err(|e| tonic::Status::internal(e.to_string()))? 331 + .map(|r| r.hits.into_iter().map(|h| h.document.into()).collect()) 332 + .unwrap_or_default(); 333 + let playlists = search_playlists(self.pool.clone(), &term) 334 + .await 335 + .unwrap_or_default() 336 + .map(|r| { 337 + r.hits 338 + .into_iter() 339 + .map(|h| SearchPlaylist { 340 + id: h.document.id, 341 + name: h.document.name, 342 + description: h.document.description, 343 + image: h.document.image, 344 + is_smart: h.document.is_smart, 345 + track_count: h.document.track_count, 346 + }) 347 + .collect() 348 + }) 349 + .unwrap_or_default(); 350 + (tracks, albums, artists, playlists) 351 + }; 308 352 309 353 Ok(tonic::Response::new(SearchResponse { 310 354 tracks,
+10 -2
crates/rpc/src/saved_playlist.rs
··· 1 1 use rockbox_playlists::{Playlist, PlaylistFolder, PlaylistStore}; 2 + #[cfg(not(feature = "fts5"))] 2 3 use rockbox_typesense::client::{delete_playlist as ts_delete_playlist, insert_playlists}; 4 + #[cfg(not(feature = "fts5"))] 3 5 use rockbox_typesense::types::Playlist as TsPlaylist; 4 6 5 7 use crate::api::rockbox::v1alpha1::{ ··· 27 29 } 28 30 } 29 31 32 + #[cfg(not(feature = "fts5"))] 30 33 fn to_ts_playlist(p: &Playlist) -> TsPlaylist { 31 34 TsPlaylist { 32 35 id: p.id.clone(), ··· 160 163 .await 161 164 .map_err(|e| tonic::Status::internal(e.to_string()))?; 162 165 } 163 - let ts_p = to_ts_playlist(&playlist); 164 - let _ = insert_playlists(vec![ts_p]).await; 166 + #[cfg(not(feature = "fts5"))] 167 + { 168 + let ts_p = to_ts_playlist(&playlist); 169 + let _ = insert_playlists(vec![ts_p]).await; 170 + } 165 171 Ok(tonic::Response::new(CreateSavedPlaylistResponse { 166 172 playlist: Some(to_proto_playlist(playlist)), 167 173 })) ··· 182 188 ) 183 189 .await 184 190 .map_err(|e| tonic::Status::internal(e.to_string()))?; 191 + #[cfg(not(feature = "fts5"))] 185 192 if let Ok(Some(updated)) = self.store.get(&req.id).await { 186 193 let ts_p = to_ts_playlist(&updated); 187 194 let _ = insert_playlists(vec![ts_p]).await; ··· 198 205 .delete(&id) 199 206 .await 200 207 .map_err(|e| tonic::Status::internal(e.to_string()))?; 208 + #[cfg(not(feature = "fts5"))] 201 209 let _ = ts_delete_playlist(&id).await; 202 210 Ok(tonic::Response::new(DeleteSavedPlaylistResponse {})) 203 211 }
+16 -9
crates/rpc/src/smart_playlist.rs
··· 1 1 use rockbox_playlists::{SmartPlaylist, TrackStats}; 2 + #[cfg(not(feature = "fts5"))] 2 3 use rockbox_typesense::client::{delete_playlist as ts_delete_playlist, insert_playlists}; 4 + #[cfg(not(feature = "fts5"))] 3 5 use rockbox_typesense::types::Playlist as TsPlaylist; 4 6 5 7 use crate::api::rockbox::v1alpha1::{ ··· 314 316 ) 315 317 .await 316 318 .map_err(|e| tonic::Status::internal(e.to_string()))?; 317 - let ts_p = TsPlaylist { 318 - id: playlist.id.clone(), 319 - name: playlist.name.clone(), 320 - description: playlist.description.clone(), 321 - image: playlist.image.clone(), 322 - is_smart: true, 323 - track_count: 0, 324 - }; 325 - let _ = insert_playlists(vec![ts_p]).await; 319 + #[cfg(not(feature = "fts5"))] 320 + { 321 + let ts_p = TsPlaylist { 322 + id: playlist.id.clone(), 323 + name: playlist.name.clone(), 324 + description: playlist.description.clone(), 325 + image: playlist.image.clone(), 326 + is_smart: true, 327 + track_count: 0, 328 + }; 329 + let _ = insert_playlists(vec![ts_p]).await; 330 + } 326 331 Ok(tonic::Response::new(CreateSmartPlaylistResponse { 327 332 playlist: Some(to_proto_smart_playlist(playlist)), 328 333 })) ··· 349 354 ) 350 355 .await 351 356 .map_err(|e| tonic::Status::internal(e.to_string()))?; 357 + #[cfg(not(feature = "fts5"))] 352 358 if let Ok(Some(updated)) = self.store.get_smart_playlist(&req.id).await { 353 359 let ts_p = TsPlaylist { 354 360 id: updated.id.clone(), ··· 372 378 .delete_smart_playlist(&id) 373 379 .await 374 380 .map_err(|e| tonic::Status::internal(e.to_string()))?; 381 + #[cfg(not(feature = "fts5"))] 375 382 let _ = ts_delete_playlist(&id).await; 376 383 Ok(tonic::Response::new(DeleteSmartPlaylistResponse {})) 377 384 }
+7
crates/server/Cargo.toml
··· 33 33 rockbox-rpc = {path = "../rpc"} 34 34 rockbox-settings = {path = "../settings"} 35 35 rockbox-typesense = { path = "../typesense" } 36 + rockbox-fts5 = { path = "../fts5", optional = true } 36 37 netstream = { path = "../netstream" } 37 38 rockbox-sys = {path = "../sys"} 38 39 rockbox-tracklist = {path = "../tracklist"} ··· 45 46 url = "2.3.1" 46 47 urlencoding = "2.1.3" 47 48 tracing = { workspace = true } 49 + 50 + [features] 51 + default = [] 52 + # Swap Typesense REST calls for SQLite FTS5 in handlers and downstream 53 + # services (graphql, rpc). See `crates/cli/Cargo.toml` for usage. 54 + fts5 = ["dep:rockbox-fts5", "rockbox-graphql/fts5", "rockbox-rpc/fts5"] 48 55 49 56 [target.'cfg(target_os = "linux")'.dependencies] 50 57 rockbox-bluetooth = { path = "../bluetooth" }
+39 -12
crates/server/src/handlers/search.rs
··· 1 1 use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 2 - use rockbox_typesense::client::{search_albums, search_artists, search_tracks}; 3 2 use serde::{Deserialize, Serialize}; 3 + 4 + use crate::http::AppState; 4 5 5 6 type HandlerResult = actix_web::Result<HttpResponse>; 6 7 ··· 16 17 artists: Vec<rockbox_typesense::types::Artist>, 17 18 } 18 19 19 - pub async fn search(query: web::Query<SearchQuery>) -> HandlerResult { 20 - let term = query.q.as_deref().unwrap_or_default(); 21 - 20 + #[cfg(not(feature = "fts5"))] 21 + async fn run_search(_state: &AppState, term: &str) -> Result<SearchResponse, anyhow::Error> { 22 + use rockbox_typesense::client::{search_albums, search_artists, search_tracks}; 22 23 let tracks = search_tracks(term) 23 - .await 24 - .map_err(ErrorInternalServerError)? 24 + .await? 25 25 .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 26 26 .unwrap_or_default(); 27 27 let albums = search_albums(term) 28 - .await 29 - .map_err(ErrorInternalServerError)? 28 + .await? 30 29 .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 31 30 .unwrap_or_default(); 32 31 let artists = search_artists(term) 33 - .await 34 - .map_err(ErrorInternalServerError)? 32 + .await? 35 33 .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 36 34 .unwrap_or_default(); 35 + Ok(SearchResponse { 36 + tracks, 37 + albums, 38 + artists, 39 + }) 40 + } 37 41 38 - Ok(HttpResponse::Ok().json(SearchResponse { 42 + #[cfg(feature = "fts5")] 43 + async fn run_search(state: &AppState, term: &str) -> Result<SearchResponse, anyhow::Error> { 44 + use rockbox_fts5::{search_albums, search_artists, search_tracks}; 45 + let tracks = search_tracks(state.pool.clone(), term) 46 + .await? 47 + .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 48 + .unwrap_or_default(); 49 + let albums = search_albums(state.pool.clone(), term) 50 + .await? 51 + .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 52 + .unwrap_or_default(); 53 + let artists = search_artists(state.pool.clone(), term) 54 + .await? 55 + .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 56 + .unwrap_or_default(); 57 + Ok(SearchResponse { 39 58 tracks, 40 59 albums, 41 60 artists, 42 - })) 61 + }) 62 + } 63 + 64 + pub async fn search(state: web::Data<AppState>, query: web::Query<SearchQuery>) -> HandlerResult { 65 + let term = query.q.as_deref().unwrap_or_default(); 66 + let response = run_search(&state, term) 67 + .await 68 + .map_err(ErrorInternalServerError)?; 69 + Ok(HttpResponse::Ok().json(response)) 43 70 }
+37 -28
crates/server/src/handlers/system.rs
··· 2 2 3 3 use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 4 4 use rockbox_graphql::{simplebroker::SimpleBroker, types::ScanCompleted}; 5 - use rockbox_library::{artists::update_metadata, audio_scan::scan_audio_files, repo}; 5 + #[cfg(not(feature = "fts5"))] 6 + use rockbox_library::repo; 7 + use rockbox_library::{artists::update_metadata, audio_scan::scan_audio_files}; 6 8 use rockbox_sys as rb; 9 + #[cfg(not(feature = "fts5"))] 7 10 use rockbox_typesense::{client::*, types::*}; 8 11 use serde::Deserialize; 9 12 ··· 60 63 return Ok(HttpResponse::Ok().body("0")); 61 64 } 62 65 63 - let tracks = repo::track::all(state.pool.clone()) 64 - .await 65 - .map_err(ErrorInternalServerError)?; 66 - let albums = repo::album::all(state.pool.clone()) 67 - .await 68 - .map_err(ErrorInternalServerError)?; 69 - let artists = repo::artist::all(state.pool.clone()) 70 - .await 71 - .map_err(ErrorInternalServerError)?; 66 + // FTS5 indexes are kept current by triggers on the track/album/artist 67 + // tables, so a rebuild after a scan is a no-op for that backend. Only 68 + // Typesense needs the explicit re-import. 69 + #[cfg(not(feature = "fts5"))] 70 + { 71 + let tracks = repo::track::all(state.pool.clone()) 72 + .await 73 + .map_err(ErrorInternalServerError)?; 74 + let albums = repo::album::all(state.pool.clone()) 75 + .await 76 + .map_err(ErrorInternalServerError)?; 77 + let artists = repo::artist::all(state.pool.clone()) 78 + .await 79 + .map_err(ErrorInternalServerError)?; 72 80 73 - create_tracks_collection() 74 - .await 75 - .map_err(ErrorInternalServerError)?; 76 - create_albums_collection() 77 - .await 78 - .map_err(ErrorInternalServerError)?; 79 - create_artists_collection() 80 - .await 81 - .map_err(ErrorInternalServerError)?; 81 + create_tracks_collection() 82 + .await 83 + .map_err(ErrorInternalServerError)?; 84 + create_albums_collection() 85 + .await 86 + .map_err(ErrorInternalServerError)?; 87 + create_artists_collection() 88 + .await 89 + .map_err(ErrorInternalServerError)?; 82 90 83 - insert_tracks(tracks.into_iter().map(Track::from).collect()) 84 - .await 85 - .map_err(ErrorInternalServerError)?; 86 - insert_artists(artists.into_iter().map(Artist::from).collect()) 87 - .await 88 - .map_err(ErrorInternalServerError)?; 89 - insert_albums(albums.into_iter().map(Album::from).collect()) 90 - .await 91 - .map_err(ErrorInternalServerError)?; 91 + insert_tracks(tracks.into_iter().map(Track::from).collect()) 92 + .await 93 + .map_err(ErrorInternalServerError)?; 94 + insert_artists(artists.into_iter().map(Artist::from).collect()) 95 + .await 96 + .map_err(ErrorInternalServerError)?; 97 + insert_albums(albums.into_iter().map(Album::from).collect()) 98 + .await 99 + .map_err(ErrorInternalServerError)?; 100 + } 92 101 93 102 SimpleBroker::publish(ScanCompleted); 94 103 Ok(HttpResponse::Ok().body("0"))