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

+23
View File
@@ -176,6 +176,29 @@ impl OfflineModeEngine {
store.set_show_network_folders_in_manual_offline(enabled)
}
/// Restore the Tauri manual-offline policy: submit scrobbles through the
/// network immediately even while the Qobuz offline gate is induced.
/// Enabling it atomically disables accumulated mode in the shared store.
pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("offline store lock poisoned: {}", e))?;
let store = guard.as_ref().ok_or("No active session")?;
store.set_allow_immediate_scrobbling(enabled)
}
/// Restore the Tauri physical-offline policy: retain scrobbles until the
/// network is usable. Enabling it atomically disables immediate mode.
pub fn set_allow_accumulated_scrobbling(&self, enabled: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("offline store lock poisoned: {}", e))?;
let store = guard.as_ref().ok_or("No active session")?;
store.set_allow_accumulated_scrobbling(enabled)
}
/// Flip induced offline (Settings toggle). Always succeeds in either
/// direction; persists the flag, handles the #279 snapshot/restore, then
/// recomputes the mode (which flips the Qobuz gate).
+88 -8
View File
@@ -3,29 +3,45 @@
//! Opens the SAME `offline_settings.db` Tauri's `src-tauri/src/offline/mod.rs`
//! uses (identical creation SQL + additive migrations), so the file stays
//! frontend-portable like `library.db`/`index.db`. This shared store exposes
//! only the subset the offline-MODE port consumes:
//! the subset the current desktop frontends consume:
//!
//! - `manual_offline_mode` — the induced-offline flag (persisted; D1).
//! - `show_network_folders_in_manual_offline` — network-mount policy (D9).
//! - `pre_offline_stream_first_track` — the issue #279 snapshot of
//! `audio_settings.stream_first_track` taken on entering induced offline.
//! - the Tauri-era immediate/accumulated scrobbling policy flags.
//!
//! The legacy columns/tables (cast/scrobbling flags, `pending_playlist_sync`,
//! `scrobble_queue`, `cache_limit_bytes`) are still CREATED for byte-level
//! compatibility with the Tauri schema, but get no API here: the dead toggles
//! are not ported (spec §1) and offline playlist creation is replaced by
//! first-class local playlists (D7/D8).
//! The remaining legacy columns/tables (`allow_cast_while_offline`,
//! `pending_playlist_sync`, `cache_limit_bytes`) are still CREATED for
//! byte-level compatibility with the Tauri schema. Offline playlist creation
//! is replaced by first-class local playlists (D7/D8).
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
/// The offline-mode settings the port consumes.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineModeSettings {
pub manual_offline_mode: bool,
pub show_network_folders_in_manual_offline: bool,
/// Permit network submission while induced/manual offline is active.
pub allow_immediate_scrobbling: bool,
/// Retain scrobbles when they cannot be sent and flush them later.
pub allow_accumulated_scrobbling: bool,
}
impl Default for OfflineModeSettings {
fn default() -> Self {
Self {
manual_offline_mode: false,
show_network_folders_in_manual_offline: false,
// Historical Tauri defaults (f9ca1bd25).
allow_immediate_scrobbling: false,
allow_accumulated_scrobbling: true,
}
}
}
/// One row of the Last.fm offline scrobble queue (`scrobble_queue`). Mirrors
@@ -120,13 +136,17 @@ impl OfflineModeStore {
self.conn
.query_row(
"SELECT manual_offline_mode,
COALESCE(show_network_folders_in_manual_offline, 0)
COALESCE(show_network_folders_in_manual_offline, 0),
COALESCE(allow_immediate_scrobbling, 0),
COALESCE(allow_accumulated_scrobbling, 1)
FROM offline_settings WHERE id = 1",
[],
|row| {
Ok(OfflineModeSettings {
manual_offline_mode: row.get::<_, i64>(0)? != 0,
show_network_folders_in_manual_offline: row.get::<_, i64>(1)? != 0,
allow_immediate_scrobbling: row.get::<_, i64>(2)? != 0,
allow_accumulated_scrobbling: row.get::<_, i64>(3)? != 0,
})
},
)
@@ -153,6 +173,38 @@ impl OfflineModeStore {
Ok(())
}
/// Tauri `set_allow_immediate_scrobbling`, with the later a68230fad
/// mutual-exclusion rule made atomic instead of two UI-side writes.
pub fn set_allow_immediate_scrobbling(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE offline_settings
SET allow_immediate_scrobbling = ?1,
allow_accumulated_scrobbling = CASE
WHEN ?1 != 0 THEN 0 ELSE allow_accumulated_scrobbling END
WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set allow immediate scrobbling: {}", e))?;
Ok(())
}
/// Tauri `set_allow_accumulated_scrobbling`, with the later a68230fad
/// mutual-exclusion rule made atomic instead of two UI-side writes.
pub fn set_allow_accumulated_scrobbling(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE offline_settings
SET allow_accumulated_scrobbling = ?1,
allow_immediate_scrobbling = CASE
WHEN ?1 != 0 THEN 0 ELSE allow_immediate_scrobbling END
WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set allow accumulated scrobbling: {}", e))?;
Ok(())
}
/// Issue #279 snapshot: the user's `stream_first_track` preference stashed
/// when entering induced offline. `None` = no snapshot active.
pub fn get_pre_offline_stream_first_track(&self) -> Result<Option<bool>, String> {
@@ -313,6 +365,8 @@ mod tests {
let settings = store.get_settings().unwrap();
assert!(!settings.manual_offline_mode);
assert!(!settings.show_network_folders_in_manual_offline);
assert!(!settings.allow_immediate_scrobbling);
assert!(settings.allow_accumulated_scrobbling);
assert_eq!(store.get_pre_offline_stream_first_track().unwrap(), None);
let _ = std::fs::remove_dir_all(dir);
@@ -347,6 +401,30 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobbling_policies_round_trip_and_are_mutually_exclusive() {
let dir = unique_test_dir("offline-store-scrobble-policy");
let store = OfflineModeStore::new_at(&dir).unwrap();
store.set_allow_immediate_scrobbling(true).unwrap();
let settings = store.get_settings().unwrap();
assert!(settings.allow_immediate_scrobbling);
assert!(!settings.allow_accumulated_scrobbling);
// Turning a mode OFF does not silently opt into the other one.
store.set_allow_immediate_scrobbling(false).unwrap();
let settings = store.get_settings().unwrap();
assert!(!settings.allow_immediate_scrobbling);
assert!(!settings.allow_accumulated_scrobbling);
store.set_allow_accumulated_scrobbling(true).unwrap();
let settings = store.get_settings().unwrap();
assert!(!settings.allow_immediate_scrobbling);
assert!(settings.allow_accumulated_scrobbling);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobble_queue_round_trips_and_marks_sent() {
let dir = unique_test_dir("offline-store-scrobbles");
@@ -420,6 +498,8 @@ mod tests {
let settings = store.get_settings().unwrap();
assert!(settings.manual_offline_mode, "Tauri-era flag must survive");
assert!(!settings.show_network_folders_in_manual_offline);
assert!(!settings.allow_immediate_scrobbling);
assert!(settings.allow_accumulated_scrobbling);
let _ = std::fs::remove_dir_all(dir);
}
+86 -3
View File
@@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScrobblerSettings {
/// Master toggle. When false, the whole scrobblers section body is hidden.
/// Default OFF — integrations are opt-in.
@@ -56,6 +56,26 @@ pub struct ScrobblerSettings {
pub listenbrainz_token: String,
/// ListenBrainz username (`UserInfo.user_name`).
pub listenbrainz_username: String,
/// Keep the independent scrobblers active without a Qobuz session.
/// Default ON: Qobuz authentication is unrelated to Last.fm/LB auth.
pub allow_logged_out_scrobbling: bool,
}
impl Default for ScrobblerSettings {
fn default() -> Self {
Self {
enabled: false,
ui_collapsed: false,
lastfm_enabled: false,
lastfm_session_key: String::new(),
lastfm_username: String::new(),
listenbrainz_enabled: false,
listenbrainz_token: String::new(),
listenbrainz_username: String::new(),
allow_logged_out_scrobbling: true,
}
}
}
impl ScrobblerSettings {
@@ -106,11 +126,20 @@ impl ScrobblerSettingsStore {
lastfm_username TEXT NOT NULL DEFAULT '',
listenbrainz_enabled INTEGER NOT NULL DEFAULT 0,
listenbrainz_token TEXT NOT NULL DEFAULT '',
listenbrainz_username TEXT NOT NULL DEFAULT ''
listenbrainz_username TEXT NOT NULL DEFAULT '',
allow_logged_out_scrobbling INTEGER NOT NULL DEFAULT 1
);",
)
.map_err(|e| format!("Failed to create scrobbler settings table: {}", e))?;
// Existing Slint/Qt profiles predate logged-out mode. Additive and
// default-on, so their established scrobbler setup keeps working.
let _ = conn.execute(
"ALTER TABLE scrobbler_settings
ADD COLUMN allow_logged_out_scrobbling INTEGER NOT NULL DEFAULT 1",
[],
);
conn.execute(
"INSERT OR IGNORE INTO scrobbler_settings
(id, enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
@@ -135,7 +164,7 @@ impl ScrobblerSettingsStore {
.query_row(
"SELECT enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
lastfm_username, listenbrainz_enabled, listenbrainz_token,
listenbrainz_username
listenbrainz_username, allow_logged_out_scrobbling
FROM scrobbler_settings WHERE id = 1",
[],
|row| {
@@ -147,6 +176,7 @@ impl ScrobblerSettingsStore {
let listenbrainz_enabled: i32 = row.get(5)?;
let listenbrainz_token: String = row.get(6)?;
let listenbrainz_username: String = row.get(7)?;
let allow_logged_out_scrobbling: i32 = row.get(8)?;
Ok(ScrobblerSettings {
enabled: enabled != 0,
ui_collapsed: ui_collapsed != 0,
@@ -156,6 +186,7 @@ impl ScrobblerSettingsStore {
listenbrainz_enabled: listenbrainz_enabled != 0,
listenbrainz_token,
listenbrainz_username,
allow_logged_out_scrobbling: allow_logged_out_scrobbling != 0,
})
},
)
@@ -182,6 +213,17 @@ impl ScrobblerSettingsStore {
Ok(())
}
pub fn set_allow_logged_out_scrobbling(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings
SET allow_logged_out_scrobbling = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set logged-out scrobbling: {}", e))?;
Ok(())
}
// --- Last.fm ---
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
@@ -315,6 +357,10 @@ impl ScrobblerSettingsState {
self.with_store(|s| s.set_ui_collapsed(value))
}
pub fn set_allow_logged_out_scrobbling(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_allow_logged_out_scrobbling(value))
}
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_lastfm_enabled(value))
}
@@ -363,6 +409,7 @@ mod tests {
assert!(!s.listenbrainz_enabled);
assert!(s.listenbrainz_token.is_empty());
assert!(s.listenbrainz_username.is_empty());
assert!(s.allow_logged_out_scrobbling);
assert!(!s.lastfm_is_authed());
assert!(!s.listenbrainz_is_authed());
assert!(!s.lastfm_active());
@@ -375,6 +422,7 @@ mod tests {
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
let s = store.get_settings().expect("get settings");
assert!(!s.enabled);
assert!(s.allow_logged_out_scrobbling);
assert!(!s.lastfm_is_authed());
assert!(!s.listenbrainz_is_authed());
let _ = std::fs::remove_dir_all(dir);
@@ -387,6 +435,9 @@ mod tests {
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
store.set_enabled(true).expect("enabled");
store.set_ui_collapsed(true).expect("collapsed");
store
.set_allow_logged_out_scrobbling(false)
.expect("logged-out scrobbling");
store.set_lastfm_enabled(true).expect("lfm enabled");
store
.set_lastfm_session(" sk-123 ", " alice ")
@@ -400,6 +451,7 @@ mod tests {
let s = reopened.get_settings().expect("get settings");
assert!(s.enabled);
assert!(s.ui_collapsed);
assert!(!s.allow_logged_out_scrobbling);
assert!(s.lastfm_enabled);
// set_lastfm_session trims whitespace.
assert_eq!(s.lastfm_session_key, "sk-123");
@@ -412,6 +464,37 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn existing_database_migrates_logged_out_scrobbling_default_on() {
let dir = unique_test_dir("scrobbler-migrate-logged-out");
std::fs::create_dir_all(&dir).unwrap();
{
let conn = Connection::open(dir.join("scrobbler_settings.db")).unwrap();
conn.execute_batch(
"CREATE TABLE scrobbler_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
ui_collapsed INTEGER NOT NULL DEFAULT 0,
lastfm_enabled INTEGER NOT NULL DEFAULT 0,
lastfm_session_key TEXT NOT NULL DEFAULT '',
lastfm_username TEXT NOT NULL DEFAULT '',
listenbrainz_enabled INTEGER NOT NULL DEFAULT 0,
listenbrainz_token TEXT NOT NULL DEFAULT '',
listenbrainz_username TEXT NOT NULL DEFAULT ''
);
INSERT INTO scrobbler_settings VALUES (1, 1, 0, 0, '', '', 0, '', '');",
)
.unwrap();
}
let store = ScrobblerSettingsStore::new_at(&dir).expect("migrate store");
let settings = store.get_settings().expect("get migrated settings");
assert!(settings.enabled);
assert!(settings.allow_logged_out_scrobbling);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobbler_disconnect_keeps_enable_flags() {
let dir = unique_test_dir("scrobbler-disconnect");
@@ -5997,3 +5997,21 @@ msgstr "Vollständigen Albumkopf anzeigen"
msgid "Show compact album header"
msgstr "Kompakten Albumkopf anzeigen"
msgid "Immediate scrobbling"
msgstr "Sofortiges Scrobbling"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Scrobbles sofort senden, während der manuelle Offline-Modus aktiviert ist."
msgid "Accumulated scrobbling"
msgstr "Gesammeltes Scrobbling"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Scrobbles bei fehlender Netzwerkverbindung zwischenspeichern und senden, sobald sie wiederhergestellt ist. Last.fm akzeptiert Scrobbles, die bis zu 2 Wochen alt sind."
msgid "Scrobble while logged out"
msgstr "Scrobbling ohne Anmeldung"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Wiedergaben auch ohne Qobuz-Sitzung an verbundene Scrobble-Dienste senden."
@@ -1599,3 +1599,21 @@ msgstr "Show full album header"
msgid "Show compact album header"
msgstr "Show compact album header"
msgid "Immediate scrobbling"
msgstr "Immediate scrobbling"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Send scrobbles immediately while manual offline mode is enabled."
msgid "Accumulated scrobbling"
msgstr "Accumulated scrobbling"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgid "Scrobble while logged out"
msgstr "Scrobble while logged out"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Continue sending plays to connected scrobblers without a Qobuz session."
@@ -6003,3 +6003,21 @@ msgstr "Mostrar el encabezado completo del álbum"
msgid "Show compact album header"
msgstr "Mostrar el encabezado compacto del álbum"
msgid "Immediate scrobbling"
msgstr "Scrobbling inmediato"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Envía los scrobbles de inmediato cuando el modo offline manual está activado."
msgid "Accumulated scrobbling"
msgstr "Scrobbling acumulado"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Guarda los scrobbles cuando no haya red y los envía al volver la conexión. Last.fm acepta scrobbles de hasta 2 semanas."
msgid "Scrobble while logged out"
msgstr "Scrobbling sin sesión"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Continúa enviando reproducciones a los servicios conectados aunque no haya una sesión de Qobuz."
@@ -5995,3 +5995,21 @@ msgstr "Afficher l’en-tête complet de l’album"
msgid "Show compact album header"
msgstr "Afficher l’en-tête compact de l’album"
msgid "Immediate scrobbling"
msgstr "Scrobbling immédiat"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Envoyer immédiatement les scrobbles lorsque le mode hors ligne manuel est activé."
msgid "Accumulated scrobbling"
msgstr "Scrobbling accumulé"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Mettre les scrobbles en attente lorsque le réseau est indisponible et les envoyer à son retour. Last.fm accepte les scrobbles datant de moins de 2 semaines."
msgid "Scrobble while logged out"
msgstr "Scrobbler sans connexion"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Continuer d’envoyer les écoutes aux services connectés sans session Qobuz."
@@ -5979,3 +5979,21 @@ msgstr "アルバムヘッダーをすべて表示"
msgid "Show compact album header"
msgstr "アルバムヘッダーをコンパクトに表示"
msgid "Immediate scrobbling"
msgstr "即時スクロブル"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "手動オフラインモードが有効な間も、スクロブルをすぐに送信します。"
msgid "Accumulated scrobbling"
msgstr "蓄積スクロブル"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "ネットワークが利用できない間はスクロブルをキューに保存し、復旧後に送信します。Last.fm が受け付けるのは2週間以内のスクロブルです。"
msgid "Scrobble while logged out"
msgstr "ログアウト中もスクロブル"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Qobuzセッションがなくても、接続済みのスクロブルサービスに再生履歴を送信します。"
@@ -5985,3 +5985,21 @@ msgstr "Volledige albumkop tonen"
msgid "Show compact album header"
msgstr "Compacte albumkop tonen"
msgid "Immediate scrobbling"
msgstr "Direct scrobblen"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Scrobbles direct verzenden wanneer de handmatige offlinemodus is ingeschakeld."
msgid "Accumulated scrobbling"
msgstr "Opgespaard scrobblen"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Scrobbles in de wachtrij zetten wanneer het netwerk niet beschikbaar is en ze verzenden zodra het terugkeert. Last.fm accepteert scrobbles tot 2 weken oud."
msgid "Scrobble while logged out"
msgstr "Scrobblen wanneer afgemeld"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Luisterbeurten naar verbonden scrobblediensten blijven sturen zonder Qobuz-sessie."
@@ -5995,3 +5995,21 @@ msgstr "Mostrar cabeçalho completo do álbum"
msgid "Show compact album header"
msgstr "Mostrar cabeçalho compacto do álbum"
msgid "Immediate scrobbling"
msgstr "Scrobbling imediato"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Enviar scrobbles imediatamente enquanto o modo offline manual estiver ativado."
msgid "Accumulated scrobbling"
msgstr "Scrobbling acumulado"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Colocar scrobbles na fila enquanto a rede estiver indisponível e enviá-los quando ela voltar. O Last.fm aceita scrobbles de até 2 semanas."
msgid "Scrobble while logged out"
msgstr "Scrobbling sem login"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Continuar enviando reproduções aos serviços conectados sem uma sessão do Qobuz."
@@ -6015,3 +6015,21 @@ msgstr "Показать полный заголовок альбома"
msgid "Show compact album header"
msgstr "Показать компактный заголовок альбома"
msgid "Immediate scrobbling"
msgstr "Немедленный скробблинг"
msgid "Send scrobbles immediately while manual offline mode is enabled."
msgstr "Немедленно отправлять скробблы, пока включён ручной автономный режим."
msgid "Accumulated scrobbling"
msgstr "Накопительный скробблинг"
msgid "Queue scrobbles while the network is unavailable and send them when it returns. Last.fm accepts scrobbles up to 2 weeks old."
msgstr "Сохранять скробблы в очереди при отсутствии сети и отправлять после восстановления соединения. Last.fm принимает скробблы не старше 2 недель."
msgid "Scrobble while logged out"
msgstr "Скробблинг без входа"
msgid "Continue sending plays to connected scrobblers without a Qobuz session."
msgstr "Продолжать отправлять прослушивания в подключённые сервисы без сеанса Qobuz."
@@ -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).