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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user