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:
2 files changed
+189
-24
No files matched your search
@@ -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
|
/// 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.
|
/// from disk, a miss downloads and stores. HTTP(S) only.
|
||||||
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
|
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
//! It reads the shared per-user `library.db` through the already
|
//! It reads the shared per-user `library.db` through the already
|
||||||
//! frontend-agnostic `qbz-library` crate (see `library_db::with_db`).
|
//! 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
|
//! Plex (2026-06-07): the Plex port is landing slice by slice — spec at
|
||||||
//! port is in progress — spec at
|
|
||||||
//! `qbz-nix-docs/plex-integration/2026-06-07-plex-slint-build-spec.md`.
|
//! `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
|
//! WIRED so far: creds + PIN auth (Settings > Local Library), and the
|
||||||
//! plumbing, or hydration trigger here, and the Albums tab uses the
|
//! Albums tab (grid union + album-detail tracks + covers). NOT yet wired
|
||||||
//! non-Plex query (see the Albums-tab note below).
|
//! 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
|
//! Folder management, scan, maintenance, and the danger zone do NOT live in
|
||||||
//! this view — they belong under Settings > Local Library. The view's gear
|
//! this view — they belong under Settings > Local Library. The view's gear
|
||||||
@@ -78,15 +78,14 @@ impl LibTab {
|
|||||||
|
|
||||||
// =============================== Albums tab ===============================
|
// =============================== Albums tab ===============================
|
||||||
//
|
//
|
||||||
// The Albums tab browses the metadata-grouped local albums via
|
// The Albums tab browses the metadata-grouped albums via
|
||||||
// `get_albums_metadata_grouped` (the non-Plex grouped path). Sort + search
|
// `get_albums_metadata_page(…, Some(plex_cache_path))` — the `plex_aggregated`
|
||||||
// are pushed to SQL; the grid pages on scroll. The shared
|
// union surfaces Plex albums alongside local ones when Plex is enabled
|
||||||
// `AlbumCollectionView` renders the result, covers load from the local
|
// (`plex_cache_db_path()` returns `None` when disabled → local-only). Sort,
|
||||||
// filesystem via the source-aware artwork pipeline.
|
// search, group + filter are derived client-side over the full-loaded set.
|
||||||
//
|
// Covers load via the source-aware artwork pipeline
|
||||||
// TODO (Plex port, slice 3): switch to `get_albums_metadata_page(…,
|
// (`spawn_local_or_plex_loads`): local files from disk, Plex `/library/...`
|
||||||
// Some(plex_cache_path))` so the `plex_aggregated` union surfaces Plex
|
// thumbs via `ArtworkRef::PlexThumb`.
|
||||||
// albums in the grid. See the build spec referenced in the module header.
|
|
||||||
|
|
||||||
/// Generation guard, bumped on every (re)load. A stale in-flight
|
/// Generation guard, bumped on every (re)load. A stale in-flight
|
||||||
/// fetch (older search/sort) is discarded on apply, and an 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);
|
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
|
/// Full-load the metadata-grouped albums off the UI thread (mapping + cover
|
||||||
/// fallback resolution all happen on the blocking thread), store the raw cache
|
/// fallback resolution all happen on the blocking thread), store the raw cache
|
||||||
/// + the mapped card set, then derive + spawn covers on the UI thread.
|
/// + 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(
|
fn spawn_albums_load(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
handle: tokio::runtime::Handle,
|
handle: tokio::runtime::Handle,
|
||||||
@@ -459,15 +481,30 @@ fn spawn_albums_load(
|
|||||||
gen: u64,
|
gen: u64,
|
||||||
) {
|
) {
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
let plex_path = plex_cache_db_path();
|
||||||
|
let plex = crate::plex_settings::get();
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
|
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| {
|
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
|
let cards: Vec<crate::album_map::AlbumCard> = albums
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
let mut card = map_local_album(a.clone());
|
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 card.artwork_url.is_empty() {
|
||||||
if let Some(cover) = db.resolve_album_cover_fallback(&card.id) {
|
if let Some(cover) = db.resolve_album_cover_fallback(&card.id) {
|
||||||
card.artwork_url = cover;
|
card.artwork_url = cover;
|
||||||
@@ -499,7 +536,15 @@ fn spawn_albums_load(
|
|||||||
s.set_albums_loading(false);
|
s.set_albums_loading(false);
|
||||||
s.set_albums_load_failed(false);
|
s.set_albums_load_failed(false);
|
||||||
derive_albums(&w);
|
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 => {
|
None => {
|
||||||
s.set_albums_loading(false);
|
s.set_albums_loading(false);
|
||||||
@@ -529,15 +574,28 @@ pub fn reload_albums(
|
|||||||
/// counts) so the nav shows numbers without visiting each tab. Cheap:
|
/// counts) so the nav shows numbers without visiting each tab. Cheap:
|
||||||
/// bounded album/folder/artist reads + a `COUNT(*)` for the (potentially
|
/// bounded album/folder/artist reads + a `COUNT(*)` for the (potentially
|
||||||
/// huge) tracks table. Album/artist counts match each tab's own loader
|
/// huge) tracks table. Album/artist counts match each tab's own loader
|
||||||
/// exactly (same `get_albums_metadata_grouped` set; same `normalize_artist`
|
/// exactly (same `get_albums_metadata_page` set incl. the Plex union; same
|
||||||
/// grouping the rail uses), so badges never jump when a tab is opened.
|
/// `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) {
|
pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let plex_path = plex_cache_db_path();
|
||||||
handle.spawn(async move {
|
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| {
|
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
|
let albums = db
|
||||||
.get_albums_metadata_grouped(false, true, false)
|
.get_albums_metadata_page(
|
||||||
.map(|v| v.len())
|
0,
|
||||||
|
ALBUMS_FULL_LOAD_LIMIT,
|
||||||
|
None,
|
||||||
|
"artist",
|
||||||
|
"asc",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
plex_path.as_deref(),
|
||||||
|
)
|
||||||
|
.map(|p| p.total as usize)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let folders = db
|
let folders = db
|
||||||
.get_folders_with_metadata()
|
.get_folders_with_metadata()
|
||||||
@@ -1097,12 +1155,17 @@ pub fn open_local_album(
|
|||||||
s.set_cover_url(album_cover.clone().into());
|
s.set_cover_url(album_cover.clone().into());
|
||||||
apply_album_version(&w, 0);
|
apply_album_version(&w, 0);
|
||||||
// Decode the album cover once (stable across version switches).
|
// 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() {
|
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 {
|
vec![ArtworkJob {
|
||||||
target: ArtworkTarget::LocalAlbumViewCover,
|
target: ArtworkTarget::LocalAlbumViewCover,
|
||||||
url: album_cover,
|
url: album_cover,
|
||||||
}],
|
}],
|
||||||
|
plex.base_url,
|
||||||
|
plex.token,
|
||||||
w.as_weak(),
|
w.as_weak(),
|
||||||
image_cache,
|
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
|
/// 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> {
|
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| {
|
crate::library_db::with_db(|db| {
|
||||||
let meta = db.get_album_tracks_metadata(group_key)?;
|
let meta = db.get_album_tracks_metadata(group_key)?;
|
||||||
if !meta.is_empty() {
|
if !meta.is_empty() {
|
||||||
|
|||||||
Reference in new issue
Block a user