feat(local-library): server-paginated chunked store for Albums grid
Phase A of the Plex-style port: the Albums grid no longer keeps the
full collection (1200+ rows on the user's library) in reactive Svelte
state. Instead, it pulls a single chunk at a time from a new Tauri
command, with the recycling-grid pool firing `requestIndex` for
indices whose data hasn't arrived yet.
Why this matters: Phase B (recycling pool against an in-memory array)
removed mount/unmount churn during scroll but left two costs in
place — every reactive tick still ran the `$derived` filter/sort
pipeline over all 1200 records, and the records themselves still
sat in Svelte's reactivity graph. Under software compositing the
combined cost was still the dominant scroll-time spike.
Server-side pagination eliminates both: sort + filter run in SQL,
only the visible chunk's records are in memory, and the per-tick
$derived work disappears.
Backend:
- `qbz-library/src/models.rs` — new `AlbumsMetadataPage` struct
(`{ albums, total }`) returned by the paginated query.
- `qbz-library/src/database.rs` — `get_albums_metadata_page`:
reuses the existing track-aggregation CTE from
`get_albums_metadata_grouped`, adds a HAVING-style search filter
over the aggregated title/artist (mirroring the legacy
`matchesAlbumSearchFast` semantics), validated allowlist-based
`ORDER BY` for artist/title/year × asc/desc, and
`LIMIT ?/OFFSET ?` on the filtered set. Total comes from
`COUNT(*) OVER ()` in the same query for one round-trip; a
fallback `count_albums_metadata_for_page` covers the empty-page
case where the window function has no row to attach to.
- `src-tauri/src/commands_v2/library.rs` — new
`v2_library_get_albums_page` Tauri command, registered in
`lib.rs`. `include_qobuz_downloads` is read from
`DownloadSettingsStore` to match the legacy
`v2_library_get_albums_metadata` behavior.
Frontend:
- `src/lib/discovery-v2/localAlbumsChunkedStore.svelte.ts` — new
class-backed store with `version` + `total` as `$state`, sparse
`Map<chunkIdx, LocalAlbumLite[]>`, in-flight `Set<chunkIdx>`
dedup, and a generation counter that drops stale responses if
the user switches filters mid-flight. `getAlbum(globalIdx)` is
sync (null for missing chunks); `requestIndex(globalIdx)` fires
the fetch.
- `src/lib/components/views/LocalLibraryView.svelte` — when the
Albums tab is in grid mode AND grouping is off AND no quality
filters are active (the common case), render through
`AlbumGridPool` bound to the chunked store. Other paths
(grouped, filtered, list mode) keep the legacy
`VirtualizedAlbumList` so we don't lose features we haven't
server-side yet. A `$effect` mirrors search/sort/source state
into `setParams` whenever they change; the store no-ops on
unchanged params.
Plex consolidation (other source listed in the audit) deferred to a
follow-up: `plex_cache.db` is a separate SQLite file, so unifying
needs `ATTACH DATABASE` + `UNION ALL` from the same aggregation,
which is a clean delta once the local-only path is verified.
cc
This commit is contained in:
6 files changed
+542
-7
No files matched your search
@@ -1879,6 +1879,277 @@ impl LibraryDatabase {
|
||||
Ok(albums)
|
||||
}
|
||||
|
||||
/// Paginated, sort/filter-aware slice of metadata-grouped local
|
||||
/// albums. Designed to back the chunked-store + recycling-grid pool
|
||||
/// on the frontend: caller asks for `[offset, offset+limit)` and
|
||||
/// receives those rows plus the total count of rows matching the
|
||||
/// same filter (via `COUNT(*) OVER ()`).
|
||||
///
|
||||
/// Sort: one of `"artist"` (default), `"title"`, `"year"`, paired
|
||||
/// with direction `"asc"` (default) or `"desc"`. Unknown values
|
||||
/// fall back to artist-ascending. Albums with no `year` always sink
|
||||
/// to the bottom for the year sort.
|
||||
///
|
||||
/// Search: a non-empty `search` becomes a `LIKE '%pattern%'` match
|
||||
/// applied after aggregation against the album's title or artist
|
||||
/// (mirrors the legacy in-memory `matchesAlbumSearchFast`).
|
||||
///
|
||||
/// Source: Plex cache lives in a separate database and is NOT yet
|
||||
/// merged here — caller orchestrates Plex separately for now. The
|
||||
/// long-term plan is `ATTACH DATABASE 'plex_cache.db'` and a
|
||||
/// `UNION ALL` from the same aggregation; deferring until the
|
||||
/// local-only path is verified.
|
||||
pub fn get_albums_metadata_page(
|
||||
&self,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
search: Option<&str>,
|
||||
sort_by: &str,
|
||||
sort_dir: &str,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
} else {
|
||||
"AND (source IS NULL OR source != 'qobuz_download')"
|
||||
};
|
||||
|
||||
let network_filter = if exclude_network_folders {
|
||||
"AND NOT EXISTS (
|
||||
SELECT 1 FROM library_folders nf
|
||||
WHERE nf.is_network = 1
|
||||
AND local_tracks.file_path LIKE nf.path || '%'
|
||||
)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
// ORDER BY clause is built from a validated allowlist so user
|
||||
// input never reaches the SQL string directly. The unknown-album
|
||||
// sentinel always sorts last regardless of mode.
|
||||
let order_clause = match (sort_by, sort_dir) {
|
||||
("title", "asc") => "(group_key = '__unknown_album__'), title COLLATE NOCASE",
|
||||
("title", "desc") => "(group_key = '__unknown_album__'), title COLLATE NOCASE DESC",
|
||||
("year", "asc") => "(group_key = '__unknown_album__'), year IS NULL, year ASC, title COLLATE NOCASE",
|
||||
("year", "desc") => "(group_key = '__unknown_album__'), year IS NULL, year DESC, title COLLATE NOCASE",
|
||||
("artist", "desc") => "(group_key = '__unknown_album__'), artist COLLATE NOCASE DESC, title COLLATE NOCASE",
|
||||
// Default = artist asc
|
||||
_ => "(group_key = '__unknown_album__'), artist COLLATE NOCASE, title COLLATE NOCASE",
|
||||
};
|
||||
|
||||
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
|
||||
|
||||
let search_pattern = search.unwrap_or("").trim();
|
||||
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
|
||||
let search_like = format!("%{}%", search_pattern);
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
{group_key} AS group_key,
|
||||
COALESCE(album_group_title, album, 'Unknown Album') AS title,
|
||||
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
|
||||
year,
|
||||
catalog_number,
|
||||
artwork_path,
|
||||
duration_secs,
|
||||
format,
|
||||
bit_depth,
|
||||
sample_rate,
|
||||
album_group_key AS source_folder,
|
||||
COALESCE(source, 'user') AS source,
|
||||
artist AS track_artist
|
||||
FROM local_tracks
|
||||
WHERE 1=1 {source_filter} {network_filter}
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT
|
||||
group_key,
|
||||
CASE WHEN group_key = '__unknown_album__'
|
||||
THEN 'Unknown Album'
|
||||
ELSE MIN(title)
|
||||
END AS title,
|
||||
CASE WHEN COUNT(DISTINCT track_artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE MIN(artist)
|
||||
END AS artist,
|
||||
GROUP_CONCAT(DISTINCT track_artist) AS all_artists,
|
||||
MIN(year) AS year,
|
||||
MIN(catalog_number) AS catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
|
||||
COUNT(*) AS track_count,
|
||||
SUM(duration_secs) AS total_duration,
|
||||
MAX(format) AS format,
|
||||
MAX(bit_depth) AS bit_depth,
|
||||
MAX(sample_rate) AS sample_rate,
|
||||
GROUP_CONCAT(DISTINCT source_folder) AS source_folders,
|
||||
MAX(source) AS source
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
),
|
||||
filtered AS (
|
||||
SELECT * FROM aggregated
|
||||
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
|
||||
)
|
||||
SELECT
|
||||
group_key, title, artist, all_artists, year, catalog_number,
|
||||
artwork, track_count, total_duration, format, bit_depth,
|
||||
sample_rate, source_folders, source,
|
||||
COUNT(*) OVER () AS total
|
||||
FROM filtered
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ?3 OFFSET ?4
|
||||
"#,
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
order_clause = order_clause,
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params![has_search, search_like, limit as i64, offset as i64],
|
||||
|row| {
|
||||
let group_key: String = row.get(0)?;
|
||||
let title: String = row.get(1)?;
|
||||
let artist: String = row.get(2)?;
|
||||
let all_artists: String =
|
||||
row.get::<_, Option<String>>(3)?.unwrap_or_default();
|
||||
let artwork_path: Option<String> = row.get(6)?;
|
||||
let source_folders: Option<String> = row.get(12)?;
|
||||
let total: u64 = row.get::<_, i64>(14)? as u64;
|
||||
|
||||
Ok((
|
||||
LocalAlbum {
|
||||
id: group_key,
|
||||
title,
|
||||
artist,
|
||||
all_artists,
|
||||
year: row.get(4)?,
|
||||
catalog_number: row.get(5)?,
|
||||
artwork_path,
|
||||
track_count: row.get(7)?,
|
||||
total_duration_secs: row.get(8)?,
|
||||
format: Self::parse_format(
|
||||
&row.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||
),
|
||||
bit_depth: row.get(10)?,
|
||||
sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0),
|
||||
directory_path: String::new(),
|
||||
source_folders,
|
||||
source: row
|
||||
.get::<_, Option<String>>(13)?
|
||||
.unwrap_or_else(|| "user".to_string()),
|
||||
},
|
||||
total,
|
||||
))
|
||||
},
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let mut albums = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
for row_result in rows {
|
||||
let (album, t) = row_result.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
total = t;
|
||||
albums.push(album);
|
||||
}
|
||||
|
||||
// Empty page (offset past the end or filter matches nothing).
|
||||
// The window-function trick gives us total only on returned
|
||||
// rows, so when there are none we have to ask separately.
|
||||
if albums.is_empty() {
|
||||
total = self.count_albums_metadata_for_page(
|
||||
search,
|
||||
include_qobuz_downloads,
|
||||
exclude_network_folders,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(crate::models::AlbumsMetadataPage { albums, total })
|
||||
}
|
||||
|
||||
/// Companion to `get_albums_metadata_page` — total count of albums
|
||||
/// matching the same filter. Used when the page is empty (so the
|
||||
/// window-function-derived total isn't available) or when the
|
||||
/// frontend wants to know the count before requesting any page.
|
||||
fn count_albums_metadata_for_page(
|
||||
&self,
|
||||
search: Option<&str>,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
) -> Result<u64, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
} else {
|
||||
"AND (source IS NULL OR source != 'qobuz_download')"
|
||||
};
|
||||
let network_filter = if exclude_network_folders {
|
||||
"AND NOT EXISTS (
|
||||
SELECT 1 FROM library_folders nf
|
||||
WHERE nf.is_network = 1
|
||||
AND local_tracks.file_path LIKE nf.path || '%'
|
||||
)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
|
||||
let search_pattern = search.unwrap_or("").trim();
|
||||
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
|
||||
let search_like = format!("%{}%", search_pattern);
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
{group_key} AS group_key,
|
||||
COALESCE(album_group_title, album, 'Unknown Album') AS title,
|
||||
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
|
||||
artist AS track_artist
|
||||
FROM local_tracks
|
||||
WHERE 1=1 {source_filter} {network_filter}
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT
|
||||
group_key,
|
||||
CASE WHEN group_key = '__unknown_album__'
|
||||
THEN 'Unknown Album'
|
||||
ELSE MIN(title)
|
||||
END AS title,
|
||||
CASE WHEN COUNT(DISTINCT track_artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE MIN(artist)
|
||||
END AS artist
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
)
|
||||
SELECT COUNT(*)
|
||||
FROM aggregated
|
||||
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
|
||||
"#,
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
);
|
||||
|
||||
let total: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
&query,
|
||||
rusqlite::params![has_search, search_like],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
Ok(total as u64)
|
||||
}
|
||||
|
||||
/// Get tracks for a metadata-grouped album. The `metadata_key`
|
||||
/// matches what [`Self::get_albums_metadata_grouped`] returns for
|
||||
/// the album's `id` field.
|
||||
|
||||
@@ -131,6 +131,15 @@ pub struct PlaylistLocalTrack {
|
||||
pub playlist_position: i32,
|
||||
}
|
||||
|
||||
/// One page of metadata-grouped local albums plus the total count of
|
||||
/// albums matching the same filter (for scrollbar pre-allocation on the
|
||||
/// frontend). Returned by `Database::get_albums_metadata_page`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlbumsMetadataPage {
|
||||
pub albums: Vec<LocalAlbum>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// An album aggregated from local tracks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalAlbum {
|
||||
|
||||
@@ -2580,6 +2580,46 @@ pub async fn v2_library_get_albums_metadata(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// V2 paginated metadata-grouped albums for Local Library. Backs the
|
||||
/// recycling-grid pool + chunked store on the frontend: caller asks for
|
||||
/// `[offset, offset+limit)` and receives that page plus the total count
|
||||
/// of albums matching the same filter (drives scrollbar pre-allocation).
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_albums_page(
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_dir: Option<String>,
|
||||
exclude_network_folders: Option<bool>,
|
||||
state: State<'_, LibraryState>,
|
||||
download_settings_state: State<'_, DownloadSettingsState>,
|
||||
) -> Result<qbz_library::AlbumsMetadataPage, String> {
|
||||
let include_qobuz = download_settings_state
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock download settings: {}", e))?
|
||||
.as_ref()
|
||||
.and_then(|s| s.get_settings().ok())
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
let guard__ = state.db.lock().await;
|
||||
let db = guard__
|
||||
.as_ref()
|
||||
.ok_or("No active session - please log in")?;
|
||||
|
||||
db.get_albums_metadata_page(
|
||||
offset,
|
||||
limit,
|
||||
search.as_deref(),
|
||||
sort_by.as_deref().unwrap_or("artist"),
|
||||
sort_dir.as_deref().unwrap_or("asc"),
|
||||
include_qobuz,
|
||||
exclude_network_folders.unwrap_or(false),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_stats(
|
||||
state: State<'_, LibraryState>,
|
||||
|
||||
@@ -1788,6 +1788,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_library_get_stats,
|
||||
commands_v2::v2_library_get_albums,
|
||||
commands_v2::v2_library_get_albums_metadata,
|
||||
commands_v2::v2_library_get_albums_page,
|
||||
commands_v2::v2_library_get_folders,
|
||||
commands_v2::v2_library_get_folders_with_metadata,
|
||||
commands_v2::v2_library_count_folder_tracks,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import { showToast, dismissBuffering } from '$lib/stores/toastStore';
|
||||
import AlbumCard from '$lib/discovery-v2/AlbumCardLibraryLite.svelte';
|
||||
import AlbumGridPool from '$lib/discovery-v2/AlbumGridPool.svelte';
|
||||
import { LocalAlbumsChunkedStore } from '$lib/discovery-v2/localAlbumsChunkedStore.svelte';
|
||||
import VirtualizedAlbumList from '../VirtualizedAlbumList.svelte';
|
||||
import VirtualizedArtistGrid from '../VirtualizedArtistGrid.svelte';
|
||||
import VirtualizedArtistList from '../VirtualizedArtistList.svelte';
|
||||
@@ -1276,6 +1277,14 @@
|
||||
let metadataAlbums = $state<LocalAlbum[]>([]);
|
||||
let metadataAlbumsLoading = $state(false);
|
||||
let metadataAlbumsLoaded = $state(false);
|
||||
|
||||
/** Chunked album store — backs the Plex-style recycling-grid pool on
|
||||
* the Albums tab when grouping is off and no quality filters are
|
||||
* active. Hydrated via `$effect` that mirrors search/sort/filter
|
||||
* state into `setParams`. Other paths (grouped grid, list mode,
|
||||
* quality-filtered) continue to read from `metadataAlbums` via the
|
||||
* legacy `VirtualizedAlbumList`. */
|
||||
const metadataAlbumsChunked = new LocalAlbumsChunkedStore();
|
||||
let hiddenAlbums = $state<LocalAlbum[]>([]);
|
||||
let artists = $state<LocalArtist[]>([]);
|
||||
let tracks = $state<LocalTrack[]>([]);
|
||||
@@ -2340,6 +2349,26 @@
|
||||
// Same pipeline for the metadata-grouped Albums tab
|
||||
let filteredAndGroupedMetadataAlbums = $derived.by(() => buildFilteredAndGroupedAlbums(metadataAlbums));
|
||||
|
||||
/** Push search/sort/filter state into the chunked store whenever any
|
||||
* of them change. The store no-ops if the params are unchanged, so
|
||||
* this is safe to run every reactive tick. We only feed the store
|
||||
* while the Albums tab is the active view — chunk 0 + total count
|
||||
* is one round-trip to the backend, cheap but not free. */
|
||||
$effect(() => {
|
||||
if (activeTab !== 'albums') return;
|
||||
if (albumViewMode !== 'grid') return;
|
||||
if (albumGroupingEnabled) return;
|
||||
if (hasActiveFilters) return;
|
||||
const mappedSortBy: 'artist' | 'title' | 'year' =
|
||||
sortBy === 'title' || sortBy === 'year' ? sortBy : 'artist';
|
||||
void metadataAlbumsChunked.setParams({
|
||||
search: debouncedAlbumSearch,
|
||||
sortBy: mappedSortBy,
|
||||
sortDir: sortDirection,
|
||||
excludeNetworkFolders: shouldExcludeNetworkFolders(),
|
||||
});
|
||||
});
|
||||
|
||||
// Albums for the selected artist (used in artist view two-column layout)
|
||||
// Includes multi-artist albums where the selected artist appears
|
||||
let selectedArtistAlbums = $derived.by(() => {
|
||||
@@ -6412,11 +6441,13 @@
|
||||
{:else}
|
||||
<div class="album-sections virtualized">
|
||||
<div class="virtualized-container">
|
||||
{#if albumViewMode === 'grid' && !albumGroupingEnabled}
|
||||
<!-- Recycling pool: 80-ish stable slots, scrolled-to
|
||||
indices update bindings in place. Headers aren't
|
||||
supported here yet, so we fall back to the
|
||||
legacy virtualizer when grouping is enabled. -->
|
||||
{#if albumViewMode === 'grid' && !albumGroupingEnabled && !hasActiveFilters}
|
||||
<!-- Chunked path: on-demand fetch via Tauri command
|
||||
per chunk, recycling-pool render. The pool
|
||||
binds to the chunked store's reactive
|
||||
`total` + `version`. Falls back to the legacy
|
||||
path below when grouping or quality filters
|
||||
are on (those don't have backend support yet). -->
|
||||
{#snippet renderMetadataAlbumCell(album: LocalAlbum, _idx: number)}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
@@ -6437,8 +6468,10 @@
|
||||
/>
|
||||
{/snippet}
|
||||
<AlbumGridPool
|
||||
totalCount={filteredMetadataAlbums.length}
|
||||
getItem={(i) => filteredMetadataAlbums[i] ?? null}
|
||||
totalCount={metadataAlbumsChunked.total}
|
||||
getItem={(i) => metadataAlbumsChunked.getAlbum(i) as LocalAlbum | null}
|
||||
onNeedIndex={(i) => metadataAlbumsChunked.requestIndex(i)}
|
||||
dataVersion={metadataAlbumsChunked.version}
|
||||
renderCell={renderMetadataAlbumCell}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Chunked album store for the Local Library Albums grid.
|
||||
*
|
||||
* Phase A of the Plex-style pagination port: instead of loading all
|
||||
* ~1200 albums into memory at once and letting Svelte's reactivity
|
||||
* track each one, we keep a sparse `Map<chunkIdx, LocalAlbum[]>` and
|
||||
* fetch chunks on demand as the recycling-pool virtualizer reports
|
||||
* which indices it's about to bind.
|
||||
*
|
||||
* The store deliberately uses a plain class instead of a Svelte 5
|
||||
* `$state` rune for the chunk Map. Mutating a $state-backed Map only
|
||||
* triggers reactivity on Map identity changes, so deeply nested writes
|
||||
* (`chunks.set(0, [...])`) wouldn't propagate. We sidestep that by
|
||||
* pairing the plain Map with a single `version` $state — every chunk
|
||||
* write bumps the version, and consumers (the grid pool) bind to that
|
||||
* version via the pool's `dataVersion` prop. When the version changes,
|
||||
* the pool's template re-evaluates `getAlbum` for every slot, which
|
||||
* reads from the now-updated Map.
|
||||
*
|
||||
* Invalidation: changing search/sort/source filters bumps a separate
|
||||
* `paramsVersion` that clears all chunks and triggers a fresh fetch of
|
||||
* chunk 0 + total. Chunk-in-flight dedup is via a Set<chunkIdx> guard.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Shape-compatible with LocalLibraryView's local `LocalAlbum` interface
|
||||
* so the snippet inside the grid pool can pass an item to legacy
|
||||
* helpers (toggleAlbumSelect, getAlbumQualityBadge, etc.) without a
|
||||
* cast. Rust's None serializes to JSON null at runtime; TypeScript
|
||||
* marks the fields optional so `?? fallback` and truthy checks work
|
||||
* for both null and undefined. */
|
||||
export interface LocalAlbumLite {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
all_artists?: string;
|
||||
year?: number;
|
||||
catalog_number?: string;
|
||||
artwork_path?: string;
|
||||
track_count: number;
|
||||
total_duration_secs: number;
|
||||
format: string;
|
||||
bit_depth?: number;
|
||||
sample_rate: number;
|
||||
directory_path: string;
|
||||
source_folders?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AlbumsPageResult {
|
||||
albums: LocalAlbumLite[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LocalAlbumsQueryParams {
|
||||
search: string;
|
||||
sortBy: 'artist' | 'title' | 'year';
|
||||
sortDir: 'asc' | 'desc';
|
||||
excludeNetworkFolders: boolean;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE = 100;
|
||||
|
||||
export class LocalAlbumsChunkedStore {
|
||||
/** Bumped on every chunk write — consumers bind to it to know when
|
||||
* to re-read `getAlbum`. Wrapped in `$state` so Svelte tracks it. */
|
||||
version = $state(0);
|
||||
|
||||
/** Reset whenever `total` is updated (initial fetch or filter
|
||||
* change). Consumers use it to refresh the scrollbar pre-allocation
|
||||
* and to know when zero-result is final vs still-loading. */
|
||||
total = $state(0);
|
||||
|
||||
/** True while the very first page (chunk 0) is in flight — used by
|
||||
* callers to show a top-level loading state vs a "no results" empty
|
||||
* state. Per-chunk in-flight is separate. */
|
||||
isInitialLoading = $state(false);
|
||||
|
||||
/** Last completed params snapshot. Compared on `setParams` to skip
|
||||
* no-op reconfigurations. */
|
||||
private currentParams: LocalAlbumsQueryParams | null = null;
|
||||
|
||||
private chunks: Map<number, LocalAlbumLite[]> = new Map();
|
||||
private inFlight: Set<number> = new Set();
|
||||
/** Generation counter — bumped on every params change. In-flight
|
||||
* responses are dropped if their captured generation no longer
|
||||
* matches `latestGeneration` (the user scrolled to a chunk and
|
||||
* then switched filters before the chunk arrived). */
|
||||
private latestGeneration = 0;
|
||||
|
||||
/** Returns the album at the given flat index, or `null` if the chunk
|
||||
* containing that index isn't loaded yet. The caller (recycling grid
|
||||
* pool) shows a placeholder for nulls and fires `requestIndex` so
|
||||
* the chunk loads. */
|
||||
getAlbum(globalIdx: number): LocalAlbumLite | null {
|
||||
if (globalIdx < 0 || globalIdx >= this.total) return null;
|
||||
const chunkIdx = Math.floor(globalIdx / CHUNK_SIZE);
|
||||
const chunk = this.chunks.get(chunkIdx);
|
||||
if (!chunk) return null;
|
||||
return chunk[globalIdx % CHUNK_SIZE] ?? null;
|
||||
}
|
||||
|
||||
/** Trigger a chunk fetch for the chunk containing `globalIdx`, if
|
||||
* not already loaded or in flight. Safe to call on every visible
|
||||
* slot every scroll frame — dedup is internal. */
|
||||
requestIndex(globalIdx: number): void {
|
||||
if (globalIdx < 0 || globalIdx >= this.total) return;
|
||||
const chunkIdx = Math.floor(globalIdx / CHUNK_SIZE);
|
||||
if (this.chunks.has(chunkIdx) || this.inFlight.has(chunkIdx)) return;
|
||||
void this.loadChunk(chunkIdx);
|
||||
}
|
||||
|
||||
/** Reset chunks + total and kick off chunk 0 with the new params. */
|
||||
async setParams(params: LocalAlbumsQueryParams): Promise<void> {
|
||||
if (this.currentParams && this.paramsEqual(this.currentParams, params)) return;
|
||||
this.currentParams = { ...params };
|
||||
this.latestGeneration++;
|
||||
this.chunks.clear();
|
||||
this.inFlight.clear();
|
||||
this.total = 0;
|
||||
this.isInitialLoading = true;
|
||||
this.version++;
|
||||
await this.loadChunk(0, true);
|
||||
}
|
||||
|
||||
/** Drop everything and force a fresh re-fetch of chunk 0. Used when
|
||||
* external state changes invalidate the cached data (e.g. a new
|
||||
* album is added, a folder is rescanned). */
|
||||
invalidate(): void {
|
||||
if (!this.currentParams) return;
|
||||
this.latestGeneration++;
|
||||
this.chunks.clear();
|
||||
this.inFlight.clear();
|
||||
this.total = 0;
|
||||
this.version++;
|
||||
void this.loadChunk(0, true);
|
||||
}
|
||||
|
||||
private async loadChunk(chunkIdx: number, isInitial = false): Promise<void> {
|
||||
if (!this.currentParams) return;
|
||||
if (this.inFlight.has(chunkIdx)) return;
|
||||
this.inFlight.add(chunkIdx);
|
||||
const generation = this.latestGeneration;
|
||||
const params = this.currentParams;
|
||||
|
||||
try {
|
||||
const result = await invoke<AlbumsPageResult>('v2_library_get_albums_page', {
|
||||
offset: chunkIdx * CHUNK_SIZE,
|
||||
limit: CHUNK_SIZE,
|
||||
search: params.search.trim() || null,
|
||||
sortBy: params.sortBy,
|
||||
sortDir: params.sortDir,
|
||||
excludeNetworkFolders: params.excludeNetworkFolders,
|
||||
});
|
||||
// Discard if filter changed mid-flight.
|
||||
if (generation !== this.latestGeneration) return;
|
||||
this.chunks.set(chunkIdx, result.albums);
|
||||
this.total = result.total;
|
||||
this.version++;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[localAlbumsChunkedStore] loadChunk failed',
|
||||
chunkIdx,
|
||||
err,
|
||||
);
|
||||
} finally {
|
||||
this.inFlight.delete(chunkIdx);
|
||||
if (isInitial) this.isInitialLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private paramsEqual(a: LocalAlbumsQueryParams, b: LocalAlbumsQueryParams): boolean {
|
||||
return (
|
||||
a.search === b.search &&
|
||||
a.sortBy === b.sortBy &&
|
||||
a.sortDir === b.sortDir &&
|
||||
a.excludeNetworkFolders === b.excludeNetworkFolders
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user