feat(slint): playlist snapshot (names+membership) and offline favorites rail

Qobuz playlist snapshot, captured opportunistically with zero new API
traffic: every online sidebar/manager list load upserts id+name+owner+
track_count for the user's playlists; opening a playlist detail online
full-replaces its membership track ids (refusing playlists never listed,
so viewed public playlists don't land). Offline consumers: synthesized
sidebar/manager entries resolve the persisted name (killing the cold-start
'Playlist (N local)' fallback when a snapshot exists), and the mixed-
playlist offline detail now renders the snapshot tracks that are playable
(snapshot intersect cached intersect grace) from the offline-cache index -
snapshot block first in snapshot order, sidecar local/plex block after.
D11 availability widens: a playlist with zero sidecar rows but cached
snapshot tracks now shows offline. 4 repo tests.

Offline favorites rail: the Favorites placeholder gains an 'Available
offline (N)' block - disk-cached favorite ids intersected with the
offline-cache READY index and LocalLibrary qobuz_download copies, compact
rows with the offline cover chain, click plays the rail as a queue through
the cache tier. Shared OfflinePlaceholder gains an @children slot; only
the Favorites mount injects the rail.

Single commit: the two features interleave in main.rs/state wiring and
ship together to keep every commit compiling.

cc
This commit is contained in:
vicrodh committed 2026-06-10 02:27:54 -06:00
1 parent 23129cb98e
commit 81587f4d17
17 files changed
+1154 -95

No files matched your search

+7
View File
@@ -96,6 +96,13 @@ pub fn is_favorite(track_id: &str) -> bool {
contains(id)
}
/// Snapshot of the full favorite-track id set. Powers the offline
/// favorites rail (B9): the disk-first seeding makes this correct while
/// offline, right after session activation.
pub fn all() -> HashSet<u64> {
FAVORITES.read().map(|g| g.clone()).unwrap_or_default()
}
/// True when the given numeric track id is in the favorite set.
pub fn contains(track_id: u64) -> bool {
FAVORITES
+98 -43
View File
@@ -732,14 +732,23 @@ pub fn navigate(
// ──────────────── Mixed Qobuz playlist — offline detail (D11.a) ────────────────
/// Open the OFFLINE rendering of a MIXED (Qobuz-id) playlist: its local
/// sidecar rows (`playlist_local_tracks`) plus — under INDUCED offline only —
/// its Plex sidecar rows (`playlist_plex_tracks`; availability rule). The
/// Qobuz membership lives in the API and is not enumerable offline, so the
/// detail structurally shows ZERO Qobuz rows (spec D11 honest limit). The
/// name/description come from the sidebar's last-loaded cache (real names
/// after a mid-session flip; the synthesized fallback on a cold offline
/// start — the name is not stored locally anywhere).
/// Open the OFFLINE rendering of a MIXED (Qobuz-id) playlist: the playlist's
/// SNAPSHOT membership rows that are playable offline (B8: snapshot ∩
/// cached, grace-gated, resolved from the offline-cache index like the
/// LOCAL detail's Cached rows), then its local sidecar rows
/// (`playlist_local_tracks`) plus — under INDUCED offline only — its Plex
/// sidecar rows (`playlist_plex_tracks`; availability rule).
///
/// MERGE RULE: the Qobuz block renders FIRST in snapshot position order,
/// then the sidecar block in sidecar position order — the sidecar positions
/// are absolute slots assigned AFTER the Qobuz block by the online append
/// convention, so block-then-block keeps each source's own order without
/// trusting cross-source position arithmetic against a cached-only Qobuz
/// subset. A track present both in the snapshot and as a sidecar local row
/// renders twice, exactly like the online detail does.
///
/// The name/description come from the sidebar's last-loaded session cache,
/// else the persisted snapshot name (B7 — survives a cold offline start).
pub fn navigate_qobuz_offline(
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
@@ -761,39 +770,84 @@ pub fn navigate_qobuz_offline(
let plex_allowed = crate::offline_mode::engine().status().mode
== qbz_app::offline_mode::OfflineMode::InducedOffline;
let (rows, custom_artwork_path) = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
let mut rows: Vec<LoadedRow> = db
.get_playlist_local_tracks_with_position(playlist_id)?
.into_iter()
.map(|r| LoadedRow {
position: r.playlist_position,
item: RowItem::Local(Box::new(r.track)),
})
.collect();
if plex_allowed {
rows.extend(
db.get_playlist_plex_tracks_with_position(playlist_id)?
.into_iter()
.map(|(key, position)| LoadedRow {
position,
item: RowItem::Plex { key },
}),
);
rows.sort_by_key(|r| r.position);
}
let custom = db
.get_playlist_settings(playlist_id)?
.and_then(|s| s.custom_artwork_path)
.filter(|p| !p.is_empty());
Ok((rows, custom))
let (sidecar_rows, custom_artwork_path, playable_ids, snapshot_name) =
tokio::task::spawn_blocking(move || {
let (rows, custom) = crate::library_db::with_db(|db| {
let mut rows: Vec<LoadedRow> = db
.get_playlist_local_tracks_with_position(playlist_id)?
.into_iter()
.map(|r| LoadedRow {
position: r.playlist_position,
item: RowItem::Local(Box::new(r.track)),
})
.collect();
if plex_allowed {
rows.extend(
db.get_playlist_plex_tracks_with_position(playlist_id)?
.into_iter()
.map(|(key, position)| LoadedRow {
position,
item: RowItem::Plex { key },
}),
);
rows.sort_by_key(|r| r.position);
}
let custom = db
.get_playlist_settings(playlist_id)?
.and_then(|s| s.custom_artwork_path)
.filter(|p| !p.is_empty());
Ok((rows, custom))
})
.unwrap_or_default();
(
rows,
custom,
crate::playlist_snapshot::playable_track_ids_blocking(playlist_id),
crate::playlist_snapshot::name_blocking(playlist_id),
)
})
.unwrap_or_default()
})
.await
.unwrap_or_default();
.await
.unwrap_or_default();
// B8: resolve the playable snapshot ids against the offline-cache
// index (metadata + B5 cover chain), keeping snapshot order. Ids
// whose copy vanished since the cached-set check resolve to nothing
// and drop, mirroring the LOCAL detail's D11 filter.
let mut rows: Vec<LoadedRow> = Vec::new();
if !playable_ids.is_empty() {
if let Some(off) = crate::offline::get().await {
let cache_path = off.get_cache_path();
let guard = off.db.lock().await;
if let Some(db) = guard.as_ref() {
for (i, tid) in playable_ids.iter().enumerate() {
if let Ok(Some(info)) = db.get_track(*tid) {
if matches!(info.status, qbz_offline_cache::OfflineCacheStatus::Ready) {
let artwork_path = info.resolve_cover_path(&cache_path);
rows.push(LoadedRow {
position: i as i32,
item: RowItem::Cached {
track_id: info.track_id,
title: info.title,
artist: info.artist,
album: info.album.unwrap_or_default(),
duration_secs: info.duration_secs,
bit_depth: info.bit_depth,
sample_rate: info.sample_rate,
artwork_path,
},
});
}
}
}
}
}
}
// Merge rule (see the doc comment): Qobuz snapshot block first,
// sidecar block after, each in its own position order.
rows.extend(sidecar_rows);
let (name, description) = crate::sidebar::playlist_name_desc(playlist_id)
.or_else(|| snapshot_name.map(|n| (n, String::new())))
.unwrap_or_else(|| ("Playlist".to_string(), String::new()));
let (http_jobs, local_jobs) = artwork_jobs(&rows);
let _ = weak.upgrade_in_event_loop(move |w| {
@@ -810,10 +864,11 @@ pub fn navigate_qobuz_offline(
});
}
/// Apply the offline sidecar rows of a mixed Qobuz playlist into
/// `PlaylistState`. Read-only header (`is_owner` false — Qobuz edits can't
/// run offline); playback flows through this module's queue snapshot, the
/// same machinery the LOCAL detail uses. UI thread.
/// Apply the offline rows of a mixed Qobuz playlist (the cached snapshot
/// block + the sidecar block, see `navigate_qobuz_offline`'s merge rule)
/// into `PlaylistState`. Read-only header (`is_owner` false — Qobuz edits
/// can't run offline); playback flows through this module's queue snapshot,
/// the same machinery the LOCAL detail uses. UI thread.
fn apply_qobuz_offline(
window: &AppWindow,
playlist_id: u64,
@@ -839,7 +894,7 @@ fn apply_qobuz_offline(
let state = window.global::<PlaylistState>();
state.set_id(playlist_id.to_string().into());
state.set_name(name.into());
state.set_owner("Local tracks only — offline".into());
state.set_owner("Available tracks only — offline".into());
let description = crate::strip_html::strip_html(&description);
state.set_description(description.clone().into());
state.set_description_short(description.into());
+77 -10
View File
@@ -67,10 +67,12 @@ mod locallibrary_prefs;
mod tag_editor;
mod offline;
mod offline_cache;
mod offline_favorites;
mod offline_manager;
mod offline_mode;
mod playlist;
mod playlist_manager;
mod playlist_snapshot;
mod plex_auth;
mod plex_settings;
mod playlist_picker;
@@ -1824,6 +1826,21 @@ fn wire_playlist_manager(
.global::<PlaylistManagerActions>()
.on_toggle_favorite(move |id| {
let Some(w) = weak.upgrade() else { return };
// LOCAL playlist (B3): the flag lives on its own
// local_playlists row — the u64 settings table can't hold it.
if local_playlist::is_local_id(id.as_str()) {
let value = playlist_manager::toggle_local_favorite(&w, id.as_str());
refresh_pm_covers(&w);
let lid = id.to_string();
handle.spawn(async move {
tokio::task::spawn_blocking(move || {
local_playlist::set_favorite_blocking(&lid, value)
})
.await
.ok();
});
return;
}
let Ok(pid) = id.parse::<u64>() else { return };
let value = playlist_manager::toggle_favorite_local(&w, pid);
refresh_pm_covers(&w);
@@ -1842,6 +1859,26 @@ fn wire_playlist_manager(
.global::<PlaylistManagerActions>()
.on_toggle_hidden(move |id| {
let Some(w) = weak.upgrade() else { return };
// LOCAL playlist (B3): the flag lives on its own
// local_playlists row; hidden locals drop from the sidebar.
if local_playlist::is_local_id(id.as_str()) {
let value = playlist_manager::toggle_local_hidden(&w, id.as_str());
refresh_pm_covers(&w);
let lid = id.to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
tokio::task::spawn_blocking(move || {
local_playlist::set_hidden_blocking(&lid, value)
})
.await
.ok();
// The sidebar reflects hidden playlists, so refresh it.
load_sidebar_playlists(runtime, weak, &handle);
});
return;
}
let Ok(pid) = id.parse::<u64>() else { return };
let value = playlist_manager::toggle_hidden_local(&w, pid);
refresh_pm_covers(&w);
@@ -4313,6 +4350,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// B9 — offline Favorites "playable favorites" rail: rebuild on every
// mount of the Favorites offline placeholder (the rail's init fires
// load), play the rail from the clicked row.
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<OfflineFavoritesActions>().on_load(move || {
offline_favorites::load(weak.clone(), handle.clone());
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<OfflineFavoritesActions>().on_play(move |id| {
offline_favorites::play(
runtime.clone(),
weak.clone(),
handle.clone(),
id.to_string(),
);
});
}
// Appearance settings persistence. The toggles/selects set their
// AppearanceState property locally, then fire these generic callbacks so
// the choice survives restart. Tray keys persist to the shared per-user
@@ -5359,17 +5420,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// move, then persist the full order off-thread.
if let Some(w) = weak.upgrade() {
let up = action == "move-up";
let orders = playlist::move_track(&w, id.as_str(), up);
let pid = w.global::<PlaylistState>().get_id().to_string();
if !orders.is_empty() {
if let Ok(pid) = pid.parse::<u64>() {
handle.spawn(async move {
tokio::task::spawn_blocking(move || {
playlist::persist_custom(pid, orders);
})
.await
.ok();
});
// LOCAL playlist (B2): the move writes the repo's
// position order directly (no custom-order sidecar).
if local_playlist::is_local_id(&pid) {
local_playlist::move_row(&w, &handle, id.as_str(), up);
} else {
let orders = playlist::move_track(&w, id.as_str(), up);
if !orders.is_empty() {
if let Ok(pid) = pid.parse::<u64>() {
handle.spawn(async move {
tokio::task::spawn_blocking(move || {
playlist::persist_custom(pid, orders);
})
.await
.ok();
});
}
}
}
}
+6
View File
@@ -39,6 +39,12 @@ pub fn is_cached(track_id: &str) -> bool {
cached_ids().lock().map(|s| s.contains(&id)).unwrap_or(false)
}
/// Clone of the ready-set (B8: playlist-snapshot ∩ cached availability
/// checks). Safe from blocking threads — plain mutex, no DB hit.
pub fn cached_ids_set() -> HashSet<u64> {
cached_ids().lock().map(|s| s.clone()).unwrap_or_default()
}
fn mark_cached(track_id: u64, cached: bool) {
if let Ok(mut set) = cached_ids().lock() {
if cached {
+234
View File
@@ -0,0 +1,234 @@
//! B9 — offline Favorites "playable favorites" rail.
//!
//! While OFFLINE the Favorites view mounts the shared OfflinePlaceholder;
//! this module fills the rail under the placeholder copy with the favorite
//! tracks that are still playable. Three local id/metadata sources:
//!
//! favorites — `fav_cache` (disk-first seeded favorites_cache.db,
//! so the set is correct with zero network)
//! offline cache — qbz-offline-cache index rows with status READY
//! library copies — library.db `local_tracks` rows with
//! `source = 'qobuz_download'` (the Local Library
//! "Offline" source-filter set)
//!
//! rail = favorites ∩ (ready ∪ qobuz_download). Metadata comes from the
//! index row when present (title/artist columns + the offline cover chain
//! via [`CachedTrackInfo::resolve_cover_path`]), else from the library row;
//! ids with no local metadata are skipped (count logged). Zero schema
//! changes — both stores are read as-is.
//!
//! A row click replaces the player queue with the WHOLE rail starting at
//! the clicked row; the tracks carry the real Qobuz id +
//! `source = "qobuz_download"` (the `local_queue_track` offline-copy
//! shape), so playback runs through the existing offline cache tier.
use std::collections::HashSet;
use std::sync::{LazyLock, Mutex};
use qbz_models::QueueTrack;
use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::{AppWindow, OfflineFavoritesState, SlimItem};
type Runtime = std::sync::Arc<qbz_app::shell::AppRuntime<SlintAdapter>>;
/// The rail's queue, in display order — rebuilt by [`load`], consumed by
/// [`play`] (clicking a row plays the rail from that row, mirroring the
/// `play_tracks` track-list semantics).
static RAIL_QUEUE: LazyLock<Mutex<Vec<QueueTrack>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Worker-built, `Send` row data; the cover image is decoded on the UI
/// thread (`slint::Image` is not `Send`) — the offline_manager pattern.
struct RowData {
id: String,
title: String,
artist: String,
cover_path: String,
}
/// Load a cover image from a path on the UI thread (empty/missing -> default).
fn load_cover(path: &str) -> slint::Image {
if path.is_empty() {
return slint::Image::default();
}
slint::Image::load_from_path(std::path::Path::new(path)).unwrap_or_default()
}
/// kHz normalization: Qobuz metadata carries kHz (96.0), library rows Hz
/// (96000.0) — same defensive rule as `local_queue_track`.
fn khz(rate: Option<f64>) -> Option<f64> {
rate.map(|r| if r >= 1000.0 { r / 1000.0 } else { r })
}
/// QueueTrack from an offline-cache index row — mirrors
/// `playback::local_queue_track`'s offline-copy arm: the real Qobuz id,
/// `source = "qobuz_download"`, `is_local = true` (playback then routes
/// through the offline cache tier), `file://` artwork from the offline
/// cover chain.
fn index_queue_track(row: &CachedTrackInfo, cover: &str) -> QueueTrack {
QueueTrack {
id: row.track_id,
title: row.title.clone(),
version: None,
artist: row.artist.clone(),
album: row.album.clone().unwrap_or_default(),
duration_secs: row.duration_secs,
artwork_url: (!cover.is_empty()).then(|| format!("file://{cover}")),
hires: row.bit_depth.map(|d| d > 16).unwrap_or(false),
bit_depth: row.bit_depth,
sample_rate: khz(row.sample_rate),
is_local: true,
album_id: row.album_id.clone(),
artist_id: None,
streamable: true,
source: Some("qobuz_download".to_string()),
parental_warning: false,
source_item_id_hint: None,
}
}
/// Rebuild the rail. Fired by the Slint rail's `init` on every mount of
/// the Favorites offline placeholder (ADR-010 conditional mount, so each
/// entry re-reads the three local stores — all cheap local reads).
pub fn load(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
handle.spawn(async move {
let favorites: HashSet<u64> = crate::fav_cache::all();
// Offline-cache index: READY rows (most-recently-accessed first,
// the DB's order) + the cache root for the cover chain.
let (index_rows, cache_path): (Vec<CachedTrackInfo>, String) =
match crate::offline::get().await {
Some(off) => {
let cp = off.get_cache_path();
let guard = off.db.lock().await;
let rows: Vec<CachedTrackInfo> = guard
.as_ref()
.and_then(|db| db.get_all_tracks().ok())
.unwrap_or_default()
.into_iter()
.filter(|t| matches!(t.status, OfflineCacheStatus::Ready))
.collect();
(rows, cp)
}
None => (Vec::new(), String::new()),
};
// library.db qobuz_download rows (the LocalLibrary "Offline"
// source-filter set). `with_db` opens the file on the current
// thread, so it runs inside spawn_blocking.
let mut lib_rows: Vec<qbz_library::LocalTrack> = tokio::task::spawn_blocking(|| {
crate::library_db::with_db(|db| db.get_qobuz_download_tracks()).unwrap_or_default()
})
.await
.unwrap_or_default();
// Folder-cover backfill: the downloaders write cover.jpg next to
// the file without always backfilling artwork_path.
crate::playback::fill_missing_covers(&mut lib_rows);
// Intersection, in display order: index rows first (recency), then
// library-only copies. The index row wins metadata when an id is in
// both (richer quality columns + the offline cover chain).
let mut seen: HashSet<u64> = HashSet::new();
let mut rows: Vec<RowData> = Vec::new();
let mut queue: Vec<QueueTrack> = Vec::new();
for t in &index_rows {
if !favorites.contains(&t.track_id) || !seen.insert(t.track_id) {
continue;
}
let cover = t.resolve_cover_path(&cache_path).unwrap_or_default();
queue.push(index_queue_track(t, &cover));
rows.push(RowData {
id: t.track_id.to_string(),
title: t.title.clone(),
artist: t.artist.clone(),
cover_path: cover,
});
}
for lt in &lib_rows {
let Some(qid) = lt.qobuz_track_id.and_then(|v| u64::try_from(v).ok()) else {
continue;
};
if !favorites.contains(&qid) || !seen.insert(qid) {
continue;
}
queue.push(crate::playback::local_queue_track(lt));
rows.push(RowData {
id: qid.to_string(),
title: lt.title.clone(),
artist: lt.artist.clone(),
cover_path: lt.artwork_path.clone().unwrap_or_default(),
});
}
// Playable favorites with no local metadata row are skipped by
// construction (membership comes FROM the metadata rows); keep the
// count observable per the backlog contract.
let ready_ids: HashSet<u64> = index_rows.iter().map(|t| t.track_id).collect();
let lib_ids: HashSet<u64> = lib_rows
.iter()
.filter_map(|t| t.qobuz_track_id.and_then(|v| u64::try_from(v).ok()))
.collect();
let playable = favorites
.iter()
.filter(|id| ready_ids.contains(id) || lib_ids.contains(id))
.count();
let skipped = playable.saturating_sub(rows.len());
log::info!(
"[qbz-slint] offline favorites rail: {} playable of {} favorites ({} skipped — no local metadata)",
rows.len(),
favorites.len(),
skipped
);
if let Ok(mut q) = RAIL_QUEUE.lock() {
*q = queue;
}
let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<SlimItem> = rows
.into_iter()
.map(|rd| SlimItem {
id: rd.id.into(),
title: rd.title.into(),
subtitle: rd.artist.into(),
rank: "".into(),
artwork_url: "".into(),
artwork: load_cover(&rd.cover_path),
following: false,
})
.collect();
w.global::<OfflineFavoritesState>()
.set_tracks(ModelRc::new(VecModel::from(items)));
});
});
}
/// Play the rail starting at the clicked track id: the rail becomes the
/// queue (replace), playback starts at the clicked row and continues down
/// the list through the existing offline-capable play path.
pub fn play(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
id: String,
) {
let queue: Vec<QueueTrack> = RAIL_QUEUE
.lock()
.map(|q| q.clone())
.unwrap_or_default();
if queue.is_empty() {
return;
}
let start = id
.parse::<u64>()
.ok()
.and_then(|tid| queue.iter().position(|t| t.id == tid))
.unwrap_or(0);
let first_id = queue[start].id;
handle.spawn(async move {
runtime.core().set_queue(queue, Some(start)).await;
crate::playback::after_track_change(&runtime, &weak, first_id).await;
crate::playback::refresh_sidebar(true);
});
}
+10
View File
@@ -313,6 +313,16 @@ where
.description
.map(|d| crate::strip_html::strip_html(&d))
.unwrap_or_default();
// B7 producer (membership): this fetch already returned the FULL track
// list — full-replace the playlist's snapshot membership, detached (the
// render never waits). No-ops for playlists outside the user's listed
// set (no snapshot header), so merely-viewed public playlists stay out.
crate::playlist_snapshot::record_detail_detached(
playlist_id,
pl.name.clone(),
pl.owner.name.clone(),
tracks.iter().map(|t| t.id).collect(),
);
Some(PlaylistData {
id: pl.id.to_string(),
name: pl.name,
+72 -15
View File
@@ -47,6 +47,9 @@ struct PmPlaylist {
position: i32,
/// Up to four de-duplicated cover URLs (same scheme as the sidebar).
cover_urls: Vec<String>,
/// B8: >= 1 snapshot track playable offline (snapshot ∩ cached,
/// grace-gated). Extends the D11.b offline filter; false while online.
offline_available: bool,
}
impl PmPlaylist {
@@ -116,8 +119,22 @@ where
log::warn!("[qbz-slint] playlist-manager playlists load failed: {e}");
Vec::new()
});
// B7 producer (names): persist id+name(+owner, track_count) for ALL
// listed playlists — data this load already fetched, written detached.
// Offline the fetch is gate-refused (empty), so nothing is written.
crate::playlist_snapshot::record_names_detached(
remote
.iter()
.map(|p| crate::playlist_snapshot::SnapshotNameEntry {
qobuz_playlist_id: p.id,
name: p.name.clone(),
owner: Some(p.owner.name.clone()).filter(|o| !o.is_empty()),
track_count: Some(p.tracks_count),
})
.collect(),
);
let (folders, settings, play_counts, local_counts, locals) =
let (folders, settings, play_counts, local_counts, locals, snapshot_names, snapshot_available) =
tokio::task::spawn_blocking(|| {
let locals: Vec<PmLocalPlaylist> = crate::local_playlist::list_blocking()
.into_iter()
@@ -130,17 +147,38 @@ where
is_hidden: p.hidden,
})
.collect();
// B7/B8 (offline only): snapshot names for the synthesized
// entries + the snapshot-available visibility set.
let (snapshot_names, snapshot_available) =
if crate::offline_mode::engine().is_offline() {
(
crate::playlist_snapshot::headers_blocking(),
crate::playlist_snapshot::available_offline_blocking(),
)
} else {
(HashMap::new(), std::collections::HashSet::new())
};
(
crate::folders::load_folders_full(),
crate::folders::playlist_settings_map(),
crate::folders::playlist_play_counts(),
crate::folders::playlist_local_counts(),
locals,
snapshot_names,
snapshot_available,
)
})
.await
.unwrap_or_else(|_| {
(Vec::new(), HashMap::new(), HashMap::new(), HashMap::new(), Vec::new())
(
Vec::new(),
HashMap::new(),
HashMap::new(),
HashMap::new(),
Vec::new(),
HashMap::new(),
std::collections::HashSet::new(),
)
});
let folder_ids: std::collections::HashSet<&String> = folders.iter().map(|f| &f.id).collect();
@@ -166,30 +204,46 @@ where
folder_id,
position: s.position,
cover_urls: cover_urls(p),
offline_available: snapshot_available.contains(&p.id),
}
})
.collect();
// D11.b — OFFLINE: the Qobuz fetch is gate-refused (empty), so the MIXED
// playlists (>= 1 local sidecar row) are synthesized from the local
// counts to stay reachable. Their names are not stored locally anywhere
// (`playlist_settings` has no name column), so a cold offline start
// falls back to "Playlist (N local)"; a mid-session flip keeps the real
// name via the sidebar's name cache (loaded while online).
// D11.b — OFFLINE: the Qobuz fetch is gate-refused (empty), so the
// reachable playlists are synthesized locally: the MIXED ones (>= 1
// local sidecar row) plus — B8 — the snapshot-available ones (>= 1
// cached snapshot track). Names: the sidebar's session cache (loaded
// while online), else the persisted snapshot (B7 — survives a cold
// offline start), else the "Playlist (N local)" fallback.
if crate::offline_mode::engine().is_offline() {
let known: std::collections::HashSet<u64> = playlists.iter().map(|p| p.id).collect();
for (&id, &count) in &local_counts {
if count == 0 || known.contains(&id) {
let mut ids: Vec<u64> = local_counts
.iter()
.filter(|&(_, &count)| count > 0)
.map(|(&id, _)| id)
.collect();
for &id in &snapshot_available {
if !ids.contains(&id) {
ids.push(id);
}
}
for id in ids {
if known.contains(&id) {
continue;
}
let count = local_counts.get(&id).copied().unwrap_or(0);
let s = settings.get(&id).cloned().unwrap_or_default();
let snapshot = snapshot_names.get(&id);
let name = crate::sidebar::playlist_name_desc(id)
.map(|(name, _)| name)
.or_else(|| snapshot.map(|(name, _)| name.clone()))
.unwrap_or_else(|| format!("Playlist ({count} local)"));
playlists.push(PmPlaylist {
id,
name,
tracks_count: 0,
// The snapshot's point-in-time Qobuz total when known
// ("# of tracks" sort + the card count line).
tracks_count: snapshot.and_then(|(_, tc)| *tc).unwrap_or(0),
duration: 0,
local_count: count,
play_count: play_counts.get(&id).copied().unwrap_or(0),
@@ -198,6 +252,7 @@ where
folder_id: s.folder_id.filter(|fid| folder_ids.contains(fid)),
position: s.position,
cover_urls: Vec::new(),
offline_available: snapshot_available.contains(&id),
});
}
}
@@ -507,12 +562,13 @@ pub fn rebuild(window: &AppWindow) {
.collect();
// Filtered + sorted playlists for the grid / list. While OFFLINE only
// the MIXED playlists (>= 1 local sidecar track) stay (D11.b).
// the MIXED (>= 1 local sidecar track) and snapshot-available (>= 1
// cached snapshot track, B8) playlists stay (D11.b).
let offline = crate::offline_mode::engine().is_offline();
let filtered: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| !offline || p.local_count > 0)
.filter(|p| !offline || p.local_count > 0 || p.offline_available)
.filter(|p| passes(p, &query, &filter, folder_mode, &view_mode))
.cloned()
.collect();
@@ -597,8 +653,9 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
let offline = crate::offline_mode::engine().is_offline();
let matches = |p: &PmPlaylist| -> bool {
// D11.b: offline only the MIXED playlists stay.
if offline && p.local_count == 0 {
// D11.b: offline only the MIXED and snapshot-available (B8)
// playlists stay.
if offline && p.local_count == 0 && !p.offline_available {
return false;
}
if searching && !p.name.to_lowercase().contains(query) {
+142
View File
@@ -0,0 +1,142 @@
//! Qobuz playlist snapshot — Slint glue (offline-mode port, B7/B8).
//!
//! Storage lives in the shared per-user `library.db`
//! (`qbz_library::qobuz_playlist_snapshot`). Producers are opportunistic and
//! DETACHED: the sidebar / playlist-manager list loads persist names for all
//! listed playlists, and the online playlist detail full-replaces one
//! playlist's membership — all from data those loads already fetched, off
//! the calling task, never blocking a render.
//!
//! Offline consumers (sidebar / manager / mixed-playlist detail) read the
//! snapshot blocking-side to resolve real names and the cached-playable
//! membership subset. Rows are point-in-time — shown as-is (no staleness UI
//! in v1).
use std::collections::{HashMap, HashSet};
use qbz_library::qobuz_playlist_snapshot as repo;
pub use repo::SnapshotNameEntry;
/// NAMES producer: persist id+name(+owner, track_count) for every listed
/// playlist. Fire-and-forget — runs on a blocking thread, the caller (an
/// async list load) never waits on it.
pub fn record_names_detached(entries: Vec<SnapshotNameEntry>) {
if entries.is_empty() {
return;
}
let write = move || {
let res = crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::upsert_names(conn, &entries)))
});
if let Some(Err(e)) = res {
log::warn!("[qbz-slint] playlist snapshot names write failed: {e}");
}
};
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::spawn_blocking(write);
} else {
std::thread::spawn(write);
}
}
/// MEMBERSHIP producer: full-replace one playlist's snapshot track ids (the
/// online detail load already fetched them). Fire-and-forget. Writes nothing
/// for playlists the names producer never captured (not the user's list —
/// merely-viewed public playlists stay out of the snapshot).
pub fn record_detail_detached(
playlist_id: u64,
name: String,
owner: String,
track_ids: Vec<u64>,
) {
let write = move || {
let owner = Some(owner.as_str()).filter(|o| !o.is_empty());
let res = crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
repo::replace_tracks(conn, playlist_id, &name, owner, &track_ids)
}))
});
match res {
Some(Ok(true)) => {}
Some(Ok(false)) => log::debug!(
"[qbz-slint] playlist snapshot: {playlist_id} not in the names set, membership skipped"
),
Some(Err(e)) => {
log::warn!("[qbz-slint] playlist snapshot membership write failed: {e}")
}
None => {}
}
};
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::spawn_blocking(write);
} else {
std::thread::spawn(write);
}
}
/// All snapshot headers: playlist id -> (name, total Qobuz track count at
/// snapshot time). Blocking — call from `spawn_blocking`.
pub fn headers_blocking() -> HashMap<u64, (String, Option<u32>)> {
crate::library_db::with_db(|db| Ok(db.with_connection(repo::all_headers)))
.and_then(|r| r.ok())
.map(|headers| {
headers
.into_iter()
.map(|h| (h.qobuz_playlist_id, (h.name, h.track_count)))
.collect()
})
.unwrap_or_default()
}
/// Snapshot name of one playlist. Blocking.
pub fn name_blocking(playlist_id: u64) -> Option<String> {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::get_header(conn, playlist_id)))
})
.and_then(|r| r.ok())
.flatten()
.map(|h| h.name)
}
/// B8 availability: the Qobuz playlists whose snapshot membership intersects
/// the offline-cache ready set — i.e. they have at least one track playable
/// offline right now. Empty past the subscription grace window (D4: the
/// cache may not serve full tracks, so nothing is playable). Blocking.
pub fn available_offline_blocking() -> HashSet<u64> {
if !crate::offline_mode::offline_playback_allowed() {
return HashSet::new();
}
let cached = crate::offline_cache::cached_ids_set();
if cached.is_empty() {
return HashSet::new();
}
crate::library_db::with_db(|db| Ok(db.with_connection(repo::all_track_ids)))
.and_then(|r| r.ok())
.map(|memberships| {
memberships
.into_iter()
.filter(|(_, ids)| ids.iter().any(|id| cached.contains(id)))
.map(|(id, _)| id)
.collect()
})
.unwrap_or_default()
}
/// B8 detail: one playlist's snapshot track ids that are PLAYABLE offline
/// (cached + within the grace window), in snapshot position order. Blocking.
pub fn playable_track_ids_blocking(playlist_id: u64) -> Vec<u64> {
if !crate::offline_mode::offline_playback_allowed() {
return Vec::new();
}
let cached = crate::offline_cache::cached_ids_set();
if cached.is_empty() {
return Vec::new();
}
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::track_ids(conn, playlist_id)))
})
.and_then(|r| r.ok())
.map(|ids| ids.into_iter().filter(|id| cached.contains(id)).collect())
.unwrap_or_default()
}
+87 -24
View File
@@ -77,6 +77,10 @@ pub struct SidebarData {
/// `playlist_local_tracks`). The D11.b offline filter keeps only the
/// MIXED playlists (count > 0); unused while online.
pub local_counts: HashMap<u64, u32>,
/// B8: Qobuz playlists whose local SNAPSHOT membership has >= 1 track
/// playable offline (snapshot ∩ cached, grace-gated). Extends the
/// D11.b offline filter; empty while online.
pub snapshot_available: HashSet<u64>,
}
/// Session-only folder expand state (matches Tauri — not persisted).
@@ -147,17 +151,32 @@ where
A: FrontendAdapter + Send + Sync + 'static,
{
let playlists: Vec<SidebarPlaylist> = match runtime.core().get_user_playlists().await {
Ok(pls) => pls
.into_iter()
.map(|p| SidebarPlaylist {
id: p.id,
name: p.name.clone(),
description: p.description.clone().unwrap_or_default(),
tracks_count: p.tracks_count,
cover_urls: playlist_cover_urls(&p),
position: 0,
})
.collect(),
Ok(pls) => {
// B7 producer (names): persist id+name(+owner, track_count) for
// ALL listed playlists — data this load already fetched, written
// detached so the render never waits. Offline the fetch is
// gate-refused (Err), so the snapshot is never clobbered.
crate::playlist_snapshot::record_names_detached(
pls.iter()
.map(|p| crate::playlist_snapshot::SnapshotNameEntry {
qobuz_playlist_id: p.id,
name: p.name.clone(),
owner: Some(p.owner.name.clone()).filter(|o| !o.is_empty()),
track_count: Some(p.tracks_count),
})
.collect(),
);
pls.into_iter()
.map(|p| SidebarPlaylist {
id: p.id,
name: p.name.clone(),
description: p.description.clone().unwrap_or_default(),
tracks_count: p.tracks_count,
cover_urls: playlist_cover_urls(&p),
position: 0,
})
.collect()
}
Err(e) => {
log::warn!("[qbz-slint] sidebar playlists load failed: {e}");
Vec::new()
@@ -166,8 +185,18 @@ where
// Folders (hidden folders excluded) + folder membership +
// per-playlist custom-sort positions + hidden-playlist set + the
// first-class LOCAL playlists + the per-playlist local sidecar counts
// (all local, library.db).
let (folders, folder_map, positions, hidden_playlists, local_playlists, local_counts) =
// (all local, library.db) + OFFLINE only: the playlist-snapshot names
// and the snapshot-available set (B7/B8).
let (
folders,
folder_map,
positions,
hidden_playlists,
local_playlists,
local_counts,
snapshot_names,
snapshot_available,
) =
tokio::task::spawn_blocking(|| {
let folders: Vec<FolderInfo> = crate::folders::load_folders_full()
.into_iter()
@@ -196,6 +225,18 @@ where
offline_only: p.offline_only,
})
.collect();
// B7/B8 (offline only): the snapshot names replace the
// synthesized "Playlist (N local)" fallback, and the
// snapshot-available set extends the D11.b visibility filter.
let (snapshot_names, snapshot_available) =
if crate::offline_mode::engine().is_offline() {
(
crate::playlist_snapshot::headers_blocking(),
crate::playlist_snapshot::available_offline_blocking(),
)
} else {
(HashMap::new(), HashSet::new())
};
(
folders,
crate::folders::playlist_folder_map(),
@@ -203,34 +244,52 @@ where
hidden_playlists,
local_playlists,
crate::folders::playlist_local_counts(),
snapshot_names,
snapshot_available,
)
})
.await
.unwrap_or_default();
let mut playlists = playlists;
// D11.b — OFFLINE: the Qobuz fetch above is gate-refused (empty), so the
// MIXED playlists (>= 1 local sidecar row) are synthesized from the local
// counts to stay reachable. Their NAMES are not stored locally anywhere
// (`playlist_settings` has no name column), so a cold offline start falls
// back to "Playlist (N local)"; a mid-session flip keeps the real name
// from the previous load's cache.
// reachable playlists are synthesized locally: the MIXED ones (>= 1
// local sidecar row) plus — B8 — the snapshot-available ones (>= 1
// cached snapshot track). Names come from the previous load's session
// cache, else the persisted snapshot (B7 — survives a cold offline
// start), else the synthesized "Playlist (N local)" fallback.
if crate::offline_mode::engine().is_offline() {
let known: HashSet<u64> = playlists.iter().map(|p| p.id).collect();
let prior: HashMap<u64, (String, String)> =
NAME_DESC.lock().map(|nd| nd.clone()).unwrap_or_default();
for (&id, &count) in &local_counts {
if count == 0 || known.contains(&id) {
let mut ids: Vec<u64> = local_counts
.iter()
.filter(|&(_, &count)| count > 0)
.map(|(&id, _)| id)
.collect();
for &id in &snapshot_available {
if !ids.contains(&id) {
ids.push(id);
}
}
for id in ids {
if known.contains(&id) {
continue;
}
let count = local_counts.get(&id).copied().unwrap_or(0);
let snapshot = snapshot_names.get(&id);
let (name, description) = prior
.get(&id)
.cloned()
.or_else(|| snapshot.map(|(name, _)| (name.clone(), String::new())))
.unwrap_or_else(|| (format!("Playlist ({count} local)"), String::new()));
// Track count: the snapshot's point-in-time Qobuz total when
// known (the "# of tracks" sort key), else the local count.
let tracks_count = snapshot.and_then(|(_, tc)| *tc).unwrap_or(count);
playlists.push(SidebarPlaylist {
id,
name,
description,
tracks_count: count,
tracks_count,
cover_urls: Vec::new(),
position: 0,
});
@@ -256,6 +315,7 @@ where
hidden_playlists,
local_playlists,
local_counts,
snapshot_available,
}
}
@@ -371,10 +431,13 @@ pub fn load_folder_popup(window: &AppWindow, folder_id: &str) {
.set_playlists(ModelRc::new(VecModel::from(entries)));
}
/// D11.b visibility: ONLINE every playlist shows; OFFLINE only the MIXED
/// ones (>= 1 local sidecar track) stay — 100% Qobuz playlists hide.
/// D11.b visibility: ONLINE every playlist shows; OFFLINE the MIXED ones
/// (>= 1 local sidecar track) stay, plus — B8 — the snapshot-available ones
/// (>= 1 cached snapshot track). Everything else hides.
fn offline_visible(data: &SidebarData, offline: bool, p: &SidebarPlaylist) -> bool {
!offline || data.local_counts.get(&p.id).copied().unwrap_or(0) > 0
!offline
|| data.local_counts.get(&p.id).copied().unwrap_or(0) > 0
|| data.snapshot_available.contains(&p.id)
}
/// Rebuild the flattened entries (+ the folders list for the