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:
vicrodh committed 2026-05-12 15:19:18 -06:00
1 parent 2e58c889b1
commit 1aea33cf19
4 files changed
+155 -16

No files matched your search

+126 -9
View File
@@ -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
+17
View File
@@ -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
);
}
}