feat(qbz-qt): restore offline and logged-out scrobbling

Port the Tauri immediate and accumulated offline policies into the active Qt fire path, retain independent scrobbler sessions across Qobuz logout behind a default-on opt-out, and flush queued Last.fm/ListenBrainz plays only when the effective policy permits it.

cx
This commit is contained in:
vicrodh committed 2026-08-25 21:21:37 -06:00
1 parent 1517b4c567
commit aadad1da54
18 files changed
+794 -109

No files matched your search

@@ -135,6 +135,19 @@ Column {
width: parent.width
spacing: 4
SettingRow {
label: QbzSession.tr("Scrobble while logged out", QbzSession.trRev)
description: QbzSession.tr("Continue sending plays to connected scrobblers without a Qobuz session.", QbzSession.trRev)
QbzToggle {
checked: root.doc.allowLoggedOutScrobbling !== false
onToggled: function (v) {
QbzBridge.settingsBool("scrobble-logged-out", v)
}
}
}
SettingsSpacer { }
// ------------------------- LAST.FM ------------------------------
GroupHeader { text: QbzSession.tr("LAST.FM", QbzSession.trRev) }
SettingRow {
@@ -61,6 +61,31 @@ Column {
onToggled: function (v) { QbzBridge.settingsBool("offline-mode-enabled", v) }
}
}
// Restored from the Tauri offline settings. Immediate and accumulated
// modes are mutually exclusive in SQLite, so one settings republish keeps
// both toggles in lock-step even if the write originates elsewhere.
SettingRow {
visible: root.off.modeEnabled === true
label: QbzSession.tr("Immediate scrobbling", QbzSession.trRev)
description: QbzSession.tr("Send scrobbles immediately while manual offline mode is enabled.", QbzSession.trRev)
QbzToggle {
checked: root.off.allowImmediateScrobbling === true
onToggled: function (v) {
QbzBridge.settingsBool("offline-scrobble-immediate", v)
}
}
}
SettingRow {
visible: root.off.modeEnabled === true
label: QbzSession.tr("Accumulated scrobbling", QbzSession.trRev)
description: QbzSession.tr("Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old.", QbzSession.trRev)
QbzToggle {
checked: root.off.allowAccumulatedScrobbling === true
onToggled: function (v) {
QbzBridge.settingsBool("offline-scrobble-accumulated", v)
}
}
}
// Ask the connectivity actor for an immediate probe rather than waiting
// for its next scheduled one. Disabled under MANUAL offline (mode 2),
// where the answer is a user decision and not a network fact.
+4
View File
@@ -209,6 +209,10 @@ where
/// logs on failure, never blocks shell entry) — one entry point, so the caller
/// cannot bind the uid and forget the schema.
async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
// Independent Last.fm/ListenBrainz credentials + queues. This binding is
// retained across Qobuz logout by design (opt-out lives in that store),
// and is replaced atomically when another profile activates.
crate::integrations_qt::init_for_user(dir);
// Invalidate any late media-server response before the registry moves to a
// different profile. Epoch checks inside every page transaction then keep
// the previous user's catalog out of this user's cache.
+368 -93
View File
@@ -27,12 +27,14 @@
//! 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::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use qbz_app::offline_mode::OfflineModeStore;
use qbz_app::offline_mode::{
Connectivity, OfflineMode, OfflineModeSettings, OfflineModeStore, OfflineStatus,
};
use qbz_app::scrobble_timing::scrobble_delay_secs;
use qbz_app::settings::discover_prefs::DiscoverPrefsStore;
use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState};
@@ -52,17 +54,47 @@ use crate::settings_qt::{pref_bool, save_pref};
// ---------------------------------------------------------------------------
static SCROBBLE: OnceLock<ScrobblerSettingsState> = OnceLock::new();
static SCROBBLE_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Qobuz authentication is independent of a Last.fm/ListenBrainz session.
/// False covers both the login screen and an explicitly started offline shell.
static QOBUZ_AUTHENTICATED: AtomicBool = AtomicBool::new(false);
fn scrobble() -> &'static ScrobblerSettingsState {
SCROBBLE.get_or_init(|| {
let state = ScrobblerSettingsState::new_empty();
if let Some(dir) = crate::sidebar_qt::user_dir() {
if let Err(e) = state.init_at(&dir) {
log::warn!("[qbz-qt] scrobbler settings store unavailable: {e}");
}
}
state
})
SCROBBLE.get_or_init(ScrobblerSettingsState::new_empty)
}
/// Bind the independent scrobbler credentials to the active/last Qobuz
/// profile. Unlike the old sidebar-derived OnceLock, this can rebind on an
/// account switch. Logout deliberately keeps the last binding: that is what
/// allows opted-in local playback to scrobble without a Qobuz session.
pub fn init_for_user(base_dir: &Path) {
if let Err(e) = scrobble().init_at(base_dir) {
log::warn!("[qbz-qt] scrobbler settings store unavailable: {e}");
return;
}
let changed = SCROBBLE_DIR
.lock()
.map(|mut dir| {
let changed = dir.as_deref() != Some(base_dir);
*dir = Some(base_dir.to_path_buf());
changed
})
.unwrap_or(false);
// A timer armed for account A must never submit through account B's newly
// rebound credentials. Re-entering the same offline profile keeps it.
if changed {
SCROBBLE_GEN.fetch_add(1, Ordering::SeqCst);
}
}
fn scrobble_user_dir() -> Option<PathBuf> {
SCROBBLE_DIR.lock().ok().and_then(|dir| dir.clone())
}
pub fn set_qobuz_authenticated(authenticated: bool) {
if QOBUZ_AUTHENTICATED.swap(authenticated, Ordering::SeqCst) != authenticated {
request_flush();
}
}
static DISCOVER: OnceLock<Mutex<Option<DiscoverPrefsStore>>> = OnceLock::new();
@@ -90,7 +122,7 @@ fn discord() -> &'static DiscordRpc {
/// 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");
let dir = scrobble_user_dir()?.join("cache");
std::fs::create_dir_all(&dir).ok()?;
Some(dir.join("listenbrainz_v2.db"))
}
@@ -174,8 +206,8 @@ pub fn discord_enabled() -> bool {
/// 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).
/// Per-user runtime start. GLUE: call from `enter_shell` (main.rs), AFTER
/// [`init_for_user`] binds the scrobbler profile.
///
/// Applies the three persisted opt-ins that would otherwise only take effect
/// after the user re-toggled them:
@@ -186,11 +218,11 @@ static FLUSH_WATCHER: OnceLock<()> = OnceLock::new();
/// - 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.
/// Then drains the offline scrobble queues whenever the combined network /
/// manual-offline / logged-out policy becomes sendable.
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.
// The auth fan-out bound this before shell entry; touch it now so a missing
// binding degrades to the normal default snapshot rather than a later race.
let _ = scrobble().get_settings();
let rt = runtime.clone();
@@ -207,25 +239,25 @@ pub fn start(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
discord_push(&rt);
}
if !crate::offline_fwd::engine().is_offline() {
flush_offline_queues().await;
}
flush_if_allowed().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();
let _ = *rx.borrow_and_update();
let mut could_send = can_send_now(&scrobble_settings()).await;
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");
let _ = *rx.borrow_and_update();
let can_send = can_send_now(&scrobble_settings()).await;
if !could_send && can_send {
log::info!("[qbz-qt] scrobblers: network policy allows flush");
flush_offline_queues().await;
}
was_offline = offline;
could_send = can_send;
}
});
});
@@ -269,19 +301,45 @@ pub fn set_show_recommendations(value: bool) -> Result<(), String> {
}
pub fn set_scrobble_enabled(value: bool) -> Result<(), String> {
scrobble().set_enabled(value)
let result = scrobble().set_enabled(value);
if result.is_ok() && value {
request_flush();
}
result
}
pub fn set_scrobble_collapsed(value: bool) -> Result<(), String> {
scrobble().set_ui_collapsed(value)
}
pub fn set_logged_out_scrobbling(value: bool) -> Result<(), String> {
let result = scrobble().set_allow_logged_out_scrobbling(value);
if result.is_ok() && value {
request_flush();
}
result
}
pub fn set_lastfm_enabled(value: bool) -> Result<(), String> {
scrobble().set_lastfm_enabled(value)
let result = scrobble().set_lastfm_enabled(value);
if result.is_ok() && value {
request_flush();
}
result
}
pub fn set_listenbrainz_enabled(value: bool) -> Result<(), String> {
scrobble().set_listenbrainz_enabled(value)
let result = scrobble().set_listenbrainz_enabled(value);
if result.is_ok() && value {
request_flush();
}
result
}
/// Settings > Offline policy changed. A newly enabled immediate path may make
/// queued listens deliverable without an offline-engine mode transition.
pub fn scrobble_policy_changed() {
request_flush();
}
/// Discord toggle (discord_rpc::set_enabled): persist the opt-in, apply it to
@@ -411,6 +469,7 @@ pub async fn listenbrainz_set_token(token: &str) {
qbz_i18n::t_args("Connected as {}", &[info.user_name.as_str()]),
2,
);
request_flush();
}
Err(e) => set_status(qbz_i18n::t_args("Error: {}", &[&e.to_string()]), 3),
}
@@ -496,6 +555,7 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
qbz_i18n::t_args("Connected as {}", &[session.name.as_str()]),
2,
);
request_flush();
}
Err(e) => set_status(
qbz_i18n::t_args(
@@ -546,6 +606,110 @@ pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &
// the normalized QueueTrack). scrobble.rs `on_track_changed` and below.
// ===========================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScrobbleAction {
SendNow,
Queue,
Drop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ScrobblePolicy {
action: ScrobbleAction,
/// A live network request can race a connectivity loss. Accumulated mode
/// decides whether that failed request is retained rather than discarded.
queue_on_failure: bool,
}
fn decide_scrobble_policy(
status: OfflineStatus,
offline: OfflineModeSettings,
qobuz_authenticated: bool,
allow_logged_out: bool,
) -> ScrobblePolicy {
let queue_on_failure = offline.allow_accumulated_scrobbling;
let queued_or_dropped = if queue_on_failure {
ScrobbleAction::Queue
} else {
ScrobbleAction::Drop
};
// The new explicit privacy gate wins over every transport policy.
if !qobuz_authenticated && !allow_logged_out {
return ScrobblePolicy {
action: ScrobbleAction::Drop,
queue_on_failure: false,
};
}
let action = match status.mode {
OfflineMode::Online => ScrobbleAction::SendNow,
OfflineMode::InducedOffline => {
// Manual offline closes only Qobuz's gate. The independent
// scrobblers may still use a real network when the historical
// immediate preference says so.
if status.connectivity != Connectivity::Down && offline.allow_immediate_scrobbling {
ScrobbleAction::SendNow
} else {
queued_or_dropped
}
}
OfflineMode::RealOffline => {
// An unauthenticated offline shell is classified RealOffline even
// with working internet. Logged-out scrobbling is precisely the
// exception: independent service credentials remain usable.
if !qobuz_authenticated && status.connectivity != Connectivity::Down {
ScrobbleAction::SendNow
} else {
queued_or_dropped
}
}
};
ScrobblePolicy {
action,
queue_on_failure,
}
}
async fn offline_scrobble_settings() -> OfflineModeSettings {
if let Ok(settings) = crate::offline_fwd::engine().settings() {
return settings;
}
// `OfflineModeEngine::teardown` must drop its per-user store on logout,
// while this feature deliberately keeps the last scrobbler profile. Read
// the same DB through that retained binding for a track that continues on
// the login screen.
let Some(dir) = scrobble_user_dir() else {
return OfflineModeSettings::default();
};
tokio::task::spawn_blocking(move || {
OfflineModeStore::new_at(&dir).and_then(|store| store.get_settings())
})
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default()
}
async fn current_scrobble_policy(cfg: &ScrobblerSettings) -> ScrobblePolicy {
decide_scrobble_policy(
crate::offline_fwd::engine().status(),
offline_scrobble_settings().await,
QOBUZ_AUTHENTICATED.load(Ordering::SeqCst),
cfg.allow_logged_out_scrobbling,
)
}
async fn can_send_now(cfg: &ScrobblerSettings) -> bool {
(cfg.lastfm_active() || cfg.listenbrainz_active())
&& current_scrobble_policy(cfg).await.action == ScrobbleAction::SendNow
}
fn request_flush() {
crate::spawn(async { flush_if_allowed().await });
}
/// Normalized track facts the fire path needs. The title is the
/// version-enriched display title so remixes/editions scrobble correctly
/// (issue #360 parity).
@@ -593,9 +757,9 @@ pub fn on_track_changed(meta: ScrobbleMeta) {
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() {
// Now-playing is never queued. It may use the independent network in
// manual-offline immediate mode or an opted-in logged-out session.
if current_scrobble_policy(&cfg).await.action == ScrobbleAction::SendNow {
send_now_playing(&meta, &cfg).await;
}
@@ -713,81 +877,84 @@ async fn send_now_playing(meta: &ScrobbleMeta, cfg: &ScrobblerSettings) {
}
}
/// 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.
/// Fire, retain or drop the actual scrobble according to the restored offline
/// policy plus the logged-out opt-out. Settings are re-read in case the user
/// changed a policy or 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();
let policy = current_scrobble_policy(&cfg).await;
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!(
match policy.action {
ScrobbleAction::SendNow => {
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
),
Err(e) if policy.queue_on_failure => {
log::warn!("[qbz-qt] Last.fm scrobble failed ({e}); queueing for later");
queue_lastfm(meta, timestamp).await;
}
Err(e) => log::warn!(
"[qbz-qt] Last.fm scrobble failed ({e}); accumulation disabled, dropping"
),
}
}
};
if !sent {
queue_lastfm(meta, timestamp).await;
ScrobbleAction::Queue => queue_lastfm(meta, timestamp).await,
ScrobbleAction::Drop => {
log::debug!("[qbz-qt] Last.fm scrobble dropped by offline/logout policy")
}
}
}
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!(
match policy.action {
ScrobbleAction::SendNow => {
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
),
Err(e) if policy.queue_on_failure => {
log::warn!(
"[qbz-qt] ListenBrainz scrobble failed ({e}); queueing for later"
);
queue_listenbrainz(meta, timestamp).await;
}
Err(e) => log::warn!(
"[qbz-qt] ListenBrainz scrobble failed ({e}); accumulation disabled, dropping"
),
}
}
};
if !sent {
queue_listenbrainz(meta, timestamp).await;
ScrobbleAction::Queue => queue_listenbrainz(meta, timestamp).await,
ScrobbleAction::Drop => {
log::debug!("[qbz-qt] ListenBrainz scrobble dropped by offline/logout policy")
}
}
}
}
@@ -802,7 +969,7 @@ fn unix_now() -> i64 {
/// 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 {
let Some(dir) = scrobble_user_dir() else {
return;
};
let artist = meta.artist.clone();
@@ -851,9 +1018,16 @@ async fn queue_listenbrainz(meta: &ScrobbleMeta, timestamp: i64) {
}
// ---------------------------------------------------------------------------
// Offline flush — drain both queues (shell entry + every offline->online edge)
// Offline flush — drain both queues whenever policy becomes sendable
// ---------------------------------------------------------------------------
async fn flush_if_allowed() {
let cfg = scrobble_settings();
if can_send_now(&cfg).await {
flush_offline_queues().await;
}
}
async fn flush_offline_queues() {
flush_lastfm_queue().await;
flush_listenbrainz_queue().await;
@@ -865,10 +1039,10 @@ async fn flush_offline_queues() {
/// 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() {
if !cfg.lastfm_active() {
return;
}
let Some(dir) = crate::sidebar_qt::user_dir() else {
let Some(dir) = scrobble_user_dir() else {
return;
};
let pending = match tokio::task::spawn_blocking({
@@ -930,7 +1104,7 @@ async fn flush_lastfm_queue() {
/// failure and retries on the next edge.
async fn flush_listenbrainz_queue() {
let cfg = scrobble_settings();
if !cfg.listenbrainz_is_authed() {
if !cfg.listenbrainz_active() {
return;
}
let Some(path) = listenbrainz_cache_path() else {
@@ -991,3 +1165,104 @@ async fn flush_listenbrainz_queue() {
log::info!("[qbz-qt] ListenBrainz flush: {count} listen(s) sent");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn status(
mode: OfflineMode,
connectivity: Connectivity,
offline_session: bool,
) -> OfflineStatus {
OfflineStatus {
mode,
connectivity,
captive_portal: false,
induced: mode == OfflineMode::InducedOffline,
offline_session,
}
}
#[test]
fn manual_offline_respects_immediate_and_accumulated_modes() {
let manual = status(OfflineMode::InducedOffline, Connectivity::Up, false);
let accumulated = OfflineModeSettings::default();
assert_eq!(
decide_scrobble_policy(manual, accumulated, true, true).action,
ScrobbleAction::Queue
);
let immediate = OfflineModeSettings {
allow_immediate_scrobbling: true,
allow_accumulated_scrobbling: false,
..OfflineModeSettings::default()
};
assert_eq!(
decide_scrobble_policy(manual, immediate, true, true).action,
ScrobbleAction::SendNow
);
}
#[test]
fn physical_offline_only_retains_when_accumulation_is_enabled() {
let physical = status(OfflineMode::RealOffline, Connectivity::Down, false);
assert_eq!(
decide_scrobble_policy(physical, OfflineModeSettings::default(), true, true).action,
ScrobbleAction::Queue
);
let disabled = OfflineModeSettings {
allow_accumulated_scrobbling: false,
..OfflineModeSettings::default()
};
assert_eq!(
decide_scrobble_policy(physical, disabled, true, true).action,
ScrobbleAction::Drop
);
}
#[test]
fn logged_out_gate_allows_independent_network_but_can_opt_out() {
// A regular logout tears the offline engine down, so it reports
// Online while the independent Qobuz-auth flag is false.
let login_screen = status(OfflineMode::Online, Connectivity::Up, false);
// Starting the unauthenticated offline shell keeps the explicit
// offline-session classification even when the network is usable.
let offline_shell = status(OfflineMode::RealOffline, Connectivity::Up, true);
for logged_out in [login_screen, offline_shell] {
assert_eq!(
decide_scrobble_policy(logged_out, OfflineModeSettings::default(), false, true)
.action,
ScrobbleAction::SendNow
);
assert_eq!(
decide_scrobble_policy(logged_out, OfflineModeSettings::default(), false, false)
.action,
ScrobbleAction::Drop
);
}
}
#[test]
fn live_send_failure_only_queues_in_accumulated_mode() {
let online = status(OfflineMode::Online, Connectivity::Up, false);
let accumulated =
decide_scrobble_policy(online, OfflineModeSettings::default(), true, true);
assert_eq!(accumulated.action, ScrobbleAction::SendNow);
assert!(accumulated.queue_on_failure);
let no_accumulation = decide_scrobble_policy(
online,
OfflineModeSettings {
allow_accumulated_scrobbling: false,
..OfflineModeSettings::default()
},
true,
true,
);
assert_eq!(no_accumulation.action, ScrobbleAction::SendNow);
assert!(!no_accumulation.queue_on_failure);
}
}
+5
View File
@@ -806,6 +806,7 @@ fn on_session_entered() {
/// Post-login UI state: the shared session entry, then the session header,
/// has-previous-session, cleared login errors/phase and the screen switch.
fn enter_shell(session: auth_qt::SessionInfo) {
integrations_qt::set_qobuz_authenticated(true);
on_session_entered();
session_bridge::ui(move |mut b| {
b.as_mut()
@@ -880,6 +881,7 @@ pub(crate) fn start_offline() {
spawn(async move {
match auth_qt::start_offline_session(&runtime).await {
Ok(user_id) => {
integrations_qt::set_qobuz_authenticated(false);
let name = if user_id == 0 {
"Guest (offline)".to_string()
} else {
@@ -912,6 +914,9 @@ pub(crate) fn start_offline() {
/// Shell logout: token + session teardown, then back to the login screen.
pub(crate) fn do_logout() {
// Flip this before the async teardown: a delayed scrobble firing anywhere
// in that window must already obey the logged-out opt-out.
integrations_qt::set_qobuz_authenticated(false);
let runtime = app();
spawn(async move {
// A connected renderer keeps playing after the session ends unless it
+12
View File
@@ -1813,6 +1813,8 @@ pub struct SettingsDoc {
pub scrobble_enabled: bool,
#[serde(rename = "scrobbleUiCollapsed")]
pub scrobble_ui_collapsed: bool,
#[serde(rename = "allowLoggedOutScrobbling")]
pub allow_logged_out_scrobbling: bool,
#[serde(rename = "lastfmEnabled")]
pub lastfm_enabled: bool,
#[serde(rename = "lastfmAuthed")]
@@ -2247,6 +2249,7 @@ pub async fn publish_snapshot() {
musicbrainz_enabled: pref_bool("musicbrainz_enabled", true),
scrobble_enabled: scrobble_snap.enabled,
scrobble_ui_collapsed: scrobble_snap.ui_collapsed,
allow_logged_out_scrobbling: scrobble_snap.allow_logged_out_scrobbling,
lastfm_enabled: scrobble_snap.lastfm_enabled,
lastfm_authed: scrobble_snap.lastfm_is_authed(),
lastfm_username: scrobble_snap.lastfm_username.clone(),
@@ -2583,6 +2586,9 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
"scrobble-collapse" => {
crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None)
}
"scrobble-logged-out" => {
crate::integrations_qt::set_logged_out_scrobbling(value).map(|_| Apply::None)
}
"lastfm-enable" => crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None),
"listenbrainz-enable" => {
crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
@@ -2592,6 +2598,12 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
// Induced offline. The engine takes the #279 stream-first snapshot,
// so the audio settings can change under us -> Reload the player.
"offline-mode-enabled" => offline::set_mode_enabled(value).map(|_| Apply::Reload),
"offline-scrobble-immediate" => {
offline::set_allow_immediate_scrobbling(value).map(|_| Apply::None)
}
"offline-scrobble-accumulated" => {
offline::set_allow_accumulated_scrobbling(value).map(|_| Apply::None)
}
// --- Local Library > Plex -----------------------------------------
"plex-metadata-write" => {
library::set_metadata_write(value);
+26 -5
View File
@@ -20,6 +20,12 @@ pub struct Snapshot {
/// The persisted induced-offline flag (the "Enable Offline Mode" toggle).
#[serde(rename = "modeEnabled")]
pub mode_enabled: bool,
/// Tauri-era manual-offline network submission preference.
#[serde(rename = "allowImmediateScrobbling")]
pub allow_immediate_scrobbling: bool,
/// Tauri-era physical-offline retention preference.
#[serde(rename = "allowAccumulatedScrobbling")]
pub allow_accumulated_scrobbling: bool,
/// False when there is no per-user lyrics cache yet — the stats line hides.
#[serde(rename = "lyricsLoaded")]
pub lyrics_loaded: bool,
@@ -59,10 +65,7 @@ fn human_size(bytes: u64) -> String {
pub fn snapshot() -> Snapshot {
let engine = crate::offline_fwd::engine();
let mode_enabled = engine
.settings()
.map(|s| s.manual_offline_mode)
.unwrap_or(false);
let settings = engine.settings().unwrap_or_default();
let (lyrics_loaded, lyrics_entries, lyrics_size) = match lyrics_db_path() {
Some(path) if path.exists() => match qbz_lyrics::LyricsCacheDb::new(&path) {
@@ -82,13 +85,31 @@ pub fn snapshot() -> Snapshot {
};
Snapshot {
mode_enabled,
mode_enabled: settings.manual_offline_mode,
allow_immediate_scrobbling: settings.allow_immediate_scrobbling,
allow_accumulated_scrobbling: settings.allow_accumulated_scrobbling,
lyrics_loaded,
lyrics_entries,
lyrics_size,
}
}
pub fn set_allow_immediate_scrobbling(value: bool) -> Result<(), String> {
let result = crate::offline_fwd::engine().set_allow_immediate_scrobbling(value);
if result.is_ok() {
crate::integrations_qt::scrobble_policy_changed();
}
result
}
pub fn set_allow_accumulated_scrobbling(value: bool) -> Result<(), String> {
let result = crate::offline_fwd::engine().set_allow_accumulated_scrobbling(value);
if result.is_ok() {
crate::integrations_qt::scrobble_policy_changed();
}
result
}
/// "Enable Offline Mode" — the persisted induced flag. `set_induced` also
/// runs the #279 stream-first snapshot/restore against the audio store, so
/// the store handle is passed in (best-effort, exactly like the Slint).