feat(qt): the Albums funnel survives navigation and restarts

The filter worked but forgot itself twice over: leaving Local Library
reset it, and so did closing the app. The first one is not a bug in the
view, it is what this port does — navigating away DESTROYS the view
(project_qt_nav_eager_tabs), so a `property var filter: ({})` cannot
outlive a trip to Discover.

Both follow from one move: the funnel lives on the BRIDGE, mirrored into
the shared ui_prefs.json. The bridge outlives the view, and the file
outlives the process, so one property buys both kinds of persistence.

`setFilter()` is the only writer, so a mutator cannot change the funnel
and forget to persist it, and the view re-seeds from a Connections on
the qproperty rather than only at construction — a republish has to
reach a view that is already open.

The saved funnel is pruned where the source GATES are published, not
somewhere else, so the two can never disagree: a restored `jellyfin`
tick with no Jellyfin configured would match zero rows while the chip
that clears it is hidden by the same gate — an empty grid with a badge
counting a filter the user cannot see. Only the media-server keys are
pruned; local/offline/plex are always meaningful words.

Verified: ticked Jellyfin, went to Discover and back (badge and grid
held), restarted the process (held), pressed Clear (ui_prefs went to
{}). The prune is reasoned, not exercised — both servers are configured
on this box, so no gate is off to test it against.

cc
This commit is contained in:
vicrodh committed 2026-08-20 16:38:28 -06:00
1 parent 1a2ebcf146
commit 021b5609e1
4 files changed
+113 -3

No files matched your search

+32 -3
View File
@@ -102,19 +102,48 @@ Rectangle {
// Albums quality/format/source filter (LibAlbumFilterState).
property bool filterOpen: false
property var filter: ({})
// SEEDED FROM THE BRIDGE, not defaulted to `{}`. This view is DESTROYED on
// every navigation away (see the bridge's `albums_filter` doc), so a
// view-local default meant the funnel reset the moment the user visited
// Discover — and never survived a restart at all. `QbzLocal.albumsFilter`
// outlives the view and mirrors `ui_prefs.json`.
property var filter: root.parseFilter(QbzLocal.albumsFilter)
function parseFilter(json) {
if (!json || json === "") return ({})
try { return JSON.parse(json) || ({}) } catch (e) { return ({}) }
}
// A LATER republish still wins: the gates and the saved funnel are
// published together when a media server is connected or removed, and the
// pruning that happens there (a tick whose chip is now hidden) has to
// reach a view that is already open. Guarded on a real difference so
// writing the property back does not bounce.
Connections {
target: QbzLocal
function onAlbumsFilterChanged() {
var next = root.parseFilter(QbzLocal.albumsFilter)
if (JSON.stringify(next) !== JSON.stringify(root.filter))
root.filter = next
}
}
readonly property int filterCount: {
var n = 0
for (var k in filter) if (filter[k]) n++
return n
}
/// The ONE writer. Both mutators funnel through it, so neither can change
/// the funnel and forget to persist it.
function setFilter(f) {
filter = f
QbzLocal.setAlbumsFilterJson(
Object.keys(f).length === 0 ? "" : JSON.stringify(f))
}
function toggleFilter(key) {
var f = Object.assign({}, filter)
f[key] = !f[key]
if (!f[key]) delete f[key]
filter = f
setFilter(f)
}
function clearFilter() { filter = ({}) }
function clearFilter() { setFilter({}) }
/// The funnel's state as the applied-filters tooltip wants it: three groups
/// in the popup's own order, each holding the labels of the keys that are
+24
View File
@@ -135,6 +135,17 @@ pub mod qbz_local {
/// so the chip only exists when its server does.
#[qproperty(bool, media_has_jellyfin)]
#[qproperty(bool, media_has_subsonic)]
/// The Albums funnel, as a JSON object of the ticked keys
/// (`{"hires":true,"jellyfin":true}`). Empty string = no filter.
///
/// It lives on the BRIDGE, not on `LocalLibraryView`, for the reason
/// the owner reported: navigating away DESTROYS the view (see
/// `project_qt_nav_eager_tabs` — Qt, Slint and Tauri all do), so a
/// view-local `property var filter` cannot survive a round trip to
/// Discover, let alone a restart. The bridge outlives the view and
/// mirrors `ui_prefs.json`, so one property buys BOTH kinds of
/// persistence the owner asked for.
#[qproperty(QString, albums_filter)]
#[qproperty(bool, media_syncing)]
#[qproperty(QString, media_sync_progress)]
/// Tracks written by the last sync; -1 = never synced this session.
@@ -323,6 +334,9 @@ pub mod qbz_local {
/// user fixing a typo and a user thinking the feature is broken.
#[qinvokable]
fn media_test(self: Pin<&mut QbzLocal>, server: QString, url: QString);
/// Persist the Albums funnel. See the `albums_filter` qproperty.
#[qinvokable]
fn set_albums_filter_json(self: Pin<&mut QbzLocal>, json: QString);
/// Authenticate and persist. Runs a first sweep on success.
#[qinvokable]
fn media_connect(
@@ -449,6 +463,7 @@ pub struct QbzLocalRust {
plex_syncing: bool,
media_has_jellyfin: bool,
media_has_subsonic: bool,
albums_filter: QString,
media_syncing: bool,
media_sync_progress: QString,
plex_last_sync_tracks: i32,
@@ -496,6 +511,7 @@ impl Default for QbzLocalRust {
plex_syncing: false,
media_has_jellyfin: false,
media_has_subsonic: false,
albums_filter: QString::default(),
media_syncing: false,
media_sync_progress: QString::default(),
plex_last_sync_tracks: -1,
@@ -738,6 +754,14 @@ impl qbz_local::QbzLocal {
// ── Media servers (Jellyfin / Subsonic) ─────────────────────────────
pub fn set_albums_filter_json(mut self: Pin<&mut Self>, json: QString) {
let text = json.to_string();
// The qproperty is what the view re-seeds from on its next mount, so
// it is written on EVERY toggle, not only on close.
self.as_mut().set_albums_filter(QString::from(text.as_str()));
crate::local_bridge_ops::save_albums_filter(&text);
}
pub fn media_test(self: Pin<&mut Self>, server: QString, url: QString) {
let (Some(kind), url) = (media_kind(&server), url.to_string()) else {
return;
+48
View File
@@ -87,12 +87,60 @@ pub(crate) fn publish_media_gates() {
let words = crate::media_servers_qt::configured_words();
let jf = words.contains(&"jellyfin");
let sub = words.contains(&"subsonic");
// The saved funnel is seeded HERE, with the gates, so the two can never
// disagree: a filter restored from disk must not tick a source whose chip
// the popup is about to hide. That combination is invisible — the grid
// comes back empty and the funnel badge counts a filter the user cannot
// see, let alone clear.
let filter = pruned_albums_filter(jf, sub);
ui(move |mut b| {
b.as_mut().set_media_has_jellyfin(jf);
b.as_mut().set_media_has_subsonic(sub);
b.as_mut().set_albums_filter(cxx_qt_lib::QString::from(filter.as_str()));
});
}
/// Key the Albums funnel is stored under in the shared `ui_prefs.json`.
const ALBUMS_FILTER_KEY: &str = "local_albums_filter";
/// Persist the funnel. Called from the bridge on every toggle.
pub(crate) fn save_albums_filter(json: &str) {
let value = if json.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(json).unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
};
crate::settings_qt::save_pref(ALBUMS_FILTER_KEY, value);
}
/// The stored funnel with any source key its server cannot serve removed.
///
/// Only the MEDIA-SERVER keys are pruned. `local` / `offline` / `plex` are
/// always meaningful words (an empty result there is an honest answer about
/// the library), but a `jellyfin` tick with no Jellyfin configured can only
/// ever match zero rows, and the chip that would let the user untick it is
/// hidden by the same gate.
fn pruned_albums_filter(has_jellyfin: bool, has_subsonic: bool) -> String {
let Some(serde_json::Value::Object(mut map)) =
crate::settings_qt::read_pref(ALBUMS_FILTER_KEY)
else {
return String::new();
};
if !has_jellyfin {
map.remove("jellyfin");
}
if !has_subsonic {
map.remove("subsonic");
}
// Only the TRUE keys travel: the view stores a key by deleting it when it
// goes false, but a hand-edited prefs file need not honour that.
map.retain(|_, v| v.as_bool() == Some(true));
if map.is_empty() {
return String::new();
}
serde_json::to_string(&map).unwrap_or_default()
}
pub(crate) fn reload_browse() {
publish_media_gates();
// The cortinilla's instant-paint cache embeds LOCAL rows and their artwork
+9
View File
@@ -1180,6 +1180,15 @@ pub(crate) fn read_pref_f32(key: &str) -> Option<f32> {
doc.get(key)?.as_f64().map(|v| v as f32)
}
/// One key of ui_prefs.json, as raw JSON. `None` when the file, the key or
/// the parse is missing — every caller treats that as "not set".
pub(crate) fn read_pref(key: &str) -> Option<serde_json::Value> {
let path = prefs_path()?;
let text = std::fs::read_to_string(path).ok()?;
let doc: serde_json::Value = serde_json::from_str(&text).ok()?;
doc.get(key).cloned()
}
/// Additive single-key patch of ui_prefs.json — THE writer every other pref
/// setter in this file funnels through, so they all inherit `update_prefs`'
/// atomic rename and its refusal to rebuild an unparsable document.