diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index a53bc616a..079837dae 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -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 { + // 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 { 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 { 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 diff --git a/src-tauri/src/commands_v2/library.rs b/src-tauri/src/commands_v2/library.rs index 07d288e62..4282add3a 100644 --- a/src-tauri/src/commands_v2/library.rs +++ b/src-tauri/src/commands_v2/library.rs @@ -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, sort_dir: Option, exclude_network_folders: Option, + include_plex: Option, state: State<'_, LibraryState>, download_settings_state: State<'_, DownloadSettingsState>, ) -> Result { @@ -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 = 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()) } diff --git a/src/lib/components/views/LocalLibraryView.svelte b/src/lib/components/views/LocalLibraryView.svelte index 93669b3c5..243c4d83d 100644 --- a/src/lib/components/views/LocalLibraryView.svelte +++ b/src/lib/components/views/LocalLibraryView.svelte @@ -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}
- {#if albumViewMode === 'grid' && !albumGroupingEnabled && !hasActiveFilters && !isPlexLibraryEnabled()} + {#if albumViewMode === 'grid' && !albumGroupingEnabled && !hasActiveFilters}