Files
qbz/crates/qbz-qt/src/playlist_manager_qt.rs
T
vicrodh e291eb3af4 fix: stabilize playback memory, local library sync and Qt settings
Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.

Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.

Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.

cx
2026-09-13 02:57:21 -06:00

1055 lines
40 KiB
Rust

//! Playlist Manager, controller part 1 — the load, the cache, the toolbar
//! state and the two publish paths.
//!
//! Port of `crates/qbz/src/playlist_manager.rs`'s `load` / `apply` / `rebuild`
//! / `reset_session` / `navigate` plus the `PlaylistManagerActions` toolbar
//! handlers in the reference's `main.rs:5230-5330`. The pure model functions
//! live in `playlist_manager_rows`; the mutations live in
//! `playlist_manager_ops`.
//!
//! # The two publish paths are NOT the same function (§5.4)
//!
//! | path | writes | writes `pmLoading`? |
//! |---|---|---|
//! | [`publish_document`] | `managerJson` + `foldersJson` | **NO** |
//! | [`publish_loaded`] (the load's terminal rebuild) | both | yes — `false` |
//!
//! `navigate()` and `reload()` each set `pmLoading = true`; only the load
//! clears it. Conflating them is not a style question: the reference's
//! `rebuild()` ends with an unconditional `set_loading(false)` and gets away
//! with it because its `navigate` publishes NOTHING. Copy "publish always
//! clears loading" while also publishing from `navigate()`, and the `false`
//! lands in the same event-loop turn as the `true` — the spinner never renders,
//! the whole network+DB load runs with it off, and the empty-state branch
//! (`!loading && playlists.length === 0`) flashes "No playlists found." on
//! every single entry.
//!
//! # `foldersJson` is cache-INDEPENDENT (D5 / §5.3)
//!
//! The sidebar's row context menu reads `foldersJson` in sessions where the
//! manager view has never been opened, so the folder list cannot be a function
//! of the manager cache. [`refresh_folders`] reads the DB directly and never
//! consults `CACHE`; [`FOLDERS_CACHE`] holds the result, and a warm manager
//! cache refreshes it in memory (its counts then track the optimistic patches).
//! `managerJson.folderCount` is derived from the SAME vector in the SAME `ui()`
//! closure, so the counter row and the folder array can never disagree.
//!
//! # Everything reachable from an invokable is Qt-thread-safe
//!
//! `LibraryDatabase` wraps a `!Send` rusqlite connection and every `folders_qt`
//! / `local_playlist_qt` call opens the DB, so none of them may run on the Qt
//! thread. An invokable here only ever mutates a `Mutex`, publishes, and
//! `crate::spawn`s the rest (§5.19). [`publish_document`] in particular NEVER
//! touches the DB.
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use serde::Serialize;
use crate::folders_qt;
use crate::playlist_manager_rows as rows;
use crate::playlist_manager_rows::{
FolderRow, PlaylistRow, PmData, PmLocalPlaylist, PmPlaylist, TreeRow,
};
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
/// Last-loaded data. `None` means **never loaded this session** — the one
/// condition `reload_if_loaded()` is allowed to test, and the one every
/// mutation is FORBIDDEN to test before its DB write (§5.3).
static CACHE: Mutex<Option<PmData>> = Mutex::new(None);
/// The canonical published folder array. Filled by [`refresh_folders`] from
/// the DB and by [`build_document`] from a warm cache; it is what
/// `foldersJson` always carries, so a cold-cache publish can never clobber it
/// with `[]`.
static FOLDERS_CACHE: LazyLock<Mutex<Vec<FolderRow>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Session tree-expand state. Never reset — only the latch below is, so a
/// re-entry re-expands every folder on top of whatever the user collapsed.
/// That is the reference behaviour (§5.5); keep it.
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
/// True once the tree has auto-expanded folders since the last `navigate()`.
static TREE_INIT: Mutex<bool> = Mutex::new(false);
/// Debounce generation for the search field (D8). A publish only happens for
/// the newest keystroke.
static SEARCH_GEN: AtomicU64 = AtomicU64::new(0);
/// Search debounce, milliseconds. Every OTHER toolbar setter publishes
/// immediately — only typing is coalesced, because each publish re-serialises
/// the whole document and tears down every visible card and collage.
const SEARCH_DEBOUNCE_MS: u64 = 150;
/// Session-scoped toolbar state. It SURVIVES leaving and re-entering the view
/// (§5.5) and resets only on restart, so `navigate()` must not touch it.
#[derive(Clone, Debug)]
struct Toolbar {
/// SEED ONLY for the QML field — the text itself is QML-local (D8).
search: String,
/// `"all" | "visible" | "hidden"`.
filter: String,
/// `"name" | "recent" | "playcount" | "tracks" | "custom"`.
sort: String,
sort_asc: bool,
/// `"grid" | "list" | "tree"`.
view_mode: String,
folder_mode: bool,
folders_collapsed: bool,
/// Grid/list drill-down. `None` is the root beside the folder cards;
/// tree mode owns its own expand state and never reads this value.
current_folder_id: Option<String>,
}
impl Default for Toolbar {
fn default() -> Self {
Self {
search: String::new(),
filter: "all".into(),
sort: "name".into(),
sort_asc: true,
view_mode: "grid".into(),
folder_mode: true,
folders_collapsed: false,
current_folder_id: None,
}
}
}
static TOOLBAR: LazyLock<Mutex<Toolbar>> = LazyLock::new(|| Mutex::new(Toolbar::default()));
// ---------------------------------------------------------------------------
// The wire document (§4.2)
// ---------------------------------------------------------------------------
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagerDoc {
search: String,
filter: String,
sort: String,
sort_asc: bool,
view_mode: String,
folder_mode: bool,
folders_collapsed: bool,
current_folder_id: String,
current_folder_name: String,
can_reorder: bool,
/// Always equal to `foldersJson.length` — same source, same closure (D5).
folder_count: i32,
/// POST-FILTER visible rows, locals included.
playlist_count: i32,
playlists: Vec<PlaylistRow>,
/// EMPTY unless `folderMode && viewMode == "tree"`.
tree: Vec<TreeRow>,
}
// ---------------------------------------------------------------------------
// Publishing
// ---------------------------------------------------------------------------
/// First-open auto-expand: the tree expands every folder the first time it is
/// opened after a `navigate()`. `reset_session` clears the latch, never
/// `EXPANDED` itself.
fn ensure_tree_init(data: &PmData) {
let mut init = TREE_INIT.lock().unwrap_or_else(|e| e.into_inner());
if *init {
return;
}
if let Ok(mut exp) = EXPANDED.lock() {
for f in &data.folders {
exp.insert(f.id.clone());
}
}
*init = true;
}
/// Serialise `(managerJson, foldersJson)` from the live cache + toolbar.
///
/// Pure with respect to I/O: no DB, no network. The only state it writes is
/// [`FOLDERS_CACHE`] (refreshed in memory from a warm manager cache) and the
/// tree auto-expand latch.
fn build_document() -> (String, String) {
let cached = CACHE.lock().unwrap().clone();
let warm = cached.is_some();
let data = cached.unwrap_or_default();
let tb = TOOLBAR.lock().unwrap().clone();
let query = tb.search.trim().to_lowercase();
let offline = crate::offline_fwd::engine().status().is_offline();
// Warm cache: recompute so the counts track optimistic moves. Cold cache:
// whatever `refresh_folders()` last read, NEVER an empty array — the
// sidebar's move-to-folder list reads this in sessions with no manager.
let folders = if warm {
let fresh = rows::folder_rows(&data);
*FOLDERS_CACHE.lock().unwrap() = fresh.clone();
fresh
} else {
FOLDERS_CACHE.lock().unwrap().clone()
};
let current_folder = (tb.folder_mode && tb.view_mode != "tree")
.then_some(tb.current_folder_id.as_deref())
.flatten()
.and_then(|id| data.folders.iter().find(|folder| folder.id == id));
let (current_folder_id, current_folder_name) = current_folder
.map(|folder| (folder.id.clone(), folder.name.clone()))
.unwrap_or_default();
let playlists = if current_folder_id.is_empty() {
rows::visible_playlist_rows(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
tb.folder_mode,
&tb.view_mode,
offline,
)
} else {
rows::visible_folder_playlist_rows(
&data,
&current_folder_id,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
offline,
)
};
let tree = if tb.folder_mode && tb.view_mode == "tree" {
ensure_tree_init(&data);
let expanded = EXPANDED.lock().unwrap().clone();
rows::build_tree(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
offline,
&expanded,
)
} else {
Vec::new()
};
let doc = ManagerDoc {
search: tb.search.clone(),
filter: tb.filter.clone(),
sort: tb.sort.clone(),
sort_asc: tb.sort_asc,
view_mode: tb.view_mode.clone(),
folder_mode: tb.folder_mode,
folders_collapsed: tb.folders_collapsed,
current_folder_id,
current_folder_name,
// D9: the DIRECTION is part of the gate. The reference omits it, and
// under custom+descending its arrows renumber `position = 0..n` over
// the REVERSED order — one press silently rewrites the user's whole
// persisted custom order.
can_reorder: tb.sort == "custom" && tb.sort_asc && query.is_empty(),
folder_count: folders.len() as i32,
playlist_count: playlists.len() as i32,
playlists,
tree,
};
(
serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()),
serde_json::to_string(&folders).unwrap_or_else(|_| "[]".into()),
)
}
/// Publish the document. **Never touches `pmLoading`** (§5.4).
pub(crate) fn publish_document() {
let deletion_revision = crate::library_qt::playlist_deletion_revision();
let (manager, folders) = build_document();
crate::playlist_manager_bridge::ui(move |mut b| {
if deletion_revision != crate::library_qt::playlist_deletion_revision() {
return;
}
b.as_mut().set_manager_json(QString::from(manager.as_str()));
b.as_mut().set_folders_json(QString::from(folders.as_str()));
});
}
/// The load's TERMINAL rebuild — the one and only place `pmLoading` is
/// cleared.
fn publish_loaded() {
let deletion_revision = crate::library_qt::playlist_deletion_revision();
let (manager, folders) = build_document();
crate::playlist_manager_bridge::ui(move |mut b| {
if deletion_revision != crate::library_qt::playlist_deletion_revision() {
b.as_mut().set_pm_loading(false);
return;
}
b.as_mut().set_manager_json(QString::from(manager.as_str()));
b.as_mut().set_folders_json(QString::from(folders.as_str()));
b.as_mut().set_pm_loading(false);
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// Seed the closed/default document and kick the cache-independent folder
/// read, so the sidebar's row menu has a folder list before the manager has
/// ever been opened (§5.19).
pub(crate) fn boot() {
publish_document();
refresh_folders();
}
/// Open the manager: record the route, clear the tree latch, raise the
/// spinner, publish the TOOLBAR so the view mounts with chrome instead of an
/// empty document. **No load** — the view's `Component.onCompleted` calls
/// [`reload`], which is also what serves the back/forward re-entry case
/// (`nav_qt::back()` republishes `currentView` and runs no per-view load, so
/// `navigate()` never ran). Loading from both would run `get_user_playlists()`
/// twice over the network on every entry (§5.11).
pub(crate) fn navigate() {
crate::nav_qt::record("playlistmanager");
reset_session();
// Raised here, cleared ONLY by publish_loaded(). Written inline rather
// than through a shared helper so the invariant is greppable: three
// `set_pm_loading` call sites in the whole crate — this one, reload()'s,
// and the single `false` at the end of the load.
crate::playlist_manager_bridge::ui(|mut b| {
b.as_mut().set_pm_loading(true);
});
publish_document();
}
/// Reset ONLY the tree-expand latch (§5.5). Search / filter / sort /
/// direction / view mode / folder mode / folders-collapsed all persist for the
/// session — do not reset them here.
fn reset_session() {
*TREE_INIT.lock().unwrap_or_else(|e| e.into_inner()) = false;
}
/// The single load. Raises the spinner at entry too, because a Back-triggered
/// `Component.onCompleted` reaches here without `navigate()` having run.
pub(crate) fn reload() {
crate::playlist_manager_bridge::ui(|mut b| {
b.as_mut().set_pm_loading(true);
});
let runtime = crate::app();
crate::spawn(async move {
let mut data = load(&runtime).await;
crate::library_qt::with_deleted_playlists(|deleted| {
data.playlists
.retain(|p| !deleted.contains(&p.id.to_string()));
*CACHE.lock().unwrap() = Some(data);
});
publish_loaded();
});
}
/// Refresh the manager after a write made ELSEWHERE (the folder editor, the
/// playlist editor, the sidebar row menu).
///
/// This early return is the ONLY thing in this controller allowed to be
/// conditional on the cache: when the manager has never been opened there is
/// no reader for the document, so the refetch would be pure cost. Generalising
/// it to the mutations turns "Hide from sidebar" and "Move to folder" into
/// silent no-ops for the account-less, offline population the whole feature
/// exists for (§5.3).
///
/// Callers: `folder_edit_qt::after_write` (block 2) and
/// `playlist_edit_qt::after_write` (block 3), after every successful write. It
/// lands here because the cross-singleton refresh seam is this controller's to
/// define, not theirs.
pub fn reload_if_loaded() {
if CACHE.lock().unwrap().is_none() {
return;
}
reload();
}
pub(crate) fn playlist_deleted(id: u64) {
{
let mut cache = CACHE.lock().unwrap();
let Some(data) = cache.as_mut() else { return };
data.playlists.retain(|p| p.id != id);
}
publish_document();
}
/// Name + description of a QOBUZ playlist out of the WARM manager cache.
///
/// Block 3's editor seeds from here before it falls back to
/// `core().get_playlist(pid)` (§5.2): the loader already fetched the
/// description and `PmPlaylist` carries it precisely so opening the editor
/// from the manager costs no request and works with the network down.
/// `None` means "the cache cannot answer" — never "no description"; a
/// playlist that genuinely has none answers `Some((name, None))`.
///
/// Deliberately NOT extended to `local:` ids: the local arm reads
/// `library.db` directly, which is always authoritative and always available.
pub(crate) fn cached_playlist_seed(id: u64) -> Option<(String, Option<String>)> {
let guard = CACHE.lock().ok()?;
let data = guard.as_ref()?;
data.playlists
.iter()
.find(|p| p.id == id)
.map(|p| (p.name.clone(), p.description.clone()))
}
/// Re-read `foldersJson` straight from the DB and republish.
///
/// Deliberately NOT gated on the manager cache (D5): a folder created from the
/// sidebar must reach `foldersJson` in a session where the manager view was
/// never opened. Counts come from `CACHE` when it is warm and from the DB when
/// it is not, so the number is never a placeholder 0 in either state.
pub fn refresh_folders() {
crate::spawn(async move {
let rows = tokio::task::spawn_blocking(folder_rows_blocking)
.await
.unwrap_or_default();
*FOLDERS_CACHE.lock().unwrap() = rows;
publish_document();
});
}
/// The DB half of [`refresh_folders`]. Blocking — `spawn_blocking` only.
fn folder_rows_blocking() -> Vec<FolderRow> {
let folders = folders_qt::load_folders_full();
// Warm cache: its counts already fold in the optimistic patches, so reuse
// them rather than re-reading a DB that may lag an in-flight write.
let cached = CACHE.lock().unwrap().clone();
if let Some(data) = cached.as_ref() {
let counts = rows::folder_counts(data);
return folders
.iter()
.map(|f| rows::folder_item(f, counts.get(&f.id).copied().unwrap_or(0)))
.collect();
}
// Cold cache: count both kinds from the DB, or a folder holding only local
// playlists reads "0 playlists" on its card (D6.3).
let settings = folders_qt::playlist_settings_map();
let locals = crate::local_playlist_qt::list_blocking();
let live: HashSet<&String> = folders.iter().map(|f| &f.id).collect();
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for s in settings.values() {
if let Some(fid) = s.folder_id.as_ref().filter(|f| live.contains(f)) {
*counts.entry(fid.clone()).or_insert(0) += 1;
}
}
for p in &locals {
if let Some(fid) = p.folder_id.as_ref().filter(|f| live.contains(f)) {
*counts.entry(fid.clone()).or_insert(0) += 1;
}
}
folders
.iter()
.map(|f| rows::folder_item(f, counts.get(&f.id).copied().unwrap_or(0)))
.collect()
}
// ---------------------------------------------------------------------------
// Toolbar
// ---------------------------------------------------------------------------
/// Search-as-you-type. The query is stored IMMEDIATELY (so a publish triggered
/// by anything else already reflects it) and the rebuild is coalesced on a
/// 150 ms debounce (D8).
pub(crate) fn search_changed(query: &str) {
TOOLBAR.lock().unwrap().search = query.to_string();
let generation = SEARCH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
crate::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS)).await;
if SEARCH_GEN.load(Ordering::SeqCst) == generation {
publish_document();
}
});
}
pub(crate) fn set_filter(value: &str) {
let valid = matches!(value, "all" | "visible" | "hidden");
TOOLBAR.lock().unwrap().filter = if valid {
value.to_string()
} else {
"all".into()
};
publish_document();
}
/// #657 direction toggle: re-selecting the ACTIVE option flips the direction;
/// picking a DIFFERENT one sets it and resets to its natural direction. Same
/// shape as `sidebar_qt::set_sort` — mirrored, not reinvented.
pub(crate) fn set_sort(value: &str) {
let option = match value {
"name" | "recent" | "playcount" | "tracks" | "custom" => value,
_ => "name",
};
{
let mut tb = TOOLBAR.lock().unwrap();
if tb.sort == option {
tb.sort_asc = !tb.sort_asc;
} else {
tb.sort = option.to_string();
tb.sort_asc = true;
}
}
publish_document();
}
pub(crate) fn set_view_mode(value: &str) {
let mode = match value {
"grid" | "list" | "tree" => value,
_ => "grid",
};
let mut toolbar = TOOLBAR.lock().unwrap();
toolbar.view_mode = mode.to_string();
if mode == "tree" {
toolbar.current_folder_id = None;
}
drop(toolbar);
publish_document();
}
/// Leaving folder mode while in the tree falls back to grid — the tree IS the
/// folder surface, so `!folderMode && viewMode == "tree"` is unreachable.
pub(crate) fn toggle_folder_mode() {
{
let mut tb = TOOLBAR.lock().unwrap();
tb.folder_mode = !tb.folder_mode;
if !tb.folder_mode && tb.view_mode == "tree" {
tb.view_mode = "grid".into();
}
if !tb.folder_mode {
tb.current_folder_id = None;
}
}
publish_document();
}
/// Collapse / expand the FOLDERS section.
///
/// The reference deliberately does NOT rebuild here (`main.rs:5313-5322`) —
/// the flag is a pure view property there. In this port the flag rides
/// `managerJson`, so it must be published; the rebuild that comes with it is
/// from the cache and hits neither the network nor the DB.
pub(crate) fn toggle_folders_collapsed() {
{
let mut tb = TOOLBAR.lock().unwrap();
tb.folders_collapsed = !tb.folders_collapsed;
}
publish_document();
}
/// Enter one folder without changing the user's grid/list presentation.
/// Invalid/stale ids are ignored rather than publishing an inescapable empty
/// drill-down after a concurrent folder deletion.
pub(crate) fn open_folder(folder_id: &str) {
let folder_id = folder_id.trim();
if folder_id.is_empty() {
return;
}
let exists = CACHE
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_ref()
.is_some_and(|data| data.folders.iter().any(|folder| folder.id == folder_id));
if !exists {
return;
}
{
let mut toolbar = TOOLBAR.lock().unwrap_or_else(|error| error.into_inner());
toolbar.folder_mode = true;
if toolbar.view_mode == "tree" {
toolbar.view_mode = "grid".into();
}
toolbar.current_folder_id = Some(folder_id.to_string());
}
publish_document();
}
/// Return from a grid/list folder drill-down to the root folders + playlists.
pub(crate) fn close_folder() {
TOOLBAR
.lock()
.unwrap_or_else(|error| error.into_inner())
.current_folder_id = None;
publish_document();
}
pub(crate) fn toggle_tree_folder(folder_id: &str) {
{
let mut expanded = EXPANDED.lock().unwrap();
if !expanded.remove(folder_id) {
expanded.insert(folder_id.to_string());
}
}
publish_document();
}
// ---------------------------------------------------------------------------
// Cache access for `playlist_manager_ops`
// ---------------------------------------------------------------------------
/// Run `f` against the cache when it is WARM; `false` when it is cold.
///
/// The mutations use the return value to decide whether to republish — never
/// whether to write to the DB (§5.3).
pub(crate) fn patch_cache(f: impl FnOnce(&mut PmData)) -> bool {
let mut guard = CACHE.lock().unwrap();
match guard.as_mut() {
Some(data) => {
f(data);
true
}
None => false,
}
}
/// The ids of the currently visible QOBUZ rows, in the published order — the
/// vector `reorder_step` swaps in and renumbers.
///
/// Locals drop out here exactly as they do in the reference (which parses the
/// model's ids as `u64`): custom positions live in `playlist_settings`, whose
/// primary key is a Qobuz id, so a local has no slot to occupy (D29).
pub(crate) fn visible_qobuz_ids() -> Vec<u64> {
let Some(data) = CACHE.lock().unwrap().clone() else {
return Vec::new();
};
let tb = TOOLBAR.lock().unwrap().clone();
let query = tb.search.trim().to_lowercase();
let offline = crate::offline_fwd::engine().status().is_offline();
let current_folder = (tb.folder_mode && tb.view_mode != "tree")
.then_some(tb.current_folder_id.as_deref())
.flatten()
.filter(|id| data.folders.iter().any(|folder| folder.id == *id));
let rows = if let Some(folder_id) = current_folder {
rows::visible_folder_playlist_rows(
&data,
folder_id,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
offline,
)
} else {
rows::visible_playlist_rows(
&data,
&query,
&tb.filter,
&tb.sort,
tb.sort_asc,
tb.folder_mode,
&tb.view_mode,
offline,
)
};
rows.iter()
.filter_map(|r| r.id.parse::<u64>().ok())
.collect()
}
// ---------------------------------------------------------------------------
// The load
// ---------------------------------------------------------------------------
/// Fetch playlists (Qobuz) + folders + settings + stats + local counts + local
/// playlists, and merge them into the Send `PmData`.
///
/// **ONE `spawn_blocking`**, returning every DB read as a tuple; a join error
/// degrades the whole tuple to empties rather than panicking. Do not split it
/// into six hops — the DB is opened per call, and the point of the single hop
/// is to open it the minimum number of times per load.
///
/// There is no error state anywhere in this path: the fetch swallows its
/// failure into an empty vector and the join swallows its own, so a network
/// outage renders as "No playlists found." That is the reference (§5.13).
async fn load(runtime: &Runtime) -> PmData {
let offline = crate::offline_fwd::engine().status().is_offline();
let remote: Vec<qbz_models::Playlist> = if offline {
Vec::new()
} else {
runtime
.core()
.get_user_playlists()
.await
.unwrap_or_else(|e| {
log::warn!("[qbz-qt] playlist-manager playlists load failed: {e}");
Vec::new()
})
};
if !(offline && remote.is_empty()) {
// A failed online fetch also lands here as an empty list; recording it
// would start the two-generation retirement clock on every playlist,
// so only a NON-empty list (or a genuinely deleted-everything account,
// which still lists a session user) is recorded.
if let Some(entries) = crate::playlist_snapshot_qt::authoritative_entries(&remote) {
if !entries.is_empty() {
crate::playlist_snapshot_qt::record_authoritative_detached(entries);
}
}
}
let (folders, settings, play_counts, local_counts, locals, fav_ids) =
tokio::task::spawn_blocking(|| {
// §5.6: locals get covers. Ask the sidebar cache FIRST — the work
// is already done there — and only fall back to the repo for the
// ones it cannot answer, because `resolve_cover_urls_blocking`
// opens the DB per call.
let locals: Vec<PmLocalPlaylist> = crate::local_playlist_qt::list_blocking()
.into_iter()
.map(|p| {
// The editor/header writes the Qt-owned override store.
// Read it BEFORE the sidebar cache so concurrent surface
// refreshes cannot republish the old mosaic here.
let custom = crate::cover_artwork_qt::playlist_cover(&p.id)
.filter(|path| std::path::Path::new(path).is_file());
let cover_urls = custom.map(|path| vec![path]).unwrap_or_else(|| {
crate::sidebar_qt::local_covers(&p.id).unwrap_or_else(|| {
crate::local_playlist_qt::resolve_cover_urls_blocking(&p.id, 4)
})
});
PmLocalPlaylist {
cover_urls,
id: p.id,
name: p.name,
offline_only: p.offline_only,
track_count: p.track_count,
is_favorite: p.favorite,
is_hidden: p.hidden,
folder_id: p.folder_id,
}
})
.collect();
(
folders_qt::load_folders_full(),
folders_qt::playlist_settings_map(),
folders_qt::playlist_play_counts(),
folders_qt::playlist_local_counts(),
locals,
crate::library_db_qt::favorite_playlist_ids(),
)
})
.await
.unwrap_or_else(|_| {
log::warn!("[qbz-qt] playlist-manager DB read join failed");
Default::default()
});
let folder_ids: HashSet<&String> = folders.iter().map(|f| &f.id).collect();
let mut playlists: Vec<PmPlaylist> = remote
.iter()
.map(|p| {
let s = settings.get(&p.id).cloned().unwrap_or_default();
PmPlaylist {
id: p.id,
name: p.name.clone(),
description: p.description.clone(),
tracks_count: p.tracks_count,
duration: p.duration,
local_count: local_counts.get(&p.id).copied().unwrap_or(0),
play_count: play_counts.get(&p.id).copied().unwrap_or(0),
is_favorite: s.is_favorite,
is_hidden: s.hidden,
// A folder that no longer exists falls back to root — the same
// guard the sidebar applies (§5.18).
folder_id: s.folder_id.filter(|fid| folder_ids.contains(fid)),
// No settings row = PlaylistSettingsLite::default(): visible,
// unfavourited, root, position 0 — so an unpositioned set is
// one tie block that keeps API order under a stable sort.
position: s.position,
cover_urls: cover_urls(p),
}
})
.collect();
// INTERNAL favourites top-up (§5.18): a hearted playlist that is neither
// owned nor subscribed never comes back from `get_user_playlists`, so
// without this it is invisible here while Favorites > Playlists shows it.
// `is_favorite: true` is the ONLY hardcoded field; everything else still
// comes from the settings + count maps. Online only.
if !offline {
let known: HashSet<u64> = playlists.iter().map(|p| p.id).collect();
for fid in fav_ids {
if known.contains(&fid) {
continue;
}
if let Ok(p) = runtime.core().get_playlist(fid).await {
let s = settings.get(&fid).cloned().unwrap_or_default();
playlists.push(PmPlaylist {
id: fid,
name: p.name.clone(),
description: p.description.clone(),
tracks_count: p.tracks_count,
duration: p.duration,
local_count: local_counts.get(&fid).copied().unwrap_or(0),
play_count: play_counts.get(&fid).copied().unwrap_or(0),
is_favorite: true,
is_hidden: s.hidden,
folder_id: s.folder_id.filter(|f| folder_ids.contains(f)),
position: s.position,
cover_urls: cover_urls(&p),
});
}
}
}
// OFFLINE synthesis: the Qobuz fetch was skipped, so the reachable Qobuz
// playlists are the MIXED ones — those with >= 1 local sidecar row. Named
// from the sidebar's session cache (loaded while online) else the
// "Playlist (N local)" fallback. The persisted snapshot subsystem that
// would supply better names offline is absent from this port (D30), which
// is why there is no third source here.
if offline {
let known: HashSet<u64> = playlists.iter().map(|p| p.id).collect();
let mut ids: Vec<u64> = local_counts
.iter()
.filter(|&(_, &count)| count > 0)
.map(|(&id, _)| id)
.filter(|id| !known.contains(id))
.collect();
// A HashMap iterates in an arbitrary order; sort so the synthesised
// block is stable across rebuilds (it is a tie block under `recent`).
ids.sort_unstable();
for id in ids {
let count = local_counts.get(&id).copied().unwrap_or(0);
let s = settings.get(&id).cloned().unwrap_or_default();
playlists.push(PmPlaylist {
id,
name: crate::sidebar_qt::playlist_name(id).unwrap_or_else(|| {
qbz_i18n::t_args("Playlist ({} local)", &[&count.to_string()])
}),
description: None,
tracks_count: 0,
duration: 0,
local_count: count,
play_count: play_counts.get(&id).copied().unwrap_or(0),
is_favorite: s.is_favorite,
is_hidden: s.hidden,
folder_id: s.folder_id.filter(|fid| folder_ids.contains(fid)),
position: s.position,
cover_urls: Vec::new(),
});
}
}
// Locals get the SAME dangling-folder guard as the Qobuz rows, so
// `build_tree`'s "only unfiled locals go in the root run" can be a plain
// `folder_id.is_none()` test.
let mut locals = locals;
for p in &mut locals {
if p.folder_id
.as_ref()
.is_some_and(|f| !folder_ids.contains(f))
{
p.folder_id = None;
}
}
log::info!(
"[qbz-qt] playlist-manager loaded: {} playlists, {} folders, {} local",
playlists.len(),
folders.len(),
locals.len()
);
PmData {
playlists,
folders,
locals,
}
}
/// Up to four de-duplicated cover urls.
///
/// `images300` → `images150` → `images`, FIRST NON-EMPTY LIST WINS, then skip
/// empties, skip duplicates, cap at 4. The reference's doc-comment claims
/// "images150 > images300" and is wrong about its own code; this matches the
/// code, and `sidebar_qt.rs:186-188`.
fn cover_urls(p: &qbz_models::Playlist) -> Vec<String> {
// A user override is one full-bleed tile and must win in the manager just
// as it already does in PlaylistView, Library and the sidebar.
if let Some(path) = crate::cover_artwork_qt::playlist_cover(&p.id.to_string()) {
if std::path::Path::new(&path).is_file() {
return vec![path];
}
}
let source = [&p.images300, &p.images150, &p.images]
.into_iter()
.flatten()
.find(|v| !v.is_empty());
let mut out: Vec<String> = Vec::new();
if let Some(list) = source {
for url in list {
if !url.is_empty() && !out.contains(url) {
out.push(url.clone());
}
if out.len() == 4 {
break;
}
}
}
out
}
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// The toolbar statics are process-global; these tests drive them through
/// the public setters and re-derive expectations from the state they just
/// set, so ordering between them cannot matter. The bridge publish hop is
/// a no-op off the Qt thread (QT_THREAD unset).
static LOCK: Mutex<()> = Mutex::new(());
fn reset_toolbar() {
*TOOLBAR.lock().unwrap() = Toolbar::default();
}
#[test]
fn set_sort_flips_direction_on_reselect_and_resets_it_on_a_new_option() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_toolbar();
assert_eq!(TOOLBAR.lock().unwrap().sort, "name");
assert!(TOOLBAR.lock().unwrap().sort_asc);
set_sort("name");
assert!(!TOOLBAR.lock().unwrap().sort_asc, "re-select flips");
set_sort("name");
assert!(TOOLBAR.lock().unwrap().sort_asc, "and flips back");
set_sort("name");
set_sort("tracks");
let tb = TOOLBAR.lock().unwrap().clone();
assert_eq!(tb.sort, "tracks");
assert!(tb.sort_asc, "a NEW option resets to its natural direction");
// Unknown values fall back to "name" rather than poisoning the state.
set_sort("nonsense");
assert_eq!(TOOLBAR.lock().unwrap().sort, "name");
reset_toolbar();
}
#[test]
fn leaving_folder_mode_from_the_tree_falls_back_to_grid() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_toolbar();
set_view_mode("tree");
toggle_folder_mode();
let tb = TOOLBAR.lock().unwrap().clone();
assert!(!tb.folder_mode);
assert_eq!(tb.view_mode, "grid");
// Re-entering folder mode does NOT restore the tree.
toggle_folder_mode();
assert_eq!(TOOLBAR.lock().unwrap().view_mode, "grid");
reset_toolbar();
}
#[test]
fn setters_reject_unknown_values() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_toolbar();
set_filter("hidden");
assert_eq!(TOOLBAR.lock().unwrap().filter, "hidden");
set_filter("bogus");
assert_eq!(TOOLBAR.lock().unwrap().filter, "all");
set_view_mode("bogus");
assert_eq!(TOOLBAR.lock().unwrap().view_mode, "grid");
reset_toolbar();
}
#[test]
fn can_reorder_needs_custom_ascending_and_an_empty_query() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_toolbar();
let can_reorder = || -> bool {
let (manager, _) = build_document();
let v: serde_json::Value = serde_json::from_str(&manager).unwrap();
v["canReorder"].as_bool().unwrap()
};
assert!(!can_reorder(), "default sort is name");
set_sort("custom");
assert!(can_reorder());
// D9: descending custom would renumber a REVERSED order.
set_sort("custom");
assert!(!can_reorder(), "custom + descending must not offer arrows");
set_sort("custom");
assert!(can_reorder());
TOOLBAR.lock().unwrap().search = " jazz ".into();
assert!(!can_reorder(), "an active query must not offer arrows");
TOOLBAR.lock().unwrap().search = " ".into();
assert!(can_reorder(), "a whitespace-only query is no query");
reset_toolbar();
}
#[test]
fn the_cold_document_is_parseable_and_folder_count_tracks_the_folder_array() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_toolbar();
*CACHE.lock().unwrap() = None;
*FOLDERS_CACHE.lock().unwrap() = vec![FolderRow {
id: "f1".into(),
name: "Jazz".into(),
count: 4,
..Default::default()
}];
let (manager, folders) = build_document();
let m: serde_json::Value = serde_json::from_str(&manager).unwrap();
let f: serde_json::Value = serde_json::from_str(&folders).unwrap();
assert_eq!(f.as_array().unwrap().len(), 1);
assert_eq!(
m["folderCount"].as_i64().unwrap(),
1,
"folderCount must equal foldersJson.length (D5)"
);
assert_eq!(m["playlistCount"].as_i64().unwrap(), 0);
assert!(m["playlists"].as_array().unwrap().is_empty());
assert!(m["tree"].as_array().unwrap().is_empty());
// The defaults QML reads with `!== false`.
assert_eq!(m["sortAsc"], serde_json::json!(true));
assert_eq!(m["folderMode"], serde_json::json!(true));
assert_eq!(m["viewMode"], serde_json::json!("grid"));
*FOLDERS_CACHE.lock().unwrap() = Vec::new();
reset_toolbar();
}
#[test]
fn the_tree_is_published_only_in_folder_mode_tree_view() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_toolbar();
*CACHE.lock().unwrap() = Some(PmData {
playlists: vec![PmPlaylist {
id: 1,
name: "Alpha".into(),
..Default::default()
}],
folders: vec![],
locals: vec![],
});
let tree_len = || -> usize {
let (manager, _) = build_document();
let v: serde_json::Value = serde_json::from_str(&manager).unwrap();
v["tree"].as_array().unwrap().len()
};
assert_eq!(tree_len(), 0, "grid mode publishes no tree");
set_view_mode("tree");
assert_eq!(tree_len(), 1);
toggle_folder_mode();
assert_eq!(tree_len(), 0, "leaving folder mode empties the tree");
*CACHE.lock().unwrap() = None;
reset_toolbar();
}
}