From 19836547d9d5287e300af5642f9e9c5334ddddca Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:06:31 -0600 Subject: [PATCH] feat(qt): give Recommendations its cache window and Refresh now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gear was dead on that tab and the cache window was unreachable, so whatever hours value happened to be in the shared prefs was permanent. It reads 24h on this account; the reference offers 24/36/48/72 and defaults to 48. Only the WRITER was missing. `reco_cache_ttl_secs()` already read `reco_cache_ttl_hours` from the shared per-user discover prefs, and `refresh()` was already written — and carrying `#[allow(dead_code)]` because nothing called it. TTL_HOURS is the reference's table verbatim (discover_prefs.rs:100). That matters because the value lands in the file BOTH builds read: if the two disagree on the index mapping, a window chosen in one reads back as a different one in the other. The QML option order is the other half of the same contract, noted at both ends. The setter republishes the RESOLVED index rather than the one it was handed, so a failed write (no per-user store) snaps the select back to what is actually stored instead of showing a window nobody saved. The index is seeded at shell entry next to publish_settings(), the first point where the per-user directory is known — without it the select would open on the store's 48h default and misreport anyone who chose otherwise. The modal now has two modes on the same six blocks the reference gates (DiscoverConfigModal.slint:187/213/221/250/261/326): section tabs keep the list, banner, count and reset footer; Recommendations hides those and shows the explainer, the window select and Refresh now. Its empty-state line is hidden there too — DiscoveryTab has no "recommendations" arm, so an empty row list is by design and "No sections to configure yet" would be a permanent lie. All seven strings already existed translated in all eight catalogs. Two comments removed on the way: both claimed the external reco engine was "not ported" and justified the disabled gear with it. It has been ported for a long time (recommendations_qt.rs, 745 lines) — the fossil was the only thing still keeping the setting out of reach. cc --- .../qml/controls/DiscoverConfigModal.qml | 115 +++++++++++++++++- crates/qbz-qt/qml/views/HomeView.qml | 28 ++--- crates/qbz-qt/src/discover_config_qt.rs | 5 +- crates/qbz-qt/src/home_bridge.rs | 30 +++++ crates/qbz-qt/src/main.rs | 8 ++ crates/qbz-qt/src/recommendations_qt.rs | 46 ++++++- 6 files changed, 212 insertions(+), 20 deletions(-) diff --git a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml index 3a2a46aef..51d5ce21f 100644 --- a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml +++ b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml @@ -12,9 +12,20 @@ // with `anchors.fill: parent`; ADR-009's ">= 3000" is satisfied structurally // AND by the explicit z below. // -// POC-NOTE: the Slint modal's Recommendations arm (explainer + cache window -// + "Refresh now") drives ExternalRecoState, and the external reco engine is -// not ported — the toolbar gear is DISABLED on that tab instead (HomeView). +// The modal has TWO modes, and the tab decides which: +// * the three section tabs (home / editorPicks / forYou) get the reorder +// list, its perf banner, the enabled/total count and the reset footer; +// * Recommendations has no configurable sections, so all of that is hidden +// and it gets the explainer + cache-window select + "Refresh now" instead. +// 1:1 with the reference, which gates the same six blocks on the same +// condition (DiscoverConfigModal.slint:187/213/221/250/261/326). +// +// (The note that used to sit here said the external reco engine was "not +// ported" and the gear was disabled on that tab as a result. The engine has +// been ported for a long time — src/recommendations_qt.rs — so the note was a +// fossil describing a state of the world that had stopped being true, and the +// disabled gear it justified was the only thing keeping the window a user +// chose in one build from being changeable in this one.) import QtQuick import com.blitzfc.qbz @@ -145,7 +156,9 @@ Item { } // --- Perf-warning banner (the shared WarningBanner replica) ---- + // This and the next three blocks are section-config only. WarningBanner { + visible: root.tab !== "recommendations" width: parent.width variant: "warning" body: QbzSession.tr("Enabling more sections increases load time.", QbzSession.trRev) @@ -153,6 +166,7 @@ Item { // --- Count line ----------------------------------------------- Text { + visible: root.tab !== "recommendations" width: parent.width text: QbzSession.tr("{} of {} enabled", QbzSession.trRev) .replace("{}", root.mine ? (doc.enabled || 0) : 0) @@ -166,6 +180,7 @@ Item { // Explicit, content-derived height capped to what is left of the // panel; the Flickable scrolls past that. Item { + visible: root.tab !== "recommendations" width: parent.width height: Math.max(0, Math.min(listCol.height, root.height * 0.78 - 260)) clip: true @@ -243,8 +258,11 @@ Item { } // Empty state — a tab whose sections have not been fetched yet. + // NOT on Recommendations: `DiscoveryTab::from_key` has no arm for + // it (discover_prefs.rs:44), so its row list is empty BY DESIGN and + // "No sections to configure yet" would be a permanent lie there. Text { - visible: root.rows.length === 0 + visible: root.rows.length === 0 && root.tab !== "recommendations" width: parent.width height: 34 text: QbzSession.tr("No sections to configure yet.", QbzSession.trRev) @@ -253,8 +271,97 @@ Item { verticalAlignment: Text.AlignVCenter } + // --- Recommendations arm: explainer + cache window + refresh --- + // Recommendations has no sections to order, so the gear opens on + // THIS instead. Both controls write through QbzHome: + // `recoSetCacheTtlIndex` persists to the shared per-user discover + // prefs (the same file the Slint build reads, so a window chosen + // in either is the window both honour), and `recoRefreshNow` + // rebuilds every row past the results blob. + Text { + visible: root.tab === "recommendations" + width: parent.width + text: QbzSession.tr("QBZ uses your Last.fm and ListenBrainz account data when they're connected (if they aren't, we recommend connecting them to enrich this section). Here you set how often that data is fetched — it defaults to 48 hours, or you can request an update right now.", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + Item { + visible: root.tab === "recommendations" + width: parent.width + height: 34 + + Text { + id: ttlLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Cache window", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + QbzSelect { + anchors.left: ttlLabel.right + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + sm: true + menuWidth: 140 + // Option ORDER is the contract with TTL_HOURS in + // src/recommendations_qt.rs — the bridge publishes an + // index, not hours, so reordering these silently changes + // what every stored value means. + options: [ + QbzSession.tr("24 hours", QbzSession.trRev), + QbzSession.tr("36 hours", QbzSession.trRev), + QbzSession.tr("48 hours", QbzSession.trRev), + QbzSession.tr("72 hours", QbzSession.trRev), + ] + currentIndex: QbzHome.recoCacheTtlIndex + onSelected: function (i) { QbzHome.recoSetCacheTtlIndex(i) } + } + + // Pinned right, like the reference's stretch spacer. + Rectangle { + id: refreshBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: refreshRow.width + 24 + height: 34 + radius: theme.radiusSm + color: refreshArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + Row { + id: refreshRow + anchors.centerIn: parent + spacing: 8 + QbzIcon { + name: "refresh-cw" + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Refresh now", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + } + } + MouseArea { + id: refreshArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzHome.recoRefreshNow() + } + } + } + // --- Footer: Reset to defaults -------------------------------- Rectangle { + visible: root.tab !== "recommendations" width: resetRow.width + 24 height: 34 radius: theme.radiusSm diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index ff2f9af8f..eaacb2344 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -21,10 +21,9 @@ // section-configurator gear (DiscoverConfigModal) are WIRED: the genre // selection feeds get_discover_index and persists to genre_filter.json; the // configurator persists to discover_prefs.db and re-renders the tabs from -// the cached section data. The gear is DISABLED on the Recommendations tab -// — its Slint arm also carries the reco cache-window + "Refresh now" -// controls, which DiscoverConfigModal.qml does not have, so it would open -// empty. +// the cached section data. The gear is live on ALL FOUR tabs: on +// Recommendations the modal shows that tab's own arm instead of a section +// list (cache window + "Refresh now"), matching the reference. // - Editor's Picks / For You mount the same rails, ordered by each tab's // discover prefs (phase 13). Recommendations is the external-reco engine // (src/recommendations_qt.rs), LAZY-loaded the first time the tab becomes @@ -1363,18 +1362,20 @@ Rectangle { } // GearButton — per-tab show/hide + reorder of the Discover - // sections. Disabled on Recommendations: its Slint arm - // configures the external reco engine (not ported), so the - // modal would open empty. + // sections, and on Recommendations the cache window + + // "Refresh now" instead (that tab has no orderable sections). + // + // It used to be DISABLED on Recommendations, on the grounds + // that the external reco engine was not ported and the modal + // would open empty. The engine is ported, and the modal now + // carries that tab's own arm — so the gear is live on all four + // and `gearEnabled` is gone with the condition it existed for. Rectangle { id: gearBtn - readonly property bool gearEnabled: root.activeTab !== "recommendations" width: 32 height: 32 radius: 4 - opacity: gearBtn.gearEnabled ? 1.0 : 0.35 - color: (gearBtn.gearEnabled && gearArea.containsMouse) - ? theme.surfaceHover : "transparent" + color: gearArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "home-gear" width: 20 @@ -1385,9 +1386,8 @@ Rectangle { MouseArea { id: gearArea anchors.fill: parent - enabled: gearBtn.gearEnabled - hoverEnabled: gearBtn.gearEnabled - cursorShape: gearBtn.gearEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + hoverEnabled: true + cursorShape: Qt.PointingHandCursor onClicked: configModal.open(root.activeTab) } } diff --git a/crates/qbz-qt/src/discover_config_qt.rs b/crates/qbz-qt/src/discover_config_qt.rs index 02345cf49..a8533ef63 100644 --- a/crates/qbz-qt/src/discover_config_qt.rs +++ b/crates/qbz-qt/src/discover_config_qt.rs @@ -79,7 +79,10 @@ struct ConfigDoc { total: i32, } -fn store() -> Option { +/// `pub(crate)` because the Recommendations cache window is stored in the SAME +/// per-user prefs file as the section order, and `recommendations_qt` writes it +/// — one accessor beats two copies of the user-dir lookup drifting apart. +pub(crate) fn store() -> Option { crate::sidebar_qt::user_dir() .and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok()) } diff --git a/crates/qbz-qt/src/home_bridge.rs b/crates/qbz-qt/src/home_bridge.rs index 888fa5062..f1572d943 100644 --- a/crates/qbz-qt/src/home_bridge.rs +++ b/crates/qbz-qt/src/home_bridge.rs @@ -74,6 +74,12 @@ pub mod qbz_home { // connected is simply absent from the document. #[qproperty(QString, reco_sections_json)] #[qproperty(bool, reco_loading)] + /// The Recommendations results-cache window, as the configurator + /// select's INDEX (0=24h 1=36h 2=48h 3=72h) rather than hours: the + /// control is a fixed option list, and publishing the index is what + /// lets it open on the stored entry without QML owning the mapping. + /// Seeded at boot from the shared per-user discover prefs. + #[qproperty(i32, reco_cache_ttl_index)] // --- "View all" full-list pages (src/browse_qt.rs) ---------------- #[qproperty(QString, discover_browse_json)] #[qproperty(bool, discover_browse_loading)] @@ -116,6 +122,17 @@ pub mod qbz_home { /// session (idempotent; a re-entry only repaints from memory). #[qinvokable] fn load_recommendations(self: Pin<&mut QbzHome>); + /// Configurator > Cache window: 0=24h 1=36h 2=48h 3=72h. Persists to + /// the shared per-user discover prefs and republishes the RESOLVED + /// index, so a failed write snaps the select back rather than showing + /// a window nobody stored. + #[qinvokable] + fn reco_set_cache_ttl_index(self: Pin<&mut QbzHome>, index: i32); + /// Configurator > "Refresh now": rebuild every row, bypassing the + /// results blob. The engine still honours its own per-week + /// ListenBrainz cache, so this is not a way to hammer that service. + #[qinvokable] + fn reco_refresh_now(self: Pin<&mut QbzHome>); // --- DiscoverBrowse ("View all" on a generic album carousel) ------ #[qinvokable] @@ -301,6 +318,7 @@ pub struct QbzHomeRust { mix_json: QString, reco_sections_json: QString, reco_loading: bool, + reco_cache_ttl_index: i32, discover_browse_json: QString, discover_browse_loading: bool, discover_browse_loading_more: bool, @@ -334,6 +352,10 @@ impl Default for QbzHomeRust { spotlight_json: QString::from("{}"), mix_json: QString::from("{}"), reco_sections_json: QString::from("[]"), + // 48h == the prefs store's own default; the real value is pushed + // by `recommendations_qt::publish_cache_ttl_index()` at boot, once + // the per-user directory is known. + reco_cache_ttl_index: 2, reco_loading: false, // "{}" so the views' JSON.parse never throws on the first frame. discover_browse_json: QString::from("{}"), @@ -385,6 +407,14 @@ impl qbz_home::QbzHome { crate::recommendations_qt::ensure_loaded(); } + pub fn reco_set_cache_ttl_index(self: Pin<&mut Self>, index: i32) { + crate::recommendations_qt::set_cache_ttl_index(index); + } + + pub fn reco_refresh_now(self: Pin<&mut Self>) { + crate::recommendations_qt::refresh(); + } + // --- DiscoverBrowse --------------------------------------------------- pub fn open_discover_browse(self: Pin<&mut Self>, endpoint: QString, title: QString) { diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 75bbe42b7..74d1d72b8 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -653,6 +653,14 @@ fn on_session_entered() { // hole because `SettingsState` is a global seeded at startup. publish_settings(); + // Same shape, same reason as the block above: the Recommendations cache + // window is a fixed option list whose select has to open on the STORED + // entry. Published once here, at shell entry, because that is the first + // point where the per-user prefs directory is known — the bridge's own + // default is just the store's default (48 h) and would silently misreport + // a user who chose something else until they changed it again. + recommendations_qt::publish_cache_ttl_index(); + // Restore the persisted player volume so audio starts at the SAVED level // (1:1 with qbz/src/main.rs:220-227). Without it every launch started at // 100% — on an exclusive-mode DAC that is not a cosmetic default. diff --git a/crates/qbz-qt/src/recommendations_qt.rs b/crates/qbz-qt/src/recommendations_qt.rs index 8ffba262a..bf6fa0f6b 100644 --- a/crates/qbz-qt/src/recommendations_qt.rs +++ b/crates/qbz-qt/src/recommendations_qt.rs @@ -394,9 +394,53 @@ pub(crate) fn ensure_loaded() { start(false); } +/// The cache-window options the configurator offers, in HOURS. The index <-> +/// hours mapping is the contract with the QbzSelect option order in +/// `DiscoverConfigModal.qml`, and it is the reference's own table verbatim +/// (`crates/qbz/src/discover_prefs.rs:100`) — the value is written into the +/// SHARED per-user prefs, so the two builds have to agree on it or a window +/// chosen in one reads back as a different one in the other. +const TTL_HOURS: [i64; 4] = [24, 36, 48, 72]; + +/// Stored hours -> select index. An unrecognised value resolves to index 2 +/// (48 h), which is what the prefs store itself defaults to. +pub(crate) fn cache_ttl_index() -> i32 { + let hours = crate::home_qt::load_prefs().reco_cache_ttl_hours; + TTL_HOURS.iter().position(|&h| h == hours).unwrap_or(2) as i32 +} + +/// Configurator select -> persist -> republish the resolved index. +/// +/// Republishing the RESOLVED index rather than trusting the one that came in +/// is what keeps the control honest when the write fails (no per-user store +/// because nobody is logged in): the select snaps back to what is actually +/// stored instead of showing a window that was never saved. +pub(crate) fn set_cache_ttl_index(index: i32) { + let hours = *TTL_HOURS.get(index.clamp(0, 3) as usize).unwrap_or(&48); + let Some(store) = crate::discover_config_qt::store() else { + log::warn!("[qbz-qt] reco cache window: no per-user prefs store (not logged in?)"); + return; + }; + let mut prefs = store.load(); + prefs.reco_cache_ttl_hours = hours; + if let Err(e) = store.save(&prefs) { + log::warn!("[qbz-qt] reco cache window: save failed: {e}"); + return; + } + log::info!("[qbz-qt] reco cache window set to {hours}h"); + publish_cache_ttl_index(); +} + +/// Push the stored window onto the bridge. Called at boot (so the select opens +/// on the right entry the FIRST time, not after a change) and after every +/// write. +pub(crate) fn publish_cache_ttl_index() { + let index = cache_ttl_index(); + crate::home_bridge::ui(move |mut b| b.as_mut().set_reco_cache_ttl_index(index)); +} + /// "Refresh now": drop the latch and rebuild every row, bypassing the results /// blob (the engine still honours its per-week ListenBrainz cache). -#[allow(dead_code)] pub(crate) fn refresh() { LOADED.store(false, Ordering::SeqCst); start(true);