feat(library): sort control for the Tracks tab (SQL-side)

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
This commit is contained in:
vicrodh committed 2026-07-06 20:56:02 -06:00
1 parent cb48e04395
commit e379aa65f6
7 files changed
+172 -25

No files matched your search

+26 -6
View File
@@ -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<Vec<LocalTrack>, 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