feat(local-library): Plex consolidation in chunked albums page query
Plex cache lives in a separate SQLite database
(`<data_dir>/qbz/plex_cache.db`), which is why `v2_library_get_albums_page`
previously only saw local_tracks and the LocalLibraryView chunked
path had to fall back to the legacy virtualizer when Plex was on.
That fallback meant Plex-dominant users (which is the common case)
got none of the chunked-store / recycling-pool perf work.
Now consolidated server-side via SQL `ATTACH DATABASE` + `UNION ALL`:
Backend (qbz-library):
- `get_albums_metadata_page` accepts `plex_cache_path: Option<&Path>`.
When the file exists it `ATTACH`es the plex DB to the connection,
runs a CTE that aggregates `plex_cache.plex_cache_tracks` by
`album_key` into the same column shape as local_aggregated, and
UNIONs both before the filter/sort/pagination step. DETACHes at
the end so subsequent queries don't see stale attachment.
Plex group_key prefixed with `'plex:'` so it doesn't collide with
local group keys; `'plex'` literal in the `source` column drives
the frontend's SourceBadge mapping.
- Schema differences smoothed via `CAST` in the plex arm:
`duration_ms / 1000 → total_duration` (matches local seconds),
`sampling_rate_hz → sample_rate` (INTEGER → REAL).
- `count_albums_metadata_for_page` mirrors the same UNION so total
is consistent across empty-page edge cases.
Backend (Tauri command):
- `v2_library_get_albums_page` accepts an `include_plex` bool.
When true, resolves the plex cache path the same way
`plex/mod.rs::open_plex_cache_db` does and passes it through.
Frontend:
- Chunked store's `LocalAlbumsQueryParams` carries an `includePlex`
flag, included in the `paramsEqual` check so toggling Plex
visibility invalidates the chunk cache.
- LocalLibraryView drops the `!isPlexLibraryEnabled()` guard from
both the `$effect` that calls `setParams` and the render
condition that picks chunked vs legacy. The new path serves
Plex-only, local-only, and mixed libraries the same way.
cc
This commit is contained in:
4 files changed
+155
-16
No files matched your search
@@ -1894,11 +1894,12 @@ impl LibraryDatabase {
|
||||
/// 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.
|
||||
/// Source consolidation: when `plex_cache_path` is provided and
|
||||
/// points to an existing file, the function `ATTACH`es that
|
||||
/// database and unions plex_cache_tracks (aggregated by album_key)
|
||||
/// with the local aggregation. Sort, filter, and pagination apply
|
||||
/// to the union as a single result set, so a Plex-dominant library
|
||||
/// behaves identically to a local-dominant one.
|
||||
pub fn get_albums_metadata_page(
|
||||
&self,
|
||||
offset: u64,
|
||||
@@ -1908,6 +1909,55 @@ impl LibraryDatabase {
|
||||
sort_dir: &str,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
plex_cache_path: Option<&std::path::Path>,
|
||||
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
|
||||
// Best-effort ATTACH of the Plex cache so the union below can
|
||||
// see `plex_cache.plex_cache_tracks`. DETACH first defensively
|
||||
// so a stale attachment from a previous call (or another
|
||||
// connection user) doesn't fail the new one. Failure to
|
||||
// attach is non-fatal — we fall back to local-only.
|
||||
let plex_attached = if let Some(path) = plex_cache_path {
|
||||
if path.exists() {
|
||||
let _ = self.conn.execute("DETACH DATABASE plex_cache", []);
|
||||
let path_str = path.to_string_lossy().replace('\'', "''");
|
||||
self.conn
|
||||
.execute(
|
||||
&format!("ATTACH DATABASE '{}' AS plex_cache", path_str),
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let result = self.get_albums_metadata_page_inner(
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
sort_by,
|
||||
sort_dir,
|
||||
include_qobuz_downloads,
|
||||
exclude_network_folders,
|
||||
plex_attached,
|
||||
);
|
||||
if plex_attached {
|
||||
let _ = self.conn.execute("DETACH DATABASE plex_cache", []);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn get_albums_metadata_page_inner(
|
||||
&self,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
search: Option<&str>,
|
||||
sort_by: &str,
|
||||
sort_dir: &str,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
plex_attached: bool,
|
||||
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
@@ -1944,6 +1994,47 @@ impl LibraryDatabase {
|
||||
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
|
||||
let search_like = format!("%{}%", search_pattern);
|
||||
|
||||
// When Plex is attached, the plex_aggregated CTE is appended
|
||||
// and the filtered set is built from the UNION of local + plex.
|
||||
// Both CTEs produce the same column shape so the UNION ALL is
|
||||
// straightforward; types are normalised via CAST in the plex
|
||||
// arm (plex stores duration_ms / sampling_rate_hz as INTEGER
|
||||
// while local uses REAL for sample_rate and seconds-INTEGER
|
||||
// for duration).
|
||||
let plex_cte = if plex_attached {
|
||||
r#",
|
||||
plex_aggregated AS (
|
||||
SELECT
|
||||
'plex:' || COALESCE(album_key, rating_key) AS group_key,
|
||||
COALESCE(album, 'Unknown Album') AS title,
|
||||
CASE WHEN COUNT(DISTINCT artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE COALESCE(MIN(artist), 'Unknown Artist')
|
||||
END AS artist,
|
||||
GROUP_CONCAT(DISTINCT artist) AS all_artists,
|
||||
MIN(year) AS year,
|
||||
CAST(NULL AS TEXT) AS catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
|
||||
COUNT(*) AS track_count,
|
||||
CAST(SUM(COALESCE(duration_ms, 0)) / 1000 AS INTEGER) AS total_duration,
|
||||
MAX(codec) AS format,
|
||||
MAX(bit_depth) AS bit_depth,
|
||||
CAST(MAX(sampling_rate_hz) AS REAL) AS sample_rate,
|
||||
CAST(NULL AS TEXT) AS source_folders,
|
||||
'plex' AS source
|
||||
FROM plex_cache.plex_cache_tracks
|
||||
GROUP BY COALESCE(album_key, rating_key)
|
||||
)"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let unioned_clause = if plex_attached {
|
||||
"SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated"
|
||||
} else {
|
||||
"SELECT * FROM aggregated"
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
@@ -1988,9 +2079,9 @@ impl LibraryDatabase {
|
||||
MAX(source) AS source
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
),
|
||||
){plex_cte},
|
||||
filtered AS (
|
||||
SELECT * FROM aggregated
|
||||
SELECT * FROM ({unioned_clause})
|
||||
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
|
||||
)
|
||||
SELECT
|
||||
@@ -2005,6 +2096,8 @@ impl LibraryDatabase {
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
plex_cte = plex_cte,
|
||||
unioned_clause = unioned_clause,
|
||||
order_clause = order_clause,
|
||||
);
|
||||
|
||||
@@ -2070,6 +2163,7 @@ impl LibraryDatabase {
|
||||
search,
|
||||
include_qobuz_downloads,
|
||||
exclude_network_folders,
|
||||
plex_attached,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -2080,11 +2174,13 @@ impl LibraryDatabase {
|
||||
/// 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.
|
||||
/// Honours the same Plex attachment state as the caller used.
|
||||
fn count_albums_metadata_for_page(
|
||||
&self,
|
||||
search: Option<&str>,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
plex_attached: bool,
|
||||
) -> Result<u64, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
@@ -2105,6 +2201,25 @@ impl LibraryDatabase {
|
||||
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
|
||||
let search_like = format!("%{}%", search_pattern);
|
||||
|
||||
let plex_cte = if plex_attached {
|
||||
r#",
|
||||
plex_aggregated AS (
|
||||
SELECT
|
||||
'plex:' || COALESCE(album_key, rating_key) AS group_key,
|
||||
COALESCE(album, 'Unknown Album') AS title,
|
||||
COALESCE(MIN(artist), 'Unknown Artist') AS artist
|
||||
FROM plex_cache.plex_cache_tracks
|
||||
GROUP BY COALESCE(album_key, rating_key)
|
||||
)"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let unioned_clause = if plex_attached {
|
||||
"SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated"
|
||||
} else {
|
||||
"SELECT * FROM aggregated"
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
@@ -2129,14 +2244,16 @@ impl LibraryDatabase {
|
||||
END AS artist
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
)
|
||||
){plex_cte}
|
||||
SELECT COUNT(*)
|
||||
FROM aggregated
|
||||
FROM ({unioned_clause})
|
||||
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
|
||||
"#,
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
plex_cte = plex_cte,
|
||||
unioned_clause = unioned_clause,
|
||||
);
|
||||
|
||||
let total: i64 = self
|
||||
|
||||
@@ -2584,6 +2584,10 @@ pub async fn v2_library_get_albums_metadata(
|
||||
/// 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).
|
||||
///
|
||||
/// When `include_plex` is true and the `plex_cache.db` cache file
|
||||
/// exists in the user's data dir, the result is the UNION of local and
|
||||
/// plex sources — sort, filter, and pagination apply to both at once.
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_albums_page(
|
||||
offset: u64,
|
||||
@@ -2592,6 +2596,7 @@ pub async fn v2_library_get_albums_page(
|
||||
sort_by: Option<String>,
|
||||
sort_dir: Option<String>,
|
||||
exclude_network_folders: Option<bool>,
|
||||
include_plex: Option<bool>,
|
||||
state: State<'_, LibraryState>,
|
||||
download_settings_state: State<'_, DownloadSettingsState>,
|
||||
) -> Result<qbz_library::AlbumsMetadataPage, String> {
|
||||
@@ -2603,6 +2608,17 @@ pub async fn v2_library_get_albums_page(
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Resolve the plex_cache.db path the same way `plex/mod.rs`
|
||||
// does (`dirs::data_dir() + qbz/plex_cache.db`). The qbz-library
|
||||
// crate doesn't know about Plex — passing `None` here keeps the
|
||||
// local-only behavior; passing the path lets the SQL ATTACH + UNION
|
||||
// light up.
|
||||
let plex_path: Option<std::path::PathBuf> = if include_plex.unwrap_or(false) {
|
||||
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let guard__ = state.db.lock().await;
|
||||
let db = guard__
|
||||
.as_ref()
|
||||
@@ -2616,6 +2632,7 @@ pub async fn v2_library_get_albums_page(
|
||||
sort_dir.as_deref().unwrap_or("asc"),
|
||||
include_qobuz,
|
||||
exclude_network_folders.unwrap_or(false),
|
||||
plex_path.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -2359,11 +2359,9 @@
|
||||
if (albumViewMode !== 'grid') return;
|
||||
if (albumGroupingEnabled) return;
|
||||
if (hasActiveFilters) return;
|
||||
// Plex cache lives in a separate SQLite DB; until ATTACH + UNION is
|
||||
// implemented in `v2_library_get_albums_page`, the chunked path
|
||||
// would silently hide every Plex album. Fall back to the legacy
|
||||
// virtualizer when Plex is enabled so users keep seeing them.
|
||||
if (isPlexLibraryEnabled()) return;
|
||||
// Plex now consolidated in the backend via ATTACH + UNION when
|
||||
// `include_plex` is passed. Users with Plex-dominant libraries
|
||||
// get the chunked-store benefit too.
|
||||
const mappedSortBy: 'artist' | 'title' | 'year' =
|
||||
sortBy === 'title' || sortBy === 'year' ? sortBy : 'artist';
|
||||
void metadataAlbumsChunked.setParams({
|
||||
@@ -2371,6 +2369,7 @@
|
||||
sortBy: mappedSortBy,
|
||||
sortDir: sortDirection,
|
||||
excludeNetworkFolders: shouldExcludeNetworkFolders(),
|
||||
includePlex: isPlexLibraryEnabled(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6446,7 +6445,7 @@
|
||||
{:else}
|
||||
<div class="album-sections virtualized">
|
||||
<div class="virtualized-container">
|
||||
{#if albumViewMode === 'grid' && !albumGroupingEnabled && !hasActiveFilters && !isPlexLibraryEnabled()}
|
||||
{#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
|
||||
|
||||
@@ -58,6 +58,10 @@ export interface LocalAlbumsQueryParams {
|
||||
sortBy: 'artist' | 'title' | 'year';
|
||||
sortDir: 'asc' | 'desc';
|
||||
excludeNetworkFolders: boolean;
|
||||
/** Include Plex cache albums in the union. The backend ATTACHes
|
||||
* `plex_cache.db` and UNIONs the aggregations so sort/filter/pagination
|
||||
* apply to both sources as a single result set. */
|
||||
includePlex: boolean;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE = 100;
|
||||
@@ -152,6 +156,7 @@ export class LocalAlbumsChunkedStore {
|
||||
sortBy: params.sortBy,
|
||||
sortDir: params.sortDir,
|
||||
excludeNetworkFolders: params.excludeNetworkFolders,
|
||||
includePlex: params.includePlex,
|
||||
});
|
||||
// Discard if filter changed mid-flight.
|
||||
if (generation !== this.latestGeneration) return;
|
||||
@@ -175,7 +180,8 @@ export class LocalAlbumsChunkedStore {
|
||||
a.search === b.search &&
|
||||
a.sortBy === b.sortBy &&
|
||||
a.sortDir === b.sortDir &&
|
||||
a.excludeNetworkFolders === b.excludeNetworkFolders
|
||||
a.excludeNetworkFolders === b.excludeNetworkFolders &&
|
||||
a.includePlex === b.includePlex
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user