perf(library): index local lossless albums

This commit is contained in:
cucadmuh
2026-07-17 00:32:16 +03:00
parent c98193ae09
commit 09169faa57
4 changed files with 52 additions and 25 deletions
@@ -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;
@@ -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 \
@@ -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::<Vec<_>>()
.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 ("));
}
}
+12 -1
View File
@@ -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(())
}