feat(qbz-qt): wire every integration to its real backend

The Integrations pane rendered rows that did nothing: Last.fm, ListenBrainz,
MusicBrainz and Discord all had prefs but no runtime behind them, and Discord
never pushed a presence at all.

Each row now drives the qbz-integrations APIs for real — connect/disconnect
state, the account label once connected, and the per-service toggles — and the
runtime starts on shell entry, applying the persisted opt-ins and the scrobble
queue's flush watcher. Scrobbles arm on the de-duped track edge (firing from
the republish path would re-arm the timer on every poll tick) and the Discord
presence refreshes on both the track and the play/pause edges, then clears on
logout so a signed-out session stops advertising what was playing.

All of it stays strictly opt-in: nothing here does anything until the user
connects the service.

cc
This commit is contained in:
vicrodh committed 2026-07-28 13:48:35 -06:00
1 parent f44f03e414
commit 1916d8d794
4 files changed
+759 -46

No files matched your search

@@ -1,16 +1,14 @@
// Settings > Integrations (phase 19) — the QML port of
// Settings > Integrations — the QML port of
// crates/qbz-ui/ui/settings/IntegrationsSettings.slint. Row order, group
// headers and auth-gating mirror the Slint 1:1; all state rides the single
// settingsJson document (integrations_qt.rs over the SAME scrobbler /
// discover / ui_prefs stores the Slint app uses).
// discover / ui_prefs / offline-queue stores the Slint app uses).
//
// POC-NOTEs (named in integrations_qt.rs too):
// - No live scrobbling (scrobble::start / on_track_changed not ported) —
// toggles + credentials persist for the Slint scrobbler / a future port.
// - Discord: the pref + live DiscordRpc flag flip; presence updates are
// not fed (no NowListening pushes on track change).
// - The Last.fm connect flow opens the system browser (open::that) — the
// offscreen smoke never clicks it.
// Every integration here is STRICTLY OPT-IN: nothing connects, fires or
// queues until the user signs the service in and its toggles are on.
//
// NOTE: the Last.fm connect flow opens the system browser (open::that) — the
// offscreen smoke never clicks it.
import QtQuick
import com.blitzfc.qbz
@@ -26,6 +24,21 @@ Column {
spacing: 4
// The Slint mirrors the in-progress token in
// ScrobbleState.listenbrainz-token-input so its "Connect ListenBrainz"
// button can submit it. QbzLineEdit only publishes the COMMITTED value
// (it commits on Enter / focus loss, and a SettingsButton click does not
// 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 =============================
GroupHeader { text: QbzSession.tr("RECOMMENDATIONS", QbzSession.trRev) }
SettingRow {
@@ -99,9 +112,10 @@ Column {
color: colArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
// 28px hit-box / 18px glyph (IntegrationsSettings.slint:129).
name: root.doc.scrobbleUiCollapsed === true ? "chevron-right" : "chevron-down"
width: 16
height: 16
width: 18
height: 18
tintName: "secondary"
}
MouseArea {
@@ -155,7 +169,10 @@ Column {
}
SettingsButton {
visible: (root.doc.lastfmAuthUrl || "") !== ""
text: QbzSession.tr("Finish", QbzSession.trRev)
text: root.doc.lastfmBusy === true
? QbzSession.tr("Working...", QbzSession.trRev)
: QbzSession.tr("Finish", QbzSession.trRev)
enabled: root.doc.lastfmBusy !== true
onClicked: QbzBridge.integrationsAction("lastfm-finish")
}
}
@@ -190,9 +207,27 @@ Column {
label: QbzSession.tr("User token", QbzSession.trRev)
description: QbzSession.tr("From listenbrainz.org/settings.", QbzSession.trRev)
QbzLineEdit {
id: lbTokenField
isPassword: true
enabled: root.doc.listenbrainzBusy !== true
placeholder: QbzSession.tr("ListenBrainz token", QbzSession.trRev)
// Enter (and focus loss) validates, like the Slint `accepted`.
onCommitted: function (v) { QbzBridge.settingsString("listenbrainz-token", v) }
// The row hides once the sign-in lands; drop the typed token
// with it (Slint resets listenbrainz-token-input on success).
onVisibleChanged: if (!visible) text = ""
}
}
SettingRow {
visible: root.doc.listenbrainzAuthed !== true
label: QbzSession.tr("Save token", QbzSession.trRev)
description: QbzSession.tr("Validates the token against ListenBrainz.", QbzSession.trRev)
SettingsButton {
text: root.doc.listenbrainzBusy === true
? QbzSession.tr("Validating...", QbzSession.trRev)
: QbzSession.tr("Connect ListenBrainz", QbzSession.trRev)
enabled: root.doc.listenbrainzBusy !== true
onClicked: QbzBridge.settingsString("listenbrainz-token", root.lbTokenText())
}
}
SettingRow {
+697 -34
View File
@@ -1,31 +1,49 @@
//! Integrations settings controller (phase 19) — the Slint
//! Integrations settings + runtime controller — the Slint
//! `crates/qbz/src/scrobble.rs` + `discord_rpc.rs` + the settings.rs
//! integration rows, ported onto the SAME stores:
//! `scrobbler_settings.db` (`qbz_app::settings::scrobblers`, per-user),
//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user) and
//! the shared `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`).
//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user), the
//! shared per-user `offline_settings.db` `scrobble_queue`, the shared
//! `cache/listenbrainz_v2.db` (credentials + `listen_queue`) and the shared
//! `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`).
//!
//! POC-NOTEs:
//! - No live scrobbling: `scrobble::start` / `on_track_changed` (the
//! now-playing/scrobble firing + offline queues) is NOT ported — the
//! toggles + credentials persist to the SAME DB the Slint scrobbler
//! reads, so they take effect in the Slint app / a future port.
//! - Discord: the enable pref persists AND flips the live `DiscordRpc`
//! flag, but presence updates (`DiscordRpc::update` on track change)
//! are not wired (no rich-presence feed in the POC).
//! - ListenBrainz disconnect does NOT clear the shared
//! `cache/listenbrainz_v2.db` credentials row (scrobble.rs:334-342 —
//! that cache feeds the scrobbler runtime, which the POC does not run).
//! Everything here is STRICTLY OPT-IN (the project rule): no client is
//! constructed and no request leaves the process until the user connected the
//! service AND its enable flags are on. `ScrobblerSettings::lastfm_active()` /
//! `listenbrainz_active()` (master + per-service + authed) gate every fire,
//! and `DiscordRpc` only opens its IPC socket once `set_enabled(true)` came
//! from the persisted opt-in or the toggle.
//!
//! Inventory (1:1 with `settings/IntegrationsSettings.slint`):
//! - Recommendations (discover_prefs `show_recommendations`)
//! - MusicBrainz (`ui_prefs.musicbrainz_enabled` -> `core.musicbrainz_set_enabled`)
//! - Scrobblers master (+ collapse) -> Last.fm, ListenBrainz
//! - Discord Rich Presence (`ui_prefs.discord_rpc_enabled`)
//! (Discogs is NOT a settings integration in either reference — it is a tag
//! editor remote-metadata provider + an album external link.)
//!
//! Runtime wiring (the fire path) needs two call sites OUTSIDE this file:
//! [`start`] at shell entry and [`on_track_changed`] / [`discord_push`] on the
//! playback track-change + play/pause edges. See the "GLUE" notes on those
//! functions.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use qbz_app::offline_mode::OfflineModeStore;
use qbz_app::scrobble_timing::scrobble_delay_secs;
use qbz_app::settings::discover_prefs::DiscoverPrefsStore;
use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState};
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_integrations::discord::DiscordRpc;
use qbz_integrations::lastfm::LastFmClient;
use qbz_integrations::listenbrainz::ListenBrainzClient;
use qbz_integrations::listenbrainz::cache::ListenBrainzCache;
use qbz_integrations::listenbrainz::{AdditionalInfo, ListenBrainzClient};
use qbz_integrations::NowListening;
use qbz_models::QueueTrack;
use crate::settings_qt::{pref_bool, save_pref};
@@ -68,6 +86,15 @@ fn discord() -> &'static DiscordRpc {
DISCORD.get_or_init(DiscordRpc::new)
}
/// `<user_dir>/cache/listenbrainz_v2.db` — the SAME per-user file the Slint
/// and Tauri builds open, so credentials AND the offline listen queue are
/// shared across frontends (scrobble.rs `listenbrainz_cache_path`).
fn listenbrainz_cache_path() -> Option<PathBuf> {
let dir = crate::sidebar_qt::user_dir()?.join("cache");
std::fs::create_dir_all(&dir).ok()?;
Some(dir.join("listenbrainz_v2.db"))
}
// ---------------------------------------------------------------------------
// Transient UI state (ScrobbleState busy/auth-url/status + the Last.fm
// pending request token — process memory only, like LASTFM_PENDING_TOKEN).
@@ -102,9 +129,9 @@ pub fn ui_snapshot() -> IntegrationUi {
}
}
fn set_status(text: &str, kind: i32) {
fn set_status(text: String, kind: i32) {
let mut g = ui_state().lock().unwrap();
g.status_text = text.to_string();
g.status_text = text;
g.status_kind = kind;
}
@@ -124,6 +151,94 @@ pub fn discord_enabled() -> bool {
pref_bool("discord_rpc_enabled", false)
}
// ---------------------------------------------------------------------------
// Shell entry (scrobble::start + discord_rpc::init + the MusicBrainz seed)
// ---------------------------------------------------------------------------
/// One-shot guard for the offline-engine flush watcher (lives for the process).
static FLUSH_WATCHER: OnceLock<()> = OnceLock::new();
/// Per-user runtime start. GLUE: call from `enter_shell` (main.rs), AFTER the
/// per-user stores bind (`sidebar_qt::init_pinned` sets the user dir).
///
/// Applies the three persisted opt-ins that would otherwise only take effect
/// after the user re-toggled them:
/// - MusicBrainz -> `core.musicbrainz_set_enabled` (main.rs:9023 in Slint),
/// - Discord -> `DiscordRpc::set_enabled` + an initial presence push
/// (discord_rpc::init — the PR #477 "enabled on restart"
/// fix: applied AFTER the session, never at early boot),
/// - ListenBrainz-> adopt the shared-cache credentials when this build has
/// none (a Tauri/Slint sign-in carries over; enable flags
/// are NOT touched, scrobbling stays opt-in).
/// Then drains the offline scrobble queues and watches for offline->online
/// edges to drain them again.
pub fn start(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
// Bind the per-user scrobbler store now (lazy OnceLock) so the fire path
// never races the first Settings visit.
let _ = scrobble().get_settings();
let rt = runtime.clone();
crate::spawn(async move {
// MusicBrainz opt-out is a plain pref; the core caches its own flag.
let mb_on = pref_bool("musicbrainz_enabled", true);
rt.core().musicbrainz_set_enabled(mb_on).await;
seed_listenbrainz_from_shared_cache().await;
let discord_on = discord_enabled();
discord().set_enabled(discord_on);
if discord_on {
discord_push(&rt);
}
if !crate::offline_fwd::engine().is_offline() {
flush_offline_queues().await;
}
});
FLUSH_WATCHER.get_or_init(|| {
crate::spawn(async move {
let mut rx = crate::offline_fwd::engine().subscribe();
let mut was_offline = rx.borrow_and_update().is_offline();
loop {
if rx.changed().await.is_err() {
break;
}
let offline = rx.borrow_and_update().is_offline();
if was_offline && !offline {
log::info!("[qbz-qt] scrobblers: back online, flushing queues");
flush_offline_queues().await;
}
was_offline = offline;
}
});
});
}
/// Adopt the shared `ListenBrainzCache` credentials when this build's store
/// has no LB token yet (scrobble.rs `seed_listenbrainz_from_shared_cache`).
/// Enable flags are NOT touched — scrobbling stays opt-in per build.
async fn seed_listenbrainz_from_shared_cache() {
if scrobble_settings().listenbrainz_is_authed() {
return;
}
let Some(path) = listenbrainz_cache_path() else {
return;
};
let creds = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.get_credentials())
})
.await;
if let Ok(Ok((Some(token), Some(user_name)))) = creds {
if !token.is_empty() {
log::info!("[qbz-qt] adopting ListenBrainz credentials from shared cache");
if let Err(e) = scrobble().set_listenbrainz_token(&token, &user_name) {
log::warn!("[qbz-qt] persist adopted ListenBrainz credentials failed: {e}");
}
}
}
}
// ---------------------------------------------------------------------------
// Toggle handlers (settings_qt.rs bool arms delegate here)
// ---------------------------------------------------------------------------
@@ -153,9 +268,15 @@ pub fn set_listenbrainz_enabled(value: bool) -> Result<(), String> {
scrobble().set_listenbrainz_enabled(value)
}
/// Discord toggle (discord_rpc::set_enabled): persist the opt-in, apply it to
/// the live client, and push the current track (enable) — `set_enabled(false)`
/// tears the IPC connection down, so the presence disappears immediately.
pub fn set_discord_enabled(value: bool) -> Result<(), String> {
save_pref("discord_rpc_enabled", serde_json::json!(value));
discord().set_enabled(value);
if value {
discord_push(&crate::app());
}
log::info!("[qbz-qt] discord_rpc_enabled -> {value}");
Ok(())
}
@@ -165,18 +286,85 @@ pub fn set_musicbrainz_enabled(value: bool) -> Result<(), String> {
Ok(())
}
// ---------------------------------------------------------------------------
// Discord Rich Presence (discord_rpc.rs)
// ---------------------------------------------------------------------------
/// Build the "now listening" snapshot from the live queue + playback state and
/// push it. No-op when the user has not opted in (cheap early return — nothing
/// is fetched, nothing connects).
///
/// GLUE: call on the playback track-change edge and on play/pause, mirroring
/// the Tauri service's (track_id, is_playing) transition pushes.
pub fn discord_push(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
if !discord().is_enabled() {
return;
}
let runtime = runtime.clone();
crate::spawn(async move {
let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else {
// Nothing playing — drop the activity.
let _ = tokio::task::spawn_blocking(|| discord().clear()).await;
return;
};
let pb = runtime.core().get_playback_state();
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
};
// Discord's large_image needs an http(s) URL or an asset key; local /
// Plex covers are filesystem paths Discord can't fetch, so drop them
// (the core falls back to the "cover" asset key).
let cover_url = track
.artwork_url
.clone()
.filter(|u| u.starts_with("http://") || u.starts_with("https://"));
let meta = NowListening {
title,
artist: track.artist.clone(),
album: track.album.clone(),
is_playing: pb.is_playing,
current_time: pb.position as f64,
duration: track.duration_secs as f64,
cover_url,
};
let _ = tokio::task::spawn_blocking(move || discord().update(&meta)).await;
});
}
/// Tear down the live activity + IPC connection (logout / app exit).
/// GLUE (optional): call from the logout path and the quit handler.
pub fn discord_clear() {
crate::spawn(async {
let _ = tokio::task::spawn_blocking(|| discord().clear()).await;
});
}
// ---------------------------------------------------------------------------
// Connection flows (ScrobbleActions)
// ---------------------------------------------------------------------------
/// ListenBrainz paste-token flow (scrobble.rs:315): validate against
/// /validate-token, persist token + username, force-enable on success.
/// ListenBrainz paste-token flow (scrobble.rs `listenbrainz_set_token`):
/// validate against `/validate-token`, persist the token + username to this
/// build's store AND the shared cache (so the other frontends see the same
/// sign-in), and force-enable on the first connect.
pub async fn listenbrainz_set_token(token: &str) {
let token = token.trim().to_string();
if token.is_empty() {
set_status(qbz_i18n::t("Paste your ListenBrainz user token first"), 3);
crate::settings_qt::publish_snapshot().await;
return;
}
ui_state().lock().unwrap().listenbrainz_busy = true;
// 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();
if g.listenbrainz_busy {
return;
}
g.listenbrainz_busy = true;
}
crate::settings_qt::publish_snapshot().await;
let client = ListenBrainzClient::new();
@@ -186,10 +374,22 @@ pub async fn listenbrainz_set_token(token: &str) {
log::error!("[qbz-qt] persist listenbrainz token failed: {e}");
}
// First-connect force-enable (scrobble.rs).
let _ = scrobble().set_listenbrainz_enabled(true);
set_status(&format!("Signed in as {}.", info.user_name), 2);
if !scrobble_settings().listenbrainz_enabled {
let _ = scrobble().set_listenbrainz_enabled(true);
}
// Write-through to the shared cache (the Slint/Tauri builds read
// it at session start). Best-effort.
if let Some(path) = listenbrainz_cache_path() {
let tok = token.clone();
let name = info.user_name.clone();
let _ = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.save_credentials(&tok, &name))
})
.await;
}
set_status(qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]), 2);
}
Err(e) => set_status(&format!("ListenBrainz: {e}"), 3),
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
}
ui_state().lock().unwrap().listenbrainz_busy = false;
crate::settings_qt::publish_snapshot().await;
@@ -198,10 +398,17 @@ pub async fn listenbrainz_set_token(token: &str) {
/// integrations_action dispatch (non-toggle rows).
pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &str) {
match action {
// Last.fm two-step browser auth (scrobble.rs:190): request token ->
// authorize URL in the browser -> Finish exchanges for a session.
// Last.fm two-step browser auth (scrobble.rs `lastfm_connect`):
// request token -> authorize URL in the browser -> Finish exchanges
// it for a session.
"lastfm-connect" => {
ui_state().lock().unwrap().lastfm_busy = true;
{
let mut g = ui_state().lock().unwrap();
if g.lastfm_busy {
return;
}
g.lastfm_busy = true;
}
crate::settings_qt::publish_snapshot().await;
let client = LastFmClient::new();
match client.get_token().await {
@@ -211,12 +418,15 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
g.pending_lastfm_token = token;
g.lastfm_auth_url = url.clone();
}
set_status("Authorize QBZ in your browser, then click Finish.", 1);
set_status(
qbz_i18n::t("Authorize QBZ in your browser, then click \"Finish\""),
1,
);
if let Err(e) = open::that(&url) {
log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}");
}
}
Err(e) => set_status(&format!("Last.fm: {e}"), 3),
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
}
ui_state().lock().unwrap().lastfm_busy = false;
crate::settings_qt::publish_snapshot().await;
@@ -232,9 +442,17 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
"lastfm-finish" => {
let token = ui_state().lock().unwrap().pending_lastfm_token.clone();
if token.is_empty() {
set_status(qbz_i18n::t("Start the sign-in first"), 3);
crate::settings_qt::publish_snapshot().await;
return;
}
ui_state().lock().unwrap().lastfm_busy = true;
{
let mut g = ui_state().lock().unwrap();
if g.lastfm_busy {
return;
}
g.lastfm_busy = true;
}
crate::settings_qt::publish_snapshot().await;
let mut client = LastFmClient::new();
match client.get_session(&token).await {
@@ -243,15 +461,26 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
log::error!("[qbz-qt] persist lastfm session failed: {e}");
}
// First-connect force-enable (scrobble.rs).
let _ = scrobble().set_lastfm_enabled(true);
if !scrobble_settings().lastfm_enabled {
let _ = scrobble().set_lastfm_enabled(true);
}
{
let mut g = ui_state().lock().unwrap();
g.pending_lastfm_token.clear();
g.lastfm_auth_url.clear();
}
set_status(&format!("Signed in as {}.", session.name), 2);
set_status(
qbz_i18n::t_args("Connected as {}", &[session.name.as_str()]),
2,
);
}
Err(e) => set_status(&format!("Last.fm: {e}"), 3),
Err(e) => set_status(
qbz_i18n::t_args(
"Error: {} (did you authorize in the browser?)",
&[&e.to_string()],
),
3,
),
}
ui_state().lock().unwrap().lastfm_busy = false;
crate::settings_qt::publish_snapshot().await;
@@ -260,16 +489,450 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
if let Err(e) = scrobble().disconnect_lastfm() {
log::error!("[qbz-qt] lastfm disconnect failed: {e}");
}
set_status("", 0);
{
let mut g = ui_state().lock().unwrap();
g.pending_lastfm_token.clear();
g.lastfm_auth_url.clear();
g.lastfm_busy = false;
}
set_status(qbz_i18n::t("Last.fm disconnected"), 1);
crate::settings_qt::publish_snapshot().await;
}
"listenbrainz-disconnect" => {
if let Err(e) = scrobble().disconnect_listenbrainz() {
log::error!("[qbz-qt] listenbrainz disconnect failed: {e}");
}
set_status("", 0);
// Clear the SHARED cache credentials too (mirrors the Slint +
// Tauri disconnect) — otherwise the next start re-adopts them.
if let Some(path) = listenbrainz_cache_path() {
let _ = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.clear_credentials())
})
.await;
}
ui_state().lock().unwrap().listenbrainz_busy = false;
set_status(qbz_i18n::t("ListenBrainz disconnected"), 1);
crate::settings_qt::publish_snapshot().await;
}
other => log::warn!("[qbz-qt] unknown integrations action: {other}"),
}
}
// ===========================================================================
// Fire + schedule (source-agnostic: Qobuz, local AND Plex all funnel through
// the normalized QueueTrack). scrobble.rs `on_track_changed` and below.
// ===========================================================================
/// Normalized track facts the fire path needs. The title is the
/// version-enriched display title so remixes/editions scrobble correctly
/// (issue #360 parity).
#[derive(Clone)]
pub struct ScrobbleMeta {
pub artist: String,
pub track: String,
/// `None` when empty — the clients take `Option<&str>` for album.
pub album: Option<String>,
pub duration_secs: u64,
}
/// Build the fire meta from the current queue track (keeps the glue one line).
pub fn meta_from_queue_track(track: &QueueTrack) -> ScrobbleMeta {
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
};
ScrobbleMeta {
artist: track.artist.clone(),
track: title,
// The clean album name — `album_version` is deliberately NOT appended
// (qbz-models: Last.fm wants the clean name).
album: Some(track.album.clone()).filter(|a| !a.is_empty()),
duration_secs: track.duration_secs,
}
}
/// Monotonic generation, bumped on every track change so a delayed scrobble
/// timer that fires after the user skipped is dropped (the Svelte
/// `clearTimeout` equivalent). Like Tauri, pause/stop do NOT cancel it.
static SCROBBLE_GEN: AtomicU64 = AtomicU64::new(0);
/// Track-change entry point. Fires now-playing immediately for each enabled +
/// authed service, then arms a delayed scrobble at `min(50% of duration,
/// 240s)`. No-op when no service is active (opt-in gate).
///
/// GLUE: call from the playback poll's de-duped track-change edge only — NOT
/// on resume/seek, or the scrobble re-arms.
pub fn on_track_changed(meta: ScrobbleMeta) {
// Always bump the generation so any in-flight stale timer self-cancels.
let my_gen = SCROBBLE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let cfg = scrobble_settings();
if !cfg.lastfm_active() && !cfg.listenbrainz_active() {
return;
}
crate::spawn(async move {
// Now-playing immediately (skipped while offline — it needs network
// and is not worth queueing; matches the Svelte path).
if !crate::offline_fwd::engine().is_offline() {
send_now_playing(&meta, &cfg).await;
}
// Delayed scrobble. Unknown / too-short duration: skip (Last.fm's
// "longer than 30 seconds" rule lives in qbz_app::scrobble_timing).
let Some(wait) = scrobble_delay_secs(meta.duration_secs) else {
log::debug!(
"[qbz-qt] scrobble: skip delayed scrobble, unusable duration for '{}'",
meta.track
);
return;
};
if wait > 0 {
tokio::time::sleep(Duration::from_secs(wait)).await;
}
// Self-cancel if a newer track change superseded us.
if SCROBBLE_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
send_scrobble(&meta).await;
});
}
/// The whole integrations reaction to a track-change edge, in one call:
/// scrobble now-playing + arm the delayed scrobble, and refresh the Discord
/// presence. Both halves early-return when the user has not opted in, so this
/// is free for everyone else (the queue state is only read when at least one
/// integration is live).
///
/// GLUE: call from the playback poll's DE-DUPED track-change edge
/// (`track_id != last_track_id`), never from `refresh_now_playing` — that runs
/// on every play/queue republish and would re-arm the scrobble timer.
pub fn on_track_change_edge(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let cfg = scrobble_settings();
let scrobblers_live = cfg.lastfm_active() || cfg.listenbrainz_active();
if !scrobblers_live && !discord().is_enabled() {
return;
}
let rt = runtime.clone();
crate::spawn(async move {
if scrobblers_live {
let state = rt.core().get_queue_state().await;
if let Some(track) = state.current_track {
on_track_changed(meta_from_queue_track(&track));
}
}
discord_push(&rt);
});
}
/// Optional ListenBrainz extras — duration is the only one the QueueTrack
/// carries (no ISRC / MB IDs on the queue model yet).
fn lb_info(duration_secs: u64) -> Option<AdditionalInfo> {
Some(AdditionalInfo {
duration_ms: (duration_secs > 0).then_some(duration_secs * 1000),
..Default::default()
})
}
/// Fire "now playing" for each enabled service. Failures only log — the
/// scrobble path is what queues.
async fn send_now_playing(meta: &ScrobbleMeta, cfg: &ScrobblerSettings) {
let album = meta.album.as_deref();
if cfg.lastfm_active() {
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
if let Err(e) = client
.update_now_playing(&meta.artist, &meta.track, album)
.await
{
log::debug!("[qbz-qt] Last.fm now-playing failed: {e}");
}
}
if cfg.listenbrainz_active() {
let client = ListenBrainzClient::new();
client
.restore_token(
cfg.listenbrainz_token.clone(),
cfg.listenbrainz_username.clone(),
)
.await;
if let Err(e) = client
.submit_playing_now(&meta.artist, &meta.track, album, lb_info(meta.duration_secs))
.await
{
log::debug!("[qbz-qt] ListenBrainz now-playing failed: {e}");
}
}
}
/// Fire the actual scrobble for each enabled service. Engine offline OR call
/// failure queues it — Last.fm to the shared `scrobble_queue`, ListenBrainz to
/// the shared `listen_queue`. Settings are re-read in case the user
/// disconnected while the timer waited.
async fn send_scrobble(meta: &ScrobbleMeta) {
let cfg = scrobble_settings();
let album = meta.album.as_deref();
let timestamp = unix_now();
let offline = crate::offline_fwd::engine().is_offline();
if cfg.lastfm_active() {
let sent = if offline {
false
} else {
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
match client
.scrobble(&meta.artist, &meta.track, album, timestamp as u64)
.await
{
Ok(()) => {
log::info!("[qbz-qt] Last.fm scrobbled: {} - {}", meta.artist, meta.track);
true
}
Err(e) => {
log::warn!("[qbz-qt] Last.fm scrobble failed ({e}); queueing for later");
false
}
}
};
if !sent {
queue_lastfm(meta, timestamp).await;
}
}
if cfg.listenbrainz_active() {
let sent = if offline {
false
} else {
let client = ListenBrainzClient::new();
client
.restore_token(
cfg.listenbrainz_token.clone(),
cfg.listenbrainz_username.clone(),
)
.await;
match client
.submit_listen(
&meta.artist,
&meta.track,
album,
timestamp,
lb_info(meta.duration_secs),
)
.await
{
Ok(()) => {
log::info!(
"[qbz-qt] ListenBrainz scrobbled: {} - {}",
meta.artist,
meta.track
);
true
}
Err(e) => {
log::warn!("[qbz-qt] ListenBrainz scrobble failed ({e}); queueing for later");
false
}
}
};
if !sent {
queue_listenbrainz(meta, timestamp).await;
}
}
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Queue a Last.fm scrobble into the SHARED per-user `offline_settings.db`
/// `scrobble_queue` (the table the other frontends queue into and flush from).
async fn queue_lastfm(meta: &ScrobbleMeta, timestamp: i64) {
let Some(dir) = crate::sidebar_qt::user_dir() else {
return;
};
let artist = meta.artist.clone();
let track = meta.track.clone();
let album = meta.album.clone();
let _ = tokio::task::spawn_blocking(move || match OfflineModeStore::new_at(&dir) {
Ok(store) => {
if let Err(e) = store.queue_scrobble(&artist, &track, album.as_deref(), timestamp) {
log::warn!("[qbz-qt] queue Last.fm scrobble failed: {e}");
}
}
Err(e) => log::warn!("[qbz-qt] open offline settings store failed: {e}"),
})
.await;
}
/// Queue a ListenBrainz listen into the SHARED per-user
/// `ListenBrainzCache.listen_queue` (the canonical LB offline store).
async fn queue_listenbrainz(meta: &ScrobbleMeta, timestamp: i64) {
let Some(path) = listenbrainz_cache_path() else {
return;
};
let artist = meta.artist.clone();
let track = meta.track.clone();
let album = meta.album.clone();
let duration_ms = (meta.duration_secs > 0).then_some(meta.duration_secs * 1000);
let _ = tokio::task::spawn_blocking(move || match ListenBrainzCache::new(&path) {
Ok(cache) => {
if let Err(e) = cache.queue_listen(
timestamp,
&artist,
&track,
album.as_deref(),
None,
None,
None,
None,
duration_ms,
) {
log::warn!("[qbz-qt] queue ListenBrainz listen failed: {e}");
}
}
Err(e) => log::warn!("[qbz-qt] open ListenBrainz cache failed: {e}"),
})
.await;
}
// ---------------------------------------------------------------------------
// Offline flush — drain both queues (shell entry + every offline->online edge)
// ---------------------------------------------------------------------------
async fn flush_offline_queues() {
flush_lastfm_queue().await;
flush_listenbrainz_queue().await;
}
/// Flush the Last.fm queue: up to 50 per pass (the Last.fm batch limit),
/// oldest first; entries older than 14 days are dropped (marked sent) since
/// Last.fm rejects them. Stops at the first network failure and retries on the
/// next edge. Cleans up sent rows older than 7 days afterwards.
async fn flush_lastfm_queue() {
let cfg = scrobble_settings();
if !cfg.lastfm_is_authed() {
return;
}
let Some(dir) = crate::sidebar_qt::user_dir() else {
return;
};
let pending = match tokio::task::spawn_blocking({
let dir = dir.clone();
move || OfflineModeStore::new_at(&dir).and_then(|s| s.get_queued_scrobbles(50))
})
.await
{
Ok(Ok(p)) => p,
_ => return,
};
if pending.is_empty() {
return;
}
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
let cutoff = unix_now() - 14 * 86400;
let mut sent_ids: Vec<i64> = Vec::new();
for item in pending {
if item.timestamp < cutoff {
// Too old for Last.fm — drop it (mark sent so it stops re-trying).
sent_ids.push(item.id);
continue;
}
match client
.scrobble(
&item.artist,
&item.track,
item.album.as_deref(),
item.timestamp as u64,
)
.await
{
Ok(()) => sent_ids.push(item.id),
Err(e) => {
log::warn!(
"[qbz-qt] Last.fm flush stopped at {} - {}: {e}",
item.artist,
item.track
);
break; // still offline / failing — retry on the next edge
}
}
}
if !sent_ids.is_empty() {
let count = sent_ids.len();
let _ = tokio::task::spawn_blocking(move || {
OfflineModeStore::new_at(&dir).and_then(|s| {
s.mark_scrobbles_sent(&sent_ids)?;
s.cleanup_sent_scrobbles(7)
})
})
.await;
log::info!("[qbz-qt] Last.fm flush: {count} scrobble(s) sent/cleared");
}
}
/// Flush the ListenBrainz queue from the shared cache. Stops at the first
/// failure and retries on the next edge.
async fn flush_listenbrainz_queue() {
let cfg = scrobble_settings();
if !cfg.listenbrainz_is_authed() {
return;
}
let Some(path) = listenbrainz_cache_path() else {
return;
};
let pending = match tokio::task::spawn_blocking({
let path = path.clone();
move || ListenBrainzCache::new(&path).and_then(|c| c.get_pending_listens(500))
})
.await
{
Ok(Ok(p)) => p,
_ => return,
};
if pending.is_empty() {
return;
}
let client = ListenBrainzClient::new();
client
.restore_token(
cfg.listenbrainz_token.clone(),
cfg.listenbrainz_username.clone(),
)
.await;
let mut sent_ids: Vec<i64> = Vec::new();
for item in pending {
let info = AdditionalInfo {
recording_mbid: item.recording_mbid.clone(),
release_mbid: item.release_mbid.clone(),
artist_mbids: item.artist_mbids.clone(),
isrc: item.isrc.clone(),
duration_ms: item.duration_ms,
..Default::default()
};
if client
.submit_listen(
&item.artist_name,
&item.track_name,
item.release_name.as_deref(),
item.listened_at,
Some(info),
)
.await
.is_ok()
{
sent_ids.push(item.id);
} else {
break; // still failing — retry on the next edge
}
}
if !sent_ids.is_empty() {
let count = sent_ids.len();
let _ = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.mark_listens_sent(&sent_ids))
})
.await;
log::info!("[qbz-qt] ListenBrainz flush: {count} listen(s) sent");
}
}
+7
View File
@@ -154,6 +154,10 @@ fn enter_shell(session: auth_qt::SessionInfo) {
load_home_once();
// Phase 4: the 1 Hz playback state pump (idempotent).
playback_qt::start_poll_loop(app());
// Integrations runtime: applies the persisted MusicBrainz / Discord /
// ListenBrainz opt-ins and starts the scrobble-queue flush watcher.
// Strictly opt-in — every one of them is inert until the user connects it.
integrations_qt::start(&app());
// Phase 7: the sidebar playlist tree.
load_sidebar_once();
// Phase 10: seed the playback request tier from the persisted
@@ -254,6 +258,9 @@ pub(crate) fn do_logout() {
if let Err(e) = auth_qt::logout(&runtime).await {
log::error!("[qbz-qt] logout failed: {e}");
}
// Integrations: drop the Discord presence so a signed-out session
// stops advertising what was playing (the Slint discord_rpc::clear).
integrations_qt::discord_clear();
// A later login must re-fetch Home + Library (new user, fresh data).
*HOME_LOADED.lock().unwrap() = false;
*LIBRARY_LOADED.lock().unwrap() = false;
+8
View File
@@ -710,6 +710,11 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
let _ = runtime.core().sync_current_to_id(track_id).await;
refresh_now_playing(&runtime).await;
publish_queue(&runtime).await;
// Integrations: scrobble now-playing + arm the delayed
// scrobble, and refresh the Discord presence. This is the
// DE-DUPED track edge on purpose — firing it from
// refresh_now_playing would re-arm the timer on every republish.
crate::integrations_qt::on_track_change_edge(&runtime);
last_track_id = track_id;
}
@@ -748,6 +753,9 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
}
seen_position = position;
if is_playing != was_playing {
crate::integrations_qt::discord_push(&runtime);
}
was_playing = is_playing;
}
});