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:
vicrodh committed 2026-05-12 13:25:47 -06:00
1 parent 810a41b4fc
commit 8f2cd965b4
6 files changed
+542 -7

No files matched your search

+271
View File
@@ -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.
+9
View File
@@ -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 {
+40
View File
@@ -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>,
+1
View File
@@ -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
);
}
}