From e379aa65f620b6e88e6ce364f5dd7dbd4bd67099 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:56:02 -0600 Subject: [PATCH] feat(library): sort control for the Tracks tab (SQL-side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tracks tab is server-paginated (200 rows/page), so the new 8-option sort dropdown (Default / Artist / Title / Year / Date added, mirroring the Albums toolbar control) rides the SQL query through a validated ORDER BY allowlist in search_with_filter_page (same pattern as the albums page query), re-queries page 1 on change (gen-bumped, stale in-flight pages drop), and persists in locallibrary_prefs. Every sort key ends in id so LIMIT/OFFSET pagination is deterministic across ties. The once-only Plex page-1 merge re-sorts client-side with a comparator matching the SQL semantics. Group modes keep their client-side visual reorder on top, unchanged. Zero new msgids — all labels already exist. cc --- crates/qbz-library/src/database.rs | 32 ++++++-- .../ui/locallibrary/LocalLibraryView.slint | 39 +++++++++- crates/qbz-ui/ui/state.slint | 6 ++ crates/qbz/src/local_library.rs | 77 +++++++++++++++++-- crates/qbz/src/locallibrary_prefs.rs | 26 ++++--- crates/qbz/src/main.rs | 13 +++- crates/qbz/src/search.rs | 4 +- 7 files changed, 172 insertions(+), 25 deletions(-) diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index 22a12d9c4..4d0699790 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -2905,7 +2905,7 @@ impl LibraryDatabase { } /// Paged variant of `search_with_filter` — the performant path for the - /// Tracks tab. Returns one page (`LIMIT`/`OFFSET`) with the SAME ordering, + /// Tracks tab. Returns one page (`LIMIT`/`OFFSET`) in the `sort` order, /// so the frontend never materializes the whole table (the documented /// ~16K-row freeze). An empty `query` matches everything. pub fn search_with_filter_page( @@ -2915,6 +2915,7 @@ impl LibraryDatabase { limit: u64, include_qobuz_downloads: bool, exclude_network_folders: bool, + sort: &str, ) -> Result, LibraryError> { let pattern = format!("%{}%", query); let source_filter = if include_qobuz_downloads { @@ -2931,19 +2932,38 @@ impl LibraryDatabase { } else { "" }; + // ORDER BY clause is built from a validated allowlist so user + // input never reaches the SQL string directly. NULL years always + // sort last regardless of direction; the fallback ("default" or + // any unknown key) is the historical album-grouped order. Every + // explicit key ends in `id` so LIMIT/OFFSET pagination is + // deterministic across ties (mass ties are real: a batch scan + // shares one indexed_at). + let order_clause = match sort { + "title-asc" => "title COLLATE NOCASE, artist COLLATE NOCASE, id", + "title-desc" => "title COLLATE NOCASE DESC, artist COLLATE NOCASE, id", + "artist-asc" => "COALESCE(album_artist, artist) COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, id", + "artist-desc" => "COALESCE(album_artist, artist) COLLATE NOCASE DESC, album COLLATE NOCASE, disc_number, track_number, id", + "year-desc" => "year IS NULL, year DESC, album COLLATE NOCASE, disc_number, track_number, id", + "year-asc" => "year IS NULL, year ASC, album COLLATE NOCASE, disc_number, track_number, id", + "added-desc" => "indexed_at DESC, album COLLATE NOCASE, disc_number, track_number, id", + // Default = the pre-sort hardcoded order (album-grouped). + _ => "album COLLATE NOCASE, \ + COALESCE(album_artist, artist) COLLATE NOCASE, \ + disc_number, \ + track_number, \ + title COLLATE NOCASE", + }; let sql = format!( "SELECT {} FROM local_tracks \ WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \ {} {} \ - ORDER BY album COLLATE NOCASE, \ - COALESCE(album_artist, artist) COLLATE NOCASE, \ - disc_number, \ - track_number, \ - title COLLATE NOCASE \ + ORDER BY {} \ LIMIT ?2 OFFSET ?3", Self::TRACK_COLUMNS, source_filter, network_filter, + order_clause, ); let mut stmt = self .conn diff --git a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint index 3ed0df77e..9f8e364c0 100644 --- a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint @@ -864,7 +864,7 @@ export component LocalLibraryView inherits Rectangle { } } - // Tracks toolbar — multi-select toggle + search + group-by. + // Tracks toolbar — multi-select toggle + search + sort + group-by. if LocalLibraryState.active-tab == "tracks" && (LocalLibraryState.tracks.length > 0 || LocalLibraryState.tracks-search != ""): HorizontalLayout { @@ -889,6 +889,43 @@ export component LocalLibraryView inherits Rectangle { } } } + // SQL-side sort (pagination order; page-1 re-query on change). + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 180px; + sm: true; + options: [ + @tr("Default"), + @tr("Artist A-Z"), + @tr("Artist Z-A"), + @tr("Title A-Z"), + @tr("Title Z-A"), + @tr("Year (newest)"), + @tr("Year (oldest)"), + @tr("Date added"), + ]; + current-index: LocalLibraryState.tracks-sort == "artist-asc" ? 1 + : LocalLibraryState.tracks-sort == "artist-desc" ? 2 + : LocalLibraryState.tracks-sort == "title-asc" ? 3 + : LocalLibraryState.tracks-sort == "title-desc" ? 4 + : LocalLibraryState.tracks-sort == "year-desc" ? 5 + : LocalLibraryState.tracks-sort == "year-asc" ? 6 + : LocalLibraryState.tracks-sort == "added-desc" ? 7 + : 0; + selected(i) => { + LocalLibraryActions.tracks-set-sort( + i == 1 ? "artist-asc" + : i == 2 ? "artist-desc" + : i == 3 ? "title-asc" + : i == 4 ? "title-desc" + : i == 5 ? "year-desc" + : i == 6 ? "year-asc" + : i == 7 ? "added-desc" + : "default"); + } + } + } VerticalLayout { alignment: center; QbzSelect { diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 5689b64fa..7d95f10fc 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -1079,6 +1079,10 @@ export global LocalLibraryState { in property tracks-load-failed: false; in-out property tracks-next-offset: 0; in-out property tracks-search: ""; + // SQL-side sort (defines the pagination order): "default" | "artist-asc" + // | "artist-desc" | "title-asc" | "title-desc" | "year-desc" + // | "year-asc" | "added-desc" + in-out property tracks-sort: "default"; // Group-by + multi-select (this slice). `tracks-visible` is the // group-ordered + search-filtered render model (the list renders THIS). in property <[TrackItem]> tracks-visible: []; @@ -1212,6 +1216,8 @@ export global LocalLibraryActions { callback track-action(string /* track id */, string /* action */); // Group-by mode (off|album|artist|name), persisted. callback tracks-set-group(string /* mode */); + // SQL sort key (default|artist-asc|...|added-desc), persisted; page-1 re-query. + callback tracks-set-sort(string /* key */); // Toggle the multi-select checkbox column. callback tracks-toggle-multi-select(); // Bulk bar action: select-all | clear | queue | play-next | add-to-playlist. diff --git a/crates/qbz/src/local_library.rs b/crates/qbz/src/local_library.rs index 04d931bf1..28ae2ba66 100644 --- a/crates/qbz/src/local_library.rs +++ b/crates/qbz/src/local_library.rs @@ -1010,12 +1010,13 @@ fn fetch_tracks_page( query: String, offset: u64, plex: bool, + sort: String, ) -> Option<(Vec, bool)> { // exclude_network_folders: connectivity-keyed — see the NETWORK-FOLDER // VISIBILITY note. let exclude_network = exclude_network_folders_now(); let mut rows = crate::library_db::with_db(|db| { - db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, exclude_network) + db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, exclude_network, &sort) })?; let has_more = rows.len() as u64 == TRACKS_PAGE; if plex && offset == 0 { @@ -1029,12 +1030,64 @@ fn fetch_tracks_page( // group mode is active. let mut merged: Vec = mapped.collect(); merged.append(&mut rows); + // Plex rows live OUTSIDE `local_tracks` (no ATTACH/union) and are + // prepended ONCE on page 1 — the pre-existing shape. When an + // explicit sort is active, re-sort the merged page client-side + // with the SQL semantics so page 1 reads coherently; later pages + // are pure-local and already come back in SQL order. + if sort != "default" { + sort_tracks_like_sql(&mut merged, &sort); + } rows = merged; } } Some((rows, has_more)) } +/// Client-side comparator mirroring `search_with_filter_page`'s ORDER BY +/// allowlist over `LocalTrack` fields — only used to re-sort the Plex-merged +/// page 1 (see `fetch_tracks_page`). Case-insensitive where SQL uses +/// `COLLATE NOCASE`; NULL years sort last in both directions (SQL's +/// `year IS NULL` prefix); `Option` disc/track order (None first) matches +/// SQLite's ASC NULLs-first. `sort_by` is stable, like SQLite pagination +/// over the same ORDER BY. +fn sort_tracks_like_sql(rows: &mut [qbz_library::LocalTrack], sort: &str) { + let lc = |s: &str| s.to_lowercase(); + let artist_key = + |t: &qbz_library::LocalTrack| lc(t.album_artist.as_deref().unwrap_or(&t.artist)); + // Shared tie-breaker tail: album NOCASE, disc_number, track_number. + let album_tail = |a: &qbz_library::LocalTrack, b: &qbz_library::LocalTrack| { + lc(&a.album) + .cmp(&lc(&b.album)) + .then(a.disc_number.cmp(&b.disc_number)) + .then(a.track_number.cmp(&b.track_number)) + }; + // year: None always sorts last; direction only flips the Some/Some arm. + let year_cmp = |a: &qbz_library::LocalTrack, b: &qbz_library::LocalTrack, desc: bool| { + match (a.year, b.year) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => std::cmp::Ordering::Less, + (Some(ya), Some(yb)) => if desc { yb.cmp(&ya) } else { ya.cmp(&yb) }, + } + }; + match sort { + "title-asc" => rows.sort_by(|a, b| { + lc(&a.title).cmp(&lc(&b.title)).then(lc(&a.artist).cmp(&lc(&b.artist))) + }), + "title-desc" => rows.sort_by(|a, b| { + lc(&b.title).cmp(&lc(&a.title)).then(lc(&a.artist).cmp(&lc(&b.artist))) + }), + "artist-asc" => rows.sort_by(|a, b| artist_key(a).cmp(&artist_key(b)).then(album_tail(a, b))), + "artist-desc" => rows.sort_by(|a, b| artist_key(b).cmp(&artist_key(a)).then(album_tail(a, b))), + "year-desc" => rows.sort_by(|a, b| year_cmp(a, b, true).then(album_tail(a, b))), + "year-asc" => rows.sort_by(|a, b| year_cmp(a, b, false).then(album_tail(a, b))), + "added-desc" => rows.sort_by(|a, b| b.indexed_at.cmp(&a.indexed_at).then(album_tail(a, b))), + // "default" never reaches here (guarded at the call site). + _ => {} + } +} + fn apply_tracks(window: &AppWindow, rows: Vec, has_more: bool) { // Keep the selection-source cache in lockstep (clone BEFORE the move). *tracks_current() = rows.clone(); @@ -1132,6 +1185,15 @@ pub fn set_tracks_group(window: &AppWindow, mode: &str) { derive_tracks(window); } +/// Set the SQL sort key, persist it, re-query page 1. Sort is server-side +/// (it defines the pagination order), so unlike group-by this is a +/// gen-bumping page-1 reload, not a client-side re-derive. +pub fn set_tracks_sort(window: &AppWindow, key: &str, handle: tokio::runtime::Handle) { + window.global::().set_tracks_sort(key.into()); + crate::locallibrary_prefs::save(window); + reload_tracks(window.as_weak(), handle); +} + /// Enter/leave multi-select; leaving clears the selection. pub fn set_tracks_multi_select(window: &AppWindow, on: bool) { window.global::().set_tracks_multi_select(on); @@ -1449,12 +1511,13 @@ pub fn selected_albums_tracks_blocking(album_keys: &[String]) -> Vec(); let query = s.get_tracks_search().to_string(); + let sort = s.get_tracks_sort().to_string(); // Snapshot the Plex gate on the UI thread (the setting read is cheap but we // never want to read it off the event loop). Page 1 only merges Plex. let plex = crate::plex_settings::get().enabled; let weak = window.as_weak(); handle.spawn(async move { - let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, 0, plex)) + let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, 0, plex, sort)) .await .ok() .flatten(); @@ -1507,16 +1570,18 @@ pub fn load_more_tracks(weak: slint::Weak, handle: tokio::runtime::Ha } let offset = s.get_tracks_next_offset().max(0) as u64; let query = s.get_tracks_search().to_string(); + let sort = s.get_tracks_sort().to_string(); let gen = TRACKS_GEN.load(Ordering::SeqCst); s.set_tracks_loading_more(true); let weak2 = w.as_weak(); handle.spawn(async move { // Plex rows are merged once on page 1; later pages stay pure-local so // the local LIMIT/OFFSET stays aligned (no duplicate Plex rows). - let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, offset, false)) - .await - .ok() - .flatten(); + let result = + tokio::task::spawn_blocking(move || fetch_tracks_page(query, offset, false, sort)) + .await + .ok() + .flatten(); let _ = weak2.upgrade_in_event_loop(move |w| { let s = w.global::(); if TRACKS_GEN.load(Ordering::SeqCst) != gen { diff --git a/crates/qbz/src/locallibrary_prefs.rs b/crates/qbz/src/locallibrary_prefs.rs index 1e72e4b87..85d23d1e4 100644 --- a/crates/qbz/src/locallibrary_prefs.rs +++ b/crates/qbz/src/locallibrary_prefs.rs @@ -1,6 +1,6 @@ -//! Persistence for the LocalLibrary toolbar choices (Tracks group mode) across -//! restarts. A small json under `/qbz/locallibrary_ui.json`, mirroring -//! `favorites_prefs.rs`. Search queries are transient, not persisted. +//! Persistence for the LocalLibrary toolbar choices (Tracks group mode + sort) +//! across restarts. A small json under `/qbz/locallibrary_ui.json`, +//! mirroring `favorites_prefs.rs`. Search queries are transient, not persisted. use std::path::PathBuf; @@ -13,6 +13,8 @@ use crate::{AppWindow, LocalLibraryState}; struct Prefs { #[serde(default = "d_off")] tracks_group: String, + #[serde(default = "d_default")] + tracks_sort: String, // Path of the last-opened ephemeral folder, re-scanned on startup. None when // no ephemeral session is active. #[serde(default)] @@ -23,6 +25,7 @@ impl Default for Prefs { fn default() -> Self { Self { tracks_group: d_off(), + tracks_sort: d_default(), ephemeral_folder: None, } } @@ -32,6 +35,10 @@ fn d_off() -> String { "off".to_string() } +fn d_default() -> String { + "default".to_string() +} + fn store_path() -> Option { Some(dirs::data_dir()?.join("qbz").join("locallibrary_ui.json")) } @@ -49,9 +56,9 @@ fn read() -> Prefs { /// Apply persisted toolbar choices to LocalLibraryState. UI thread. pub fn load(window: &AppWindow) { let p = read(); - window - .global::() - .set_tracks_group_mode(p.tracks_group.into()); + let s = window.global::(); + s.set_tracks_group_mode(p.tracks_group.into()); + s.set_tracks_sort(p.tracks_sort.into()); } fn write(p: &Prefs) { @@ -70,10 +77,9 @@ fn write(p: &Prefs) { /// the ephemeral-folder path (read-modify-write). pub fn save(window: &AppWindow) { let mut p = read(); - p.tracks_group = window - .global::() - .get_tracks_group_mode() - .into(); + let s = window.global::(); + p.tracks_group = s.get_tracks_group_mode().into(); + p.tracks_sort = s.get_tracks_sort().into(); write(&p); } diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 1229e1c90..637418a76 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -15850,7 +15850,7 @@ fn main() -> Result<(), Box> { }); } - // ---- Tracks tab: group-by + multi-select + bulk ---- + // ---- Tracks tab: sort + group-by + multi-select + bulk ---- { let weak = window.as_weak(); window @@ -15861,6 +15861,17 @@ fn main() -> Result<(), Box> { } }); } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_tracks_set_sort(move |key| { + if let Some(w) = weak.upgrade() { + local_library::set_tracks_sort(&w, key.as_str(), handle.clone()); + } + }); + } { let weak = window.as_weak(); window diff --git a/crates/qbz/src/search.rs b/crates/qbz/src/search.rs index d7b96a2f1..01654db0e 100644 --- a/crates/qbz/src/search.rs +++ b/crates/qbz/src/search.rs @@ -967,7 +967,9 @@ pub async fn load_cortinilla_local( let q_log = q.clone(); let rows: Vec = tokio::task::spawn_blocking(move || { let mut rows = crate::library_db::with_db(|db| { - db.search_with_filter_page(q.trim(), 0, limit, true, exclude_network) + // "default" sort: the cortinilla has no sort control; keep the + // historical album-grouped order. + db.search_with_filter_page(q.trim(), 0, limit, true, exclude_network, "default") }) .unwrap_or_default(); if plex_enabled {