fix(settings): serialize ListenBrainz token validation

cx
This commit is contained in:
vicrodh committed 2026-09-02 10:28:41 -06:00
1 parent 87f32bc5e5
commit c8ad6a5215
2 files changed
+136 -36

No files matched your search

@@ -19,6 +19,9 @@ Column {
id: root id: root
property var doc: ({}) property var doc: ({})
// Password drafts never enter settingsJson. Keep the live token inside
// this component until the user explicitly submits it.
property string listenbrainzTokenDraft: ""
/// The view-level SettingsConfirmHost (SettingsView.qml), for the /// The view-level SettingsConfirmHost (SettingsView.qml), for the
/// destructive "Clear listening history" row. Null in previews. /// destructive "Clear listening history" row. Null in previews.
property var confirmHost: null property var confirmHost: null
@@ -27,19 +30,10 @@ Column {
spacing: 4 spacing: 4
// The Slint mirrors the in-progress token in function submitListenBrainzToken() {
// ScrobbleState.listenbrainz-token-input so its "Connect ListenBrainz" if (root.doc.listenbrainzBusy === true)
// button can submit it. QbzLineEdit only publishes the COMMITTED value return
// (it commits on Enter / focus loss, and a SettingsButton click does not QbzBridge.settingsString("listenbrainz-token", root.listenbrainzTokenDraft)
// steal focus from the TextInput), so read the live value off its inner
// input — the one child that has an echoMode.
function lbTokenText() {
for (var i = 0; i < lbTokenField.children.length; ++i) {
var c = lbTokenField.children[i]
if (c && c.echoMode !== undefined)
return c.text
}
return ""
} }
// ======================= RECOMMENDATIONS ============================= // ======================= RECOMMENDATIONS =============================
@@ -242,7 +236,9 @@ Column {
GroupHeader { text: QbzSession.tr("LISTENBRAINZ", QbzSession.trRev) } GroupHeader { text: QbzSession.tr("LISTENBRAINZ", QbzSession.trRev) }
SettingRow { SettingRow {
label: QbzSession.tr("Scrobble to ListenBrainz", QbzSession.trRev) label: QbzSession.tr("Scrobble to ListenBrainz", QbzSession.trRev)
description: root.doc.listenbrainzAuthed === true description: root.doc.listenbrainzBusy === true
? QbzSession.tr("Validating...", QbzSession.trRev)
: root.doc.listenbrainzAuthed === true
? QbzSession.tr("Signed in as {}.", QbzSession.trRev).replace("{}", root.doc.listenbrainzUsername || "") ? QbzSession.tr("Signed in as {}.", QbzSession.trRev).replace("{}", root.doc.listenbrainzUsername || "")
: QbzSession.tr("Paste your ListenBrainz user token to enable scrobbling.", QbzSession.trRev) : QbzSession.tr("Paste your ListenBrainz user token to enable scrobbling.", QbzSession.trRev)
QbzToggle { QbzToggle {
@@ -259,11 +255,21 @@ Column {
isPassword: true isPassword: true
enabled: root.doc.listenbrainzBusy !== true enabled: root.doc.listenbrainzBusy !== true
placeholder: QbzSession.tr("ListenBrainz token", QbzSession.trRev) placeholder: QbzSession.tr("ListenBrainz token", QbzSession.trRev)
// Enter (and focus loss) validates, like the Slint `accepted`. // Typing only updates the local draft. Enter submits once;
onCommitted: function (v) { QbzBridge.settingsString("listenbrainz-token", v) } // focus loss merely settles the draft, so a button click can
// never race an implicit blur validation.
onEdited: function (v) { root.listenbrainzTokenDraft = v }
onCommitted: function (v) { root.listenbrainzTokenDraft = v }
onAccepted: function (v) {
root.listenbrainzTokenDraft = v
root.submitListenBrainzToken()
}
// The row hides once the sign-in lands; drop the typed token // The row hides once the sign-in lands; drop the typed token
// with it (Slint resets listenbrainz-token-input on success). // with it (Slint resets listenbrainz-token-input on success).
onVisibleChanged: if (!visible) text = "" onVisibleChanged: if (!visible) {
text = ""
root.listenbrainzTokenDraft = ""
}
} }
} }
SettingRow { SettingRow {
@@ -275,7 +281,8 @@ Column {
? QbzSession.tr("Validating...", QbzSession.trRev) ? QbzSession.tr("Validating...", QbzSession.trRev)
: QbzSession.tr("Connect ListenBrainz", QbzSession.trRev) : QbzSession.tr("Connect ListenBrainz", QbzSession.trRev)
enabled: root.doc.listenbrainzBusy !== true enabled: root.doc.listenbrainzBusy !== true
onClicked: QbzBridge.settingsString("listenbrainz-token", root.lbTokenText()) && root.listenbrainzTokenDraft.trim().length > 0
onClicked: root.submitListenBrainzToken()
} }
} }
SettingRow { SettingRow {
+111 -18
View File
@@ -199,6 +199,44 @@ fn set_status(text: String, kind: i32) {
g.status_kind = kind; g.status_kind = kind;
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ListenBrainzValidationStart {
Started,
Busy,
MissingToken,
}
/// Acquire the ListenBrainz validation slot and publish its first honest UI
/// state atomically. The busy check deliberately precedes the empty-token
/// check: QML used to submit the real token on blur and an accidental empty
/// value on click, so the empty request overwrote "Validating..." while the
/// real request was still in flight.
fn begin_listenbrainz_validation(
ui: &mut IntegrationUi,
has_token: bool,
validating_status: String,
missing_status: String,
) -> ListenBrainzValidationStart {
if ui.listenbrainz_busy {
return ListenBrainzValidationStart::Busy;
}
if !has_token {
ui.status_text = missing_status;
ui.status_kind = 3;
return ListenBrainzValidationStart::MissingToken;
}
ui.listenbrainz_busy = true;
ui.status_text = validating_status;
ui.status_kind = 1;
ListenBrainzValidationStart::Started
}
fn finish_listenbrainz_validation(ui: &mut IntegrationUi, status: String, kind: i32) {
ui.listenbrainz_busy = false;
ui.status_text = status;
ui.status_kind = kind;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Snapshot fields (folded into SettingsDoc by settings_qt::publish_snapshot) // Snapshot fields (folded into SettingsDoc by settings_qt::publish_snapshot)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -557,24 +595,27 @@ pub fn discord_clear() {
/// sign-in), and force-enable on the first connect. /// sign-in), and force-enable on the first connect.
pub async fn listenbrainz_set_token(token: &str) { pub async fn listenbrainz_set_token(token: &str) {
let token = token.trim().to_string(); let token = token.trim().to_string();
if token.is_empty() { let start = {
set_status(qbz_i18n::t("Paste your ListenBrainz user token first"), 3);
crate::settings_qt::publish_snapshot().await;
return;
}
// Re-entrancy guard: the field commits on blur AND the button submits, so
// both can land back-to-back; one validation at a time.
{
let mut g = ui_state().lock().unwrap(); let mut g = ui_state().lock().unwrap();
if g.listenbrainz_busy { begin_listenbrainz_validation(
&mut g,
!token.is_empty(),
qbz_i18n::t("Validating..."),
qbz_i18n::t("Paste your ListenBrainz user token first"),
)
};
match start {
ListenBrainzValidationStart::Busy => return,
ListenBrainzValidationStart::MissingToken => {
crate::settings_qt::publish_snapshot().await;
return; return;
} }
g.listenbrainz_busy = true; ListenBrainzValidationStart::Started => {}
} }
crate::settings_qt::publish_snapshot().await; crate::settings_qt::publish_snapshot().await;
let client = ListenBrainzClient::new(); let client = ListenBrainzClient::new();
match client.set_token(&token).await { let (status, kind) = match client.set_token(&token).await {
Ok(info) => { Ok(info) => {
if let Err(e) = scrobble().set_listenbrainz_token(&token, &info.user_name) { if let Err(e) = scrobble().set_listenbrainz_token(&token, &info.user_name) {
log::error!("[qbz-qt] persist listenbrainz token failed: {e}"); log::error!("[qbz-qt] persist listenbrainz token failed: {e}");
@@ -593,15 +634,13 @@ pub async fn listenbrainz_set_token(token: &str) {
}) })
.await; .await;
} }
set_status( let status = qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]);
qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]),
2,
);
request_flush(); request_flush();
(status, 2)
} }
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3), Err(e) => (qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
} };
ui_state().lock().unwrap().listenbrainz_busy = false; finish_listenbrainz_validation(&mut ui_state().lock().unwrap(), status, kind);
crate::settings_qt::publish_snapshot().await; crate::settings_qt::publish_snapshot().await;
} }
@@ -1453,6 +1492,60 @@ mod tests {
} }
} }
#[test]
fn listenbrainz_validation_reports_progress_and_ignores_reentrant_empty_submit() {
let mut ui = IntegrationUi::default();
assert_eq!(
begin_listenbrainz_validation(
&mut ui,
true,
"Validating...".into(),
"Missing token".into(),
),
ListenBrainzValidationStart::Started
);
assert!(ui.listenbrainz_busy);
assert_eq!(ui.status_text, "Validating...");
assert_eq!(ui.status_kind, 1);
// Regression: blur submitted the real token, then the button's broken
// child lookup submitted "" and painted a false error over progress.
assert_eq!(
begin_listenbrainz_validation(
&mut ui,
false,
"Validating again...".into(),
"Missing token".into(),
),
ListenBrainzValidationStart::Busy
);
assert!(ui.listenbrainz_busy);
assert_eq!(ui.status_text, "Validating...");
assert_eq!(ui.status_kind, 1);
finish_listenbrainz_validation(&mut ui, "Connected as listener".into(), 2);
assert!(!ui.listenbrainz_busy);
assert_eq!(ui.status_text, "Connected as listener");
assert_eq!(ui.status_kind, 2);
}
#[test]
fn listenbrainz_missing_token_is_an_idle_error() {
let mut ui = IntegrationUi::default();
assert_eq!(
begin_listenbrainz_validation(
&mut ui,
false,
"Validating...".into(),
"Missing token".into(),
),
ListenBrainzValidationStart::MissingToken
);
assert!(!ui.listenbrainz_busy);
assert_eq!(ui.status_text, "Missing token");
assert_eq!(ui.status_kind, 3);
}
#[test] #[test]
fn manual_offline_respects_immediate_and_accumulated_modes() { fn manual_offline_respects_immediate_and_accumulated_modes() {
let manual = status(OfflineMode::InducedOffline, Connectivity::Up, false); let manual = status(OfflineMode::InducedOffline, Connectivity::Up, false);