feat(slint): Plex albums in Local Library grid + detail + covers (port slice 3a/3b)

Albums tab only (Artists + flat Tracks tab remain Plex-blind — next slice):
- Albums loader switches to get_albums_metadata_page(.., Some(plex_cache_path))
  so the plex_aggregated union surfaces Plex albums alongside local; gated on
  plex_settings enabled (None path = local-only, no regression). seed_counts
  uses the page total so the badge includes Plex.
- Album-detail branches group_key "plex:" -> qbz_plex::plex_cache_get_album_tracks,
  mapped to LocalTrack (rating_key in file_path, source=plex, album name into
  album_group_title for the detail header).
- Source-aware grid + album-detail-header covers via spawn_local_or_plex_loads:
  a /library|/photo path with creds becomes ArtworkRef::PlexThumb, else LocalFile.
  Queue/now-playing/MPRIS artwork untouched (that is slice 4).
- Truthful module/Albums-tab docs.

cc
This commit is contained in:
vicrodh committed 2026-06-07 22:39:19 -06:00
1 parent f7619a92fc
commit a45840d178
2 files changed
+189 -24

No files matched your search

+50
View File
@@ -270,6 +270,56 @@ pub fn spawn_local_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>,
}
}
/// Like `spawn_local_loads`, but Plex-aware: each job's `url` is either a
/// LOCAL filesystem path (Local Library covers) OR a raw Plex thumbnail path
/// (`/library/...`, `/photo/...`). When the path looks like a Plex thumb AND
/// Plex creds are present, the job is decoded via `ArtworkRef::PlexThumb`
/// (tokenized HTTP through the disk cache); otherwise it falls back to the
/// local-file path. The path prefix is self-describing, so no per-job source
/// tag is needed (local covers are always absolute filesystem paths, never
/// `/library/` or `/photo/`). Used by the Local Library Albums grid + album
/// detail so Plex albums render covers. Browse-only; the queue/now-playing
/// /MPRIS artwork is unaffected.
pub fn spawn_local_or_plex_loads(
jobs: Vec<ArtworkJob>,
plex_base_url: String,
plex_token: String,
window: slint::Weak<AppWindow>,
cache: ImageCache,
) {
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT));
let plex_base_url = Arc::new(plex_base_url);
let plex_token = Arc::new(plex_token);
for job in jobs {
let semaphore = semaphore.clone();
let window = window.clone();
let cache = cache.clone();
let plex_base_url = plex_base_url.clone();
let plex_token = plex_token.clone();
tokio::spawn(async move {
let _permit = semaphore.acquire().await.ok()?;
let decode_size = job.target.decode_size();
let is_plex_path =
job.url.starts_with("/library/") || job.url.starts_with("/photo/");
let art = if is_plex_path && !plex_base_url.is_empty() && !plex_token.is_empty() {
ArtworkRef::PlexThumb {
base_url: (*plex_base_url).clone(),
token: (*plex_token).clone(),
path: job.url.clone(),
}
} else {
ArtworkRef::LocalFile(job.url.clone())
};
let (pixels, width, height) = fetch_and_decode_ref(&art, &cache, decode_size).await?;
let target = job.target;
let _ = window.upgrade_in_event_loop(move |w| {
apply_artwork(&w, target, &pixels, width, height);
});
Some(())
});
}
}
/// Resolve a remote URL to raw bytes via the shared disk cache: a hit reads
/// from disk, a miss downloads and stores. HTTP(S) only.
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
+139 -24
View File
@@ -6,12 +6,12 @@
//! It reads the shared per-user `library.db` through the already
//! frontend-agnostic `qbz-library` crate (see `library_db::with_db`).
//!
//! NOTE (2026-06-07): Plex is NOT yet wired into this view. The Slint Plex
//! port is in progress — spec at
//! Plex (2026-06-07): the Plex port is landing slice by slice — spec at
//! `qbz-nix-docs/plex-integration/2026-06-07-plex-slint-build-spec.md`.
//! Until it lands there is no Plex play path, queue builder, creds
//! plumbing, or hydration trigger here, and the Albums tab uses the
//! non-Plex query (see the Albums-tab note below).
//! WIRED so far: creds + PIN auth (Settings > Local Library), and the
//! Albums tab (grid union + album-detail tracks + covers). NOT yet wired
//! here: the Artists + flat Tracks tabs (still Plex-blind), the Plex play
//! path / queue builder, and quality hydration.
//!
//! Folder management, scan, maintenance, and the danger zone do NOT live in
//! this view — they belong under Settings > Local Library. The view's gear
@@ -78,15 +78,14 @@ impl LibTab {
// =============================== Albums tab ===============================
//
// The Albums tab browses the metadata-grouped local albums via
// `get_albums_metadata_grouped` (the non-Plex grouped path). Sort + search
// are pushed to SQL; the grid pages on scroll. The shared
// `AlbumCollectionView` renders the result, covers load from the local
// filesystem via the source-aware artwork pipeline.
//
// TODO (Plex port, slice 3): switch to `get_albums_metadata_page(…,
// Some(plex_cache_path))` so the `plex_aggregated` union surfaces Plex
// albums in the grid. See the build spec referenced in the module header.
// The Albums tab browses the metadata-grouped albums via
// `get_albums_metadata_page(…, Some(plex_cache_path))` — the `plex_aggregated`
// union surfaces Plex albums alongside local ones when Plex is enabled
// (`plex_cache_db_path()` returns `None` when disabled → local-only). Sort,
// search, group + filter are derived client-side over the full-loaded set.
// Covers load via the source-aware artwork pipeline
// (`spawn_local_or_plex_loads`): local files from disk, Plex `/library/...`
// thumbs via `ArtworkRef::PlexThumb`.
/// Generation guard, bumped on every (re)load. A stale in-flight
/// fetch (older search/sort) is discarded on apply, and an in-flight
@@ -449,9 +448,32 @@ pub fn clear_album_filter(window: &AppWindow) {
derive_albums(window);
}
/// Resolve the Plex cache DB path (`<data_dir>/qbz/plex_cache.db`), gated on
/// the user's Plex master toggle being ON. Returns `None` when Plex is
/// disabled OR the data dir is unavailable, so the Albums union degrades to
/// local-only (identical behaviour to before Plex existed). Mirrors the Tauri
/// command's resolution (`commands_v2/library.rs`).
fn plex_cache_db_path() -> Option<std::path::PathBuf> {
if !crate::plex_settings::get().enabled {
return None;
}
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
}
/// Upper bound for the full-load page. The Albums tab loads the entire set in
/// one shot (search/sort/filter/group are all derived client-side over the
/// cached set in `derive_albums`), so we request a single large page rather
/// than truly paginating. `total` from the page is informational here.
const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
/// Full-load the metadata-grouped albums off the UI thread (mapping + cover
/// fallback resolution all happen on the blocking thread), store the raw cache
/// + the mapped card set, then derive + spawn covers on the UI thread.
///
/// Uses the Plex-aware paginated query so that when the Plex master toggle is
/// ON, the `plex_aggregated` union surfaces Plex albums in the grid (with
/// covers via the source-aware artwork pipeline). When Plex is OFF the path is
/// `None` → the query runs local-only, exactly as before.
fn spawn_albums_load(
window: &AppWindow,
handle: tokio::runtime::Handle,
@@ -459,15 +481,30 @@ fn spawn_albums_load(
gen: u64,
) {
let weak = window.as_weak();
let plex_path = plex_cache_db_path();
let plex = crate::plex_settings::get();
handle.spawn(async move {
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
tokio::task::spawn_blocking(|| {
tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
let albums = db.get_albums_metadata_grouped(false, true, false)?;
let page = db.get_albums_metadata_page(
0,
ALBUMS_FULL_LOAD_LIMIT,
None,
"artist",
"asc",
false,
true,
plex_path.as_deref(),
)?;
let albums = page.albums;
let cards: Vec<crate::album_map::AlbumCard> = albums
.iter()
.map(|a| {
let mut card = map_local_album(a.clone());
// Local-cover fallback scans the on-disk folder; it
// never applies to Plex rows (their artwork_url is a
// non-empty /library/... path, so this no-ops).
if card.artwork_url.is_empty() {
if let Some(cover) = db.resolve_album_cover_fallback(&card.id) {
card.artwork_url = cover;
@@ -499,7 +536,15 @@ fn spawn_albums_load(
s.set_albums_loading(false);
s.set_albums_load_failed(false);
derive_albums(&w);
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
// Route covers through the Plex-aware spawn: Plex rows carry
// a /library/... path → PlexThumb; local rows → LocalFile.
crate::artwork::spawn_local_or_plex_loads(
jobs,
plex.base_url.clone(),
plex.token.clone(),
w.as_weak(),
image_cache,
);
}
None => {
s.set_albums_loading(false);
@@ -529,15 +574,28 @@ pub fn reload_albums(
/// counts) so the nav shows numbers without visiting each tab. Cheap:
/// bounded album/folder/artist reads + a `COUNT(*)` for the (potentially
/// huge) tracks table. Album/artist counts match each tab's own loader
/// exactly (same `get_albums_metadata_grouped` set; same `normalize_artist`
/// grouping the rail uses), so badges never jump when a tab is opened.
/// exactly (same `get_albums_metadata_page` set incl. the Plex union; same
/// `normalize_artist` grouping the rail uses), so badges never jump when a
/// tab is opened.
pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
let plex_path = plex_cache_db_path();
handle.spawn(async move {
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(|| {
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
// Total under the same filter the Albums tab uses, incl. the
// Plex union when enabled, so the badge matches the grid.
let albums = db
.get_albums_metadata_grouped(false, true, false)
.map(|v| v.len())
.get_albums_metadata_page(
0,
ALBUMS_FULL_LOAD_LIMIT,
None,
"artist",
"asc",
false,
true,
plex_path.as_deref(),
)
.map(|p| p.total as usize)
.unwrap_or(0);
let folders = db
.get_folders_with_metadata()
@@ -1097,12 +1155,17 @@ pub fn open_local_album(
s.set_cover_url(album_cover.clone().into());
apply_album_version(&w, 0);
// Decode the album cover once (stable across version switches).
// Plex-aware: a Plex album's cover is a raw `/library/...` thumb
// path that needs the token (PlexThumb), not a local file read.
if !album_cover.is_empty() {
crate::artwork::spawn_local_loads(
let plex = crate::plex_settings::get();
crate::artwork::spawn_local_or_plex_loads(
vec![ArtworkJob {
target: ArtworkTarget::LocalAlbumViewCover,
url: album_cover,
}],
plex.base_url,
plex.token,
w.as_weak(),
image_cache,
);
@@ -2447,9 +2510,61 @@ pub fn select_local_artist(
});
}
/// Map a format string (Plex container/codec, e.g. "flac", "alac") to the
/// `AudioFormat` enum the UI quality badge expects. Mirrors the private
/// `Database::parse_format`, but case-insensitive on the lowercase Plex value.
fn parse_audio_format(s: &str) -> qbz_library::AudioFormat {
use qbz_library::AudioFormat;
match s.to_ascii_lowercase().as_str() {
"flac" => AudioFormat::Flac,
"alac" => AudioFormat::Alac,
"wav" | "wave" => AudioFormat::Wav,
"aiff" | "aif" => AudioFormat::Aiff,
"ape" => AudioFormat::Ape,
"mp3" => AudioFormat::Mp3,
_ => AudioFormat::Unknown,
}
}
/// Map a Plex-cache track row to the `LocalTrack` shape the album-detail view
/// renders. The `file_path` carries the Plex `rating_key` (the playback slice
/// resolves the stream URL from it); `artwork_path` is the raw `/library/...`
/// thumb path (tokenized at decode time by the PlexThumb artwork arm);
/// `source` is `"plex"` so the UI classifies the row correctly.
fn map_plex_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> qbz_library::LocalTrack {
qbz_library::LocalTrack {
id: t.id as i64,
file_path: t.rating_key,
title: t.title,
artist: t.artist,
album: t.album.clone(),
album_group_key: t.album_key.clone(),
// Feeds the album-detail header title (apply_album_version reads
// tracks.first().album_group_title) — use the Plex album name.
album_group_title: t.album.clone(),
track_number: t.track_number,
disc_number: t.disc_number,
duration_secs: t.duration_secs,
format: parse_audio_format(&t.format),
bit_depth: t.bit_depth,
sample_rate: t.sample_rate as f64,
artwork_path: t.artwork_path,
source: Some("plex".to_string()),
..Default::default()
}
}
/// Fetch an album's tracks by group key, trying the metadata grouping first
/// (Albums tab) then the folder grouping (Folders tab). Blocking.
/// (Albums tab) then the folder grouping (Folders tab). Blocking. Plex albums
/// (`plex:<hash>` group keys) are served from the Plex cache DB instead.
pub fn fetch_album_tracks_blocking(group_key: &str) -> Vec<qbz_library::LocalTrack> {
if group_key.starts_with("plex:") {
return qbz_plex::plex_cache_get_album_tracks(group_key.to_string())
.unwrap_or_default()
.into_iter()
.map(map_plex_cached_to_local_track)
.collect();
}
crate::library_db::with_db(|db| {
let meta = db.get_album_tracks_metadata(group_key)?;
if !meta.is_empty() {