From 09169faa579995cbacddc09c32bf427d6ff80ebf Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:32:16 +0300 Subject: [PATCH] perf(library): index local lossless albums --- .../021_lossless_album_browse_index.sql | 8 +++ .../psysonic-library/src/lossless_albums.rs | 50 +++++++++++-------- .../psysonic-library/src/lossless_formats.rs | 6 +-- .../crates/psysonic-library/src/store.rs | 13 ++++- 4 files changed, 52 insertions(+), 25 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/migrations/021_lossless_album_browse_index.sql diff --git a/src-tauri/crates/psysonic-library/migrations/021_lossless_album_browse_index.sql b/src-tauri/crates/psysonic-library/migrations/021_lossless_album_browse_index.sql new file mode 100644 index 00000000..1e8efec5 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/021_lossless_album_browse_index.sql @@ -0,0 +1,8 @@ +-- Drive Lossless Albums from the small suffix-qualified candidate set instead +-- of scanning every album track on large mixed-format libraries. +CREATE INDEX IF NOT EXISTS idx_track_lossless_album_browse + ON track(server_id, suffix COLLATE NOCASE, library_id, album_id) + WHERE deleted = 0 + AND album_id IS NOT NULL + AND album_id != '' + AND suffix IS NOT NULL; diff --git a/src-tauri/crates/psysonic-library/src/lossless_albums.rs b/src-tauri/crates/psysonic-library/src/lossless_albums.rs index 3c157f57..fd056e2d 100644 --- a/src-tauri/crates/psysonic-library/src/lossless_albums.rs +++ b/src-tauri/crates/psysonic-library/src/lossless_albums.rs @@ -62,21 +62,7 @@ pub fn list_lossless_albums( let where_sql = where_clauses.join(" AND "); let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la"); let sql = format!( - "SELECT \ - la.server_id, \ - la.album_id, \ - COALESCE(a.name, la.album_name), \ - COALESCE(a.artist, {la_artist}), \ - COALESCE(a.artist_id, la.artist_id), \ - COALESCE(a.song_count, la.track_count), \ - COALESCE(a.duration_sec, la.duration_sec), \ - COALESCE(a.year, la.year), \ - COALESCE(a.genre, la.genre), \ - COALESCE(a.cover_art_id, la.cover_art_id), \ - COALESCE(a.starred_at, la.starred_at), \ - COALESCE(a.synced_at, la.synced_at), \ - a.raw_json \ - FROM ( \ + "WITH lossless_album AS ( \ SELECT \ t.server_id, \ t.album_id, \ @@ -89,16 +75,38 @@ pub fn list_lossless_albums( MAX(t.cover_art_id) AS cover_art_id, \ MAX(t.starred_at) AS starred_at, \ MAX(t.synced_at) AS synced_at, \ - (SELECT COUNT(*) FROM track c \ - WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS track_count, \ - (SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c \ - WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS duration_sec, \ MAX(COALESCE(CAST(json_extract(t.raw_json, '$.bitDepth') AS INTEGER), 0)) AS max_bit_depth \ FROM track t \ WHERE {where_sql} \ GROUP BY t.server_id, t.album_id \ - ) la \ - LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \ + ), \ + album_totals AS ( \ + SELECT c.server_id, c.album_id, COUNT(*) AS track_count, \ + COALESCE(SUM(c.duration_sec), 0) AS duration_sec \ + FROM track c \ + INNER JOIN lossless_album la \ + ON la.server_id = c.server_id AND la.album_id = c.album_id \ + WHERE c.deleted = 0 \ + GROUP BY c.server_id, c.album_id \ + ) \ + SELECT \ + la.server_id, \ + la.album_id, \ + COALESCE(a.name, la.album_name), \ + COALESCE(a.artist, {la_artist}), \ + COALESCE(a.artist_id, la.artist_id), \ + COALESCE(a.song_count, totals.track_count), \ + COALESCE(a.duration_sec, totals.duration_sec), \ + COALESCE(a.year, la.year), \ + COALESCE(a.genre, la.genre), \ + COALESCE(a.cover_art_id, la.cover_art_id), \ + COALESCE(a.starred_at, la.starred_at), \ + COALESCE(a.synced_at, la.synced_at), \ + a.raw_json \ + FROM lossless_album la \ + INNER JOIN album_totals totals \ + ON totals.server_id = la.server_id AND totals.album_id = la.album_id \ + LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \ ORDER BY la.max_bit_depth DESC, \ COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \ la.album_id ASC \ diff --git a/src-tauri/crates/psysonic-library/src/lossless_formats.rs b/src-tauri/crates/psysonic-library/src/lossless_formats.rs index 70600122..d3d13900 100644 --- a/src-tauri/crates/psysonic-library/src/lossless_formats.rs +++ b/src-tauri/crates/psysonic-library/src/lossless_formats.rs @@ -7,14 +7,14 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[ "flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta", ]; -/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses. +/// Case-insensitive suffix predicate that can use the lossless browse index. pub fn track_is_lossless_sql(table_alias: &str) -> String { let list = LOSSLESS_SUFFIXES .iter() .map(|s| format!("'{s}'")) .collect::>() .join(", "); - format!("LOWER({table_alias}.suffix) IN ({list})") + format!("{table_alias}.suffix COLLATE NOCASE IN ({list})") } /// Album has at least one indexed lossless track (same allowlist as browse). @@ -50,6 +50,6 @@ mod tests { let sql = track_is_lossless_sql("t"); assert!(sql.contains("'flac'")); assert!(sql.contains("'tta'")); - assert!(sql.starts_with("LOWER(t.suffix) IN (")); + assert!(sql.starts_with("t.suffix COLLATE NOCASE IN (")); } } diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index fcafb5be..9c30419c 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -12,7 +12,7 @@ use tauri::Manager; /// /// Migration checklist (wiring, data backfill, open/swap path): /// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`. -pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 20; +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 21; /// One-time data repair after migration 014 (`artist.name_sort`). pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1"; @@ -66,6 +66,9 @@ pub(crate) const MIGRATION_019_MAINSTAGE_FEED_INDEXES: &str = /// Version 20: owner-scoped cache for track, album, and artist user ratings. pub(crate) const MIGRATION_020_ENTITY_USER_RATING: &str = include_str!("../migrations/020_entity_user_rating.sql"); +/// Version 21: suffix-selective candidate index for local lossless album browse. +pub(crate) const MIGRATION_021_LOSSLESS_ALBUM_BROWSE_INDEX: &str = + include_str!("../migrations/021_lossless_album_browse_index.sql"); /// Embedded migrations. Ordered ascending by `version`; the runner sorts /// defensively before applying so the source order can stay readable. @@ -80,6 +83,7 @@ const MIGRATIONS: &[(i64, &str)] = &[ (18, MIGRATION_018_ARTIST_SYNCED_INDEX), (19, MIGRATION_019_MAINSTAGE_FEED_INDEXES), (20, MIGRATION_020_ENTITY_USER_RATING), + (21, MIGRATION_021_LOSSLESS_ALBUM_BROWSE_INDEX), ]; /// Idempotent repair — also runs after the migration runner on every open so @@ -101,6 +105,12 @@ pub(crate) fn ensure_entity_user_rating_schema(conn: &Connection) -> rusqlite::R conn.execute_batch(MIGRATION_020_ENTITY_USER_RATING) } +/// Repairs a partial-v21 open where the migration marker exists but the +/// additive lossless candidate index was not retained. +pub(crate) fn ensure_lossless_album_browse_index(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(MIGRATION_021_LOSSLESS_ALBUM_BROWSE_INDEX) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MigrationOutcome { /// Every missing migration was applied (or the DB was already at head). @@ -682,6 +692,7 @@ fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> ensure_genre_tags_schema(conn)?; ensure_mainstage_feed_indexes(conn)?; ensure_entity_user_rating_schema(conn)?; + ensure_lossless_album_browse_index(conn)?; checkpoint_wal_conn(conn, "open")?; Ok(()) }