feat(slint): Plex quality hydration on album-open + consistent quality badges (port slice 6)
Hydration:
- qbz-plex: additive plex_hydrate_album_quality(base, token, rating_keys) — per
key plex_get_track_metadata (batch 5, 5s/call timeout, skip failures) then the
COALESCE write-back plex_cache_update_track_quality; awaited before re-read.
- qbz-player/qbz-core: patch_plex_quality / patch_plex_queue_quality patch the
in-memory queue in place by rating_key (for the now-playing stamp).
- qbz-slint: album-open trigger (gated on DB-NULL, not a value heuristic) then
refresh of all badge surfaces — album-card grid (re-read aggregate + patch
every model + the raw filter source), album-detail header + rows, now-playing
stamp. Re-sync stays routed through plex_cache_save_tracks (carry-over).
Quality badge consistency (the four surfaces must agree — and pre-hydration
show the filetype, not nothing):
- One shared classifier crate::quality::badge(format, bit_depth, sample_rate) ->
(tier, detail, tooltip); the album card, album-detail header and track rows
all route through it (kills the per-surface duplication that produced
"96 kHz" vs "96000 kHz"). sample_rate normalized to kHz via a >=1000 guard so
Qobuz's already-kHz values are never re-divided (no "0.041 kHz").
- New "lossless" tier: an un-hydrated lossless file (FLAC/WAV/ALAC… with unknown
bit depth) shows a generic filetype badge ("FLAC") instead of an empty badge,
rendered in QualityBadge (compact chip) + QualityBadgeFull; upgrades to
"24-bit / 96 kHz" after hydration. Local/Qobuz/mp3 unchanged.
cc
This commit is contained in:
10 files changed
+599
-38
No files matched your search
@@ -625,6 +625,27 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Patch the cached quality of any queued Plex track whose `rating_key`
|
||||
/// matches one of `updates` (`(rating_key, bit_depth, sample_rate_khz)`).
|
||||
/// Frontend-agnostic hook for the Plex quality-hydration path: a track
|
||||
/// hydrated while it is already enqueued/playing carries a frozen quality
|
||||
/// snapshot, so this upgrades it in place. Returns true if the CURRENT
|
||||
/// track was patched — the caller then re-pushes the now-playing stamp.
|
||||
pub async fn patch_plex_queue_quality(
|
||||
&self,
|
||||
updates: &[(String, Option<u32>, Option<f64>)],
|
||||
) -> bool {
|
||||
let queue = self.queue.write().await;
|
||||
let current_patched = queue.patch_plex_quality(updates);
|
||||
if current_patched {
|
||||
self.emit(CoreEvent::QueueUpdated {
|
||||
state: queue.get_state(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
current_patched
|
||||
}
|
||||
|
||||
// ==================== Search & Catalog ====================
|
||||
|
||||
/// Search for albums
|
||||
|
||||
@@ -73,6 +73,46 @@ impl QueueManager {
|
||||
.and_then(|idx| state.tracks.get(idx).cloned())
|
||||
}
|
||||
|
||||
/// Patch the cached quality (bit depth + sample rate) of every queued track
|
||||
/// whose Plex `rating_key` matches one of `updates`. Used by the Plex
|
||||
/// quality-hydration path so a track that gets hydrated while it is already
|
||||
/// enqueued/playing has its frozen quality snapshot upgraded in place. Plex
|
||||
/// rows carry their `rating_key` in `source_item_id_hint`. `sample_rate` is
|
||||
/// in kHz to match the enqueue-time snapshot (`local_queue_track`). Returns
|
||||
/// true if the CURRENT track was among those patched (the caller then
|
||||
/// re-pushes the now-playing stamp).
|
||||
pub fn patch_plex_quality(&self, updates: &[(String, Option<u32>, Option<f64>)]) -> bool {
|
||||
if updates.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let current_idx = state.current_index;
|
||||
let mut current_patched = false;
|
||||
for (idx, track) in state.tracks.iter_mut().enumerate() {
|
||||
if track.source.as_deref() != Some("plex") {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = track.source_item_id_hint.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if let Some((_, bit_depth, sample_rate)) =
|
||||
updates.iter().find(|(k, _, _)| k == key)
|
||||
{
|
||||
if let Some(bd) = *bit_depth {
|
||||
track.bit_depth = Some(bd);
|
||||
track.hires = bd > 16;
|
||||
}
|
||||
if let Some(sr) = *sample_rate {
|
||||
track.sample_rate = Some(sr);
|
||||
}
|
||||
if current_idx == Some(idx) {
|
||||
current_patched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
current_patched
|
||||
}
|
||||
|
||||
/// Add a track to the end of the queue
|
||||
pub fn add_track(&self, track: QueueTrack) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
@@ -312,6 +312,14 @@ fn open_plex_cache_db() -> Result<Connection, String> {
|
||||
}
|
||||
|
||||
fn build_plex_client() -> Result<reqwest::Client, String> {
|
||||
build_plex_client_with_timeout(Duration::from_secs(120))
|
||||
}
|
||||
|
||||
/// Build the LAN Plex client with a caller-chosen request timeout. Quality
|
||||
/// hydration uses a short (~5s) bound per metadata call so a single dead/slow
|
||||
/// `rating_key` cannot stall the batch (matches the Svelte 5000 ms per-call
|
||||
/// timeout); normal browsing keeps the generous 120s default.
|
||||
fn build_plex_client_with_timeout(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-Plex-Product", HeaderValue::from_static("QBZ"));
|
||||
headers.insert("X-Plex-Version", HeaderValue::from_static("0.1-poc"));
|
||||
@@ -327,7 +335,7 @@ fn build_plex_client() -> Result<reqwest::Client, String> {
|
||||
|
||||
reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create Plex HTTP client: {}", e))
|
||||
@@ -803,9 +811,21 @@ pub async fn plex_get_track_metadata(
|
||||
rating_key: String,
|
||||
) -> Result<PlexTrack, String> {
|
||||
let client = build_plex_client()?;
|
||||
let base = normalize_base_url(&base_url);
|
||||
plex_get_track_metadata_with_client(&client, &base_url, &token, &rating_key).await
|
||||
}
|
||||
|
||||
/// Shared metadata fetch that reuses an existing client. The hydration path
|
||||
/// passes a short-timeout client so it bounds each per-track call without
|
||||
/// rebuilding a client per `rating_key`.
|
||||
async fn plex_get_track_metadata_with_client(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
rating_key: &str,
|
||||
) -> Result<PlexTrack, String> {
|
||||
let base = normalize_base_url(base_url);
|
||||
let detail_url = format!("{base}/library/metadata/{rating_key}");
|
||||
let url = with_token(&detail_url, &token);
|
||||
let url = with_token(&detail_url, token);
|
||||
|
||||
let xml = client
|
||||
.get(url)
|
||||
@@ -824,6 +844,59 @@ pub async fn plex_get_track_metadata(
|
||||
.ok_or_else(|| "Plex track metadata not found".to_string())
|
||||
}
|
||||
|
||||
/// Hydrate real per-track quality for a set of Plex `rating_keys`.
|
||||
///
|
||||
/// Frontend-agnostic orchestration over the two existing primitives:
|
||||
/// `plex_get_track_metadata` (the real per-track `container` / `samplingRate` /
|
||||
/// `bitDepth` the bulk `/all` list omits) is fetched per key, then
|
||||
/// `plex_cache_update_track_quality` persists the result (COALESCE write-back,
|
||||
/// so a NULL field never erases an existing value). The keys come from the
|
||||
/// DB-NULL queue (`plex_cache_get_tracks_needing_hydration`) — NOT a value
|
||||
/// heuristic — so a genuine 16/44.1 FLAC is written once and never re-probed.
|
||||
///
|
||||
/// Calls run in batches of `BATCH`, sequentially within a batch (matching the
|
||||
/// Svelte reference), each bounded to ~5s by the client timeout. Failures and
|
||||
/// timeouts are skipped so one dead key cannot abort the batch. Returns the
|
||||
/// successfully-fetched updates so the caller can refresh in-memory state
|
||||
/// without re-reading the cache.
|
||||
pub async fn plex_hydrate_album_quality(
|
||||
base_url: String,
|
||||
token: String,
|
||||
rating_keys: Vec<String>,
|
||||
) -> Result<Vec<PlexTrackQualityUpdate>, String> {
|
||||
const BATCH: usize = 5;
|
||||
if rating_keys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Short per-call bound (5s) so a slow/dead rating_key can't stall the batch.
|
||||
let client = build_plex_client_with_timeout(Duration::from_secs(5))?;
|
||||
let mut updates: Vec<PlexTrackQualityUpdate> = Vec::with_capacity(rating_keys.len());
|
||||
|
||||
for chunk in rating_keys.chunks(BATCH) {
|
||||
for key in chunk {
|
||||
match plex_get_track_metadata_with_client(&client, &base_url, &token, key).await {
|
||||
Ok(track) => updates.push(PlexTrackQualityUpdate {
|
||||
rating_key: track.rating_key,
|
||||
container: track.container,
|
||||
sampling_rate_hz: track.sampling_rate_hz,
|
||||
bit_depth: track.bit_depth,
|
||||
}),
|
||||
Err(_) => continue, // skip dead/410/timeout keys, keep the batch alive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist (COALESCE write-back). Fire the DB write here so the caller's
|
||||
// single await gives both fetched + persisted; persistence MUST complete
|
||||
// before the UI refresh (fixes the Svelte fire-and-forget race).
|
||||
if !updates.is_empty() {
|
||||
plex_cache_update_track_quality(updates.clone())?;
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
pub async fn plex_auth_pin_start(client_identifier: String) -> Result<PlexPinStartResult, String> {
|
||||
let client = build_plex_auth_client(&client_identifier)?;
|
||||
let pin = client
|
||||
|
||||
@@ -113,16 +113,12 @@ pub fn albums_gen_current(gen: u64) -> bool {
|
||||
pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCard {
|
||||
// Format-first classification (mirrors Tauri): a lossy format (MP3) gets
|
||||
// the dedicated MP3 badge tier, never CD.
|
||||
let tier = if a.format.to_string().eq_ignore_ascii_case("mp3") {
|
||||
"mp3"
|
||||
} else {
|
||||
match a.bit_depth {
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None => "",
|
||||
}
|
||||
};
|
||||
let (quality_detail, quality_label) = local_quality(a.bit_depth, a.sample_rate);
|
||||
// One shared classifier (see crate::quality::badge) so the card, the
|
||||
// album-detail header and the track rows can never disagree — and so an
|
||||
// un-hydrated lossless Plex album shows a generic "FLAC" badge instead of
|
||||
// nothing. `a.sample_rate` is Hz; `badge` normalizes it to kHz (guarded).
|
||||
let (tier, quality_detail, quality_label) =
|
||||
crate::quality::badge(&a.format.to_string(), a.bit_depth, Some(a.sample_rate));
|
||||
let year = a.year.map(|y| y.to_string()).unwrap_or_default();
|
||||
let track_count = if a.track_count > 0 {
|
||||
a.track_count.to_string()
|
||||
@@ -708,16 +704,11 @@ fn fmt_duration(secs: u64) -> String {
|
||||
/// non-Send `slint::Image`). Local tracks aren't Qobuz-linkable, so the
|
||||
/// artist/album link ids are empty (the row renders them as plain text).
|
||||
fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
|
||||
// Format-first classification (mirrors Tauri): MP3/lossy → MP3 tier.
|
||||
let tier = if t.format.to_string().eq_ignore_ascii_case("mp3") {
|
||||
"mp3"
|
||||
} else {
|
||||
match t.bit_depth {
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None => "",
|
||||
}
|
||||
};
|
||||
// One shared classifier (crate::quality::badge) — same source the album
|
||||
// card + header use, so all surfaces agree; an un-hydrated lossless track
|
||||
// shows a generic "FLAC" detail. `t.sample_rate` is Hz; badge normalizes.
|
||||
let (tier, quality_detail, _) =
|
||||
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
|
||||
TrackItem {
|
||||
id: t.id.to_string().into(),
|
||||
number: t.track_number.map(|n| n.to_string()).unwrap_or_default().into(),
|
||||
@@ -726,12 +717,7 @@ fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
|
||||
album: t.album.into(),
|
||||
duration: fmt_duration(t.duration_secs).into(),
|
||||
quality_tier: tier.into(),
|
||||
quality_detail: if tier == "mp3" {
|
||||
String::new()
|
||||
} else {
|
||||
crate::quality::detail(t.bit_depth, Some(t.sample_rate))
|
||||
}
|
||||
.into(),
|
||||
quality_detail: quality_detail.into(),
|
||||
explicit: false,
|
||||
selected: false,
|
||||
artwork_url: t.artwork_path.unwrap_or_default().into(),
|
||||
@@ -1144,6 +1130,7 @@ pub fn open_local_album(
|
||||
s.set_cover(slint::Image::default());
|
||||
});
|
||||
let gk = group_key.clone();
|
||||
let hydrate_handle = handle.clone();
|
||||
handle.spawn(async move {
|
||||
let tracks = tokio::task::spawn_blocking(move || {
|
||||
let mut t = fetch_album_tracks_blocking(&gk);
|
||||
@@ -1210,6 +1197,16 @@ pub fn open_local_album(
|
||||
s.set_loading(false);
|
||||
s.set_cover_url(album_cover.clone().into());
|
||||
apply_album_version(&w, 0);
|
||||
// Plex quality hydration (slice 6): if this is a Plex album, the
|
||||
// cached rows may carry NULL/incomplete quality (the bulk `/all`
|
||||
// list omits bitDepth/samplingRate). The cached badge is already
|
||||
// painted above (KEEP it — a partial badge beats nothing); now
|
||||
// hydrate the real per-track quality in the background and fan the
|
||||
// result out to every badge surface so they agree.
|
||||
let gk_for_hydrate = w.global::<crate::LocalAlbumState>().get_id().to_string();
|
||||
if gk_for_hydrate.starts_with("plex:") {
|
||||
spawn_plex_quality_hydration(w.as_weak(), hydrate_handle.clone(), gk_for_hydrate);
|
||||
}
|
||||
// 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.
|
||||
@@ -1261,12 +1258,11 @@ pub fn apply_album_version(window: &AppWindow, index: i32) {
|
||||
let info_line = format!("{} tracks · {}", tracks.len(), fmt_album_duration(total_secs));
|
||||
let (tier, detail) = match tracks.iter().max_by_key(|t| t.bit_depth.unwrap_or(0)) {
|
||||
Some(t) => {
|
||||
let tier = match t.bit_depth {
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None => "",
|
||||
};
|
||||
(tier.to_string(), local_quality(t.bit_depth, t.sample_rate).0)
|
||||
// Same shared classifier as the card + rows (badge), so the header
|
||||
// matches them; un-hydrated lossless → generic "FLAC".
|
||||
let (tier, detail, _) =
|
||||
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
|
||||
(tier.to_string(), detail)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
@@ -1288,6 +1284,305 @@ pub fn apply_album_version(window: &AppWindow, index: i32) {
|
||||
s.set_version_index(index);
|
||||
}
|
||||
|
||||
// ======================= Plex quality hydration (slice 6) =================
|
||||
// Album-open trigger: a Plex album's cached rows often carry NULL/incomplete
|
||||
// quality (the bulk `/library/sections/.../all` list omits bitDepth and
|
||||
// samplingRate). We fetch the real per-track quality on open, AWAIT the SQLite
|
||||
// write-back (qbz_plex persists via COALESCE so a NULL incoming value never
|
||||
// erases an existing one), then fan the result out to every badge surface so
|
||||
// they agree:
|
||||
// (1) album-CARD grid badge — re-read the album aggregate, patch the 3 album
|
||||
// models + the raw LOCAL_ALBUMS filter source.
|
||||
// (2) album-DETAIL header + per-row + audio-specs — rebuild album_versions()
|
||||
// from the hydrated cache, re-run apply_album_version.
|
||||
// (3) flat Tracks-tab rows — patch matching rows in `tracks`/`tracks-visible`
|
||||
// + the tracks_current() selection cache by id.
|
||||
// (4) now-playing STAMP — if a hydrated track is the current queue track,
|
||||
// patch its frozen snapshot and re-push.
|
||||
//
|
||||
// "Needs hydration" is the DB-NULL definition (bit_depth IS NULL OR sample_rate
|
||||
// is 0) — NOT a value heuristic; a genuine 16/44.1 FLAC is written once and
|
||||
// never re-probed (fixes the Tauri isLikelyFallbackPlexQuality bug).
|
||||
|
||||
/// Spawn the album-open Plex quality hydration. Reads the just-loaded version
|
||||
/// tracks to find which Plex rating_keys still need hydration, hydrates them
|
||||
/// (awaiting the persist), then refreshes all four badge surfaces.
|
||||
fn spawn_plex_quality_hydration(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
group_key: String,
|
||||
) {
|
||||
// Collect rating_keys whose cached quality is still incomplete. The Plex
|
||||
// LocalTrack carries the rating_key in `file_path` and the cached quality in
|
||||
// `bit_depth` / `sample_rate` (0.0 == unknown).
|
||||
let needing: Vec<String> = {
|
||||
let versions = album_versions();
|
||||
let mut keys: Vec<String> = versions
|
||||
.iter()
|
||||
.flat_map(|(_, tracks)| tracks.iter())
|
||||
.filter(|t| t.source.as_deref() == Some("plex"))
|
||||
.filter(|t| t.bit_depth.is_none() || t.sample_rate <= 0.0)
|
||||
.map(|t| t.file_path.clone())
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
keys
|
||||
};
|
||||
if needing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let plex = crate::plex_settings::get();
|
||||
if plex.base_url.is_empty() || plex.token.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
handle.spawn(async move {
|
||||
let updates = match qbz_plex::plex_hydrate_album_quality(
|
||||
plex.base_url.clone(),
|
||||
plex.token.clone(),
|
||||
needing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(u) if !u.is_empty() => u,
|
||||
_ => return, // nothing fetched/persisted → leave the cached badge as-is
|
||||
};
|
||||
|
||||
// Surface 4 (now-playing): if any hydrated track is the current queue
|
||||
// track, patch its frozen snapshot and re-push the stamp. Fire-and-forget
|
||||
// through the global queue controller.
|
||||
let queue_updates: Vec<(String, Option<u32>, Option<f64>)> = updates
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let khz = u.sampling_rate_hz.map(|hz| hz as f64 / 1000.0);
|
||||
(u.rating_key.clone(), u.bit_depth, khz)
|
||||
})
|
||||
.collect();
|
||||
crate::playback::apply_plex_quality_to_queue(queue_updates);
|
||||
|
||||
// Re-read the now-hydrated album tracks + the album aggregate off the UI
|
||||
// thread (both are blocking SQLite reads).
|
||||
let gk = group_key.clone();
|
||||
let plex_path = plex_cache_db_path();
|
||||
let reread = tokio::task::spawn_blocking(move || {
|
||||
let tracks = qbz_plex::plex_cache_get_album_tracks(gk.clone())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(map_plex_cached_to_local_track)
|
||||
.collect::<Vec<_>>();
|
||||
// The album aggregate (MAX over tracks) is a pure runtime query; the
|
||||
// refreshed Plex track quality flows into it automatically.
|
||||
let album = plex_path.as_deref().and_then(|p| {
|
||||
crate::library_db::with_db(|db| {
|
||||
let page = db.get_albums_metadata_page(
|
||||
0,
|
||||
ALBUMS_FULL_LOAD_LIMIT,
|
||||
None,
|
||||
"artist",
|
||||
"asc",
|
||||
false,
|
||||
true,
|
||||
Some(p),
|
||||
)?;
|
||||
Ok(page.albums.into_iter().find(|a| a.id == gk))
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
(tracks, album)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| (Vec::new(), None));
|
||||
|
||||
let (hydrated_tracks, hydrated_album) = reread;
|
||||
if hydrated_tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let updated_rows: Vec<(String, Option<u32>, u32)> = updates
|
||||
.iter()
|
||||
.map(|u| (u.rating_key.clone(), u.bit_depth, u.sampling_rate_hz.unwrap_or(0)))
|
||||
.collect();
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
refresh_open_album_after_hydration(&w, &group_key, hydrated_tracks);
|
||||
if let Some(album) = hydrated_album {
|
||||
refresh_album_card_quality(&w, &album);
|
||||
}
|
||||
refresh_track_rows_quality(&w, &updated_rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Surface 2 — rebuild the open album's version tracks from the hydrated cache
|
||||
/// rows and re-run `apply_album_version` so the header MAX badge, the audio
|
||||
/// specs, and the per-row badges all recompute from the real quality. No-op if
|
||||
/// the open album changed while we were hydrating.
|
||||
fn refresh_open_album_after_hydration(
|
||||
window: &AppWindow,
|
||||
group_key: &str,
|
||||
hydrated_tracks: Vec<qbz_library::LocalTrack>,
|
||||
) {
|
||||
let s = window.global::<crate::LocalAlbumState>();
|
||||
if s.get_id().to_string() != group_key {
|
||||
return; // user navigated away; the album-detail surface is no longer this album
|
||||
}
|
||||
// Re-split into versions by source dir, mirroring open_local_album.
|
||||
let mut groups: std::collections::HashMap<String, Vec<qbz_library::LocalTrack>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for t in hydrated_tracks {
|
||||
let key = t.album_group_key.clone();
|
||||
if !groups.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
groups.entry(key).or_default().push(t);
|
||||
}
|
||||
let mut versions: Vec<(String, Vec<qbz_library::LocalTrack>)> = order
|
||||
.into_iter()
|
||||
.filter_map(|k| {
|
||||
groups.remove(&k).map(|mut v| {
|
||||
v.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(0)));
|
||||
(k, v)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
versions.sort_by(|a, b| {
|
||||
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0));
|
||||
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0));
|
||||
qb.cmp(&qa)
|
||||
});
|
||||
let index = s.get_version_index().max(0);
|
||||
*album_versions() = versions;
|
||||
apply_album_version(window, index);
|
||||
}
|
||||
|
||||
/// Surface 1 — patch the album-CARD grid badge for one album across all three
|
||||
/// rendered album models (`albums`, `albums-visible`, each `albums-grouped`
|
||||
/// section) AND the raw `LOCAL_ALBUMS` filter source, recomputing tier/label/
|
||||
/// detail from the freshly aggregated quality. Mirrors `set_local_album_artwork`
|
||||
/// (refresh-one-row across every collection — the Tauri bug was patching only
|
||||
/// one collection, leaving the grid badge stale).
|
||||
fn refresh_album_card_quality(window: &AppWindow, album: &qbz_library::LocalAlbum) {
|
||||
let card = map_local_album(album.clone());
|
||||
let tier: slint::SharedString = card.quality_tier.clone().into();
|
||||
let label: slint::SharedString = card.quality_label.clone().into();
|
||||
let detail: slint::SharedString = card.quality_detail.clone().into();
|
||||
let id = album.id.clone();
|
||||
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let patch_in = |m: &ModelRc<AlbumCardItem>| {
|
||||
for i in 0..m.row_count() {
|
||||
if let Some(mut it) = m.row_data(i) {
|
||||
if it.id.as_str() == id {
|
||||
it.quality_tier = tier.clone();
|
||||
it.quality_label = label.clone();
|
||||
it.quality_detail = detail.clone();
|
||||
m.set_row_data(i, it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
patch_in(&s.get_albums());
|
||||
patch_in(&s.get_albums_visible());
|
||||
let grouped = s.get_albums_grouped();
|
||||
for gi in 0..grouped.row_count() {
|
||||
if let Some(sec) = grouped.row_data(gi) {
|
||||
patch_in(&sec.albums);
|
||||
}
|
||||
}
|
||||
// Keep the raw filter source consistent so a re-derive (search/filter/sort)
|
||||
// doesn't snap the badge back to the stale MAX.
|
||||
{
|
||||
let mut cache = local_albums();
|
||||
if let Some(a) = cache.iter_mut().find(|a| a.id == id) {
|
||||
a.bit_depth = album.bit_depth;
|
||||
a.sample_rate = album.sample_rate;
|
||||
a.format = album.format.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface 3 — patch the flat Tracks-tab row badges (and the `tracks_current()`
|
||||
/// selection cache) for the hydrated Plex tracks, matched by their namespaced
|
||||
/// row id. Patches in place (no model rebuild) to avoid re-grouping the 16K-row
|
||||
/// set — the same reason Tauri used an override map instead of a `tracks`
|
||||
/// reassignment.
|
||||
fn refresh_track_rows_quality(window: &AppWindow, updates: &[(String, Option<u32>, u32)]) {
|
||||
if updates.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Map rating_key -> namespaced TrackItem id string (matches map_plex_cached_to_local_track).
|
||||
let by_id: std::collections::HashMap<String, (Option<u32>, u32)> = {
|
||||
let cache = tracks_current();
|
||||
let mut m = std::collections::HashMap::new();
|
||||
for t in cache.iter() {
|
||||
if t.source.as_deref() != Some("plex") {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, bd, sr)) = updates.iter().find(|(rk, _, _)| *rk == t.file_path) {
|
||||
m.insert(t.id.to_string(), (*bd, *sr));
|
||||
}
|
||||
}
|
||||
m
|
||||
};
|
||||
if by_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Patch the in-memory selection cache so a later derive_tracks keeps quality.
|
||||
{
|
||||
let mut cache = tracks_current();
|
||||
for t in cache.iter_mut() {
|
||||
if t.source.as_deref() != Some("plex") {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, bd, sr)) = updates.iter().find(|(rk, _, _)| *rk == t.file_path) {
|
||||
if bd.is_some() {
|
||||
t.bit_depth = *bd;
|
||||
}
|
||||
if *sr > 0 {
|
||||
t.sample_rate = *sr as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recompute = |bd: Option<u32>, sr: u32| -> (slint::SharedString, slint::SharedString) {
|
||||
let tier = match bd {
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None => "",
|
||||
};
|
||||
let detail = if tier.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
crate::quality::detail(bd, Some(sr as f64))
|
||||
};
|
||||
(tier.into(), detail.into())
|
||||
};
|
||||
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let patch_in = |m: &ModelRc<TrackItem>| {
|
||||
for i in 0..m.row_count() {
|
||||
if let Some(mut it) = m.row_data(i) {
|
||||
if it.source.as_str() != "plex" {
|
||||
continue;
|
||||
}
|
||||
if let Some((bd, sr)) = by_id.get(it.id.as_str()) {
|
||||
let (tier, detail) = recompute(*bd, *sr);
|
||||
it.quality_tier = tier;
|
||||
it.quality_detail = detail;
|
||||
m.set_row_data(i, it);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
patch_in(&s.get_tracks());
|
||||
patch_in(&s.get_tracks_visible());
|
||||
}
|
||||
|
||||
/// The source directory of version `index` (for the tag editor — a real dir).
|
||||
pub fn album_version_dir(index: i32) -> Option<String> {
|
||||
album_versions().get(index as usize).map(|(dir, _)| dir.clone())
|
||||
|
||||
@@ -48,6 +48,30 @@ pub(crate) fn refresh_sidebar(with_favorites: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Plex quality updates to any queued track (by `rating_key`) and, if
|
||||
/// the CURRENTLY-playing track was among them, re-push the now-playing stamp so
|
||||
/// the player-bar quality badge agrees with the freshly-hydrated value. Reaches
|
||||
/// the runtime through the global queue controller (the hydration path runs in
|
||||
/// a detail-view context that does not carry the runtime). No-op before the
|
||||
/// controller is registered or when nothing matches. `updates` is
|
||||
/// `(rating_key, bit_depth, sample_rate_khz)`.
|
||||
pub fn apply_plex_quality_to_queue(updates: Vec<(String, Option<u32>, Option<f64>)>) {
|
||||
if updates.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(controller) = QUEUE_CONTROLLER.get() else {
|
||||
return;
|
||||
};
|
||||
let runtime = controller.runtime().clone();
|
||||
let weak = controller.weak().clone();
|
||||
controller.handle().spawn(async move {
|
||||
let current_patched = runtime.core().patch_plex_queue_quality(&updates).await;
|
||||
if current_patched {
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared post-track-change step: update the now-playing card, record the
|
||||
/// play in the recently-played store, and start audio for `track_id`.
|
||||
/// Used by the queue controller's play paths.
|
||||
|
||||
@@ -14,6 +14,11 @@ pub fn detail(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
|
||||
let hi_res = matches!(bit_depth, Some(depth) if depth >= 24);
|
||||
let depth = bit_depth.unwrap_or(if hi_res { 24 } else { 16 });
|
||||
let rate = sample_rate.unwrap_or(if hi_res { 96.0 } else { 44.1 });
|
||||
// Accept Hz or kHz: Qobuz passes kHz (e.g. 96.0), while local/Plex track
|
||||
// rows pass raw Hz (e.g. 96000). Normalize so every surface — album-card,
|
||||
// album-detail header, track row, now-playing stamp — shows "96 kHz", not
|
||||
// a mix of "96 kHz" and "96000 kHz".
|
||||
let rate = if rate >= 1000.0 { rate / 1000.0 } else { rate };
|
||||
let rate = if rate.fract().abs() < f64::EPSILON {
|
||||
format!("{}", rate as i64)
|
||||
} else {
|
||||
@@ -21,3 +26,60 @@ pub fn detail(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
|
||||
};
|
||||
format!("{depth}-bit / {rate} kHz")
|
||||
}
|
||||
|
||||
/// Lossless container/codec formats — the file IS lossless even when its exact
|
||||
/// bit depth / sample rate isn't known yet (e.g. an un-hydrated Plex track).
|
||||
pub fn is_lossless_format(format: &str) -> bool {
|
||||
matches!(
|
||||
format.trim().to_ascii_lowercase().as_str(),
|
||||
"flac" | "wav" | "wave" | "aiff" | "aif" | "alac" | "ape" | "dsd" | "dsf" | "dff"
|
||||
)
|
||||
}
|
||||
|
||||
/// Badge tier from a container/codec + (possibly-unknown) max bit depth:
|
||||
/// - `"mp3"` lossy
|
||||
/// - `"hires"` >= 24-bit
|
||||
/// - `"cd"` known bit depth < 24
|
||||
/// - `"lossless"` bit depth UNKNOWN but the container IS lossless (un-hydrated
|
||||
/// Plex FLAC etc.) — show the filetype; better than no badge at all
|
||||
/// - `""` unknown format AND unknown bit depth → badge hidden
|
||||
pub fn tier(format: &str, bit_depth: Option<u32>) -> &'static str {
|
||||
if format.trim().eq_ignore_ascii_case("mp3") {
|
||||
return "mp3";
|
||||
}
|
||||
match bit_depth {
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None if is_lossless_format(format) => "lossless",
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The ONE source of truth for a quality badge: `(tier, detail, tooltip)`.
|
||||
/// Every local/Plex surface — album card, album-detail header, track row,
|
||||
/// now-playing stamp — goes through this so they can never disagree (the
|
||||
/// earlier per-surface duplication is exactly what produced "96 kHz" on one
|
||||
/// surface and "96000 kHz" on another). `sample_rate` accepts Hz OR kHz: the
|
||||
/// shared [`detail`] normalizes via a `>= 1000` guard, so Qobuz's already-kHz
|
||||
/// values are never re-divided (no "0.041 kHz") and Hz values become kHz once.
|
||||
/// For an un-hydrated lossless track the detail is the bare filetype (`"FLAC"`);
|
||||
/// after hydration it becomes the usual `"24-bit / 96 kHz"`, matching Qobuz.
|
||||
pub fn badge(
|
||||
format: &str,
|
||||
bit_depth: Option<u32>,
|
||||
sample_rate: Option<f64>,
|
||||
) -> (&'static str, String, String) {
|
||||
let t = tier(format, bit_depth);
|
||||
match t {
|
||||
"" | "mp3" => (t, String::new(), String::new()),
|
||||
"lossless" => {
|
||||
let f = format.trim().to_uppercase();
|
||||
(t, f.clone(), format!("Lossless: {f}"))
|
||||
}
|
||||
_ => {
|
||||
let d = detail(bit_depth, sample_rate);
|
||||
let prefix = if t == "hires" { "Hi-Res" } else { "CD" };
|
||||
(t, d.clone(), format!("{prefix}: {d}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,19 @@ impl QueueController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessors so background flows (e.g. Plex quality hydration) reachable
|
||||
/// only through the global controller can re-push now-playing without
|
||||
/// threading the runtime through every detail-view entry point.
|
||||
pub fn runtime(&self) -> &Runtime {
|
||||
&self.runtime
|
||||
}
|
||||
pub fn weak(&self) -> &slint::Weak<AppWindow> {
|
||||
&self.weak
|
||||
}
|
||||
pub fn handle(&self) -> &tokio::runtime::Handle {
|
||||
&self.handle
|
||||
}
|
||||
|
||||
/// Pull a fresh full queue snapshot, re-apply the search filter and
|
||||
/// current page, and push the result onto `QueueState`. Spawns on the
|
||||
/// tokio runtime; safe to call from any thread.
|
||||
|
||||
@@ -373,6 +373,7 @@ export component AlbumCard inherits Rectangle {
|
||||
QualityBadge {
|
||||
tier: root.album.quality-tier;
|
||||
label: root.album.quality-label;
|
||||
detail: root.album.quality-detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ import { Radius } from "../foundation/radius.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
|
||||
export component QualityBadge inherits Rectangle {
|
||||
// "hires" | "cd" | "mp3" | "" — empty hides the badge entirely.
|
||||
// "hires" | "cd" | "mp3" | "lossless" | "" — empty hides the badge entirely.
|
||||
in property <string> tier;
|
||||
// Exact quality string for the hover tooltip.
|
||||
in property <string> label;
|
||||
// For the "lossless" tier (un-hydrated filetype, e.g. an un-hydrated Plex
|
||||
// FLAC): the bare filetype to print, e.g. "FLAC".
|
||||
in property <string> detail;
|
||||
|
||||
visible: root.tier != "";
|
||||
width: root.tier == "hires" ? 42px : 30px;
|
||||
width: root.tier == "hires" ? 42px : (root.tier == "lossless" ? 36px : 30px);
|
||||
height: 30px;
|
||||
|
||||
if root.tier == "hires": Image {
|
||||
@@ -62,6 +65,25 @@ export component QualityBadge inherits Rectangle {
|
||||
tint: #b0b0b0;
|
||||
}
|
||||
}
|
||||
// Un-hydrated lossless: the bit-depth/rate isn't known yet, but the
|
||||
// filetype is — show it as a neutral text chip (e.g. "FLAC") rather than
|
||||
// disappearing. Upgrades to the cd/hires badge once hydrated.
|
||||
if root.tier == "lossless": Rectangle {
|
||||
width: 36px;
|
||||
height: 30px;
|
||||
border-radius: 3px;
|
||||
background: Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
Text {
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
text: root.detail;
|
||||
color: #b0b0b0;
|
||||
font-size: 9px;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
|
||||
// Hover tooltip — floats just above the badge, right-anchored so it
|
||||
// extends leftward into the card and is never clipped by the next card.
|
||||
|
||||
@@ -30,7 +30,9 @@ export component QualityBadgeFull inherits Rectangle {
|
||||
|
||||
property <string> tier-label: root.tier == "hires"
|
||||
? "HI-RES"
|
||||
: (root.tier == "mp3" ? "MP3" : "CD");
|
||||
: (root.tier == "mp3"
|
||||
? "MP3"
|
||||
: (root.tier == "lossless" ? "LOSSLESS" : "CD"));
|
||||
|
||||
visible: root.tier != "";
|
||||
height: (root.compact ? 29px : 36px) * root.scale-factor;
|
||||
@@ -70,6 +72,14 @@ export component QualityBadgeFull inherits Rectangle {
|
||||
height: (root.compact ? 13px : 16px) * root.scale-factor;
|
||||
tint: #b0b0b0;
|
||||
}
|
||||
// Un-hydrated lossless (filetype known, bit-depth/rate not yet):
|
||||
// reuse the neutral disc mark; the detail line carries "FLAC".
|
||||
if root.tier == "lossless": QbzIcon {
|
||||
source: @image-url("../assets/cd.svg");
|
||||
width: (root.compact ? 13px : 16px) * root.scale-factor;
|
||||
height: (root.compact ? 13px : 16px) * root.scale-factor;
|
||||
tint: #b0b0b0;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
|
||||
Reference in new issue
Block a user