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:
18 files changed
+794
-109
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user