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");