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:
17 files changed
+1154
-95
No files matched your search
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user